// Package server exposes the session registry over HTTP. package server import ( "encoding/json" "errors" "fmt" "net/http" "strings" "syscall" "termd/internal/api" "termd/internal/keymap" "termd/internal/session" ) // Server is the HTTP API over a session registry. type Server struct { registry *session.Registry token string mux *http.ServeMux } // New builds the server. If token is non-empty, every request must carry // "Authorization: Bearer ". func New(reg *session.Registry, token string) *Server { s := &Server{registry: reg, token: token, mux: http.NewServeMux()} s.mux.HandleFunc("GET /healthz", s.handleHealthz) s.mux.HandleFunc("POST /sessions", s.handleCreate) s.mux.HandleFunc("GET /sessions", s.handleList) s.mux.HandleFunc("GET /sessions/{id}", s.handleInfo) s.mux.HandleFunc("DELETE /sessions/{id}", s.handleDelete) s.mux.HandleFunc("POST /sessions/{id}/resize", s.handleResize) s.mux.HandleFunc("POST /sessions/{id}/input", s.handleInput) s.mux.HandleFunc("POST /sessions/{id}/mouse", s.handleMouse) s.mux.HandleFunc("GET /sessions/{id}/screen", s.handleScreen) s.mux.HandleFunc("GET /sessions/{id}/attach", s.handleAttach) return s } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if s.token != "" && r.Header.Get("Authorization") != "Bearer "+s.token { writeError(w, http.StatusUnauthorized, "missing or wrong bearer token", nil) return } s.mux.ServeHTTP(w, r) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) enc := json.NewEncoder(w) enc.SetEscapeHTML(false) _ = enc.Encode(v) } func writeError(w http.ResponseWriter, status int, msg string, modes *api.Modes) { writeJSON(w, status, api.Error{Error: msg, Modes: modes}) } // getSession resolves {id} or writes a 404 and returns nil. func (s *Server) getSession(w http.ResponseWriter, r *http.Request) *session.Session { id := r.PathValue("id") sess := s.registry.Get(id) if sess == nil { writeError(w, http.StatusNotFound, fmt.Sprintf("no session %q", id), nil) } return sess } func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { writeError(w, http.StatusBadRequest, "bad request body: "+err.Error(), nil) return false } return true } func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { var req api.CreateSessionRequest if !decodeBody(w, r, &req) { return } sess, err := s.registry.Create(session.Options{ Command: req.Command, Cwd: req.Cwd, Env: req.Env, Cols: req.Cols, Rows: req.Rows, Term: req.Term, }) if err != nil { writeError(w, http.StatusInternalServerError, err.Error(), nil) return } writeJSON(w, http.StatusCreated, sess.Info()) } func (s *Server) handleList(w http.ResponseWriter, _ *http.Request) { sessions := s.registry.List() list := api.SessionList{Sessions: make([]api.SessionInfo, 0, len(sessions))} for _, sess := range sessions { list.Sessions = append(list.Sessions, sess.Info()) } writeJSON(w, http.StatusOK, list) } func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { sess := s.getSession(w, r) if sess == nil { return } writeJSON(w, http.StatusOK, sess.Info()) } var signals = map[string]syscall.Signal{ "TERM": syscall.SIGTERM, "KILL": syscall.SIGKILL, "INT": syscall.SIGINT, "HUP": syscall.SIGHUP, "QUIT": syscall.SIGQUIT, "USR1": syscall.SIGUSR1, "USR2": syscall.SIGUSR2, } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { sess := s.getSession(w, r) if sess == nil { return } // Default to SIGHUP: it's what a closing terminal sends, and interactive // shells ignore SIGTERM (which would ride the 3s SIGKILL escalation). sigName := r.URL.Query().Get("signal") if sigName == "" { sigName = "HUP" } sig, ok := signals[strings.TrimPrefix(strings.ToUpper(sigName), "SIG")] if !ok { writeError(w, http.StatusBadRequest, fmt.Sprintf("unknown signal %q", sigName), nil) return } if err := sess.Kill(sig); err != nil { writeError(w, http.StatusInternalServerError, err.Error(), nil) return } s.registry.Remove(sess.ID) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleResize(w http.ResponseWriter, r *http.Request) { sess := s.getSession(w, r) if sess == nil { return } var req api.ResizeRequest if !decodeBody(w, r, &req) { return } if err := sess.Resize(req.Cols, req.Rows); err != nil { writeError(w, http.StatusBadRequest, err.Error(), nil) return } writeJSON(w, http.StatusOK, map[string]int{"cols": req.Cols, "rows": req.Rows}) } func (s *Server) handleInput(w http.ResponseWriter, r *http.Request) { sess := s.getSession(w, r) if sess == nil { return } var req api.InputRequest if !decodeBody(w, r, &req) { return } if len(req.Input) == 0 { writeError(w, http.StatusBadRequest, "input array is empty", nil) return } // Validate everything before sending anything: an input list is all or // nothing, never delivered halfway. type step struct { text string key *keymap.Key raw []byte } steps := make([]step, 0, len(req.Input)) for i, item := range req.Input { set := 0 for _, p := range []bool{item.Text != nil, item.Key != nil, item.Raw != nil} { if p { set++ } } if set != 1 { writeError(w, http.StatusBadRequest, fmt.Sprintf("input[%d]: exactly one of text, key, raw must be set", i), nil) return } switch { case item.Text != nil: steps = append(steps, step{text: *item.Text}) case item.Key != nil: k, err := keymap.Parse(*item.Key) if err != nil { writeError(w, http.StatusBadRequest, fmt.Sprintf("input[%d]: %s", i, err), nil) return } steps = append(steps, step{key: &k}) case item.Raw != nil: steps = append(steps, step{raw: []byte(*item.Raw)}) } } for i, st := range steps { var err error switch { case st.key != nil: err = sess.SendKey(*st.key) case st.raw != nil: err = sess.SendRaw(st.raw) default: err = sess.SendText(st.text) } if err != nil { status := http.StatusInternalServerError if errors.Is(err, session.ErrExited) { status = http.StatusConflict } writeError(w, status, fmt.Sprintf("input[%d]: %s", i, err), nil) return } } writeJSON(w, http.StatusOK, api.InputResponse{Written: len(steps)}) } func (s *Server) handleMouse(w http.ResponseWriter, r *http.Request) { sess := s.getSession(w, r) if sess == nil { return } var req api.MouseRequest if !decodeBody(w, r, &req) { return } if err := sess.SendMouse(req); err != nil { var merr *session.MouseError switch { case errors.As(err, &merr): writeError(w, http.StatusConflict, merr.Reason, &merr.Modes) case errors.Is(err, session.ErrExited): writeError(w, http.StatusConflict, err.Error(), nil) default: writeError(w, http.StatusBadRequest, err.Error(), nil) } return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { sess := s.getSession(w, r) if sess == nil { return } format := r.URL.Query().Get("format") switch format { case "", "text", "raw": default: writeError(w, http.StatusBadRequest, fmt.Sprintf("unknown format %q (want text or raw)", format), nil) return } writeJSON(w, http.StatusOK, sess.Snapshot(format == "raw")) }