From 1af6e757bc14f10e5a6ca7ecac9ecb5847bb13ab Mon Sep 17 00:00:00 2001 From: Itx-Psycho0 Date: Thu, 14 May 2026 16:34:22 +0530 Subject: [PATCH 1/4] feat: add TLS certificate support for Docker contexts Extends Docker context detection (from #3684) to support TLS certificates stored in Docker contexts. This enables secure connections to remote Docker daemons configured via Docker contexts. Changes: - Extended getDockerContextHost() to getDockerContextConfig() which returns both host and TLS configuration - Added DockerContextConfig struct to hold host and TLS settings - Modified newHttpClient() to check Docker context first, then fall back to environment variables - Added newHttpClientFromContext() to create HTTP client from context config - Load TLS certificates directly from context (no temp files or env vars) - Added comprehensive test with mock TLS daemon Benefits: - Remote Docker setups work automatically with TLS - Consistent with Docker CLI behavior - No manual environment variables needed - Proper TLS support for secure connections - Clean implementation without temp files Fixes #3719 --- pkg/docker/docker_client.go | 152 +++++++++++++++++++-- pkg/docker/docker_client_test.go | 225 +++++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+), 14 deletions(-) diff --git a/pkg/docker/docker_client.go b/pkg/docker/docker_client.go index 3cd04896c8..6aeb233daa 100644 --- a/pkg/docker/docker_client.go +++ b/pkg/docker/docker_client.go @@ -2,10 +2,12 @@ package docker import ( "context" + "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/json" "errors" + "fmt" "io" "net" "net/http" @@ -59,6 +61,15 @@ type DockerClient interface { Close() error } +// DockerContextConfig holds Docker context configuration including TLS settings +type DockerContextConfig struct { + Host string + TLSCACert []byte + TLSCert []byte + TLSKey []byte + SkipTLSVerify bool +} + var ErrNoDocker = errors.New("docker/podman API not available") // NewClient creates a new docker client. @@ -166,6 +177,7 @@ func NewClient(defaultHost string) (dc DockerClient, dockerHostInRemote string, if !isSSH { opts := []client.Opt{client.FromEnv, client.WithHost(dockerHost)} if isTCP { + // Try to get HTTP client with TLS from Docker context or environment variables if httpClient := newHttpClient(); httpClient != nil { opts = append(opts, client.WithHTTPClient(httpClient)) } @@ -213,6 +225,12 @@ func NewClient(defaultHost string) (dc DockerClient, dockerHostInRemote string, // this function returns HTTP client with appropriately configured TLS config. // Otherwise, nil is returned. func newHttpClient() *http.Client { + // First, try to get TLS config from Docker context + if contextConfig := getDockerContextConfig(); contextConfig != nil && len(contextConfig.TLSCert) > 0 && len(contextConfig.TLSKey) > 0 { + return newHttpClientFromContext(contextConfig) + } + + // Fall back to environment variables tlsVerifyStr, tlsVerifyChanged := os.LookupEnv("DOCKER_TLS_VERIFY") if !tlsVerifyChanged { @@ -276,6 +294,52 @@ func newHttpClient() *http.Client { } } +// newHttpClientFromContext creates an HTTP client configured with TLS from Docker context +func newHttpClientFromContext(contextConfig *DockerContextConfig) *http.Client { + var tlsOpts []func(*tls.Config) + + if contextConfig.SkipTLSVerify { + tlsOpts = append(tlsOpts, func(t *tls.Config) { + t.InsecureSkipVerify = true + }) + } + + // Load CA certificate if provided + if len(contextConfig.TLSCACert) > 0 { + caCertPool := x509.NewCertPool() + if caCertPool.AppendCertsFromPEM(contextConfig.TLSCACert) { + tlsOpts = append(tlsOpts, func(t *tls.Config) { + t.RootCAs = caCertPool + }) + } + } + + // Load client certificate and key + if len(contextConfig.TLSCert) > 0 && len(contextConfig.TLSKey) > 0 { + cert, err := tls.X509KeyPair(contextConfig.TLSCert, contextConfig.TLSKey) + if err == nil { + tlsOpts = append(tlsOpts, func(t *tls.Config) { + t.Certificates = []tls.Certificate{cert} + }) + } + } + + dialer := &net.Dialer{ + KeepAlive: 30 * time.Second, + Timeout: 30 * time.Second, + } + + tlsConfig := tlsconfig.ClientDefault(tlsOpts...) + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + DialContext: dialer.DialContext, + }, + CheckRedirect: client.CheckRedirect, + } +} + // tries to get connection to default podman machine func tryGetPodmanRemoteConn() (uri string, identity string) { cmd := exec.Command("podman", "system", "connection", "list", "--format=json") @@ -310,21 +374,27 @@ func podmanPresent() bool { return err == nil } -// getDockerContextHost tries to get the Docker host from the current Docker context. -// This is useful for Docker Desktop which uses context-specific sockets. -// Returns empty string if unable to determine the context host. -func getDockerContextHost() string { +// getDockerContextConfig tries to get the Docker host and TLS configuration from the current Docker context. +// This is useful for Docker Desktop which uses context-specific sockets and for remote Docker with TLS. +// Returns nil if unable to determine the context configuration. +func getDockerContextConfig() *DockerContextConfig { // Check if docker CLI is available dockerPath, err := exec.LookPath("docker") if err != nil { - return "" + return nil } // Run 'docker context inspect' to get current context details cmd := exec.Command(dockerPath, "context", "inspect") + + // Respect DOCKER_CONFIG environment variable + if dockerConfig := os.Getenv("DOCKER_CONFIG"); dockerConfig != "" { + cmd.Env = append(os.Environ(), "DOCKER_CONFIG="+dockerConfig) + } + out, err := cmd.CombinedOutput() if err != nil { - return "" + return nil } // Parse the JSON output @@ -332,24 +402,78 @@ func getDockerContextHost() string { Name string Endpoints struct { Docker struct { - Host string `json:"Host"` + Host string `json:"Host"` + SkipTLSVerify bool `json:"SkipTLSVerify"` } `json:"docker"` } `json:"Endpoints"` + Storage struct { + MetadataPath string `json:"MetadataPath"` + TLSPath string `json:"TLSPath"` + } `json:"Storage"` } if err := json.Unmarshal(out, &contexts); err != nil { - return "" + return nil + } + + // Return config from the first (current) context + if len(contexts) == 0 || contexts[0].Endpoints.Docker.Host == "" { + return nil + } + + // Skip default context + if contexts[0].Name == "default" { + return nil } - // Return the host from the first (current) context - if len(contexts) > 0 && contexts[0].Endpoints.Docker.Host != "" { - if contexts[0].Name == "default" { - return "" + config := &DockerContextConfig{ + Host: contexts[0].Endpoints.Docker.Host, + SkipTLSVerify: contexts[0].Endpoints.Docker.SkipTLSVerify, + } + + // Try to load TLS certificates from the context storage + tlsPath := contexts[0].Storage.TLSPath + + // If TLSPath is not a real path (e.g., ""), try to find it manually + if tlsPath == "" || tlsPath == "" || !filepath.IsAbs(tlsPath) { + // Determine Docker config directory + dockerConfigDir := os.Getenv("DOCKER_CONFIG") + if dockerConfigDir == "" { + dockerConfigDir = filepath.Join(os.Getenv("HOME"), ".docker") } - return contexts[0].Endpoints.Docker.Host + + // Docker stores context TLS files in contexts/meta// + // The hash is SHA256 of the context name + hash := sha256.Sum256([]byte(contexts[0].Name)) + tlsPath = filepath.Join(dockerConfigDir, "contexts", "meta", fmt.Sprintf("%x", hash)) } - return "" + // Try to read TLS files from the determined path + if tlsPath != "" && tlsPath != "" { + // Read CA certificate + if caData, err := os.ReadFile(filepath.Join(tlsPath, "ca.pem")); err == nil { + config.TLSCACert = caData + } + + // Read client certificate and key + if certData, err := os.ReadFile(filepath.Join(tlsPath, "cert.pem")); err == nil { + config.TLSCert = certData + } + if keyData, err := os.ReadFile(filepath.Join(tlsPath, "key.pem")); err == nil { + config.TLSKey = keyData + } + } + + return config +} + +// getDockerContextHost is a wrapper for backward compatibility +func getDockerContextHost() string { + config := getDockerContextConfig() + if config == nil { + return "" + } + return config.Host } // GetDockerContextHostFunc is a variable to allow mocking in tests diff --git a/pkg/docker/docker_client_test.go b/pkg/docker/docker_client_test.go index 276282ae7a..c80763a4b9 100644 --- a/pkg/docker/docker_client_test.go +++ b/pkg/docker/docker_client_test.go @@ -2,9 +2,16 @@ package docker_test import ( "context" + "crypto/rand" + "crypto/rsa" "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/json" + "encoding/pem" "fmt" + "math/big" "net" "net/http" "os" @@ -244,3 +251,221 @@ func createDockerContextConfig(t *testing.T, configDir, contextName, host string t.Fatal(err) } } + +// startMockTLSDaemon creates a TLS-enabled mock Docker daemon for testing. +// Returns the listener, CA cert, client cert, and client key in PEM format. +func startMockTLSDaemon(t *testing.T) (net.Listener, []byte, []byte, []byte) { + t.Helper() + + // Generate CA certificate + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Test CA"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + + caCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCertDER}) + + // Generate server certificate + serverKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + serverTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{ + Organization: []string{"Test Server"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + serverCertDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, caTemplate, &serverKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + + // Generate client certificate + clientKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + clientTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{ + Organization: []string{"Test Client"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + clientCertDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caTemplate, &clientKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + + clientCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: clientCertDER}) + clientKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(clientKey)}) + + // Create TLS config for server + // Server needs to trust the CA that signed the client cert + clientCACertPool := x509.NewCertPool() + caCert, err := x509.ParseCertificate(caCertDER) + if err != nil { + t.Fatal(err) + } + clientCACertPool.AddCert(caCert) + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{ + { + Certificate: [][]byte{serverCertDER}, + PrivateKey: serverKey, + }, + }, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCACertPool, + } + + // Start TLS listener + listener, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig) + if err != nil { + t.Fatal(err) + } + + // Start mock daemon with TLS + startMockDaemon(t, listener) + + return listener, caCertPEM, clientCertPEM, clientKeyPEM +} + +// TestNewClient_DockerContextTLS tests that TLS configuration from Docker context +// is properly loaded and used when connecting to remote Docker daemons. +func TestNewClient_DockerContextTLS(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping Docker context TLS test on Windows") + } + + // Check if docker CLI is available + _, err := exec.LookPath("docker") + if err != nil { + t.Skip("Docker CLI not available, skipping context TLS test") + } + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) + defer cancel() + + tmpDir := t.TempDir() + + // Start a mock TLS daemon + tlsListener, caCert, clientCert, clientKey := startMockTLSDaemon(t) + tlsHost := fmt.Sprintf("tcp://%s", tlsListener.Addr().String()) + + // Build a Docker config directory with a context that has TLS configuration + configDir := filepath.Join(tmpDir, "docker-config") + contextName := "func-test-tls-ctx" + + // Calculate the hash for the context name (Docker uses SHA256) + hash := sha256.Sum256([]byte(contextName)) + hashStr := fmt.Sprintf("%x", hash) + + // Docker stores TLS files in contexts/tls// + tlsDir := filepath.Join(configDir, "contexts", "tls", hashStr) + + // Create TLS directory and write the actual certificate files + if err := os.MkdirAll(tlsDir, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(tlsDir, "ca.pem"), caCert, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tlsDir, "cert.pem"), clientCert, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tlsDir, "key.pem"), clientKey, 0o600); err != nil { + t.Fatal(err) + } + + createDockerContextConfigWithTLS(t, configDir, contextName, tlsHost, tlsDir) + + t.Setenv("DOCKER_HOST", "") + t.Setenv("DOCKER_CONFIG", configDir) + + // Pass a non-existent socket as the default host to force context detection + nonExistentDefault := fmt.Sprintf("unix://%s", filepath.Join(tmpDir, "nonexistent.sock")) + dockerClient, _, err := docker.NewClient(nonExistentDefault) + if err != nil { + t.Fatalf("Failed to create Docker client with TLS context: %v", err) + } + defer dockerClient.Close() + + // Verify we can connect to the TLS-enabled mock daemon + // This proves that TLS certificates from the context are actually being used + _, err = dockerClient.Ping(ctx, client.PingOptions{}) + if err != nil { + t.Fatalf("Failed to ping TLS-enabled mock daemon: %v", err) + } + + // Verify we're actually talking to our mock daemon + nfo, err := dockerClient.Info(ctx, client.InfoOptions{}) + if err != nil { + t.Fatalf("Failed to get info from TLS mock daemon: %v", err) + } + if nfo.Info.ID != "mock-daemon" { + t.Errorf("unexpected server ID: got %q, want %q", nfo.Info.ID, "mock-daemon") + } +} + +// createDockerContextConfigWithTLS writes a Docker CLI config directory +// with a context that includes TLS configuration. +func createDockerContextConfigWithTLS(t *testing.T, configDir, contextName, host, tlsPath string) { + t.Helper() + + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + + configJSON := fmt.Sprintf(`{"auths":{},"currentContext":%q}`, contextName) + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(configJSON), 0o644); err != nil { + t.Fatal(err) + } + + hash := sha256.Sum256([]byte(contextName)) + metaDir := filepath.Join(configDir, "contexts", "meta", fmt.Sprintf("%x", hash)) + if err := os.MkdirAll(metaDir, 0o755); err != nil { + t.Fatal(err) + } + + metaJSON := fmt.Sprintf( + `{"Name":%q,"Metadata":{"Description":"test context with TLS"},"Endpoints":{"docker":{"Host":%q,"SkipTLSVerify":false}},"Storage":{"MetadataPath":%q,"TLSPath":%q}}`, + contextName, host, metaDir, tlsPath, + ) + if err := os.WriteFile(filepath.Join(metaDir, "meta.json"), []byte(metaJSON), 0o644); err != nil { + t.Fatal(err) + } +} From 1277ea5dfc6fa32ae0fc7cc01d7cb9ac5ec78489 Mon Sep 17 00:00:00 2001 From: Itx-Psycho0 Date: Thu, 14 May 2026 23:27:01 +0530 Subject: [PATCH 2/4] fix: address code review feedback for Docker context TLS Fixes based on @matejvasek's review: Significant fixes: 1. Fixed TLS path: use contexts/tls// not contexts/meta// 2. Fixed precedence: env vars (DOCKER_TLS_VERIFY) now override context 3. Context TLS only applies when host came from context detection 4. Cache context config to avoid calling 'docker context inspect' twice Minor improvements: 5. Unexported dockerContextConfig (internal-only struct) 6. Removed redundant DOCKER_CONFIG passthrough (auto-inherited) 7. Added error logging for malformed certificates The implementation now correctly: - Checks DOCKER_TLS_VERIFY env var first - Only uses context TLS when env vars are not set - Only applies context TLS when host came from context - Calls docker CLI once instead of twice - Logs warnings for cert loading failures --- pkg/docker/docker_client.go | 161 +++++++++++++++++++----------------- 1 file changed, 83 insertions(+), 78 deletions(-) diff --git a/pkg/docker/docker_client.go b/pkg/docker/docker_client.go index 6aeb233daa..25255b4f7a 100644 --- a/pkg/docker/docker_client.go +++ b/pkg/docker/docker_client.go @@ -61,8 +61,8 @@ type DockerClient interface { Close() error } -// DockerContextConfig holds Docker context configuration including TLS settings -type DockerContextConfig struct { +// dockerContextConfig holds Docker context configuration including TLS settings +type dockerContextConfig struct { Host string TLSCACert []byte TLSCert []byte @@ -89,6 +89,7 @@ func NewClient(defaultHost string) (dc DockerClient, dockerHostInRemote string, }() var _url *url.URL + var contextConfig *dockerContextConfig // Cache context config to avoid calling docker CLI twice dockerHost := os.Getenv("DOCKER_HOST") dockerHostSSHIdentity := os.Getenv("DOCKER_HOST_SSH_IDENTITY") @@ -107,24 +108,25 @@ func NewClient(defaultHost string) (dc DockerClient, dockerHostInRemote string, return case os.IsNotExist(err): // Default socket doesn't exist, try Docker context - if contextHost := GetDockerContextHostFunc(); contextHost != "" { + contextConfig = getDockerContextConfig() // Fetch once and cache + if contextConfig != nil && contextConfig.Host != "" { // Verify the context socket actually exists - contextURL, parseErr := url.Parse(contextHost) + contextURL, parseErr := url.Parse(contextConfig.Host) if parseErr == nil { switch contextURL.Scheme { case "unix", "": // For unix sockets, verify the socket file exists socketPath := contextURL.Path if contextURL.Scheme == "" { - socketPath = contextHost + socketPath = contextConfig.Host } if _, statErr := os.Stat(socketPath); statErr == nil { - dockerHost = contextHost + dockerHost = contextConfig.Host } case "ssh", "tcp", "npipe": // For remote connections, use the context host directly // We can't verify connectivity here, so trust the context - dockerHost = contextHost + dockerHost = contextConfig.Host } } } @@ -177,8 +179,9 @@ func NewClient(defaultHost string) (dc DockerClient, dockerHostInRemote string, if !isSSH { opts := []client.Opt{client.FromEnv, client.WithHost(dockerHost)} if isTCP { - // Try to get HTTP client with TLS from Docker context or environment variables - if httpClient := newHttpClient(); httpClient != nil { + // Try to get HTTP client with TLS + // Pass contextConfig only if the host came from context detection (contextConfig != nil) + if httpClient := newHttpClient(contextConfig); httpClient != nil { opts = append(opts, client.WithHTTPClient(httpClient)) } } @@ -221,81 +224,84 @@ func NewClient(defaultHost string) (dc DockerClient, dockerHostInRemote string, return dc, dockerHostInRemote, err } -// If the DOCKER_TLS_VERIFY environment variable is set -// this function returns HTTP client with appropriately configured TLS config. -// Otherwise, nil is returned. -func newHttpClient() *http.Client { - // First, try to get TLS config from Docker context - if contextConfig := getDockerContextConfig(); contextConfig != nil && len(contextConfig.TLSCert) > 0 && len(contextConfig.TLSKey) > 0 { - return newHttpClientFromContext(contextConfig) - } - - // Fall back to environment variables +// newHttpClient returns an HTTP client with TLS configuration. +// It checks environment variables first (DOCKER_TLS_VERIFY, DOCKER_CERT_PATH), +// and only falls back to Docker context if env vars are not set. +// contextConfig should only be passed if the host came from context detection. +func newHttpClient(contextConfig *dockerContextConfig) *http.Client { + // Check environment variables FIRST - they take precedence over context tlsVerifyStr, tlsVerifyChanged := os.LookupEnv("DOCKER_TLS_VERIFY") - if !tlsVerifyChanged { - return nil - } - - var tlsOpts []func(*tls.Config) - - tlsVerify := true - if b, err := strconv.ParseBool(tlsVerifyStr); err == nil { - tlsVerify = b - } - - if !tlsVerify { - tlsOpts = append(tlsOpts, func(t *tls.Config) { - t.InsecureSkipVerify = true - }) - } + if tlsVerifyChanged { + // Environment variables are set - use them, ignore context + var tlsOpts []func(*tls.Config) - dockerCertPath := os.Getenv("DOCKER_CERT_PATH") - if dockerCertPath == "" { - dockerCertPath = config.Dir() - } + tlsVerify := true + if b, err := strconv.ParseBool(tlsVerifyStr); err == nil { + tlsVerify = b + } - // Set root CA. - caData, err := os.ReadFile(filepath.Join(dockerCertPath, "ca.pem")) - if err == nil { - certPool := x509.NewCertPool() - if certPool.AppendCertsFromPEM(caData) { + if !tlsVerify { tlsOpts = append(tlsOpts, func(t *tls.Config) { - t.RootCAs = certPool + t.InsecureSkipVerify = true }) } - } - // Set client certificate. - certData, certErr := os.ReadFile(filepath.Join(dockerCertPath, "cert.pem")) - keyData, keyErr := os.ReadFile(filepath.Join(dockerCertPath, "key.pem")) - if certErr == nil && keyErr == nil { - cliCert, err := tls.X509KeyPair(certData, keyData) + dockerCertPath := os.Getenv("DOCKER_CERT_PATH") + if dockerCertPath == "" { + dockerCertPath = config.Dir() + } + + // Set root CA. + caData, err := os.ReadFile(filepath.Join(dockerCertPath, "ca.pem")) if err == nil { - tlsOpts = append(tlsOpts, func(cfg *tls.Config) { - cfg.Certificates = []tls.Certificate{cliCert} - }) + certPool := x509.NewCertPool() + if certPool.AppendCertsFromPEM(caData) { + tlsOpts = append(tlsOpts, func(t *tls.Config) { + t.RootCAs = certPool + }) + } } - } - dialer := &net.Dialer{ - KeepAlive: 30 * time.Second, - Timeout: 30 * time.Second, - } + // Set client certificate. + certData, certErr := os.ReadFile(filepath.Join(dockerCertPath, "cert.pem")) + keyData, keyErr := os.ReadFile(filepath.Join(dockerCertPath, "key.pem")) + if certErr == nil && keyErr == nil { + cliCert, err := tls.X509KeyPair(certData, keyData) + if err == nil { + tlsOpts = append(tlsOpts, func(cfg *tls.Config) { + cfg.Certificates = []tls.Certificate{cliCert} + }) + } + } - tlsConfig := tlsconfig.ClientDefault(tlsOpts...) + dialer := &net.Dialer{ + KeepAlive: 30 * time.Second, + Timeout: 30 * time.Second, + } - return &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsConfig, - DialContext: dialer.DialContext, - }, - CheckRedirect: client.CheckRedirect, + tlsConfig := tlsconfig.ClientDefault(tlsOpts...) + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + DialContext: dialer.DialContext, + }, + CheckRedirect: client.CheckRedirect, + } + } + + // No env vars set - try Docker context if available + if contextConfig != nil && len(contextConfig.TLSCert) > 0 && len(contextConfig.TLSKey) > 0 { + return newHttpClientFromContext(contextConfig) } + + // No TLS configuration found + return nil } // newHttpClientFromContext creates an HTTP client configured with TLS from Docker context -func newHttpClientFromContext(contextConfig *DockerContextConfig) *http.Client { +func newHttpClientFromContext(contextConfig *dockerContextConfig) *http.Client { var tlsOpts []func(*tls.Config) if contextConfig.SkipTLSVerify { @@ -317,7 +323,10 @@ func newHttpClientFromContext(contextConfig *DockerContextConfig) *http.Client { // Load client certificate and key if len(contextConfig.TLSCert) > 0 && len(contextConfig.TLSKey) > 0 { cert, err := tls.X509KeyPair(contextConfig.TLSCert, contextConfig.TLSKey) - if err == nil { + if err != nil { + // Log warning but continue - connection might still work without client cert + fmt.Fprintf(os.Stderr, "Warning: failed to load TLS client certificate from Docker context: %v\n", err) + } else { tlsOpts = append(tlsOpts, func(t *tls.Config) { t.Certificates = []tls.Certificate{cert} }) @@ -377,7 +386,7 @@ func podmanPresent() bool { // getDockerContextConfig tries to get the Docker host and TLS configuration from the current Docker context. // This is useful for Docker Desktop which uses context-specific sockets and for remote Docker with TLS. // Returns nil if unable to determine the context configuration. -func getDockerContextConfig() *DockerContextConfig { +func getDockerContextConfig() *dockerContextConfig { // Check if docker CLI is available dockerPath, err := exec.LookPath("docker") if err != nil { @@ -387,11 +396,7 @@ func getDockerContextConfig() *DockerContextConfig { // Run 'docker context inspect' to get current context details cmd := exec.Command(dockerPath, "context", "inspect") - // Respect DOCKER_CONFIG environment variable - if dockerConfig := os.Getenv("DOCKER_CONFIG"); dockerConfig != "" { - cmd.Env = append(os.Environ(), "DOCKER_CONFIG="+dockerConfig) - } - + // Note: DOCKER_CONFIG is automatically inherited from parent environment out, err := cmd.CombinedOutput() if err != nil { return nil @@ -426,7 +431,7 @@ func getDockerContextConfig() *DockerContextConfig { return nil } - config := &DockerContextConfig{ + config := &dockerContextConfig{ Host: contexts[0].Endpoints.Docker.Host, SkipTLSVerify: contexts[0].Endpoints.Docker.SkipTLSVerify, } @@ -442,10 +447,10 @@ func getDockerContextConfig() *DockerContextConfig { dockerConfigDir = filepath.Join(os.Getenv("HOME"), ".docker") } - // Docker stores context TLS files in contexts/meta// - // The hash is SHA256 of the context name + // Docker stores context TLS files in contexts/tls// + // NOT in contexts/meta// (that's where meta.json lives) hash := sha256.Sum256([]byte(contexts[0].Name)) - tlsPath = filepath.Join(dockerConfigDir, "contexts", "meta", fmt.Sprintf("%x", hash)) + tlsPath = filepath.Join(dockerConfigDir, "contexts", "tls", fmt.Sprintf("%x", hash)) } // Try to read TLS files from the determined path From 7886033100c2c1c4caf532c81d21612ade10c9c2 Mon Sep 17 00:00:00 2001 From: Itx-Psycho0 Date: Sat, 16 May 2026 00:12:14 +0530 Subject: [PATCH 3/4] fix: Remove dead code and add fallback TLS path test Address remaining code review feedback from matejvasek: 1. Remove dead code: - Removed GetDockerContextHostFunc variable - Removed getDockerContextHost() wrapper function These are no longer called anywhere since getDockerContextConfig() is now called directly. 2. Add test for fallback TLS path: - Added TestNewClient_DockerContextTLS_FallbackPath - Tests the scenario where storage.TLSPath is "" or empty - Verifies that TLS certificates are still found via the calculated path based on context name hash (contexts/tls//) - This exercises the fallback logic that was previously untested All tests pass including the new fallback path test. --- pkg/docker/docker_client.go | 12 ----- pkg/docker/docker_client_test.go | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/pkg/docker/docker_client.go b/pkg/docker/docker_client.go index 25255b4f7a..ce64565dd4 100644 --- a/pkg/docker/docker_client.go +++ b/pkg/docker/docker_client.go @@ -472,18 +472,6 @@ func getDockerContextConfig() *dockerContextConfig { return config } -// getDockerContextHost is a wrapper for backward compatibility -func getDockerContextHost() string { - config := getDockerContextConfig() - if config == nil { - return "" - } - return config.Host -} - -// GetDockerContextHostFunc is a variable to allow mocking in tests -var GetDockerContextHostFunc = getDockerContextHost - type clientWithAdditionalCleanup struct { client.APIClient cleanUp func() diff --git a/pkg/docker/docker_client_test.go b/pkg/docker/docker_client_test.go index c80763a4b9..fd3e9fd2f3 100644 --- a/pkg/docker/docker_client_test.go +++ b/pkg/docker/docker_client_test.go @@ -469,3 +469,83 @@ func createDockerContextConfigWithTLS(t *testing.T, configDir, contextName, host t.Fatal(err) } } + +// TestNewClient_DockerContextTLS_FallbackPath tests that TLS configuration is properly +// loaded even when storage.TLSPath is "" or empty, by falling back to the +// calculated path based on the context name hash. +func TestNewClient_DockerContextTLS_FallbackPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping Docker context TLS fallback test on Windows") + } + + // Check if docker CLI is available + _, err := exec.LookPath("docker") + if err != nil { + t.Skip("Docker CLI not available, skipping context TLS fallback test") + } + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) + defer cancel() + + tmpDir := t.TempDir() + + // Start a mock TLS daemon + tlsListener, caCert, clientCert, clientKey := startMockTLSDaemon(t) + tlsHost := fmt.Sprintf("tcp://%s", tlsListener.Addr().String()) + + // Build a Docker config directory with a context that has TLS configuration + configDir := filepath.Join(tmpDir, "docker-config") + contextName := "func-test-tls-fallback-ctx" + + // Calculate the hash for the context name (Docker uses SHA256) + hash := sha256.Sum256([]byte(contextName)) + hashStr := fmt.Sprintf("%x", hash) + + // Docker stores TLS files in contexts/tls// + tlsDir := filepath.Join(configDir, "contexts", "tls", hashStr) + + // Create TLS directory and write the actual certificate files + if err := os.MkdirAll(tlsDir, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(tlsDir, "ca.pem"), caCert, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tlsDir, "cert.pem"), clientCert, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tlsDir, "key.pem"), clientKey, 0o600); err != nil { + t.Fatal(err) + } + + // Create context config with TLSPath set to "" to test fallback + createDockerContextConfigWithTLS(t, configDir, contextName, tlsHost, "") + + t.Setenv("DOCKER_HOST", "") + t.Setenv("DOCKER_CONFIG", configDir) + + // Pass a non-existent socket as the default host to force context detection + nonExistentDefault := fmt.Sprintf("unix://%s", filepath.Join(tmpDir, "nonexistent.sock")) + dockerClient, _, err := docker.NewClient(nonExistentDefault) + if err != nil { + t.Fatalf("Failed to create Docker client with TLS context (fallback path): %v", err) + } + defer dockerClient.Close() + + // Verify we can connect to the TLS-enabled mock daemon + // This proves that TLS certificates were found via the fallback path calculation + _, err = dockerClient.Ping(ctx, client.PingOptions{}) + if err != nil { + t.Fatalf("Failed to ping TLS-enabled mock daemon (fallback path): %v", err) + } + + // Verify we're actually talking to our mock daemon + nfo, err := dockerClient.Info(ctx, client.InfoOptions{}) + if err != nil { + t.Fatalf("Failed to get info from TLS mock daemon (fallback path): %v", err) + } + if nfo.Info.ID != "mock-daemon" { + t.Errorf("unexpected server ID: got %q, want %q", nfo.Info.ID, "mock-daemon") + } +} From af939b8583eed1dc988c30cefb4df02356b11228 Mon Sep 17 00:00:00 2001 From: Itx-Psycho0 Date: Sat, 16 May 2026 22:49:09 +0530 Subject: [PATCH 4/4] test: Add TLS env vars test for backward compatibility Add TestNewClient_TLS_EnvVars to test the original TLS functionality using environment variables (DOCKER_TLS_VERIFY, DOCKER_CERT_PATH) without Docker context. This ensures backward compatibility with the pre-context TLS configuration method and addresses Matej's feedback to test the 'old' functionality. The test: - Creates a TLS-enabled mock Docker daemon - Sets up TLS certificates in a directory - Configures TLS via environment variables (not context) - Verifies successful TLS connection and client cert authentication All tests pass including the new env vars test. --- pkg/docker/docker_client_test.go | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/pkg/docker/docker_client_test.go b/pkg/docker/docker_client_test.go index fd3e9fd2f3..4afc6f477c 100644 --- a/pkg/docker/docker_client_test.go +++ b/pkg/docker/docker_client_test.go @@ -549,3 +549,66 @@ func TestNewClient_DockerContextTLS_FallbackPath(t *testing.T) { t.Errorf("unexpected server ID: got %q, want %q", nfo.Info.ID, "mock-daemon") } } + +// TestNewClient_TLS_EnvVars tests the original TLS functionality using environment +// variables (DOCKER_TLS_VERIFY, DOCKER_CERT_PATH) without Docker context. +// This ensures backward compatibility with the pre-context TLS configuration method. +func TestNewClient_TLS_EnvVars(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping TLS env vars test on Windows") + } + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) + defer cancel() + + tmpDir := t.TempDir() + + // Start a mock TLS daemon + tlsListener, caCert, clientCert, clientKey := startMockTLSDaemon(t) + tlsHost := fmt.Sprintf("tcp://%s", tlsListener.Addr().String()) + + // Create a directory for TLS certificates (simulating DOCKER_CERT_PATH) + certDir := filepath.Join(tmpDir, "docker-certs") + if err := os.MkdirAll(certDir, 0o755); err != nil { + t.Fatal(err) + } + + // Write TLS certificates to the cert directory + if err := os.WriteFile(filepath.Join(certDir, "ca.pem"), caCert, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "cert.pem"), clientCert, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "key.pem"), clientKey, 0o600); err != nil { + t.Fatal(err) + } + + // Set environment variables for TLS (the "old" way, pre-context) + t.Setenv("DOCKER_HOST", tlsHost) + t.Setenv("DOCKER_TLS_VERIFY", "1") + t.Setenv("DOCKER_CERT_PATH", certDir) + + // Create Docker client - should use env vars for TLS, not context + dockerClient, _, err := docker.NewClient(client.DefaultDockerHost) + if err != nil { + t.Fatalf("Failed to create Docker client with TLS env vars: %v", err) + } + defer dockerClient.Close() + + // Verify we can connect to the TLS-enabled mock daemon + // This proves that TLS configuration from env vars is working + _, err = dockerClient.Ping(ctx, client.PingOptions{}) + if err != nil { + t.Fatalf("Failed to ping TLS-enabled mock daemon (env vars): %v", err) + } + + // Verify we're actually talking to our mock daemon + nfo, err := dockerClient.Info(ctx, client.InfoOptions{}) + if err != nil { + t.Fatalf("Failed to get info from TLS mock daemon (env vars): %v", err) + } + if nfo.Info.ID != "mock-daemon" { + t.Errorf("unexpected server ID: got %q, want %q", nfo.Info.ID, "mock-daemon") + } +}