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/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"