Files
hamlab/internal/api/server.go
T

346 lines
8.9 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"
"strings"
"time"
"github.com/coder/websocket"
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/metrics"
"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
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, ui fs.FS, version string) *Server {
host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")}
if s.user == "" {
s.user = "core"
}
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("/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())
}
// ---- 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")
}