Idle sessions sign themselves out; a KVM's login is remembered; the compose editor knows YAML
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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
faaa6aaf9d
commit
f3ca3d8f73
@@ -31,6 +31,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
const kvmPrefix = "/kvm/"
|
||||
@@ -82,9 +83,25 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
|
||||
}
|
||||
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 {
|
||||
@@ -94,6 +111,24 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
|
||||
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) {
|
||||
@@ -114,6 +149,93 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
|
||||
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) {
|
||||
@@ -157,6 +279,7 @@ func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -305,6 +428,7 @@ func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.forgetKVMProxy(id)
|
||||
s.forgetKVMCookies(id)
|
||||
log.Printf("kvm %s removed by %s", id, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user