diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index e60080e..bfcd771 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -256,10 +256,16 @@ func (api *API) StartConvergenceLoop(ctx gocontext.Context, interval time.Durati slog.Info("convergence loop started", "component", "reconcile", "interval", interval) } -func (api *API) RegisterRoutes(r *mux.Router) { +func (api *API) RegisterRoutes(r *mux.Router, bearerToken string, devMode bool) { r.Use(RequestLoggingMiddleware) - // Health + // Bearer token authentication — protects all /api/ routes. + // Dev mode skips auth for ease of development. + if !devMode && bearerToken != "" { + r.Use(BearerAuthMiddleware(bearerToken)) + } + + // Health (accessible even with auth — middleware exempts /health) r.HandleFunc("/api/v1/health/reconcile", api.ReconcileHealth).Methods("GET") // Resource-oriented settings (persisted in state.yaml) diff --git a/internal/api/v1/middleware.go b/internal/api/v1/middleware.go index 562a0e1..2a50a7b 100644 --- a/internal/api/v1/middleware.go +++ b/internal/api/v1/middleware.go @@ -37,6 +37,43 @@ func (w *statusResponseWriter) Flush() { } } +// BearerAuthMiddleware returns middleware that requires a valid Bearer token +// on all API endpoints except health checks and the SSE event stream. +func BearerAuthMiddleware(token string) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + // Public endpoints — no auth required + if path == "/api/v1/health" || path == "/api/v1/health/reconcile" || + strings.HasSuffix(path, "/events") { + next.ServeHTTP(w, r) + return + } + + // Non-API paths (frontend static files) — no auth required + if !strings.HasPrefix(path, "/api/") { + next.ServeHTTP(w, r) + return + } + + auth := r.Header.Get("Authorization") + if auth == "" { + http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized) + return + } + + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) || auth[len(prefix):] != token { + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} + // RequestLoggingMiddleware logs method, path, status, and duration for each request. // Long-lived connections (SSE, WebSocket) are excluded. func RequestLoggingMiddleware(next http.Handler) http.Handler { diff --git a/main.go b/main.go index 765a52a..1d29692 100644 --- a/main.go +++ b/main.go @@ -119,6 +119,19 @@ func main() { os.Exit(1) } + // Load or generate API bearer token + apiToken, _ := secretsMgr.GetSecret("api.bearerToken") + if apiToken == "" { + apiToken, _ = secrets.GenerateSecret(32) + _ = secretsMgr.SetSecret("api.bearerToken", apiToken) + slog.Info("generated API bearer token", "component", "startup") + } + + isDev := os.Getenv("WILD_CENTRAL_ENV") == "development" + if isDev { + slog.Info("development mode: API authentication disabled", "component", "startup") + } + allowedOrigins := buildAllowedOrigins(dataDir) api, err := v1.NewAPI(dataDir, Version, allowedOrigins) @@ -137,7 +150,7 @@ func main() { api.StartDNSFilter(ctx) router := mux.NewRouter() - api.RegisterRoutes(router) + api.RegisterRoutes(router, apiToken, isDev) router.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/web/src/services/api/client.ts b/web/src/services/api/client.ts index 41affc0..e5cbb30 100644 --- a/web/src/services/api/client.ts +++ b/web/src/services/api/client.ts @@ -21,17 +21,43 @@ interface ErrorResponseBody { import { getApiBaseUrl } from './config'; +const TOKEN_KEY = 'wild-central:token'; + export class ApiClient { private baseUrl: string; + private token: string | null = null; constructor(baseUrl: string = getApiBaseUrl()) { this.baseUrl = baseUrl; + // Load token from localStorage on init + try { this.token = localStorage.getItem(TOKEN_KEY); } catch { /* noop */ } } getBaseURL(): string { return this.baseUrl; } + setToken(token: string) { + this.token = token; + try { localStorage.setItem(TOKEN_KEY, token); } catch { /* noop */ } + } + + clearToken() { + this.token = null; + try { localStorage.removeItem(TOKEN_KEY); } catch { /* noop */ } + } + + hasToken(): boolean { + return this.token !== null && this.token !== ''; + } + + private authHeaders(): Record { + if (this.token) { + return { 'Authorization': `Bearer ${this.token}` }; + } + return {}; + } + private async request( endpoint: string, options?: RequestInit @@ -43,6 +69,7 @@ export class ApiClient { ...options, headers: { 'Content-Type': 'application/json', + ...this.authHeaders(), ...options?.headers, }, }); @@ -104,7 +131,7 @@ export class ApiClient { async getText(endpoint: string): Promise { const url = `${this.baseUrl}${endpoint}`; - const response = await fetch(url); + const response = await fetch(url, { headers: this.authHeaders() }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); @@ -122,7 +149,7 @@ export class ApiClient { const url = `${this.baseUrl}${endpoint}`; const response = await fetch(url, { method: 'PUT', - headers: { 'Content-Type': 'text/plain' }, + headers: { 'Content-Type': 'text/plain', ...this.authHeaders() }, body: text, });