diff --git a/pkg/docker/docker_client.go b/pkg/docker/docker_client.go index 3cd04896c8..ce64565dd4 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. @@ -78,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") @@ -96,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 } } } @@ -166,7 +179,9 @@ func NewClient(defaultHost string) (dc DockerClient, dockerHostInRemote string, if !isSSH { opts := []client.Opt{client.FromEnv, client.WithHost(dockerHost)} if isTCP { - 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)) } } @@ -209,53 +224,111 @@ 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 { +// 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 - } + if tlsVerifyChanged { + // Environment variables are set - use them, ignore context + var tlsOpts []func(*tls.Config) - 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 + }) + } + + 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 { + certPool := x509.NewCertPool() + if certPool.AppendCertsFromPEM(caData) { + tlsOpts = append(tlsOpts, func(t *tls.Config) { + t.RootCAs = certPool + }) + } + } + + // 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} + }) + } + } + + dialer := &net.Dialer{ + KeepAlive: 30 * time.Second, + Timeout: 30 * time.Second, + } - tlsVerify := true - if b, err := strconv.ParseBool(tlsVerifyStr); err == nil { - tlsVerify = b + tlsConfig := tlsconfig.ClientDefault(tlsOpts...) + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + DialContext: dialer.DialContext, + }, + CheckRedirect: client.CheckRedirect, + } } - if !tlsVerify { + // 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 { + var tlsOpts []func(*tls.Config) + + if contextConfig.SkipTLSVerify { tlsOpts = append(tlsOpts, func(t *tls.Config) { t.InsecureSkipVerify = true }) } - 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 { - certPool := x509.NewCertPool() - if certPool.AppendCertsFromPEM(caData) { + // 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 = certPool + t.RootCAs = caCertPool }) } } - // 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} + // 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 { + // 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} }) } } @@ -310,21 +383,23 @@ 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") + + // Note: DOCKER_CONFIG is automatically inherited from parent environment out, err := cmd.CombinedOutput() if err != nil { - return "" + return nil } // Parse the JSON output @@ -332,29 +407,71 @@ 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 } - // Return the host from the first (current) context - if len(contexts) > 0 && contexts[0].Endpoints.Docker.Host != "" { - if contexts[0].Name == "default" { - return "" + // Skip default context + if contexts[0].Name == "default" { + return nil + } + + 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") + } + + // 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", "tls", fmt.Sprintf("%x", hash)) + } + + // 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 contexts[0].Endpoints.Docker.Host } - return "" + return config } -// 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 276282ae7a..4afc6f477c 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,364 @@ 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) + } +} + +// 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") + } +} + +// 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") + } +}