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) }