package modules import ( "crypto/subtle" "net/http" "sync" "time" "github.com/gin-gonic/gin" "github.com/spf13/viper" ) // AgentModule is the app-server-facing control plane for external Agent // adapters. Message delivery remains in MessageModule; this module makes the // adapter's liveness and delivery cursor observable without exposing Hermes. type AgentModule struct { vp *viper.Viper mu sync.RWMutex entries map[string]agentHeartbeat } type agentHeartbeat struct { Status string `json:"status"` LastMessageSeq uint64 `json:"last_message_seq"` Detail string `json:"detail,omitempty"` UpdatedAt time.Time `json:"updated_at"` } type agentHeartbeatRequest struct { Status string `json:"status" binding:"required"` LastMessageSeq uint64 `json:"last_message_seq"` Detail string `json:"detail"` } func NewAgentModule(vp *viper.Viper) *AgentModule { return &AgentModule{vp: vp, entries: make(map[string]agentHeartbeat)} } func (m *AgentModule) RegisterRoutes(r *gin.Engine) { r.GET("/agents/:uid/health", m.handleHealth) r.POST("/agents/:uid/heartbeat", m.handleHeartbeat) } func (m *AgentModule) configuredSecret() string { return m.vp.GetString("agent.shared_secret") } func (m *AgentModule) authorize(c *gin.Context) bool { secret := m.configuredSecret() if secret == "" { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Agent heartbeat 未配置 shared_secret"}) return false } provided := c.GetHeader("X-LineUp-Agent-Secret") if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { c.JSON(http.StatusUnauthorized, gin.H{"error": "Agent heartbeat 认证失败"}) return false } return true } func (m *AgentModule) handleHeartbeat(c *gin.Context) { if !m.authorize(c) { return } var req agentHeartbeatRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: status必填"}) return } entry := agentHeartbeat{ Status: req.Status, LastMessageSeq: req.LastMessageSeq, Detail: req.Detail, UpdatedAt: time.Now().UTC(), } m.mu.Lock() m.entries[c.Param("uid")] = entry m.mu.Unlock() c.JSON(http.StatusOK, gin.H{"status": "ok", "updated_at": entry.UpdatedAt}) } func (m *AgentModule) handleHealth(c *gin.Context) { uid := c.Param("uid") m.mu.RLock() entry, found := m.entries[uid] m.mu.RUnlock() if !found { c.JSON(http.StatusOK, gin.H{"uid": uid, "status": "offline", "known": false}) return } // A worker normally reports once per poll. Keep a generous threshold so a // slow Hermes turn does not look dead while it is still being supervised. online := time.Since(entry.UpdatedAt) <= 45*time.Second c.JSON(http.StatusOK, gin.H{ "uid": uid, "status": entry.Status, "known": true, "online": online, "last_message_seq": entry.LastMessageSeq, "detail": entry.Detail, "updated_at": entry.UpdatedAt, }) }