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)
+266
View File
@@ -0,0 +1,266 @@
package api
// Accounts and their sessions. Every person has their own login, and the console's look
// (theme, accents, dashboard layout) follows the account rather than the machine. Anyone
// signed in may add or remove accounts — a household console, not a tenancy — but removing
// one, or resetting another's password, asks for the caller's own password first, so a
// picked-up unlocked phone is not enough.
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"strings"
"time"
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/store"
)
// actor is who a request is from: a signed-in account (with its session), or a token.
type actor struct {
User string // account name, "" for a token
Session string // session ID, "" for a token
token string // the raw cookie token, so a handler can spare the current session
Token string // token name, "" for a session
}
type ctxKey int
const actorKey ctxKey = 1
func withActor(r *http.Request, a actor) *http.Request {
return r.WithContext(context.WithValue(r.Context(), actorKey, a))
}
func actorOf(r *http.Request) actor {
a, _ := r.Context().Value(actorKey).(actor)
return a
}
// who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub.
func (a actor) String() string {
switch {
case a.User != "":
return a.User
case a.Token != "":
return "token:" + a.Token
}
return ""
}
// ---- users ----
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
me := actorOf(r).User
counts := s.sessions.Count()
var out []map[string]any
s.cfg.View(func(c *config.Config) {
for _, u := range c.Users {
var seen any
if !u.LastSeen.IsZero() {
seen = u.LastSeen
}
out = append(out, map[string]any{"name": u.Name, "created": u.Created, "last_seen": seen, "sessions": counts[u.Name], "self": u.Name == me})
}
})
if out == nil {
out = []map[string]any{}
}
jsonOK(w, 200, out)
}
func (s *Server) addUser(w http.ResponseWriter, r *http.Request) {
var body struct{ Name, Password string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
name := strings.TrimSpace(body.Name)
if !auth.UserNameRe.MatchString(name) {
jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit")
return
}
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())
return
}
clash := false
if err := s.cfg.Update(func(c *config.Config) {
if _, ok := c.User(name); ok {
clash = true
return
}
c.Users = append(c.Users, config.User{Name: name, Hash: h, Created: time.Now()})
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
if clash {
jsonErr(w, 409, "a user with that name exists")
return
}
log.Printf("user %q added (by %s)", name, clientIP(r))
jsonOK(w, 200, map[string]any{"name": name})
}
// confirmSelf checks the caller's own password, the gate on anything done to another account.
func (s *Server) confirmSelf(w http.ResponseWriter, r *http.Request, current string) bool {
me := actorOf(r).User
if me == "" {
jsonErr(w, 403, "only a signed-in user may do that")
return false
}
var users []config.User
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
if !auth.CheckUser(users, me, current) {
jsonErr(w, 403, "your password is wrong")
return false
}
return true
}
func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var body struct{ Current string }
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
if name == actorOf(r).User {
jsonErr(w, 400, "you can't remove the account you are signed in as")
return
}
if !s.confirmSelf(w, r, body.Current) {
return
}
found, last := false, false
if err := s.cfg.Update(func(c *config.Config) {
if len(c.Users) <= 1 {
last = true
return
}
kept := c.Users[:0]
for _, u := range c.Users {
if u.Name == name {
found = true
continue
}
kept = append(kept, u)
}
c.Users = kept
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
if last {
jsonErr(w, 400, "the last account can't be removed")
return
}
if !found {
jsonErr(w, 404, "no such user")
return
}
s.sessions.RevokeUser(name)
_ = s.store.Update(func(st *store.State) { delete(st.Users, name) })
log.Printf("user %q removed (by %s)", name, clientIP(r))
w.WriteHeader(204)
}
// resetPassword sets another user's password, on the caller's own password.
func (s *Server) resetPassword(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var body struct{ Current, Password string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
if !s.confirmSelf(w, r, body.Current) {
return
}
if len(body.Password) < auth.MinPasswordLen {
jsonErr(w, 400, "password must be at least 12 characters")
return
}
if !s.setPassword(w, name, body.Password) {
return
}
if name != actorOf(r).User { // whoever held the old password is signed out
s.sessions.RevokeUser(name)
}
log.Printf("password of %q reset (by %s)", name, clientIP(r))
w.WriteHeader(204)
}
func (s *Server) setPassword(w http.ResponseWriter, name, pw string) bool {
h, err := auth.HashPassword(pw)
if err != nil {
jsonErr(w, 500, err.Error())
return false
}
found := false
if err := s.cfg.Update(func(c *config.Config) {
for i := range c.Users {
if c.Users[i].Name == name {
c.Users[i].Hash = h
found = true
}
}
}); err != nil {
jsonErr(w, 500, err.Error())
return false
}
if !found {
jsonErr(w, 404, "no such user")
return false
}
return true
}
// ---- sessions: where the caller's account is signed in ----
func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
a := actorOf(r)
if a.User == "" {
jsonOK(w, 200, []any{})
return
}
var out []map[string]any
for _, ses := range s.sessions.List(a.User) {
out = append(out, map[string]any{"id": ses.ID, "created": ses.Created, "last_seen": ses.LastSeen, "expires": ses.Expires, "ip": ses.IP, "agent": ses.Agent, "current": ses.ID == a.Session})
}
if out == nil {
out = []map[string]any{}
}
jsonOK(w, 200, out)
}
func (s *Server) revokeSession(w http.ResponseWriter, r *http.Request) {
a := actorOf(r)
id := r.PathValue("id")
if a.User == "" || !s.sessions.Revoke(a.User, id) {
jsonErr(w, 404, "no such session")
return
}
log.Printf("session %s of %q revoked (by %s)", id, a.User, clientIP(r))
if id == a.Session {
s.setCookie(w, r, "", -1)
}
w.WriteHeader(204)
}
func (s *Server) revokeOtherSessions(w http.ResponseWriter, r *http.Request) {
a := actorOf(r)
if a.User == "" {
jsonErr(w, 403, "only a signed-in user may do that")
return
}
n := s.sessions.RevokeOthers(a.User, a.token)
log.Printf("%d other session(s) of %q revoked (by %s)", n, a.User, clientIP(r))
jsonOK(w, 200, map[string]int{"revoked": n})
}
+145 -25
View File
@@ -9,12 +9,15 @@ import (
"encoding/hex"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
"hamlab/internal/config"
"hamlab/internal/store"
)
@@ -78,17 +81,52 @@ func HashToken(token string) string {
}
// TokenMatches compares a presented token against stored hashes in constant time.
func TokenMatches(token string, hashes []string) bool {
func TokenMatches(token string, hashes []string) bool { return TokenIndex(token, hashes) >= 0 }
// TokenIndex is which of hashes the presented token matches, -1 for none; every hash is
// compared whatever the answer.
func TokenIndex(token string, hashes []string) int {
h := []byte(HashToken(token))
ok := false
for _, want := range hashes {
idx := -1
for i, want := range hashes {
if subtle.ConstantTimeCompare(h, []byte(want)) == 1 {
ok = true
idx = i
}
}
return ok
return idx
}
// ---- users ----
// UserNameRe is what an account may be called: like a Unix login, lower-case.
var UserNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
const MinPasswordLen = 12
// dummyHash is verified against when the named user does not exist, so a login attempt
// takes the same time either way and the form cannot be used to list accounts.
var dummyHash = func() string {
h, err := HashPassword("not-a-real-password")
if err != nil {
return ""
}
return h
}()
// CheckUser verifies a password for one of users. A missing user fails in the same time
// as a wrong password.
func CheckUser(users []config.User, name, pw string) bool {
for _, u := range users {
if u.Name == name {
return VerifyPassword(u.Hash, pw)
}
}
VerifyPassword(dummyHash, pw)
return false
}
// ---- sessions ----
// Sessions is a token store backed by the state file. Expiry is sliding.
type Sessions struct {
st *store.Store
@@ -102,54 +140,136 @@ func NewSessions(st *store.Store) *Sessions {
func pruneExpired(state *store.State) {
now := time.Now()
for tok, exp := range state.Sessions {
if exp.Before(now) {
for tok, ses := range state.Sessions {
if ses.Expires.Before(now) {
delete(state.Sessions, tok)
}
}
}
func (s *Sessions) Create() (string, error) {
b := make([]byte, 32)
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
tok := hex.EncodeToString(b)
err := s.st.Update(func(state *store.State) {
return hex.EncodeToString(b), nil
}
// Create signs user in from ip with agent, returning the cookie token.
func (s *Sessions) Create(user, ip, agent string) (string, error) {
tok, err := randomHex(32)
if err != nil {
return "", err
}
id, err := randomHex(6)
if err != nil {
return "", err
}
if len(agent) > 200 {
agent = agent[:200]
}
now := time.Now()
err = s.st.Update(func(state *store.State) {
pruneExpired(state)
state.Sessions[tok] = time.Now().Add(SessionTTL)
state.Sessions[tok] = store.Session{ID: id, User: user, Expires: now.Add(SessionTTL), Created: now, LastSeen: now, IP: ip, Agent: agent}
})
return tok, err
}
// Valid reports whether tok is live, sliding its expiry (persisted at most hourly).
func (s *Sessions) Valid(tok string) bool {
// Lookup reports whether tok is live and whose it is, sliding its expiry and noting the
// visit (persisted at most hourly, so a busy console is not writing the file all day).
func (s *Sessions) Lookup(tok, ip string) (store.Session, bool) {
if tok == "" {
return false
return store.Session{}, false
}
ok := false
extend := false
var ses store.Session
ok, touch := false, false
s.st.View(func(state *store.State) {
exp, found := state.Sessions[tok]
if found && exp.After(time.Now()) {
ok = true
extend = time.Until(exp) < SessionTTL-time.Hour
found, have := state.Sessions[tok]
if have && found.Expires.After(time.Now()) {
ses, ok = found, true
touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour
}
})
if ok && extend {
if ok && touch {
_ = s.st.Update(func(state *store.State) {
if _, found := state.Sessions[tok]; found {
state.Sessions[tok] = time.Now().Add(SessionTTL)
if x, found := state.Sessions[tok]; found {
x.Expires = time.Now().Add(SessionTTL)
x.LastSeen = time.Now()
if ip != "" {
x.IP = ip
}
state.Sessions[tok] = x
}
})
}
return ok
return ses, ok
}
func (s *Sessions) Delete(tok string) {
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
}
// List is every live session of user, newest first.
func (s *Sessions) List(user string) []store.Session {
var out []store.Session
s.st.View(func(state *store.State) {
now := time.Now()
for _, ses := range state.Sessions {
if ses.User == user && ses.Expires.After(now) {
out = append(out, ses)
}
}
})
sort.Slice(out, func(i, j int) bool { return out[i].LastSeen.After(out[j].LastSeen) })
return out
}
// Count is how many live sessions each user has.
func (s *Sessions) Count() map[string]int {
n := map[string]int{}
s.st.View(func(state *store.State) {
now := time.Now()
for _, ses := range state.Sessions {
if ses.Expires.After(now) {
n[ses.User]++
}
}
})
return n
}
// Revoke ends the session of user with this ID. Reports whether there was one.
func (s *Sessions) Revoke(user, id string) bool {
found := false
_ = s.st.Update(func(state *store.State) {
for tok, ses := range state.Sessions {
if ses.User == user && ses.ID == id {
delete(state.Sessions, tok)
found = true
}
}
})
return found
}
// RevokeOthers ends every session of user except the one holding keep. Returns how many.
func (s *Sessions) RevokeOthers(user, keep string) int {
n := 0
_ = s.st.Update(func(state *store.State) {
for tok, ses := range state.Sessions {
if ses.User == user && tok != keep {
delete(state.Sessions, tok)
n++
}
}
})
return n
}
// RevokeUser ends every session of user (the account is going away).
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
// 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
+36 -2
View File
@@ -24,6 +24,8 @@ type Token struct {
Scope string `json:"scope,omitempty"`
// Sensors narrows a sensors:read token to the IDs these patterns match. Empty is all.
Sensors []string `json:"sensors,omitempty"`
// IssuedBy is the account that made the token (or paired the hub that holds it).
IssuedBy string `json:"issued_by,omitempty"`
}
const ScopeSensorsRead = "sensors:read"
@@ -38,9 +40,20 @@ type Node struct {
Added time.Time `json:"added"`
}
// User is an account on this machine's console. Only the argon2id hash is kept.
type User struct {
Name string `json:"name"`
Hash string `json:"hash"`
Created time.Time `json:"created"`
LastSeen time.Time `json:"last_seen,omitempty"`
}
type Config struct {
Listen string `json:"listen"`
PasswordHash string `json:"password_hash"`
Listen string `json:"listen"`
// PasswordHash is the single login of installs before users existed. On load it becomes
// the first user (see MigrateUsers) and is then dropped from the file.
PasswordHash string `json:"password_hash,omitempty"`
Users []User `json:"users,omitempty"`
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
Tokens []Token `json:"tokens,omitempty"`
Nodes []Node `json:"nodes,omitempty"`
@@ -91,6 +104,27 @@ func Load(path string) (*Config, error) {
return c, nil
}
// MigrateUsers turns the pre-users single password into the first account, named after
// the OS user the console runs as. Reports whether anything changed (the caller saves).
func (c *Config) MigrateUsers(name string) bool {
if len(c.Users) > 0 || c.PasswordHash == "" {
return false
}
c.Users = []User{{Name: name, Hash: c.PasswordHash, Created: time.Now()}}
c.PasswordHash = ""
return true
}
// User finds an account by name. Call under View/Update or on a config nobody else holds.
func (c *Config) User(name string) (User, bool) {
for _, u := range c.Users {
if u.Name == name {
return u, true
}
}
return User{}, false
}
func (c *Config) Path() string { return c.path }
func (c *Config) DataDir() string { return filepath.Dir(c.path) }
+2 -2
View File
@@ -198,7 +198,7 @@ func (m *Manager) List() []Status {
// Add pairs with a node: pin its certificate (trust on first use), trade the node's
// password for a token named after this hub, and store the node. The password is
// used once and never kept.
func (m *Manager) Add(ctx context.Context, name, rawURL, password string) (Status, error) {
func (m *Manager) Add(ctx context.Context, name, rawURL, username, password string) (Status, error) {
if !nameRe.MatchString(name) {
return Status{}, errors.New("name: lowercase letters, digits and dashes only")
}
@@ -221,7 +221,7 @@ func (m *Manager) Add(ctx context.Context, name, rawURL, password string) (Statu
}
n.Fingerprint = fp
}
body, _ := json.Marshal(map[string]string{"password": password, "name": "hub:" + m.hubName})
body, _ := json.Marshal(map[string]string{"username": username, "password": password, "name": "hub:" + m.hubName})
req, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/tokens", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Transport: transportFor(n), Timeout: 15 * time.Second}).Do(req)
+12 -3
View File
@@ -175,14 +175,23 @@ func Install(args []string, version string) error {
return fmt.Errorf("--tls %q: want auto or off", o.tls)
}
generated := ""
if cfg.PasswordHash == "" || o.passwordStdin {
cfg.MigrateUsers(acct.name)
if len(cfg.Users) == 0 || o.passwordStdin {
pw, gen, err := choosePassword(o.passwordStdin)
if err != nil {
return err
}
if cfg.PasswordHash, err = auth.HashPassword(pw); err != nil {
h, err := auth.HashPassword(pw)
if err != nil {
return err
}
// the first account is named after the OS user the service runs as; --password-stdin
// on an existing install resets that first account
if len(cfg.Users) == 0 {
cfg.Users = []config.User{{Name: acct.name, Hash: h, Created: time.Now()}}
} else {
cfg.Users[0].Hash = h
}
if gen {
generated = pw
}
@@ -257,7 +266,7 @@ func Install(args []string, version string) error {
say("\nInstalled, but %s isn't answering yet. Check: %s", url, map[string]string{"systemd": "journalctl -u hamlab -n 30", "OpenRC": "tail /var/log/hamlab.log"}[initSys])
}
if generated != "" {
say("Login password (shown once; change it in Settings): %s", generated)
say("Login: user %s, password (shown once; change it in Settings): %s", cfg.Users[0].Name, generated)
}
if host, _, _ := net.SplitHostPort(cfg.Listen); isLoopback(host) {
say("It only listens on this machine. For LAN access: hamlab install --listen 0.0.0.0:%s", port)
+53 -8
View File
@@ -16,14 +16,59 @@ type Terminal struct {
Name string `json:"name"`
}
// Session is one signed-in browser. The map key is the cookie's token; ID is what the
// UI sees and revokes by, so a session list never shows a usable token.
type Session struct {
ID string `json:"id"`
User string `json:"user"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created,omitempty"`
LastSeen time.Time `json:"last_seen,omitempty"`
IP string `json:"ip,omitempty"`
Agent string `json:"agent,omitempty"`
}
// UnmarshalJSON also reads the pre-users form, a bare expiry time: such a session has no
// user yet; the server gives it to the migrated first account.
func (s *Session) UnmarshalJSON(b []byte) error {
if len(b) > 0 && b[0] == '"' {
return json.Unmarshal(b, &s.Expires)
}
type plain Session
return json.Unmarshal(b, (*plain)(s))
}
// UserState is what follows an account rather than the machine: how the console looks
// to that person and how their dashboard is laid out.
type UserState struct {
Dashboard json.RawMessage `json:"dashboard,omitempty"` // layout prefs, opaque to the server
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
}
type State struct {
Terminals []Terminal `json:"terminals"`
Sessions map[string]time.Time `json:"sessions"` // token -> expiry
Dashboard json.RawMessage `json:"dashboard,omitempty"` // UI layout prefs, opaque to the server
UI json.RawMessage `json:"ui,omitempty"` // theme etc., opaque to the server
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
Terminals []Terminal `json:"terminals"`
Sessions map[string]Session `json:"sessions"` // token -> session
Users map[string]*UserState `json:"users,omitempty"`
// Dashboard and UI are the pre-users machine-wide prefs; on first boot with users they
// move to the first account. Kept so an old state file still reads.
Dashboard json.RawMessage `json:"dashboard,omitempty"`
UI json.RawMessage `json:"ui,omitempty"`
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
}
// UserState returns the prefs of an account, making an empty one if needed.
func (st *State) UserState(name string) *UserState {
if st.Users == nil {
st.Users = map[string]*UserState{}
}
u := st.Users[name]
if u == nil {
u = &UserState{}
st.Users[name] = u
}
return u
}
type Store struct {
@@ -48,7 +93,7 @@ func Open(path string) (*Store, error) {
}
}
if s.state.Sessions == nil {
s.state.Sessions = map[string]time.Time{}
s.state.Sessions = map[string]Session{}
}
return s, nil
}