diff --git a/internal/api/kvm.go b/internal/api/kvm.go new file mode 100644 index 0000000..23c182f --- /dev/null +++ b/internal/api/kvm.go @@ -0,0 +1,272 @@ +package api + +// IP-KVMs: a device's own web page, shown inside Ham Lab. +// +// The whole difficulty is that Ham Lab is served over TLS and a KVM usually is not, so a +// browser refuses to put the device's page in an iframe — mixed content, blocked before +// anything loads. The fix is to serve the device from Ham Lab's own origin, which also +// means it inherits Ham Lab's login instead of needing a second one, and makes the iframe +// same-origin. +// +// Proxying an arbitrary web app under a path prefix is the hard part, and nothing here +// knows anything about any particular device. A device UI asks for its assets by absolute +// path — "/assets/app.js", "/api/whatever" — which a prefix cannot rewrite away. So the +// prefix is only the entry point: any request Ham Lab does not serve itself is routed to +// whichever KVM the asking page belongs to, worked out from the Referer, and from a cookie +// when there is no Referer (a WebSocket upgrade, say). Ham Lab's own routes always win, so +// a device can never shadow the console. + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync" + "time" + + "hamlab/internal/config" +) + +const kvmPrefix = "/kvm/" +const kvmCookie = "hamlab_kvm" + +type kvmProxies struct { + mu sync.Mutex + m map[string]*httputil.ReverseProxy +} + +func (s *Server) kvm(id string) (config.KVM, bool) { + var found config.KVM + ok := false + s.cfg.View(func(c *config.Config) { + for _, k := range c.KVMs { + if k.ID == id { + found, ok = k, true + } + } + }) + return found, ok +} + +// kvmProxy is the reverse proxy for one device, made once and kept. +func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) { + s.kvms.mu.Lock() + defer s.kvms.mu.Unlock() + if s.kvms.m == nil { + s.kvms.m = map[string]*httputil.ReverseProxy{} + } + if p, ok := s.kvms.m[k.ID]; ok { + return p, nil + } + target, err := url.Parse(k.URL) + if err != nil || target.Host == "" { + return nil, errors.New("this KVM's address is not a URL") + } + prefix := kvmPrefix + k.ID + p := &httputil.ReverseProxy{ + Director: func(r *http.Request) { + r.URL.Scheme, r.URL.Host = target.Scheme, target.Host + // Entry through the prefix keeps the path after it; anything else arrived by its + // own absolute path and is passed through untouched. + if rest, ok := strings.CutPrefix(r.URL.Path, prefix); ok { + if rest == "" { + rest = "/" + } + r.URL.Path = rest + } + r.URL.RawPath = "" + r.Host = target.Host + // The device gets its own host and none of Ham Lab's session. + r.Header.Del("Origin") + stripCookie(r, cookieName, kvmCookie) + }, + FlushInterval: -1, // a video stream must not be buffered + ModifyResponse: func(resp *http.Response) error { + // The device's own framing and transport policy would stop it being embedded, or + // send a browser off to plain HTTP. Ham Lab is the origin now, and says what it + // serves; its own headers are added on the way out. + for _, h := range []string{"X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "Referrer-Policy"} { + resp.Header.Del(h) + } + // A redirect to the device's own root has to come back through the prefix. + if loc := resp.Header.Get("Location"); loc != "" { + if u, err := url.Parse(loc); err == nil && (u.Host == "" || u.Host == target.Host) && strings.HasPrefix(u.Path, "/") && !strings.HasPrefix(u.Path, prefix) { + u.Scheme, u.Host = "", "" + u.Path = prefix + u.Path + resp.Header.Set("Location", u.String()) + } + } + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]string{"error": k.Name + " unreachable: " + err.Error()}) + }, + } + s.kvms.m[k.ID] = p + return p, nil +} + +// stripCookie removes named cookies from a request, so Ham Lab's session is never handed to +// a device and the device never sees which KVM the browser is looking at. +func stripCookie(r *http.Request, names ...string) { + cs := r.Cookies() + kept := cs[:0] + for _, c := range cs { + drop := false + for _, n := range names { + if c.Name == n { + drop = true + } + } + if !drop { + kept = append(kept, c) + } + } + r.Header.Del("Cookie") + for _, c := range kept { + r.AddCookie(c) + } +} + +func (s *Server) forgetKVMProxy(id string) { + s.kvms.mu.Lock() + delete(s.kvms.m, id) + s.kvms.mu.Unlock() +} + +// serveKVM is the entry point: /kvm//… . It also drops a cookie naming the device, so +// that requests the page makes later by absolute path can be traced back to it. +func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + k, ok := s.kvm(id) + if !ok { + jsonErr(w, 404, "no such KVM") + return + } + p, err := s.kvmProxy(k) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + http.SetCookie(w, &http.Cookie{Name: kvmCookie, Value: id, Path: "/", HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteLaxMode}) + p.ServeHTTP(w, r) +} + +// kvmFor works out which device a request belongs to when it arrived by absolute path +// rather than through the prefix: the Referer names the page that asked, and a cookie +// covers what has no Referer at all. Nothing device-specific: it never has to know which +// paths a given KVM uses. +func (s *Server) kvmFor(r *http.Request) (config.KVM, bool) { + if ref := r.Header.Get("Referer"); ref != "" { + if u, err := url.Parse(ref); err == nil { + if rest, ok := strings.CutPrefix(u.Path, kvmPrefix); ok { + if id, _, _ := strings.Cut(rest, "/"); id != "" { + if k, ok := s.kvm(id); ok { + return k, true + } + } + } + } + // asked for by a page that is not a KVM: not ours to answer + return config.KVM{}, false + } + if c, err := r.Cookie(kvmCookie); err == nil && c.Value != "" { + return s.kvm(c.Value) + } + return config.KVM{}, false +} + +// kvmFallback answers a request Ham Lab does not serve itself, when it came from inside a +// KVM's page. It returns false when the request has nothing to do with a KVM, and the +// caller carries on with its own 404. +func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool { + k, ok := s.kvmFor(r) + if !ok { + return false + } + p, err := s.kvmProxy(k) + if err != nil { + return false + } + p.ServeHTTP(w, r) + return true +} + +// ---- the list ---- + +func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) { + out := []config.KVM{} + s.cfg.View(func(c *config.Config) { out = append(out, c.KVMs...) }) + jsonOK(w, 200, out) +} + +func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) { + var body struct{ Name, URL, Machine string } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { + jsonErr(w, 400, "bad request") + return + } + raw := strings.TrimSpace(body.URL) + if raw != "" && !strings.Contains(raw, "://") { + raw = "http://" + raw // what you would type: an address, not a scheme + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + jsonErr(w, 400, "address: http(s)://host, or just the host") + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + name = u.Host + } + if len(name) > maxNameLen { + jsonErr(w, 400, "that name is too long") + return + } + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + jsonErr(w, 500, err.Error()) + return + } + k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: strings.TrimSpace(body.Machine), Added: time.Now()} + if err := s.cfg.Update(func(c *config.Config) { c.KVMs = append(c.KVMs, k) }); err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("kvm %q added (%s) by %s", k.Name, k.URL, clientIP(r)) + jsonOK(w, 201, k) +} + +func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + found := false + if err := s.cfg.Update(func(c *config.Config) { + kept := c.KVMs[:0] + for _, k := range c.KVMs { + if k.ID == id { + found = true + continue + } + kept = append(kept, k) + } + c.KVMs = kept + }); err != nil { + jsonErr(w, 500, err.Error()) + return + } + if !found { + jsonErr(w, 404, "no such KVM") + return + } + s.forgetKVMProxy(id) + log.Printf("kvm %s removed by %s", id, clientIP(r)) + w.WriteHeader(204) +} diff --git a/internal/api/server.go b/internal/api/server.go index d145c40..b2dbd66 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -69,6 +69,7 @@ type Server struct { storage *storage.Manager space *space.Manager update updateState + kvms kvmProxies fsLocal files.FS fsSudo files.FS // nil without passwordless sudo nodes *nodes.Manager @@ -284,7 +285,20 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler)) m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow)) m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow)) - m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) + m.HandleFunc("GET /api/kvms", s.auth(s.listKVMs)) + m.HandleFunc("POST /api/kvms", s.auth(s.addKVM)) + m.HandleFunc("DELETE /api/kvms/{id}", s.auth(s.removeKVM)) + m.HandleFunc("/kvm/{id}/{rest...}", s.auth(s.serveKVM)) + m.HandleFunc("/kvm/{id}", s.auth(s.serveKVM)) + // A path Ham Lab does not serve may still belong to a KVM's page, which asks for its + // own assets by absolute path. Ham Lab's own routes matched first, so this can only + // catch what would otherwise have been a 404. + m.HandleFunc("/api/", s.auth(func(w http.ResponseWriter, r *http.Request) { + if s.kvmFallback(w, r) { + return + } + jsonErr(w, 404, "not found") + })) m.HandleFunc("/", s.static) s.mux = m return s @@ -355,6 +369,17 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m }) } +// signedIn reports whether a request carries a live browser session, without the +// side effects of the auth middleware. +func (s *Server) signedIn(r *http.Request) bool { + c, err := r.Cookie(cookieName) + if err != nil { + return false + } + _, ok := s.sessions.Lookup(c.Value, clientKey(r)) + return ok +} + // auth accepts the browser session cookie or a bearer API token issued to a hub. func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -2445,6 +2470,12 @@ func (s *Server) static(w http.ResponseWriter, r *http.Request) { return } } + // A path with no file of ours behind it may belong to a KVM's page, which asks for its + // own assets by absolute path. Checked here rather than in the router because this + // handler is the catch-all — and gated on a session, since this handler is public. + if s.signedIn(r) && s.kvmFallback(w, r) { + return + } // SPA fallback w.Header().Set("Cache-Control", "no-cache") http.ServeFileFS(w, r, s.ui, "index.html") diff --git a/internal/config/config.go b/internal/config/config.go index 3cb4dbd..5be2337 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,6 +61,21 @@ type UpdateSource struct { Check string `json:"check,omitempty"` } +// KVM is an IP-KVM: a device with a web page that gives screen, keyboard and mouse access +// to a machine, including when that machine has no OS running. Ham Lab holds the list on +// the hub and proxies each one, because a KVM matters exactly when the machine it is +// attached to cannot answer for itself. +type KVM struct { + ID string `json:"id"` + Name string `json:"name"` + // URL is the device's own address, as you would type it into a browser on the LAN. + URL string `json:"url"` + // Machine is which host it is attached to, for display only — it need not be a machine + // Ham Lab manages, and usually the point is that it is not answering. + Machine string `json:"machine,omitempty"` + Added time.Time `json:"added"` +} + type Config struct { Listen string `json:"listen"` // PasswordHash is the single login of installs before users existed. On load it becomes @@ -70,6 +85,7 @@ type Config struct { TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP Tokens []Token `json:"tokens,omitempty"` Nodes []Node `json:"nodes,omitempty"` + KVMs []KVM `json:"kvms,omitempty"` // Updates: where a newer build comes from. See UpdateSource. Updates UpdateSource `json:"update,omitempty"` diff --git a/web/src/KVM.tsx b/web/src/KVM.tsx new file mode 100644 index 0000000..864939e --- /dev/null +++ b/web/src/KVM.tsx @@ -0,0 +1,153 @@ +// IP-KVMs: a device's own web page, shown inside Ham Lab. +// +// Paste the address, and the device appears. Nothing here knows anything about any +// particular KVM — it is an iframe pointed at Ham Lab's own proxy, so whatever web UI the +// device serves is what you get. +// +// On the keyboard: a page cannot capture keys and forward them into an iframe, and does not +// need to. An iframe with focus receives them directly. What the browser itself swallows — +// Escape, Tab, Ctrl-W, the function keys — is what `navigator.keyboard.lock()` releases, +// and that needs fullscreen and only exists in Chromium. Everywhere else you get the plain +// version, which is most keys, and the device's own on-screen buttons for the rest. +import { FormEvent, useCallback, useEffect, useRef, useState } from 'react' +import { KVM as Device, MachineStatus, api } from './api' +import { Btn, Loading, askYes, toast, useEscape } from './ui' + +// lock() releases the keys the browser would otherwise keep for itself. Chromium only; a +// browser without it simply does not get the extra keys. +type KeyboardLock = { lock?: (keys?: string[]) => Promise; unlock?: () => void } +const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard + +export default function KVM({ machines }: { machines: MachineStatus[] }) { + const [list, setList] = useState(null) + const [sel, setSel] = useState('') + const [adding, setAdding] = useState(false) + const [err, setErr] = useState('') + const [full, setFull] = useState(false) + const box = useRef(null) + const frame = useRef(null) + + const load = useCallback(async () => { + try { const l = await api.kvms(); setList(l); setSel((s) => (l.some((k) => k.id === s) ? s : l[0]?.id ?? '')) } + catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + }, []) + useEffect(() => { load() }, [load]) + + // Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather + // than the browser. Both are undone together when fullscreen ends, however it ends. + useEffect(() => { + const onChange = () => { + const on = !!document.fullscreenElement + setFull(on) + if (!on) keyboardAPI()?.unlock?.() + } + document.addEventListener('fullscreenchange', onChange) + return () => { document.removeEventListener('fullscreenchange', onChange); keyboardAPI()?.unlock?.() } + }, []) + + const goFullscreen = async () => { + try { + await box.current?.requestFullscreen() + await keyboardAPI()?.lock?.() + frame.current?.focus() + } catch (e) { toast(e instanceof Error ? e.message : 'fullscreen refused', 'err') } + } + + const remove = async (k: Device) => { + if (!await askYes(`Remove ${k.name}? The device is not touched — this only forgets its address.`, 'Remove')) return + try { await api.removeKVM(k.id); toast(`${k.name} removed`); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + + if (err && !list) return
{err}
+ if (!list) return
+ + const cur = list.find((k) => k.id === sel) + const locks = !!keyboardAPI()?.lock + + return ( +
+
+ {list.length > 0 && ( + + {list.map((k) => ( + + ))} + + )} + + {cur && <> + {cur.url} + Fullscreen + Open ↗ + remove(cur)}>Remove + } + setAdding(true)}>+ Add +
+ + {cur ? ( +
+