diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index ecb5f06..08c1334 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -85,6 +85,10 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.POST("/tokens", createToken) apiGroup.DELETE("/tokens/:id", revokeToken) + apiGroup.GET("/openapi.json", getOpenAPI) + apiGroup.GET("/docs", getAPIDocs) + apiGroup.GET("/docs/scalar.js", getScalarJS) + settings := apiGroup.Group("/settings") settings.Use(auth.RequireRole("owner", "admin")) { @@ -161,6 +165,19 @@ func RegisterRoutes(r *gin.Engine) { } } +// listServers godoc +// +// @Summary List servers +// @Description Returns every server in the instance, optionally filtered by tag (repeatable, key:value). +// @Tags servers +// @Produce json +// @Param tag query []string false "Filter by tag as key:value, repeatable" +// @Success 200 {array} models.Server +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers [get] func listServers(c *gin.Context) { sel, err := services.ParseTagFilters(c.QueryArray("tag")) if err != nil { @@ -175,6 +192,17 @@ func listServers(c *gin.Context) { c.JSON(http.StatusOK, servers) } +// listKnownTags godoc +// +// @Summary List known tags +// @Description Returns every tag key currently used by any server, with the values seen for each. +// @Tags servers +// @Produce json +// @Success 200 {object} map[string][]string +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers/tags [get] func listKnownTags(c *gin.Context) { tags, err := services.KnownTags(auth.InstanceID(c)) if err != nil { @@ -184,6 +212,22 @@ func listKnownTags(c *gin.Context) { c.JSON(http.StatusOK, tags) } +// putServerTags godoc +// +// @Summary Replace a server's tags +// @Description Replaces the whole tag map for a server. Last write wins. +// @Tags servers +// @Accept json +// @Produce json +// @Param id path string true "Server ID" +// @Param body body object{tags=map[string]string} true "New tag map" +// @Success 200 {object} TagsResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers/{id}/tags [put] func putServerTags(c *gin.Context) { var body struct { Tags map[string]string `json:"tags"` @@ -213,9 +257,21 @@ func putServerTags(c *gin.Context) { services.LogEvent(instanceID, "server.tags_updated", actorFromCtx(c), serverID, "", fmt.Sprintf("tags %v -> %v", before.Tags, body.Tags)) - c.JSON(http.StatusOK, gin.H{"tags": body.Tags}) + c.JSON(http.StatusOK, TagsResponse{Tags: body.Tags}) } +// createServer godoc +// +// @Summary Add a server +// @Description Creates a server record and a single-use pre-registration token (TTL 1 hour). +// @Tags servers +// @Produce json +// @Success 201 {object} CreateServerResponse +// @Failure 403 {object} LimitExceededResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers [post] func createServer(c *gin.Context) { s, token, err := services.CreateServer(auth.InstanceID(c)) if err != nil { @@ -225,13 +281,26 @@ func createServer(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusCreated, gin.H{ - "server": s, - "token": token, - "server_id": s.ServerID, + c.JSON(http.StatusCreated, CreateServerResponse{ + Server: s, + Token: token, + ServerID: s.ServerID, }) } +// newServer godoc +// +// @Summary Add a server (install page) +// @Description Identical to POST /servers; also reachable by GET for the install page. Mints a new pre-registration token. +// @Tags servers +// @Produce json +// @Success 200 {object} NewServerResponse +// @Failure 403 {object} LimitExceededResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers/new [get] +// @Router /servers/new [post] func newServer(c *gin.Context) { s, token, err := services.CreateServer(auth.InstanceID(c)) if err != nil { @@ -255,14 +324,26 @@ func newServer(c *gin.Context) { host, s.ServerID, token, ) - c.JSON(http.StatusOK, gin.H{ - "server_id": s.ServerID, - "pre_reg_token": token, - "install_command": installCmd, - "install_command_ps": installCmdPS, + c.JSON(http.StatusOK, NewServerResponse{ + ServerID: s.ServerID, + PreRegToken: token, + InstallCommand: installCmd, + InstallCommandPS: installCmdPS, }) } +// getServer godoc +// +// @Summary Get a server +// @Description Returns a server together with its resolved key assignments. +// @Tags servers +// @Produce json +// @Param id path string true "Server ID" +// @Success 200 {object} ServerDetailResponse +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers/{id} [get] func getServer(c *gin.Context) { id := c.Param("id") s, err := services.GetServer(auth.InstanceID(c), id) @@ -273,16 +354,23 @@ func getServer(c *gin.Context) { assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id) - type serverResponse struct { - *models.Server - Keys interface{} `json:"keys"` - } - c.JSON(http.StatusOK, serverResponse{ + c.JSON(http.StatusOK, ServerDetailResponse{ Server: s, Keys: assignments, }) } +// deleteServer godoc +// +// @Summary Delete a server +// @Tags servers +// @Produce json +// @Param id path string true "Server ID" +// @Success 200 {object} DeletedResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers/{id} [delete] func deleteServer(c *gin.Context) { id := c.Param("id") s, _ := services.GetServer(auth.InstanceID(c), id) @@ -295,9 +383,24 @@ func deleteServer(c *gin.Context) { hostname = s.Hostname } services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname)) - c.JSON(http.StatusOK, gin.H{"deleted": true}) + c.JSON(http.StatusOK, DeletedResponse{Deleted: true}) } +// generateKey godoc +// +// @Summary Generate a key on a server +// @Description Dispatches an agent command that generates a keypair on the target server and reports it back. +// @Tags keys +// @Accept json +// @Produce json +// @Param id path string true "Server ID" +// @Param body body object{label=string,key_type=string,key_size=int,passphrase=string,comment=string} false "Key generation parameters" +// @Success 202 {object} GenerateKeyResponse +// @Failure 404 {object} ErrorResponse +// @Failure 503 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers/{id}/generate-key [post] func generateKey(c *gin.Context) { id := c.Param("id") @@ -332,13 +435,23 @@ func generateKey(c *gin.Context) { } services.LogEvent(auth.InstanceID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType)) - c.JSON(http.StatusAccepted, gin.H{ - "message": "key generation command sent to agent", - "command_id": cmdID, - "server_id": s.ServerID, + c.JSON(http.StatusAccepted, GenerateKeyResponse{ + Message: "key generation command sent to agent", + CommandID: cmdID, + ServerID: s.ServerID, }) } +// listKeys godoc +// +// @Summary List keys +// @Tags keys +// @Produce json +// @Success 200 {array} models.Key +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /keys [get] func listKeys(c *gin.Context) { keys, err := services.ListKeys(auth.InstanceID(c)) if err != nil { @@ -348,6 +461,19 @@ func listKeys(c *gin.Context) { c.JSON(http.StatusOK, keys) } +// createKey godoc +// +// @Summary Upload a key +// @Tags keys +// @Accept json +// @Produce json +// @Param body body object{label=string,public_key=string,private_key=string,passphrase=string} true "Key material" +// @Success 201 {object} models.Key +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /keys [post] func createKey(c *gin.Context) { var body struct { Label string `json:"label" binding:"required"` @@ -369,6 +495,18 @@ func createKey(c *gin.Context) { c.JSON(http.StatusCreated, key) } +// getPrivateKey godoc +// +// @Summary Get a key's private material +// @Description Returns the decrypted private key. Reading is a keys:read action even though the material is sensitive. +// @Tags keys +// @Produce json +// @Param id path string true "Key ID" +// @Success 200 {object} PrivateKeyResponse +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /keys/{id}/private-key [get] func getPrivateKey(c *gin.Context) { id := c.Param("id") plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id) @@ -376,9 +514,21 @@ func getPrivateKey(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"private_key": plaintext}) + c.JSON(http.StatusOK, PrivateKeyResponse{PrivateKey: plaintext}) } +// getKey godoc +// +// @Summary Get a key +// @Description Returns a key together with the servers it is assigned to. +// @Tags keys +// @Produce json +// @Param id path string true "Key ID" +// @Success 200 {object} KeyDetailResponse +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /keys/{id} [get] func getKey(c *gin.Context) { id := c.Param("id") key, err := services.GetKey(auth.InstanceID(c), id) @@ -389,16 +539,23 @@ func getKey(c *gin.Context) { assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id) - type keyResponse struct { - *models.Key - Assignments any `json:"assignments"` - } - c.JSON(http.StatusOK, keyResponse{ + c.JSON(http.StatusOK, KeyDetailResponse{ Key: key, Assignments: assignments, }) } +// deleteKey godoc +// +// @Summary Delete a key +// @Tags keys +// @Produce json +// @Param id path string true "Key ID" +// @Success 200 {object} DeletedResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /keys/{id} [delete] func deleteKey(c *gin.Context) { id := c.Param("id") k, _ := services.GetKey(auth.InstanceID(c), id) @@ -411,9 +568,23 @@ func deleteKey(c *gin.Context) { label = k.Label } services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label)) - c.JSON(http.StatusOK, gin.H{"deleted": true}) + c.JSON(http.StatusOK, DeletedResponse{Deleted: true}) } +// assignKey godoc +// +// @Summary Assign a key to a server +// @Tags keys +// @Accept json +// @Produce json +// @Param id path string true "Key ID" +// @Param body body object{server_id=string} true "Target server" +// @Success 201 {object} models.Assignment +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /keys/{id}/assign [post] func assignKey(c *gin.Context) { keyID := c.Param("id") var body struct { @@ -433,6 +604,19 @@ func assignKey(c *gin.Context) { c.JSON(http.StatusCreated, a) } +// revokeAssignment godoc +// +// @Summary Revoke a key assignment +// @Description Soft revocation: sets revoked_at rather than deleting, preserving audit history. +// @Tags keys +// @Produce json +// @Param id path string true "Key ID" +// @Param serverId path string true "Server ID" +// @Success 200 {object} RevokedResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /keys/{id}/assign/{serverId} [delete] func revokeAssignment(c *gin.Context) { keyID := c.Param("id") serverID := c.Param("serverId") @@ -442,18 +626,42 @@ func revokeAssignment(c *gin.Context) { return } services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID)) - c.JSON(http.StatusOK, gin.H{"revoked": true}) + c.JSON(http.StatusOK, RevokedResponse{Revoked: true}) } +// getLatestAgentVersion godoc +// +// @Summary Get the latest agent version +// @Description Reads the latest agent/v* tag from the Gitea release API. +// @Tags servers +// @Produce json +// @Success 200 {object} AgentVersionResponse +// @Failure 503 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /agent/latest-version [get] func getLatestAgentVersion(c *gin.Context) { version, err := services.GetLatestAgentVersion() if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"version": version}) + c.JSON(http.StatusOK, AgentVersionResponse{Version: version}) } +// updateAgent godoc +// +// @Summary Update a server's agent +// @Description Dispatches UpdateAgentCmd to the agent, telling it to download and replace itself. +// @Tags servers +// @Produce json +// @Param id path string true "Server ID" +// @Success 202 {object} UpdateAgentResponse +// @Failure 404 {object} ErrorResponse +// @Failure 503 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers/{id}/update-agent [post] func updateAgent(c *gin.Context) { id := c.Param("id") s, err := services.GetServer(auth.InstanceID(c), id) @@ -468,12 +676,25 @@ func updateAgent(c *gin.Context) { return } services.LogEvent(auth.InstanceID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version)) - c.JSON(http.StatusAccepted, gin.H{ - "message": "update command sent to agent", - "version": version, + c.JSON(http.StatusAccepted, UpdateAgentResponse{ + Message: "update command sent to agent", + Version: version, }) } +// applyUpdates godoc +// +// @Summary Apply pending OS updates on a server +// @Description Dispatches ApplyUpdatesCmd. Exempt from the licence gate: security patching is never paywalled. +// @Tags servers +// @Produce json +// @Param id path string true "Server ID" +// @Success 202 {object} MessageResponse +// @Failure 404 {object} ErrorResponse +// @Failure 503 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /servers/{id}/apply-updates [post] func applyUpdates(c *gin.Context) { id := c.Param("id") s, err := services.GetServer(auth.InstanceID(c), id) @@ -487,9 +708,17 @@ func applyUpdates(c *gin.Context) { return } services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname)) - c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"}) + c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"}) } +// handleUpdateScript godoc +// +// @Summary Agent update script (Linux) +// @Description Dynamically generated shell script that downloads and installs the latest agent. +// @Tags install +// @Produce plain +// @Success 200 {string} string "shell script" +// @Router /update [get] func handleUpdateScript(c *gin.Context) { giteaHost := "gitea.hostxtra.co.uk" @@ -543,6 +772,21 @@ echo "vantage-agent updated to ${VERSION} and restarted." c.String(http.StatusOK, script) } +// listAuditEvents godoc +// +// @Summary List audit events +// @Description Every mutating API path writes an audit event. Paginated with a total, since a short page is not proof of the end of the log. +// @Tags audit +// @Produce json +// @Param q query string false "Free-text search" +// @Param category query string false "Filter by category" +// @Param limit query int false "Max events to return" +// @Param skip query int false "Events to skip" +// @Success 200 {object} AuditEventsResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /audit [get] func listAuditEvents(c *gin.Context) { f := services.AuditFilter{ Search: c.Query("q"), @@ -566,9 +810,19 @@ func listAuditEvents(c *gin.Context) { } // An object rather than a bare array: a page is meaningless without the // total it came from, and a short page is not proof of the end of the log. - c.JSON(http.StatusOK, gin.H{"events": events, "total": total}) + c.JSON(http.StatusOK, AuditEventsResponse{Events: events, Total: total}) } +// getSettings godoc +// +// @Summary Get instance settings +// @Tags settings +// @Produce json +// @Success 200 {object} models.Settings +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /settings [get] func getSettings(c *gin.Context) { s, err := services.GetSettings(auth.InstanceID(c)) if err != nil { @@ -578,6 +832,21 @@ func getSettings(c *gin.Context) { c.JSON(http.StatusOK, s) } +// saveSettings godoc +// +// @Summary Save instance settings +// @Description Owner and admin only. Refuses a change that would leave neither local login nor an enabled auth provider. +// @Tags settings +// @Accept json +// @Produce json +// @Param body body object{alerts=models.AlertSettings,workflow_log_retention_days=int,local_login_enabled=bool,api_token_max_days=int} true "Settings to save" +// @Success 200 {object} SavedResponse +// @Failure 400 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /settings [put] func saveSettings(c *gin.Context) { var body struct { Alerts models.AlertSettings `json:"alerts"` @@ -606,9 +875,19 @@ func saveSettings(c *gin.Context) { services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "", fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays)) } - c.JSON(http.StatusOK, gin.H{"saved": true}) + c.JSON(http.StatusOK, SavedResponse{Saved: true}) } +// handleInstallScript godoc +// +// @Summary Agent install script (Linux) +// @Description Dynamically generated shell script that downloads, verifies and installs the agent, seeded with a pre-registration token. +// @Tags install +// @Produce plain +// @Param server_id query string true "Server ID" +// @Param token query string true "Pre-registration token" +// @Success 200 {string} string "shell script" +// @Router /install [get] func handleInstallScript(c *gin.Context) { serverID := c.Query("server_id") token := c.Query("token") diff --git a/server/internal/api/tokens.go b/server/internal/api/tokens.go index c7c0711..fbbe0b9 100644 --- a/server/internal/api/tokens.go +++ b/server/internal/api/tokens.go @@ -16,8 +16,19 @@ func elevated(c *gin.Context) bool { return r == models.RoleOwner || r == models.RoleAdmin } -// listTokens returns the caller's own tokens. Owner and admin may ask for every -// token in the instance with ?all=true. +// listTokens godoc +// +// @Summary List API tokens +// @Description Returns the caller's own tokens. Owner and admin may pass all=true to see every token in the instance. +// @Tags tokens +// @Produce json +// @Param all query bool false "Include every token in the instance (owner and admin only)" +// @Success 200 {object} ListTokensResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /tokens [get] func listTokens(c *gin.Context) { all := c.Query("all") == "true" && elevated(c) tokens, err := services.ListAPITokens(auth.InstanceID(c), auth.UserID(c), all) @@ -25,14 +36,40 @@ func listTokens(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"tokens": tokens, "all": all}) + c.JSON(http.StatusOK, ListTokensResponse{Tokens: tokens, All: all}) } -// listTokenScopes advertises the vocabulary so the UI never hardcodes it. +// listTokenScopes godoc +// +// @Summary List available token scopes +// @Description Advertises the scope vocabulary so the UI never hardcodes it. +// @Tags tokens +// @Produce json +// @Success 200 {object} TokenScopesResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /tokens/scopes [get] func listTokenScopes(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"scopes": services.AllScopes()}) + c.JSON(http.StatusOK, TokenScopesResponse{Scopes: services.AllScopes()}) } +// createToken godoc +// +// @Summary Create an API token +// @Description The plaintext token is returned exactly once and stored nowhere. A token's role and scopes cannot exceed the creator's own. +// @Tags tokens +// @Accept json +// @Produce json +// @Param body body CreateTokenRequest true "Token parameters" +// @Success 201 {object} CreateTokenResponse +// @Failure 400 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 422 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /tokens [post] func createToken(c *gin.Context) { var body struct { Name string `json:"name" binding:"required"` @@ -78,9 +115,22 @@ func createToken(c *gin.Context) { fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", tok.Name, tok.Role, tok.Scopes, expiry)) // The plaintext is returned exactly once and is not stored anywhere. - c.JSON(http.StatusCreated, gin.H{"token": plaintext, "record": tok}) + c.JSON(http.StatusCreated, CreateTokenResponse{Token: plaintext, Record: *tok}) } +// revokeToken godoc +// +// @Summary Revoke an API token +// @Tags tokens +// @Produce json +// @Param id path string true "Token ID" +// @Success 200 {object} RevokedResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /tokens/{id} [delete] func revokeToken(c *gin.Context) { requester, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c)) if err != nil { @@ -100,5 +150,5 @@ func revokeToken(c *gin.Context) { services.LogEvent(auth.InstanceID(c), "token.revoked", actorFromCtx(c), "", "", fmt.Sprintf("API token '%s' revoked", tok.Name)) - c.JSON(http.StatusOK, gin.H{"revoked": true}) + c.JSON(http.StatusOK, RevokedResponse{Revoked: true}) }