Users, with their own settings, and a list of where each is signed in

The login is a user name and a password. The old single password becomes the account
named after the OS user on first boot, along with its sessions and the machine-wide look.
Settings gains Users (add, remove, reset a password — your own password confirms) and
Sessions (device, IP, last seen; revoke one or sign out everywhere else). Theme, accents
and the dashboard layout follow the account; the refresh rate, graph history and alerts
stay the machine's. Pairing a machine takes a user on it. Log lines name the actor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-22 18:20:30 -07:00
co-authored by Claude Fable 5.1
parent db214ebc2b
commit 2b54a06821
15 changed files with 1059 additions and 170 deletions
+196 -64
View File
@@ -90,12 +90,21 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
if s.user == "" {
s.user = "?"
}
s.migrateUsers()
s.sensors.Add(newStatusSensors(s))
s.alerts = newAlertEngine(s)
// The saved dashboard layout carries the sampling interval; apply it on boot.
var raw json.RawMessage
var histSec int
st.View(func(x *store.State) { raw, histSec = x.Dashboard, x.HistorySec })
st.View(func(x *store.State) {
raw, histSec = x.Dashboard, x.HistorySec
if len(raw) == 0 {
for _, u := range x.Users {
raw = u.Dashboard
break
}
}
})
s.applyPrefs(raw)
if histSec > 0 {
col.SetWindow(time.Duration(histSec) * time.Second)
@@ -106,7 +115,15 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
// happened to open its Dashboard.
var samplerMs int
var uiRaw json.RawMessage
st.View(func(x *store.State) { samplerMs, uiRaw = x.SamplerMs, x.UI })
st.View(func(x *store.State) {
samplerMs, uiRaw = x.SamplerMs, x.UI
if len(uiRaw) == 0 && len(x.Users) > 0 { // any account's saved rate, before the machine had one of its own
for _, u := range x.Users {
uiRaw = u.UI
break
}
}
})
if samplerMs > 0 {
col.SetInterval(time.Duration(samplerMs) * time.Millisecond)
}
@@ -114,7 +131,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
var ui struct {
RefreshMs float64 `json:"refreshMs"`
}
if json.Unmarshal(uiRaw, &ui) == nil && ui.RefreshMs >= 100 {
if samplerMs > 0 {
go s.setSampler(samplerMs, false)
} else if json.Unmarshal(uiRaw, &ui) == nil && ui.RefreshMs >= 100 {
go s.setSampler(int(ui.RefreshMs), false)
}
go s.keepFleetInStep()
@@ -233,6 +252,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/procs", s.auth(s.procList))
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
m.HandleFunc("POST /api/password", s.auth(s.changePassword))
m.HandleFunc("GET /api/users", s.auth(s.listUsers))
m.HandleFunc("POST /api/users", s.auth(s.addUser))
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14).
@@ -320,21 +346,24 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
// auth accepts the browser session cookie or a bearer API token issued to a hub.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) {
next(w, r)
return
if c, err := r.Cookie(cookieName); err == nil {
if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok {
next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}))
return
}
}
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
var hashes []string
var hashes, names []string
s.cfg.View(func(c *config.Config) {
for _, t := range c.Tokens {
if t.Scope == "" { // a scoped token is not a key to the machine
hashes = append(hashes, t.Hash)
names = append(names, t.Name)
}
}
})
if auth.TokenMatches(strings.TrimPrefix(h, "Bearer "), hashes) {
next(w, r)
if i := auth.TokenIndex(strings.TrimPrefix(h, "Bearer "), hashes); i >= 0 {
next(w, withActor(r, actor{Token: names[i]}))
return
}
}
@@ -355,9 +384,13 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ok, trusted := false, false
var patterns []string
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) {
ok, trusted = true, true
} else {
if c, err := r.Cookie(cookieName); err == nil {
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
ok, trusted = true, true
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})
}
}
if !ok {
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
if presented == "" {
presented, fromURL = r.URL.Query().Get("token"), true
@@ -477,7 +510,7 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
return
}
}
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now(), Scope: config.ScopeSensorsRead, Sensors: pats})
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now(), Scope: config.ScopeSensorsRead, Sensors: pats, IssuedBy: actorOf(r).User})
}); err != nil {
jsonErr(w, 500, err.Error())
return
@@ -500,6 +533,7 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
Name string `json:"name"`
}
@@ -507,13 +541,19 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "password and name required")
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
user, ok := s.checkLogin(body.Username, body.Password)
if !ok {
s.limiter.Fail(clientKey(r))
log.Printf("token request failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password")
log.Printf("token request failed for %q from %s", body.Username, clientIP(r))
if body.Username == "" {
jsonErr(w, 401, "wrong password, or this machine has several users: give a user name")
return
}
jsonErr(w, 401, "wrong user name or password")
return
}
s.limiter.Reset(clientKey(r))
s.touchUser(user)
tok, hash, err := auth.NewToken()
if err != nil {
jsonErr(w, 500, "token error")
@@ -527,12 +567,12 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
kept = append(kept, t)
}
}
c.Tokens = append(kept, config.Token{Name: name, Hash: hash, Created: time.Now()})
c.Tokens = append(kept, config.Token{Name: name, Hash: hash, Created: time.Now(), IssuedBy: user})
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("API token %q issued to %s", name, clientIP(r))
log.Printf("API token %q issued to %s by %s", name, clientIP(r), user)
jsonOK(w, 200, map[string]string{"token": tok, "name": name})
}
@@ -540,7 +580,7 @@ func (s *Server) listTokens(w http.ResponseWriter, _ *http.Request) {
var out []map[string]any
s.cfg.View(func(c *config.Config) {
for _, t := range c.Tokens {
out = append(out, map[string]any{"name": t.Name, "created": t.Created, "scope": t.Scope, "sensors": t.Sensors})
out = append(out, map[string]any{"name": t.Name, "created": t.Created, "scope": t.Scope, "sensors": t.Sensors, "issued_by": t.IssuedBy})
}
})
if out == nil {
@@ -578,12 +618,12 @@ func (s *Server) listNodes(w http.ResponseWriter, _ *http.Request) {
}
func (s *Server) addNode(w http.ResponseWriter, r *http.Request) {
var body struct{ Name, URL, Password string }
var body struct{ Name, URL, Username, Password string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
st, err := s.nodes.Add(r.Context(), strings.TrimSpace(body.Name), body.URL, body.Password)
st, err := s.nodes.Add(r.Context(), strings.TrimSpace(body.Name), body.URL, strings.TrimSpace(body.Username), body.Password)
if err != nil {
jsonErr(w, 400, err.Error())
return
@@ -746,27 +786,101 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
name, ok := s.checkLogin(body.Username, body.Password)
if !ok {
s.limiter.Fail(clientKey(r))
log.Printf("login failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password")
log.Printf("login failed for %q from %s", body.Username, clientIP(r))
jsonErr(w, 401, "wrong user name or password")
return
}
s.limiter.Reset(clientKey(r))
tok, err := s.sessions.Create()
tok, err := s.sessions.Create(name, clientKey(r), r.UserAgent())
if err != nil {
jsonErr(w, 500, "session error")
return
}
s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds()))
log.Printf("login ok from %s", clientIP(r))
w.WriteHeader(204)
s.touchUser(name)
log.Printf("login ok: %s from %s", name, clientIP(r))
jsonOK(w, 200, map[string]string{"user": name})
}
// checkLogin verifies a user name and password, returning the account. An empty user
// name is allowed only while there is exactly one account — what a client from before
// user names sends, and what a hub pairing with a one-user node may leave out.
func (s *Server) checkLogin(username, password string) (string, bool) {
var users []config.User
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
username = strings.TrimSpace(username)
if username == "" && len(users) == 1 {
username = users[0].Name
}
if username == "" {
auth.CheckUser(nil, "", password) // take as long as a real check
return "", false
}
return username, auth.CheckUser(users, username, password)
}
// touchUser notes that an account was just used.
func (s *Server) touchUser(name string) {
_ = s.cfg.Update(func(c *config.Config) {
for i := range c.Users {
if c.Users[i].Name == name {
c.Users[i].LastSeen = time.Now()
}
}
})
}
// migrateUsers is the one-time move from a single shared password to accounts: the old
// hash becomes the account named after the OS user the console runs as, the sessions that
// were signed in with it become that account's, and the machine-wide look becomes theirs.
func (s *Server) migrateUsers() {
first := ""
changed := false
if err := s.cfg.Update(func(c *config.Config) {
changed = c.MigrateUsers(s.user)
if len(c.Users) > 0 {
first = c.Users[0].Name
}
}); err != nil {
log.Printf("users: %v", err)
}
if changed {
log.Printf("users: the login password is now the account %q", first)
}
if first == "" {
return
}
_ = s.store.Update(func(st *store.State) {
for tok, ses := range st.Sessions {
if ses.User == "" {
ses.User = first
if ses.ID == "" {
ses.ID = tok[:12] // an old session has no ID of its own; a slice of the token is not usable as one
}
st.Sessions[tok] = ses
}
}
if len(st.UI) > 0 || len(st.Dashboard) > 0 {
u := st.UserState(first)
if len(u.UI) == 0 {
u.UI = st.UI
}
if len(u.Dashboard) == 0 {
u.Dashboard = st.Dashboard
}
st.UI, st.Dashboard = nil, nil
}
})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
@@ -790,11 +904,16 @@ func clientKey(r *http.Request) string {
return host
}
// clientIP names the caller for a log line: who (once signed in) and from where.
func clientIP(r *http.Request) string {
ip := r.RemoteAddr
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
ip = strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
}
return r.RemoteAddr
if who := actorOf(r).String(); who != "" {
return who + "@" + ip
}
return ip
}
// ---- me ----
@@ -818,7 +937,7 @@ func (s *Server) hostName() string {
return s.host
}
func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
load := ""
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(b))
@@ -841,7 +960,7 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
}
})
jsonOK(w, 200, map[string]any{
"host": s.hostName(), "user": s.user, "version": s.version,
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version,
"load": load, "uptime": uptime, "service_started": s.started,
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL, "addr": addr,
})
@@ -1876,38 +1995,49 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "bad request")
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Current) {
jsonErr(w, 403, "current password is wrong")
if !s.confirmSelf(w, r, body.Current) {
return
}
if len(body.Password) < 12 {
if len(body.Password) < auth.MinPasswordLen {
jsonErr(w, 400, "password must be at least 12 characters")
return
}
h, err := auth.HashPassword(body.Password)
if err != nil {
jsonErr(w, 500, err.Error())
if !s.setPassword(w, actorOf(r).User, body.Password) {
return
}
if err := s.cfg.Update(func(c *config.Config) { c.PasswordHash = h }); err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("password changed (from %s)", clientIP(r))
log.Printf("password changed (by %s)", clientIP(r))
w.WriteHeader(204)
}
// ---- ui prefs (theme) ----
func (s *Server) getUIPrefs(w http.ResponseWriter, _ *http.Request) {
var raw json.RawMessage
s.store.View(func(st *store.State) { raw = st.UI })
if len(raw) == 0 {
raw = json.RawMessage("{}")
}
w.Header().Set("Content-Type", "application/json")
// The look is the account's: theme and accents follow whoever is signed in. The refresh
// rate rides in the same object for the client's convenience but is the machine's — it is
// how often every machine samples — so it is kept once, machine-wide, and folded in here.
func (s *Server) getUIPrefs(w http.ResponseWriter, r *http.Request) {
prefs := s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.UI })
prefs["refreshMs"] = s.metrics.Interval().Milliseconds()
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(raw)
jsonOK(w, 200, prefs)
}
// userPrefs reads one of the caller's pref blobs as a map (empty when none, or for a token).
func (s *Server) userPrefs(r *http.Request, pick func(*store.UserState) json.RawMessage) map[string]any {
out := map[string]any{}
user := actorOf(r).User
if user == "" {
return out
}
var raw json.RawMessage
s.store.View(func(st *store.State) {
if u := st.Users[user]; u != nil {
raw = pick(u)
}
})
if len(raw) > 0 {
_ = json.Unmarshal(raw, &out)
}
return out
}
func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
@@ -1929,9 +2059,15 @@ func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
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
if user := actorOf(r).User; user != "" {
var m map[string]json.RawMessage
_ = json.Unmarshal(b, &m)
delete(m, "refreshMs")
kept, _ := json.Marshal(m)
if err := s.store.Update(func(st *store.State) { st.UserState(user).UI = kept }); err != nil {
jsonErr(w, 500, err.Error())
return
}
}
if p.RefreshMs != nil { // the refresh rate is also how often every machine samples
go s.setSampler(int(*p.RefreshMs), true)
@@ -2087,15 +2223,9 @@ func (s *Server) pushHistoryWindow(seconds int) {
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
var raw json.RawMessage
s.store.View(func(st *store.State) { raw = st.Dashboard })
if len(raw) == 0 {
raw = json.RawMessage("{}")
}
w.Header().Set("Content-Type", "application/json")
func (s *Server) getDashPrefs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(raw)
jsonOK(w, 200, s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.Dashboard }))
}
func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
@@ -2104,9 +2234,11 @@ func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "expected a JSON object")
return
}
if err := s.store.Update(func(st *store.State) { st.Dashboard = json.RawMessage(b) }); err != nil {
jsonErr(w, 500, err.Error())
return
if user := actorOf(r).User; user != "" {
if err := s.store.Update(func(st *store.State) { st.UserState(user).Dashboard = json.RawMessage(b) }); err != nil {
jsonErr(w, 500, err.Error())
return
}
}
s.applyPrefs(b)
w.WriteHeader(204)