Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
72 changes: 59 additions & 13 deletions internal/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,35 +30,80 @@ 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
}

client := &Client{
socketPath: socketPath,
dockerHost: dockerHost,
httpClient: &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
Expand Down Expand Up @@ -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)
}
Expand Down
22 changes: 17 additions & 5 deletions internal/docker/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,33 @@ 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
apiVersion string // Docker API version to use (for version negotiation)
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://<dockerSocket>.
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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 6 additions & 8 deletions internal/edge/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"regexp"
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
6 changes: 3 additions & 3 deletions internal/edge/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 7 additions & 7 deletions internal/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down