feat: public status page endpoint with per-address rate limit

This commit is contained in:
2026-08-24 14:29:24 +00:00
parent 21a2d077d8
commit a3c6b2a305
5 changed files with 351 additions and 0 deletions
+229
View File
@@ -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": [
+4
View File
@@ -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
+90
View File
@@ -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)
}