Bug bash fixes: special-file reads, per-client lockout, prefs validation, security headers, kernel-thread guard, login feedback, error text; plus journal-quiet sudoers + journal groups, upload overwrite guard, machines pattern, dnf freshness, cron/services speed, clean version

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 17:41:52 -07:00
co-authored by Claude Fable 5.1
parent 57c58ebffb
commit 27f9665606
18 changed files with 306 additions and 85 deletions
+68 -11
View File
@@ -9,6 +9,7 @@ import (
"io/fs"
"log"
"mime"
"net"
"net/http"
"net/url"
"os"
@@ -154,7 +155,17 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
return s
}
func (s *Server) Handler() http.Handler { return s.mux }
// Handler wraps the mux with headers a root-capable console should always send.
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
s.mux.ServeHTTP(w, r)
})
}
// ---- helpers ----
@@ -207,7 +218,7 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
// issueToken trades the password for a long-lived token, named by the caller (a hub).
// Rate-limited like login; an existing token with the same name is replaced.
func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
if err := s.limiter.Allow(); err != nil {
if err := s.limiter.Allow(clientKey(r)); err != nil {
jsonErr(w, 429, "too many attempts, try again later")
return
}
@@ -220,12 +231,12 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
s.limiter.Fail()
s.limiter.Fail(clientKey(r))
log.Printf("token request failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password")
return
}
s.limiter.Reset()
s.limiter.Reset(clientKey(r))
tok, hash, err := auth.NewToken()
if err != nil {
jsonErr(w, 500, "token error")
@@ -415,7 +426,7 @@ func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
// ---- auth ----
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if err := s.limiter.Allow(); err != nil {
if err := s.limiter.Allow(clientKey(r)); err != nil {
jsonErr(w, 429, "too many attempts, try again later")
return
}
@@ -427,12 +438,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
s.limiter.Fail()
s.limiter.Fail(clientKey(r))
log.Printf("login failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password")
return
}
s.limiter.Reset()
s.limiter.Reset(clientKey(r))
tok, err := s.sessions.Create()
if err != nil {
jsonErr(w, 500, "session error")
@@ -451,6 +462,19 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
// clientKey is the address the limiter buckets by: the first X-Forwarded-For hop when a
// reverse proxy set it, else the socket peer.
func clientKey(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
@@ -880,8 +904,13 @@ func fsErr(w http.ResponseWriter, err error) {
code = 403
}
msg := err.Error()
if pe, ok := err.(*os.PathError); ok { // "open /x: permission denied" -> "permission denied"
var pe *os.PathError
var le *os.LinkError
switch {
case errors.As(err, &pe): // "open /x: permission denied" -> "permission denied"
msg = pe.Err.Error()
case errors.As(err, &le): // "rename /a /b: no such file" -> "no such file or directory"
msg = le.Err.Error()
}
jsonErr(w, code, msg)
}
@@ -948,7 +977,12 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "is a directory")
return
}
if mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64); mx > 0 && in.Size > mx {
if in.Type == "other" {
jsonErr(w, 400, "not a regular file (device, pipe or socket)")
return
}
mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64)
if mx > 0 && in.Size > mx {
jsonErr(w, 413, "file too large")
return
}
@@ -958,6 +992,10 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
return
}
defer f.Close()
var body io.Reader = f
if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent
body = io.LimitReader(f, mx+1)
}
name := filepath.Base(p)
ct := mime.TypeByExtension(filepath.Ext(name))
if ct == "" {
@@ -975,10 +1013,10 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
} else {
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
}
if in.Size > 0 {
if in.Size > 0 && mx == 0 {
w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10))
}
_, _ = io.Copy(w, f)
_, _ = io.Copy(w, body)
}
func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) {
@@ -1014,6 +1052,12 @@ func (s *Server) filesUpload(w http.ResponseWriter, r *http.Request) {
return
}
dst := filepath.Join(dir, name)
if r.URL.Query().Get("overwrite") != "1" {
if _, err := fsys.Stat(r.Context(), dst); err == nil {
jsonErr(w, 409, "a file with that name already exists")
return
}
}
if err := fsys.Write(r.Context(), dst, r.Body); err != nil {
fsErr(w, err)
return
@@ -1241,6 +1285,19 @@ func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "expected a JSON object")
return
}
var p struct {
Theme *string `json:"theme"`
RefreshMs *float64 `json:"refreshMs"`
}
_ = json.Unmarshal(b, &p)
if p.RefreshMs != nil && (*p.RefreshMs < 100 || *p.RefreshMs > 60000) {
jsonErr(w, 400, "refreshMs must be between 100 and 60000")
return
}
if p.Theme != nil && (len(*p.Theme) > 32 || strings.ContainsAny(*p.Theme, "<>\"'")) {
jsonErr(w, 400, "theme must be a short identifier")
return
}
if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil {
jsonErr(w, 500, err.Error())
return
+64 -11
View File
@@ -152,39 +152,92 @@ func (s *Sessions) Delete(tok string) {
// Limiter is a single global backoff: after 5 failures, each further failure
// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box.
// Limiter locks out a client after repeated failed logins, per client address, with a
// global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one
// address never blocks another.
type Limiter struct {
mu sync.Mutex
mu sync.Mutex
clients map[string]*bucket
global bucket
}
type bucket struct {
failures int
lockUntil time.Time
last time.Time
}
var ErrLocked = errors.New("too many failed logins")
func (l *Limiter) Allow() error {
const (
perClientLock = 5 // failures before a client is locked
globalLock = 100 // failures (all clients) in the window before everyone waits a minute
globalWindow = 5 * time.Minute
)
func (l *Limiter) get(key string) *bucket {
if l.clients == nil {
l.clients = map[string]*bucket{}
}
b, ok := l.clients[key]
if !ok {
b = &bucket{}
l.clients[key] = b
}
// forget clients that have been quiet for an hour
if len(l.clients) > 256 {
for k, x := range l.clients {
if time.Since(x.last) > time.Hour && time.Now().After(x.lockUntil) {
delete(l.clients, k)
}
}
}
return b
}
func (l *Limiter) Allow(key string) error {
l.mu.Lock()
defer l.mu.Unlock()
if time.Now().Before(l.lockUntil) {
now := time.Now()
if now.Before(l.global.lockUntil) {
return ErrLocked
}
if now.Sub(l.global.last) > globalWindow {
l.global.failures = 0
}
if b := l.get(key); now.Before(b.lockUntil) {
return ErrLocked
}
return nil
}
func (l *Limiter) Fail() {
func (l *Limiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
l.failures++
if l.failures >= 5 {
d := 30 * time.Second << uint(l.failures-5)
now := time.Now()
b := l.get(key)
b.failures++
b.last = now
if b.failures >= perClientLock {
d := 30 * time.Second << uint(b.failures-perClientLock)
if d > 10*time.Minute {
d = 10 * time.Minute
}
l.lockUntil = time.Now().Add(d)
b.lockUntil = now.Add(d)
}
if now.Sub(l.global.last) > globalWindow {
l.global.failures = 0
}
l.global.failures++
l.global.last = now
if l.global.failures >= globalLock {
l.global.lockUntil = now.Add(time.Minute)
l.global.failures = 0
}
}
func (l *Limiter) Reset() {
func (l *Limiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
l.failures = 0
l.lockUntil = time.Time{}
delete(l.clients, key)
}
+61 -42
View File
@@ -82,6 +82,7 @@ type Manager struct {
mu sync.Mutex
lastRuns map[string]int64 // user\x00command -> unix ms
lastAt time.Time
lastCursor string // journal cursor of the newest entry folded into lastRuns
logging bool
runs map[string]*Run
}
@@ -388,32 +389,48 @@ func (m *Manager) Overview(ctx context.Context) Overview {
var cronLogRe = regexp.MustCompile(`^\((\S+)\) CMD \((.*)\)$`)
// lastRunsFromJournal maps user+command to the most recent "(user) CMD (command)" line.
// The first call scans 30 days; later calls only read entries after the last cursor, so
// the cost per refresh is proportional to new cron activity, not to history.
func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
m.mu.Lock()
if time.Since(m.lastAt) < 5*time.Second && m.lastRuns != nil {
if time.Since(m.lastAt) < 2*time.Second && m.lastRuns != nil {
r := m.lastRuns
m.mu.Unlock()
return r
}
cursor := m.lastCursor
res := map[string]int64{}
for k, v := range m.lastRuns {
res[k] = v
}
m.mu.Unlock()
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "--since", "-30d", "-n", "20000"}
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q"}
if cursor != "" {
args = append(args, "--after-cursor="+cursor)
} else {
args = append(args, "--since", "-30d", "-n", "20000")
}
if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...)
}
out, _ := run(ctx, "", args...)
res := map[string]int64{}
seen := false
seen := m.logging
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() {
var raw struct {
Msg any `json:"MESSAGE"`
TS string `json:"__REALTIME_TIMESTAMP"`
Msg any `json:"MESSAGE"`
TS string `json:"__REALTIME_TIMESTAMP"`
Cursor string `json:"__CURSOR"`
}
if json.Unmarshal(sc.Bytes(), &raw) != nil {
continue
}
seen = true
if raw.Cursor != "" {
cursor = raw.Cursor
}
msg, _ := raw.Msg.(string)
mm := cronLogRe.FindStringSubmatch(msg)
if mm == nil {
@@ -426,7 +443,7 @@ func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
}
}
m.mu.Lock()
m.lastRuns, m.lastAt, m.logging = res, time.Now(), seen
m.lastRuns, m.lastAt, m.lastCursor, m.logging = res, time.Now(), cursor, seen
m.mu.Unlock()
return res
}
@@ -480,48 +497,50 @@ func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error)
// ---- timers ----
// timers: one `systemctl show` over every loaded timer instead of three list commands.
func (m *Manager) timers(ctx context.Context) []Timer {
out, err := run(ctx, "", "systemctl", "list-timers", "--all", "--output=json", "--no-pager")
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--timestamp=unix", "--no-pager",
"-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers")
if err != nil {
return []Timer{}
}
var lt []struct {
Next int64 `json:"next"`
Last int64 `json:"last"`
Unit string `json:"unit"`
Activates string `json:"activates"`
}
_ = json.Unmarshal([]byte(out), &lt)
units, _ := run(ctx, "", "systemctl", "list-units", "--type=timer", "--all", "--output=json", "--no-pager", "--plain")
var lu []struct{ Unit, Active, Sub, Description string }
_ = json.Unmarshal([]byte(units), &lu)
meta := map[string]struct{ Active, Sub, Description string }{}
for _, u := range lu {
meta[u.Unit] = struct{ Active, Sub, Description string }{u.Active, u.Sub, u.Description}
}
files, _ := run(ctx, "", "systemctl", "list-unit-files", "--type=timer", "--output=json", "--no-pager")
var uf []struct {
UnitFile string `json:"unit_file"`
State string `json:"state"`
}
_ = json.Unmarshal([]byte(files), &uf)
enabled := map[string]string{}
for _, f := range uf {
enabled[f.UnitFile] = f.State
}
res := make([]Timer, 0, len(lt))
for _, t := range lt {
x := Timer{Unit: t.Unit, Activates: t.Activates, Enabled: enabled[t.Unit]}
if mt, ok := meta[t.Unit]; ok {
x.Active, x.Sub, x.Description = mt.Active, mt.Sub, mt.Description
unix := func(v string) int64 { // "@1789711405" (seconds) or "n/a"
if strings.HasPrefix(v, "@") {
s, _ := strconv.ParseInt(strings.TrimPrefix(v, "@"), 10, 64)
return s * 1000
}
if t.Next > 0 {
x.Next = t.Next / 1000
return 0
}
res := []Timer{}
for _, block := range strings.Split(out, "\n\n") {
t := Timer{}
for _, line := range strings.Split(block, "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "Id":
t.Unit = v
case "Description":
t.Description = v
case "ActiveState":
t.Active = v
case "SubState":
t.Sub = v
case "UnitFileState":
t.Enabled = v
case "NextElapseUSecRealtime":
t.Next = unix(v)
case "LastTriggerUSec":
t.Last = unix(v)
case "Triggers":
t.Activates = strings.Fields(v + " ")[0:1][0]
}
}
if t.Last > 0 {
x.Last = t.Last / 1000
if t.Unit != "" {
res = append(res, t)
}
res = append(res, x)
}
sort.Slice(res, func(i, j int) bool { return res[i].Unit < res[j].Unit })
return res
+5
View File
@@ -206,6 +206,8 @@ func looksText(b []byte) bool {
return ctrl*20 < len(b)
}
// Open refuses anything that isn't a regular file: devices stream forever (/dev/zero),
// FIFOs block on open, sockets can't be read. /proc files are regular and still work.
func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
st, err := os.Stat(path)
if err != nil {
@@ -214,6 +216,9 @@ func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
if st.IsDir() {
return nil, errors.New("is a directory")
}
if !st.Mode().IsRegular() {
return nil, errors.New("not a regular file (device, pipe or socket)")
}
return os.Open(path)
}
+6 -1
View File
@@ -1093,9 +1093,14 @@ func (c *Collector) Processes() ProcTable {
// Signal sends a signal to a pid; falls back to sudo when the process isn't ours.
func Signal(pid int, sig syscall.Signal) error {
if pid <= 1 {
if pid <= 1 || pid == os.Getpid() {
return errors.New("refusing to signal pid " + strconv.Itoa(pid))
}
if strings.TrimRight(readStr("/proc/"+strconv.Itoa(pid)+"/cmdline"), "\x00") == "" {
if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err == nil {
return errors.New("refusing to signal a kernel thread")
}
}
if err := syscall.Kill(pid, sig); err == nil {
return nil
} else if !errors.Is(err, syscall.EPERM) {
+9 -2
View File
@@ -114,7 +114,15 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) {
}
m.mu.Unlock()
out, err := run(ctx, "systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager", "--plain")
var out, files string
var err error
done := make(chan struct{})
go func() {
files, _ = run(ctx, "systemctl", "list-unit-files", "--type=service", "--output=json", "--no-pager")
close(done)
}()
out, err = run(ctx, "systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager", "--plain")
<-done
if err != nil {
return nil, err
}
@@ -122,7 +130,6 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) {
if err := json.Unmarshal([]byte(out), &units); err != nil {
return nil, fmt.Errorf("parse list-units: %w", err)
}
files, _ := run(ctx, "systemctl", "list-unit-files", "--type=service", "--output=json", "--no-pager")
var uf []struct {
UnitFile string `json:"unit_file"`
State string `json:"state"`
+49 -1
View File
@@ -12,6 +12,7 @@ import (
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -83,6 +84,52 @@ func out(ctx context.Context, args ...string) (string, error) {
return string(b), nil
}
// newestMtime: the newest mtime among the paths matching any of the globs.
func newestMtime(globs ...string) int64 {
var best int64
for _, g := range globs {
matches, _ := filepath.Glob(g)
for _, m := range matches {
if t := mtimeMs(m); t > best {
best = t
}
}
}
return best
}
// dnfLastUpgrade: newest transaction in `dnf history` whose action includes an upgrade,
// else the newest transaction of any kind.
func dnfLastUpgrade(ctx context.Context) int64 {
text, err := out(ctx, "dnf", "history", "list")
if err != nil {
return 0
}
re := regexp.MustCompile(`(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(\S+)`)
var newest, upgrade int64
for _, line := range strings.Split(text, "\n") {
m := re.FindStringSubmatch(line)
if m == nil {
continue
}
t, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)
if err != nil {
continue
}
ms := t.UnixMilli()
if ms > newest {
newest = ms
}
if strings.Contains(m[2], "U") && ms > upgrade {
upgrade = ms
}
}
if upgrade > 0 {
return upgrade
}
return newest
}
func mtimeMs(p string) int64 {
st, err := os.Stat(p)
if err != nil {
@@ -113,7 +160,8 @@ func (m *Manager) Status(ctx context.Context) Status {
s.AutoUpgrades = aptAutoUpgrades(ctx)
case "dnf":
s.Packages = dnfUpgradable(ctx)
s.LastRefresh = mtimeMs("/var/cache/dnf")
s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf")
s.LastUpgrade = dnfLastUpgrade(ctx)
case "pacman":
s.Packages = pacmanUpgradable(ctx)
s.LastRefresh = mtimeMs("/var/lib/pacman/sync")