From 4b238ea93e6c73b15c1a01579bda2cd4c9ce43c3 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Mon, 9 Nov 2026 23:52:38 -0800 Subject: [PATCH] Remove the KVM tab: proxy, routes, config and state fields, UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not needed — the KVMs are reached over WireGuard. Co-Authored-By: Claude Opus 5.5 --- PLAN.md | 36 +--- PORTABILITY.md | 2 - internal/api/kvm.go | 420 -------------------------------------- internal/api/kvm_test.go | 123 ----------- internal/api/server.go | 32 +-- internal/config/config.go | 18 -- internal/store/store.go | 10 +- web/src/KVM.tsx | 187 ----------------- web/src/Shell.tsx | 9 +- web/src/api.ts | 5 - web/src/styles.css | 16 -- 11 files changed, 13 insertions(+), 845 deletions(-) delete mode 100644 internal/api/kvm.go delete mode 100644 internal/api/kvm_test.go delete mode 100644 web/src/KVM.tsx diff --git a/PLAN.md b/PLAN.md index 138608d..6501e45 100644 --- a/PLAN.md +++ b/PLAN.md @@ -417,39 +417,9 @@ something long in one, switch away and back, refresh — it's all still there. snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it had taken left untouched and fully readable here — which is what adopting its naming bought. See `hamlet/docs/hosts.md`. -- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`). - Paste a device's address; its own web page appears in the tab. **Nothing in it knows - anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid` - at `10.0.0.217`, but none of that is in the code. - **Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and - a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it - loads. Serving the device from Ham Lab's own origin (`/kvm//…`) fixes that, puts it - behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin. - **The general problem, and the answer.** A device UI asks for its assets by absolute path - — `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting - bodies per device would not be general. 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, read - from the **Referer**, falling back to a **cookie** when there is none (a WebSocket upgrade). - Ham Lab's own routes always match first, so a device can never shadow the console, and the - fallback is gated on a live session. The device never receives Ham Lab's session cookie, - and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin - now. Redirects to the device's root are rewritten back through the prefix. - **One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even - from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was - refused before it loaded. - **The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and - does not need to, since one with focus receives them directly. Fullscreen plus - `navigator.keyboard.lock()` is what takes Escape, Tab and Ctrl-W back from the browser; - that is Chromium-only, and the tab says which version you are getting rather than - pretending. - **One KVM per machine, and the tab follows the machine switcher** (corrected the same day: - the first cut was a hub-side list you pointed at machines, which had it backwards). Switch - to `grid` and you get grid's; adding a second for a machine replaces the first. The - addresses are still kept **on the hub** rather than on each machine, because a KVM earns - its keep exactly when the machine is down and its own Ham Lab cannot answer — the hub - proxies the device either way — and a KVM need not belong to a machine Ham Lab manages at - all. Not done: holding the device's own login on the hub, so its page does not ask for a - second password. +- **KVM tab** — shipped 2026-09-24, **removed 2026-11-09** at Mitchell's call: not needed — + the KVMs are reached over WireGuard (see `hamlet/docs/hosts.md`). It proxied a device's web + page through Ham Lab's origin; the code is in git history before that date. - **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark, Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; diff --git a/PORTABILITY.md b/PORTABILITY.md index 2277323..08c31c5 100644 --- a/PORTABILITY.md +++ b/PORTABILITY.md @@ -32,7 +32,6 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments | # | Sev | Where | Text | General form | |---|---|---|---|---| | 2.1 | **C** | `web/src/Machines.tsx:116-118` | `placeholder="grid"`, `"https://10.0.0.214:8100"`, `"core"` | `nas`, `https://192.0.2.10:8100`, `admin` or the hub's own user. | -| 2.2 | **C** | `web/src/KVM.tsx:166` | `placeholder="10.0.0.217"` (the NanoKVM on `grid`) | `192.0.2.20` or `kvm.local`. | | 2.3 | **C** | `web/src/Settings.tsx:242` | `placeholder="cpu.*, temp.*, zfs.tank.*"` | `cpu.*, temp.*, fs.*`. | | 2.4 | **C** | `web/src/Settings.tsx:225` | read-only token default name `'glance'` | Empty, or `dashboard`. | | 2.5 | **C** | `web/src/Alerts.tsx:38, 40` | `"Tank nearly full"`, `"zfs.tank.used_pct"` | `"Disk nearly full"`, `"fs.root.used_pct"`. | @@ -146,7 +145,6 @@ Listed so the sweep is known to be complete, not because they need work. - `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push. - `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design. - `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`. -- `internal/api/kvm.go`: "nothing in it knows anything about any particular KVM" — verified. - `internal/compose` (modulo 3.5). - The React UI: machine-switcher model, hash routing, per-account prefs, themes. diff --git a/internal/api/kvm.go b/internal/api/kvm.go deleted file mode 100644 index f6155c7..0000000 --- a/internal/api/kvm.go +++ /dev/null @@ -1,420 +0,0 @@ -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" - "hamlab/internal/store" -) - -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 - // Whether the console itself is on TLS: the response rewriter needs to know, and by - // then only the outbound request is in hand. - if secure(r) { - r.Header.Set(kvmSecureHeader, "1") - } else { - r.Header.Del(kvmSecureHeader) - } - // The device gets its own host and none of Ham Lab's session. - r.Header.Del("Origin") - stripCookie(r, cookieName, kvmCookie) - // What is left is the device's own login. A browser that has it teaches the hub; one - // that has not is lent what the hub remembers, so the device sees a signed-in client. - if own := r.Cookies(); len(own) > 0 { - s.rememberKVMCookies(k.ID, own) - } else { - for name, val := range s.kvmCookies(k.ID) { - r.AddCookie(&http.Cookie{Name: name, Value: val}) - } - } - }, - 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 cookie the device sets by header is kept for Ham Lab's origin — so its Domain - // (the device's) goes, Secure follows the console rather than the device, and the - // hub remembers it too. - if set := resp.Cookies(); len(set) > 0 { - https := resp.Request != nil && resp.Request.Header.Get(kvmSecureHeader) == "1" - resp.Header.Del("Set-Cookie") - for _, c := range set { - c.Domain = "" - if c.Path == "" { - c.Path = "/" - } - c.Secure = c.Secure && https - if v := c.String(); v != "" { - resp.Header.Add("Set-Cookie", v) - } - } - s.rememberKVMCookies(k.ID, set) - } - // 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 -} - -// kvmSecureHeader rides on the outbound request to say whether the console was reached -// over TLS; the device ignores it. -const kvmSecureHeader = "X-Hamlab-Secure" - -// kvmCookies is what the hub remembers of a device's own cookies — its login, in practice. -func (s *Server) kvmCookies(id string) map[string]string { - var out map[string]string - s.store.View(func(st *store.State) { - if m := st.KVMCookies[id]; len(m) > 0 { - out = make(map[string]string, len(m)) - for k, v := range m { - out[k] = v - } - } - }) - return out -} - -// rememberKVMCookies keeps the cookies a browser (or the device's own Set-Cookie) carries -// for a KVM, so that a browser without them can be lent them. A cookie emptied or expired -// by the device is forgotten. Written only when something changed: this runs on every -// proxied request. -func (s *Server) rememberKVMCookies(id string, cs []*http.Cookie) { - have := s.kvmCookies(id) - changed := false - for _, c := range cs { - if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie { - continue - } - gone := c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) - if gone { - if _, ok := have[c.Name]; ok { - changed = true - } - continue - } - if have[c.Name] != c.Value { - changed = true - } - } - if !changed { - return - } - _ = s.store.Update(func(st *store.State) { - if st.KVMCookies == nil { - st.KVMCookies = map[string]map[string]string{} - } - m := st.KVMCookies[id] - if m == nil { - m = map[string]string{} - } - for _, c := range cs { - if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie { - continue - } - if c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) { - delete(m, c.Name) - } else { - m[c.Name] = c.Value - } - } - if len(m) == 0 { - delete(st.KVMCookies, id) - } else { - st.KVMCookies[id] = m - } - }) -} - -// forgetKVMCookies drops a device's remembered login — when the device is removed. -func (s *Server) forgetKVMCookies(id string) { - _ = s.store.Update(func(st *store.State) { delete(st.KVMCookies, id) }) -} - -// lendKVMCookies hands the browser the device cookies the hub remembers and it lacks, as -// the device's own page would have set them: readable by its scripts, for Ham Lab's whole -// origin, kept for half a year. The device's SPA checks for its token client-side before it -// ever asks the server, so lending it only on the wire would still land on the login form. -func (s *Server) lendKVMCookies(w http.ResponseWriter, r *http.Request, id string) { - for name, val := range s.kvmCookies(id) { - if _, err := r.Cookie(name); err == nil { - continue - } - http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: 180 * 24 * 3600, Secure: secure(r), SameSite: http.SameSiteLaxMode}) - } -} - -// 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}) - s.lendKVMCookies(w, r, id) - 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 -} - -func machineLabel(m string) string { - if m == "" { - return "this host" - } - return m -} - -// ---- 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 - } - machine := strings.TrimSpace(body.Machine) - k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: machine, Added: time.Now()} - // One per machine: a second one for the same host replaces the first rather than sitting - // beside it, since the tab shows the machine you are looking at. - var replaced string - if err := s.cfg.Update(func(c *config.Config) { - kept := c.KVMs[:0] - for _, old := range c.KVMs { - if old.Machine == machine { - replaced = old.ID - continue - } - kept = append(kept, old) - } - c.KVMs = append(kept, k) - }); err != nil { - jsonErr(w, 500, err.Error()) - return - } - if replaced != "" { - s.forgetKVMProxy(replaced) - } - log.Printf("kvm %q added for %s (%s) by %s", k.Name, machineLabel(machine), 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) - s.forgetKVMCookies(id) - log.Printf("kvm %s removed by %s", id, clientIP(r)) - w.WriteHeader(204) -} diff --git a/internal/api/kvm_test.go b/internal/api/kvm_test.go deleted file mode 100644 index 44edfc3..0000000 --- a/internal/api/kvm_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "net/url" - "path/filepath" - "strings" - "testing" - - "hamlab/internal/auth" - "hamlab/internal/config" - "hamlab/internal/store" -) - -// A KVM's own login is a cookie its page sets in one browser. The hub remembers it from the -// requests that carry it, lends it to a browser that lacks it — both on the wire to the -// device and back to the browser, since the device's page checks for it client-side — and -// forgets it when the device says it is gone or the KVM is removed. -func TestKVMCookiesAreRememberedAndLent(t *testing.T) { - var seen []string // the Cookie header the fake device received, per request - device := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seen = append(seen, r.Header.Get("Cookie")) - if r.URL.Path == "/api/auth/login" { - // a device that sets its token by header, with its own domain and Secure - http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "tok-1", Domain: "10.0.0.217", Secure: true}) - } - if r.URL.Path == "/api/auth/logout" { - http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "", MaxAge: -1}) - } - w.WriteHeader(200) - })) - defer device.Close() - - dir := t.TempDir() - cfg := config.Default(filepath.Join(dir, "config.json")) - if err := cfg.Update(func(c *config.Config) { - c.KVMs = []config.KVM{{ID: "abc123", Name: "test", URL: device.URL, Machine: ""}} - }); err != nil { - t.Fatal(err) - } - st, err := store.Open(filepath.Join(dir, "state.json")) - if err != nil { - t.Fatal(err) - } - s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)} - k, _ := s.kvm("abc123") - p, err := s.kvmProxy(k) - if err != nil { - t.Fatal(err) - } - ask := func(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder { - r := httptest.NewRequest("GET", path, nil) - r.SetPathValue("id", "abc123") - for _, c := range cookies { - r.AddCookie(c) - } - w := httptest.NewRecorder() - if strings.HasPrefix(path, kvmPrefix) { - s.serveKVM(w, r) - } else { - p.ServeHTTP(w, r) - } - return w - } - - // 1. the device sets its token by header: it reaches the browser without the device's - // domain or Secure (plain-http console), and the hub remembers it - w := ask("/api/auth/login", &http.Cookie{Name: cookieName, Value: "hamlab-session"}) - var set *http.Cookie - for _, c := range w.Result().Cookies() { - if c.Name == "nano-kvm-token" { - set = c - } - } - if set == nil || set.Domain != "" || set.Secure || set.Value != "tok-1" || set.Path != "/" { - t.Fatalf("rewritten Set-Cookie = %+v, want nano-kvm-token=tok-1 for / without Domain or Secure", set) - } - if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-1" { - t.Fatalf("remembered = %v, want the token", got) - } - if strings.Contains(seen[0], "hamlab_session") { - t.Fatalf("the device was handed Ham Lab's session: %q", seen[0]) - } - - // 2. a browser that has the cookie teaches a newer value - ask("/api/vm/info", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"}, &http.Cookie{Name: cookieName, Value: "x"}) - if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-2" { - t.Fatalf("remembered = %v, want tok-2", got) - } - if seen[1] != "nano-kvm-token=tok-2" { - t.Fatalf("device saw %q, want the browser's own cookie only", seen[1]) - } - - // 3. a browser without it is lent it: on the wire, and back as a Set-Cookie it can read - w = ask(kvmPrefix+"abc123/", &http.Cookie{Name: cookieName, Value: "x"}) - if seen[2] != "nano-kvm-token=tok-2" { - t.Fatalf("device saw %q from a browser with no cookie, want the remembered token", seen[2]) - } - lent := false - for _, c := range w.Result().Cookies() { - if c.Name == "nano-kvm-token" && c.Value == "tok-2" && !c.HttpOnly && c.Path == "/" && c.MaxAge > 0 { - lent = true - } - } - if !lent { - t.Fatalf("browser was not lent the token: %v", w.Result().Cookies()) - } - - // 4. the device logging out forgets it - ask("/api/auth/logout", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"}) - if got := s.kvmCookies("abc123"); got != nil { - t.Fatalf("after logout remembered = %v, want nothing", got) - } - - // 5. removing the KVM clears whatever is left - s.rememberKVMCookies("abc123", []*http.Cookie{{Name: "nano-kvm-token", Value: "tok-3"}}) - s.forgetKVMCookies("abc123") - if got := s.kvmCookies("abc123"); got != nil { - t.Fatalf("after remove remembered = %v, want nothing", got) - } - _ = url.Parse -} diff --git a/internal/api/server.go b/internal/api/server.go index 3ec2b74..c4775d6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -75,7 +75,6 @@ type Server struct { net *network.Watcher update updateState events eventLog - kvms kvmProxies fsLocal files.FS fsSudo files.FS // nil without passwordless sudo thumbs *thumbSvc @@ -351,18 +350,7 @@ func (s *Server) routes() *http.ServeMux { 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("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) @@ -386,16 +374,10 @@ func sensorPath(p string) bool { func (s *Server) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() - // The console must never be framed. Two exceptions: an embedded widget exists to be - // (a page that can only read sensors, and only with a read-only token in its URL), - // and a proxied KVM is framed by the console itself — 'none' forbids that even from - // the same origin, so those paths say 'self' instead of opening up. - switch { - case r.URL.Path == "/embed/widget": - case strings.HasPrefix(r.URL.Path, kvmPrefix): - h.Set("X-Frame-Options", "SAMEORIGIN") - h.Set("Content-Security-Policy", "frame-ancestors 'self'") - default: + // The console must never be framed. The one exception is an embedded widget, which + // exists to be (a page that can only read sensors, and only with a read-only token in + // its URL). + if r.URL.Path != "/embed/widget" { h.Set("X-Frame-Options", "DENY") h.Set("Content-Security-Policy", "frame-ancestors 'none'") } @@ -2654,12 +2636,6 @@ 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 557946f..1c4468f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,23 +77,6 @@ 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 the host this KVM is attached to, and is the key: one KVM per machine, shown - // on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even - // for a node's KVM, because a KVM earns its keep when the machine is down and cannot - // answer for itself. - Machine string `json:"machine"` - 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 @@ -103,7 +86,6 @@ 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/internal/store/store.go b/internal/store/store.go index c283f53..2144c78 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -68,13 +68,9 @@ type State struct { // Mounts remembers where a container's bind mounts pointed, so a stack deployed by an // orchestrator that has since been removed can still be found on disk. See // internal/api/stacks.go. - Mounts []RememberedMount `json:"mounts,omitempty"` - // KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so - // the next browser — or this one after it has forgotten them — is signed into the device - // already. See internal/api/kvm.go. - KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"` - SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate - HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one + Mounts []RememberedMount `json:"mounts,omitempty"` + SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate + HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one // Transcode is where videos played in the Files viewer are made (internal/api/play.go): // a machine and a device on it. nil is the machine the file is on, its best device. Transcode *Transcode `json:"transcode,omitempty"` diff --git a/web/src/KVM.tsx b/web/src/KVM.tsx deleted file mode 100644 index ed8a44f..0000000 --- a/web/src/KVM.tsx +++ /dev/null @@ -1,187 +0,0 @@ -// The KVM of the machine you are looking at: the device's own web page, shown in the tab. -// -// One per machine, and it follows the machine switcher — switch to `grid` and you get -// grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns -// its keep exactly when that machine is down and cannot answer for itself; the hub proxies -// the device either way. -// -// 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, 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 - -// Fullscreen, across the three ways browsers spell it. Safari on a Mac wants the webkit -// prefix; Safari on iOS has no fullscreen for an ordinary element at all — only for a -//