diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 8be3694..ee61828 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -132,6 +132,15 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.POST("/vuln-rules", auth.RequireRole("owner", "admin"), createVulnRule) apiGroup.PUT("/vuln-rules/:id", auth.RequireRole("owner", "admin"), updateVulnRule) apiGroup.DELETE("/vuln-rules/:id", auth.RequireRole("owner", "admin"), deleteVulnRule) + + // Control actions and log reads are owner|admin: container output is + // arbitrary and cannot be masked, so a member who can see the fleet + // still cannot read its logs. + apiGroup.GET("/workloads", listWorkloads) + apiGroup.GET("/servers/:id/workloads", getServerWorkloads) + apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads) + apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload) + apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs) } } diff --git a/server/internal/api/workloads.go b/server/internal/api/workloads.go new file mode 100644 index 0000000..fc158cd --- /dev/null +++ b/server/internal/api/workloads.go @@ -0,0 +1,196 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// getServerWorkloads returns the stored snapshot. +// +// A server that has never reported answers an empty list rather than 404: the +// agent may simply not have got there yet, and 404 reads as "no such server". +func getServerWorkloads(c *gin.Context) { + instanceID := auth.InstanceID(c) + id := c.Param("id") + + if _, err := services.GetServer(instanceID, id); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + sw, err := services.GetWorkloads(instanceID, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if sw == nil { + c.JSON(http.StatusOK, models.ServerWorkloads{ + ServerID: id, + Workloads: []models.Workload{}, + }) + return + } + if sw.Workloads == nil { + sw.Workloads = []models.Workload{} + } + c.JSON(http.StatusOK, sw) +} + +// refreshServerWorkloads nudges the agent to report now. It returns no data: +// the client refetches the stored document once the agent has written it. +func refreshServerWorkloads(c *gin.Context) { + instanceID := auth.InstanceID(c) + id := c.Param("id") + + s, err := services.GetServer(instanceID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + if err := services.DispatchRefreshWorkloads(s.ServerID); err != nil { + // Not queued: a command whose owner died must fail loudly, so the + // client can show the stored snapshot as stale rather than pretend. + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusAccepted, gin.H{"message": "refresh requested"}) +} + +func controlWorkload(c *gin.Context) { + instanceID := auth.InstanceID(c) + id := c.Param("id") + + wid, ok := workloadIDParam(c) + if !ok { + return + } + + var body struct { + Action string `json:"action"` + Kind string `json:"kind"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + switch body.Action { + case models.WorkloadStart, models.WorkloadStop, models.WorkloadRestart: + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "action must be start, stop or restart"}) + return + } + if body.Kind != models.WorkloadContainer && body.Kind != models.WorkloadUnit { + c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"}) + return + } + + s, err := services.GetServer(instanceID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + err = services.DispatchControlWorkload(s.ServerID, body.Kind, wid, body.Action) + if err != nil { + switch { + case errors.Is(err, services.ErrAgentNotConnected): + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + case services.IsWorkloadProtected(err): + // Nothing failed — the agent refused, which is the design. 409, not + // 500, and the reason is carried through. + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + } + return + } + + services.LogEvent(instanceID, "workload."+body.Action, actorFromCtx(c), s.ServerID, "", + fmt.Sprintf("%s %s %s on %s", body.Action, body.Kind, wid, s.Hostname)) + c.JSON(http.StatusOK, gin.H{"message": body.Action + " ok"}) +} + +func getWorkloadLogs(c *gin.Context) { + instanceID := auth.InstanceID(c) + id := c.Param("id") + + wid, ok := workloadIDParam(c) + if !ok { + return + } + + kind := c.DefaultQuery("kind", models.WorkloadContainer) + if kind != models.WorkloadContainer && kind != models.WorkloadUnit { + c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"}) + return + } + + // Clamped rather than refused: a client asking for more than the cap gets + // the cap, which is what it would have got anyway. + tail, _ := strconv.Atoi(c.Query("tail")) + if tail <= 0 || tail > services.MaxWorkloadLogLines { + tail = services.MaxWorkloadLogLines + } + + s, err := services.GetServer(instanceID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + text, truncated, err := services.DispatchWorkloadLogs(s.ServerID, kind, wid, tail) + if err != nil { + if errors.Is(err, services.ErrAgentNotConnected) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + // Audited because container output is arbitrary and cannot be masked: a + // startup banner or a stack trace may carry credentials nobody declared. + services.LogEvent(instanceID, "workload.logs_read", actorFromCtx(c), s.ServerID, "", + fmt.Sprintf("read %s logs for %s on %s", kind, wid, s.Hostname)) + + c.JSON(http.StatusOK, gin.H{"text": text, "truncated": truncated}) +} + +// listWorkloads answers the fleet-wide question, which is the reason the +// snapshot is stored rather than fetched on demand and discarded. +func listWorkloads(c *gin.Context) { + hits, err := services.SearchWorkloads(auth.InstanceID(c), + c.Query("image"), c.Query("stack"), c.Query("state")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, hits) +} + +// workloadIDParam decodes :wid. Unit names carry dots and '@', so the client +// encodes it and this is where it comes back. +func workloadIDParam(c *gin.Context) (string, bool) { + raw := c.Param("wid") + decoded, err := url.PathUnescape(raw) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workload id"}) + return "", false + } + decoded = strings.TrimSpace(decoded) + if decoded == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workload id is required"}) + return "", false + } + return decoded, true +} diff --git a/server/internal/services/workloads.go b/server/internal/services/workloads.go index c87e359..19dee0f 100644 --- a/server/internal/services/workloads.go +++ b/server/internal/services/workloads.go @@ -3,6 +3,7 @@ package services import ( "context" "fmt" + "strings" "time" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" @@ -20,6 +21,24 @@ import ( // agent reports its real error rather than being cut off by this side. const workloadResultTimeout = 120 * time.Second +// MaxWorkloadLogLines mirrors the agent's own cap. It is declared again here +// rather than imported: agent/ is a separate module with an internal/ tree, so +// the two cannot share a constant. Change one, change the other — the same +// shape of hazard as the mirrored token blocks in the web apps. +const MaxWorkloadLogLines = 500 + +// workloadProtectedMarker is the text the agent's ErrProtected carries. The +// refusal crosses the wire as a string, so this is how the control plane knows +// a 409 is owed rather than a 502. +const workloadProtectedMarker = "workload is protected" + +// IsWorkloadProtected reports whether an agent refused because the target is +// protected — the agent's own guard, which is the boundary. Nothing failed, so +// the API answers 409 rather than an error status. +func IsWorkloadProtected(err error) bool { + return err != nil && strings.Contains(err.Error(), workloadProtectedMarker) +} + func HasWorkloadHash(instanceID, serverID, hash string) (bool, error) { err := db.Col("server_workloads").FindOne(context.Background(), bson.M{ "instance_id": instanceID,