From 9b18d09d9b41b59202514518cdec7d0c28e9d92a Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 15:22:51 +0000 Subject: [PATCH] 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. --- server/internal/api/monitors.go | 85 +++++++++++++ server/internal/api/secrets.go | 126 +++++++++++++++++-- server/internal/api/vulnerabilities.go | 168 +++++++++++++++++++++++-- 3 files changed, 361 insertions(+), 18 deletions(-) diff --git a/server/internal/api/monitors.go b/server/internal/api/monitors.go index edab847..32b3caa 100644 --- a/server/internal/api/monitors.go +++ b/server/internal/api/monitors.go @@ -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 { diff --git a/server/internal/api/secrets.go b/server/internal/api/secrets.go index 7466c8e..e58c7d1 100644 --- a/server/internal/api/secrets.go +++ b/server/internal/api/secrets.go @@ -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}) } diff --git a/server/internal/api/vulnerabilities.go b/server/internal/api/vulnerabilities.go index bfe503d..5937c2a 100644 --- a/server/internal/api/vulnerabilities.go +++ b/server/internal/api/vulnerabilities.go @@ -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"}) }