fix: resolve deploy context cancellation, spurious auth warnings, and local CLI auth gate
- Use detached context for container restart so podman operations complete even if the HTTP client disconnects (fixes "context canceled" on restart) - Use WithoutCancel for GetContainerLogs so log streaming outlives the deploy HTTP request (fixes "container log streaming ended with error") - Skip insecure-HTTP warning for localhost registry requests since they are internal proxy traffic that never leaves the machine - Allow local CLI control plane when auth.enabled=true — shell access already implies full server access, so the gate added no security
- commit
- 9cd24cc5bda3bd66b08fb85f1f71fd61293b4b60
- author
- brice · · 2026-03-19 03:45 UTC
- parent
- 05b767c
- signature
- unsigned
5 files · +20 -18
M internal/adapters/in/cli/controlplane_resolver.go +0 -4
| @@ -34,10 +34,6 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) { | |||
| 34 | 34 | if err != nil { | |
| 35 | 35 | return nil, fmt.Errorf("failed to initialize local control plane: %w", err) | |
| 36 | 36 | } | |
| 37 | - | if kernel.AuthEnabled() { | |
| 38 | - | kernel.Close() | |
| 39 | - | return nil, fmt.Errorf("local control plane is disabled when auth.enabled=true; use --remote with token") | |
| 40 | - | } | |
| 41 | 37 | ||
| 42 | 38 | return &controlPlaneHandle{ | |
| 43 | 39 | plane: NewLocalControlPlane(kernel), | |
| @@ -34,10 +34,6 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) { | |||||
| 34 | if err != nil { | 34 | if err != nil { | ||
| 35 | return nil, fmt.Errorf("failed to initialize local control plane: %w", err) | 35 | return nil, fmt.Errorf("failed to initialize local control plane: %w", err) | ||
| 36 | } | 36 | } | ||
| 37 | - | if kernel.AuthEnabled() { | |||
| 38 | - | kernel.Close() | |||
| 39 | - | return nil, fmt.Errorf("local control plane is disabled when auth.enabled=true; use --remote with token") | |||
| 40 | - | } | |||
| 41 | 37 | ||||
| 42 | return &controlPlaneHandle{ | 38 | return &controlPlaneHandle{ | ||
| 43 | plane: NewLocalControlPlane(kernel), | 39 | plane: NewLocalControlPlane(kernel), | ||
M internal/adapters/in/cli/controlplane_resolver_test.go +6 -9
| @@ -3,11 +3,10 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | - | "strings" | |
| 7 | 6 | "testing" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| 10 | - | func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) { | |
| 9 | + | func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) { | |
| 11 | 10 | tmpDir := t.TempDir() | |
| 12 | 11 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | |
| 13 | 12 | t.Setenv("GORDON_REMOTE", "") | |
| @@ -37,15 +36,13 @@ secrets_backend = "unsafe" | |||
| 37 | 36 | } | |
| 38 | 37 | ||
| 39 | 38 | handle, err := resolveControlPlane(configPath) | |
| 40 | - | if err == nil { | |
| 41 | - | if handle != nil { | |
| 42 | - | handle.close() | |
| 43 | - | } | |
| 44 | - | t.Fatalf("expected error when auth.enabled=true and no remote target") | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatalf("expected local control-plane handle, got error: %v", err) | |
| 45 | 41 | } | |
| 46 | - | if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") { | |
| 47 | - | t.Fatalf("unexpected error: %v", err) | |
| 42 | + | if handle == nil || handle.plane == nil { | |
| 43 | + | t.Fatalf("expected non-nil local control-plane handle") | |
| 48 | 44 | } | |
| 45 | + | handle.close() | |
| 49 | 46 | } | |
| 50 | 47 | ||
| 51 | 48 | func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) { | |
| @@ -3,11 +3,10 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "os" | 4 | "os" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | - | "strings" | |||
| 7 | "testing" | 6 | "testing" | ||
| 8 | ) | 7 | ) | ||
| 9 | 8 | ||||
| 10 | - | func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) { | 9 | + | func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) { |
| 11 | tmpDir := t.TempDir() | 10 | tmpDir := t.TempDir() | ||
| 12 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | 11 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | ||
| 13 | t.Setenv("GORDON_REMOTE", "") | 12 | t.Setenv("GORDON_REMOTE", "") | ||
| @@ -37,15 +36,13 @@ secrets_backend = "unsafe" | |||||
| 37 | } | 36 | } | ||
| 38 | 37 | ||||
| 39 | handle, err := resolveControlPlane(configPath) | 38 | handle, err := resolveControlPlane(configPath) | ||
| 40 | - | if err == nil { | 39 | + | if err != nil { |
| 41 | - | if handle != nil { | 40 | + | t.Fatalf("expected local control-plane handle, got error: %v", err) |
| 42 | - | handle.close() | |||
| 43 | - | } | |||
| 44 | - | t.Fatalf("expected error when auth.enabled=true and no remote target") | |||
| 45 | } | 41 | } | ||
| 46 | - | if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") { | 42 | + | if handle == nil || handle.plane == nil { |
| 47 | - | t.Fatalf("unexpected error: %v", err) | 43 | + | t.Fatalf("expected non-nil local control-plane handle") |
| 48 | } | 44 | } | ||
| 45 | + | handle.close() | |||
| 49 | } | 46 | } | ||
| 50 | 47 | ||||
| 51 | func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) { | 48 | func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) { | ||
M internal/adapters/in/http/admin/handler.go +6 -1
| @@ -1325,7 +1325,12 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str | |||
| 1325 | 1325 | ||
| 1326 | 1326 | withAttachments := r.URL.Query().Get("attachments") == "true" | |
| 1327 | 1327 | ||
| 1328 | - | if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil { | |
| 1328 | + | // Use a detached context so the restart completes even if the HTTP client | |
| 1329 | + | // disconnects. Podman restart can take 30+ seconds (SIGTERM + wait + start). | |
| 1330 | + | restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) | |
| 1331 | + | defer cancel() | |
| 1332 | + | ||
| 1333 | + | if err := h.containerSvc.Restart(restartCtx, restartDomain, withAttachments); err != nil { | |
| 1329 | 1334 | log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container") | |
| 1330 | 1335 | if errors.Is(err, domain.ErrContainerNotFound) { | |
| 1331 | 1336 | h.sendError(w, http.StatusNotFound, "container not found") | |
| @@ -1325,7 +1325,12 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str | |||||
| 1325 | 1325 | ||||
| 1326 | withAttachments := r.URL.Query().Get("attachments") == "true" | 1326 | withAttachments := r.URL.Query().Get("attachments") == "true" | ||
| 1327 | 1327 | ||||
| 1328 | - | if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil { | 1328 | + | // Use a detached context so the restart completes even if the HTTP client |
| 1329 | + | // disconnects. Podman restart can take 30+ seconds (SIGTERM + wait + start). | |||
| 1330 | + | restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) | |||
| 1331 | + | defer cancel() | |||
| 1332 | + | ||||
| 1333 | + | if err := h.containerSvc.Restart(restartCtx, restartDomain, withAttachments); err != nil { | |||
| 1329 | log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container") | 1334 | log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container") | ||
| 1330 | if errors.Is(err, domain.ErrContainerNotFound) { | 1335 | if errors.Is(err, domain.ErrContainerNotFound) { | ||
| 1331 | h.sendError(w, http.StatusNotFound, "container not found") | 1336 | h.sendError(w, http.StatusNotFound, "container not found") | ||
M internal/adapters/in/http/middleware/auth.go +2 -2
| @@ -69,8 +69,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 69 | 69 | return | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | - | // Warn if not using TLS | |
| 73 | - | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { | |
| 72 | + | // Warn if not using TLS (skip for localhost — internal proxy traffic) | |
| 73 | + | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httputil.IsLocalhostRequest(r) { | |
| 74 | 74 | log.Warn(). | |
| 75 | 75 | Str(zerowrap.FieldLayer, "adapter"). | |
| 76 | 76 | Str(zerowrap.FieldAdapter, "http"). | |
| @@ -69,8 +69,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 69 | return | 69 | return | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | - | // Warn if not using TLS | 72 | + | // Warn if not using TLS (skip for localhost — internal proxy traffic) |
| 73 | - | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { | 73 | + | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httputil.IsLocalhostRequest(r) { |
| 74 | log.Warn(). | 74 | log.Warn(). | ||
| 75 | Str(zerowrap.FieldLayer, "adapter"). | 75 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 76 | Str(zerowrap.FieldAdapter, "http"). | 76 | Str(zerowrap.FieldAdapter, "http"). | ||
M internal/usecase/container/service.go +6 -2
| @@ -1429,13 +1429,17 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam | |||
| 1429 | 1429 | ||
| 1430 | 1430 | log := zerowrap.FromCtx(ctx) | |
| 1431 | 1431 | ||
| 1432 | - | logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true) | |
| 1432 | + | // Use context.WithoutCancel so the log stream outlives the HTTP request. | |
| 1433 | + | // Without this, the Docker log stream closes when the deploy response completes. | |
| 1434 | + | bgCtx := context.WithoutCancel(ctx) | |
| 1435 | + | ||
| 1436 | + | logStream, err := s.runtime.GetContainerLogs(bgCtx, containerID, true) | |
| 1433 | 1437 | if err != nil { | |
| 1434 | 1438 | log.Warn().Err(err).Msg("failed to get container logs for collection") | |
| 1435 | 1439 | return | |
| 1436 | 1440 | } | |
| 1437 | 1441 | ||
| 1438 | - | if err := s.logWriter.StartLogging(context.WithoutCancel(ctx), containerID, domainName, logStream); err != nil { | |
| 1442 | + | if err := s.logWriter.StartLogging(bgCtx, containerID, domainName, logStream); err != nil { | |
| 1439 | 1443 | log.Warn().Err(err).Msg("failed to start container log collection") | |
| 1440 | 1444 | logStream.Close() | |
| 1441 | 1445 | } | |
| @@ -1429,13 +1429,17 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam | |||||
| 1429 | 1429 | ||||
| 1430 | log := zerowrap.FromCtx(ctx) | 1430 | log := zerowrap.FromCtx(ctx) | ||
| 1431 | 1431 | ||||
| 1432 | - | logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true) | 1432 | + | // Use context.WithoutCancel so the log stream outlives the HTTP request. |
| 1433 | + | // Without this, the Docker log stream closes when the deploy response completes. | |||
| 1434 | + | bgCtx := context.WithoutCancel(ctx) | |||
| 1435 | + | ||||
| 1436 | + | logStream, err := s.runtime.GetContainerLogs(bgCtx, containerID, true) | |||
| 1433 | if err != nil { | 1437 | if err != nil { | ||
| 1434 | log.Warn().Err(err).Msg("failed to get container logs for collection") | 1438 | log.Warn().Err(err).Msg("failed to get container logs for collection") | ||
| 1435 | return | 1439 | return | ||
| 1436 | } | 1440 | } | ||
| 1437 | 1441 | ||||
| 1438 | - | if err := s.logWriter.StartLogging(context.WithoutCancel(ctx), containerID, domainName, logStream); err != nil { | 1442 | + | if err := s.logWriter.StartLogging(bgCtx, containerID, domainName, logStream); err != nil { |
| 1439 | log.Warn().Err(err).Msg("failed to start container log collection") | 1443 | log.Warn().Err(err).Msg("failed to start container log collection") | ||
| 1440 | logStream.Close() | 1444 | logStream.Close() | ||
| 1441 | } | 1445 | } | ||