278 lines
7.5 KiB
Go
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"))
|
|
}
|