From 6be0c1c284baf42a40ad31eb8487b63dcc0b58ed 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 9677aa316e..d6771955d8 100644 --- a/image/docker/docker_client.go +++ b/image/docker/docker_client.go @@ -102,6 +102,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 @@ -270,18 +274,24 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc } } - // 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 one of %s", reg.Prefix, sysregistriesv2.ConfigurationSourceDescription(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 @@ -295,6 +305,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 @@ -976,6 +987,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 0550edbf06..09afe85a12 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2.go +++ b/image/pkg/sysregistriesv2/system_registries_v2.go @@ -3,6 +3,7 @@ package sysregistriesv2 import ( "fmt" "maps" + "net/url" "reflect" "slices" "sort" @@ -44,6 +45,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 @@ -327,6 +330,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) @@ -395,6 +424,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"} @@ -424,6 +457,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 @@ -469,6 +506,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 e01f7ea978e0cdfe0f72fc974553f840e70a5768 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 528008f7c1..222d13fc3c 100644 --- a/image/docker/docker_client_test.go +++ b/image/docker/docker_client_test.go @@ -482,3 +482,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 72007b0597..ea9d77881a 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", @@ -947,3 +969,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 35008d859ab2a76ad11fe748b624cbdc6597ad79 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 09afe85a12..cc254d4bfb 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2.go +++ b/image/pkg/sysregistriesv2/system_registries_v2.go @@ -337,22 +337,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 b19986a546bbd931f0038820f2c024c9ab4c69a4 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 d6771955d8..a347781a61 100644 --- a/image/docker/docker_client.go +++ b/image/docker/docker_client.go @@ -102,9 +102,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 @@ -274,7 +276,9 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc } } - // 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) @@ -285,9 +289,7 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc if reg.Blocked { return nil, fmt.Errorf("registry %s is blocked in one of %s", reg.Prefix, sysregistriesv2.ConfigurationSourceDescription(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 8d22f510b8e9968140706cd0709623cec43679c3 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 222d13fc3c..a2de157fec 100644 --- a/image/docker/docker_client_test.go +++ b/image/docker/docker_client_test.go @@ -525,6 +525,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 ea9d77881a..e5a66a2f3d 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2_test.go +++ b/image/pkg/sysregistriesv2/system_registries_v2_test.go @@ -981,19 +981,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 720125ba3dbe45495f47e605d4f9f3a9db6ce4e4 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 a347781a61..98a8475942 100644 --- a/image/docker/docker_client.go +++ b/image/docker/docker_client.go @@ -290,10 +290,7 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc return nil, fmt.Errorf("registry %s is blocked in one of %s", reg.Prefix, sysregistriesv2.ConfigurationSourceDescription(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 cc254d4bfb..1d16515e73 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2.go +++ b/image/pkg/sysregistriesv2/system_registries_v2.go @@ -46,7 +46,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 @@ -330,9 +334,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 } @@ -419,10 +423,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"} @@ -440,6 +440,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) @@ -452,10 +457,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 @@ -463,6 +464,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 e5a66a2f3d..98fedcfb47 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) } } @@ -971,29 +971,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 e8de34bab9353be599e32a9dbd7ee8a00030c919 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 a2de157fec..1e85305785 100644 --- a/image/docker/docker_client_test.go +++ b/image/docker/docker_client_test.go @@ -483,40 +483,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{ @@ -525,6 +491,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 fa80fdb2936e21973aa58556f2335362f11b304c 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 98a8475942..9da9caadfa 100644 --- a/image/docker/docker_client.go +++ b/image/docker/docker_client.go @@ -986,14 +986,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 1e85305785..7671d6b3eb 100644 --- a/image/docker/docker_client_test.go +++ b/image/docker/docker_client_test.go @@ -500,15 +500,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) @@ -519,6 +510,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 5ffc8884d80fc8e5d99815ff948c521e54a54a5b 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 12db6766a1..5ee23fe7ef 100644 --- a/image/docs/containers-registries.conf.5.md +++ b/image/docs/containers-registries.conf.5.md @@ -105,6 +105,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. @@ -136,14 +144,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 @@ -159,6 +167,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. @@ -281,6 +291,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"