Files
tyto/backend/internal/api/agents.go
vikingowl 80f6e788f4 feat: implement server hub for multi-device agent management
Server Package (internal/server/):
- Registry: Agent registration with approval workflow, persistence
- Hub: Connection manager for connected agents, message routing
- GRPCServer: mTLS-enabled gRPC server with interceptors
- SSEBridge: Bridges agent metrics to browser SSE clients

Registry Features:
- JSON file-based persistence
- Agent lifecycle: pending -> approved -> connected -> offline
- Revocation support for certificate-based agent removal
- Automatic last-seen tracking

Hub Features:
- Bidirectional gRPC stream handling
- MetricsSubscriber interface for metric distribution
- Stale connection detection and cleanup
- Broadcast and per-agent command sending

gRPC Server:
- Unary and stream interceptors for auth
- Agent ID extraction from mTLS certificates
- Delegation to Hub for business logic

Agent Management API:
- GET/DELETE /api/v1/agents - List/remove agents
- GET /api/v1/agents/pending - Pending approvals
- POST /api/v1/agents/pending/:id/approve|reject
- GET /api/v1/agents/:id/metrics - Latest agent metrics
- GET /api/v1/agents/connected - Connected agents

Server Mode Startup:
- Full initialization of registry, hub, gRPC, SSE bridge
- Graceful shutdown with signal handling
- Agent mode now uses the agent package

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 07:53:23 +01:00

225 lines
5.5 KiB
Go

package api
import (
"net/http"
"github.com/gin-gonic/gin"
"tyto/internal/server"
)
// AgentAPI handles agent management endpoints.
type AgentAPI struct {
registry *server.Registry
hub *server.Hub
}
// NewAgentAPI creates a new agent API handler.
func NewAgentAPI(registry *server.Registry, hub *server.Hub) *AgentAPI {
return &AgentAPI{
registry: registry,
hub: hub,
}
}
// RegisterRoutes adds agent management routes to a router group.
func (a *AgentAPI) RegisterRoutes(group *gin.RouterGroup) {
agents := group.Group("/agents")
{
agents.GET("", a.listAgents)
agents.GET("/:id", a.getAgent)
agents.DELETE("/:id", a.removeAgent)
agents.POST("/:id/revoke", a.revokeAgent)
// Pending registrations
agents.GET("/pending", a.listPending)
agents.POST("/pending/:id/approve", a.approveAgent)
agents.POST("/pending/:id/reject", a.rejectAgent)
// Connected agents
agents.GET("/connected", a.listConnected)
agents.GET("/:id/metrics", a.getAgentMetrics)
}
}
// AgentResponse is the API representation of an agent.
type AgentResponse struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Architecture string `json:"architecture"`
Version string `json:"version"`
Capabilities []string `json:"capabilities,omitempty"`
Status string `json:"status"`
Connected bool `json:"connected"`
LastSeen string `json:"lastSeen,omitempty"`
RegisteredAt string `json:"registeredAt"`
Tags []string `json:"tags,omitempty"`
}
func agentToResponse(agent *server.AgentRecord, connected bool) AgentResponse {
lastSeen := ""
if !agent.LastSeen.IsZero() {
lastSeen = agent.LastSeen.Format("2006-01-02T15:04:05Z07:00")
}
registeredAt := ""
if !agent.RegisteredAt.IsZero() {
registeredAt = agent.RegisteredAt.Format("2006-01-02T15:04:05Z07:00")
}
return AgentResponse{
ID: agent.ID,
Name: agent.Name,
Hostname: agent.Hostname,
OS: agent.OS,
Architecture: agent.Architecture,
Version: agent.Version,
Capabilities: agent.Capabilities,
Status: string(agent.Status),
Connected: connected,
LastSeen: lastSeen,
RegisteredAt: registeredAt,
Tags: agent.Tags,
}
}
// listAgents returns all registered agents.
func (a *AgentAPI) listAgents(c *gin.Context) {
agents := a.registry.List()
connectedIDs := make(map[string]bool)
if a.hub != nil {
for _, id := range a.hub.GetConnectedAgents() {
connectedIDs[id] = true
}
}
response := make([]AgentResponse, len(agents))
for i, agent := range agents {
response[i] = agentToResponse(agent, connectedIDs[agent.ID])
}
c.JSON(http.StatusOK, response)
}
// getAgent returns a specific agent.
func (a *AgentAPI) getAgent(c *gin.Context) {
id := c.Param("id")
agent, exists := a.registry.Get(id)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
connected := false
if a.hub != nil {
for _, connID := range a.hub.GetConnectedAgents() {
if connID == id {
connected = true
break
}
}
}
c.JSON(http.StatusOK, agentToResponse(agent, connected))
}
// removeAgent removes an agent registration.
func (a *AgentAPI) removeAgent(c *gin.Context) {
id := c.Param("id")
if err := a.registry.Remove(id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "removed"})
}
// revokeAgent revokes an agent's registration.
func (a *AgentAPI) revokeAgent(c *gin.Context) {
id := c.Param("id")
if err := a.registry.Revoke(id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// listPending returns agents awaiting approval.
func (a *AgentAPI) listPending(c *gin.Context) {
agents := a.registry.ListPending()
response := make([]AgentResponse, len(agents))
for i, agent := range agents {
response[i] = agentToResponse(agent, false)
}
c.JSON(http.StatusOK, response)
}
// approveAgent approves a pending agent.
func (a *AgentAPI) approveAgent(c *gin.Context) {
id := c.Param("id")
if err := a.registry.Approve(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "approved"})
}
// rejectAgent rejects a pending agent.
func (a *AgentAPI) rejectAgent(c *gin.Context) {
id := c.Param("id")
if err := a.registry.Reject(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "rejected"})
}
// listConnected returns currently connected agents.
func (a *AgentAPI) listConnected(c *gin.Context) {
if a.hub == nil {
c.JSON(http.StatusOK, []AgentResponse{})
return
}
connectedIDs := a.hub.GetConnectedAgents()
response := make([]AgentResponse, 0, len(connectedIDs))
for _, id := range connectedIDs {
if agent, exists := a.registry.Get(id); exists {
response = append(response, agentToResponse(agent, true))
}
}
c.JSON(http.StatusOK, response)
}
// getAgentMetrics returns the latest metrics for an agent.
func (a *AgentAPI) getAgentMetrics(c *gin.Context) {
id := c.Param("id")
if a.hub == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub not available"})
return
}
metrics, exists := a.hub.GetAgentMetrics(id)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "no metrics available"})
return
}
c.JSON(http.StatusOK, metrics)
}