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>
311 lines
9.0 KiB
Go
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)
|
|
}
|