feat: Annotate monitor, secrets and vulnerability routes

Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. VulnSummaryResponse uses pointer fields so the
db-freshness block stays entirely absent when no vulndb_meta document
exists yet, matching the handler's original conditional gin.H exactly.
This commit is contained in:
2026-08-12 15:22:51 +00:00
parent a398da0eac
commit 9b18d09d9b
3 changed files with 361 additions and 18 deletions
+85
View File
@@ -21,6 +21,16 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
g.GET("/monitors/:id/uptime", getMonitorUptime)
}
// listMonitors godoc
//
// @Summary List monitors
// @Tags monitors
// @Produce json
// @Success 200 {array} models.Monitor
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors [get]
func listMonitors(c *gin.Context) {
monitors, err := services.ListMonitors(auth.InstanceID(c))
if err != nil {
@@ -30,6 +40,20 @@ func listMonitors(c *gin.Context) {
c.JSON(http.StatusOK, monitors)
}
// createMonitor godoc
//
// @Summary Create a monitor
// @Tags monitors
// @Accept json
// @Produce json
// @Param body body models.Monitor true "Monitor to create"
// @Success 201 {object} models.Monitor
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} LimitExceededResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors [post]
func createMonitor(c *gin.Context) {
var m models.Monitor
if err := c.ShouldBindJSON(&m); err != nil {
@@ -55,6 +79,18 @@ func createMonitor(c *gin.Context) {
c.JSON(http.StatusCreated, created)
}
// getMonitor godoc
//
// @Summary Get a monitor
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {object} models.Monitor
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id} [get]
func getMonitor(c *gin.Context) {
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -68,6 +104,20 @@ func getMonitor(c *gin.Context) {
c.JSON(http.StatusOK, m)
}
// updateMonitor godoc
//
// @Summary Update a monitor
// @Tags monitors
// @Accept json
// @Produce json
// @Param id path string true "Monitor ID"
// @Param body body object{name=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
// @Success 204
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id} [put]
func updateMonitor(c *gin.Context) {
var body struct {
Name *string `json:"name"`
@@ -119,6 +169,16 @@ func updateMonitor(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// deleteMonitor godoc
//
// @Summary Delete a monitor
// @Tags monitors
// @Param id path string true "Monitor ID"
// @Success 204
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id} [delete]
func deleteMonitor(c *gin.Context) {
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -127,6 +187,18 @@ func deleteMonitor(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// getMonitorIncidents godoc
//
// @Summary List a monitor's incidents
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {array} models.Incident
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/incidents [get]
func getMonitorIncidents(c *gin.Context) {
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -145,6 +217,19 @@ func getMonitorIncidents(c *gin.Context) {
c.JSON(http.StatusOK, incidents)
}
// getMonitorUptime godoc
//
// @Summary Get a monitor's uptime rollups
// @Description Hourly rollups for the last 30 days.
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {array} models.Rollup
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/uptime [get]
func getMonitorUptime(c *gin.Context) {
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
+119 -7
View File
@@ -37,6 +37,19 @@ func secretsReadAuth() gin.HandlerFunc {
}
}
// esoGetGroup godoc
//
// @Summary Read a secret group's values (ESO)
// @Description Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings — a different credential from an API token, never substitutable for one.
// @Tags secrets
// @Produce json
// @Param group path string true "Secret group name"
// @Success 200 {object} map[string]string
// @Failure 401 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security esoAuth
// @Router /secrets/{group}/values [get]
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
@@ -58,6 +71,16 @@ func esoGetGroup(c *gin.Context) {
c.JSON(http.StatusOK, values)
}
// listSecretGroups godoc
//
// @Summary List secret groups
// @Tags secrets
// @Produce json
// @Success 200 {array} models.GroupSummary
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets [get]
func listSecretGroups(c *gin.Context) {
groups, err := services.ListSecretGroups(auth.InstanceID(c))
if err != nil {
@@ -67,6 +90,20 @@ func listSecretGroups(c *gin.Context) {
c.JSON(http.StatusOK, groups)
}
// createSecretGroup godoc
//
// @Summary Create a secret group
// @Tags secrets
// @Accept json
// @Produce json
// @Param body body object{group=string,values=map[string]string} true "Group and its initial key/value pairs"
// @Success 201 {object} GroupResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} LimitExceededResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets [post]
func createSecretGroup(c *gin.Context) {
var body struct {
Group string `json:"group" binding:"required"`
@@ -98,9 +135,22 @@ func createSecretGroup(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
c.JSON(http.StatusCreated, GroupResponse{Group: body.Group})
}
// getSecretGroup godoc
//
// @Summary Get a secret group's keys
// @Description Returns the group's key metadata, not decrypted values. See POST /secrets/{group}/reveal for a value.
// @Tags secrets
// @Produce json
// @Param group path string true "Secret group name"
// @Success 200 {object} SecretGroupResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group} [get]
func getSecretGroup(c *gin.Context) {
group := c.Param("group")
secrets, err := services.GetSecretGroup(auth.InstanceID(c), group)
@@ -112,9 +162,24 @@ func getSecretGroup(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
c.JSON(http.StatusOK, SecretGroupResponse{Group: group, Secrets: secrets})
}
// putSecretGroup godoc
//
// @Summary Replace a secret group's keys
// @Tags secrets
// @Accept json
// @Produce json
// @Param group path string true "Secret group name"
// @Param body body map[string]string true "Key/value pairs"
// @Success 200 {object} SavedResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} LimitExceededResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group} [put]
func putSecretGroup(c *gin.Context) {
group := c.Param("group")
if !validName(group) {
@@ -144,9 +209,23 @@ func putSecretGroup(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
c.JSON(http.StatusOK, gin.H{"saved": true})
c.JSON(http.StatusOK, SavedResponse{Saved: true})
}
// revealSecret godoc
//
// @Summary Reveal a secret value
// @Tags secrets
// @Accept json
// @Produce json
// @Param group path string true "Secret group name"
// @Param body body object{key=string} true "Key to reveal"
// @Success 200 {object} RevealSecretResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group}/reveal [post]
func revealSecret(c *gin.Context) {
group := c.Param("group")
var body struct {
@@ -162,9 +241,21 @@ func revealSecret(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
c.JSON(http.StatusOK, gin.H{"value": value})
c.JSON(http.StatusOK, RevealSecretResponse{Value: value})
}
// deleteSecretKey godoc
//
// @Summary Delete a key from a secret group
// @Tags secrets
// @Produce json
// @Param group path string true "Secret group name"
// @Param key path string true "Key name"
// @Success 200 {object} DeletedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group}/{key} [delete]
func deleteSecretKey(c *gin.Context) {
group := c.Param("group")
key := c.Param("key")
@@ -173,9 +264,20 @@ func deleteSecretKey(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// deleteSecretGroup godoc
//
// @Summary Delete a secret group
// @Tags secrets
// @Produce json
// @Param group path string true "Secret group name"
// @Success 200 {object} DeletedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group} [delete]
func deleteSecretGroup(c *gin.Context) {
group := c.Param("group")
if err := services.DeleteSecretGroup(auth.InstanceID(c), group); err != nil {
@@ -183,9 +285,19 @@ func deleteSecretGroup(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// rotateSecretsToken godoc
//
// @Summary Rotate the ESO read token
// @Tags settings
// @Produce json
// @Success 200 {object} SecretsTokenResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /settings/secrets-token [post]
func rotateSecretsToken(c *gin.Context) {
token, err := services.RotateSecretsReadToken(auth.InstanceID(c))
if err != nil {
@@ -193,5 +305,5 @@ func rotateSecretsToken(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
c.JSON(http.StatusOK, SecretsTokenResponse{Token: token})
}
+157 -11
View File
@@ -26,6 +26,22 @@ type vulnGroup struct {
Findings []models.VulnFinding `json:"findings"`
}
// listVulnerabilities godoc
//
// @Summary List vulnerabilities
// @Description Groups findings by CVE, most severe first — the same CVE on forty servers is one decision, not forty rows.
// @Tags vulnerabilities
// @Produce json
// @Param severity query string false "Filter by severity"
// @Param state query string false "Filter by state (default open)"
// @Param server query string false "Filter by server ID"
// @Param tag query []string false "Filter by tag as key:value, repeatable"
// @Param has_fix query bool false "Filter by whether a vendor fix exists"
// @Success 200 {array} vulnGroup
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities [get]
func listVulnerabilities(c *gin.Context) {
findings, err := services.ListInstanceFindings(auth.InstanceID(c), services.FindingFilter{
Severity: c.Query("severity"),
@@ -115,6 +131,17 @@ func tagsFromQuery(c *gin.Context) map[string]string {
return out
}
// vulnerabilitySummary godoc
//
// @Summary Get vulnerability counts and database freshness
// @Description Counts travel with the database version and pull time, since a fleet scanned against a stale database must say so wherever its findings are read.
// @Tags vulnerabilities
// @Produce json
// @Success 200 {object} VulnSummaryResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities/summary [get]
func vulnerabilitySummary(c *gin.Context) {
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
if err != nil {
@@ -122,24 +149,32 @@ func vulnerabilitySummary(c *gin.Context) {
return
}
resp := gin.H{"counts": counts}
resp := VulnSummaryResponse{Counts: counts}
// Database freshness travels with the counts rather than living in
// settings: a fleet scanned against a three-week-old database must say so
// wherever its findings are read, not somewhere the reader has to go and
// look for it.
if meta, err := services.GetVulnDBMeta(); err == nil && meta != nil {
resp["db_version"] = meta.DBVersion
resp["pulled_at"] = meta.PulledAt
resp["last_full_scan_at"] = meta.LastFullScanAt
if meta.LastError != "" {
resp["last_error"] = meta.LastError
}
resp.DBVersion = &meta.DBVersion
resp.PulledAt = &meta.PulledAt
resp.LastFullScanAt = &meta.LastFullScanAt
resp.LastError = meta.LastError
}
c.JSON(http.StatusOK, resp)
}
// rescanVulnerabilities godoc
//
// @Summary Queue the fleet for a vulnerability rescan
// @Tags vulnerabilities
// @Produce json
// @Success 200 {object} QueuedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities/rescan [post]
func rescanVulnerabilities(c *gin.Context) {
instanceID := auth.InstanceID(c)
@@ -151,7 +186,7 @@ func rescanVulnerabilities(c *gin.Context) {
services.LogEvent(instanceID, "vuln.rescan", actorFromCtx(c), "", "",
"queued "+strconv.FormatInt(n, 10)+" server(s) for rescan")
c.JSON(http.StatusOK, gin.H{"queued": n})
c.JSON(http.StatusOK, QueuedResponse{Queued: n})
}
type acceptFindingRequest struct {
@@ -159,6 +194,22 @@ type acceptFindingRequest struct {
Until time.Time `json:"until"`
}
// acceptFinding godoc
//
// @Summary Accept a finding
// @Description Requires a reason and a future expiry. Reopens automatically at expiry — permanent dismissal is never allowed.
// @Tags vulnerabilities
// @Accept json
// @Produce json
// @Param id path string true "Finding ID"
// @Param body body acceptFindingRequest true "Reason and expiry"
// @Success 200 {object} models.VulnFinding
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities/{id}/accept [post]
func acceptFinding(c *gin.Context) {
var req acceptFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -192,6 +243,18 @@ func acceptFinding(c *gin.Context) {
c.JSON(http.StatusOK, f)
}
// unacceptFinding godoc
//
// @Summary Return an accepted finding to open
// @Tags vulnerabilities
// @Produce json
// @Param id path string true "Finding ID"
// @Success 200 {object} models.VulnFinding
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities/{id}/accept [delete]
func unacceptFinding(c *gin.Context) {
instanceID := auth.InstanceID(c)
actor := actorFromCtx(c)
@@ -215,6 +278,17 @@ func writeFindingError(c *gin.Context, err error) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
// listServerVulnerabilities godoc
//
// @Summary List a server's vulnerabilities
// @Tags vulnerabilities
// @Produce json
// @Param id path string true "Server ID"
// @Success 200 {array} models.VulnFinding
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/vulnerabilities [get]
func listServerVulnerabilities(c *gin.Context) {
findings, err := services.ListFindings(c.Request.Context(), auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -227,6 +301,18 @@ func listServerVulnerabilities(c *gin.Context) {
c.JSON(http.StatusOK, findings)
}
// getServerPackages godoc
//
// @Summary Get a server's package inventory
// @Description A server that has not reported yet answers reported=false rather than 404 — that is the normal state for the first hour after install.
// @Tags vulnerabilities
// @Produce json
// @Param id path string true "Server ID"
// @Success 200 {object} models.ServerPackages
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/packages [get]
func getServerPackages(c *gin.Context) {
sp, err := services.ListPackages(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -237,12 +323,24 @@ func getServerPackages(c *gin.Context) {
// Not a 404: an agent that has not reported yet is the normal state for
// the first hour after install, and is a different thing from a bad
// server id.
c.JSON(http.StatusOK, gin.H{"reported": false})
c.JSON(http.StatusOK, ReportedResponse{Reported: false})
return
}
c.JSON(http.StatusOK, sp)
}
// searchPackages godoc
//
// @Summary Search packages fleet-wide
// @Tags vulnerabilities
// @Produce json
// @Param name query string true "Package name"
// @Success 200 {array} services.PackageHit
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /packages/search [get]
func searchPackages(c *gin.Context) {
name := c.Query("name")
if name == "" {
@@ -257,6 +355,16 @@ func searchPackages(c *gin.Context) {
c.JSON(http.StatusOK, hits)
}
// listVulnRules godoc
//
// @Summary List vulnerability alert rules
// @Tags vulnerabilities
// @Produce json
// @Success 200 {array} models.VulnAlertRule
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vuln-rules [get]
func listVulnRules(c *gin.Context) {
rules, err := services.ListVulnRules(auth.InstanceID(c))
if err != nil {
@@ -266,6 +374,18 @@ func listVulnRules(c *gin.Context) {
c.JSON(http.StatusOK, rules)
}
// createVulnRule godoc
//
// @Summary Create a vulnerability alert rule
// @Tags vulnerabilities
// @Accept json
// @Produce json
// @Param body body models.VulnAlertRule true "Rule to create"
// @Success 201 {object} models.VulnAlertRule
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vuln-rules [post]
func createVulnRule(c *gin.Context) {
var r models.VulnAlertRule
if err := c.ShouldBindJSON(&r); err != nil {
@@ -284,6 +404,20 @@ func createVulnRule(c *gin.Context) {
c.JSON(http.StatusCreated, created)
}
// updateVulnRule godoc
//
// @Summary Update a vulnerability alert rule
// @Tags vulnerabilities
// @Accept json
// @Produce json
// @Param id path string true "Rule ID"
// @Param body body models.VulnAlertRule true "Rule fields"
// @Success 200 {object} StatusResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vuln-rules/{id} [put]
func updateVulnRule(c *gin.Context) {
var r models.VulnAlertRule
if err := c.ShouldBindJSON(&r); err != nil {
@@ -302,9 +436,21 @@ func updateVulnRule(c *gin.Context) {
}
services.LogEvent(instanceID, "vuln.rule_updated", actorFromCtx(c), "", "", "rule "+r.Name)
c.JSON(http.StatusOK, gin.H{"status": "updated"})
c.JSON(http.StatusOK, StatusResponse{Status: "updated"})
}
// deleteVulnRule godoc
//
// @Summary Delete a vulnerability alert rule
// @Tags vulnerabilities
// @Produce json
// @Param id path string true "Rule ID"
// @Success 200 {object} StatusResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vuln-rules/{id} [delete]
func deleteVulnRule(c *gin.Context) {
instanceID := auth.InstanceID(c)
if err := services.DeleteVulnRule(instanceID, c.Param("id")); err != nil {
@@ -317,5 +463,5 @@ func deleteVulnRule(c *gin.Context) {
}
services.LogEvent(instanceID, "vuln.rule_deleted", actorFromCtx(c), "", "", "rule "+c.Param("id"))
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
c.JSON(http.StatusOK, StatusResponse{Status: "deleted"})
}