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)
|
||||
}
|
||||
}
|
||||
@@ -209,10 +209,16 @@ func Shape(all []Sensor, query json.RawMessage, full bool) (any, error) {
|
||||
if err := json.Unmarshal(query, &q); err != nil {
|
||||
return nil, errors.New("query is not valid JSON")
|
||||
}
|
||||
return shape(all, q, full, 0)
|
||||
budget := MaxValues
|
||||
return shape(all, q, full, 0, &budget)
|
||||
}
|
||||
|
||||
func shape(all []Sensor, q any, full bool, depth int) (any, error) {
|
||||
// MaxValues is how many sensor values one query may return. A query is a few bytes that
|
||||
// can ask for everything many times over ({"a":"*","b":"*",…}); without a ceiling 10 KB of
|
||||
// request buys 27 MB of response. Ten thousand is thirty times a whole machine.
|
||||
const MaxValues = 10000
|
||||
|
||||
func shape(all []Sensor, q any, full bool, depth int, budget *int) (any, error) {
|
||||
if depth > 16 {
|
||||
return nil, errors.New("query nested too deep")
|
||||
}
|
||||
@@ -232,8 +238,12 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) {
|
||||
if len(got) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
*budget--
|
||||
return leaf(got[0]), nil
|
||||
}
|
||||
if *budget -= len(got); *budget < 0 {
|
||||
return nil, fmt.Errorf("query returns more than %d values; ask for less", MaxValues)
|
||||
}
|
||||
m := make(map[string]any, len(got))
|
||||
for _, s := range got {
|
||||
m[s.ID] = leaf(s)
|
||||
@@ -242,7 +252,7 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) {
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(v))
|
||||
for k, sub := range v {
|
||||
r, err := shape(all, sub, full, depth+1)
|
||||
r, err := shape(all, sub, full, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -252,7 +262,7 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) {
|
||||
case []any:
|
||||
a := make([]any, len(v))
|
||||
for i, sub := range v {
|
||||
r, err := shape(all, sub, full, depth+1)
|
||||
r, err := shape(all, sub, full, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -75,3 +75,21 @@ func TestWriteProm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapeBudget(t *testing.T) {
|
||||
all := make([]Sensor, 300)
|
||||
for i := range all {
|
||||
all[i] = Sensor{ID: "s." + strings.Repeat("x", i%7) + string(rune('a'+i%26)) + strings.Repeat("y", i/26), Value: 1.0}
|
||||
}
|
||||
q := map[string]string{}
|
||||
for i := 0; i < 40; i++ { // 40 × 300 = 12 000 values
|
||||
q["k"+strings.Repeat("z", i)] = "*"
|
||||
}
|
||||
b, _ := json.Marshal(q)
|
||||
if _, err := Shape(all, b, false); err == nil {
|
||||
t.Fatal("a query returning more than MaxValues must be refused")
|
||||
}
|
||||
if _, err := Shape(all, json.RawMessage(`{"a":"*","b":"*"}`), false); err != nil {
|
||||
t.Fatalf("an ordinary query must pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,6 +546,13 @@ func quietSudoers() {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, bin := range []string{"zpool", "zfs"} { // listing only: scrubs and snapshots still log
|
||||
for _, dir := range []string{"/usr/sbin", "/sbin", "/usr/bin", "/bin"} {
|
||||
if exists(dir + "/" + bin) {
|
||||
cmds = append(cmds, dir+"/"+bin+" list *", dir+"/"+bin+" status *")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -754,6 +754,36 @@ var snapRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`)
|
||||
func u64(s string) uint64 { v, _ := strconv.ParseUint(s, 10, 64); return v }
|
||||
func atoi(s string) int { v, _ := strconv.Atoi(strings.TrimSuffix(s, "%")); return v }
|
||||
|
||||
// Pools is the pool list alone, for callers that poll: one `zpool list`, tried without
|
||||
// root first (it needs none on Linux) so polling writes nothing to the auth log.
|
||||
func (m *Manager) Pools(ctx context.Context) []Pool {
|
||||
bin, ok := lookPath("zpool")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
args := []string{"list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup"}
|
||||
out, err := exec.CommandContext(ctx, bin, args...).Output()
|
||||
if err != nil {
|
||||
var code int
|
||||
if out, code, err = m.run(ctx, append([]string{"zpool"}, args...)...); err != nil || code != 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return parsePools(out)
|
||||
}
|
||||
|
||||
func parsePools(out []byte) []Pool {
|
||||
pools := []Pool{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 8 {
|
||||
continue
|
||||
}
|
||||
pools = append(pools, Pool{Name: f[0], Size: u64(f[1]), Alloc: u64(f[2]), Free: u64(f[3]), Frag: atoi(f[4]), Cap: atoi(f[5]), Health: f[6], Dedup: f[7]})
|
||||
}
|
||||
return pools
|
||||
}
|
||||
|
||||
func (m *Manager) ZFS(ctx context.Context) ZFS {
|
||||
z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}}
|
||||
if _, ok := lookPath("zpool"); !ok {
|
||||
|
||||
Reference in New Issue
Block a user