feat(monitors): public heartbeat ping endpoints, header token, log masking and sweeper

This commit is contained in:
2026-09-17 08:27:34 +00:00
parent 839d716a47
commit 17ed0192c1
10 changed files with 329 additions and 12 deletions
+8
View File
@@ -71,6 +71,14 @@ func RegisterRoutes(r *gin.Engine) {
// rather than under /api precisely so that none of those apply.
r.GET("/public/status/:pageId", RateLimitPublicStatus(), getPublicStatusPage)
// Ping endpoints for heartbeat monitors. The token is in the URL or the
// X-Vantage-Token header; see resolveHeartbeat for the shapes. Rate
// limiting is per token inside the handler.
for _, p := range []string{"/public/hb", "/public/hb/:a", "/public/hb/:a/:b"} {
r.GET(p, handleHeartbeat)
r.POST(p, handleHeartbeat)
}
apiGroup := r.Group("/api")
apiGroup.Use(auth.Middleware())
// Scope enforcement sits between authentication and the licence gate, and
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"errors"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// HeartbeatTokenHeader carries the ping token for callers who want it out of
// URLs, and so out of their own proxies' access logs.
const HeartbeatTokenHeader = "X-Vantage-Token"
const heartbeatPathPrefix = "/public/hb/"
func heartbeatKind(seg string) string {
switch seg {
case "":
return services.HeartbeatPing
case "start":
return services.HeartbeatStart
case "fail":
return services.HeartbeatFail
}
return ""
}
// resolveHeartbeat maps the three route shapes onto a token and a kind. a and b
// are the first and second path segments after /public/hb. A token in the URL
// wins over the header, so a URL copied from the UI behaves the same no matter
// what headers a client adds.
func resolveHeartbeat(header, a, b string) (string, string, bool) {
var token, kindSeg string
switch {
// a is a URL token unless a header is present, no second segment follows
// and a is itself a kind word (the header routes /public/hb/start|fail).
case a != "" && (header == "" || b != "" || heartbeatKind(a) == ""):
token, kindSeg = a, b
case header != "":
token, kindSeg = header, a
default:
return "", "", false
}
kind := heartbeatKind(kindSeg)
if token == "" || kind == "" {
return "", "", false
}
return token, kind, true
}
// MaskLogPath hides a heartbeat token in a request path before it is logged.
// "start" and "fail" directly under the prefix are the header-token routes and
// carry no secret, so they are left readable.
func MaskLogPath(path string) string {
rest, found := strings.CutPrefix(path, heartbeatPathPrefix)
if !found {
return path
}
end := strings.IndexAny(rest, "/?")
if end < 0 {
end = len(rest)
}
seg := rest[:end]
if seg == "" || seg == "start" || seg == "fail" {
return path
}
return heartbeatPathPrefix + "***" + rest[end:]
}
// heartbeatAllowed admits one request per token per second. A cron job pinging
// in a loop should not become a write per request, and a leaked URL should not
// be a way to hammer Mongo. Like the status page limiter it allows when Redis
// is down: a missed ping pages someone. It runs inside the handler rather than
// as middleware because the token may come from a header.
func heartbeatAllowed(c *gin.Context, token string) bool {
rdb := auth.Redis()
if rdb == nil {
return true
}
key := "vantage:hbrl:" + services.HashHeartbeatToken(token) + ":" + strconv.FormatInt(time.Now().Unix(), 10)
count, err := rdb.Incr(c.Request.Context(), key).Result()
if err != nil {
return true
}
if count == 1 {
rdb.Expire(c.Request.Context(), key, 2*time.Second)
}
return count <= 1
}
// handleHeartbeat records a push from a job. It is mounted on the gin root
// under /public for the same reasons as the status page (see
// getPublicStatusPage): no session, no token, no licence gate, and /public is
// already routed to this server by every deployment.
//
// Unknown token, disabled monitor and an unresolvable path all answer the same
// 404.
func handleHeartbeat(c *gin.Context) {
token, kind, ok := resolveHeartbeat(c.GetHeader(HeartbeatTokenHeader), c.Param("a"), c.Param("b"))
if !ok {
c.String(http.StatusNotFound, "not found")
return
}
if !heartbeatAllowed(c, token) {
c.Header("Retry-After", "1")
c.String(http.StatusTooManyRequests, "too many requests")
return
}
var body string
if kind == services.HeartbeatFail && c.Request.Body != nil {
b, _ := io.ReadAll(io.LimitReader(c.Request.Body, services.MaxHeartbeatBody))
body = string(b)
}
err := services.RecordHeartbeat(token, kind, body, time.Now())
if errors.Is(err, services.ErrHeartbeatNotFound) {
c.String(http.StatusNotFound, "not found")
return
}
if err != nil {
log.Printf("heartbeat: %v", err)
c.String(http.StatusInternalServerError, "error")
return
}
c.String(http.StatusOK, "OK")
}
// rotateHeartbeatToken godoc
//
// @Summary Rotate a heartbeat monitor's ping token
// @Description Issues a new token and invalidates the old ping URL immediately. The token is returned only in this response.
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {object} object{heartbeat_token=string}
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/rotate-token [post]
func rotateHeartbeatToken(c *gin.Context) {
tok, err := services.RotateHeartbeatToken(auth.InstanceID(c), c.Param("id"))
if errors.Is(err, services.ErrHeartbeatNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "heartbeat monitor not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"heartbeat_token": tok})
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestResolveHeartbeat(t *testing.T) {
cases := []struct {
name, header, a, b string
token, kind string
ok bool
}{
{"url ping", "", "tok", "", "tok", "ping", true},
{"url start", "", "tok", "start", "tok", "start", true},
{"url fail", "", "tok", "fail", "tok", "fail", true},
{"url bad kind", "", "tok", "explode", "", "", false},
{"header ping", "htok", "", "", "htok", "ping", true},
{"header start", "htok", "start", "", "htok", "start", true},
{"header fail", "htok", "fail", "", "htok", "fail", true},
{"url token wins over header", "htok", "tok", "", "tok", "ping", true},
{"url token and kind win over header", "htok", "tok", "fail", "tok", "fail", true},
{"nothing", "", "", "", "", "", false},
// Without a header, /public/hb/start is a token called "start": it
// resolves, and the lookup answers 404 like any unknown token.
{"bare start without header", "", "start", "", "start", "ping", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
tok, kind, ok := resolveHeartbeat(c.header, c.a, c.b)
if tok != c.token || kind != c.kind || ok != c.ok {
t.Fatalf("got (%q,%q,%v), want (%q,%q,%v)", tok, kind, ok, c.token, c.kind, c.ok)
}
})
}
}
// A ping URL is a credential. Anything that logs request paths must see a
// masked one.
func TestMaskLogPath(t *testing.T) {
cases := map[string]string{
"/public/hb/abc123": "/public/hb/***",
"/public/hb/abc123/fail": "/public/hb/***/fail",
"/public/hb/abc123?x=1": "/public/hb/***?x=1",
"/public/hb/start": "/public/hb/start",
"/public/hb/fail": "/public/hb/fail",
"/public/hb": "/public/hb",
"/public/status/page": "/public/status/page",
"/api/monitors/abc/uptime": "/api/monitors/abc/uptime",
}
for in, want := range cases {
if got := MaskLogPath(in); got != want {
t.Errorf("MaskLogPath(%q) = %q, want %q", in, got, want)
}
}
}
func TestHeartbeatUnresolvableIs404(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/public/hb/:a/:b", handleHeartbeat)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/public/hb/abc/explode", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
}
+10
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"net/http"
"strconv"
"time"
@@ -21,6 +22,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
g.GET("/monitors/:id/incidents", getMonitorIncidents)
g.GET("/monitors/:id/uptime", getMonitorUptime)
g.GET("/monitors/:id/samples", getMonitorSamples)
g.POST("/monitors/:id/rotate-token", rotateHeartbeatToken)
}
// listMonitors godoc
@@ -85,6 +87,10 @@ func createMonitor(c *gin.Context) {
}
created, err := services.CreateMonitor(auth.InstanceID(c), &m, auth.ServerScope(c))
if err != nil {
if errors.Is(err, services.ErrInvalidMonitor) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -187,6 +193,10 @@ func updateMonitor(c *gin.Context) {
return
}
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd, auth.ServerScope(c)); err != nil {
if errors.Is(err, services.ErrInvalidMonitor) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+9 -8
View File
@@ -99,14 +99,15 @@ var routeScopes = map[string]string{
"GET /api/runs/:runId/servers/:serverId/logs/stream": "workflows:read",
// Monitor and incident routes, registered by registerMonitorRoutes.
"GET /api/monitors": "monitors:read",
"POST /api/monitors": "monitors:write",
"GET /api/monitors/:id": "monitors:read",
"PUT /api/monitors/:id": "monitors:write",
"DELETE /api/monitors/:id": "monitors:write",
"GET /api/monitors/:id/incidents": "monitors:read",
"GET /api/monitors/:id/uptime": "monitors:read",
"GET /api/monitors/:id/samples": "monitors:read",
"GET /api/monitors": "monitors:read",
"POST /api/monitors": "monitors:write",
"GET /api/monitors/:id": "monitors:read",
"PUT /api/monitors/:id": "monitors:write",
"DELETE /api/monitors/:id": "monitors:write",
"GET /api/monitors/:id/incidents": "monitors:read",
"GET /api/monitors/:id/uptime": "monitors:read",
"GET /api/monitors/:id/samples": "monitors:read",
"POST /api/monitors/:id/rotate-token": "monitors:write",
// Channel routes, registered by registerChannelRoutes. Channels exist to
// serve alerts, so they share the monitors scope rather than getting their
+3
View File
@@ -292,6 +292,9 @@ var serverScopedRoutes = map[string]scopeDecl{
// then act on.
"DELETE /api/monitors/:id": fleetWide,
// Rotating a ping token touches no server and returns only the token.
"POST /api/monitors/:id/rotate-token": fleetWide,
// A monitor's incidents, uptime rollups and recent samples are all about
// the monitored endpoint - status, latency, timestamps - and carry no
// server identifier at all; the runner is a field of the monitor