A session unused for seven days is dropped, the Sessions list marks a day's idleness and can sign all of those out at once. The hub keeps a KVM's own cookies from any browser that has them and lends them to one that hasn't, on the wire and back to the page. The stack editor and the new-stack dialog use CodeEditor: line numbers, YAML and .env colour under a real textarea, Tab/Shift+Tab/Enter indentation, and a key bar on touch screens. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
435 lines
13 KiB
Go
435 lines
13 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"
|
|
"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/<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})
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
s.forgetKVMCookies(id)
|
|
log.Printf("kvm %s removed by %s", id, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|