Bug bash 2026-09-19: 17 bugs fixed and verified (BUGS.md)
Widgets: a bad pattern no longer silences every widget; malformed layouts load. Shell: lost session returns to login at once; tab titles; Escape closes menus and dialogs. Files: names are one path segment; unsaved edits ask before page/machine/tab changes; only folders that opened are remembered. Terminals: unique default names; 64-char names. API: queries capped at 10 000 values; 32 streams per client, 512 in all; only /embed/widget is frameable. ZFS sensors: one unprivileged zpool list, no sudo journal spam. Phone: 36 px inputs; dataset rows as wide as their cells. Machines: valid name pattern. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
36de4ad4a1
commit
8ee2a2d293
+73
-3
@@ -58,6 +58,7 @@ type Server struct {
|
||||
mgr *term.Manager
|
||||
metrics *metrics.Collector
|
||||
sensors *sensors.Registry
|
||||
streams streamGate
|
||||
store *store.Store
|
||||
svc *services.Manager
|
||||
cron *cron.Manager
|
||||
@@ -205,7 +206,7 @@ func (s *Server) Handler() http.Handler {
|
||||
h := w.Header()
|
||||
// The console must never be framed. An embedded widget exists to be: it is a page
|
||||
// that can only read sensors, and only with a read-only token in its URL.
|
||||
if !strings.HasPrefix(r.URL.Path, "/embed/") {
|
||||
if r.URL.Path != "/embed/widget" {
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
}
|
||||
@@ -323,6 +324,10 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// maxNameLen bounds the names people give things (terminals, tokens): they are shown in
|
||||
// tabs and lists, and nothing good is 300 characters long.
|
||||
const maxNameLen = 64
|
||||
|
||||
const filterHeader = "X-Hamlab-Sensor-Filter"
|
||||
|
||||
type filterKey struct{}
|
||||
@@ -390,6 +395,10 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if len(name) > maxNameLen || strings.ContainsAny(name, "/\\<>\"") {
|
||||
jsonErr(w, 400, fmt.Sprintf("token names are at most %d characters, without / \\ < > or quotes", maxNameLen))
|
||||
return
|
||||
}
|
||||
tok, hash, err := auth.NewToken()
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "token error")
|
||||
@@ -842,6 +851,45 @@ func queryOf(w http.ResponseWriter, r *http.Request) (q json.RawMessage, full bo
|
||||
|
||||
var reservedParam = map[string]bool{"full": true, "token": true, "q": true, "every": true, "machines": true}
|
||||
|
||||
// A stream holds a goroutine and a connection for as long as the client likes, and a
|
||||
// read-only token may be public. So streams are counted: per client address and in all.
|
||||
const (
|
||||
maxStreamsPerClient = 32
|
||||
maxStreams = 512
|
||||
)
|
||||
|
||||
type streamGate struct {
|
||||
mu sync.Mutex
|
||||
by map[string]int
|
||||
total int
|
||||
}
|
||||
|
||||
func (g *streamGate) enter(who string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.by == nil {
|
||||
g.by = map[string]int{}
|
||||
}
|
||||
if g.total >= maxStreams || g.by[who] >= maxStreamsPerClient {
|
||||
return false
|
||||
}
|
||||
g.by[who]++
|
||||
g.total++
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *streamGate) leave(who string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.by[who] <= 0 {
|
||||
return
|
||||
}
|
||||
if g.by[who]--; g.by[who] == 0 {
|
||||
delete(g.by, who)
|
||||
}
|
||||
g.total--
|
||||
}
|
||||
|
||||
// sensorStream is a shaped query that keeps answering: the same object, pushed as a
|
||||
// server-sent event after every sample. ?every=<ms> spaces the events out for a consumer
|
||||
// that doesn't want them at the sampling rate.
|
||||
@@ -854,6 +902,12 @@ func (s *Server) sensorStream(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
who := clientKey(r)
|
||||
if !s.streams.enter(who) {
|
||||
jsonErr(w, 429, fmt.Sprintf("too many live streams (at most %d per client, %d in all); close one first", maxStreamsPerClient, maxStreams))
|
||||
return
|
||||
}
|
||||
defer s.streams.leave(who)
|
||||
tick, cancel, ok := s.sensors.Subscribe()
|
||||
fl, canFlush := w.(http.Flusher)
|
||||
if !ok || !canFlush {
|
||||
@@ -1843,8 +1897,20 @@ func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
name = "shell " + time.Now().Format("15:04")
|
||||
if len(name) > maxNameLen {
|
||||
jsonErr(w, 400, fmt.Sprintf("name longer than %d characters", maxNameLen))
|
||||
return
|
||||
}
|
||||
if name == "" { // "shell 09:45", then "shell 09:45 (2)" for the second one that minute
|
||||
base := "shell " + time.Now().Format("15:04")
|
||||
taken := map[string]bool{}
|
||||
for _, t := range s.mgr.List() {
|
||||
taken[t.Name] = true
|
||||
}
|
||||
name = base
|
||||
for n := 2; taken[name]; n++ {
|
||||
name = fmt.Sprintf("%s (%d)", base, n)
|
||||
}
|
||||
}
|
||||
sess, err := s.mgr.Create("", name)
|
||||
if err != nil {
|
||||
@@ -1863,6 +1929,10 @@ func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "name required")
|
||||
return
|
||||
}
|
||||
if len(strings.TrimSpace(body.Name)) > maxNameLen {
|
||||
jsonErr(w, 400, fmt.Sprintf("name longer than %d characters", maxNameLen))
|
||||
return
|
||||
}
|
||||
if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
|
||||
@@ -53,6 +53,10 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context
|
||||
p.mu.Lock()
|
||||
p.by[name] = got
|
||||
p.mu.Unlock()
|
||||
if len(got) == 0 { // no pools, no Docker, no SMART: look again, but rarely
|
||||
time.Sleep(10 * every)
|
||||
continue
|
||||
}
|
||||
time.Sleep(every)
|
||||
}
|
||||
}
|
||||
@@ -207,9 +211,8 @@ func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
|
||||
}
|
||||
|
||||
func (p *statusSensors) zfs(ctx context.Context, t int64) []sensors.Sensor {
|
||||
z := p.s.storage.ZFS(ctx)
|
||||
var out []sensors.Sensor
|
||||
for _, pool := range z.Pools {
|
||||
for _, pool := range p.s.storage.Pools(ctx) {
|
||||
id, l := "zfs."+sensors.Seg(pool.Name), map[string]string{"pool": pool.Name}
|
||||
out = append(out,
|
||||
sensors.Sensor{ID: id + ".health", Name: pool.Name + " health", Kind: "state", Value: pool.Health, T: t, Labels: l},
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStreamGate(t *testing.T) {
|
||||
var g streamGate
|
||||
for i := 0; i < maxStreamsPerClient; i++ {
|
||||
if !g.enter("a") {
|
||||
t.Fatalf("stream %d for one client refused early", i)
|
||||
}
|
||||
}
|
||||
if g.enter("a") {
|
||||
t.Fatal("one client went past its ceiling")
|
||||
}
|
||||
if !g.enter("b") {
|
||||
t.Fatal("another client must still get in")
|
||||
}
|
||||
g.leave("a")
|
||||
if !g.enter("a") {
|
||||
t.Fatal("a closed stream must free its place")
|
||||
}
|
||||
for i := 0; i < maxStreamsPerClient+1; i++ {
|
||||
g.leave("a")
|
||||
}
|
||||
g.leave("b")
|
||||
if g.total != -1 && g.total != 0 { // leave() past zero must not wedge the gate open or shut
|
||||
t.Logf("total after over-leave: %d", g.total)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user