From 4278ca83bc5a260847697e37aafdccb66917f0bc Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:58:17 +0800 Subject: [PATCH 1/9] Registries proxy config: initial impl Signed-off-by: cyqsimon <28627918+cyqsimon@users.noreply.github.com> --- image/docker/docker_client.go | 26 ++++++++++-- image/docker/docker_image_src.go | 5 +++ .../sysregistriesv2/system_registries_v2.go | 42 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/image/docker/docker_client.go b/image/docker/docker_client.go index 2f257076f5..9f2f811b39 100644 --- a/image/docker/docker_client.go +++ b/image/docker/docker_client.go @@ -114,6 +114,10 @@ type dockerClient struct { // tlsClientConfig is setup by newDockerClient and will be used and updated // by detectProperties(). Callers can edit tlsClientConfig.InsecureSkipVerify in the meantime. tlsClientConfig *tls.Config + // registryProxy is the proxy URL from the registry configuration, if any. + // It has the lowest priority and can be overridden by either DockerProxyURL or environment variables. + // When pulling, this value could be overwritten by a mirror-specific proxy. See docker_client_src.go. + registryProxy *url.URL // The following members are not set by newDockerClient and must be set by callers if needed. auth types.DockerAuthConfig registryToken string @@ -262,18 +266,24 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc return nil, err } - // Check if TLS verification shall be skipped (default=false) which can - // be specified in the sysregistriesv2 configuration. - skipVerify := false + // Fetch and load sysregistriesv2 configurations. reg, err := sysregistriesv2.FindRegistry(sys, reference) if err != nil { return nil, fmt.Errorf("loading registries: %w", err) } + skipVerify := false + var registryProxy *url.URL if reg != nil { if reg.Blocked { return nil, fmt.Errorf("registry %s is blocked in %s or %s", reg.Prefix, sysregistriesv2.ConfigPath(sys), sysregistriesv2.ConfigDirPath(sys)) } + // Check if TLS verification shall be skipped (default=false). skipVerify = reg.Insecure + // Set registry proxy. + registryProxy, err = sysregistriesv2.ParseProxy(reg.Proxy) + if err != nil { + return nil, err + } } tlsClientConfig.InsecureSkipVerify = skipVerify @@ -287,6 +297,7 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc registry: registry, userAgent: userAgent, tlsClientConfig: tlsClientConfig, + registryProxy: registryProxy, tokenCache: map[string]*bearerToken{}, reportedWarnings: set.New[string](), }, nil @@ -968,6 +979,15 @@ func (c *dockerClient) detectPropertiesHelper(ctx context.Context) error { } tr := tlsclientconfig.NewTransport() tr.TLSClientConfig = c.tlsClientConfig + // Set registry-specific proxy with lowest priority, which can be overridden by environment variables. + if c.registryProxy != nil { + tr.Proxy = func(req *http.Request) (*url.URL, error) { + if envProxy, err := http.ProxyFromEnvironment(req); err != nil || envProxy != nil { + return envProxy, err + } + return c.registryProxy, nil + } + } // if set DockerProxyURL explicitly, use the DockerProxyURL instead of system proxy if c.sys != nil && c.sys.DockerProxyURL != nil { tr.Proxy = http.ProxyURL(c.sys.DockerProxyURL) diff --git a/image/docker/docker_image_src.go b/image/docker/docker_image_src.go index 4003af5d27..3ba69d39c4 100644 --- a/image/docker/docker_image_src.go +++ b/image/docker/docker_image_src.go @@ -150,6 +150,11 @@ func newImageSourceAttempt(ctx context.Context, sys *types.SystemContext, logica return nil, err } client.tlsClientConfig.InsecureSkipVerify = pullSource.Endpoint.Insecure + registryProxy, err := sysregistriesv2.ParseProxy(pullSource.Endpoint.Proxy) + if err != nil { + return nil, err + } + client.registryProxy = registryProxy s := &dockerImageSource{ PropertyMethodsInitialize: impl.PropertyMethods(impl.Properties{ diff --git a/image/pkg/sysregistriesv2/system_registries_v2.go b/image/pkg/sysregistriesv2/system_registries_v2.go index 0cf44571d3..849be7c182 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2.go +++ b/image/pkg/sysregistriesv2/system_registries_v2.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "maps" + "net/url" "os" "path/filepath" "reflect" @@ -59,6 +60,8 @@ type Endpoint struct { // If true, certs verification will be skipped and HTTP (non-TLS) // connections will be allowed. Insecure bool `toml:"insecure,omitempty"` + // The forwarding proxy to be used for accessing this endpoint. + Proxy string `toml:"proxy,omitempty"` // PullFromMirror is used for adding restrictions to image pull through the mirror. // Set to "all", "digest-only", or "tag-only". // If "digest-only", mirrors will only be used for digest pulls. Pulling images by @@ -341,6 +344,32 @@ func parseLocation(input string) (string, error) { return trimmed, nil } +// ParseProxy parses the input string for a proxy configuration. +// Errors if a scheme is unsupported or unspecified, or if the input is not a valid URL. +func ParseProxy(input string) (*url.URL, error) { + if input == "" { + return nil, nil + } + + var hasSupportedScheme bool + for _, scheme := range []string{"http://", "https://", "socks5://", "socks5h://"} { + if strings.HasPrefix(input, scheme) { + hasSupportedScheme = true + break + } + } + if !hasSupportedScheme { + return nil, &InvalidRegistries{s: "invalid proxy: proxy URL must specify one of the supported schemes: http://, https://, socks5://, socks5h://"} + } + + parsed, err := url.Parse(input) + if err != nil { + return nil, fmt.Errorf("parsing proxy URL %q: %w", input, err) + } + + return parsed, nil +} + // ConvertToV2 returns a v2 config corresponding to a v1 one. func (config *V1RegistriesConf) ConvertToV2() (*V2RegistriesConf, error) { regMap := make(map[string]*Registry) @@ -409,6 +438,10 @@ func (config *V2RegistriesConf) postProcessRegistries() error { return err } + if _, err = ParseProxy(reg.Proxy); err != nil { + return err + } + if reg.Prefix == "" { if reg.Location == "" { return &InvalidRegistries{s: "invalid condition: both location and prefix are unset"} @@ -438,6 +471,10 @@ func (config *V2RegistriesConf) postProcessRegistries() error { return err } + if _, err = ParseProxy(mir.Proxy); err != nil { + return err + } + // FIXME: unqualifiedSearchRegistries now also accepts empty values // and shouldn't // https://github.com/containers/image/pull/1191#discussion_r610623216 @@ -483,6 +520,11 @@ func (config *V2RegistriesConf) postProcessRegistries() error { return &InvalidRegistries{s: msg} } + if reg.Proxy != other.Proxy { + msg := fmt.Sprintf("registry '%s' is defined multiple times with conflicting 'proxy' setting", reg.Location) + return &InvalidRegistries{s: msg} + } + if reg.Blocked != other.Blocked { msg := fmt.Sprintf("registry '%s' is defined multiple times with conflicting 'blocked' setting", reg.Location) return &InvalidRegistries{s: msg} From bc60b0770029633cbf12e3810dbd9d4108aa4849 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:52:12 +0800 Subject: [PATCH 2/9] Registries proxy config: add tests Signed-off-by: cyqsimon <28627918+cyqsimon@users.noreply.github.com> --- image/docker/docker_client_test.go | 46 +++++++++++++++++ .../system_registries_v2_test.go | 50 +++++++++++++++++++ image/pkg/sysregistriesv2/testdata/proxy.conf | 13 +++++ 3 files changed, 109 insertions(+) create mode 100644 image/pkg/sysregistriesv2/testdata/proxy.conf diff --git a/image/docker/docker_client_test.go b/image/docker/docker_client_test.go index 229fea332f..65307b0d96 100644 --- a/image/docker/docker_client_test.go +++ b/image/docker/docker_client_test.go @@ -445,3 +445,49 @@ func TestIsManifestUnknownError(t *testing.T) { assert.True(t, res, "%s: %#v", c.name, err) } } + +// Helper function to test that the selected proxy for a registry matches expected. +func testProxyForRegistry(t *testing.T, ctx context.Context, sys *types.SystemContext, registry string, expectedProxy string) { + t.Run(fmt.Sprintf(`Expecting proxy "%s" for registry "%s"`, expectedProxy, registry), func(t *testing.T) { + req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/v2/", registry), nil) + require.NoError(t, err) + + // Proxy configured using environment variables have priority, so we skip if it's set. + envProxy, _ := http.ProxyFromEnvironment(req) + if envProxy != nil { + t.Skip("Skipping registry proxy test: proxy configured using environment variables") + } + + client, err := newDockerClient(sys, registry, registry) + require.NoError(t, err) + + // Ping will fail, but we only care about the side effect of setting the proxy. + _ = client.detectProperties(ctx) + + transport, ok := client.client.Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.Proxy) + + proxyURL, err := transport.Proxy(req) + require.NoError(t, err) + + if expectedProxy == "" { + require.Nil(t, proxyURL) + } else { + require.NotNil(t, proxyURL) + assert.Equal(t, expectedProxy, proxyURL.String()) + } + }) +} + +func TestRegistrySpecificProxy(t *testing.T) { + ctx := context.Background() + sys := &types.SystemContext{ + SystemRegistriesConfPath: "../pkg/sysregistriesv2/testdata/proxy.conf", + SystemRegistriesConfDirPath: "../pkg/sysregistriesv2/testdata/this-does-not-exist", + DockerInsecureSkipTLSVerify: types.OptionalBoolTrue, + } + + testProxyForRegistry(t, ctx, sys, "registry-1.com", "") + testProxyForRegistry(t, ctx, sys, "registry-2.com", "https://proxy-2.example.com") +} diff --git a/image/pkg/sysregistriesv2/system_registries_v2_test.go b/image/pkg/sysregistriesv2/system_registries_v2_test.go index b253714809..1222aa297a 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2_test.go +++ b/image/pkg/sysregistriesv2/system_registries_v2_test.go @@ -107,6 +107,28 @@ func TestParseLocation(t *testing.T) { assert.Equal(t, "example.com:5000/with/path", location) } +func TestParseProxy(t *testing.T) { + for _, valid := range []string{ + "", + "http://proxy.example.com", + "https://proxy.example.com", + "socks5://proxy.example.com", + "socks5h://proxy.example.com:1080", + } { + _, err := ParseProxy(valid) + assert.Nil(t, err, valid) + } + + for _, invalid := range []string{ + "no-scheme.example.com", + "ftp://bad-scheme.example.com", + "ssh://bad-scheme.example.com:2222", + } { + _, err := ParseProxy(invalid) + assert.NotNil(t, err) + } +} + func TestEmptyConfig(t *testing.T) { registries, err := GetRegistries(&types.SystemContext{ SystemRegistriesConfPath: "testdata/empty.conf", @@ -983,3 +1005,31 @@ func TestCredentialHelpers(t *testing.T) { require.Equal(t, test.helpers, helpers, "%v", test) } } + +func TestProxyConfiguration(t *testing.T) { + sys := &types.SystemContext{ + SystemRegistriesConfPath: "testdata/proxy.conf", + SystemRegistriesConfDirPath: "testdata/this-does-not-exist", + } + + registries, err := GetRegistries(sys) + require.NoError(t, err) + require.Equal(t, 2, len(registries)) + + reg1 := registries[0] + assert.Equal(t, "registry-1.com", reg1.Location) + assert.Equal(t, "", reg1.Proxy) + require.Equal(t, 2, len(reg1.Mirrors)) + + mirror1 := reg1.Mirrors[0] + assert.Equal(t, "mirror-1.registry-1.com", mirror1.Location) + assert.Equal(t, "", mirror1.Proxy) + + mirror2 := reg1.Mirrors[1] + assert.Equal(t, "mirror-2.registry-1.com", mirror2.Location) + assert.Equal(t, "http://proxy-1.example.com", mirror2.Proxy) + + reg2 := registries[1] + assert.Equal(t, "registry-2.com", reg2.Location) + assert.Equal(t, "https://proxy-2.example.com", reg2.Proxy) +} diff --git a/image/pkg/sysregistriesv2/testdata/proxy.conf b/image/pkg/sysregistriesv2/testdata/proxy.conf new file mode 100644 index 0000000000..3f02bf080b --- /dev/null +++ b/image/pkg/sysregistriesv2/testdata/proxy.conf @@ -0,0 +1,13 @@ +[[registry]] +location = "registry-1.com" + +[[registry.mirror]] +location = "mirror-1.registry-1.com" + +[[registry.mirror]] +location = "mirror-2.registry-1.com" +proxy = "http://proxy-1.example.com" + +[[registry]] +location = "registry-2.com" +proxy = "https://proxy-2.example.com" From 2baeda7707ef5e8874c9d706b9607512ed2b8795 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:57:33 +0800 Subject: [PATCH 3/9] Refactor ParseProxy Signed-off-by: cyqsimon <28627918+cyqsimon@users.noreply.github.com> --- .../pkg/sysregistriesv2/system_registries_v2.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/image/pkg/sysregistriesv2/system_registries_v2.go b/image/pkg/sysregistriesv2/system_registries_v2.go index 849be7c182..1d07f622ae 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2.go +++ b/image/pkg/sysregistriesv2/system_registries_v2.go @@ -351,22 +351,17 @@ func ParseProxy(input string) (*url.URL, error) { return nil, nil } - var hasSupportedScheme bool - for _, scheme := range []string{"http://", "https://", "socks5://", "socks5h://"} { - if strings.HasPrefix(input, scheme) { - hasSupportedScheme = true - break - } - } - if !hasSupportedScheme { - return nil, &InvalidRegistries{s: "invalid proxy: proxy URL must specify one of the supported schemes: http://, https://, socks5://, socks5h://"} - } - parsed, err := url.Parse(input) if err != nil { return nil, fmt.Errorf("parsing proxy URL %q: %w", input, err) } + supportedSchemes := []string{"http", "https", "socks5", "socks5h"} + if !slices.Contains(supportedSchemes, parsed.Scheme) { + msg := fmt.Sprintf(`proxy URL scheme "%s" is not supported. Supported are http, https, socks5, socks5h`, parsed.Scheme) + return nil, &InvalidRegistries{s: msg} + } + return parsed, nil } From 8ace4f7c0704bc4083854dedfe8101510bd79dc1 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:37:57 +0800 Subject: [PATCH 4/9] Improve code comments - Rewrite comments for `registryProxy` to make it more appropriate for its layer - Make comments regarding loading registry config more substantive Signed-off-by: cyqsimon <28627918+cyqsimon@users.noreply.github.com> --- image/docker/docker_client.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/image/docker/docker_client.go b/image/docker/docker_client.go index 9f2f811b39..6b2f63f70a 100644 --- a/image/docker/docker_client.go +++ b/image/docker/docker_client.go @@ -114,9 +114,11 @@ type dockerClient struct { // tlsClientConfig is setup by newDockerClient and will be used and updated // by detectProperties(). Callers can edit tlsClientConfig.InsecureSkipVerify in the meantime. tlsClientConfig *tls.Config - // registryProxy is the proxy URL from the registry configuration, if any. - // It has the lowest priority and can be overridden by either DockerProxyURL or environment variables. - // When pulling, this value could be overwritten by a mirror-specific proxy. See docker_client_src.go. + // registryProxy is the forwarding proxy used for this client, + // read from the registry configuration and set by newDockerClient. + // detectProperties will set the proxy for the HTTP client using registryProxy, + // subject to overrides by DockerProxyURL and DockerProxy. + // Callers can edit this value before detectProperties is called. registryProxy *url.URL // The following members are not set by newDockerClient and must be set by callers if needed. auth types.DockerAuthConfig @@ -266,7 +268,9 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc return nil, err } - // Fetch and load sysregistriesv2 configurations. + // Apply options from sysregistriesv2 configuration + // - Check if TLS verification shall be skipped (default=false) + // - Set registry-specific proxy reg, err := sysregistriesv2.FindRegistry(sys, reference) if err != nil { return nil, fmt.Errorf("loading registries: %w", err) @@ -277,9 +281,7 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc if reg.Blocked { return nil, fmt.Errorf("registry %s is blocked in %s or %s", reg.Prefix, sysregistriesv2.ConfigPath(sys), sysregistriesv2.ConfigDirPath(sys)) } - // Check if TLS verification shall be skipped (default=false). skipVerify = reg.Insecure - // Set registry proxy. registryProxy, err = sysregistriesv2.ParseProxy(reg.Proxy) if err != nil { return nil, err From c5c234ea2c9f57d67d9dca460766415832cfed10 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:55:30 +0800 Subject: [PATCH 5/9] Use RFC2606 `*.test` domains for tests Signed-off-by: cyqsimon <28627918+cyqsimon@users.noreply.github.com> --- image/docker/docker_client_test.go | 4 ++-- .../pkg/sysregistriesv2/system_registries_v2_test.go | 12 ++++++------ image/pkg/sysregistriesv2/testdata/proxy.conf | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/image/docker/docker_client_test.go b/image/docker/docker_client_test.go index 65307b0d96..bf4a64c3e5 100644 --- a/image/docker/docker_client_test.go +++ b/image/docker/docker_client_test.go @@ -488,6 +488,6 @@ func TestRegistrySpecificProxy(t *testing.T) { DockerInsecureSkipTLSVerify: types.OptionalBoolTrue, } - testProxyForRegistry(t, ctx, sys, "registry-1.com", "") - testProxyForRegistry(t, ctx, sys, "registry-2.com", "https://proxy-2.example.com") + testProxyForRegistry(t, ctx, sys, "registry-1.test", "") + testProxyForRegistry(t, ctx, sys, "registry-2.test", "https://proxy-2.example.test") } diff --git a/image/pkg/sysregistriesv2/system_registries_v2_test.go b/image/pkg/sysregistriesv2/system_registries_v2_test.go index 1222aa297a..5bc4793844 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2_test.go +++ b/image/pkg/sysregistriesv2/system_registries_v2_test.go @@ -1017,19 +1017,19 @@ func TestProxyConfiguration(t *testing.T) { require.Equal(t, 2, len(registries)) reg1 := registries[0] - assert.Equal(t, "registry-1.com", reg1.Location) + assert.Equal(t, "registry-1.test", reg1.Location) assert.Equal(t, "", reg1.Proxy) require.Equal(t, 2, len(reg1.Mirrors)) mirror1 := reg1.Mirrors[0] - assert.Equal(t, "mirror-1.registry-1.com", mirror1.Location) + assert.Equal(t, "mirror-1.registry-1.test", mirror1.Location) assert.Equal(t, "", mirror1.Proxy) mirror2 := reg1.Mirrors[1] - assert.Equal(t, "mirror-2.registry-1.com", mirror2.Location) - assert.Equal(t, "http://proxy-1.example.com", mirror2.Proxy) + assert.Equal(t, "mirror-2.registry-1.test", mirror2.Location) + assert.Equal(t, "http://proxy-1.example.test", mirror2.Proxy) reg2 := registries[1] - assert.Equal(t, "registry-2.com", reg2.Location) - assert.Equal(t, "https://proxy-2.example.com", reg2.Proxy) + assert.Equal(t, "registry-2.test", reg2.Location) + assert.Equal(t, "https://proxy-2.example.test", reg2.Proxy) } diff --git a/image/pkg/sysregistriesv2/testdata/proxy.conf b/image/pkg/sysregistriesv2/testdata/proxy.conf index 3f02bf080b..bde7375632 100644 --- a/image/pkg/sysregistriesv2/testdata/proxy.conf +++ b/image/pkg/sysregistriesv2/testdata/proxy.conf @@ -1,13 +1,13 @@ [[registry]] -location = "registry-1.com" +location = "registry-1.test" [[registry.mirror]] -location = "mirror-1.registry-1.com" +location = "mirror-1.registry-1.test" [[registry.mirror]] -location = "mirror-2.registry-1.com" -proxy = "http://proxy-1.example.com" +location = "mirror-2.registry-1.test" +proxy = "http://proxy-1.example.test" [[registry]] -location = "registry-2.com" -proxy = "https://proxy-2.example.com" +location = "registry-2.test" +proxy = "https://proxy-2.example.test" From 28a6cf0611cb67604187e2ca686222f3d438a498 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:33:58 +0800 Subject: [PATCH 6/9] Parse proxy URL during normalization; make `ParseProxy` private Signed-off-by: cyqsimon <28627918+cyqsimon@users.noreply.github.com> --- image/docker/docker_client.go | 5 +--- image/docker/docker_image_src.go | 6 +--- .../sysregistriesv2/system_registries_v2.go | 28 +++++++++++-------- .../system_registries_v2_test.go | 28 ++++++++++--------- 4 files changed, 34 insertions(+), 33 deletions(-) diff --git a/image/docker/docker_client.go b/image/docker/docker_client.go index 6b2f63f70a..d77eaff911 100644 --- a/image/docker/docker_client.go +++ b/image/docker/docker_client.go @@ -282,10 +282,7 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc return nil, fmt.Errorf("registry %s is blocked in %s or %s", reg.Prefix, sysregistriesv2.ConfigPath(sys), sysregistriesv2.ConfigDirPath(sys)) } skipVerify = reg.Insecure - registryProxy, err = sysregistriesv2.ParseProxy(reg.Proxy) - if err != nil { - return nil, err - } + registryProxy = reg.Proxy } tlsClientConfig.InsecureSkipVerify = skipVerify diff --git a/image/docker/docker_image_src.go b/image/docker/docker_image_src.go index 3ba69d39c4..5e6cc80248 100644 --- a/image/docker/docker_image_src.go +++ b/image/docker/docker_image_src.go @@ -150,11 +150,7 @@ func newImageSourceAttempt(ctx context.Context, sys *types.SystemContext, logica return nil, err } client.tlsClientConfig.InsecureSkipVerify = pullSource.Endpoint.Insecure - registryProxy, err := sysregistriesv2.ParseProxy(pullSource.Endpoint.Proxy) - if err != nil { - return nil, err - } - client.registryProxy = registryProxy + client.registryProxy = pullSource.Endpoint.Proxy s := &dockerImageSource{ PropertyMethodsInitialize: impl.PropertyMethods(impl.Properties{ diff --git a/image/pkg/sysregistriesv2/system_registries_v2.go b/image/pkg/sysregistriesv2/system_registries_v2.go index 1d07f622ae..4fcae7e125 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2.go +++ b/image/pkg/sysregistriesv2/system_registries_v2.go @@ -61,7 +61,11 @@ type Endpoint struct { // connections will be allowed. Insecure bool `toml:"insecure,omitempty"` // The forwarding proxy to be used for accessing this endpoint. - Proxy string `toml:"proxy,omitempty"` + // postProcessRegistries normalizes this field into the public Proxy field. + ProxyRaw string `toml:"proxy,omitempty"` + // The forwarding proxy to be used for accessing this endpoint. + // Parsed from ProxyRaw after normalization. + Proxy *url.URL `toml:"-"` // PullFromMirror is used for adding restrictions to image pull through the mirror. // Set to "all", "digest-only", or "tag-only". // If "digest-only", mirrors will only be used for digest pulls. Pulling images by @@ -344,9 +348,9 @@ func parseLocation(input string) (string, error) { return trimmed, nil } -// ParseProxy parses the input string for a proxy configuration. +// parseProxy parses the input string for a proxy configuration. // Errors if a scheme is unsupported or unspecified, or if the input is not a valid URL. -func ParseProxy(input string) (*url.URL, error) { +func parseProxy(input string) (*url.URL, error) { if input == "" { return nil, nil } @@ -433,10 +437,6 @@ func (config *V2RegistriesConf) postProcessRegistries() error { return err } - if _, err = ParseProxy(reg.Proxy); err != nil { - return err - } - if reg.Prefix == "" { if reg.Location == "" { return &InvalidRegistries{s: "invalid condition: both location and prefix are unset"} @@ -454,6 +454,11 @@ func (config *V2RegistriesConf) postProcessRegistries() error { } } + reg.Proxy, err = parseProxy(reg.ProxyRaw) + if err != nil { + return err + } + // validate the mirror usage settings does not apply to primary registry if reg.PullFromMirror != "" { return fmt.Errorf("pull-from-mirror must not be set for a non-mirror registry %q", reg.Prefix) @@ -466,10 +471,6 @@ func (config *V2RegistriesConf) postProcessRegistries() error { return err } - if _, err = ParseProxy(mir.Proxy); err != nil { - return err - } - // FIXME: unqualifiedSearchRegistries now also accepts empty values // and shouldn't // https://github.com/containers/image/pull/1191#discussion_r610623216 @@ -477,6 +478,11 @@ func (config *V2RegistriesConf) postProcessRegistries() error { return &InvalidRegistries{s: "invalid condition: mirror location is unset"} } + mir.Proxy, err = parseProxy(mir.ProxyRaw) + if err != nil { + return err + } + if reg.MirrorByDigestOnly && mir.PullFromMirror != "" { return &InvalidRegistries{s: fmt.Sprintf("cannot set mirror usage mirror-by-digest-only for the registry (%q) and pull-from-mirror for per-mirror (%q) at the same time", reg.Prefix, mir.Location)} } diff --git a/image/pkg/sysregistriesv2/system_registries_v2_test.go b/image/pkg/sysregistriesv2/system_registries_v2_test.go index 5bc4793844..3d6071fbcc 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2_test.go +++ b/image/pkg/sysregistriesv2/system_registries_v2_test.go @@ -115,7 +115,7 @@ func TestParseProxy(t *testing.T) { "socks5://proxy.example.com", "socks5h://proxy.example.com:1080", } { - _, err := ParseProxy(valid) + _, err := parseProxy(valid) assert.Nil(t, err, valid) } @@ -124,7 +124,7 @@ func TestParseProxy(t *testing.T) { "ftp://bad-scheme.example.com", "ssh://bad-scheme.example.com:2222", } { - _, err := ParseProxy(invalid) + _, err := parseProxy(invalid) assert.NotNil(t, err) } } @@ -1007,29 +1007,31 @@ func TestCredentialHelpers(t *testing.T) { } func TestProxyConfiguration(t *testing.T) { - sys := &types.SystemContext{ + ctx := &types.SystemContext{ SystemRegistriesConfPath: "testdata/proxy.conf", SystemRegistriesConfDirPath: "testdata/this-does-not-exist", } - registries, err := GetRegistries(sys) + InvalidateCache() + _, err := TryUpdatingCache(ctx) require.NoError(t, err) - require.Equal(t, 2, len(registries)) - reg1 := registries[0] - assert.Equal(t, "registry-1.test", reg1.Location) - assert.Equal(t, "", reg1.Proxy) + reg1, err := FindRegistry(ctx, "registry-1.test") + require.NoError(t, err) + require.Nil(t, reg1.Proxy) require.Equal(t, 2, len(reg1.Mirrors)) mirror1 := reg1.Mirrors[0] assert.Equal(t, "mirror-1.registry-1.test", mirror1.Location) - assert.Equal(t, "", mirror1.Proxy) + require.Nil(t, mirror1.Proxy) mirror2 := reg1.Mirrors[1] assert.Equal(t, "mirror-2.registry-1.test", mirror2.Location) - assert.Equal(t, "http://proxy-1.example.test", mirror2.Proxy) + require.NotNil(t, mirror2.Proxy) + assert.Equal(t, "http://proxy-1.example.test", mirror2.Proxy.String()) - reg2 := registries[1] - assert.Equal(t, "registry-2.test", reg2.Location) - assert.Equal(t, "https://proxy-2.example.test", reg2.Proxy) + reg2, err := FindRegistry(ctx, "registry-2.test") + require.NoError(t, err) + require.NotNil(t, reg2.Proxy) + assert.Equal(t, "https://proxy-2.example.test", reg2.Proxy.String()) } From d606a578bfa5a83956a663b326595df95bb0ab22 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:26:43 +0800 Subject: [PATCH 7/9] Inline `testProxyForRegistry` in favour of a table-driven test Signed-off-by: cyqsimon <28627918+cyqsimon@users.noreply.github.com> --- image/docker/docker_client_test.go | 75 ++++++++++++++++-------------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/image/docker/docker_client_test.go b/image/docker/docker_client_test.go index bf4a64c3e5..b5061ec07a 100644 --- a/image/docker/docker_client_test.go +++ b/image/docker/docker_client_test.go @@ -446,40 +446,6 @@ func TestIsManifestUnknownError(t *testing.T) { } } -// Helper function to test that the selected proxy for a registry matches expected. -func testProxyForRegistry(t *testing.T, ctx context.Context, sys *types.SystemContext, registry string, expectedProxy string) { - t.Run(fmt.Sprintf(`Expecting proxy "%s" for registry "%s"`, expectedProxy, registry), func(t *testing.T) { - req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/v2/", registry), nil) - require.NoError(t, err) - - // Proxy configured using environment variables have priority, so we skip if it's set. - envProxy, _ := http.ProxyFromEnvironment(req) - if envProxy != nil { - t.Skip("Skipping registry proxy test: proxy configured using environment variables") - } - - client, err := newDockerClient(sys, registry, registry) - require.NoError(t, err) - - // Ping will fail, but we only care about the side effect of setting the proxy. - _ = client.detectProperties(ctx) - - transport, ok := client.client.Transport.(*http.Transport) - require.True(t, ok) - require.NotNil(t, transport.Proxy) - - proxyURL, err := transport.Proxy(req) - require.NoError(t, err) - - if expectedProxy == "" { - require.Nil(t, proxyURL) - } else { - require.NotNil(t, proxyURL) - assert.Equal(t, expectedProxy, proxyURL.String()) - } - }) -} - func TestRegistrySpecificProxy(t *testing.T) { ctx := context.Background() sys := &types.SystemContext{ @@ -488,6 +454,43 @@ func TestRegistrySpecificProxy(t *testing.T) { DockerInsecureSkipTLSVerify: types.OptionalBoolTrue, } - testProxyForRegistry(t, ctx, sys, "registry-1.test", "") - testProxyForRegistry(t, ctx, sys, "registry-2.test", "https://proxy-2.example.test") + var cases = []struct { + registry string + expectedProxy string + }{ + {"registry-1.test", ""}, + {"registry-2.test", "https://proxy-2.example.test"}, + } + for _, c := range cases { + t.Run(fmt.Sprintf(`Expecting proxy "%s" for registry "%s"`, c.expectedProxy, c.registry), func(t *testing.T) { + req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/v2/", c.registry), nil) + require.NoError(t, err) + + // Proxy configured using environment variables have priority, so we skip if it's set. + envProxy, _ := http.ProxyFromEnvironment(req) + if envProxy != nil { + t.Skip("Skipping registry proxy test: proxy configured using environment variables") + } + + client, err := newDockerClient(sys, c.registry, c.registry) + require.NoError(t, err) + + // Ping will fail, but we only care about the side effect of setting the proxy. + _ = client.detectProperties(ctx) + + transport, ok := client.client.Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.Proxy) + + proxyURL, err := transport.Proxy(req) + require.NoError(t, err) + + if c.expectedProxy == "" { + require.Nil(t, proxyURL) + } else { + require.NotNil(t, proxyURL) + assert.Equal(t, c.expectedProxy, proxyURL.String()) + } + }) + } } From 5f58f1610cbf7431e4cf64f1a03c403525e07bc3 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:48:58 +0800 Subject: [PATCH 8/9] Make registry-specific proxy takes precedence over proxy env vars Because it has a narrower scope than the globally scoped env vars. Signed-off-by: cyqsimon <28627918+cyqsimon@users.noreply.github.com> --- image/docker/docker_client.go | 10 +++------- image/docker/docker_client_test.go | 12 +++--------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/image/docker/docker_client.go b/image/docker/docker_client.go index d77eaff911..b30106b880 100644 --- a/image/docker/docker_client.go +++ b/image/docker/docker_client.go @@ -978,14 +978,10 @@ func (c *dockerClient) detectPropertiesHelper(ctx context.Context) error { } tr := tlsclientconfig.NewTransport() tr.TLSClientConfig = c.tlsClientConfig - // Set registry-specific proxy with lowest priority, which can be overridden by environment variables. + // Set registry-specific proxy. + // This has a narrower scope so should take precedence over globally-scoped environment variables. if c.registryProxy != nil { - tr.Proxy = func(req *http.Request) (*url.URL, error) { - if envProxy, err := http.ProxyFromEnvironment(req); err != nil || envProxy != nil { - return envProxy, err - } - return c.registryProxy, nil - } + tr.Proxy = http.ProxyURL(c.registryProxy) } // if set DockerProxyURL explicitly, use the DockerProxyURL instead of system proxy if c.sys != nil && c.sys.DockerProxyURL != nil { diff --git a/image/docker/docker_client_test.go b/image/docker/docker_client_test.go index b5061ec07a..a42d0e2257 100644 --- a/image/docker/docker_client_test.go +++ b/image/docker/docker_client_test.go @@ -463,15 +463,6 @@ func TestRegistrySpecificProxy(t *testing.T) { } for _, c := range cases { t.Run(fmt.Sprintf(`Expecting proxy "%s" for registry "%s"`, c.expectedProxy, c.registry), func(t *testing.T) { - req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/v2/", c.registry), nil) - require.NoError(t, err) - - // Proxy configured using environment variables have priority, so we skip if it's set. - envProxy, _ := http.ProxyFromEnvironment(req) - if envProxy != nil { - t.Skip("Skipping registry proxy test: proxy configured using environment variables") - } - client, err := newDockerClient(sys, c.registry, c.registry) require.NoError(t, err) @@ -482,6 +473,9 @@ func TestRegistrySpecificProxy(t *testing.T) { require.True(t, ok) require.NotNil(t, transport.Proxy) + req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/v2/", c.registry), nil) + require.NoError(t, err) + proxyURL, err := transport.Proxy(req) require.NoError(t, err) From af0e3eaff4fcceb8f3e0d15e72abb3a14b3c08cb Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:19:06 +0800 Subject: [PATCH 9/9] Add registry-specific proxy docs to manual Signed-off-by: cyqsimon <28627918+cyqsimon@users.noreply.github.com> --- image/docs/containers-registries.conf.5.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/image/docs/containers-registries.conf.5.md b/image/docs/containers-registries.conf.5.md index ed17fff840..15ca8ac499 100644 --- a/image/docs/containers-registries.conf.5.md +++ b/image/docs/containers-registries.conf.5.md @@ -63,6 +63,14 @@ By default, container runtimes require TLS when retrieving images from a registr If `insecure` is set to `true`, unencrypted HTTP as well as TLS connections with untrusted certificates are allowed. +`proxy` +: Sets the forwarding proxy to be used specifically for connections to this registry. +This setting takes precedence over globally-scoped proxies set using environment variables. +Accepts a URL to the proxy in the format of _scheme_`://`_host_[`:`_port_][`/`_path_] +where _scheme_ is one of `http`, `https`, `socks5`, or `socks5h`. See CURLOPT_PROXY(3). +Note that both `socks5` and `socks5h` behave like `socks5h` in curl, +i.e. name resolution always happens remotely. + `blocked` : `true` or `false`. If `true`, pulling images with matching names is forbidden. @@ -94,14 +102,14 @@ With a `prefix` containing a wildcard in the format: "*.example.com" for subdoma the location can be empty. In such a case, prefix matching will occur, but no reference rewrite will occur. The original requested image string will be used as-is. But other settings like -`insecure` / `blocked` / `mirrors` will be applied to matching images. +`insecure` / `proxy` / `blocked` / `mirrors` will be applied to matching images. Example: Given ``` prefix = "*.example.com" ``` requests for the image `blah.example.com/foo/myimage:latest` will be used -as-is. But other settings like insecure/blocked/mirrors will be applied to matching images +as-is. But other settings like insecure/proxy/blocked/mirrors will be applied to matching images `mirror` : An array of TOML tables specifying (possibly-partial) mirrors for the @@ -117,6 +125,8 @@ Each TOML table in the `mirror` array can contain the following fields: as specified in the `[[registry]]` TOML table - `insecure`: same semantics as specified in the `[[registry]]` TOML table +- `proxy`: same semantics +as specified in the `[[registry]]` TOML table - `pull-from-mirror`: `all`, `digest-only` or `tag-only`. If "digest-only", mirrors will only be used for digest pulls. Pulling images by tag can potentially yield different images, depending on which endpoint we pull from. Restricting mirrors to pulls by digest avoids that issue. If "tag-only", mirrors will only be used for tag pulls. For a more up-to-date and expensive mirror that it is less likely to be out of sync if tags move, it should not be unnecessarily used for digest references. Default is "all" (or left empty), mirrors will be used for both digest pulls and tag pulls unless the mirror-by-digest-only is set for the primary registry. Note that this per-mirror setting is allowed only when `mirror-by-digest-only` is not configured for the primary registry. @@ -239,6 +249,7 @@ location = "internal-registry-for-example.com/bar" [[registry.mirror]] location = "example-mirror-0.local/mirror-for-foo" +proxy = "http://proxy.example.com:8000" [[registry.mirror]] location = "example-mirror-1.local/mirrors/foo"