diff --git a/docsite/docs/reference/environment-variables.md b/docsite/docs/reference/environment-variables.md index fee38df..b0239a3 100644 --- a/docsite/docs/reference/environment-variables.md +++ b/docsite/docs/reference/environment-variables.md @@ -25,6 +25,7 @@ it is absent. | `VANTAGE_LICENSE` | no | | A licence supplied at startup, so an automated install does not have to paste one in | | `VANTAGE_TRIVY_DB_REF` | no | `ghcr.io/aquasecurity/trivy-db:2` | Where the vulnerability database is pulled from. Point it at a mirror for an air-gapped install | | `VANTAGE_VULNDB_DISABLED` | no | | `true` switches [vulnerability scanning](../vantage/vulnerabilities.md) off entirely. Findings already stored are still served, and still shown as stale | +| `TRUSTED_PROXIES` | no | | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. Unset trusts none, so the client address is the direct peer — behind a reverse proxy that makes every visitor share one address for rate-limiting purposes. Set it to your proxy's range | :::danger `KEY_ENCRYPTION_KEY` has no recovery path It encrypts SSH private keys, vault secrets, OIDC client secrets and console diff --git a/server/cmd/main.go b/server/cmd/main.go index 61f1796..6103ff7 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -263,6 +263,14 @@ func serve() { }) r := gin.New() + // Without this gin trusts every proxy and ClientIP() is whatever the + // caller wrote in X-Forwarded-For. That was survivable while ClientIP() + // only produced audit strings; the public status limiter makes it load + // bearing. Empty means trust nobody, which is correct for a direct + // exposure and wrong behind a proxy — hence the explicit setting. + if err := r.SetTrustedProxies(trustedProxies()); err != nil { + log.Fatalf("trusted proxies: %v", err) + } r.Use(gin.Recovery()) r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}})) r.Use(corsMiddleware()) @@ -318,6 +326,25 @@ func corsMiddleware() gin.HandlerFunc { } } +// trustedProxies reads TRUSTED_PROXIES, a comma-separated list of CIDRs or +// addresses. Unset means trust none: ClientIP() is then the peer address, +// which is right for a direct exposure and means every request behind an +// un-configured proxy shares one address for rate limiting. That is a visible +// failure (one client limited) rather than an invisible one (no limit at all). +func trustedProxies() []string { + v := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES")) + if v == "" { + return nil + } + out := []string{} + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/server/internal/api/docs/openapi.json b/server/internal/api/docs/openapi.json index d7c8605..8f73b33 100644 --- a/server/internal/api/docs/openapi.json +++ b/server/internal/api/docs/openapi.json @@ -2016,6 +2016,184 @@ }, "type": "object" }, + "services.PublicBanner": { + "properties": { + "level": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "services.PublicComponent": { + "properties": { + "days": { + "items": { + "$ref": "#/components/schemas/services.PublicDay" + }, + "type": "array", + "uniqueItems": false + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "uptime_90d": { + "type": "number" + } + }, + "type": "object" + }, + "services.PublicDay": { + "properties": { + "date": { + "type": "string" + }, + "state": { + "type": "string" + }, + "uptime": { + "type": "number" + } + }, + "type": "object" + }, + "services.PublicIncident": { + "properties": { + "affected": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "id": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "resolved_at": { + "type": "string" + }, + "scheduled_end": { + "type": "string" + }, + "scheduled_start": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updates": { + "items": { + "$ref": "#/components/schemas/services.PublicIncidentUpdate" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "services.PublicIncidentUpdate": { + "properties": { + "at": { + "type": "string" + }, + "body": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + }, + "services.PublicSection": { + "properties": { + "components": { + "items": { + "$ref": "#/components/schemas/services.PublicComponent" + }, + "type": "array", + "uniqueItems": false + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "services.StatusSnapshot": { + "properties": { + "active_incidents": { + "items": { + "$ref": "#/components/schemas/services.PublicIncident" + }, + "type": "array", + "uniqueItems": false + }, + "available": { + "type": "boolean" + }, + "banner": { + "$ref": "#/components/schemas/services.PublicBanner" + }, + "description": { + "type": "string" + }, + "generated_at": { + "type": "string" + }, + "history": { + "items": { + "$ref": "#/components/schemas/services.PublicIncident" + }, + "type": "array", + "uniqueItems": false + }, + "logo_url": { + "type": "string" + }, + "overall": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "sections": { + "items": { + "$ref": "#/components/schemas/services.PublicSection" + }, + "type": "array", + "uniqueItems": false + }, + "title": { + "type": "string" + }, + "upcoming_maintenance": { + "items": { + "$ref": "#/components/schemas/services.PublicIncident" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "services.WorkloadHit": { "properties": { "server_id": { @@ -4591,6 +4769,57 @@ ] } }, + "/public/status/{pageId}": { + "get": { + "parameters": [ + { + "description": "Status page id", + "in": "path", + "name": "pageId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/services.StatusSnapshot" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Public status page", + "tags": [ + "status" + ] + } + }, "/runs/{runId}": { "get": { "parameters": [ diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 0f8c15e..6e7694f 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -47,6 +47,10 @@ func RegisterRoutes(r *gin.Engine) { r.GET("/auth/oidc/:providerId/callback", auth.HandleSSOCallback) r.GET("/auth/providers", auth.HandleListPublicProviders) + // Completely public: no session, no token, no licence gate. Mounted here + // rather than under /api precisely so that none of those apply. + r.GET("/public/status/:pageId", RateLimitPublicStatus(), getPublicStatusPage) + apiGroup := r.Group("/api") apiGroup.Use(auth.Middleware()) // Scope enforcement sits between authentication and the licence gate, and diff --git a/server/internal/api/publicstatus.go b/server/internal/api/publicstatus.go new file mode 100644 index 0000000..75ecc1b --- /dev/null +++ b/server/internal/api/publicstatus.go @@ -0,0 +1,90 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// publicStatusRateLimit is per client address per minute. Generous enough that +// a busy page during an outage is unaffected, small enough that scanning for +// page ids is not free. +const publicStatusRateLimit = 120 + +// RateLimitPublicStatus counts requests per client address in a one-minute +// fixed window, exactly as RateLimitTokens does — including the part that +// matters most: when Redis is unavailable it allows rather than denies. A +// status page must survive the outage it exists to report. +func RateLimitPublicStatus() gin.HandlerFunc { + return func(c *gin.Context) { + rdb := auth.Redis() + if rdb == nil { + c.Next() + return + } + window := time.Now().UTC().Unix() / 60 + key := "vantage:statusrl:" + c.ClientIP() + ":" + strconv.FormatInt(window, 10) + + count, err := rdb.Incr(c.Request.Context(), key).Result() + if err != nil { + c.Next() + return + } + if count == 1 { + rdb.Expire(c.Request.Context(), key, 2*time.Minute) + } + if count > publicStatusRateLimit { + c.Header("Retry-After", "60") + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "too many requests", + "code": "rate_limited", + }) + return + } + c.Next() + } +} + +// getPublicStatusPage is the only unauthenticated read of monitor data in the +// product. +// +// It is mounted on the gin root rather than under /api on purpose: /api +// carries auth.Middleware, RequireScopes, RateLimitTokens and +// RequireActiveLicense by virtue of where it is mounted, and a public route +// there would need four exemptions, each one a hole a later change can widen. +// +// Unknown host, unknown page and unpublished page all answer the same 404. +// +// @Summary Public status page +// @Tags status +// @Produce json +// @Param pageId path string true "Status page id" +// @Success 200 {object} services.StatusSnapshot +// @Failure 404 {object} ErrorResponse +// @Failure 429 {object} ErrorResponse +// @Router /public/status/{pageId} [get] +func getPublicStatusPage(c *gin.Context) { + inst, ok := auth.InstanceFromHost(c) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + snap, err := services.PublicStatusSnapshot(inst.InstanceID, c.Param("pageId")) + if errors.Is(err, services.ErrPageNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + // Public and cacheable, but only briefly: an intermediary holding this for + // minutes would show a resolved incident as ongoing. + c.Header("Cache-Control", "public, max-age=30") + c.JSON(http.StatusOK, snap) +}