// Package bridge provides a local HTTP server on MONITOR_PORT for // communication between the HarborForge OpenClaw plugin and Monitor. // // The plugin queries this endpoint to enrich its telemetry with // host/hardware data. The bridge is optional: if monitorPort is 0 // or not set, the bridge is not started and Monitor operates normally. package bridge import ( "context" "encoding/json" "fmt" "log" "net" "net/http" "sync" "time" "git.hangman-lab.top/zhi/HarborForge.Monitor/internal/config" "git.hangman-lab.top/zhi/HarborForge.Monitor/internal/telemetry" ) // Server is the local bridge HTTP server. type Server struct { cfg config.Config logger *log.Logger srv *http.Server mu sync.RWMutex lastPayload *telemetry.Payload lastUpdated time.Time } // New creates a bridge server. It does not start listening. func New(cfg config.Config, logger *log.Logger) *Server { return &Server{ cfg: cfg, logger: logger, } } // UpdatePayload stores the latest telemetry payload so the bridge can // serve it to plugin queries without re-collecting. func (s *Server) UpdatePayload(p telemetry.Payload) { s.mu.Lock() defer s.mu.Unlock() s.lastPayload = &p s.lastUpdated = time.Now() } // bridgeResponse is the JSON structure served to the plugin. type bridgeResponse struct { Status string `json:"status"` MonitorVer string `json:"monitor_version"` Identifier string `json:"identifier"` Telemetry *telemetry.Payload `json:"telemetry,omitempty"` LastUpdated *time.Time `json:"last_updated,omitempty"` } func (s *Server) handler() http.Handler { mux := http.NewServeMux() // Health / discovery endpoint mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "status": "ok", "monitor_version": telemetry.Version, "identifier": s.cfg.Identifier, }) }) // Telemetry endpoint — returns the latest cached payload mux.HandleFunc("/telemetry", func(w http.ResponseWriter, r *http.Request) { s.mu.RLock() payload := s.lastPayload updated := s.lastUpdated s.mu.RUnlock() resp := bridgeResponse{ Status: "ok", MonitorVer: telemetry.Version, Identifier: s.cfg.Identifier, } if payload != nil { resp.Telemetry = payload resp.LastUpdated = &updated } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) }) return mux } // Start begins listening on 127.0.0.1:. It blocks until // the context is cancelled or an error occurs. func (s *Server) Start(ctx context.Context) error { if s.cfg.MonitorPort <= 0 { return nil // bridge disabled } addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.MonitorPort) listener, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("bridge listen on %s: %w", addr, err) } s.srv = &http.Server{ Handler: s.handler(), ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second, } s.logger.Printf("bridge listening on %s", addr) go func() { <-ctx.Done() shutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() s.srv.Shutdown(shutCtx) }() if err := s.srv.Serve(listener); err != nil && err != http.ErrServerClosed { return fmt.Errorf("bridge serve: %w", err) } return nil }