termd/internal/server/server.go
2026-07-02 22:13:49 -03:00

278 lines
7.5 KiB
Go

// 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 <token>".
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"))
}