package main import ( "crypto/subtle" "encoding/json" "fmt" "io" "log" "net/http" "strconv" "strings" ) type API struct { cfg *Config mgr *Manager shares *ShareStore ntfy *Ntfy } func (a *API) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tok := r.Header.Get("X-Api-Token") if tok == "" { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { tok = strings.TrimPrefix(h, "Bearer ") } } if tok == "" { tok = r.URL.Query().Get("token") // SSE/EventSource + links } if subtle.ConstantTimeCompare([]byte(tok), []byte(a.cfg.Token)) != 1 { jsonErr(w, http.StatusUnauthorized, "ogiltig eller saknad token") return } next(w, r) } } func jsonOut(w http.ResponseWriter, code int, v interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) json.NewEncoder(w).Encode(v) } func jsonErr(w http.ResponseWriter, code int, msg string) { jsonOut(w, code, map[string]string{"error": msg}) } func readJSON(r *http.Request, v interface{}) error { defer r.Body.Close() return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v) } func (a *API) routes() *http.ServeMux { mux := http.NewServeMux() // oautentiserad läges-detektering: webbklienten är samma SPA för // lokal helmd och hub och avgör läget med det här svaret mux.HandleFunc("GET /api/mode", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]string{"mode": "local", "version": version}) }) mux.HandleFunc("GET /api/health", a.auth(func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]interface{}{ "ok": true, "version": version, "sessions": len(a.mgr.List()), }) })) mux.HandleFunc("GET /api/sessions", a.auth(func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, a.mgr.List()) })) mux.HandleFunc("POST /api/sessions", a.auth(func(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Cmd string `json:"cmd"` Cwd string `json:"cwd"` Adopt bool `json:"adopt"` Tmux string `json:"tmux"` } if err := readJSON(r, &req); err != nil { jsonErr(w, 400, err.Error()) return } var ( s *SessionState err error ) if req.Adopt { s, err = a.mgr.Adopt(req.Name, req.Tmux) } else { s, err = a.mgr.Create(req.Name, req.Cmd, req.Cwd) } if err != nil { jsonErr(w, 400, err.Error()) return } jsonOut(w, 201, s) })) mux.HandleFunc("GET /api/sessions/{name}", a.auth(func(w http.ResponseWriter, r *http.Request) { s, ok := a.mgr.Get(r.PathValue("name")) if !ok { jsonErr(w, 404, "ingen sådan session") return } jsonOut(w, 200, s) })) mux.HandleFunc("DELETE /api/sessions/{name}", a.auth(func(w http.ResponseWriter, r *http.Request) { if err := a.mgr.Kill(r.PathValue("name")); err != nil { jsonErr(w, 404, err.Error()) return } jsonOut(w, 200, map[string]bool{"killed": true}) })) mux.HandleFunc("GET /api/sessions/{name}/screen", a.auth(func(w http.ResponseWriter, r *http.Request) { ansi := r.URL.Query().Get("ansi") == "1" history, _ := strconv.Atoi(r.URL.Query().Get("history")) screen, err := a.mgr.Screen(r.PathValue("name"), ansi, history) if err != nil { jsonErr(w, 404, err.Error()) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") io.WriteString(w, screen) })) mux.HandleFunc("POST /api/sessions/{name}/prompt", a.auth(func(w http.ResponseWriter, r *http.Request) { var req struct { Text string `json:"text"` Submit *bool `json:"submit"` } if err := readJSON(r, &req); err != nil || req.Text == "" { jsonErr(w, 400, "JSON-kropp med \"text\" krävs") return } submit := req.Submit == nil || *req.Submit if err := a.mgr.Prompt(r.PathValue("name"), req.Text, submit); err != nil { jsonErr(w, 400, err.Error()) return } jsonOut(w, 200, map[string]bool{"sent": true}) })) mux.HandleFunc("GET /api/sessions/{name}/question", a.auth(func(w http.ResponseWriter, r *http.Request) { s, ok := a.mgr.Get(r.PathValue("name")) if !ok { jsonErr(w, 404, "ingen sådan session") return } jsonOut(w, 200, map[string]interface{}{"question": s.Question}) })) mux.HandleFunc("POST /api/sessions/{name}/answer", a.auth(func(w http.ResponseWriter, r *http.Request) { var req struct { Option int `json:"option"` } if err := readJSON(r, &req); err != nil { jsonErr(w, 400, err.Error()) return } if err := a.mgr.Answer(r.PathValue("name"), req.Option); err != nil { jsonErr(w, 400, err.Error()) return } jsonOut(w, 200, map[string]bool{"answered": true}) })) mux.HandleFunc("POST /api/sessions/{name}/keys", a.auth(func(w http.ResponseWriter, r *http.Request) { var req struct { Keys []string `json:"keys"` } if err := readJSON(r, &req); err != nil || len(req.Keys) == 0 { jsonErr(w, 400, "JSON-kropp med \"keys\": [\"Enter\", ...] krävs") return } if err := a.mgr.Keys(r.PathValue("name"), req.Keys); err != nil { jsonErr(w, 400, err.Error()) return } jsonOut(w, 200, map[string]bool{"sent": true}) })) // Mode: "cycle" sends the agent's mode-switch key (Shift+Tab in // agy/Claude Code). Arbitrary key sequences via /keys. mux.HandleFunc("POST /api/sessions/{name}/mode", a.auth(func(w http.ResponseWriter, r *http.Request) { var req struct { Action string `json:"action"` } if err := readJSON(r, &req); err != nil { jsonErr(w, 400, err.Error()) return } if req.Action != "cycle" { jsonErr(w, 400, "stödd action: \"cycle\"") return } if err := a.mgr.Keys(r.PathValue("name"), []string{"BTab"}); err != nil { jsonErr(w, 400, err.Error()) return } jsonOut(w, 200, map[string]bool{"sent": true}) })) mux.HandleFunc("GET /api/config", a.auth(func(w http.ResponseWriter, r *http.Request) { c := *a.cfg c.Token = "(dold — se konfigfilen)" jsonOut(w, 200, c) })) mux.HandleFunc("PUT /api/config", a.auth(func(w http.ResponseWriter, r *http.Request) { var req struct { AgentCmd *string `json:"agent_cmd"` AgentArgs *[]string `json:"agent_args"` Workdir *string `json:"workdir"` PollMs *int `json:"poll_ms"` Ntfy *NtfyConfig `json:"ntfy"` } if err := readJSON(r, &req); err != nil { jsonErr(w, 400, err.Error()) return } if req.AgentCmd != nil { a.cfg.AgentCmd = *req.AgentCmd } if req.AgentArgs != nil { a.cfg.AgentArgs = *req.AgentArgs } if req.Workdir != nil { a.cfg.Workdir = *req.Workdir } if req.PollMs != nil && *req.PollMs >= 100 { a.cfg.PollMs = *req.PollMs } if req.Ntfy != nil { a.cfg.Ntfy = *req.Ntfy } if err := a.cfg.Save(); err != nil { jsonErr(w, 500, err.Error()) return } jsonOut(w, 200, map[string]bool{"saved": true}) })) mux.HandleFunc("GET /api/shares", a.auth(func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, a.shares.List()) })) mux.HandleFunc("POST /api/shares", a.auth(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(64 << 20); err != nil { jsonErr(w, 400, "multipart-form med fältet \"file\" krävs: "+err.Error()) return } f, hdr, err := r.FormFile("file") if err != nil { jsonErr(w, 400, "fältet \"file\" saknas") return } defer f.Close() sh, err := a.shares.Add(hdr.Filename, r.FormValue("note"), r.FormValue("session"), f) if err != nil { jsonErr(w, 400, err.Error()) return } a.mgr.emit(Event{Type: "share", Data: sh}) jsonOut(w, 201, sh) })) mux.HandleFunc("GET /api/shares/{id}", a.auth(func(w http.ResponseWriter, r *http.Request) { sh, err := a.shares.Get(r.PathValue("id")) if err != nil { jsonErr(w, 404, "ingen sådan delning") return } jsonOut(w, 200, sh) })) mux.HandleFunc("GET /api/shares/{id}/raw", a.auth(func(w http.ResponseWriter, r *http.Request) { sh, err := a.shares.Get(r.PathValue("id")) if err != nil { jsonErr(w, 404, "ingen sådan delning") return } if sh.Mime != "" { w.Header().Set("Content-Type", sh.Mime) } w.Header().Set("Content-Security-Policy", "sandbox allow-scripts") // shared HTML must not reach the API with our token http.ServeFile(w, r, a.shares.Path(sh)) })) mux.HandleFunc("DELETE /api/shares/{id}", a.auth(func(w http.ResponseWriter, r *http.Request) { if err := a.shares.Delete(r.PathValue("id")); err != nil { jsonErr(w, 404, err.Error()) return } jsonOut(w, 200, map[string]bool{"deleted": true}) })) mux.HandleFunc("POST /api/notify", a.auth(func(w http.ResponseWriter, r *http.Request) { var req struct { Topic string `json:"topic"` Title string `json:"title"` Message string `json:"message"` Priority string `json:"priority"` } if err := readJSON(r, &req); err != nil || req.Message == "" { jsonErr(w, 400, "JSON-kropp med \"message\" krävs") return } if req.Topic == "" { req.Topic = a.cfg.Ntfy.Topic } if !a.cfg.topicAllowed(req.Topic) { jsonErr(w, 403, "topic inte i allowed_topics") return } if err := a.ntfy.Publish(req.Topic, req.Title, req.Message, req.Priority); err != nil { jsonErr(w, 502, err.Error()) return } jsonOut(w, 200, map[string]bool{"published": true}) })) mux.HandleFunc("GET /api/events", a.auth(func(w http.ResponseWriter, r *http.Request) { fl, ok := w.(http.Flusher) if !ok { jsonErr(w, 500, "streaming stöds inte") return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") ch := a.mgr.Subscribe() defer a.mgr.Unsubscribe(ch) fmt.Fprintf(w, "event: hello\ndata: {\"version\":%q}\n\n", version) fl.Flush() for { select { case <-r.Context().Done(): return case ev := <-ch: data, _ := json.Marshal(ev) fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data) fl.Flush() } } })) return mux } func serve(cfg *Config) error { if _, err := exec_LookPath("tmux"); err != nil { return fmt.Errorf("tmux hittas inte i PATH — helmd kräver tmux") } ntfy := &Ntfy{cfg: &cfg.Ntfy} mgr := NewManager(cfg, ntfy) shares, err := NewShareStore(cfg) if err != nil { return err } api := &API{cfg: cfg, mgr: mgr, shares: shares, ntfy: ntfy} mux := api.routes() if cfg.Hub.Enabled && cfg.Hub.URL != "" { startUplink(cfg, mux, mgr) } mountWeb(mux) log.Printf("helmd %s lyssnar på http://%s (token i %s)", version, cfg.Listen, cfg.path) return http.ListenAndServe(cfg.Listen, mux) }