diff --git a/server/internal/api/docs/openapi.json b/server/internal/api/docs/openapi.json index 80b950b..1057760 100644 --- a/server/internal/api/docs/openapi.json +++ b/server/internal/api/docs/openapi.json @@ -1045,6 +1045,10 @@ "resolved_at": { "type": "string" }, + "server_id": { + "description": "ServerID is set only for metric monitors, which keep one incident per\nbreaching server.", + "type": "string" + }, "started_at": { "type": "string" } @@ -1114,6 +1118,10 @@ "reboot_required": { "type": "boolean" }, + "reboot_required_since": { + "description": "RebootRequiredSince is when the host first reported a pending reboot.", + "type": "string" + }, "static_at": { "type": "string" }, @@ -1224,10 +1232,18 @@ "enabled": { "type": "boolean" }, + "for_sec": { + "description": "ForSec is how long a metric condition must hold before a server is down.", + "type": "integer" + }, "group": { "description": "Group is a display-only label. It buckets rows on the monitors page and\nhas no effect on scheduling, alerting or scope; an empty group means the\nmonitor is listed on its own under \"Ungrouped\".", "type": "string" }, + "heartbeat_token": { + "description": "HeartbeatToken is the plaintext token, set only on the create response.", + "type": "string" + }, "instance_id": { "type": "string" }, @@ -1278,6 +1294,38 @@ }, "type": "object" }, + "models.MonitorServerState": { + "properties": { + "breach_since": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "instance_id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "monitor_id": { + "type": "string" + }, + "server_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "type": "object" + }, "models.MonitorState": { "properties": { "cert_expiry_at": { @@ -1292,12 +1340,18 @@ "last_notified_at": { "type": "string" }, + "last_ping_at": { + "type": "string" + }, "latency_ms": { "type": "integer" }, "message": { "type": "string" }, + "started_at": { + "type": "string" + }, "status": { "type": "string" } @@ -1309,6 +1363,9 @@ "expected_status": { "type": "integer" }, + "grace_sec": { + "type": "integer" + }, "host": { "type": "string" }, @@ -1321,9 +1378,29 @@ "method": { "type": "string" }, + "metric": { + "type": "string" + }, + "mount": { + "type": "string" + }, + "period_sec": { + "description": "Heartbeat monitors.", + "type": "integer" + }, "port": { "type": "integer" }, + "selector": { + "additionalProperties": { + "type": "string" + }, + "description": "Metric monitors.", + "type": "object" + }, + "threshold": { + "type": "number" + }, "tls_warn_days": { "type": "integer" }, @@ -6402,6 +6479,9 @@ "enabled": { "type": "boolean" }, + "for_sec": { + "type": "integer" + }, "group": { "type": "string" }, @@ -6535,6 +6615,71 @@ ] } }, + "/monitors/{id}/rotate-token": { + "post": { + "description": "Issues a new token and invalidates the old ping URL immediately. The token is returned only in this response.", + "parameters": [ + { + "description": "Monitor ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "heartbeat_token": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Rotate a heartbeat monitor's ping token", + "tags": [ + "monitors" + ] + } + }, "/monitors/{id}/samples": { "get": { "description": "Raw check results for the last `minutes` minutes, oldest first. Samples expire after 48 hours; use the uptime rollups for longer ranges.", @@ -6606,6 +6751,68 @@ ] } }, + "/monitors/{id}/servers": { + "get": { + "parameters": [ + { + "description": "Monitor ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/models.MonitorServerState" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "List a metric monitor's per-server states", + "tags": [ + "monitors" + ] + } + }, "/monitors/{id}/uptime": { "get": { "description": "Hourly rollups for the last 30 days.", diff --git a/server/internal/api/monitors.go b/server/internal/api/monitors.go index f711783..687c233 100644 --- a/server/internal/api/monitors.go +++ b/server/internal/api/monitors.go @@ -22,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.GET("/monitors/:id/servers", getMonitorServers) g.POST("/monitors/:id/rotate-token", rotateHeartbeatToken) } @@ -264,9 +265,63 @@ func getMonitorIncidents(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + incidents = services.FilterByVisibleServer(incidents, func(i models.Incident) string { return i.ServerID }, visible, restricted) c.JSON(http.StatusOK, incidents) } +// getMonitorServers godoc +// +// @Summary List a metric monitor's per-server states +// @Tags monitors +// @Produce json +// @Param id path string true "Monitor ID" +// @Success 200 {array} models.MonitorServerState +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /monitors/{id}/servers [get] +func getMonitorServers(c *gin.Context) { + instanceID := auth.InstanceID(c) + m, err := services.GetMonitor(instanceID, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if m == nil || m.Type != models.MonitorMetric { + c.JSON(http.StatusNotFound, gin.H{"error": "metric monitor not found"}) + return + } + states, err := services.ListMonitorServerStates(instanceID, m.MonitorID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + visible, restricted, err := services.VisibleServerIDs(instanceID, auth.ServerScope(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + states = services.FilterByVisibleServer(states, func(s models.MonitorServerState) string { return s.ServerID }, visible, restricted) + + servers, err := services.ListServersFiltered(instanceID, m.Target.Selector) + if err == nil { + names := map[string]string{} + for _, s := range servers { + names[s.ServerID] = s.Hostname + } + for i := range states { + states[i].Hostname = names[states[i].ServerID] + } + } + c.JSON(http.StatusOK, states) +} + // getMonitorSamples godoc // // @Summary Get a monitor's individual check results diff --git a/server/internal/api/scopes.go b/server/internal/api/scopes.go index 2930339..e5062db 100644 --- a/server/internal/api/scopes.go +++ b/server/internal/api/scopes.go @@ -107,6 +107,7 @@ var routeScopes = map[string]string{ "GET /api/monitors/:id/incidents": "monitors:read", "GET /api/monitors/:id/uptime": "monitors:read", "GET /api/monitors/:id/samples": "monitors:read", + "GET /api/monitors/:id/servers": "monitors:read", "POST /api/monitors/:id/rotate-token": "monitors:write", // Channel routes, registered by registerChannelRoutes. Channels exist to diff --git a/server/internal/api/serverscope.go b/server/internal/api/serverscope.go index 2f6141a..47046b0 100644 --- a/server/internal/api/serverscope.go +++ b/server/internal/api/serverscope.go @@ -295,13 +295,22 @@ var serverScopedRoutes = map[string]scopeDecl{ // 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 - // document, which these do not return. - "GET /api/monitors/:id/incidents": exempt, - "GET /api/monitors/:id/uptime": exempt, - "GET /api/monitors/:id/samples": exempt, + // Uptime rollups and recent samples are about the monitored endpoint - + // status, latency, timestamps - and carry no server identifier at all; + // the runner is a field of the monitor document, which these do not + // return. + "GET /api/monitors/:id/uptime": exempt, + "GET /api/monitors/:id/samples": exempt, + + // A metric monitor's incidents carry server_id, so a restricted token + // must not see an incident for a server outside its scope. Handled by + // services.FilterByVisibleServer rather than by the route classification + // itself. + "GET /api/monitors/:id/incidents": scoped, + + // Per-server metric state names servers, so it is filtered to the ones the + // credential can see. + "GET /api/monitors/:id/servers": scoped, // ---- notification channels ---- diff --git a/server/internal/services/metricfilter_test.go b/server/internal/services/metricfilter_test.go new file mode 100644 index 0000000..9021482 --- /dev/null +++ b/server/internal/services/metricfilter_test.go @@ -0,0 +1,20 @@ +package services + +import ( + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +func TestFilterByVisibleServer(t *testing.T) { + incs := []models.Incident{{IncidentID: "a"}, {IncidentID: "b", ServerID: "s1"}, {IncidentID: "c", ServerID: "s2"}} + id := func(i models.Incident) string { return i.ServerID } + + if got := FilterByVisibleServer(incs, id, nil, false); len(got) != 3 { + t.Fatalf("unrestricted keeps all, got %d", len(got)) + } + got := FilterByVisibleServer(incs, id, map[string]bool{"s1": true}, true) + if len(got) != 2 || got[0].IncidentID != "a" || got[1].IncidentID != "b" { + t.Fatalf("restricted keeps serverless and visible only, got %+v", got) + } +} diff --git a/server/internal/services/tokenscope.go b/server/internal/services/tokenscope.go index 5669eb2..84cf027 100644 --- a/server/internal/services/tokenscope.go +++ b/server/internal/services/tokenscope.go @@ -105,3 +105,18 @@ func SelectorNarrowerOrEqual(child, parent map[string]string) bool { } return true } + +// FilterByVisibleServer drops items tied to a server the credential cannot +// see. Items with no server (every non-metric incident) are always kept. +func FilterByVisibleServer[T any](items []T, serverID func(T) string, visible map[string]bool, restricted bool) []T { + if !restricted { + return items + } + out := items[:0:0] + for _, it := range items { + if id := serverID(it); id == "" || visible[id] { + out = append(out, it) + } + } + return out +}