diff --git a/image/docker/docker_client.go b/image/docker/docker_client.go index 2f257076f5..b30106b880 100644 --- a/image/docker/docker_client.go +++ b/image/docker/docker_client.go @@ -114,6 +114,12 @@ 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 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 registryToken string @@ -262,18 +268,21 @@ 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 + // 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) } + 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)) } skipVerify = reg.Insecure + registryProxy = reg.Proxy } tlsClientConfig.InsecureSkipVerify = skipVerify @@ -287,6 +296,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 +978,11 @@ func (c *dockerClient) detectPropertiesHelper(ctx context.Context) error { } tr := tlsclientconfig.NewTransport() tr.TLSClientConfig = c.tlsClientConfig + // Set registry-specific proxy. + // This has a narrower scope so should take precedence over globally-scoped environment variables. + if 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 { tr.Proxy = http.ProxyURL(c.sys.DockerProxyURL) diff --git a/image/docker/docker_client_test.go b/image/docker/docker_client_test.go index 229fea332f..a42d0e2257 100644 --- a/image/docker/docker_client_test.go +++ b/image/docker/docker_client_test.go @@ -445,3 +445,46 @@ func TestIsManifestUnknownError(t *testing.T) { assert.True(t, res, "%s: %#v", c.name, err) } } + +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, + } + + 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) { + 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) + + 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) + + if c.expectedProxy == "" { + require.Nil(t, proxyURL) + } else { + require.NotNil(t, proxyURL) + assert.Equal(t, c.expectedProxy, proxyURL.String()) + } + }) + } +} diff --git a/image/docker/docker_image_src.go b/image/docker/docker_image_src.go index 4003af5d27..5e6cc80248 100644 --- a/image/docker/docker_image_src.go +++ b/image/docker/docker_image_src.go @@ -150,6 +150,7 @@ func newImageSourceAttempt(ctx context.Context, sys *types.SystemContext, logica return nil, err } client.tlsClientConfig.InsecureSkipVerify = pullSource.Endpoint.Insecure + client.registryProxy = pullSource.Endpoint.Proxy s := &dockerImageSource{ PropertyMethodsInitialize: impl.PropertyMethods(impl.Properties{ 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" diff --git a/image/pkg/sysregistriesv2/system_registries_v2.go b/image/pkg/sysregistriesv2/system_registries_v2.go index 0cf44571d3..4fcae7e125 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,12 @@ 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. + // 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 @@ -341,6 +348,27 @@ 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 + } + + 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 +} + // ConvertToV2 returns a v2 config corresponding to a v1 one. func (config *V1RegistriesConf) ConvertToV2() (*V2RegistriesConf, error) { regMap := make(map[string]*Registry) @@ -426,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) @@ -445,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)} } @@ -483,6 +521,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} diff --git a/image/pkg/sysregistriesv2/system_registries_v2_test.go b/image/pkg/sysregistriesv2/system_registries_v2_test.go index b253714809..3d6071fbcc 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,33 @@ func TestCredentialHelpers(t *testing.T) { require.Equal(t, test.helpers, helpers, "%v", test) } } + +func TestProxyConfiguration(t *testing.T) { + ctx := &types.SystemContext{ + SystemRegistriesConfPath: "testdata/proxy.conf", + SystemRegistriesConfDirPath: "testdata/this-does-not-exist", + } + + InvalidateCache() + _, err := TryUpdatingCache(ctx) + require.NoError(t, err) + + 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) + require.Nil(t, mirror1.Proxy) + + mirror2 := reg1.Mirrors[1] + assert.Equal(t, "mirror-2.registry-1.test", mirror2.Location) + require.NotNil(t, mirror2.Proxy) + assert.Equal(t, "http://proxy-1.example.test", mirror2.Proxy.String()) + + 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()) +} diff --git a/image/pkg/sysregistriesv2/testdata/proxy.conf b/image/pkg/sysregistriesv2/testdata/proxy.conf new file mode 100644 index 0000000000..bde7375632 --- /dev/null +++ b/image/pkg/sysregistriesv2/testdata/proxy.conf @@ -0,0 +1,13 @@ +[[registry]] +location = "registry-1.test" + +[[registry.mirror]] +location = "mirror-1.registry-1.test" + +[[registry.mirror]] +location = "mirror-2.registry-1.test" +proxy = "http://proxy-1.example.test" + +[[registry]] +location = "registry-2.test" +proxy = "https://proxy-2.example.test"