package nodes import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "syscall" "time" ) type RegistryServer struct { addr string token string mgr *Manager server *http.Server configPath string onChat func(ctx context.Context, sessionKey, content string) (string, error) onConfigAfter func() onCron func(action string, args map[string]interface{}) (interface{}, error) webUIDir string } func NewRegistryServer(host string, port int, token string, mgr *Manager) *RegistryServer { addr := strings.TrimSpace(host) if addr == "" { addr = "0.0.0.0" } if port <= 0 { port = 7788 } return &RegistryServer{addr: fmt.Sprintf("%s:%d", addr, port), token: strings.TrimSpace(token), mgr: mgr} } func (s *RegistryServer) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) } func (s *RegistryServer) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) { s.onChat = fn } func (s *RegistryServer) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn } func (s *RegistryServer) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) { s.onCron = fn } func (s *RegistryServer) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) } func (s *RegistryServer) Start(ctx context.Context) error { if s.mgr == nil { return nil } mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) mux.HandleFunc("/nodes/register", s.handleRegister) mux.HandleFunc("/nodes/heartbeat", s.handleHeartbeat) mux.HandleFunc("/webui", s.handleWebUI) mux.HandleFunc("/webui/", s.handleWebUIAsset) mux.HandleFunc("/webui/api/config", s.handleWebUIConfig) mux.HandleFunc("/webui/api/chat", s.handleWebUIChat) mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload) mux.HandleFunc("/webui/api/nodes", s.handleWebUINodes) mux.HandleFunc("/webui/api/cron", s.handleWebUICron) s.server = &http.Server{Addr: s.addr, Handler: mux} go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = s.server.Shutdown(shutdownCtx) }() go func() { _ = s.server.ListenAndServe() }() return nil } func (s *RegistryServer) handleRegister(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var n NodeInfo if err := json.NewDecoder(r.Body).Decode(&n); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if strings.TrimSpace(n.ID) == "" { http.Error(w, "id required", http.StatusBadRequest) return } s.mgr.Upsert(n) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": n.ID}) } func (s *RegistryServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var body struct{ ID string `json:"id"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.ID) == "" { http.Error(w, "id required", http.StatusBadRequest) return } n, ok := s.mgr.Get(body.ID) if !ok { http.Error(w, "node not found", http.StatusNotFound) return } n.LastSeenAt = time.Now().UTC() n.Online = true s.mgr.Upsert(n) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": body.ID}) } func (s *RegistryServer) handleWebUI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.token != "" { http.SetCookie(w, &http.Cookie{ Name: "clawgo_webui_token", Value: s.token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400, }) } if s.tryServeWebUIDist(w, r, "/webui/index.html") { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(webUIHTML)) } func (s *RegistryServer) handleWebUIAsset(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.tryServeWebUIDist(w, r, r.URL.Path) { return } // SPA fallback if s.tryServeWebUIDist(w, r, "/webui/index.html") { return } http.NotFound(w, r) } func (s *RegistryServer) tryServeWebUIDist(w http.ResponseWriter, r *http.Request, reqPath string) bool { dir := strings.TrimSpace(s.webUIDir) if dir == "" { return false } p := strings.TrimPrefix(reqPath, "/webui/") if reqPath == "/webui" || reqPath == "/webui/" || reqPath == "/webui/index.html" { p = "index.html" } p = filepath.Clean(strings.TrimPrefix(p, "/")) if strings.HasPrefix(p, "..") { return false } full := filepath.Join(dir, p) fi, err := os.Stat(full) if err != nil || fi.IsDir() { return false } http.ServeFile(w, r, full) return true } func (s *RegistryServer) handleWebUIConfig(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if strings.TrimSpace(s.configPath) == "" { http.Error(w, "config path not set", http.StatusInternalServerError) return } switch r.Method { case http.MethodGet: b, err := os.ReadFile(s.configPath) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if r.URL.Query().Get("include_hot_reload_fields") == "1" || strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("mode")), "hot") { var cfg map[string]interface{} if err := json.Unmarshal(b, &cfg); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "config": cfg, "hot_reload_fields": hotReloadFieldPaths(), }) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(b) case http.MethodPost: var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } b, err := json.MarshalIndent(body, "", " ") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } tmp := s.configPath + ".tmp" if err := os.WriteFile(tmp, b, 0644); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := os.Rename(tmp, s.configPath); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if s.onConfigAfter != nil { s.onConfigAfter() } else { _ = syscall.Kill(os.Getpid(), syscall.SIGHUP) } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reloaded": true}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *RegistryServer) handleWebUIUpload(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } f, h, err := r.FormFile("file") if err != nil { http.Error(w, "file required", http.StatusBadRequest) return } defer f.Close() dir := filepath.Join(os.TempDir(), "clawgo_webui_uploads") _ = os.MkdirAll(dir, 0755) name := fmt.Sprintf("%d_%s", time.Now().UnixNano(), filepath.Base(h.Filename)) path := filepath.Join(dir, name) out, err := os.Create(path) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer out.Close() if _, err := io.Copy(out, f); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "path": path, "name": h.Filename}) } func (s *RegistryServer) handleWebUIChat(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if s.onChat == nil { http.Error(w, "chat handler not configured", http.StatusInternalServerError) return } var body struct { Session string `json:"session"` Message string `json:"message"` Media string `json:"media"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } session := strings.TrimSpace(body.Session) if session == "" { session = "webui:default" } prompt := strings.TrimSpace(body.Message) if strings.TrimSpace(body.Media) != "" { if prompt != "" { prompt += "\n" } prompt += "[file: " + strings.TrimSpace(body.Media) + "]" } resp, err := s.onChat(r.Context(), session, prompt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reply": resp, "session": session}) } func (s *RegistryServer) handleWebUINodes(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } list := []NodeInfo{} if s.mgr != nil { list = s.mgr.List() } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list}) } func (s *RegistryServer) handleWebUICron(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.onCron == nil { http.Error(w, "cron handler not configured", http.StatusInternalServerError) return } switch r.Method { case http.MethodGet: id := strings.TrimSpace(r.URL.Query().Get("id")) action := "list" if id != "" { action = "get" } res, err := s.onCron(action, map[string]interface{}{"id": id}) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if action == "list" { _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "jobs": res}) } else { _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "job": res}) } case http.MethodPost: args := map[string]interface{}{} if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&args) } if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" { args["id"] = id } action := "create" if a, ok := args["action"].(string); ok && strings.TrimSpace(a) != "" { action = strings.ToLower(strings.TrimSpace(a)) } res, err := s.onCron(action, args) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": res}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *RegistryServer) checkAuth(r *http.Request) bool { if s.token == "" { return true } auth := strings.TrimSpace(r.Header.Get("Authorization")) if auth == "Bearer "+s.token { return true } if strings.TrimSpace(r.URL.Query().Get("token")) == s.token { return true } if c, err := r.Cookie("clawgo_webui_token"); err == nil && strings.TrimSpace(c.Value) == s.token { return true } // Browser asset fallback: allow token propagated via Referer query. if ref := strings.TrimSpace(r.Referer()); ref != "" { if u, err := url.Parse(ref); err == nil { if strings.TrimSpace(u.Query().Get("token")) == s.token { return true } } } return false } func hotReloadFieldPaths() []string { return []string{ "logging.*", "sentinel.*", "agents.*", "providers.*", "tools.*", "channels.*", "cron.*", "agents.defaults.heartbeat.*", "agents.defaults.autonomy.*", "gateway.* (except listen address/port may require restart in some environments)", } } const webUIHTML = ` ClawGo WebUI

ClawGo WebUI

Token:

Config (dynamic + hot reload)

Chat (supports media upload)

Session:
`