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>
225 lines
5.5 KiB
Go
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)
|
|
}
|