From 18d5c3034661ebacd3a8d384607e24ebcf87c658 Mon Sep 17 00:00:00 2001 From: "Przemyslaw (Ozzy) Ozgo" Date: Sun, 1 Feb 2026 18:42:05 +0100 Subject: [PATCH] fix: healthchecks --- API.md | 17 ++++- Dockerfile | 10 ++- docker-compose.yml | 2 +- docs/SECURITY.md | 28 ++++++-- docs/docs.go | 30 ++++++++ docs/swagger.json | 30 ++++++++ docs/swagger.yaml | 21 ++++++ internal/middleware/auth.go | 17 +++-- internal/middleware/auth_test.go | 118 +++++++++++++++++++++++++++++++ internal/server/handlers_test.go | 101 ++++++++++++++++++++++++++ internal/server/server.go | 16 ++++- scripts/healthcheck.sh | 37 ++++++++++ 12 files changed, 406 insertions(+), 21 deletions(-) create mode 100755 scripts/healthcheck.sh diff --git a/API.md b/API.md index bf36722..acf37b8 100644 --- a/API.md +++ b/API.md @@ -113,19 +113,24 @@ export AUTH_API_TOKEN="your-api-token-here" **HTTP Basic Auth:** ```bash -curl -u admin:password http://localhost:7777/api/health +curl -u admin:password http://localhost:7777/api/keys ``` **Bearer Token:** ```bash -curl -H "Authorization: Bearer your-token" http://localhost:7777/api/health +curl -H "Authorization: Bearer your-token" http://localhost:7777/api/keys ``` +### Unauthenticated Endpoints + +The `/api/health` endpoint is exempt from authentication to allow Docker health checks, Kubernetes probes, and load balancer monitoring to work without credentials. + ### Security Features - Constant-time credential comparison (prevents timing attacks) - Supports both Basic Auth and Bearer token simultaneously - Bearer token takes precedence if both are provided - bcrypt password hashing for stored credentials +- Health endpoint excluded from auth for container orchestration compatibility **Note**: In production environments, always enable authentication and use HTTPS. @@ -135,10 +140,12 @@ curl -H "Authorization: Bearer your-token" http://localhost:7777/api/health ### Get Server Health Status -Check if the server is running and responsive. +Check if the server is running and responsive. This endpoint does **not** require authentication, making it suitable for Docker health checks, Kubernetes probes, and load balancer health monitoring. **Endpoint**: `GET /health` +**Authentication**: None required + **Response**: `200 OK` ```json @@ -150,7 +157,11 @@ Check if the server is running and responsive. **Example**: ```bash +# No authentication needed curl http://localhost:7777/api/health + +# Also works with HTTPS when TLS is enabled +curl -k https://localhost:7777/api/health ``` --- diff --git a/Dockerfile b/Dockerfile index 5fb3cc5..c9084cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -94,8 +94,12 @@ WORKDIR /app # Copy binary from builder COPY --from=go-builder /app/web-cli /app/web-cli +# Copy healthcheck script +COPY scripts/healthcheck.sh /app/healthcheck.sh + # Set ownership -RUN chown webcli:webcli /app/web-cli +RUN chown webcli:webcli /app/web-cli /app/healthcheck.sh && \ + chmod +x /app/healthcheck.sh # Switch to non-root user USER webcli @@ -103,9 +107,9 @@ USER webcli # Expose default port EXPOSE 7777 -# Health check using curl +# Health check using script (auto-detects http/https based on TLS config) HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD curl -sf http://localhost:7777/api/health || exit 1 + CMD ["/app/healthcheck.sh"] # Environment variables with defaults # Note: WEBCLI_ENCRYPTION_KEY_PATH is a file path to the key file, not the secret itself diff --git a/docker-compose.yml b/docker-compose.yml index 0e47f75..31eec5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -96,7 +96,7 @@ services: # CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:7777/api/health"] + test: ["CMD", "/app/healthcheck.sh"] interval: 30s timeout: 5s retries: 3 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 785dcad..cfdd2b9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -47,27 +47,41 @@ export AUTH_API_TOKEN="your-api-token-here" - Supports both methods simultaneously (token takes precedence) - **Startup validation**: Server fails fast if auth is enabled but credentials are missing +### Unauthenticated Endpoints + +The following endpoints are exempt from authentication to support container health checks and orchestration probes: + +| Endpoint | Purpose | +|----------|---------| +| `/api/health` | Health check for Docker/Kubernetes probes | + ### Usage Examples ```bash -# Basic Auth -curl -u admin:password http://localhost:7777/api/health +# Health check (no auth required) +curl http://localhost:7777/api/health + +# Basic Auth (required for all other endpoints) +curl -u admin:password http://localhost:7777/api/keys -# Bearer Token -curl -H "Authorization: Bearer your-token" http://localhost:7777/api/health +# Bearer Token (required for all other endpoints) +curl -H "Authorization: Bearer your-token" http://localhost:7777/api/keys ``` ### Testing Authentication ```bash -# Should fail (401 Unauthorized) +# Health endpoint - always succeeds (no auth required) curl http://localhost:7777/api/health +# Other endpoints - should fail without auth (401 Unauthorized) +curl http://localhost:7777/api/keys + # Should succeed with Basic Auth -curl -u admin:password http://localhost:7777/api/health +curl -u admin:password http://localhost:7777/api/keys # Should succeed with Bearer token -curl -H "Authorization: Bearer your-token" http://localhost:7777/api/health +curl -H "Authorization: Bearer your-token" http://localhost:7777/api/keys ``` --- diff --git a/docs/docs.go b/docs/docs.go index ee2df02..6c993a9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -832,6 +832,26 @@ const docTemplate = `{ } } }, + "/health": { + "get": { + "description": "Check if the server is running and responsive. This endpoint does not require authentication.", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_server.HealthResponse" + } + } + } + } + }, "/history": { "get": { "security": [ @@ -3840,6 +3860,16 @@ const docTemplate = `{ } } }, + "internal_server.HealthResponse": { + "description": "Health check response", + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + }, "internal_server.ShellInfo": { "description": "Information about an available shell", "type": "object", diff --git a/docs/swagger.json b/docs/swagger.json index 0914d0d..f32b2b1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -826,6 +826,26 @@ } } }, + "/health": { + "get": { + "description": "Check if the server is running and responsive. This endpoint does not require authentication.", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_server.HealthResponse" + } + } + } + } + }, "/history": { "get": { "security": [ @@ -3834,6 +3854,16 @@ } } }, + "internal_server.HealthResponse": { + "description": "Health check response", + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + }, "internal_server.ShellInfo": { "description": "Information about an available shell", "type": "object", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 07906ae..e95c30b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -656,6 +656,13 @@ definitions: example: Invalid request body type: string type: object + internal_server.HealthResponse: + description: Health check response + properties: + status: + example: ok + type: string + type: object internal_server.ShellInfo: description: Information about an available shell properties: @@ -1206,6 +1213,20 @@ paths: summary: List all environment variable groups tags: - Environment Variables + /health: + get: + description: Check if the server is running and responsive. This endpoint does + not require authentication. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_server.HealthResponse' + summary: Health check + tags: + - System /history: get: consumes: diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index bcbf85b..5a7bb0f 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -13,10 +13,11 @@ var ErrAuthMisconfigured = errors.New("authentication is enabled but no credenti // AuthConfig holds authentication configuration type AuthConfig struct { - Enabled bool - Username string - Password string - APIToken string + Enabled bool + Username string + Password string + APIToken string + ExcludePaths []string // Paths exempt from authentication (e.g., /api/health) } // LoadAuthConfig loads authentication configuration from environment @@ -61,6 +62,14 @@ func BasicAuth(config *AuthConfig) func(http.Handler) http.Handler { return } + // Skip auth for excluded paths (e.g., health checks) + for _, path := range config.ExcludePaths { + if r.URL.Path == path { + next.ServeHTTP(w, r) + return + } + } + // Check for API token first (Bearer token in Authorization header) authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index 2683821..330bdbd 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -218,3 +218,121 @@ func TestBasicAuth_ConstantTimeComparison(t *testing.T) { t.Error("Invalid credentials should not authenticate") } } + +func TestBasicAuth_ExcludedPaths(t *testing.T) { + config := &AuthConfig{ + Enabled: true, + Username: "admin", + Password: "secret", + ExcludePaths: []string{"/api/health"}, + } + + handler := BasicAuth(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + })) + + tests := []struct { + name string + path string + withAuth bool + expectedStatus int + }{ + { + name: "excluded path without auth", + path: "/api/health", + withAuth: false, + expectedStatus: http.StatusOK, + }, + { + name: "excluded path with auth", + path: "/api/health", + withAuth: true, + expectedStatus: http.StatusOK, + }, + { + name: "non-excluded path without auth", + path: "/api/keys", + withAuth: false, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "non-excluded path with auth", + path: "/api/keys", + withAuth: true, + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + if tt.withAuth { + req.SetBasicAuth("admin", "secret") + } + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d for %s, got %d", tt.expectedStatus, tt.path, w.Code) + } + }) + } +} + +func TestBasicAuth_ExcludedPathsEmpty(t *testing.T) { + // Verify that with no excluded paths, all paths require auth + config := &AuthConfig{ + Enabled: true, + Username: "admin", + Password: "secret", + ExcludePaths: nil, + } + + handler := BasicAuth(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/api/health", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d when ExcludePaths is nil, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestBasicAuth_ExcludedPathsMultiple(t *testing.T) { + config := &AuthConfig{ + Enabled: true, + Username: "admin", + Password: "secret", + ExcludePaths: []string{"/api/health", "/api/readyz"}, + } + + handler := BasicAuth(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + tests := []struct { + path string + expectedStatus int + }{ + {"/api/health", http.StatusOK}, + {"/api/readyz", http.StatusOK}, + {"/api/keys", http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d for %s, got %d", tt.expectedStatus, tt.path, w.Code) + } + }) + } +} diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index c23347a..c6c7a21 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -10,6 +10,7 @@ import ( "github.com/gorilla/mux" "github.com/pozgo/web-cli/internal/database" + "github.com/pozgo/web-cli/internal/middleware" "github.com/pozgo/web-cli/internal/models" "github.com/pozgo/web-cli/internal/repository" ) @@ -39,6 +40,106 @@ func setupTestServer(t *testing.T) (*Server, func()) { return server, cleanup } +func TestHandleHealth(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + req, err := http.NewRequest("GET", "/api/health", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + rr := httptest.NewRecorder() + server.handleHealth(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("Handler returned wrong status: got %v want %v", status, http.StatusOK) + } + + expected := `{"status":"ok"}` + if rr.Body.String() != expected { + t.Errorf("Handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + } + + contentType := rr.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Handler returned wrong content type: got %v want application/json", contentType) + } +} + +func TestHandleHealth_NoAuthRequired(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + // Simulate full routing with auth middleware enabled + router := mux.NewRouter() + + authConfig := &middleware.AuthConfig{ + Enabled: true, + Username: "admin", + Password: "secret", + ExcludePaths: []string{"/api/health"}, + } + router.Use(middleware.BasicAuth(authConfig)) + + api := router.PathPrefix("/api").Subrouter() + api.HandleFunc("/health", server.handleHealth).Methods("GET") + api.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }).Methods("GET") + + tests := []struct { + name string + path string + withAuth bool + expectedStatus int + }{ + { + name: "health without auth succeeds", + path: "/api/health", + withAuth: false, + expectedStatus: http.StatusOK, + }, + { + name: "health with auth succeeds", + path: "/api/health", + withAuth: true, + expectedStatus: http.StatusOK, + }, + { + name: "other endpoint without auth fails", + path: "/api/keys", + withAuth: false, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "other endpoint with auth succeeds", + path: "/api/keys", + withAuth: true, + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", tt.path, nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + if tt.withAuth { + req.SetBasicAuth("admin", "secret") + } + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if status := rr.Code; status != tt.expectedStatus { + t.Errorf("%s: got status %v, want %v", tt.name, status, tt.expectedStatus) + } + }) + } +} + func TestHandleListBashScripts(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup() diff --git a/internal/server/server.go b/internal/server/server.go index 60534c5..2f23c97 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -52,13 +52,17 @@ func (s *Server) setupRoutes() { // Load auth configuration authConfig := middleware.LoadAuthConfig() - // Apply authentication middleware to ALL routes (frontend + API) + // Exempt health endpoint from authentication + // Health checks must work without credentials for Docker/K8s probes + authConfig.ExcludePaths = []string{"/api/health"} + + // Apply authentication middleware to all routes except excluded paths s.router.Use(middleware.BasicAuth(authConfig)) // API routes api := s.router.PathPrefix("/api").Subrouter() - // Health endpoint (authenticated) + // Health endpoint (unauthenticated - excluded from auth middleware) api.HandleFunc("/health", s.handleHealth).Methods("GET") // SSH Keys endpoints @@ -166,7 +170,13 @@ func (s *Server) setupRoutes() { s.serveFrontend() } -// handleHealth returns the health status of the server +// handleHealth godoc +// @Summary Health check +// @Description Check if the server is running and responsive. This endpoint does not require authentication. +// @Tags System +// @Produce json +// @Success 200 {object} HealthResponse +// @Router /health [get] func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100755 index 0000000..3b03f71 --- /dev/null +++ b/scripts/healthcheck.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +# healthcheck.sh - Docker/container health check script for web-cli +# +# Determines the correct scheme (http/https) based on TLS configuration +# and checks the /api/health endpoint. The health endpoint does not +# require authentication. +# +# Environment variables used: +# WEBCLI_PORT - Port the server listens on (default: 7777) +# WEBCLI_TLS_CERT_PATH - TLS certificate path (if set, uses https) +# WEBCLI_TLS_KEY_PATH - TLS key path (if set, uses https) +# TLS_CERT_PATH - Alternative TLS cert path env var +# TLS_KEY_PATH - Alternative TLS key path env var + +readonly PORT="${WEBCLI_PORT:-7777}" + +# Determine scheme based on TLS configuration +# Check both WEBCLI_TLS_* and TLS_* env vars (server accepts both) +CERT_PATH="${WEBCLI_TLS_CERT_PATH:-${TLS_CERT_PATH:-}}" +KEY_PATH="${WEBCLI_TLS_KEY_PATH:-${TLS_KEY_PATH:-}}" + +if [[ -n "${CERT_PATH}" && -n "${KEY_PATH}" ]]; then + SCHEME="https" +else + SCHEME="http" +fi + +readonly URL="${SCHEME}://localhost:${PORT}/api/health" + +# Use -k to accept self-signed certificates in TLS mode +if [[ "${SCHEME}" == "https" ]]; then + exec curl -sfk "${URL}" +else + exec curl -sf "${URL}" +fi