From fee4c09e68b9a7d1df6238912eec59d00a0ebe6a Mon Sep 17 00:00:00 2001 From: Dan Hollis <27447239+dan-hollis@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:19:19 -0500 Subject: [PATCH] feat: add support for docker-socket-proxy --- internal/config/config.go | 10 ++++-- internal/docker/client.go | 72 +++++++++++++++++++++++++++++++------- internal/docker/compose.go | 22 +++++++++--- internal/edge/client.go | 14 ++++---- internal/edge/tunnel.go | 6 ++-- internal/server/http.go | 14 ++++---- 6 files changed, 100 insertions(+), 38 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fdd6544..2fd6da6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,8 +131,14 @@ func (c *Config) validate() error { return fmt.Errorf("PORT must be between 1 and 65535") } - // Validate docker socket exists (if using socket) - if c.DockerHost == "" { + // Validate Docker connection + if c.DockerHost != "" { + // TCP mode: validate the host URL format (tcp:// is the Docker convention) + if !strings.HasPrefix(c.DockerHost, "tcp://") { + return fmt.Errorf("DOCKER_HOST must start with tcp://, got %s", c.DockerHost) + } + } else { + // Unix socket mode: validate the socket file exists if _, err := os.Stat(c.DockerSocket); os.IsNotExist(err) { return fmt.Errorf("Docker socket not found at %s", c.DockerSocket) } diff --git a/internal/docker/client.go b/internal/docker/client.go index ecee878..f2c6dc7 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -15,7 +15,8 @@ import ( // Client wraps Docker API operations type Client struct { - socketPath string + socketPath string // Unix socket path (used when dockerHost is empty) + dockerHost string // TCP host (e.g., "tcp://socket-proxy:2375") httpClient *http.Client streamClient *http.Client // Separate client for streaming (no timeout) apiVersion string @@ -29,28 +30,72 @@ func (c *Client) GetAPIVersion() string { return c.apiVersion } -// GetSocketPath returns the Docker socket path for raw connections +// GetSocketPath returns the Docker socket path for raw connections. +// Returns empty string when using a TCP host. func (c *Client) GetSocketPath() string { return c.socketPath } -// NewClient creates a new Docker client +// GetDockerHost returns the TCP Docker host (e.g., "tcp://socket-proxy:2375"). +// Returns empty string when using a Unix socket. +func (c *Client) GetDockerHost() string { + return c.dockerHost +} + +// IsTCP returns true if the client is connected via TCP rather than a Unix socket. +func (c *Client) IsTCP() bool { + return c.dockerHost != "" +} + +// DialDocker opens a raw connection to the Docker daemon. +// Uses TCP when DockerHost is configured, Unix socket otherwise. +func (c *Client) DialDocker() (net.Conn, error) { + if c.dockerHost != "" { + return net.Dial("tcp", parseTCPAddr(c.dockerHost)) + } + return net.Dial("unix", c.socketPath) +} + +// parseTCPAddr strips the tcp:// prefix from a Docker host URL, +// returning a bare host:port suitable for net.Dial("tcp", ...). +func parseTCPAddr(dockerHost string) string { + return strings.TrimPrefix(dockerHost, "tcp://") +} + +// NewClient creates a new Docker client connected via Unix socket. +// For TCP connections, use NewClientWithHost instead. func NewClient(socketPath string) (*Client, error) { - // Create HTTP transport for Unix socket - transport := &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return NewClientWithHost(socketPath, "") +} + +// NewClientWithHost creates a new Docker client. +// If dockerHost is set (e.g., "tcp://socket-proxy:2375"), it connects via TCP. +// Otherwise, it connects via the Unix socket at socketPath. +func NewClientWithHost(socketPath, dockerHost string) (*Client, error) { + var dialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + + if dockerHost != "" { + tcpAddr := parseTCPAddr(dockerHost) + dialFunc = func(ctx context.Context, network, addr string) (net.Conn, error) { + return net.Dial("tcp", tcpAddr) + } + log.Infof("Using TCP Docker connection: %s", dockerHost) + } else { + dialFunc = func(ctx context.Context, network, addr string) (net.Conn, error) { return net.Dial("unix", socketPath) - }, + } + } + + transport := &http.Transport{ + DialContext: dialFunc, MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, } - // Create streaming transport (same settings, reused for all streaming requests) + // Create streaming transport (same dial, no idle timeout) streamTransport := &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return net.Dial("unix", socketPath) - }, + DialContext: dialFunc, MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 0, // No idle timeout for streaming connections @@ -58,6 +103,7 @@ func NewClient(socketPath string) (*Client, error) { client := &Client{ socketPath: socketPath, + dockerHost: dockerHost, httpClient: &http.Client{ Transport: transport, Timeout: 30 * time.Second, @@ -306,8 +352,8 @@ type HijackedConn struct { // StartExecAttach starts an exec instance and returns a hijacked connection func (c *Client) StartExecAttach(ctx context.Context, execID string) (*HijackedConn, error) { - // Connect directly to the Unix socket - conn, err := net.Dial("unix", c.socketPath) + // Connect directly to Docker daemon (Unix socket or TCP) + conn, err := c.DialDocker() if err != nil { return nil, fmt.Errorf("failed to connect to Docker socket: %w", err) } diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 1ea69d2..81b50d5 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -41,6 +41,7 @@ var deniedEnvKeys = map[string]bool{ // ComposeClient handles Docker Compose operations type ComposeClient struct { dockerSocket string + dockerHost string // TCP host (e.g., "tcp://socket-proxy:2375"); empty means use Unix socket composeCmd string // "docker" for v2, "docker-compose" for v1 composeArgs []string // ["compose"] for v2, [] for v1 composeChecked bool @@ -48,14 +49,25 @@ type ComposeClient struct { stacksDir string // Base directory for stack files } -// NewComposeClient creates a new Compose client -func NewComposeClient(dockerSocket, stacksDir string) *ComposeClient { +// NewComposeClient creates a new Compose client. +// If dockerHost is non-empty, subprocess DOCKER_HOST is set to the TCP address; +// otherwise it is set to unix://. +func NewComposeClient(dockerSocket, dockerHost, stacksDir string) *ComposeClient { return &ComposeClient{ dockerSocket: dockerSocket, + dockerHost: dockerHost, stacksDir: stacksDir, } } +// dockerHostEnv returns the DOCKER_HOST environment variable value for subprocesses. +func (c *ComposeClient) dockerHostEnv() string { + if c.dockerHost != "" { + return fmt.Sprintf("DOCKER_HOST=%s", c.dockerHost) + } + return fmt.Sprintf("DOCKER_HOST=unix://%s", c.dockerSocket) +} + // SetAPIVersion sets the Docker API version to use for compose commands. // This enables compatibility when the docker CLI version differs from the daemon. func (c *ComposeClient) SetAPIVersion(version string) { @@ -156,7 +168,7 @@ func (c *ComposeClient) loginToRegistries(ctx context.Context, registries []Regi log.Debugf("Compose: Logging into registry %s", registryHost) cmd := exec.CommandContext(ctx, "docker", "login", "-u", reg.Username, "--password-stdin", registryHost) - cmd.Env = append(os.Environ(), fmt.Sprintf("DOCKER_HOST=unix://%s", c.dockerSocket)) + cmd.Env = append(os.Environ(), c.dockerHostEnv()) cmd.Stdin = strings.NewReader(reg.Password) var stderr bytes.Buffer @@ -402,8 +414,8 @@ func (c *ComposeClient) Execute(ctx context.Context, op *ComposeOperation) (*Com cmd.Dir = op.WorkDir } - // Set Docker socket environment - cmd.Env = append(os.Environ(), fmt.Sprintf("DOCKER_HOST=unix://%s", c.dockerSocket)) + // Set Docker host environment (TCP or Unix socket) + cmd.Env = append(os.Environ(), c.dockerHostEnv()) // Set API version for compatibility with newer Docker daemons // This allows older docker CLI to work with newer daemons diff --git a/internal/edge/client.go b/internal/edge/client.go index a8d3acb..579e2a0 100644 --- a/internal/edge/client.go +++ b/internal/edge/client.go @@ -12,7 +12,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "os" "regexp" @@ -74,8 +73,8 @@ type StreamContext struct { // Run starts the Edge mode client with auto-reconnect func Run(cfg *config.Config, stop <-chan os.Signal) error { - // Create Docker client - dockerClient, err := docker.NewClient(cfg.DockerSocket) + // Create Docker client (TCP if DOCKER_HOST is set, Unix socket otherwise) + dockerClient, err := docker.NewClientWithHost(cfg.DockerSocket, cfg.DockerHost) if err != nil { return fmt.Errorf("failed to create Docker client: %w", err) } @@ -90,7 +89,7 @@ func Run(cfg *config.Config, stop <-chan os.Signal) error { } // Create compose client with API version negotiation - composeClient := docker.NewComposeClient(cfg.DockerSocket, cfg.StacksDir) + composeClient := docker.NewComposeClient(cfg.DockerSocket, cfg.DockerHost, cfg.StacksDir) if version != nil && version.APIVersion != "" { composeClient.SetAPIVersion(version.APIVersion) log.Debugf("Compose client using API version %s", version.APIVersion) @@ -753,13 +752,12 @@ func (c *Client) eventsLoop(done <-chan struct{}) { // Uses raw socket instead of http.Client to avoid connection pooling issues // that cause immediate EOF on Docker 29+ (see Finsys/dockhand#126). func (c *Client) streamEvents(done <-chan struct{}) error { - socketPath := c.dockerClient.GetSocketPath() apiVersion := c.dockerClient.GetAPIVersion() - // Open a dedicated Unix socket connection (not pooled) - conn, err := net.Dial("unix", socketPath) + // Open a dedicated connection to Docker daemon (not pooled) + conn, err := c.dockerClient.DialDocker() if err != nil { - return fmt.Errorf("failed to connect to Docker socket: %w", err) + return fmt.Errorf("failed to connect to Docker: %w", err) } defer conn.Close() diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go index 4bb98be..a7876a3 100644 --- a/internal/edge/tunnel.go +++ b/internal/edge/tunnel.go @@ -112,10 +112,10 @@ func (c *Client) createExec(ctx context.Context, req *ExecRequest) (string, erro // Start begins the exec session with hijacking func (t *ExecTunnel) Start(ctx context.Context, tty bool) error { - // Connect to Docker socket for hijacked connection - conn, err := net.Dial("unix", t.client.cfg.DockerSocket) + // Connect to Docker daemon for hijacked connection (Unix socket or TCP) + conn, err := t.client.dockerClient.DialDocker() if err != nil { - return fmt.Errorf("failed to connect to Docker socket: %w", err) + return fmt.Errorf("failed to connect to Docker: %w", err) } t.conn = conn diff --git a/internal/server/http.go b/internal/server/http.go index b7e087c..7c29ef9 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -32,8 +32,8 @@ type Server struct { // Run starts the Standard mode HTTP server func Run(cfg *config.Config, stop <-chan os.Signal) error { - // Create Docker client - dockerClient, err := docker.NewClient(cfg.DockerSocket) + // Create Docker client (TCP if DOCKER_HOST is set, Unix socket otherwise) + dockerClient, err := docker.NewClientWithHost(cfg.DockerSocket, cfg.DockerHost) if err != nil { return fmt.Errorf("failed to create Docker client: %w", err) } @@ -48,7 +48,7 @@ func Run(cfg *config.Config, stop <-chan os.Signal) error { } // Create compose client with API version negotiation - composeClient := docker.NewComposeClient(cfg.DockerSocket, cfg.StacksDir) + composeClient := docker.NewComposeClient(cfg.DockerSocket, cfg.DockerHost, cfg.StacksDir) if version != nil && version.APIVersion != "" { composeClient.SetAPIVersion(version.APIVersion) log.Debugf("Compose client using API version %s", version.APIVersion) @@ -215,8 +215,8 @@ func (s *Server) handleExecHijack(w http.ResponseWriter, r *http.Request) { return } - // Open raw connection to Docker socket - dockerConn, err := net.Dial("unix", s.cfg.DockerSocket) + // Open raw connection to Docker daemon (Unix socket or TCP) + dockerConn, err := s.dockerClient.DialDocker() if err != nil { http.Error(w, "Failed to connect to Docker: "+err.Error(), http.StatusBadGateway) return @@ -354,8 +354,8 @@ func (s *Server) handleExecHijack(w http.ResponseWriter, r *http.Request) { // Uses http.ReadResponse to correctly handle chunked transfer encoding from Docker func (s *Server) handleEventsStream(w http.ResponseWriter, r *http.Request) { - // Open raw connection to Docker socket - dockerConn, err := net.Dial("unix", s.cfg.DockerSocket) + // Open raw connection to Docker daemon (Unix socket or TCP) + dockerConn, err := s.dockerClient.DialDocker() if err != nil { http.Error(w, "Failed to connect to Docker: "+err.Error(), http.StatusBadGateway) return