feat: public status page endpoint with per-address rate limit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user