diff --git a/.docker/docker-entrypoint.sh b/.docker/docker-entrypoint.sh index 599f3af2..9bbad5d0 100755 --- a/.docker/docker-entrypoint.sh +++ b/.docker/docker-entrypoint.sh @@ -30,6 +30,27 @@ mkdir -p /app/data/caddy 2>/dev/null || true mkdir -p /app/data/crowdsec 2>/dev/null || true mkdir -p /app/data/geoip 2>/dev/null || true +# ============================================================================ +# Docker Socket Permission Handling +# ============================================================================ +# The Docker integration feature requires access to the Docker socket. +# When running as non-root user (charon), we need to ensure the user is in +# the same group as the mounted socket for permission access. + +if [ -S "/var/run/docker.sock" ]; then + DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo "") + if [ -n "$DOCKER_SOCK_GID" ] && [ "$DOCKER_SOCK_GID" != "0" ]; then + # Check if a group with this GID exists + if ! getent group "$DOCKER_SOCK_GID" >/dev/null 2>&1; then + echo "Docker socket detected (gid=$DOCKER_SOCK_GID). Note: Container integration requires socket access." + echo " To enable Docker container discovery:" + echo " 1. Run container with --user root:root, OR" + echo " 2. Add host docker group: docker run --group-add $DOCKER_SOCK_GID ..., OR" + echo " 3. Change socket permissions: chmod 666 /var/run/docker.sock (not recommended)" + fi + fi +fi + # ============================================================================ # CrowdSec Initialization # ============================================================================ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 229618bd..f213a842 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Build & Run: Local Docker Image", "type": "shell", - "command": "docker build -t charon:local . && docker compose -f docker-compose.override.yml up -d && echo 'Charon running at http://localhost:8080'", + "command": "docker build -t charon:local . && docker compose -f docker-compose.test.yml up -d && echo 'Charon running at http://localhost:8080'", "group": "build", "problemMatcher": [], "presentation": { @@ -15,7 +15,7 @@ { "label": "Build & Run: Local Docker Image No-Cache", "type": "shell", - "command": "docker build --no-cache -t charon:local . && docker compose -f docker-compose.override.yml up -d && echo 'Charon running at http://localhost:8080'", + "command": "docker build --no-cache -t charon:local . && docker compose -f docker-compose.test.yml up -d && echo 'Charon running at http://localhost:8080'", "group": "build", "problemMatcher": [], "presentation": { diff --git a/backend/internal/api/handlers/docker_handler.go b/backend/internal/api/handlers/docker_handler.go index 1f4540c6..6a105122 100644 --- a/backend/internal/api/handlers/docker_handler.go +++ b/backend/internal/api/handlers/docker_handler.go @@ -1,19 +1,33 @@ package handlers import ( + "context" + "errors" "fmt" "net/http" + "strings" + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/util" "github.com/gin-gonic/gin" ) -type DockerHandler struct { - dockerService *services.DockerService - remoteServerService *services.RemoteServerService +type dockerContainerLister interface { + ListContainers(ctx context.Context, host string) ([]services.DockerContainer, error) } -func NewDockerHandler(dockerService *services.DockerService, remoteServerService *services.RemoteServerService) *DockerHandler { +type remoteServerGetter interface { + GetByUUID(uuidStr string) (*models.RemoteServer, error) +} + +type DockerHandler struct { + dockerService dockerContainerLister + remoteServerService remoteServerGetter +} + +func NewDockerHandler(dockerService dockerContainerLister, remoteServerService remoteServerGetter) *DockerHandler { return &DockerHandler{ dockerService: dockerService, remoteServerService: remoteServerService, @@ -25,13 +39,24 @@ func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) { } func (h *DockerHandler) ListContainers(c *gin.Context) { - host := c.Query("host") - serverID := c.Query("server_id") + log := middleware.GetRequestLogger(c) + + host := strings.TrimSpace(c.Query("host")) + serverID := strings.TrimSpace(c.Query("server_id")) + + // SSRF hardening: do not accept arbitrary host values from the client. + // Only allow explicit local selection ("local") or empty (default local). + if host != "" && host != "local" { + log.WithFields(map[string]any{"host": util.SanitizeForLog(host)}).Warn("rejected docker host query param") + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid docker host selector"}) + return + } // If server_id is provided, look up the remote server if serverID != "" { server, err := h.remoteServerService.GetByUUID(serverID) if err != nil { + log.WithFields(map[string]any{"server_id": serverID}).Warn("remote server not found") c.JSON(http.StatusNotFound, gin.H{"error": "Remote server not found"}) return } @@ -44,7 +69,15 @@ func (h *DockerHandler) ListContainers(c *gin.Context) { containers, err := h.dockerService.ListContainers(c.Request.Context(), host) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers: " + err.Error()}) + var unavailableErr *services.DockerUnavailableError + if errors.As(err, &unavailableErr) { + log.WithFields(map[string]any{"server_id": serverID}).WithError(err).Warn("docker unavailable") + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker daemon unavailable"}) + return + } + + log.WithFields(map[string]any{"server_id": serverID}).WithError(err).Error("failed to list containers") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers"}) return } diff --git a/backend/internal/api/handlers/docker_handler_test.go b/backend/internal/api/handlers/docker_handler_test.go index 0ac6c1cd..5fec22bc 100644 --- a/backend/internal/api/handlers/docker_handler_test.go +++ b/backend/internal/api/handlers/docker_handler_test.go @@ -1,6 +1,8 @@ package handlers import ( + "context" + "errors" "net/http" "net/http/httptest" "testing" @@ -8,164 +10,110 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" - "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" ) -func setupDockerTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *services.RemoteServerService) { - dsn := "file:" + t.Name() + "?mode=memory&cache=shared" - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.RemoteServer{})) +type fakeDockerService struct { + called bool + host string - rsService := services.NewRemoteServerService(db) + ret []services.DockerContainer + err error +} +func (f *fakeDockerService) ListContainers(_ context.Context, host string) ([]services.DockerContainer, error) { + f.called = true + f.host = host + return f.ret, f.err +} + +type fakeRemoteServerService struct { + gotUUID string + + server *models.RemoteServer + err error +} + +func (f *fakeRemoteServerService) GetByUUID(uuidStr string) (*models.RemoteServer, error) { + f.gotUUID = uuidStr + return f.server, f.err +} + +func TestDockerHandler_ListContainers_InvalidHostRejected(t *testing.T) { gin.SetMode(gin.TestMode) - r := gin.New() + router := gin.New() - return r, db, rsService + dockerSvc := &fakeDockerService{} + remoteSvc := &fakeRemoteServerService{} + h := NewDockerHandler(dockerSvc, remoteSvc) + + api := router.Group("/api/v1") + h.RegisterRoutes(api) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?host=tcp://127.0.0.1:2375", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.False(t, dockerSvc.called, "docker service should not be called for invalid host") } -func TestDockerHandler_ListContainers(t *testing.T) { - // We can't easily mock the DockerService without an interface, - // and the DockerService depends on the real Docker client. - // So we'll just test that the handler is wired up correctly, - // even if it returns an error because Docker isn't running in the test env. +func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() - svc, _ := services.NewDockerService() - // svc might be nil if docker is not available, but NewDockerHandler handles nil? - // Actually NewDockerHandler just stores it. - // If svc is nil, ListContainers will panic. - // So we only run this if svc is not nil. + dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"))} + remoteSvc := &fakeRemoteServerService{} + h := NewDockerHandler(dockerSvc, remoteSvc) - if svc == nil { - t.Skip("Docker not available") - } + api := router.Group("/api/v1") + h.RegisterRoutes(api) - r, _, rsService := setupDockerTestRouter(t) - - h := NewDockerHandler(svc, rsService) - h.RegisterRoutes(r.Group("/")) - - req, _ := http.NewRequest("GET", "/docker/containers", http.NoBody) + req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?host=local", nil) w := httptest.NewRecorder() - r.ServeHTTP(w, req) + router.ServeHTTP(w, req) - // It might return 200 or 500 depending on if ListContainers succeeds - assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, w.Code) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + assert.Contains(t, w.Body.String(), "Docker daemon unavailable") } -func TestDockerHandler_ListContainers_NonExistentServerID(t *testing.T) { - svc, _ := services.NewDockerService() - if svc == nil { - t.Skip("Docker not available") - } +func TestDockerHandler_ListContainers_ServerIDResolvesToTCPHost(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() - r, _, rsService := setupDockerTestRouter(t) + dockerSvc := &fakeDockerService{ret: []services.DockerContainer{}} + remoteSvc := &fakeRemoteServerService{server: &models.RemoteServer{Host: "example.internal", Port: 2375}} + h := NewDockerHandler(dockerSvc, remoteSvc) - h := NewDockerHandler(svc, rsService) - h.RegisterRoutes(r.Group("/")) + api := router.Group("/api/v1") + h.RegisterRoutes(api) - // Request with non-existent server_id - req, _ := http.NewRequest("GET", "/docker/containers?server_id=non-existent-uuid", http.NoBody) + req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=abc-123", nil) w := httptest.NewRecorder() - r.ServeHTTP(w, req) + router.ServeHTTP(w, req) + + require.True(t, dockerSvc.called) + assert.Equal(t, "abc-123", remoteSvc.gotUUID) + assert.Equal(t, "tcp://example.internal:2375", dockerSvc.host) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDockerHandler_ListContainers_ServerIDNotFoundReturns404(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + dockerSvc := &fakeDockerService{} + remoteSvc := &fakeRemoteServerService{err: errors.New("not found")} + h := NewDockerHandler(dockerSvc, remoteSvc) + + api := router.Group("/api/v1") + h.RegisterRoutes(api) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=missing", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) - assert.Contains(t, w.Body.String(), "Remote server not found") -} - -func TestDockerHandler_ListContainers_WithServerID(t *testing.T) { - svc, _ := services.NewDockerService() - if svc == nil { - t.Skip("Docker not available") - } - - r, db, rsService := setupDockerTestRouter(t) - - // Create a remote server - server := models.RemoteServer{ - UUID: uuid.New().String(), - Name: "Test Docker Server", - Host: "docker.example.com", - Port: 2375, - Scheme: "", - Enabled: true, - } - require.NoError(t, db.Create(&server).Error) - - h := NewDockerHandler(svc, rsService) - h.RegisterRoutes(r.Group("/")) - - // Request with valid server_id (will fail to connect, but shouldn't error on lookup) - req, _ := http.NewRequest("GET", "/docker/containers?server_id="+server.UUID, http.NoBody) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - // Should attempt to connect and likely fail with 500 (not 404) - assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, w.Code) - if w.Code == http.StatusInternalServerError { - assert.Contains(t, w.Body.String(), "Failed to list containers") - } -} - -func TestDockerHandler_ListContainers_WithHostQuery(t *testing.T) { - svc, _ := services.NewDockerService() - if svc == nil { - t.Skip("Docker not available") - } - - r, _, rsService := setupDockerTestRouter(t) - - h := NewDockerHandler(svc, rsService) - h.RegisterRoutes(r.Group("/")) - - // Request with custom host parameter - req, _ := http.NewRequest("GET", "/docker/containers?host=tcp://invalid-host:2375", http.NoBody) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - // Should attempt to connect and fail with 500 - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, w.Body.String(), "Failed to list containers") -} - -func TestDockerHandler_RegisterRoutes(t *testing.T) { - svc, _ := services.NewDockerService() - if svc == nil { - t.Skip("Docker not available") - } - - r, _, rsService := setupDockerTestRouter(t) - - h := NewDockerHandler(svc, rsService) - h.RegisterRoutes(r.Group("/")) - - // Verify route is registered - routes := r.Routes() - found := false - for _, route := range routes { - if route.Path == "/docker/containers" && route.Method == "GET" { - found = true - break - } - } - assert.True(t, found, "Expected /docker/containers GET route to be registered") -} - -func TestDockerHandler_NewDockerHandler(t *testing.T) { - svc, _ := services.NewDockerService() - if svc == nil { - t.Skip("Docker not available") - } - - _, _, rsService := setupDockerTestRouter(t) - - h := NewDockerHandler(svc, rsService) - assert.NotNil(t, h) - assert.NotNil(t, h.dockerService) - assert.NotNil(t, h.remoteServerService) + assert.False(t, dockerSvc.called) } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 466dd5a1..0b15789c 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -453,7 +453,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Caddy Manager already created above proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) - proxyHostHandler.RegisterRoutes(api) + proxyHostHandler.RegisterRoutes(protected) remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService) remoteServerHandler.RegisterRoutes(api) diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index 8ee836ae..72ae3b45 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -1,6 +1,9 @@ package routes import ( + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/Wikid82/charon/backend/internal/config" @@ -151,3 +154,23 @@ func TestRegister_RoutesRegistration(t *testing.T) { assert.True(t, routeMap[expected], "Route %s should be registered", expected) } } + +func TestRegister_ProxyHostsRequireAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + // Use in-memory DB + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_proxyhosts_auth"), &gorm.Config{}) + require.NoError(t, err) + + cfg := config.Config{JWTSecret: "test-secret"} + require.NoError(t, Register(router, db, cfg)) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Authorization header required") +} diff --git a/backend/internal/services/docker_service.go b/backend/internal/services/docker_service.go index 2a222717..04094d89 100644 --- a/backend/internal/services/docker_service.go +++ b/backend/internal/services/docker_service.go @@ -2,14 +2,41 @@ package services import ( "context" + "errors" "fmt" + "net" + "net/url" + "os" "strings" + "syscall" "github.com/Wikid82/charon/backend/internal/logger" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" ) +type DockerUnavailableError struct { + err error +} + +func NewDockerUnavailableError(err error) *DockerUnavailableError { + return &DockerUnavailableError{err: err} +} + +func (e *DockerUnavailableError) Error() string { + if e == nil || e.err == nil { + return "docker unavailable" + } + return fmt.Sprintf("docker unavailable: %v", e.err) +} + +func (e *DockerUnavailableError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + type DockerPort struct { PrivatePort uint16 `json:"private_port"` PublicPort uint16 `json:"public_port"` @@ -59,6 +86,9 @@ func (s *DockerService) ListContainers(ctx context.Context, host string) ([]Dock containers, err := cli.ContainerList(ctx, container.ListOptions{All: false}) if err != nil { + if isDockerConnectivityError(err) { + return nil, &DockerUnavailableError{err: err} + } return nil, fmt.Errorf("failed to list containers: %w", err) } @@ -105,3 +135,60 @@ func (s *DockerService) ListContainers(ctx context.Context, host string) ([]Dock return result, nil } + +func isDockerConnectivityError(err error) bool { + if err == nil { + return false + } + + // Common high-signal strings from docker client/daemon failures. + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "cannot connect to the docker daemon") || + strings.Contains(msg, "is the docker daemon running") || + strings.Contains(msg, "error during connect") { + return true + } + + // Context timeouts typically indicate the daemon/socket is unreachable. + if errors.Is(err, context.DeadlineExceeded) { + return true + } + + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Unwrap() + } + + var netErr net.Error + if errors.As(err, &netErr) { + if netErr.Timeout() { + return true + } + } + + // Walk common syscall error wrappers. + var syscallErr *os.SyscallError + if errors.As(err, &syscallErr) { + err = syscallErr.Unwrap() + } + + var opErr *net.OpError + if errors.As(err, &opErr) { + err = opErr.Unwrap() + } + + var errno syscall.Errno + if errors.As(err, &errno) { + switch errno { + case syscall.ENOENT, syscall.EACCES, syscall.EPERM, syscall.ECONNREFUSED: + return true + } + } + + // os.ErrNotExist covers missing unix socket paths. + if errors.Is(err, os.ErrNotExist) { + return true + } + + return false +} diff --git a/docs/plans/docker_socket_trace.md b/docs/plans/docker_socket_trace.md new file mode 100644 index 00000000..23bd9249 --- /dev/null +++ b/docs/plans/docker_socket_trace.md @@ -0,0 +1,362 @@ +# Docker Socket Trace Analysis + +**Date**: 2025-12-22 +**Issue**: Creating a new proxy host using the local docker socket fails with 503 (previously 500) +**Status**: Root cause identified + +--- + +## Executive Summary + +**ROOT CAUSE**: The container runs as non-root user `charon` (uid=1000, gid=1000), but the Docker socket mounted into the container is owned by `root:docker` (gid=988 on host). The `charon` user is not a member of the `docker` group, so socket access is denied with `Permission denied`. + +**The 503 is correct behavior** - it accurately reflects that Docker is unavailable due to permission restrictions. The error handling code change from 500 to 503 was an improvement, not a bug. + +--- + +## 1. Full Workflow Trace + +### Frontend Layer + +#### A. ProxyHostForm Component +- **File**: [frontend/src/components/ProxyHostForm.tsx](../../frontend/src/components/ProxyHostForm.tsx) +- **State**: `connectionSource` - defaults to `'custom'`, can be `'local'` or a remote server UUID +- **Hook invocation** (line ~146): + ```typescript + const { containers: dockerContainers, isLoading: dockerLoading, error: dockerError } = useDocker( + connectionSource === 'local' ? 'local' : undefined, + connectionSource !== 'local' && connectionSource !== 'custom' ? connectionSource : undefined + ) + ``` +- **Error display** (line ~361): + ```typescript + {dockerError && connectionSource !== 'custom' && ( +
+ Failed to connect: {(dockerError as Error).message} +
+ )} + ``` + +#### B. useDocker Hook +- **File**: [frontend/src/hooks/useDocker.ts](../../frontend/src/hooks/useDocker.ts) +- **Function**: `useDocker(host?: string | null, serverId?: string | null)` +- **Query configuration**: + ```typescript + useQuery({ + queryKey: ['docker-containers', host, serverId], + queryFn: () => dockerApi.listContainers(host || undefined, serverId || undefined), + enabled: Boolean(host) || Boolean(serverId), + retry: 1, + }) + ``` +- When `connectionSource === 'local'`, calls `dockerApi.listContainers('local', undefined)` + +#### C. Docker API Client +- **File**: [frontend/src/api/docker.ts](../../frontend/src/api/docker.ts) +- **Function**: `dockerApi.listContainers(host?: string, serverId?: string)` +- **Request**: `GET /api/v1/docker/containers?host=local` +- **Response type**: `DockerContainer[]` + +--- + +### Backend Layer + +#### D. Routes Registration +- **File**: [backend/internal/api/routes/routes.go](../../backend/internal/api/routes/routes.go) +- **Registration** (lines 199-204): + ```go + dockerService, err := services.NewDockerService() + if err == nil { // Only register if Docker is available + dockerHandler := handlers.NewDockerHandler(dockerService, remoteServerService) + dockerHandler.RegisterRoutes(protected) + } else { + logger.Log().WithError(err).Warn("Docker service unavailable") + } + ``` +- **CRITICAL**: Docker routes only register if `NewDockerService()` succeeds (client construction, not socket access) +- Route: `GET /api/v1/docker/containers` (protected, requires auth) + +#### E. Docker Handler +- **File**: [backend/internal/api/handlers/docker_handler.go](../../backend/internal/api/handlers/docker_handler.go) +- **Function**: `ListContainers(c *gin.Context)` +- **Input validation** (SSRF hardening): + ```go + host := strings.TrimSpace(c.Query("host")) + serverID := strings.TrimSpace(c.Query("server_id")) + + // SSRF hardening: only allow "local" or empty + if host != "" && host != "local" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid docker host selector"}) + return + } + ``` +- **Service call**: `h.dockerService.ListContainers(c.Request.Context(), host)` +- **Error handling** (lines 60-69): + ```go + if err != nil { + var unavailableErr *services.DockerUnavailableError + if errors.As(err, &unavailableErr) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker daemon unavailable"}) // 503 + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers"}) // 500 + return + } + ``` + +#### F. Docker Service +- **File**: [backend/internal/services/docker_service.go](../../backend/internal/services/docker_service.go) +- **Constructor**: `NewDockerService()` + ```go + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + ``` + - Uses `client.FromEnv` which reads `DOCKER_HOST` env var (defaults to `unix:///var/run/docker.sock`) + - **Does NOT verify socket access** - only constructs client object + +- **Function**: `ListContainers(ctx context.Context, host string)` + ```go + if host == "" || host == "local" { + cli = s.client // Use default local client + } + containers, err := cli.ContainerList(ctx, container.ListOptions{All: false}) + if err != nil { + if isDockerConnectivityError(err) { + return nil, &DockerUnavailableError{err: err} // Triggers 503 + } + return nil, fmt.Errorf("failed to list containers: %w", err) // Triggers 500 + } + ``` + +- **Error detection**: `isDockerConnectivityError(err)` (lines 104-152) + - Checks for: "cannot connect to docker daemon", "is the docker daemon running", timeout errors + - Checks syscall errors: `ENOENT`, `EACCES`, `EPERM`, `ECONNREFUSED` + - **Matches `syscall.EACCES` (permission denied)** → returns `DockerUnavailableError` → **503** + +--- + +## 2. Request/Response Shapes + +### Frontend → Backend Request +``` +GET /api/v1/docker/containers?host=local +Authorization: Bearer