// Package api is the HTTP/WebSocket surface: auth, terminal CRUD, attach, static UI. package api import ( "context" "encoding/json" "errors" "io" "io/fs" "log" "net/http" "os" "os/user" "strconv" "strings" "time" "github.com/coder/websocket" "hamlab/internal/auth" "hamlab/internal/config" "hamlab/internal/cron" "hamlab/internal/metrics" "hamlab/internal/services" "hamlab/internal/store" "hamlab/internal/term" ) const cookieName = "hamlab_session" type Server struct { cfg *config.Config sessions *auth.Sessions limiter auth.Limiter mgr *term.Manager metrics *metrics.Collector store *store.Store svc *services.Manager cron *cron.Manager version string started time.Time user string host string ui fs.FS mux *http.ServeMux } func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, ui fs.FS, version string) *Server { host, _ := os.Hostname() s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, ui: ui, version: version, started: time.Now(), host: host, user: os.Getenv("USER")} if u, err := user.Current(); err == nil && u.Username != "" { s.user = u.Username } if s.user == "" { s.user = "?" } // The saved dashboard layout carries the sampling interval; apply it on boot. var raw json.RawMessage st.View(func(x *store.State) { raw = x.Dashboard }) s.applyPrefs(raw) m := http.NewServeMux() m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) m.HandleFunc("POST /api/login", s.login) m.HandleFunc("POST /api/logout", s.auth(s.logout)) m.HandleFunc("GET /api/me", s.auth(s.me)) m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals)) m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal)) m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal)) m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal)) m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach)) m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest)) m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory)) m.HandleFunc("GET /api/services", s.auth(s.listServices)) m.HandleFunc("GET /api/services/{unit}", s.auth(s.showService)) m.HandleFunc("GET /api/services/{unit}/file", s.auth(s.serviceFile)) m.HandleFunc("GET /api/services/{unit}/logs", s.auth(s.serviceLogs)) m.HandleFunc("GET /api/services/{unit}/logs/tail", s.auth(s.serviceTail)) m.HandleFunc("POST /api/services/{unit}/{action}", s.auth(s.serviceAction)) m.HandleFunc("GET /api/cron", s.auth(s.cronOverview)) m.HandleFunc("GET /api/cron/source", s.auth(s.cronSource)) m.HandleFunc("PUT /api/cron/source", s.auth(s.cronWriteSource)) m.HandleFunc("GET /api/cron/jobs/{id}/logs", s.auth(s.cronJobLogs)) m.HandleFunc("GET /api/cron/jobs/{id}/output", s.auth(s.cronJobOutput)) m.HandleFunc("POST /api/cron/jobs/{id}/{action}", s.auth(s.cronJobAction)) m.HandleFunc("GET /api/cron/runs/{id}", s.auth(s.cronRun)) m.HandleFunc("POST /api/cron/timers/{unit}/{action}", s.auth(s.cronTimerAction)) m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs)) m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs)) m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) m.HandleFunc("/", s.static) s.mux = m return s } func (s *Server) Handler() http.Handler { return s.mux } // ---- helpers ---- func jsonOK(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } func jsonErr(w http.ResponseWriter, code int, msg string) { jsonOK(w, code, map[string]string{"error": msg}) } func secure(r *http.Request) bool { return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, maxAge int) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: tok, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteStrictMode, }) } func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie(cookieName) if err != nil || !s.sessions.Valid(c.Value) { jsonErr(w, 401, "unauthenticated") return } next(w, r) } } // ---- auth ---- func (s *Server) login(w http.ResponseWriter, r *http.Request) { if err := s.limiter.Allow(); err != nil { jsonErr(w, 429, "too many attempts, try again later") return } var body struct { Password string `json:"password"` } if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { jsonErr(w, 400, "bad request") return } if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) { s.limiter.Fail() log.Printf("login failed from %s", clientIP(r)) jsonErr(w, 401, "wrong password") return } s.limiter.Reset() tok, err := s.sessions.Create() if err != nil { jsonErr(w, 500, "session error") return } s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds())) log.Printf("login ok from %s", clientIP(r)) w.WriteHeader(204) } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(cookieName); err == nil { s.sessions.Delete(c.Value) } s.setCookie(w, r, "", -1) w.WriteHeader(204) } func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr } return r.RemoteAddr } // ---- me ---- func (s *Server) me(w http.ResponseWriter, _ *http.Request) { load := "" if b, err := os.ReadFile("/proc/loadavg"); err == nil { f := strings.Fields(string(b)) if len(f) >= 3 { load = strings.Join(f[:3], " ") } } uptime := 0.0 if b, err := os.ReadFile("/proc/uptime"); err == nil { if f := strings.Fields(string(b)); len(f) > 0 { uptime, _ = json.Number(f[0]).Float64() } } jsonOK(w, 200, map[string]any{ "host": s.host, "user": s.user, "version": s.version, "load": load, "uptime": uptime, "service_started": s.started, }) } // ---- metrics (Dashboard) ---- func (s *Server) metricsLatest(w http.ResponseWriter, _ *http.Request) { smp, ok := s.metrics.Latest() if !ok { jsonErr(w, 503, "no sample yet") return } jsonOK(w, 200, smp) } func (s *Server) metricsHistory(w http.ResponseWriter, _ *http.Request) { jsonOK(w, 200, s.metrics.History()) } // ---- services (systemd) ---- func (s *Server) listServices(w http.ResponseWriter, r *http.Request) { list, err := s.svc.List(r.Context()) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, 200, map[string]any{"units": list, "sudo": s.svc.Sudo()}) } func (s *Server) showService(w http.ResponseWriter, r *http.Request) { d, err := s.svc.Show(r.Context(), r.PathValue("unit")) if err != nil { jsonErr(w, 404, err.Error()) return } jsonOK(w, 200, d) } func (s *Server) serviceFile(w http.ResponseWriter, r *http.Request) { out, err := s.svc.Cat(r.Context(), r.PathValue("unit")) if err != nil { jsonErr(w, 404, err.Error()) return } jsonOK(w, 200, map[string]string{"content": out}) } func logCount(r *http.Request) int { n, _ := strconv.Atoi(r.URL.Query().Get("n")) if n <= 0 { n = 200 } if n > 5000 { n = 5000 } return n } func (s *Server) serviceLogs(w http.ResponseWriter, r *http.Request) { entries, err := s.svc.Journal(r.Context(), r.PathValue("unit"), logCount(r)) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, 200, entries) } // serviceTail streams journal entries as JSON text frames until the client goes away. func (s *Server) serviceTail(w http.ResponseWriter, r *http.Request) { unit := r.PathValue("unit") if !services.Valid(unit) { jsonErr(w, 400, "invalid unit name") return } conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled}) if err != nil { return } defer conn.Close(websocket.StatusNormalClosure, "") ctx, cancel := context.WithCancel(r.Context()) defer cancel() go func() { // reader: only to notice the close (and keep pings flowing) defer cancel() for { if _, _, err := conn.Read(ctx); err != nil { return } } }() go func() { t := time.NewTicker(25 * time.Second) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: pctx, pc := context.WithTimeout(ctx, 10*time.Second) err := conn.Ping(pctx) pc() if err != nil { cancel() return } } } }() _ = s.svc.Tail(ctx, unit, logCount(r), func(e services.Entry) { b, _ := json.Marshal(e) if err := conn.Write(ctx, websocket.MessageText, b); err != nil { cancel() } }) } func (s *Server) serviceAction(w http.ResponseWriter, r *http.Request) { unit, action := r.PathValue("unit"), r.PathValue("action") if err := s.svc.Action(r.Context(), action, unit); err != nil { log.Printf("systemctl %s %s: %v", action, unit, err) jsonErr(w, 400, err.Error()) return } log.Printf("systemctl %s %s ok (from %s)", action, unit, clientIP(r)) w.WriteHeader(204) } // ---- cron (crontabs + systemd timers) ---- func (s *Server) cronOverview(w http.ResponseWriter, r *http.Request) { jsonOK(w, 200, s.cron.Overview(r.Context())) } func (s *Server) cronSource(w http.ResponseWriter, r *http.Request) { text, editable, err := s.cron.Source(r.Context(), r.URL.Query().Get("name")) if err != nil { jsonErr(w, 404, err.Error()) return } jsonOK(w, 200, map[string]any{"text": text, "editable": editable}) } func (s *Server) cronWriteSource(w http.ResponseWriter, r *http.Request) { var body struct { Text string `json:"text"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { jsonErr(w, 400, "bad request") return } name := r.URL.Query().Get("name") if err := s.cron.WriteSource(r.Context(), name, body.Text); err != nil { jsonErr(w, 400, err.Error()) return } log.Printf("cron: wrote %s (from %s)", name, clientIP(r)) w.WriteHeader(204) } func (s *Server) cronJob(w http.ResponseWriter, r *http.Request) (cron.Job, bool) { j, ok := s.cron.FindJob(r.Context(), r.PathValue("id")) if !ok { jsonErr(w, 404, "no such job (crontab may have changed; reload)") } return j, ok } func (s *Server) cronJobLogs(w http.ResponseWriter, r *http.Request) { j, ok := s.cronJob(w, r) if !ok { return } entries, err := s.cron.JobLogs(r.Context(), j, logCount(r)) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, 200, entries) } func (s *Server) cronJobOutput(w http.ResponseWriter, r *http.Request) { j, ok := s.cronJob(w, r) if !ok { return } out, err := s.cron.OutputTail(r.Context(), j, logCount(r)) if err != nil { jsonErr(w, 400, err.Error()) return } jsonOK(w, 200, map[string]string{"file": j.OutFile, "text": out}) } func (s *Server) cronJobAction(w http.ResponseWriter, r *http.Request) { j, ok := s.cronJob(w, r) if !ok { return } action := r.PathValue("action") var err error switch action { case "enable", "disable": err = s.cron.Toggle(r.Context(), j, action == "enable") case "run": var run *cron.Run if run, err = s.cron.StartRun(r.Context(), j); err == nil { log.Printf("cron: run now %q as %s (from %s)", j.Command, j.User, clientIP(r)) jsonOK(w, 202, run.Snapshot()) return } default: jsonErr(w, 400, "unknown action") return } if err != nil { jsonErr(w, 400, err.Error()) return } log.Printf("cron: %s %q in %s (from %s)", action, j.Command, j.Source, clientIP(r)) w.WriteHeader(204) } func (s *Server) cronRun(w http.ResponseWriter, r *http.Request) { run, ok := s.cron.GetRun(r.PathValue("id")) if !ok { jsonErr(w, 404, "no such run") return } jsonOK(w, 200, run.Snapshot()) } func (s *Server) cronTimerAction(w http.ResponseWriter, r *http.Request) { unit, action := r.PathValue("unit"), r.PathValue("action") if err := s.cron.TimerAction(r.Context(), unit, action); err != nil { jsonErr(w, 400, err.Error()) return } log.Printf("timer %s %s ok (from %s)", action, unit, clientIP(r)) w.WriteHeader(204) } // ---- dashboard layout prefs (opaque JSON object, owned by the UI) ---- func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) { var raw json.RawMessage s.store.View(func(st *store.State) { raw = st.Dashboard }) if len(raw) == 0 { raw = json.RawMessage("{}") } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(raw) } func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) { b, err := io.ReadAll(io.LimitReader(r.Body, 64<<10)) if err != nil || !json.Valid(b) || len(b) == 0 || b[0] != '{' { jsonErr(w, 400, "expected a JSON object") return } if err := s.store.Update(func(st *store.State) { st.Dashboard = json.RawMessage(b) }); err != nil { jsonErr(w, 500, err.Error()) return } s.applyPrefs(b) w.WriteHeader(204) } // applyPrefs picks the few layout fields the server acts on (just the interval). func (s *Server) applyPrefs(raw []byte) { var p struct { IntervalMs int `json:"intervalMs"` } if len(raw) == 0 || json.Unmarshal(raw, &p) != nil { return } if p.IntervalMs > 0 { s.metrics.SetInterval(time.Duration(p.IntervalMs) * time.Millisecond) } else { s.metrics.SetInterval(metrics.DefaultInterval) } } // ---- terminals ---- func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { jsonOK(w, 200, s.mgr.List()) } func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` } _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) name := strings.TrimSpace(body.Name) if name == "" { name = "shell " + time.Now().Format("15:04") } sess, err := s.mgr.Create("", name) if err != nil { log.Printf("create terminal: %v", err) jsonErr(w, 500, err.Error()) return } jsonOK(w, 201, map[string]string{"id": sess.ID, "name": sess.Name}) } func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` } if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" { jsonErr(w, 400, "name required") return } if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil { jsonErr(w, 404, err.Error()) return } w.WriteHeader(204) } func (s *Server) deleteTerminal(w http.ResponseWriter, r *http.Request) { if err := s.mgr.Close(r.PathValue("id")); err != nil { jsonErr(w, 404, err.Error()) return } w.WriteHeader(204) } type resizeMsg struct { Type string `json:"type"` Cols uint16 `json:"cols"` Rows uint16 `json:"rows"` } func (s *Server) attach(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") client, snapshot, running, err := s.mgr.Attach(id) if err != nil { jsonErr(w, 404, err.Error()) return } // Origin must match Host (coder/websocket default) — blocks cross-site attaches. conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled}) if err != nil { s.mgr.Detach(client) return } defer s.mgr.Detach(client) defer conn.Close(websocket.StatusNormalClosure, "") conn.SetReadLimit(1 << 20) ctx, cancel := context.WithCancel(r.Context()) defer cancel() if len(snapshot) > 0 { if err := conn.Write(ctx, websocket.MessageBinary, snapshot); err != nil { return } } if !running { _ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"exit"}`)) } // writer: PTY → socket, plus keepalive pings so idle proxies don't cut us. go func() { defer cancel() ping := time.NewTicker(25 * time.Second) defer ping.Stop() for { select { case <-ctx.Done(): return case <-ping.C: pctx, pc := context.WithTimeout(ctx, 10*time.Second) err := conn.Ping(pctx) pc() if err != nil { return } case msg, ok := <-client.Ch: if !ok { _ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"closed"}`)) return } if msg.Data != nil { if err := conn.Write(ctx, websocket.MessageBinary, msg.Data); err != nil { return } continue } if msg.Event == "exit" { b, _ := json.Marshal(map[string]any{"type": "exit", "code": msg.Code}) _ = conn.Write(ctx, websocket.MessageText, b) } } } }() // reader: socket → PTY (binary) or control (text JSON). for { typ, data, err := conn.Read(ctx) if err != nil { return } switch typ { case websocket.MessageBinary: if err := s.mgr.Write(id, data); err != nil && !errors.Is(err, term.ErrNotFound) { log.Printf("pty write %s: %v", id, err) } case websocket.MessageText: var m resizeMsg if json.Unmarshal(data, &m) == nil && m.Type == "resize" { _ = s.mgr.Resize(id, m.Cols, m.Rows) } } } } // ---- static UI with SPA fallback ---- func (s *Server) static(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/") if p == "" { p = "index.html" } f, err := s.ui.Open(p) if err == nil { st, _ := f.Stat() f.Close() if st != nil && !st.IsDir() { if strings.HasPrefix(p, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } http.ServeFileFS(w, r, s.ui, p) return } } // SPA fallback w.Header().Set("Cache-Control", "no-cache") http.ServeFileFS(w, r, s.ui, "index.html") }