From 32de5b26a8546a2611757f1fd26e9dcfa11d228b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 27 Mar 2026 23:20:56 +0300 Subject: [PATCH] feat(docker-watcher): phase 12 - hardening Blue-green zero-downtime deploys, promote flow validation. Dual auth: local (bcrypt + JWT) and OAuth2/OIDC (any provider). Auth middleware, login page, auth settings UI. Structured logging (slog JSON), config export to YAML. Graceful shutdown with deploy draining. Multi-stage Dockerfile and production docker-compose.yml. Swap phase order: Volumes & Env before UI Polish. --- Dockerfile | 48 ++++ PLAN.md | 39 ++- cmd/server/main.go | 96 +++++-- docker-compose.yml | 46 ++++ go.mod | 4 + internal/api/auth.go | 319 ++++++++++++++++++++++ internal/api/config_export.go | 21 ++ internal/api/deploys.go | 4 +- internal/api/instances.go | 6 +- internal/api/middleware.go | 11 +- internal/api/response.go | 6 +- internal/api/router.go | 184 ++++++++----- internal/api/sse.go | 6 +- internal/auth/local.go | 111 ++++++++ internal/auth/middleware.go | 68 +++++ internal/auth/models.go | 42 +++ internal/auth/oidc.go | 87 ++++++ internal/config/export.go | 118 ++++++++ internal/deployer/bluegreen.go | 173 ++++++++++++ internal/deployer/deployer.go | 78 ++++-- internal/deployer/promote.go | 49 ++++ internal/deployer/rollback.go | 16 +- internal/logging/logger.go | 34 +++ internal/store/store.go | 22 ++ internal/store/users.go | 183 +++++++++++++ plans/docker-watcher-core/PLAN.md | 21 +- web/src/lib/api.ts | 37 ++- web/src/routes/login/+page.svelte | 128 +++++++++ web/src/routes/settings/+layout.svelte | 3 +- web/src/routes/settings/auth/+page.svelte | 317 +++++++++++++++++++++ 30 files changed, 2134 insertions(+), 143 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 internal/api/auth.go create mode 100644 internal/api/config_export.go create mode 100644 internal/auth/local.go create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/models.go create mode 100644 internal/auth/oidc.go create mode 100644 internal/config/export.go create mode 100644 internal/deployer/bluegreen.go create mode 100644 internal/deployer/promote.go create mode 100644 internal/logging/logger.go create mode 100644 internal/store/users.go create mode 100644 web/src/routes/login/+page.svelte create mode 100644 web/src/routes/settings/auth/+page.svelte diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..83d075d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# Stage 1: Build frontend +FROM node:20-alpine AS frontend-builder + +WORKDIR /build/web +COPY web/package.json web/package-lock.json* ./ +RUN npm ci --no-audit + +COPY web/ ./ +RUN npm run build + +# Stage 2: Build Go binary +FROM golang:1.23-alpine AS backend-builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +# Copy built frontend into the expected embed location. +COPY --from=frontend-builder /build/web/build ./web/build + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /docker-watcher ./cmd/server + +# Stage 3: Minimal runtime image +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +# Create non-root user. +RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app + +WORKDIR /app + +COPY --from=backend-builder /docker-watcher /app/docker-watcher + +# Data directory for SQLite database. +RUN mkdir -p /app/data && chown -R app:app /app + +USER app + +EXPOSE 8080 + +ENV DATA_DIR=/app/data +ENV LISTEN_ADDR=:8080 + +ENTRYPOINT ["/app/docker-watcher"] diff --git a/PLAN.md b/PLAN.md index 66403fc..8bca141 100644 --- a/PLAN.md +++ b/PLAN.md @@ -325,16 +325,37 @@ stages: NODE_ENV: production # uses project-level default ``` -### Phase 5: Hardening +### Phase 5: Hardening (Phase 12) -- COMPLETED -30. **Blue-green deploys** — start new, health check, swap, stop old (zero downtime) -31. **Promote flow** — enforce `promote_from` for production deploys -32. **Auth on dashboard** — two modes, configurable via settings: - - **Local auth** — username/password stored in SQLite (hashed), for simple setups - - **OAuth2 / OpenID Connect** — integration with Authentik (or any OIDC provider), configurable client ID/secret/discovery URL -33. **Graceful shutdown** — drain in-progress deploys on SIGTERM -34. **Structured logging** — JSON logs with deploy context -35. **Config export** — download current SQLite state as YAML +30. **Blue-green deploys** -- start new, health check, swap, stop old (zero downtime) +31. **Promote flow** -- enforce `promote_from` for production deploys +32. **Auth on dashboard** -- two modes, configurable via settings: + - **Local auth** -- username/password stored in SQLite (bcrypt hashed), JWT session tokens + - **OAuth2 / OpenID Connect** -- integration with any OIDC provider (configurable client ID/secret/discovery URL) +33. **Graceful shutdown** -- drain in-progress deploys on SIGTERM, close DB, stop poller +34. **Structured logging** -- JSON logs via `log/slog` with deploy context +35. **Config export** -- download current SQLite state as YAML +36. **Dockerfile** -- multi-stage build (Node.js 20 + Go 1.23 build, alpine runtime) +37. **docker-compose.yml** -- production-ready compose with volumes, network, env +38. **Auth middleware** -- protects all /api/* routes except webhook and auth endpoints +39. **Auth settings UI** -- settings page to toggle auth mode, configure OIDC, manage users +40. **Login page** -- username/password form with OIDC SSO option +41. **Final wiring** -- all services properly initialized and shut down in main.go + +#### Phase 12 Handoff Notes + +- Auth: `auth.LocalAuth` handles JWT generation/validation, `auth.OIDCProvider` handles OIDC flow +- Default admin user created on first launch (ADMIN_PASSWORD env var, default: "admin") +- JWT secret derived from ENCRYPTION_KEY via HMAC-SHA256 +- Blue-green: triggered automatically when stage has `max_instances=1`; otherwise standard deploy +- Promote: validated in `TriggerDeploy` before deploy begins +- Graceful shutdown: `deployer.Drain()` waits for in-progress deploys; poller stopped; HTTP server drained; DB closed +- Structured logging: all API, deployer, and main.go use `log/slog` JSON handler +- New dependencies: `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto/bcrypt`, `github.com/coreos/go-oidc/v3`, `golang.org/x/oauth2` +- New tables: `users` (id, username, password_hash, email, role, timestamps), `auth_settings` (single-row: auth_mode, OIDC config) +- Auth middleware applied to all `/api/*` routes except `/api/auth/login`, `/api/auth/oidc/*`, `/api/webhook/*`, `/api/config/export` +- Frontend: token stored in `localStorage`, sent as `Authorization: Bearer` header +- Run `go mod tidy` after checkout to resolve transitive dependencies ## Key Dependencies (Go) diff --git a/cmd/server/main.go b/cmd/server/main.go index b7d80fb..ec88781 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,8 +2,9 @@ package main import ( "context" + "errors" "io/fs" - "log" + "log/slog" "net/http" "os" "os/signal" @@ -13,12 +14,14 @@ import ( dockerwatcher "github.com/alexei/docker-watcher" "github.com/alexei/docker-watcher/internal/api" + "github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/docker-watcher/internal/config" "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/deployer" "github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/docker-watcher/internal/events" "github.com/alexei/docker-watcher/internal/health" + "github.com/alexei/docker-watcher/internal/logging" "github.com/alexei/docker-watcher/internal/notify" "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/registry" @@ -27,44 +30,58 @@ import ( ) func main() { + // Initialize structured JSON logging. + logging.Setup() + dataDir := envOrDefault("DATA_DIR", "./data") if err := os.MkdirAll(dataDir, 0o755); err != nil { - log.Fatalf("create data directory: %v", err) + slog.Error("create data directory", "error", err) + os.Exit(1) } // Open database. dbPath := filepath.Join(dataDir, "docker-watcher.db") db, err := store.New(dbPath) if err != nil { - log.Fatalf("open store: %v", err) + slog.Error("open store", "error", err) + os.Exit(1) } defer db.Close() // Import seed config on first launch (idempotent). seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml") if err := config.ImportSeed(db, seedPath); err != nil { - log.Fatalf("seed import: %v", err) + slog.Error("seed import", "error", err) + os.Exit(1) } // Derive encryption key from environment. encKey, err := crypto.KeyFromEnv() if err != nil { - log.Printf("WARNING: %v — encrypted fields will not work", err) + slog.Warn("encryption key not set, using default", "warning", err.Error()) encKey = crypto.DeriveKey("docker-watcher-default-key") } + // Ensure default admin user exists on first launch. + if err := ensureDefaultAdmin(db); err != nil { + slog.Error("ensure default admin", "error", err) + os.Exit(1) + } + // Initialize Docker client. dockerClient, err := docker.New() if err != nil { - log.Fatalf("create docker client: %v", err) + slog.Error("create docker client", "error", err) + os.Exit(1) } defer dockerClient.Close() // Read settings for NPM URL and polling interval. settings, err := db.GetSettings() if err != nil { - log.Fatalf("get settings: %v", err) + slog.Error("get settings", "error", err) + os.Exit(1) } // Initialize NPM client. @@ -84,16 +101,17 @@ func main() { // Ensure webhook secret exists. _, err = webhook.EnsureWebhookSecret(db) if err != nil { - log.Fatalf("ensure webhook secret: %v", err) + slog.Error("ensure webhook secret", "error", err) + os.Exit(1) } - log.Printf("Webhook secret configured (use /api/settings/webhook-url to retrieve)") + slog.Info("webhook secret configured (use /api/settings/webhook-url to retrieve)") // Initialize registry poller. poller := registry.NewPoller(db, dep, encKey) pollingInterval := envOrDefault("POLLING_INTERVAL", settings.PollingInterval) if pollingInterval != "" { if err := poller.Start(pollingInterval); err != nil { - log.Printf("WARNING: failed to start poller: %v", err) + slog.Warn("failed to start poller", "error", err) } } @@ -105,7 +123,7 @@ func main() { // The embed.FS has "web/build" as a prefix, so we sub it to get the root. webBuildFS, err := fs.Sub(dockerwatcher.WebBuildFS, "web/build") if err != nil { - log.Printf("WARNING: embedded frontend not available: %v", err) + slog.Warn("embedded frontend not available", "error", err) } else { staticHandler := api.StaticHandler(webBuildFS) // Handle all non-API routes with the static file server. @@ -129,25 +147,36 @@ func main() { signal.Notify(done, os.Interrupt, syscall.SIGTERM) go func() { - log.Printf("Docker Watcher started. Listening on %s", addr) + slog.Info("Docker Watcher started", "addr", addr) if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("HTTP server error: %v", err) + slog.Error("HTTP server error", "error", err) + os.Exit(1) } }() <-done - log.Println("Shutting down...") + slog.Info("shutting down...") + // Stop accepting new work. poller.Stop() + // Drain in-progress deploys. + dep.Drain() + + // Shut down HTTP server. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := httpServer.Shutdown(ctx); err != nil { - log.Printf("HTTP server shutdown error: %v", err) + slog.Error("HTTP server shutdown error", "error", err) } - log.Println("Docker Watcher stopped.") + // Close database. + if err := db.Close(); err != nil { + slog.Error("database close error", "error", err) + } + + slog.Info("Docker Watcher stopped") } // envOrDefault reads an environment variable or returns the fallback value. @@ -157,3 +186,38 @@ func envOrDefault(key, fallback string) string { } return fallback } + +// ensureDefaultAdmin creates a default admin user on first launch if no users exist. +// The password comes from ADMIN_PASSWORD env var, defaulting to "admin". +func ensureDefaultAdmin(db *store.Store) error { + count, err := db.UserCount() + if err != nil { + return err + } + if count > 0 { + return nil // Users already exist, skip. + } + + password := envOrDefault("ADMIN_PASSWORD", "admin") + hash, err := auth.HashPassword(password) + if err != nil { + return err + } + + _, err = db.CreateUser(store.User{ + Username: "admin", + PasswordHash: hash, + Email: "", + Role: "admin", + }) + if err != nil { + // Ignore duplicate key errors (race condition on concurrent startup). + if errors.Is(err, store.ErrNotFound) { + return nil + } + return err + } + + slog.Info("default admin user created", "username", "admin") + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..80722d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + docker-watcher: + build: . + image: docker-watcher:latest + container_name: docker-watcher + restart: unless-stopped + ports: + - "8080:8080" + volumes: + # Mount Docker socket for container management. + - /var/run/docker.sock:/var/run/docker.sock + # Persistent data (SQLite database). + - docker-watcher-data:/app/data + # Optional seed config (read on first launch only). + - ./docker-watcher.yaml:/app/docker-watcher.yaml:ro + environment: + # Required: protects all credentials stored in the database. + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env} + # Optional: default admin password on first launch (default: "admin"). + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin} + # Optional: override seed file location. + - SEED_FILE=/app/docker-watcher.yaml + # Optional: override data directory. + - DATA_DIR=/app/data + # Optional: override listen address. + - LISTEN_ADDR=:8080 + # Optional: override NPM URL (otherwise uses value from settings). + # - NPM_URL=http://npm:81 + # Optional: override polling interval. + # - POLLING_INTERVAL=5m + networks: + - staging-net + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/auth/login"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + docker-watcher-data: + driver: local + +networks: + staging-net: + external: true diff --git a/go.mod b/go.mod index 5970c4b..33344e0 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,15 @@ module github.com/alexei/docker-watcher go 1.23 require ( + github.com/coreos/go-oidc/v3 v3.11.0 github.com/docker/docker v27.5.1+incompatible github.com/docker/go-connections v0.5.0 github.com/go-chi/chi/v5 v5.2.1 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/robfig/cron/v3 v3.0.1 + golang.org/x/crypto v0.31.0 + golang.org/x/oauth2 v0.25.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.34.5 ) diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..2256e35 --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,319 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/auth" + "github.com/alexei/docker-watcher/internal/store" +) + +// login handles POST /api/auth/login. +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + var req auth.LoginRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Username == "" || req.Password == "" { + respondError(w, http.StatusBadRequest, "username and password are required") + return + } + + user, err := s.store.GetUserByUsername(req.Username) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondError(w, http.StatusUnauthorized, "invalid credentials") + return + } + respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + return + } + + if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil { + respondError(w, http.StatusUnauthorized, "invalid credentials") + return + } + + token, err := s.localAuth.GenerateToken(auth.Claims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, token) +} + +// currentUser handles GET /api/auth/me — returns the authenticated user. +func (s *Server) currentUser(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + respondError(w, http.StatusUnauthorized, "not authenticated") + return + } + + user, err := s.store.GetUserByID(claims.UserID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, user) +} + +// oidcLogin handles GET /api/auth/oidc/login — redirects to OIDC provider. +func (s *Server) oidcLogin(w http.ResponseWriter, r *http.Request) { + if s.oidcProvider == nil { + respondError(w, http.StatusBadRequest, "OIDC is not configured") + return + } + + // Generate random state. + stateBytes := make([]byte, 16) + if _, err := rand.Read(stateBytes); err != nil { + respondError(w, http.StatusInternalServerError, "failed to generate state") + return + } + state := hex.EncodeToString(stateBytes) + + // Store state in a short-lived cookie for validation on callback. + http.SetCookie(w, &http.Cookie{ + Name: "oidc_state", + Value: state, + Path: "/api/auth/oidc", + MaxAge: 300, // 5 minutes + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, s.oidcProvider.AuthCodeURL(state), http.StatusFound) +} + +// oidcCallback handles GET /api/auth/oidc/callback — exchanges code for tokens. +func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) { + if s.oidcProvider == nil { + respondError(w, http.StatusBadRequest, "OIDC is not configured") + return + } + + // Validate state parameter. + stateCookie, err := r.Cookie("oidc_state") + if err != nil || stateCookie.Value == "" { + respondError(w, http.StatusBadRequest, "missing OIDC state") + return + } + + if r.URL.Query().Get("state") != stateCookie.Value { + respondError(w, http.StatusBadRequest, "invalid OIDC state") + return + } + + // Clear the state cookie. + http.SetCookie(w, &http.Cookie{ + Name: "oidc_state", + Value: "", + Path: "/api/auth/oidc", + MaxAge: -1, + }) + + code := r.URL.Query().Get("code") + if code == "" { + respondError(w, http.StatusBadRequest, "missing authorization code") + return + } + + userInfo, err := s.oidcProvider.Exchange(r.Context(), code) + if err != nil { + slog.Error("OIDC exchange failed", "error", err) + respondError(w, http.StatusInternalServerError, "OIDC authentication failed") + return + } + + // Find or create local user linked to the OIDC identity. + username := userInfo.Username + if username == "" { + username = userInfo.Email + } + if username == "" { + username = userInfo.Subject + } + + user, err := s.store.GetUserByUsername(username) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + // Auto-create user from OIDC. + user, err = s.store.CreateUser(store.User{ + Username: username, + Email: userInfo.Email, + Role: "viewer", // OIDC users default to viewer; admin promotes via settings + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) + return + } + } else { + respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + return + } + } + + token, err := s.localAuth.GenerateToken(auth.Claims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error()) + return + } + + // Redirect to frontend with token in query parameter. + // The frontend extracts the token and stores it in localStorage. + http.Redirect(w, r, "/?token="+token.Token, http.StatusFound) +} + +// getAuthSettings handles GET /api/auth/settings. +func (s *Server) getAuthSettings(w http.ResponseWriter, r *http.Request) { + as, err := s.store.GetAuthSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get auth settings: "+err.Error()) + return + } + // Mask the client secret for the response. + if as.OIDCClientSecret != "" { + as.OIDCClientSecret = "********" + } + respondJSON(w, http.StatusOK, as) +} + +// updateAuthSettings handles PUT /api/auth/settings. +func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) { + var req store.AuthSettings + if !decodeJSON(w, r, &req) { + return + } + + if req.AuthMode != "local" && req.AuthMode != "oidc" { + respondError(w, http.StatusBadRequest, "auth_mode must be 'local' or 'oidc'") + return + } + + // If client secret is masked, preserve the existing value. + if req.OIDCClientSecret == "********" || req.OIDCClientSecret == "" { + existing, err := s.store.GetAuthSettings() + if err == nil { + req.OIDCClientSecret = existing.OIDCClientSecret + } + } + + if err := s.store.UpdateAuthSettings(req); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update auth settings: "+err.Error()) + return + } + + // Re-initialize OIDC provider if mode is oidc and config is present. + if req.AuthMode == "oidc" && req.OIDCIssuerURL != "" && req.OIDCClientID != "" { + s.initOIDCProvider(r.Context(), req) + } + + respondJSON(w, http.StatusOK, req) +} + +// listUsers handles GET /api/auth/users. +func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) { + users, err := s.store.GetAllUsers() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list users: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, users) +} + +// createUser handles POST /api/auth/users. +func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + Role string `json:"role"` + } + if !decodeJSON(w, r, &req) { + return + } + + if req.Username == "" || req.Password == "" { + respondError(w, http.StatusBadRequest, "username and password are required") + return + } + + if req.Role == "" { + req.Role = "viewer" + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to hash password: "+err.Error()) + return + } + + user, err := s.store.CreateUser(store.User{ + Username: req.Username, + PasswordHash: hash, + Email: req.Email, + Role: req.Role, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) + return + } + + respondJSON(w, http.StatusCreated, user) +} + +// deleteUser handles DELETE /api/auth/users/{uid}. +func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "uid") + + // Prevent deleting the last admin. + user, err := s.store.GetUserByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "user") + return + } + respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + return + } + + if user.Role == "admin" { + users, err := s.store.GetAllUsers() + if err == nil { + adminCount := 0 + for _, u := range users { + if u.Role == "admin" { + adminCount++ + } + } + if adminCount <= 1 { + respondError(w, http.StatusBadRequest, "cannot delete the last admin user") + return + } + } + } + + if err := s.store.DeleteUser(id); err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete user: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) +} diff --git a/internal/api/config_export.go b/internal/api/config_export.go new file mode 100644 index 0000000..4a0f396 --- /dev/null +++ b/internal/api/config_export.go @@ -0,0 +1,21 @@ +package api + +import ( + "net/http" + + "github.com/alexei/docker-watcher/internal/config" +) + +// exportConfig handles GET /api/config/export — downloads current state as YAML. +func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) { + data, err := config.ExportConfig(s.store) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to export config: "+err.Error()) + return + } + + w.Header().Set("Content-Type", "application/x-yaml") + w.Header().Set("Content-Disposition", "attachment; filename=docker-watcher.yaml") + w.WriteHeader(http.StatusOK) + w.Write(data) +} diff --git a/internal/api/deploys.go b/internal/api/deploys.go index e4baef6..a689a71 100644 --- a/internal/api/deploys.go +++ b/internal/api/deploys.go @@ -1,7 +1,7 @@ package api import ( - "log" + "log/slog" "net/http" "strconv" "strings" @@ -61,7 +61,7 @@ func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) { // Split image:tag for the pull call. imageRef, tag := splitImageTag(req.Image) if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil { - log.Printf("[api] pull image %s for inspect: %v", req.Image, err) + slog.Warn("pull image for inspect", "image", req.Image, "error", err) // Try to inspect anyway in case the image is already local. } diff --git a/internal/api/instances.go b/internal/api/instances.go index a7abf3c..4dca489 100644 --- a/internal/api/instances.go +++ b/internal/api/instances.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "log" + "log/slog" "net/http" "github.com/go-chi/chi/v5" @@ -103,7 +103,7 @@ func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) { // Remove the Docker container if it has one. if inst.ContainerID != "" { if err := s.docker.RemoveContainer(r.Context(), inst.ContainerID, true); err != nil { - log.Printf("[api] remove container %s: %v", inst.ContainerID, err) + slog.Error("remove container", "container_id", inst.ContainerID, "error", err) } } @@ -175,7 +175,7 @@ func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action // Update status in store. if err := s.store.UpdateInstanceStatus(instanceID, newStatus); err != nil { - log.Printf("[api] update instance %s status to %s: %v", instanceID, newStatus, err) + slog.Error("update instance status", "instance_id", instanceID, "status", newStatus, "error", err) } respondJSON(w, http.StatusOK, map[string]string{ diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 6f597e1..30d6401 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,7 +1,7 @@ package api import ( - "log" + "log/slog" "net/http" "runtime/debug" "time" @@ -16,7 +16,12 @@ func logging(next http.Handler) http.Handler { next.ServeHTTP(wrapped, r) - log.Printf("[api] %s %s %d %s", r.Method, r.URL.Path, wrapped.status, time.Since(start)) + slog.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "status", wrapped.status, + "duration", time.Since(start).String(), + ) }) } @@ -25,7 +30,7 @@ func recovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { - log.Printf("[api] panic: %v\n%s", err, debug.Stack()) + slog.Error("panic recovered", "error", err, "stack", string(debug.Stack())) respondError(w, http.StatusInternalServerError, "internal server error") } }() diff --git a/internal/api/response.go b/internal/api/response.go index 812a11f..ea82ec4 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -2,7 +2,7 @@ package api import ( "encoding/json" - "log" + "log/slog" "net/http" ) @@ -18,7 +18,7 @@ func respondJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(envelope{Success: true, Data: data}); err != nil { - log.Printf("[api] encode response: %v", err) + slog.Error("encode response", "error", err) } } @@ -27,7 +27,7 @@ func respondError(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(envelope{Success: false, Error: msg}); err != nil { - log.Printf("[api] encode error response: %v", err) + slog.Error("encode error response", "error", err) } } diff --git a/internal/api/router.go b/internal/api/router.go index 81ad872..bc4ece4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1,8 +1,12 @@ package api import ( + "context" + "log/slog" + "github.com/go-chi/chi/v5" + "github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/docker-watcher/internal/events" "github.com/alexei/docker-watcher/internal/store" @@ -11,12 +15,14 @@ import ( // Server holds all dependencies for the API layer. type Server struct { - store *store.Store - docker *docker.Client - deployer DeployTriggerer - webhook *webhook.Handler - eventBus *events.Bus - encKey [32]byte + store *store.Store + docker *docker.Client + deployer DeployTriggerer + webhook *webhook.Handler + eventBus *events.Bus + encKey [32]byte + localAuth *auth.LocalAuth + oidcProvider *auth.OIDCProvider } // NewServer creates a new API Server with all required dependencies. @@ -28,19 +34,44 @@ func NewServer( eventBus *events.Bus, encKey [32]byte, ) *Server { - return &Server{ - store: st, - docker: dockerClient, - deployer: deployer, - webhook: webhookHandler, - eventBus: eventBus, - encKey: encKey, + localAuth := auth.NewLocalAuth(encKey) + + s := &Server{ + store: st, + docker: dockerClient, + deployer: deployer, + webhook: webhookHandler, + eventBus: eventBus, + encKey: encKey, + localAuth: localAuth, } + + // Try to initialize OIDC provider from stored settings. + authSettings, err := st.GetAuthSettings() + if err == nil && authSettings.AuthMode == "oidc" && authSettings.OIDCIssuerURL != "" { + s.initOIDCProvider(context.Background(), authSettings) + } + + return s +} + +// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal. +func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) { + provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{ + IssuerURL: as.OIDCIssuerURL, + ClientID: as.OIDCClientID, + ClientSecret: as.OIDCClientSecret, + RedirectURL: as.OIDCRedirectURL, + }) + if err != nil { + slog.Warn("failed to initialize OIDC provider", "error", err) + return + } + s.oidcProvider = provider + slog.Info("OIDC provider initialized", "issuer", as.OIDCIssuerURL) } // Router returns a chi router with all API routes mounted. -// NOTE: Authentication middleware is added in Phase 12 (Hardening). -// Until then, this API should only be exposed on trusted networks. func (s *Server) Router() chi.Router { r := chi.NewRouter() @@ -51,59 +82,80 @@ func (s *Server) Router() chi.Router { r.Use(jsonContentType) r.Route("/api", func(r chi.Router) { - // Project endpoints. - r.Get("/projects", s.listProjects) - r.Post("/projects", s.createProject) - r.Route("/projects/{id}", func(r chi.Router) { - r.Get("/", s.getProject) - r.Put("/", s.updateProject) - r.Delete("/", s.deleteProject) + // Public auth endpoints (no auth required). + r.Post("/auth/login", s.login) + r.Get("/auth/oidc/login", s.oidcLogin) + r.Get("/auth/oidc/callback", s.oidcCallback) - // Stage endpoints. - r.Post("/stages", s.createStage) - r.Put("/stages/{stage}", s.updateStage) - r.Delete("/stages/{stage}", s.deleteStage) - - // Instance endpoints. - r.Get("/stages/{stage}/instances", s.listInstances) - r.Post("/stages/{stage}/instances", s.deployInstance) - r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance) - - // Instance control endpoints. - r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance) - r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance) - r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance) - }) - - // Deploy endpoints. - r.Get("/deploys", s.listDeploys) - r.Get("/deploys/{id}/logs", s.streamDeployLogs) - - // SSE endpoint for real-time instance status and deploy events. - r.Get("/events", s.streamEvents) - - // Quick deploy endpoints. - r.Post("/deploy/inspect", s.inspectImage) - r.Post("/deploy/quick", s.quickDeploy) - - // Registry endpoints. - r.Get("/registries", s.listRegistries) - r.Post("/registries", s.createRegistry) - r.Route("/registries/{id}", func(r chi.Router) { - r.Put("/", s.updateRegistry) - r.Delete("/", s.deleteRegistry) - r.Post("/test", s.testRegistry) - r.Get("/tags/*", s.listRegistryTags) - }) - - // Settings endpoints. - r.Get("/settings", s.getSettings) - r.Put("/settings", s.updateSettings) - r.Get("/settings/webhook-url", s.getWebhookURL) - r.Post("/settings/regenerate", s.regenerateWebhookSecret) - - // Webhook handler (from webhook package). + // Webhook handler (uses its own secret-based auth). r.Mount("/webhook", s.webhook.Route()) + + // Config export (public endpoint, useful for backup). + r.Get("/config/export", s.exportConfig) + + // Protected routes: require valid JWT. + r.Group(func(r chi.Router) { + r.Use(auth.Middleware(s.localAuth)) + + // Auth management. + r.Get("/auth/me", s.currentUser) + r.Get("/auth/settings", s.getAuthSettings) + r.Put("/auth/settings", s.updateAuthSettings) + r.Get("/auth/users", s.listUsers) + r.Post("/auth/users", s.createUser) + r.Delete("/auth/users/{uid}", s.deleteUser) + + // Project endpoints. + r.Get("/projects", s.listProjects) + r.Post("/projects", s.createProject) + r.Route("/projects/{id}", func(r chi.Router) { + r.Get("/", s.getProject) + r.Put("/", s.updateProject) + r.Delete("/", s.deleteProject) + + // Stage endpoints. + r.Post("/stages", s.createStage) + r.Put("/stages/{stage}", s.updateStage) + r.Delete("/stages/{stage}", s.deleteStage) + + // Instance endpoints. + r.Get("/stages/{stage}/instances", s.listInstances) + r.Post("/stages/{stage}/instances", s.deployInstance) + r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance) + + // Instance control endpoints. + r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance) + r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance) + r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance) + }) + + // Deploy endpoints. + r.Get("/deploys", s.listDeploys) + r.Get("/deploys/{id}/logs", s.streamDeployLogs) + + // SSE endpoint for real-time instance status and deploy events. + r.Get("/events", s.streamEvents) + + // Quick deploy endpoints. + r.Post("/deploy/inspect", s.inspectImage) + r.Post("/deploy/quick", s.quickDeploy) + + // Registry endpoints. + r.Get("/registries", s.listRegistries) + r.Post("/registries", s.createRegistry) + r.Route("/registries/{id}", func(r chi.Router) { + r.Put("/", s.updateRegistry) + r.Delete("/", s.deleteRegistry) + r.Post("/test", s.testRegistry) + r.Get("/tags/*", s.listRegistryTags) + }) + + // Settings endpoints. + r.Get("/settings", s.getSettings) + r.Put("/settings", s.updateSettings) + r.Get("/settings/webhook-url", s.getWebhookURL) + r.Post("/settings/regenerate", s.regenerateWebhookSecret) + }) }) return r diff --git a/internal/api/sse.go b/internal/api/sse.go index a408601..fb2a2b5 100644 --- a/internal/api/sse.go +++ b/internal/api/sse.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "log" + "log/slog" "net/http" "strings" @@ -68,7 +68,7 @@ func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) { // Send existing logs first. existingLogs, err := s.store.GetDeployLogs(deployID) if err != nil { - log.Printf("[sse] failed to get existing deploy logs: %v", err) + slog.Error("get existing deploy logs", "error", err) } else { for _, entry := range existingLogs { writeSSE(w, flusher, events.Event{ @@ -174,7 +174,7 @@ func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) { func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) { data, err := json.Marshal(evt) if err != nil { - log.Printf("[sse] marshal event: %v", err) + slog.Error("marshal SSE event", "error", err) return } fmt.Fprintf(w, "data: %s\n\n", data) diff --git a/internal/auth/local.go b/internal/auth/local.go new file mode 100644 index 0000000..bd1cd37 --- /dev/null +++ b/internal/auth/local.go @@ -0,0 +1,111 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +// ErrInvalidCredentials indicates that the supplied username/password is wrong. +var ErrInvalidCredentials = errors.New("invalid credentials") + +// ErrInvalidToken indicates that the JWT is invalid or expired. +var ErrInvalidToken = errors.New("invalid or expired token") + +// TokenExpiry is the lifetime of a JWT session token. +const TokenExpiry = 24 * time.Hour + +// jwtClaims extends jwt.RegisteredClaims with application-specific fields. +type jwtClaims struct { + jwt.RegisteredClaims + UserID string `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` +} + +// LocalAuth handles password hashing and JWT token management for local auth mode. +type LocalAuth struct { + jwtSecret []byte +} + +// NewLocalAuth creates a LocalAuth deriving the JWT signing key from the encryption key +// using HMAC-SHA256. +func NewLocalAuth(encKey [32]byte) *LocalAuth { + mac := hmac.New(sha256.New, encKey[:]) + mac.Write([]byte("docker-watcher-jwt-secret")) + return &LocalAuth{ + jwtSecret: mac.Sum(nil), + } +} + +// HashPassword hashes a plaintext password using bcrypt. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + return string(hash), nil +} + +// CheckPassword compares a plaintext password against a bcrypt hash. +func CheckPassword(hash, password string) error { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return ErrInvalidCredentials + } + return nil +} + +// GenerateToken creates a signed JWT for the given user claims. +func (la *LocalAuth) GenerateToken(claims Claims) (SessionToken, error) { + expiresAt := time.Now().Add(TokenExpiry) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "docker-watcher", + }, + UserID: claims.UserID, + Username: claims.Username, + Role: claims.Role, + }) + + signed, err := token.SignedString(la.jwtSecret) + if err != nil { + return SessionToken{}, fmt.Errorf("sign token: %w", err) + } + + return SessionToken{ + Token: signed, + ExpiresAt: expiresAt, + }, nil +} + +// ValidateToken parses and validates a JWT, returning the embedded claims. +func (la *LocalAuth) ValidateToken(tokenString string) (Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &jwtClaims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return la.jwtSecret, nil + }) + if err != nil { + return Claims{}, ErrInvalidToken + } + + claims, ok := token.Claims.(*jwtClaims) + if !ok || !token.Valid { + return Claims{}, ErrInvalidToken + } + + return Claims{ + UserID: claims.UserID, + Username: claims.Username, + Role: claims.Role, + }, nil +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..c0416f0 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,68 @@ +package auth + +import ( + "context" + "net/http" + "strings" +) + +// contextKey is the type for context value keys used by the auth package. +type contextKey string + +const claimsKey contextKey = "auth_claims" + +// Middleware returns an HTTP middleware that protects routes by requiring a valid JWT. +// It extracts the token from the Authorization header (Bearer scheme) or the "token" +// query parameter (for SSE connections). +// Unauthenticated requests receive a 401 JSON response. +func Middleware(la *LocalAuth) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenStr := extractToken(r) + if tokenStr == "" { + http.Error(w, `{"success":false,"error":"authentication required"}`, http.StatusUnauthorized) + return + } + + claims, err := la.ValidateToken(tokenStr) + if err != nil { + http.Error(w, `{"success":false,"error":"invalid or expired token"}`, http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), claimsKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// AdminOnly returns an HTTP middleware that requires the authenticated user to have +// the "admin" role. +func AdminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok || claims.Role != "admin" { + http.Error(w, `{"success":false,"error":"admin access required"}`, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// ClaimsFromContext retrieves the authenticated user's claims from the request context. +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + claims, ok := ctx.Value(claimsKey).(Claims) + return claims, ok +} + +// extractToken gets the JWT from the Authorization header or "token" query param. +func extractToken(r *http.Request) string { + // Try Authorization: Bearer + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimPrefix(authHeader, "Bearer ") + } + + // Fall back to query parameter (used by SSE and browser-based connections). + return r.URL.Query().Get("token") +} diff --git a/internal/auth/models.go b/internal/auth/models.go new file mode 100644 index 0000000..3a182aa --- /dev/null +++ b/internal/auth/models.go @@ -0,0 +1,42 @@ +package auth + +import "time" + +// User represents an authenticated user stored in the database. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Email string `json:"email"` + Role string `json:"role"` // admin, viewer + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// AuthSettings holds the authentication configuration (single-row pattern). +type AuthSettings struct { + AuthMode string `json:"auth_mode"` // local, oidc + OIDCClientID string `json:"oidc_client_id"` + OIDCClientSecret string `json:"-"` + OIDCIssuerURL string `json:"oidc_issuer_url"` + OIDCRedirectURL string `json:"oidc_redirect_url"` +} + +// Claims represents the JWT token claims. +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` +} + +// SessionToken is the response sent to the client after successful authentication. +type SessionToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// LoginRequest is the expected JSON body for the login endpoint. +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..edc6782 --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,87 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// OIDCProvider wraps an OIDC provider and OAuth2 configuration. +type OIDCProvider struct { + provider *oidc.Provider + oauth2Config oauth2.Config + verifier *oidc.IDTokenVerifier +} + +// OIDCConfig holds the configuration needed to set up an OIDC provider. +type OIDCConfig struct { + IssuerURL string + ClientID string + ClientSecret string + RedirectURL string +} + +// OIDCUserInfo represents the user information extracted from an OIDC ID token. +type OIDCUserInfo struct { + Subject string `json:"sub"` + Email string `json:"email"` + Username string `json:"preferred_username"` + Name string `json:"name"` +} + +// NewOIDCProvider initializes an OIDC provider using the discovery URL. +func NewOIDCProvider(ctx context.Context, cfg OIDCConfig) (*OIDCProvider, error) { + provider, err := oidc.NewProvider(ctx, cfg.IssuerURL) + if err != nil { + return nil, fmt.Errorf("create oidc provider: %w", err) + } + + oauth2Config := oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + + verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) + + return &OIDCProvider{ + provider: provider, + oauth2Config: oauth2Config, + verifier: verifier, + }, nil +} + +// AuthCodeURL returns the URL to redirect the user to for OIDC authentication. +func (op *OIDCProvider) AuthCodeURL(state string) string { + return op.oauth2Config.AuthCodeURL(state) +} + +// Exchange trades an authorization code for tokens and returns the user info +// extracted from the ID token. +func (op *OIDCProvider) Exchange(ctx context.Context, code string) (OIDCUserInfo, error) { + token, err := op.oauth2Config.Exchange(ctx, code) + if err != nil { + return OIDCUserInfo{}, fmt.Errorf("exchange code: %w", err) + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return OIDCUserInfo{}, fmt.Errorf("no id_token in response") + } + + idToken, err := op.verifier.Verify(ctx, rawIDToken) + if err != nil { + return OIDCUserInfo{}, fmt.Errorf("verify id_token: %w", err) + } + + var userInfo OIDCUserInfo + if err := idToken.Claims(&userInfo); err != nil { + return OIDCUserInfo{}, fmt.Errorf("parse id_token claims: %w", err) + } + + return userInfo, nil +} diff --git a/internal/config/export.go b/internal/config/export.go new file mode 100644 index 0000000..76f9f0a --- /dev/null +++ b/internal/config/export.go @@ -0,0 +1,118 @@ +package config + +import ( + "encoding/json" + "fmt" + + "github.com/alexei/docker-watcher/internal/store" + "gopkg.in/yaml.v3" +) + +// ExportConfig reads the current database state and produces a SeedConfig YAML +// representation. Credential fields (tokens, passwords) are exported as placeholder +// strings since they are encrypted in the database. +func ExportConfig(db *store.Store) ([]byte, error) { + cfg, err := buildSeedConfig(db) + if err != nil { + return nil, fmt.Errorf("build seed config: %w", err) + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal yaml: %w", err) + } + + return data, nil +} + +// buildSeedConfig constructs a SeedConfig from the current database state. +func buildSeedConfig(db *store.Store) (SeedConfig, error) { + settings, err := db.GetSettings() + if err != nil { + return SeedConfig{}, fmt.Errorf("get settings: %w", err) + } + + registries, err := db.GetAllRegistries() + if err != nil { + return SeedConfig{}, fmt.Errorf("get registries: %w", err) + } + + projects, err := db.GetAllProjects() + if err != nil { + return SeedConfig{}, fmt.Errorf("get projects: %w", err) + } + + cfg := SeedConfig{ + Global: GlobalConfig{ + Domain: settings.Domain, + ServerIP: settings.ServerIP, + Network: settings.Network, + SubdomainPattern: settings.SubdomainPattern, + NotificationURL: settings.NotificationURL, + Npm: NpmConfig{ + URL: settings.NpmURL, + Email: settings.NpmEmail, + Password: "CHANGE_ME", // Encrypted value, export placeholder. + }, + }, + Registries: make(map[string]RegistryDef), + Projects: make(map[string]ProjectDef), + } + + for _, reg := range registries { + cfg.Registries[reg.Name] = RegistryDef{ + URL: reg.URL, + Type: reg.Type, + Token: "CHANGE_ME", // Encrypted value, export placeholder. + } + } + + for _, proj := range projects { + stages, err := db.GetStagesByProjectID(proj.ID) + if err != nil { + return SeedConfig{}, fmt.Errorf("get stages for project %s: %w", proj.Name, err) + } + + stageDefs := make(map[string]StageDef) + for _, st := range stages { + stageDefs[st.Name] = StageDef{ + TagPattern: st.TagPattern, + AutoDeploy: st.AutoDeploy, + MaxInstances: st.MaxInstances, + Confirm: st.Confirm, + PromoteFrom: st.PromoteFrom, + Subdomain: st.Subdomain, + } + } + + envMap := parseJSONMap(proj.Env) + volMap := parseJSONMap(proj.Volumes) + + cfg.Projects[proj.Name] = ProjectDef{ + Registry: proj.Registry, + Image: proj.Image, + Port: proj.Port, + Healthcheck: proj.Healthcheck, + Env: envMap, + Volumes: volMap, + Stages: stageDefs, + } + } + + return cfg, nil +} + +// parseJSONMap safely parses a JSON-encoded map string. Returns nil on failure. +func parseJSONMap(jsonStr string) map[string]string { + if jsonStr == "" || jsonStr == "{}" { + return nil + } + var m map[string]string + if err := json.Unmarshal([]byte(jsonStr), &m); err != nil { + return nil + } + if len(m) == 0 { + return nil + } + return m +} diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go new file mode 100644 index 0000000..d7c4089 --- /dev/null +++ b/internal/deployer/bluegreen.go @@ -0,0 +1,173 @@ +package deployer + +import ( + "context" + "fmt" + "log/slog" + + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/store" + "github.com/google/uuid" +) + +// blueGreenDeploy performs a zero-downtime deployment: +// 1. Start new container (green) +// 2. Health check green +// 3. Swap NPM proxy to point to green +// 4. Stop old container (blue) +// +// If the new container fails health check, it is removed and the old one stays. +func (d *Deployer) blueGreenDeploy( + ctx context.Context, + project store.Project, + stage store.Stage, + settings store.Settings, + deployID string, + imageTag string, +) (string, int, string, error) { + // Find existing running instance for this stage (the "blue" instance). + existingInstances, err := d.store.GetInstancesByStageID(stage.ID) + if err != nil { + return "", 0, "", fmt.Errorf("get existing instances: %w", err) + } + + var blueInstance *store.Instance + for _, inst := range existingInstances { + if inst.Status == "running" { + instCopy := inst + blueInstance = &instCopy + break + } + } + + // Step 1: Pull image. + if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "") + d.logDeploy(deployID, fmt.Sprintf("Blue-green: pulling image %s:%s", project.Image, imageTag), "info") + + authConfig, err := d.buildRegistryAuth(project) + if err != nil { + return "", 0, "", fmt.Errorf("build registry auth: %w", err) + } + + if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil { + return "", 0, "", fmt.Errorf("pull image: %w", err) + } + d.logDeploy(deployID, "Image pulled successfully", "info") + + // Step 2: Ensure network. + networkID, err := d.docker.EnsureNetwork(ctx, settings.Network) + if err != nil { + return "", 0, "", fmt.Errorf("ensure network: %w", err) + } + + // Step 3: Create and start green container. + if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "") + + instanceID := uuid.New().String() + subdomain := d.buildSubdomain(project, stage, settings, imageTag) + containerName := docker.ContainerName(project.Name, stage.Name, imageTag) + portStr := fmt.Sprintf("%d/tcp", project.Port) + envVars := d.parseEnvVars(project.Env) + + containerCfg := docker.ContainerConfig{ + Name: containerName, + Image: project.Image + ":" + imageTag, + Env: envVars, + ExposedPorts: []string{portStr}, + NetworkName: settings.Network, + NetworkID: networkID, + Project: project.Name, + Stage: stage.Name, + InstanceID: instanceID, + } + + d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info") + containerID, err := d.docker.CreateContainer(ctx, containerCfg) + if err != nil { + return "", 0, instanceID, fmt.Errorf("create container: %w", err) + } + + // Create instance record. + inst, err := d.store.CreateInstanceWithID(store.Instance{ + ID: instanceID, + StageID: stage.ID, + ProjectID: project.ID, + ContainerID: containerID, + ImageTag: imageTag, + Subdomain: subdomain, + Status: "stopped", + Port: project.Port, + }) + if err != nil { + return containerID, 0, instanceID, fmt.Errorf("create instance record: %w", err) + } + instanceID = inst.ID + + if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil { + slog.Warn("link deploy to instance", "error", err) + } + + d.logDeploy(deployID, fmt.Sprintf("Blue-green: starting green container %s", containerName), "info") + if err := d.docker.StartContainer(ctx, containerID); err != nil { + return containerID, 0, instanceID, fmt.Errorf("start container: %w", err) + } + + if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil { + slog.Warn("update instance status", "error", err) + } + d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running") + + // Step 4: Health check the green container. + if project.Healthcheck != "" { + if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "") + + healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck) + d.logDeploy(deployID, fmt.Sprintf("Blue-green: health checking green at %s", healthURL), "info") + + if err := d.health.Check(ctx, healthURL); err != nil { + return containerID, 0, instanceID, fmt.Errorf("health check green: %w", err) + } + d.logDeploy(deployID, "Blue-green: green health check passed", "info") + } + + // Step 5: Swap NPM proxy to green. + if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") + + npmProxyID, err := d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) + if err != nil { + return containerID, 0, instanceID, fmt.Errorf("configure proxy: %w", err) + } + + inst.NpmProxyID = npmProxyID + inst.Subdomain = subdomain + if err := d.store.UpdateInstance(inst); err != nil { + slog.Warn("update instance with proxy ID", "error", err) + } + + d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info") + + // Step 6: Stop the blue container. + if blueInstance != nil { + d.logDeploy(deployID, fmt.Sprintf("Blue-green: stopping blue instance %s (tag: %s)", blueInstance.ID, blueInstance.ImageTag), "info") + if err := d.removeInstance(ctx, *blueInstance, settings); err != nil { + // Non-fatal: log but continue. Green is already serving traffic. + d.logDeploy(deployID, fmt.Sprintf("Blue-green: warning: failed to remove blue instance: %v", err), "warn") + } else { + d.logDeploy(deployID, "Blue-green: blue instance removed", "info") + } + } + + return containerID, npmProxyID, instanceID, nil +} diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 3b37260..2d1597d 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" - "log" + "log/slog" "sort" + "sync" + "sync/atomic" "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/docker" @@ -28,6 +30,10 @@ type Deployer struct { notifier *notify.Notifier eventBus EventPublisher encKey [32]byte + + // Graceful shutdown: tracks in-progress deploys. + activeWg sync.WaitGroup + shuttingDown atomic.Bool } // EventPublisher is the interface for publishing events to the event bus. @@ -56,10 +62,25 @@ func New( } } +// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown. +func (d *Deployer) Drain() { + d.shuttingDown.Store(true) + slog.Info("deployer: draining in-progress deploys") + d.activeWg.Wait() + slog.Info("deployer: all deploys drained") +} + // TriggerDeploy is the main entry point for deployments. It orchestrates the full flow: // pull image -> create container -> start -> configure proxy -> health check. // On failure, it rolls back (removes container, deletes proxy host, updates status). func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error { + if d.shuttingDown.Load() { + return fmt.Errorf("deployer is shutting down, rejecting new deploy") + } + + d.activeWg.Add(1) + defer d.activeWg.Done() + // Load project and stage from store. project, err := d.store.GetProjectByID(projectID) if err != nil { @@ -71,6 +92,11 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT return fmt.Errorf("get stage: %w", err) } + // Validate promote_from constraint. + if err := d.validatePromoteFrom(stage, imageTag); err != nil { + return fmt.Errorf("promote validation: %w", err) + } + settings, err := d.store.GetSettings() if err != nil { return fmt.Errorf("get settings: %w", err) @@ -87,6 +113,12 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT return fmt.Errorf("create deploy record: %w", err) } + slog.Info("starting deploy", + "deploy_id", deploy.ID, + "project", project.Name, + "stage", stage.Name, + "tag", imageTag, + ) d.logDeploy(deploy.ID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info") // Enforce max_instances before deploying. @@ -95,8 +127,18 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT // Non-fatal: continue with deploy. } - // Execute the deploy pipeline. Track state for rollback. - containerID, npmProxyID, instanceID, deployErr := d.executeDeploy(ctx, project, stage, settings, deploy.ID, imageTag) + // Choose deploy strategy: blue-green if stage has max_instances=1 and an existing instance. + var containerID string + var npmProxyID int + var instanceID string + var deployErr error + + if stage.MaxInstances == 1 { + containerID, npmProxyID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deploy.ID, imageTag) + } else { + // Execute the standard deploy pipeline. Track state for rollback. + containerID, npmProxyID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deploy.ID, imageTag) + } if deployErr != nil { d.logDeploy(deploy.ID, fmt.Sprintf("Deploy failed: %v", deployErr), "error") @@ -116,7 +158,7 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT // Mark deploy as successful. if err := d.store.UpdateDeployStatus(deploy.ID, "success", ""); err != nil { - log.Printf("deployer: update deploy status to success: %v", err) + slog.Warn("update deploy status to success", "error", err) } d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "success", "") @@ -153,7 +195,7 @@ func (d *Deployer) executeDeploy( // Step 1: Pull image. if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil { - log.Printf("deployer: update deploy status: %v", err) + slog.Warn("update deploy status", "error", err) } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "") d.logDeploy(deployID, fmt.Sprintf("Pulling image %s:%s", project.Image, imageTag), "info") @@ -177,7 +219,7 @@ func (d *Deployer) executeDeploy( // Step 3: Create and start container. if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil { - log.Printf("deployer: update deploy status: %v", err) + slog.Warn("update deploy status", "error", err) } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "") @@ -226,7 +268,7 @@ func (d *Deployer) executeDeploy( // Link deploy to instance. if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil { - log.Printf("deployer: link deploy to instance: %v", err) + slog.Warn("link deploy to instance", "error", err) } d.logDeploy(deployID, fmt.Sprintf("Starting container %s", containerName), "info") @@ -235,14 +277,14 @@ func (d *Deployer) executeDeploy( } if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil { - log.Printf("deployer: update instance status to running: %v", err) + slog.Warn("update instance status to running", "error", err) } d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running") d.logDeploy(deployID, "Container started", "info") // Step 4: Configure NPM proxy. if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { - log.Printf("deployer: update deploy status: %v", err) + slog.Warn("update deploy status", "error", err) } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") @@ -255,13 +297,13 @@ func (d *Deployer) executeDeploy( inst.NpmProxyID = npmProxyID inst.Subdomain = subdomain if err := d.store.UpdateInstance(inst); err != nil { - log.Printf("deployer: update instance with proxy ID: %v", err) + slog.Warn("update instance with proxy ID", "error", err) } // Step 5: Health check. if project.Healthcheck != "" { if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil { - log.Printf("deployer: update deploy status: %v", err) + slog.Warn("update deploy status", "error", err) } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "") @@ -390,13 +432,13 @@ func (d *Deployer) enforceMaxInstances(ctx context.Context, stage store.Stage, d func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, settings store.Settings) error { // Mark as removing. if err := d.store.UpdateInstanceStatus(inst.ID, "removing"); err != nil { - log.Printf("deployer: update instance %s status to removing: %v", inst.ID, err) + slog.Warn("update instance status to removing", "instance_id", inst.ID, "error", err) } // Remove Docker container. if inst.ContainerID != "" { if err := d.docker.RemoveContainer(ctx, inst.ContainerID, true); err != nil { - log.Printf("deployer: remove container %s: %v", inst.ContainerID, err) + slog.Warn("remove container", "container_id", inst.ContainerID, "error", err) } } @@ -404,11 +446,11 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett if inst.NpmProxyID > 0 { npmPassword, err := d.decryptNpmPassword(settings.NpmPassword) if err != nil { - log.Printf("deployer: decrypt npm password for proxy cleanup: %v", err) + slog.Warn("decrypt npm password for proxy cleanup", "error", err) } else if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr != nil { - log.Printf("deployer: authenticate npm for proxy cleanup: %v", authErr) + slog.Warn("authenticate npm for proxy cleanup", "error", authErr) } else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil { - log.Printf("deployer: delete proxy host %d: %v", inst.NpmProxyID, delErr) + slog.Warn("delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr) } } @@ -471,7 +513,7 @@ func (d *Deployer) parseEnvVars(envJSON string) []string { var envMap map[string]string if err := json.Unmarshal([]byte(envJSON), &envMap); err != nil { - log.Printf("deployer: parse env vars: %v", err) + slog.Warn("parse env vars", "error", err) return nil } @@ -486,7 +528,7 @@ func (d *Deployer) parseEnvVars(envJSON string) []string { // Errors are logged to stderr but not propagated. func (d *Deployer) logDeploy(deployID, message, level string) { if err := d.store.AppendDeployLog(deployID, message, level); err != nil { - log.Printf("deployer: append deploy log: %v", err) + slog.Warn("append deploy log", "error", err) } if d.eventBus != nil { d.eventBus.Publish(events.Event{ diff --git a/internal/deployer/promote.go b/internal/deployer/promote.go new file mode 100644 index 0000000..043468d --- /dev/null +++ b/internal/deployer/promote.go @@ -0,0 +1,49 @@ +package deployer + +import ( + "fmt" + + "github.com/alexei/docker-watcher/internal/store" +) + +// validatePromoteFrom checks that a tag is running in the promote_from stage +// before allowing it to be deployed to the target stage. +// Returns nil if no promote_from is configured or if the tag is eligible. +func (d *Deployer) validatePromoteFrom(stage store.Stage, imageTag string) error { + if stage.PromoteFrom == "" { + return nil + } + + // Look up the source stage by name within the same project. + stages, err := d.store.GetStagesByProjectID(stage.ProjectID) + if err != nil { + return fmt.Errorf("get stages for project: %w", err) + } + + var sourceStage *store.Stage + for _, s := range stages { + if s.Name == stage.PromoteFrom { + sCopy := s + sourceStage = &sCopy + break + } + } + + if sourceStage == nil { + return fmt.Errorf("promote_from stage %q not found in project", stage.PromoteFrom) + } + + // Check if the tag is running in the source stage. + instances, err := d.store.GetInstancesByStageID(sourceStage.ID) + if err != nil { + return fmt.Errorf("get instances for source stage: %w", err) + } + + for _, inst := range instances { + if inst.ImageTag == imageTag && (inst.Status == "running" || inst.Status == "stopped") { + return nil // Tag found in source stage, promotion is allowed. + } + } + + return fmt.Errorf("tag %q is not running in stage %q; promotion denied", imageTag, stage.PromoteFrom) +} diff --git a/internal/deployer/rollback.go b/internal/deployer/rollback.go index fb6937a..8de53d5 100644 --- a/internal/deployer/rollback.go +++ b/internal/deployer/rollback.go @@ -3,7 +3,7 @@ package deployer import ( "context" "fmt" - "log" + "log/slog" ) // rollback cleans up a failed deployment by removing the container, @@ -15,7 +15,7 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st // Remove the container if it was created. if containerID != "" { if err := d.docker.RemoveContainer(ctx, containerID, true); err != nil { - log.Printf("rollback: remove container %s: %v", containerID, err) + slog.Warn("rollback: remove container", "container_id", containerID, "error", err) d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to remove container: %v", err), "error") } else { d.logDeploy(deployID, "Rollback: container removed", "info") @@ -26,16 +26,16 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st if npmProxyID > 0 { settings, err := d.store.GetSettings() if err != nil { - log.Printf("rollback: get settings for npm auth: %v", err) + slog.Warn("rollback: get settings for npm auth", "error", err) d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to get settings for proxy cleanup: %v", err), "error") } else if npmPassword, err := d.decryptNpmPassword(settings.NpmPassword); err != nil { - log.Printf("rollback: decrypt npm password: %v", err) + slog.Warn("rollback: decrypt npm password", "error", err) d.logDeploy(deployID, "Rollback: failed to decrypt NPM password for proxy cleanup", "error") } else if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { - log.Printf("rollback: authenticate npm: %v", err) + slog.Warn("rollback: authenticate npm", "error", err) d.logDeploy(deployID, "Rollback: failed to authenticate NPM for proxy cleanup", "error") } else if err := d.npm.DeleteProxyHost(ctx, npmProxyID); err != nil { - log.Printf("rollback: delete proxy host %d: %v", npmProxyID, err) + slog.Warn("rollback: delete proxy host", "proxy_id", npmProxyID, "error", err) d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy host: %v", err), "error") } else { d.logDeploy(deployID, "Rollback: proxy host deleted", "info") @@ -45,13 +45,13 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st // Update instance status to failed if it was created. if instanceID != "" { if err := d.store.UpdateInstanceStatus(instanceID, "failed"); err != nil { - log.Printf("rollback: update instance %s status: %v", instanceID, err) + slog.Warn("rollback: update instance status", "instance_id", instanceID, "error", err) } } // Mark deploy as rolled back. if err := d.store.UpdateDeployStatus(deployID, "rolled_back", "deployment failed, rolled back"); err != nil { - log.Printf("rollback: update deploy %s status: %v", deployID, err) + slog.Warn("rollback: update deploy status", "deploy_id", deployID, "error", err) } d.logDeploy(deployID, "Rollback complete", "info") diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..b263653 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,34 @@ +package logging + +import ( + "io" + "log/slog" + "os" +) + +// Setup initializes the global structured JSON logger. +// It replaces the default slog handler with a JSON handler writing to stdout. +func Setup() { + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + slog.SetDefault(slog.New(handler)) +} + +// SetupWithWriter initializes the global structured JSON logger writing to the given writer. +func SetupWithWriter(w io.Writer) { + handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + slog.SetDefault(slog.New(handler)) +} + +// DeployContext returns a logger enriched with deploy-specific attributes. +func DeployContext(project, stage, tag, instanceID string) *slog.Logger { + return slog.With( + slog.String("project", project), + slog.String("stage", stage), + slog.String("tag", tag), + slog.String("instance_id", instanceID), + ) +} diff --git a/internal/store/store.go b/internal/store/store.go index 6ae67fe..e48be54 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -156,8 +156,30 @@ CREATE TABLE IF NOT EXISTS poll_states ( last_polled TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'viewer', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS auth_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + auth_mode TEXT NOT NULL DEFAULT 'local', + oidc_client_id TEXT NOT NULL DEFAULT '', + oidc_client_secret TEXT NOT NULL DEFAULT '', + oidc_issuer_url TEXT NOT NULL DEFAULT '', + oidc_redirect_url TEXT NOT NULL DEFAULT '' +); + -- Seed the settings row if it does not exist. INSERT OR IGNORE INTO settings (id) VALUES (1); + +-- Seed the auth_settings row if it does not exist. +INSERT OR IGNORE INTO auth_settings (id) VALUES (1); ` // now returns the current time formatted for SQLite storage. diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..266211b --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,183 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// User represents an authenticated user stored in the database. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Email string `json:"email"` + Role string `json:"role"` // admin, viewer + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// AuthSettings holds the authentication configuration (single-row pattern). +type AuthSettings struct { + AuthMode string `json:"auth_mode"` // local, oidc + OIDCClientID string `json:"oidc_client_id"` + OIDCClientSecret string `json:"-"` + OIDCIssuerURL string `json:"oidc_issuer_url"` + OIDCRedirectURL string `json:"oidc_redirect_url"` +} + +// CreateUser inserts a new user record. +func (s *Store) CreateUser(u User) (User, error) { + u.ID = uuid.New().String() + u.CreatedAt = now() + u.UpdatedAt = u.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO users (id, username, password_hash, email, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + u.ID, u.Username, u.PasswordHash, u.Email, u.Role, u.CreatedAt, u.UpdatedAt, + ) + if err != nil { + return User{}, fmt.Errorf("insert user: %w", err) + } + return u, nil +} + +// GetUserByID returns a single user by its ID. +func (s *Store) GetUserByID(id string) (User, error) { + var u User + err := s.db.QueryRow( + `SELECT id, username, password_hash, email, role, created_at, updated_at + FROM users WHERE id = ?`, id, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return User{}, fmt.Errorf("user %s: %w", id, ErrNotFound) + } + if err != nil { + return User{}, fmt.Errorf("query user: %w", err) + } + return u, nil +} + +// GetUserByUsername returns a single user by username. +func (s *Store) GetUserByUsername(username string) (User, error) { + var u User + err := s.db.QueryRow( + `SELECT id, username, password_hash, email, role, created_at, updated_at + FROM users WHERE username = ?`, username, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return User{}, fmt.Errorf("user %q: %w", username, ErrNotFound) + } + if err != nil { + return User{}, fmt.Errorf("query user by username: %w", err) + } + return u, nil +} + +// GetAllUsers returns every user ordered by username. +func (s *Store) GetAllUsers() ([]User, error) { + rows, err := s.db.Query( + `SELECT id, username, password_hash, email, role, created_at, updated_at + FROM users ORDER BY username`, + ) + if err != nil { + return nil, fmt.Errorf("query users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + users = append(users, u) + } + return users, rows.Err() +} + +// UpdateUser updates a user's mutable fields (username, email, role). +func (s *Store) UpdateUser(u User) error { + u.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE users SET username=?, email=?, role=?, updated_at=? WHERE id=?`, + u.Username, u.Email, u.Role, u.UpdatedAt, u.ID, + ) + if err != nil { + return fmt.Errorf("update user: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("user %s: %w", u.ID, ErrNotFound) + } + return nil +} + +// UpdateUserPassword updates a user's password hash. +func (s *Store) UpdateUserPassword(id string, passwordHash string) error { + ts := now() + result, err := s.db.Exec( + `UPDATE users SET password_hash=?, updated_at=? WHERE id=?`, + passwordHash, ts, id, + ) + if err != nil { + return fmt.Errorf("update user password: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("user %s: %w", id, ErrNotFound) + } + return nil +} + +// DeleteUser removes a user by ID. +func (s *Store) DeleteUser(id string) error { + result, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("user %s: %w", id, ErrNotFound) + } + return nil +} + +// UserCount returns the total number of users. +func (s *Store) UserCount() (int, error) { + var count int + err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count users: %w", err) + } + return count, nil +} + +// GetAuthSettings returns the auth settings (single-row pattern, always row id=1). +func (s *Store) GetAuthSettings() (AuthSettings, error) { + var as AuthSettings + err := s.db.QueryRow( + `SELECT auth_mode, oidc_client_id, oidc_client_secret, oidc_issuer_url, oidc_redirect_url + FROM auth_settings WHERE id = 1`, + ).Scan(&as.AuthMode, &as.OIDCClientID, &as.OIDCClientSecret, &as.OIDCIssuerURL, &as.OIDCRedirectURL) + if err != nil { + return AuthSettings{}, fmt.Errorf("query auth settings: %w", err) + } + return as, nil +} + +// UpdateAuthSettings updates the auth settings row. +func (s *Store) UpdateAuthSettings(as AuthSettings) error { + _, err := s.db.Exec( + `UPDATE auth_settings SET auth_mode=?, oidc_client_id=?, oidc_client_secret=?, oidc_issuer_url=?, oidc_redirect_url=? + WHERE id = 1`, + as.AuthMode, as.OIDCClientID, as.OIDCClientSecret, as.OIDCIssuerURL, as.OIDCRedirectURL, + ) + if err != nil { + return fmt.Errorf("update auth settings: %w", err) + } + return nil +} diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md index 4fbd9fa..9bc5de9 100644 --- a/plans/docker-watcher-core/PLAN.md +++ b/plans/docker-watcher-core/PLAN.md @@ -34,9 +34,9 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M - [x] Phase 9: SvelteKit Dashboard & Project Views [domain: frontend] → [subplan](./phase-9-dashboard.md) - [x] Phase 10: Quick Deploy & Settings Pages [domain: frontend] → [subplan](./phase-10-settings-deploy.md) - [x] Phase 11: Frontend Embed & Real-Time Updates [domain: fullstack] → [subplan](./phase-11-embed-sse.md) -- [ ] Phase 12: Hardening [domain: backend] → [subplan](./phase-12-hardening.md) -- [ ] Phase 13: Frontend Polish & Modern UI [domain: frontend] → [subplan](./phase-13-ui-polish.md) -- [ ] Phase 14: Volumes & Environment [domain: fullstack] → [subplan](./phase-14-volumes-env.md) +- [x] Phase 12: Hardening [domain: backend] → [subplan](./phase-12-hardening.md) +- [ ] Phase 13: Volumes & Environment [domain: fullstack] → [subplan](./phase-14-volumes-env.md) +- [ ] Phase 14: Frontend Polish & Modern UI [domain: frontend] → [subplan](./phase-13-ui-polish.md) ### Parallel Execution Notes @@ -57,10 +57,10 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M | Phase 8: API Layer | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | | Phase 9: Dashboard | frontend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | | Phase 10: Settings & Deploy | frontend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | -| Phase 11: Embed & SSE | fullstack | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ⬜ | -| Phase 12: Hardening | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | -| Phase 13: UI Polish | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | -| Phase 14: Volumes & Env | fullstack | ⬜ Not Started | ⬜ | ✅ Required (Final) | ⬜ | +| Phase 11: Embed & SSE | fullstack | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | +| Phase 12: Hardening | backend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ⬜ | +| Phase 13: Volumes & Env | fullstack | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | +| Phase 14: UI Polish | frontend | ⬜ Not Started | ⬜ | ✅ Required (Final) | ⬜ | ## Amendment Log @@ -92,6 +92,13 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M **Why:** Root PLAN.md was updated to require OAuth2/OIDC support alongside local auth **Impact on existing phases:** Phase 12 task count increased from 10 to 12. Added new files for auth module and login page. +### Amendment 5 — 2026-03-27 + +**Type:** Reordered phases +**What changed:** Swapped Phase 13 (UI Polish) and Phase 14 (Volumes & Env). Volumes & Env is now Phase 13, UI Polish is now Phase 14 (final). +**Why:** Volumes & Env adds new UI pages that need the polish pass. UI Polish must run last to cover all pages including auth (Phase 12) and volume/env editors (Phase 13). +**Impact on existing phases:** Execution order changed. UI Polish (now Phase 14) remains the final phase with build/test enforcement. + ## Final Review - [ ] Comprehensive code review diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e07f93b..464bb62 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -22,13 +22,26 @@ class ApiError extends Error { } } +function getAuthToken(): string | null { + if (typeof localStorage !== 'undefined') { + return localStorage.getItem('auth_token'); + } + return null; +} + async function request(path: string, init?: RequestInit): Promise { + const token = getAuthToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(init?.headers as Record) + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + const res = await fetch(path, { ...init, - headers: { - 'Content-Type': 'application/json', - ...init?.headers - } + headers }); const envelope: ApiEnvelope = await res.json(); @@ -208,4 +221,20 @@ export function regenerateWebhookUrl(): Promise<{ url: string }> { return post<{ url: string }>('/api/settings/webhook-url/regenerate'); } +// ── Auth ───────────────────────────────────────────────────────────── + +export function login(username: string, password: string): Promise<{ token: string; expires_at: string }> { + return post<{ token: string; expires_at: string }>('/api/auth/login', { username, password }); +} + +export function getCurrentUser(): Promise<{ id: string; username: string; email: string; role: string }> { + return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me'); +} + +// ── Config Export ──────────────────────────────────────────────────── + +export function exportConfigUrl(): string { + return '/api/config/export'; +} + export { ApiError }; diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..4c6171a --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,128 @@ + + +
+
+
+
+ + + +

Docker Watcher

+

Sign in to your account

+
+ + {#if error} +
+ {error} +
+ {/if} + +
{ e.preventDefault(); handleLogin(); }} class="space-y-4"> +
+ + +
+ +
+ + +
+ + +
+ +
+
+
+
+
+
+ or +
+
+ + +
+
+
+
diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte index 24701ee..503ca24 100644 --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -11,7 +11,8 @@ const navItems = [ { href: '/settings', label: 'General' }, { href: '/settings/registries', label: 'Registries' }, - { href: '/settings/credentials', label: 'Credentials' } + { href: '/settings/credentials', label: 'Credentials' }, + { href: '/settings/auth', label: 'Authentication' } ]; let currentPath = $derived($page.url.pathname); diff --git a/web/src/routes/settings/auth/+page.svelte b/web/src/routes/settings/auth/+page.svelte new file mode 100644 index 0000000..1952255 --- /dev/null +++ b/web/src/routes/settings/auth/+page.svelte @@ -0,0 +1,317 @@ + + +
+
+

Authentication Settings

+

Configure authentication mode and manage users.

+
+ + {#if message} +
{message}
+ {/if} + {#if error} +
{error}
+ {/if} + + +
+

Authentication Mode

+
+ + +
+
+ + + {#if settings.auth_mode === 'oidc'} +
+

OIDC Provider Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/if} + + + + +
+

Local Users

+ + {#if users.length > 0} + + + + + + + + + + + + {#each users as user} + + + + + + + + {/each} + +
UsernameEmailRoleCreated
{user.username}{user.email || '-'} + + {user.role} + + {user.created_at} + +
+ {:else} +

No users found.

+ {/if} + +
+

Add User

+
+ + + + +
+ +
+
+