From 2b54a068215b75425ecc62172369e49e3786284b Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Tue, 22 Sep 2026 18:20:30 -0700 Subject: [PATCH] Users, with their own settings, and a list of where each is signed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PLAN.md | 54 ++++---- README.md | 17 ++- cmd/hamlab/main.go | 139 +++++++++++++++++--- internal/api/server.go | 260 ++++++++++++++++++++++++++++--------- internal/api/users.go | 266 ++++++++++++++++++++++++++++++++++++++ internal/auth/auth.go | 170 ++++++++++++++++++++---- internal/config/config.go | 38 +++++- internal/nodes/nodes.go | 4 +- internal/setup/setup.go | 15 ++- internal/store/store.go | 61 +++++++-- web/src/Login.tsx | 14 +- web/src/Machines.tsx | 10 +- web/src/Settings.tsx | 161 ++++++++++++++++++++++- web/src/Shell.tsx | 1 + web/src/api.ts | 19 ++- 15 files changed, 1059 insertions(+), 170 deletions(-) create mode 100644 internal/api/users.go diff --git a/PLAN.md b/PLAN.md index 48aa301..bd4164d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -295,8 +295,33 @@ something long in one, switch away and back, refresh — it's all still there. 3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. Not done: an OpenRC backend for the Services tab; runit/s6/sysvinit service files; public hosting for `install.sh` (the Gitea repo is private, so the one-liner needs `HAMLAB_TOKEN`). -- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session - list + revoke, optional NPM access list restricting to WireGuard/LAN. +- **Users, sessions and settings that follow the account** — **shipped 2026-09-22** + (`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`, + `Settings.tsx`). The login is a user name and a password. `config.json` holds `users` + (name, argon2id hash, created, last seen); the old single `password_hash` becomes the + account named after the OS user the console runs as on first boot, its signed-in + sessions become that account's, and the machine-wide theme/accents/dashboard become + theirs — nothing to redo after the upgrade. A session carries its user, an ID, when it + was made and last seen, the IP and the browser; **Settings → Sessions** lists yours + ("Safari on iPhone · 10.0.0.5 · seen 2 min ago"), revokes one, or signs out everywhere + else. **Settings → Users** lists accounts with their session counts, adds one, removes + one (their sessions and settings go too; never yourself, never the last), and resets + another's password (they are signed out); removing and resetting take your own + password first. Everyone signed in can do everything — a household console, not a + tenancy — and each machine keeps its own accounts: a hub reaches a node with its token, + so pairing (Machines → Add) now takes a user and password on the node, the user + optional while it has one account. Per account: `ui` (theme, accents) and `dashboard` + in `state.json` under `users.`. Still the machine's: refresh rate (it is how + often every machine samples — it rides in the UI prefs object for the client but is + stored once, machine-wide), graph history, alert rules, terminals (the shells run as + one OS user anyway). A login attempt for a missing user takes as long as a wrong + password, so the form can't list accounts. Every write's log line now names the + account or token (`core@10.0.0.5 via …`), and Access tokens say who issued them. CLI: + `hamlab passwd [user]` (makes the account if new; reads stdin when piped), `hamlab + users`, `hamlab user-rm `; `hamlab install` names the first account after the + service user. Not done: a read-only viewer role. +- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, optional NPM + access list restricting to WireGuard/LAN. - **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from `/` with breadcrumbs, an editable path box, sort, hidden-file toggle and a filter; rows show type icon, size, modified, perms and owner, link targets and broken links. @@ -350,31 +375,6 @@ something long in one, switch away and back, refresh — it's all still there. Downgrades are already refused by the installer's `vnum()` comparison; the updater should use the same rule. -- **Users, with their own settings** — asked for 2026-09-22, not started. Today Ham Lab is - one shared password: `config.json` holds a single `password_hash`, a login mints a session - token in `state.json`, and everything a person can set — theme, accent colours, refresh - rate, dashboard layout and its widgets — is stored per *machine*, not per person - (`store.State.UI`, `.Dashboard`). So two people share one look, one layout, and one - audit trail. Wanted: a username and password field, an account each, and settings that - follow the account. - - **The login.** A `users` list in `config.json` (name, hash, created, last seen) beside - the existing hash, which becomes the first user on upgrade so nothing breaks. The - session already exists and is already a token → just carry which user it belongs to. - Argon2id or bcrypt as now; a login attempt must take the same time whether or not the - user exists, or the form becomes a way to enumerate accounts. - - **The settings.** `UI` and `Dashboard` move from one blob to one per user. Machine - settings — the sampling rate, the history window, alert rules — stay machine-wide: - they are facts about the box, not a preference, and the fleet is held to them. - - **Decisions to make when it is built:** whether anyone can do anything (simplest, and - honest for a household) or whether there is a read-only account — a viewer who can see - graphs but not open a terminal, remove a container or shut a machine down; who may add - and remove users; what happens to a user's terminals when their account is removed; and - whether the hub's users are the fleet's, pushed like the sampling rate, or each machine - keeps its own (each machine keeps its own is less surprising and less to go wrong — - a hub already authenticates to a node with a token, not a password). - - **Worth doing at the same time:** the log line every write already carries an IP should - carry the user, and Settings → Access tokens should say which account issued each one. - ## Open questions - Idle-session reaping policy (never / after N days) — leaning never; it's a diff --git a/README.md b/README.md index 6f9820d..5e314e0 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,24 @@ checks it against `SHA256SUMS` and runs `hamlab install`; re-running it upgrades - gets root through `sudo` or `doas` if you aren't root already, and runs the console as **you** (`--user`), so terminals are your login shell with your rights; - writes the config to `~/.config/hamlab/` (`--data`; an existing `~/apps/hamlab/` is kept) - and asks for the login password, or generates one and prints it once - (`--password-stdin` for scripts); + and asks for the first account's password (the account is named after the user the + console runs as), or generates one and prints it once (`--password-stdin` for scripts); - registers a boot-time service with **systemd** or **OpenRC**, naming only groups that exist on the host, and waits for `/healthz`; with neither init it prints the one line to put under your own supervisor; - says so when firewalld or ufw will block the port (`--open-firewall` opens it). -Re-running it upgrades in place and keeps config, user and password. `hamlab uninstall +Re-running it upgrades in place and keeps config, user and accounts. `hamlab uninstall [--purge]` reverses it. +Accounts: every person has their own user name and password, and the theme, accent +colours and dashboard layout follow the account. Manage them under **Settings → Users** +(add, remove, reset a password; your own password confirms the last two) or from the +shell: `hamlab passwd [user]` sets a password and makes the account if it is new, +`hamlab users` lists them, `hamlab user-rm ` removes one. **Settings → Sessions** +shows where you are signed in and signs out any device. Machine settings — refresh rate, +graph history, alert rules — stay the machine's and are shared by everyone. + What adapts to the host, rather than assuming Debian: | | | @@ -67,7 +75,8 @@ Terminals run as the user the service runs as, with that user's login shell. Install Ham Lab on each host; on the ones the hub should reach: `hamlab install --listen :8100 --tls auto` (a self-signed certificate is generated on first start). Then on the hub, open **Machines**, enter a name, the address -(`https://host:8100`) and that machine's Ham Lab password once. The hub pins the +(`https://host:8100`) and a user and password on that machine once (the user may be +left out when it has only one). The hub pins the certificate, keeps an access token, and every tab works against the chosen machine through the switcher at the top of the rail. diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index cafeacb..7894bf3 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -2,6 +2,7 @@ package main import ( + "bufio" "context" "errors" "flag" @@ -55,7 +56,9 @@ func main() { install [flags] install this binary as a service on this machine (install -h for flags) uninstall [--purge] remove the service and the binary serve run the console in the foreground (what the service runs) - passwd set the login password + passwd [user] set a user's password (makes the account if it is new; reads stdin when piped) + users list the accounts + user-rm remove an account and sign it out everywhere check verify the config and state load, then exit version push-nodes [--force] hub: send this build to every managed machine @@ -120,7 +123,23 @@ func main() { } fmt.Println("ok") case "passwd": - if err := passwd(cfg); err != nil { + if err := passwd(cfg, flag.Arg(1)); err != nil { + log.Fatal(err) + } + case "users": + migrateUsers(cfg) + if len(cfg.Users) == 0 { + fmt.Println("no accounts in", cfg.Path()) + } + for _, u := range cfg.Users { + seen := "never" + if !u.LastSeen.IsZero() { + seen = u.LastSeen.Local().Format("2006-01-02 15:04") + } + fmt.Printf("%-20s created %s last seen %s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen) + } + case "user-rm": + if err := userRm(cfg, flag.Arg(1)); err != nil { log.Fatal(err) } case "push-nodes": // hub CLI: send this binary to every managed machine @@ -154,40 +173,122 @@ func main() { } } -func passwd(cfg *config.Config) error { - fmt.Fprint(os.Stderr, "New password: ") - p1, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Fprintln(os.Stderr) - if err != nil { +// migrateUsers gives a pre-users config its first account, named after this OS user, so +// the CLI sees the same accounts the daemon will. +func migrateUsers(cfg *config.Config) { + name := os.Getenv("USER") + if u, err := user.Current(); err == nil && u.Username != "" { + name = u.Username + } + if cfg.MigrateUsers(name) { + _ = cfg.Save() + } +} + +func userRm(cfg *config.Config, name string) error { + migrateUsers(cfg) + if name == "" { + return errors.New("usage: hamlab user-rm ") + } + if len(cfg.Users) <= 1 { + return errors.New("the last account can't be removed") + } + kept := cfg.Users[:0] + found := false + for _, u := range cfg.Users { + if u.Name == name { + found = true + continue + } + kept = append(kept, u) + } + if !found { + return fmt.Errorf("no account %q", name) + } + cfg.Users = kept + if err := cfg.Save(); err != nil { return err } - fmt.Fprint(os.Stderr, "Again: ") - p2, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Fprintln(os.Stderr) - if err != nil { - return err + if st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")); err == nil { + auth.NewSessions(st).RevokeUser(name) + _ = st.Update(func(s *store.State) { delete(s.Users, name) }) } - if string(p1) != string(p2) { - return errors.New("passwords do not match") + fmt.Fprintln(os.Stderr, "removed", name, "(the running service picks it up at its next config save; restart it to be sure)") + return nil +} + +func passwd(cfg *config.Config, name string) error { + migrateUsers(cfg) + if name == "" { + switch len(cfg.Users) { + case 0: + name = os.Getenv("USER") + if u, err := user.Current(); err == nil && u.Username != "" { + name = u.Username + } + case 1: + name = cfg.Users[0].Name + default: + return errors.New("several accounts: hamlab passwd ") + } } - if len(p1) < 12 { + if !auth.UserNameRe.MatchString(name) { + return fmt.Errorf("%q: a user name is 1–32 lower-case letters, digits, . _ or -", name) + } + var p1 []byte + if !term.IsTerminal(int(os.Stdin.Fd())) { // scripted: one line on stdin + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + p1 = []byte(strings.TrimRight(line, "\r\n")) + } else { + fmt.Fprintf(os.Stderr, "New password for %s: ", name) + var err error + p1, err = term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return err + } + fmt.Fprint(os.Stderr, "Again: ") + p2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return err + } + if string(p1) != string(p2) { + return errors.New("passwords do not match") + } + } + if len(p1) < auth.MinPasswordLen { return errors.New("password must be at least 12 characters") } h, err := auth.HashPassword(string(p1)) if err != nil { return err } - cfg.PasswordHash = h + made := true + for i := range cfg.Users { + if cfg.Users[i].Name == name { + cfg.Users[i].Hash = h + made = false + } + } + if made { + cfg.Users = append(cfg.Users, config.User{Name: name, Hash: h, Created: time.Now()}) + } if err := cfg.Save(); err != nil { return err } - fmt.Fprintln(os.Stderr, "password set in", cfg.Path()) + if made { + fmt.Fprintln(os.Stderr, "account", name, "made in", cfg.Path()) + } else { + fmt.Fprintln(os.Stderr, "password of", name, "set in", cfg.Path()) + } + fmt.Fprintln(os.Stderr, "the running service reads the config at its next save; restart it to be sure") return nil } func serve(cfg *config.Config) error { - if cfg.PasswordHash == "" { - return fmt.Errorf("no password set — run: hamlab -config %s passwd", cfg.Path()) + if cfg.PasswordHash == "" && len(cfg.Users) == 0 { + return fmt.Errorf("no account set — run: hamlab -config %s passwd", cfg.Path()) } if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil { return err diff --git a/internal/api/server.go b/internal/api/server.go index 0ec9d35..d9bb032 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/api/users.go b/internal/api/users.go new file mode 100644 index 0000000..3e77a57 --- /dev/null +++ b/internal/api/users.go @@ -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}) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 80a5bd1..f973caa 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 8bd425d..8bbbfef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) } diff --git a/internal/nodes/nodes.go b/internal/nodes/nodes.go index 2d2c4cd..2944129 100644 --- a/internal/nodes/nodes.go +++ b/internal/nodes/nodes.go @@ -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) diff --git a/internal/setup/setup.go b/internal/setup/setup.go index d078419..261601d 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -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) diff --git a/internal/store/store.go b/internal/store/store.go index 877a847..846c9b5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 } diff --git a/web/src/Login.tsx b/web/src/Login.tsx index 1f8b1b1..2c9cea4 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -2,7 +2,10 @@ import { FormEvent, useState } from 'react' import { api } from './api' import { Spinner } from './ui' +const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again + export default function Login({ onLogin }: { onLogin: () => void }) { + const [user, setUser] = useState(localStorage.getItem(USER_KEY) ?? '') const [pw, setPw] = useState('') const [err, setErr] = useState('') const [busy, setBusy] = useState(false) @@ -10,15 +13,20 @@ export default function Login({ onLogin }: { onLogin: () => void }) { async function submit(e: FormEvent) { e.preventDefault() setBusy(true); setErr('') - try { await api.login(pw); onLogin() } - catch (e) { setErr((e instanceof Error && e.message) || 'login failed'); setBusy(false) } + try { + const r = await api.login(user.trim(), pw) + localStorage.setItem(USER_KEY, r?.user ?? user.trim()) + onLogin() + } catch (e) { setErr((e instanceof Error && e.message) || 'login failed'); setBusy(false) } } return (
Ham Lab - setUser(e.target.value)} disabled={busy} /> + setPw(e.target.value)} disabled={busy} /> {err &&
{err}
} diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index c0e6b3b..7083c94 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -14,6 +14,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange const [err, setErr] = useState('') const [name, setName] = useState('') const [url, setUrl] = useState('https://') + const [user, setUser] = useState('') const [pw, setPw] = useState('') const [adding, setAdding] = useState(false) const [addErr, setAddErr] = useState('') @@ -30,9 +31,9 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange e.preventDefault() setAdding(true); setAddErr('') try { - const st = await api.addMachine(name.trim().toLowerCase(), url.trim(), pw) + const st = await api.addMachine(name.trim().toLowerCase(), url.trim(), user.trim(), pw) toast(`${st.name} added${st.online ? ` · ${st.host} v${st.version}` : ' (offline)'}`) - setName(''); setUrl('https://'); setPw('') + setName(''); setUrl('https://'); setUser(''); setPw('') await load(); onChanged() } catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') } finally { setAdding(false) } @@ -111,11 +112,12 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange - + + {addErr &&
{addErr}
}
- The password is used once to obtain an access token and is not stored. The machine's certificate is pinned on first contact. + The password is used once to obtain an access token and is not stored. The user may be left blank on a machine with a single account. The machine's certificate is pinned on first contact.
diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index 74ba4df..bad1300 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -1,6 +1,7 @@ import { FormEvent, useCallback, useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' import { fetchCatalog } from './sensors' -import { api, Me } from './api' +import { api, Me, Session, TokenInfo, User } from './api' import { savePrefs } from './accents' import { applyTheme, currentThemeId, THEMES } from './themes' import { Btn, Loading, askYes, toast, useEscape } from './ui' @@ -35,7 +36,7 @@ export default function Settings({ me }: { me: Me }) { const [refresh, setRefresh] = useState(getRefreshMs()) const [history, setHistory] = useState(getHistorySec()) const [histErr, setHistErr] = useState('') - const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null) + const [tokens, setTokens] = useState(null) const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null) const [making, setMaking] = useState(false) const [cur, setCur] = useState('') @@ -116,15 +117,18 @@ export default function Settings({ me }: { me: Me }) { -

Account · {me.host}

+

Account · {me.account ?? '?'}@{me.host}

{pwErr &&
{pwErr}
} -
Changes the login for this host only. Other machines keep their own password. Existing sessions stay signed in.
+
Your login on this host only. Other machines keep their own accounts. Your sessions stay signed in.
+ + +

Access tokens

A full token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A read-only token can only read sensors — that's the one to give a dashboard.
@@ -134,7 +138,7 @@ export default function Settings({ me }: { me: Me }) {
{t.name} {t.scope ? (t.sensors?.length ? `read-only · ${t.sensors.join(' ')}` : 'read-only') : 'full'} - issued {new Date(t.created).toLocaleString()} + issued {new Date(t.created).toLocaleString()}{t.issued_by ? ` by ${t.issued_by}` : ''} revoke(t.name)}>Revoke
))} @@ -167,6 +171,153 @@ export default function Settings({ me }: { me: Me }) { ) } +// Where this account is signed in. Revoking one signs that device out at its next request. +function Sessions() { + const [list, setList] = useState(null) + const load = useCallback(async () => { try { setList(await api.sessions()) } catch { setList([]) } }, []) + useEffect(() => { load() }, [load]) + const revoke = async (s: Session) => { + if (s.current && !await askYes('Sign out this device? You will be back at the login form.')) return + try { await api.revokeSession(s.id); toast(s.current ? 'signed out' : 'session revoked'); if (s.current) window.dispatchEvent(new Event('hamlab:unauthenticated')); else load() } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + const others = list?.filter((s) => !s.current).length ?? 0 + const revokeOthers = async () => { + if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return + try { const r = await api.revokeOtherSessions(); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + return ( + <> +

Sessions

where you are signed in
+
+ {list === null && } + {list && list.map((s) => ( +
+ {device(s.agent)}{s.current && this device} + {s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()} + revoke(s)}>{s.current ? 'Sign out' : 'Revoke'} +
+ ))} + {others > 0 &&
Sign out everywhere else
} +
+ + ) +} + +// The accounts on this host. Anyone signed in may add one; removing one, or resetting a +// password, asks for your own password first. +function Users({ me }: { me: Me }) { + const [list, setList] = useState(null) + const [name, setName] = useState('') + const [pw, setPw] = useState('') + const [err, setErr] = useState('') + const [reset, setReset] = useState(null) + const load = useCallback(async () => { try { setList(await api.users()) } catch { setList([]) } }, []) + useEffect(() => { load() }, [load]) + const add = async (e: FormEvent) => { + e.preventDefault(); setErr('') + if (pw.length < 12) { setErr('At least 12 characters.'); return } + try { await api.addUser(name.trim(), pw); toast(`${name.trim()} added`); setName(''); setPw(''); load() } + catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + } + const remove = async (u: User) => { + const cur = await askPassword(`Remove the account "${u.name}"? Their sessions end and their settings are gone. Your password to confirm:`) + if (cur === null) return + try { await api.removeUser(u.name, cur); toast(`${u.name} removed`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + return ( + <> +

Users · {me.host}

each with their own look and dashboard
+
+ {list === null && } + {list && list.map((u) => ( +
+ {u.name}{u.self && you} + {u.sessions} {u.sessions === 1 ? 'session' : 'sessions'} · seen {ago(u.last_seen)} · since {new Date(u.created).toLocaleDateString()} + + {!u.self && setReset(u)}>Reset password} + {!u.self && remove(u)}>Remove} + +
+ ))} +
+
+ + + {err &&
{err}
} +
Every user can do everything a signed-in console can. Accounts are this host's own; a hub reaches other machines with its token, not with these.
+
+ {reset && setReset(null)} onDone={() => { setReset(null); load() }} />} + + ) +} + +function ResetDialog({ user, onClose, onDone }: { user: User; onClose: () => void; onDone: () => void }) { + const [cur, setCur] = useState('') + const [pw, setPw] = useState('') + const [err, setErr] = useState('') + useEscape(onClose) + const go = async () => { + setErr('') + if (pw.length < 12) { setErr('At least 12 characters.'); return } + try { await api.resetPassword(user.name, cur, pw); toast(`password of ${user.name} reset`); onDone() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + } + return ( +
+
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); go() }}> +

Reset the password of {user.name}

+ + +
They are signed out everywhere and sign in again with the new one.
+ {err &&
{err}
} +
+
+
+ ) +} + +// A password typed into a modal; null when cancelled. +function askPassword(prompt: string): Promise { + return new Promise((resolve) => { + const host = document.createElement('div') + document.body.appendChild(host) + const done = (v: string | null) => { root.unmount(); host.remove(); resolve(v) } + const root = createRoot(host) + root.render() + }) +} + +function PasswordDialog({ prompt, onDone }: { prompt: string; onDone: (v: string | null) => void }) { + const [v, setV] = useState('') + useEscape(() => onDone(null)) + return ( +
onDone(null)}> +
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onDone(v) }}> +
{prompt}
+ setV(e.target.value)} autoComplete="current-password" /> +
+
+
+ ) +} + +// A short name for a browser from its user agent: "Safari on iPhone", "Firefox on Linux". +function device(ua?: string): string { + if (!ua) return 'unknown device' + const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '' + const br = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : /curl\//.test(ua) ? 'curl' : ua.split(/[\s/]/)[0] || 'browser' + return os ? `${br} on ${os}` : br +} + +function ago(iso?: string): string { + if (!iso) return 'never' + const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000) + if (s < 60) return 'just now' + if (s < 3600) return `${Math.floor(s / 60)} min ago` + if (s < 86400) return `${Math.floor(s / 3600)} h ago` + return `${Math.floor(s / 86400)} d ago` +} + // One dialog for a read-only token: what will use it, and (optionally) which sensor // families it may see — picked from this machine's own catalog, or typed as patterns. function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) { diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 92425f8..cac7c08 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -249,6 +249,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: load {me.load} + {hubMe.account && {hubMe.account}} logout diff --git a/web/src/api.ts b/web/src/api.ts index 1edbc53..35eb680 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -54,7 +54,7 @@ async function req(method: string, path: string, body?: unknown, local = fals return r.json() } -export interface Me { demo_url?: string; addr?: string; host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } +export interface Me { demo_url?: string; addr?: string; host: string; user: string; account?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } export interface PushResult { name: string; ok: boolean; version?: string; error?: string } // What a machine is called on screen: the hostname it reports, which follows a rename within // a poll. The name it was added under stays its identity — the URL, its accent colour, and @@ -121,6 +121,10 @@ export interface AlertsInfo { rules: AlertRule[]; channels: AlertChannel[]; even export interface ZFSSnapshot { name: string; used: number; created: number } export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] } +export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean } +export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean } +export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string } + export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean } export interface FileInfo extends FileEntry { path: string; text: boolean } @@ -190,16 +194,23 @@ export const api = { setHistoryWindow: (seconds: number) => req('PUT', '/api/prefs/history', { seconds }, true), dashPrefs: () => req('GET', '/api/prefs/dashboard'), saveDashPrefs: (p: DashPrefs) => req('PUT', '/api/prefs/dashboard', p), - login: (password: string) => req('POST', '/api/login', { password }, true), + login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true), + users: () => req('GET', '/api/users', undefined, true), + addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true), + removeUser: (name: string, current: string) => req('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true), + resetPassword: (name: string, current: string, password: string) => req('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true), + sessions: () => req('GET', '/api/sessions', undefined, true), + revokeSession: (id: string) => req('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true), + revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true), logout: () => req('POST', '/api/logout', undefined, true), meHere: () => req('GET', '/api/me', undefined, true), me: () => req('GET', '/api/me'), machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true), - addMachine: (name: string, url: string, password: string) => req('POST', '/api/nodes', { name, url, password }, true), + addMachine: (name: string, url: string, username: string, password: string) => req('POST', '/api/nodes', { name, url, username, password }, true), updateMachine: (name: string, force = false) => req('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true), updateAllMachines: (force = false) => req('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true), changePassword: (current: string, password: string) => req('POST', '/api/password', { current, password }, true), - tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true), + tokens: () => req('GET', '/api/tokens', undefined, true), createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), docker: () => req('GET', '/api/docker'), dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),