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.
This commit is contained in:
+48
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
|
||||
+80
-16
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-66
@@ -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
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <token>
|
||||
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")
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+33
-4
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers as Record<string, string>)
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers
|
||||
}
|
||||
headers
|
||||
});
|
||||
|
||||
const envelope: ApiEnvelope<T> = 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 };
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Check if we got a token from OIDC callback redirect.
|
||||
const urlToken = $page.url.searchParams.get('token');
|
||||
if (urlToken) {
|
||||
localStorage.setItem('auth_token', urlToken);
|
||||
goto('/');
|
||||
}
|
||||
|
||||
// If already logged in, redirect to dashboard.
|
||||
const existingToken = localStorage.getItem('auth_token');
|
||||
if (existingToken) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const envelope = await res.json();
|
||||
|
||||
if (!envelope.success) {
|
||||
error = envelope.error ?? 'Login failed';
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', envelope.data.token);
|
||||
goto('/');
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOIDCLogin() {
|
||||
window.location.href = '/api/auth/oidc/login';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
|
||||
<div class="mb-6 text-center">
|
||||
<svg class="mx-auto h-10 w-10 text-indigo-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
||||
</svg>
|
||||
<h1 class="mt-3 text-xl font-bold text-gray-900">Docker Watcher</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
required
|
||||
autocomplete="username"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-200"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs">
|
||||
<span class="bg-white px-2 text-gray-400">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={handleOIDCLogin}
|
||||
class="mt-4 w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
Sign in with SSO (OIDC)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface AuthSettings {
|
||||
auth_mode: string;
|
||||
oidc_client_id: string;
|
||||
oidc_client_secret: string;
|
||||
oidc_issuer_url: string;
|
||||
oidc_redirect_url: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let settings = $state<AuthSettings>({
|
||||
auth_mode: 'local',
|
||||
oidc_client_id: '',
|
||||
oidc_client_secret: '',
|
||||
oidc_issuer_url: '',
|
||||
oidc_redirect_url: ''
|
||||
});
|
||||
let users = $state<User[]>([]);
|
||||
let saving = $state(false);
|
||||
let message = $state('');
|
||||
let error = $state('');
|
||||
|
||||
// New user form
|
||||
let newUsername = $state('');
|
||||
let newPassword = $state('');
|
||||
let newEmail = $state('');
|
||||
let newRole = $state('viewer');
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem('auth_token') ?? '';
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getToken()}`
|
||||
};
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadSettings(), loadUsers()]);
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { headers: authHeaders() });
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
settings = envelope.data;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load settings';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/users', { headers: authHeaders() });
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
users = envelope.data ?? [];
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load users';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving = true;
|
||||
message = '';
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
message = 'Settings saved';
|
||||
} else {
|
||||
error = envelope.error ?? 'Failed to save';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
if (!newUsername || !newPassword) {
|
||||
error = 'Username and password are required';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/users', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({
|
||||
username: newUsername,
|
||||
password: newPassword,
|
||||
email: newEmail,
|
||||
role: newRole
|
||||
})
|
||||
});
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
newUsername = '';
|
||||
newPassword = '';
|
||||
newEmail = '';
|
||||
newRole = 'viewer';
|
||||
await loadUsers();
|
||||
message = 'User created';
|
||||
} else {
|
||||
error = envelope.error ?? 'Failed to create user';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: string) {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/auth/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
await loadUsers();
|
||||
message = 'User deleted';
|
||||
} else {
|
||||
error = envelope.error ?? 'Failed to delete user';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Authentication Settings</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Configure authentication mode and manage users.</p>
|
||||
</div>
|
||||
|
||||
{#if message}
|
||||
<div class="rounded-md bg-green-50 p-3 text-sm text-green-700">{message}</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Auth Mode Toggle -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Authentication Mode</h2>
|
||||
<div class="mt-4 flex gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={settings.auth_mode} value="local" class="text-indigo-600" />
|
||||
<span class="text-sm font-medium text-gray-700">Local (username/password)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={settings.auth_mode} value="oidc" class="text-indigo-600" />
|
||||
<span class="text-sm font-medium text-gray-700">OIDC (SSO)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OIDC Configuration -->
|
||||
{#if settings.auth_mode === 'oidc'}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">OIDC Provider Configuration</h2>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label for="issuer" class="block text-sm font-medium text-gray-700">Issuer URL</label>
|
||||
<input
|
||||
id="issuer"
|
||||
type="url"
|
||||
bind:value={settings.oidc_issuer_url}
|
||||
placeholder="https://auth.example.com/application/o/docker-watcher/"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700">Client ID</label>
|
||||
<input
|
||||
id="client_id"
|
||||
type="text"
|
||||
bind:value={settings.oidc_client_id}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
|
||||
<input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
bind:value={settings.oidc_client_secret}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="redirect" class="block text-sm font-medium text-gray-700">Redirect URL</label>
|
||||
<input
|
||||
id="redirect"
|
||||
type="url"
|
||||
bind:value={settings.oidc_redirect_url}
|
||||
placeholder="https://watcher.example.com/api/auth/oidc/callback"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={saveSettings}
|
||||
disabled={saving}
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
|
||||
<!-- Local Users Management -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Local Users</h2>
|
||||
|
||||
{#if users.length > 0}
|
||||
<table class="mt-4 min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Username</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Email</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Role</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Created</th>
|
||||
<th class="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{#each users as user}
|
||||
<tr>
|
||||
<td class="px-3 py-2 text-sm text-gray-900">{user.username}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500">{user.email || '-'}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold {user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'}">
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500">{user.created_at}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="mt-4 text-sm text-gray-500">No users found.</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 pt-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Add User</h3>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
placeholder="Username"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="Password"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
placeholder="Email (optional)"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<select
|
||||
bind:value={newRole}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onclick={addUser}
|
||||
class="mt-3 rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-gray-900"
|
||||
>
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user