Files
hamlab/internal/api/kvm.go
T
Mitchell-JohnsonandClaude Opus 5 f3441ae718 The KVM tab is per machine, and follows the machine switcher
It was a hub-side list you pointed at machines, which had it backwards. A machine has one
KVM; switch to grid and you get grid's. The address is still kept on the hub rather than on
the machine, because a KVM earns its keep exactly when that machine is down and cannot
answer for itself — so the tab reaches the device either way. Adding a second KVM for a
machine replaces the first rather than sitting beside it.

The tab is no longer hub-only and now sits beside Machines in the rail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:58:08 -07:00

311 lines
9.0 KiB
Go

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/<id>/… . 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
}
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)
}
// kvmForMachine is the KVM attached to a machine, "" being the hub itself.
func (s *Server) kvmForMachine(machine string) (config.KVM, bool) {
var found config.KVM
ok := false
s.cfg.View(func(c *config.Config) {
for _, k := range c.KVMs {
if k.Machine == machine {
found, ok = k, true
}
}
})
return found, ok
}
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)
log.Printf("kvm %s removed by %s", id, clientIP(r))
w.WriteHeader(204)
}