Skip to content

Commit fa3c523

Browse files
donCappoawoimbee
andauthored
feat: path prefix proxy credentials (#478)
* Add path_prefix support for scoped proxy registry credentials Some container registries (e.g., GitLab) issue scoped deploy tokens per project. When proxying from a single registry host with multiple projects, each project needs its own credentials. Previously, Trow only supported one credential set per host. Add an optional `path_prefix` field to `SingleRegistryProxyConfig` that enables different credentials for different repo paths on the same host. Uses longest-prefix matching with host-only fallback. --------- Co-authored-by: Arthur Woimbée <arthur.woimbee@gmail.com> Co-authored-by: Pavel Vitous <pavel@vi-to.us>
1 parent f893fe6 commit fa3c523

5 files changed

Lines changed: 154 additions & 7 deletions

File tree

charts/trow/values.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ trow:
5050
# username: toto
5151
# password: titi
5252
# - host: ghcr.io
53+
# ## Use path_prefix for scoped credentials on the same host
54+
# ## (e.g. GitLab deploy tokens per project):
55+
# - host: registry.example.com
56+
# path_prefix: project-a
57+
# username: project-a-deploy-token
58+
# password: glpat-xxx
59+
# - host: registry.example.com
60+
# path_prefix: project-b
61+
# username: project-b-deploy-token
62+
# password: glpat-yyy
5363
# max_size: 50GiB
5464
## For more info on log levels see https://docs.rs/tracing-subscriber/0.3.17/tracing_subscriber/filter/struct.EnvFilter.html
5565
logLevel: info

docker/multi-arch.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ set -exo pipefail
55
src_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
66
cd "$src_dir"
77

8-
GH_REPO="ghcr.io/trow-registry/trow"
8+
# Use GITHUB_REPOSITORY if available (e.g. in CI), otherwise default to upstream
9+
GH_REPO="ghcr.io/${GITHUB_REPOSITORY:-trow-registry/trow}"
910

1011

1112
# Check if cargo-sqlx is installed

docs/USER_GUIDE.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,39 @@ Trow will keep a cached copy and check for new versions on each pull. The check
6565
request which does not count towards the dockerhub rate limits. If the image cannot be pulled a cached
6666
version will be returned, if available. This can be used to effectively mitigate availability issues with registries.
6767

68+
### Scoped credentials with `path_prefix`
69+
70+
Some container registries (e.g. GitLab) issue scoped deploy tokens that only grant
71+
access to a single project. When proxying multiple projects from the same registry
72+
host, each project needs its own credentials. The `path_prefix` field allows you to
73+
configure different credentials for different repository paths on the same host:
74+
75+
```yaml
76+
registry_proxies:
77+
registries:
78+
# Scoped token for project-a
79+
- host: registry.example.com
80+
path_prefix: project-a
81+
username: project-a-deploy-token
82+
password: glpat-aaa
83+
84+
# Scoped token for project-b
85+
- host: registry.example.com
86+
path_prefix: project-b
87+
username: project-b-deploy-token
88+
password: glpat-bbb
89+
90+
# Fallback for any other repo on this host (optional)
91+
- host: registry.example.com
92+
username: default-token
93+
password: glpat-ccc
94+
```
95+
96+
When Trow resolves credentials for a proxied image, it uses **longest-prefix matching**:
97+
an image like `registry.example.com/project-a/app` will match the `project-a` entry.
98+
If no prefix matches, a host-only entry (without `path_prefix`) is used as a fallback.
99+
If neither matches, the image is proxied without authentication.
100+
68101
### Configuring containerd
69102

70103
See [the containerd docs](https://github.com/containerd/containerd/blob/main/docs/hosts.md#setup-default-mirror-for-all-registries).

src/configuration.rs

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,27 @@ pub struct RegistryProxiesConfig {
3535
pub max_size: Option<size::Size>,
3636
}
3737

38+
fn normalize_path_prefix<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
39+
where
40+
D: serde::Deserializer<'de>,
41+
{
42+
let opt = Option::<String>::deserialize(deserializer)?;
43+
Ok(opt
44+
.map(|s| s.trim_matches('/').to_string())
45+
.filter(|s| !s.is_empty()))
46+
}
47+
3848
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
3949
pub struct SingleRegistryProxyConfig {
4050
/// What containerd calls "namespace" (ghcr.io, docker.io, ...)
4151
/// This can be empty !!
4252
pub host: String,
53+
/// Optional path prefix for scoped credential matching.
54+
/// Allows different credentials for different projects on the same registry host.
55+
/// Example: "system" matches repos like "system/app", "system/worker".
56+
/// When multiple entries match the same host, the longest matching prefix wins.
57+
#[serde(default, deserialize_with = "normalize_path_prefix")]
58+
pub path_prefix: Option<String>,
4359
/// TODO: insecure currently means "use HTTP", we should also support self-signed TLS
4460
#[serde(default)]
4561
pub insecure: bool,
@@ -58,11 +74,30 @@ impl Default for RegistryProxiesConfig {
5874
}
5975

6076
impl RegistryProxyConfigs {
61-
pub fn get_for<'a>(&'a self, registry_host: &str) -> Option<&'a SingleRegistryProxyConfig> {
62-
self.0
63-
.iter()
64-
.find(|&registry| registry.host == registry_host)
65-
.map(|v| v as _)
77+
pub fn get_for<'a>(
78+
&'a self,
79+
registry: &str,
80+
repo: &str,
81+
) -> Option<&'a SingleRegistryProxyConfig> {
82+
let matches = self.0.iter().filter_map(|proxy| {
83+
if proxy.host == registry {
84+
if let Some(proxy_prefix) = proxy.path_prefix.as_deref() {
85+
// for prefix "org" match org/toto, not org_b/toto
86+
if repo == proxy_prefix
87+
|| (repo.starts_with(proxy_prefix)
88+
&& repo.as_bytes().get(proxy_prefix.len()) == Some(&b'/'))
89+
{
90+
return Some((proxy_prefix.len(), proxy));
91+
}
92+
} else {
93+
return Some((0, proxy));
94+
}
95+
}
96+
None
97+
});
98+
matches
99+
.max_by_key(|(prefix_len, _)| *prefix_len)
100+
.map(|(_, registry)| registry)
66101
}
67102
}
68103

@@ -71,3 +106,71 @@ impl From<Vec<SingleRegistryProxyConfig>> for RegistryProxyConfigs {
71106
RegistryProxyConfigs(vec)
72107
}
73108
}
109+
110+
#[cfg(test)]
111+
mod tests {
112+
use super::*;
113+
114+
#[test]
115+
fn test_registry_proxy_deserialize_path_prefix() {
116+
let proxy_config: SingleRegistryProxyConfig =
117+
serde_json::from_str(r#"{"host": "registry.example.com", "path_prefix": "/org/sub/"}"#)
118+
.unwrap();
119+
assert_eq!(proxy_config.path_prefix.unwrap(), "org/sub");
120+
121+
let proxy_config: SingleRegistryProxyConfig =
122+
serde_json::from_str(r#"{"host": "registry.example.com", "path_prefix": "/"}"#)
123+
.unwrap();
124+
assert_eq!(proxy_config.path_prefix, None);
125+
}
126+
127+
#[test]
128+
fn test_registry_proxy_configs_path_prefix_longest_match_wins() {
129+
let config = RegistryProxiesConfig {
130+
registries: vec![
131+
SingleRegistryProxyConfig {
132+
host: "registry.example.com".to_string(),
133+
username: Some("default".to_string()),
134+
..Default::default()
135+
},
136+
SingleRegistryProxyConfig {
137+
host: "registry.example.com".to_string(),
138+
path_prefix: Some("org".to_string()),
139+
username: Some("org-token".to_string()),
140+
..Default::default()
141+
},
142+
SingleRegistryProxyConfig {
143+
host: "registry.example.com".to_string(),
144+
path_prefix: Some("org/sub".to_string()),
145+
username: Some("org-sub-token".to_string()),
146+
..Default::default()
147+
},
148+
]
149+
.into(),
150+
..Default::default()
151+
};
152+
// "org/sub/app" matches both, but "org/sub" is longer
153+
let proxy = config
154+
.registries
155+
.get_for("registry.example.com", "org/sub/app");
156+
assert_eq!(proxy.unwrap().username, Some("org-sub-token".to_string()));
157+
158+
// "org/other" matches only "org"
159+
let proxy = config
160+
.registries
161+
.get_for("registry.example.com", "org/other");
162+
assert_eq!(proxy.unwrap().username, Some("org-token".to_string()));
163+
164+
// no path_prefix match
165+
let proxy = config
166+
.registries
167+
.get_for("registry.example.com", "outta-this-world");
168+
assert_eq!(proxy.unwrap().username, Some("default".to_string()));
169+
170+
// doesn't match path prefix across '/' boundary
171+
let proxy = config
172+
.registries
173+
.get_for("registry.example.com", "org_b/app");
174+
assert_eq!(proxy.unwrap().username, Some("default".to_string()));
175+
}
176+
}

src/routes/manifest.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ async fn get_manifest(
5454
.config_file
5555
.registry_proxies
5656
.registries
57-
.get_for(image.registry());
57+
.get_for(image.registry(), image.repository());
5858
download_image(&image, proxy_config, &state).await?
5959
} else {
6060
let digest = if let Some(tag) = image.tag() {

0 commit comments

Comments
 (0)