Files
tyto/backend/internal/server/registry.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

285 lines
6.1 KiB
Go

// Package server implements the central Tyto server for multi-device monitoring.
package server
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// AgentStatus represents the current state of an agent.
type AgentStatus string
const (
AgentStatusPending AgentStatus = "pending"
AgentStatusApproved AgentStatus = "approved"
AgentStatusConnected AgentStatus = "connected"
AgentStatusOffline AgentStatus = "offline"
AgentStatusRevoked AgentStatus = "revoked"
)
// AgentRecord stores metadata about a registered agent.
type AgentRecord 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 AgentStatus `json:"status"`
CertSerial string `json:"certSerial,omitempty"`
CertExpiry time.Time `json:"certExpiry,omitempty"`
LastSeen time.Time `json:"lastSeen,omitempty"`
RegisteredAt time.Time `json:"registeredAt"`
Tags []string `json:"tags,omitempty"`
}
// Registry manages agent registrations.
type Registry struct {
mu sync.RWMutex
agents map[string]*AgentRecord
filePath string
}
// NewRegistry creates a new agent registry.
func NewRegistry(filePath string) *Registry {
r := &Registry{
agents: make(map[string]*AgentRecord),
filePath: filePath,
}
// Load existing registrations
if filePath != "" {
r.load()
}
return r
}
// Register adds or updates an agent registration.
func (r *Registry) Register(agent *AgentRecord) error {
r.mu.Lock()
defer r.mu.Unlock()
existing, exists := r.agents[agent.ID]
if exists {
// Update existing record
existing.Hostname = agent.Hostname
existing.OS = agent.OS
existing.Architecture = agent.Architecture
existing.Version = agent.Version
existing.Capabilities = agent.Capabilities
existing.LastSeen = time.Now()
// Don't change status if already approved/connected
if existing.Status == AgentStatusRevoked {
return fmt.Errorf("agent %s is revoked", agent.ID)
}
} else {
// New registration
agent.RegisteredAt = time.Now()
agent.LastSeen = time.Now()
if agent.Status == "" {
agent.Status = AgentStatusPending
}
r.agents[agent.ID] = agent
}
return r.save()
}
// Get returns an agent record by ID.
func (r *Registry) Get(id string) (*AgentRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
agent, exists := r.agents[id]
if !exists {
return nil, false
}
// Return a copy
copy := *agent
return &copy, true
}
// List returns all registered agents.
func (r *Registry) List() []*AgentRecord {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*AgentRecord, 0, len(r.agents))
for _, agent := range r.agents {
copy := *agent
result = append(result, &copy)
}
return result
}
// ListPending returns agents awaiting approval.
func (r *Registry) ListPending() []*AgentRecord {
r.mu.RLock()
defer r.mu.RUnlock()
var result []*AgentRecord
for _, agent := range r.agents {
if agent.Status == AgentStatusPending {
copy := *agent
result = append(result, &copy)
}
}
return result
}
// Approve marks an agent as approved.
func (r *Registry) Approve(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
agent, exists := r.agents[id]
if !exists {
return fmt.Errorf("agent %s not found", id)
}
if agent.Status == AgentStatusRevoked {
return fmt.Errorf("agent %s is revoked", id)
}
agent.Status = AgentStatusApproved
return r.save()
}
// Reject removes a pending agent registration.
func (r *Registry) Reject(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
agent, exists := r.agents[id]
if !exists {
return fmt.Errorf("agent %s not found", id)
}
if agent.Status != AgentStatusPending {
return fmt.Errorf("agent %s is not pending", id)
}
delete(r.agents, id)
return r.save()
}
// Revoke marks an agent as revoked.
func (r *Registry) Revoke(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
agent, exists := r.agents[id]
if !exists {
return fmt.Errorf("agent %s not found", id)
}
agent.Status = AgentStatusRevoked
return r.save()
}
// Remove deletes an agent registration.
func (r *Registry) Remove(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.agents[id]; !exists {
return fmt.Errorf("agent %s not found", id)
}
delete(r.agents, id)
return r.save()
}
// IsApproved checks if an agent is approved to connect.
func (r *Registry) IsApproved(id string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
agent, exists := r.agents[id]
if !exists {
return false
}
return agent.Status == AgentStatusApproved || agent.Status == AgentStatusConnected
}
// UpdateStatus updates an agent's connection status.
func (r *Registry) UpdateStatus(id string, status AgentStatus) error {
r.mu.Lock()
defer r.mu.Unlock()
agent, exists := r.agents[id]
if !exists {
return fmt.Errorf("agent %s not found", id)
}
agent.Status = status
agent.LastSeen = time.Now()
return r.save()
}
// UpdateLastSeen updates the last seen timestamp.
func (r *Registry) UpdateLastSeen(id string) {
r.mu.Lock()
defer r.mu.Unlock()
if agent, exists := r.agents[id]; exists {
agent.LastSeen = time.Now()
}
}
// load reads the registry from disk.
func (r *Registry) load() error {
data, err := os.ReadFile(r.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var agents []*AgentRecord
if err := json.Unmarshal(data, &agents); err != nil {
return err
}
for _, agent := range agents {
r.agents[agent.ID] = agent
}
return nil
}
// save writes the registry to disk.
func (r *Registry) save() error {
if r.filePath == "" {
return nil
}
agents := make([]*AgentRecord, 0, len(r.agents))
for _, agent := range r.agents {
agents = append(agents, agent)
}
data, err := json.MarshalIndent(agents, "", " ")
if err != nil {
return err
}
// Ensure directory exists
dir := filepath.Dir(r.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(r.filePath, data, 0644)
}