Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
642 lines
17 KiB
Go
642 lines
17 KiB
Go
// 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")
|
|
}
|