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
+27 -27
View File
@@ -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 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 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`). `install.sh` (the Gitea repo is private, so the one-liner needs `HAMLAB_TOKEN`).
- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session - **Users, sessions and settings that follow the account** — **shipped 2026-09-22**
list + revoke, optional NPM access list restricting to WireGuard/LAN. (`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.<name>`. 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 <user>`; `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 - **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; `/` 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. 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 Downgrades are already refused by the installer's `vnum()` comparison; the updater
should use the same rule. 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 ## Open questions
- Idle-session reaping policy (never / after N days) — leaning never; it's a - Idle-session reaping policy (never / after N days) — leaning never; it's a
+13 -4
View File
@@ -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 - 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; **you** (`--user`), so terminals are your login shell with your rights;
- writes the config to `~/.config/hamlab/` (`--data`; an existing `~/apps/hamlab/` is kept) - 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 and asks for the first account's password (the account is named after the user the
(`--password-stdin` for scripts); 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 - 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 exist on the host, and waits for `/healthz`; with neither init it prints the one line
to put under your own supervisor; to put under your own supervisor;
- says so when firewalld or ufw will block the port (`--open-firewall` opens it). - 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. [--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 <user>` 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: 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: Install Ham Lab on each host; on the ones the hub should reach:
`hamlab install --listen <lan-ip>:8100 --tls auto` (a self-signed certificate is generated `hamlab install --listen <lan-ip>:8100 --tls auto` (a self-signed certificate is generated
on first start). Then on the hub, open **Machines**, enter a name, the address 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 certificate, keeps an access token, and every tab works against the chosen machine
through the switcher at the top of the rail. through the switcher at the top of the rail.
+111 -10
View File
@@ -2,6 +2,7 @@
package main package main
import ( import (
"bufio"
"context" "context"
"errors" "errors"
"flag" "flag"
@@ -55,7 +56,9 @@ func main() {
install [flags] install this binary as a service on this machine (install -h for flags) install [flags] install this binary as a service on this machine (install -h for flags)
uninstall [--purge] remove the service and the binary uninstall [--purge] remove the service and the binary
serve run the console in the foreground (what the service runs) 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 <user> remove an account and sign it out everywhere
check verify the config and state load, then exit check verify the config and state load, then exit
version version
push-nodes [--force] hub: send this build to every managed machine push-nodes [--force] hub: send this build to every managed machine
@@ -120,7 +123,23 @@ func main() {
} }
fmt.Println("ok") fmt.Println("ok")
case "passwd": 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) log.Fatal(err)
} }
case "push-nodes": // hub CLI: send this binary to every managed machine case "push-nodes": // hub CLI: send this binary to every managed machine
@@ -154,9 +173,76 @@ func main() {
} }
} }
func passwd(cfg *config.Config) error { // migrateUsers gives a pre-users config its first account, named after this OS user, so
fmt.Fprint(os.Stderr, "New password: ") // the CLI sees the same accounts the daemon will.
p1, err := term.ReadPassword(int(os.Stdin.Fd())) 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 <user>")
}
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
}
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) })
}
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 <user>")
}
}
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) fmt.Fprintln(os.Stderr)
if err != nil { if err != nil {
return err return err
@@ -170,24 +256,39 @@ func passwd(cfg *config.Config) error {
if string(p1) != string(p2) { if string(p1) != string(p2) {
return errors.New("passwords do not match") return errors.New("passwords do not match")
} }
if len(p1) < 12 { }
if len(p1) < auth.MinPasswordLen {
return errors.New("password must be at least 12 characters") return errors.New("password must be at least 12 characters")
} }
h, err := auth.HashPassword(string(p1)) h, err := auth.HashPassword(string(p1))
if err != nil { if err != nil {
return err 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 { if err := cfg.Save(); err != nil {
return err 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 return nil
} }
func serve(cfg *config.Config) error { func serve(cfg *config.Config) error {
if cfg.PasswordHash == "" { if cfg.PasswordHash == "" && len(cfg.Users) == 0 {
return fmt.Errorf("no password set — run: hamlab -config %s passwd", cfg.Path()) return fmt.Errorf("no account set — run: hamlab -config %s passwd", cfg.Path())
} }
if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil { if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil {
return err return err
+190 -58
View File
@@ -90,12 +90,21 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
if s.user == "" { if s.user == "" {
s.user = "?" s.user = "?"
} }
s.migrateUsers()
s.sensors.Add(newStatusSensors(s)) s.sensors.Add(newStatusSensors(s))
s.alerts = newAlertEngine(s) s.alerts = newAlertEngine(s)
// The saved dashboard layout carries the sampling interval; apply it on boot. // The saved dashboard layout carries the sampling interval; apply it on boot.
var raw json.RawMessage var raw json.RawMessage
var histSec int 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) s.applyPrefs(raw)
if histSec > 0 { if histSec > 0 {
col.SetWindow(time.Duration(histSec) * time.Second) 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. // happened to open its Dashboard.
var samplerMs int var samplerMs int
var uiRaw json.RawMessage 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 { if samplerMs > 0 {
col.SetInterval(time.Duration(samplerMs) * time.Millisecond) 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 { var ui struct {
RefreshMs float64 `json:"refreshMs"` 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.setSampler(int(ui.RefreshMs), false)
} }
go s.keepFleetInStep() 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("GET /api/procs", s.auth(s.procList))
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal)) m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
m.HandleFunc("POST /api/password", s.auth(s.changePassword)) 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("GET /api/prefs/ui", s.auth(s.getUIPrefs))
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs)) m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14). // 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. // auth accepts the browser session cookie or a bearer API token issued to a hub.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) { if c, err := r.Cookie(cookieName); err == nil {
next(w, r) 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 return
} }
}
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
var hashes []string var hashes, names []string
s.cfg.View(func(c *config.Config) { s.cfg.View(func(c *config.Config) {
for _, t := range c.Tokens { for _, t := range c.Tokens {
if t.Scope == "" { // a scoped token is not a key to the machine if t.Scope == "" { // a scoped token is not a key to the machine
hashes = append(hashes, t.Hash) hashes = append(hashes, t.Hash)
names = append(names, t.Name)
} }
} }
}) })
if auth.TokenMatches(strings.TrimPrefix(h, "Bearer "), hashes) { if i := auth.TokenIndex(strings.TrimPrefix(h, "Bearer "), hashes); i >= 0 {
next(w, r) next(w, withActor(r, actor{Token: names[i]}))
return return
} }
} }
@@ -355,9 +384,13 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ok, trusted := false, false ok, trusted := false, false
var patterns []string var patterns []string
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) { if c, err := r.Cookie(cookieName); err == nil {
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
ok, trusted = true, true ok, trusted = true, true
} else { 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 presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
if presented == "" { if presented == "" {
presented, fromURL = r.URL.Query().Get("token"), true presented, fromURL = r.URL.Query().Get("token"), true
@@ -477,7 +510,7 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
return 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 { }); err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
@@ -500,6 +533,7 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
return return
} }
var body struct { var body struct {
Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Name string `json:"name"` 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") jsonErr(w, 400, "password and name required")
return return
} }
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) { user, ok := s.checkLogin(body.Username, body.Password)
if !ok {
s.limiter.Fail(clientKey(r)) s.limiter.Fail(clientKey(r))
log.Printf("token request failed from %s", clientIP(r)) log.Printf("token request failed for %q from %s", body.Username, clientIP(r))
jsonErr(w, 401, "wrong password") 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 return
} }
s.limiter.Reset(clientKey(r)) s.limiter.Reset(clientKey(r))
s.touchUser(user)
tok, hash, err := auth.NewToken() tok, hash, err := auth.NewToken()
if err != nil { if err != nil {
jsonErr(w, 500, "token error") jsonErr(w, 500, "token error")
@@ -527,12 +567,12 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
kept = append(kept, t) 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 { }); err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return 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}) 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 var out []map[string]any
s.cfg.View(func(c *config.Config) { s.cfg.View(func(c *config.Config) {
for _, t := range c.Tokens { 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 { 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) { 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 { if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request") jsonErr(w, 400, "bad request")
return 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 { if err != nil {
jsonErr(w, 400, err.Error()) jsonErr(w, 400, err.Error())
return return
@@ -746,27 +786,101 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
return return
} }
var body struct { var body struct {
Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
} }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request") jsonErr(w, 400, "bad request")
return return
} }
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) { name, ok := s.checkLogin(body.Username, body.Password)
if !ok {
s.limiter.Fail(clientKey(r)) s.limiter.Fail(clientKey(r))
log.Printf("login failed from %s", clientIP(r)) log.Printf("login failed for %q from %s", body.Username, clientIP(r))
jsonErr(w, 401, "wrong password") jsonErr(w, 401, "wrong user name or password")
return return
} }
s.limiter.Reset(clientKey(r)) s.limiter.Reset(clientKey(r))
tok, err := s.sessions.Create() tok, err := s.sessions.Create(name, clientKey(r), r.UserAgent())
if err != nil { if err != nil {
jsonErr(w, 500, "session error") jsonErr(w, 500, "session error")
return return
} }
s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds())) s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds()))
log.Printf("login ok from %s", clientIP(r)) s.touchUser(name)
w.WriteHeader(204) 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) { func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
@@ -790,11 +904,16 @@ func clientKey(r *http.Request) string {
return host return host
} }
// clientIP names the caller for a log line: who (once signed in) and from where.
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {
ip := r.RemoteAddr
if xff := r.Header.Get("X-Forwarded-For"); xff != "" { 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 ---- // ---- me ----
@@ -818,7 +937,7 @@ func (s *Server) hostName() string {
return s.host return s.host
} }
func (s *Server) me(w http.ResponseWriter, _ *http.Request) { func (s *Server) me(w http.ResponseWriter, r *http.Request) {
load := "" load := ""
if b, err := os.ReadFile("/proc/loadavg"); err == nil { if b, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(b)) f := strings.Fields(string(b))
@@ -841,7 +960,7 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
} }
}) })
jsonOK(w, 200, map[string]any{ 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, "load": load, "uptime": uptime, "service_started": s.started,
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL, "addr": addr, "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") jsonErr(w, 400, "bad request")
return return
} }
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Current) { if !s.confirmSelf(w, r, body.Current) {
jsonErr(w, 403, "current password is wrong")
return return
} }
if len(body.Password) < 12 { if len(body.Password) < auth.MinPasswordLen {
jsonErr(w, 400, "password must be at least 12 characters") jsonErr(w, 400, "password must be at least 12 characters")
return return
} }
h, err := auth.HashPassword(body.Password) if !s.setPassword(w, actorOf(r).User, body.Password) {
if err != nil {
jsonErr(w, 500, err.Error())
return return
} }
if err := s.cfg.Update(func(c *config.Config) { c.PasswordHash = h }); err != nil { log.Printf("password changed (by %s)", clientIP(r))
jsonErr(w, 500, err.Error())
return
}
log.Printf("password changed (from %s)", clientIP(r))
w.WriteHeader(204) w.WriteHeader(204)
} }
// ---- ui prefs (theme) ---- // ---- ui prefs (theme) ----
func (s *Server) getUIPrefs(w http.ResponseWriter, _ *http.Request) { // The look is the account's: theme and accents follow whoever is signed in. The refresh
var raw json.RawMessage // rate rides in the same object for the client's convenience but is the machine's — it is
s.store.View(func(st *store.State) { raw = st.UI }) // how often every machine samples — so it is kept once, machine-wide, and folded in here.
if len(raw) == 0 { func (s *Server) getUIPrefs(w http.ResponseWriter, r *http.Request) {
raw = json.RawMessage("{}") prefs := s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.UI })
} prefs["refreshMs"] = s.metrics.Interval().Milliseconds()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") 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) { func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
@@ -1929,10 +2059,16 @@ func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "theme must be a short identifier") jsonErr(w, 400, "theme must be a short identifier")
return return
} }
if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil { 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()) jsonErr(w, 500, err.Error())
return return
} }
}
if p.RefreshMs != nil { // the refresh rate is also how often every machine samples if p.RefreshMs != nil { // the refresh rate is also how often every machine samples
go s.setSampler(int(*p.RefreshMs), true) 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) ---- // ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) { func (s *Server) getDashPrefs(w http.ResponseWriter, r *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")
w.Header().Set("Cache-Control", "no-store") 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) { func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
@@ -2104,10 +2234,12 @@ func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "expected a JSON object") jsonErr(w, 400, "expected a JSON object")
return return
} }
if err := s.store.Update(func(st *store.State) { st.Dashboard = json.RawMessage(b) }); err != nil { 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()) jsonErr(w, 500, err.Error())
return return
} }
}
s.applyPrefs(b) s.applyPrefs(b)
w.WriteHeader(204) 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" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"regexp"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"hamlab/internal/config"
"hamlab/internal/store" "hamlab/internal/store"
) )
@@ -78,17 +81,52 @@ func HashToken(token string) string {
} }
// TokenMatches compares a presented token against stored hashes in constant time. // 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)) h := []byte(HashToken(token))
ok := false idx := -1
for _, want := range hashes { for i, want := range hashes {
if subtle.ConstantTimeCompare(h, []byte(want)) == 1 { 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. // Sessions is a token store backed by the state file. Expiry is sliding.
type Sessions struct { type Sessions struct {
st *store.Store st *store.Store
@@ -102,54 +140,136 @@ func NewSessions(st *store.Store) *Sessions {
func pruneExpired(state *store.State) { func pruneExpired(state *store.State) {
now := time.Now() now := time.Now()
for tok, exp := range state.Sessions { for tok, ses := range state.Sessions {
if exp.Before(now) { if ses.Expires.Before(now) {
delete(state.Sessions, tok) delete(state.Sessions, tok)
} }
} }
} }
func (s *Sessions) Create() (string, error) { func randomHex(n int) (string, error) {
b := make([]byte, 32) b := make([]byte, n)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return "", err return "", err
} }
tok := hex.EncodeToString(b) return hex.EncodeToString(b), nil
err := s.st.Update(func(state *store.State) { }
// 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) 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 return tok, err
} }
// Valid reports whether tok is live, sliding its expiry (persisted at most hourly). // Lookup reports whether tok is live and whose it is, sliding its expiry and noting the
func (s *Sessions) Valid(tok string) bool { // 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 == "" { if tok == "" {
return false return store.Session{}, false
} }
ok := false var ses store.Session
extend := false ok, touch := false, false
s.st.View(func(state *store.State) { s.st.View(func(state *store.State) {
exp, found := state.Sessions[tok] found, have := state.Sessions[tok]
if found && exp.After(time.Now()) { if have && found.Expires.After(time.Now()) {
ok = true ses, ok = found, true
extend = time.Until(exp) < SessionTTL-time.Hour 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) { _ = s.st.Update(func(state *store.State) {
if _, found := state.Sessions[tok]; found { if x, found := state.Sessions[tok]; found {
state.Sessions[tok] = time.Now().Add(SessionTTL) 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) { func (s *Sessions) Delete(tok string) {
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) }) _ = 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 // 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. // 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 // Limiter locks out a client after repeated failed logins, per client address, with a
+35 -1
View File
@@ -24,6 +24,8 @@ type Token struct {
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
// Sensors narrows a sensors:read token to the IDs these patterns match. Empty is all. // Sensors narrows a sensors:read token to the IDs these patterns match. Empty is all.
Sensors []string `json:"sensors,omitempty"` 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" const ScopeSensorsRead = "sensors:read"
@@ -38,9 +40,20 @@ type Node struct {
Added time.Time `json:"added"` 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 { type Config struct {
Listen string `json:"listen"` Listen string `json:"listen"`
PasswordHash string `json:"password_hash"` // 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 TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
Tokens []Token `json:"tokens,omitempty"` Tokens []Token `json:"tokens,omitempty"`
Nodes []Node `json:"nodes,omitempty"` Nodes []Node `json:"nodes,omitempty"`
@@ -91,6 +104,27 @@ func Load(path string) (*Config, error) {
return c, nil 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) Path() string { return c.path }
func (c *Config) DataDir() string { return filepath.Dir(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 // 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 // password for a token named after this hub, and store the node. The password is
// used once and never kept. // 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) { if !nameRe.MatchString(name) {
return Status{}, errors.New("name: lowercase letters, digits and dashes only") 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 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, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/tokens", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Transport: transportFor(n), Timeout: 15 * time.Second}).Do(req) 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) return fmt.Errorf("--tls %q: want auto or off", o.tls)
} }
generated := "" generated := ""
if cfg.PasswordHash == "" || o.passwordStdin { cfg.MigrateUsers(acct.name)
if len(cfg.Users) == 0 || o.passwordStdin {
pw, gen, err := choosePassword(o.passwordStdin) pw, gen, err := choosePassword(o.passwordStdin)
if err != nil { if err != nil {
return err return err
} }
if cfg.PasswordHash, err = auth.HashPassword(pw); err != nil { h, err := auth.HashPassword(pw)
if err != nil {
return err 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 { if gen {
generated = pw 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]) 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 != "" { 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) { 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) say("It only listens on this machine. For LAN access: hamlab install --listen 0.0.0.0:%s", port)
+49 -4
View File
@@ -16,16 +16,61 @@ type Terminal struct {
Name string `json:"name"` 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 { type State struct {
Terminals []Terminal `json:"terminals"` Terminals []Terminal `json:"terminals"`
Sessions map[string]time.Time `json:"sessions"` // token -> expiry Sessions map[string]Session `json:"sessions"` // token -> session
Dashboard json.RawMessage `json:"dashboard,omitempty"` // UI layout prefs, opaque to the server Users map[string]*UserState `json:"users,omitempty"`
UI json.RawMessage `json:"ui,omitempty"` // theme etc., opaque to the server // 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) 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 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 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 { type Store struct {
mu sync.Mutex mu sync.Mutex
path string path string
@@ -48,7 +93,7 @@ func Open(path string) (*Store, error) {
} }
} }
if s.state.Sessions == nil { if s.state.Sessions == nil {
s.state.Sessions = map[string]time.Time{} s.state.Sessions = map[string]Session{}
} }
return s, nil return s, nil
} }
+11 -3
View File
@@ -2,7 +2,10 @@ import { FormEvent, useState } from 'react'
import { api } from './api' import { api } from './api'
import { Spinner } from './ui' 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 }) { export default function Login({ onLogin }: { onLogin: () => void }) {
const [user, setUser] = useState(localStorage.getItem(USER_KEY) ?? '')
const [pw, setPw] = useState('') const [pw, setPw] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -10,15 +13,20 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
async function submit(e: FormEvent) { async function submit(e: FormEvent) {
e.preventDefault() e.preventDefault()
setBusy(true); setErr('') setBusy(true); setErr('')
try { await api.login(pw); onLogin() } try {
catch (e) { setErr((e instanceof Error && e.message) || 'login failed'); setBusy(false) } 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 ( return (
<div className="center"> <div className="center">
<form className="login" onSubmit={submit}> <form className="login" onSubmit={submit}>
<img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} /> <img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} />
<input type="password" autoFocus autoComplete="current-password" placeholder="password" <input type="text" autoFocus={!user} autoComplete="username" placeholder="user" spellCheck={false} autoCapitalize="off" autoCorrect="off"
value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} />
<input type="password" autoFocus={!!user} autoComplete="current-password" placeholder="password"
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} /> value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button> <button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
+6 -4
View File
@@ -14,6 +14,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [name, setName] = useState('') const [name, setName] = useState('')
const [url, setUrl] = useState('https://') const [url, setUrl] = useState('https://')
const [user, setUser] = useState('')
const [pw, setPw] = useState('') const [pw, setPw] = useState('')
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [addErr, setAddErr] = useState('') const [addErr, setAddErr] = useState('')
@@ -30,9 +31,9 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
e.preventDefault() e.preventDefault()
setAdding(true); setAddErr('') setAdding(true); setAddErr('')
try { 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)'}`) toast(`${st.name} added${st.online ? ` · ${st.host} v${st.version}` : ' (offline)'}`)
setName(''); setUrl('https://'); setPw('') setName(''); setUrl('https://'); setUser(''); setPw('')
await load(); onChanged() await load(); onChanged()
} catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') } } catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') }
finally { setAdding(false) } finally { setAdding(false) }
@@ -111,11 +112,12 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
<form className="card addform" onSubmit={add}> <form className="card addform" onSubmit={add}>
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9_\-]*" title="lower-case letters, digits, - and _" required /></label> <label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9_\-]*" title="lower-case letters, digits, - and _" required /></label>
<label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label> <label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label>
<label>Its Ham Lab password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label> <label>A user on it <input value={user} onChange={(e) => setUser(e.target.value)} placeholder="core" autoComplete="off" spellCheck={false} autoCapitalize="off" /></label>
<label>Their password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label>
{addErr && <div className="error">{addErr}</div>} {addErr && <div className="error">{addErr}</div>}
<div className="svc-actions"> <div className="svc-actions">
<button className="btn on" disabled={adding || !name || !pw}>{adding ? 'Pairing…' : 'Add machine'}</button> <button className="btn on" disabled={adding || !name || !pw}>{adding ? 'Pairing…' : 'Add machine'}</button>
<span className="muted small">The password is used once to obtain an access token and is not stored. The machine's certificate is pinned on first contact.</span> <span className="muted small">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.</span>
</div> </div>
</form> </form>
</div> </div>
+156 -5
View File
@@ -1,6 +1,7 @@
import { FormEvent, useCallback, useEffect, useState } from 'react' import { FormEvent, useCallback, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { fetchCatalog } from './sensors' import { fetchCatalog } from './sensors'
import { api, Me } from './api' import { api, Me, Session, TokenInfo, User } from './api'
import { savePrefs } from './accents' import { savePrefs } from './accents'
import { applyTheme, currentThemeId, THEMES } from './themes' import { applyTheme, currentThemeId, THEMES } from './themes'
import { Btn, Loading, askYes, toast, useEscape } from './ui' 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 [refresh, setRefresh] = useState(getRefreshMs())
const [history, setHistory] = useState(getHistorySec()) const [history, setHistory] = useState(getHistorySec())
const [histErr, setHistErr] = useState('') const [histErr, setHistErr] = useState('')
const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null) const [tokens, setTokens] = useState<TokenInfo[] | null>(null)
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null) const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
const [making, setMaking] = useState(false) const [making, setMaking] = useState(false)
const [cur, setCur] = useState('') const [cur, setCur] = useState('')
@@ -116,15 +117,18 @@ export default function Settings({ me }: { me: Me }) {
</div> </div>
</section> </section>
<div className="dash-bar"><h2 className="section">Account · {me.host}</h2></div> <div className="dash-bar"><h2 className="section">Account · {me.account ?? '?'}@{me.host}</h2></div>
<form className="card addform" onSubmit={changePassword}> <form className="card addform" onSubmit={changePassword}>
<label>Current password <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" required /></label> <label>Current password <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" required /></label>
<label>New password <input type="password" value={pw1} onChange={(e) => setPw1(e.target.value)} autoComplete="new-password" required /></label> <label>New password <input type="password" value={pw1} onChange={(e) => setPw1(e.target.value)} autoComplete="new-password" required /></label>
<label>New password again <input type="password" value={pw2} onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" required /></label> <label>New password again <input type="password" value={pw2} onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" required /></label>
{pwErr && <div className="error">{pwErr}</div>} {pwErr && <div className="error">{pwErr}</div>}
<div className="svc-actions"><button className="btn on" disabled={!cur || !pw1 || !pw2}>Change password</button><span className="muted small">Changes the login for this host only. Other machines keep their own password. Existing sessions stay signed in.</span></div> <div className="svc-actions"><button className="btn on" disabled={!cur || !pw1 || !pw2}>Change password</button><span className="muted small">Your login on this host only. Other machines keep their own accounts. Your sessions stay signed in.</span></div>
</form> </form>
<Sessions />
<Users me={me} />
<div className="dash-bar"><h2 className="section">Access tokens</h2></div> <div className="dash-bar"><h2 className="section">Access tokens</h2></div>
<section className="card"> <section className="card">
<div className="muted small" style={{ marginBottom: 8 }}>A <b>full</b> token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A <b>read-only</b> token can only read sensors — that's the one to give a dashboard.</div> <div className="muted small" style={{ marginBottom: 8 }}>A <b>full</b> token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A <b>read-only</b> token can only read sensors — that's the one to give a dashboard.</div>
@@ -134,7 +138,7 @@ export default function Settings({ me }: { me: Me }) {
<div className="row" key={t.name}> <div className="row" key={t.name}>
<span className="k mono">{t.name}</span> <span className="k mono">{t.name}</span>
<span className="badge" style={{ marginLeft: 0 }} title={t.sensors?.length ? t.sensors.join(', ') : undefined}>{t.scope ? (t.sensors?.length ? `read-only · ${t.sensors.join(' ')}` : 'read-only') : 'full'}</span> <span className="badge" style={{ marginLeft: 0 }} title={t.sensors?.length ? t.sensors.join(', ') : undefined}>{t.scope ? (t.sensors?.length ? `read-only · ${t.sensors.join(' ')}` : 'read-only') : 'full'}</span>
<span className="muted small">issued {new Date(t.created).toLocaleString()}</span> <span className="muted small">issued {new Date(t.created).toLocaleString()}{t.issued_by ? ` by ${t.issued_by}` : ''}</span>
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn> <Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
</div> </div>
))} ))}
@@ -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<Session[] | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Sessions</h2><span className="spacer" /><span className="muted small">where you are signed in</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list && list.map((s) => (
<div className="row" key={s.id}>
<span className="k">{device(s.agent)}{s.current && <span className="badge" style={{ marginLeft: 6 }}>this device</span>}</span>
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}</span>
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
</div>
))}
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}><Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn></div>}
</section>
</>
)
}
// 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<User[] | null>(null)
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [err, setErr] = useState('')
const [reset, setReset] = useState<User | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Users · {me.host}</h2><span className="spacer" /><span className="muted small">each with their own look and dashboard</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list && list.map((u) => (
<div className="row" key={u.name}>
<span className="k mono">{u.name}{u.self && <span className="badge" style={{ marginLeft: 6 }}>you</span>}</span>
<span className="muted small">{u.sessions} {u.sessions === 1 ? 'session' : 'sessions'} · seen {ago(u.last_seen)} · since {new Date(u.created).toLocaleDateString()}</span>
<span style={{ display: 'flex', gap: 6 }}>
{!u.self && <Btn className="btn" onClick={() => setReset(u)}>Reset password</Btn>}
{!u.self && <Btn className="btn danger" onClick={() => remove(u)}>Remove</Btn>}
</span>
</div>
))}
</section>
<form className="card addform" onSubmit={add} style={{ marginTop: 8 }}>
<label>New user <input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-z0-9][a-z0-9._\-]{0,31}" title="lower-case letters, digits, . _ and -" autoComplete="off" spellCheck={false} autoCapitalize="off" required /></label>
<label>Their password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" required /></label>
{err && <div className="error">{err}</div>}
<div className="svc-actions"><button className="btn on" disabled={!name || !pw}>Add user</button><span className="muted small">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.</span></div>
</form>
{reset && <ResetDialog user={reset} onClose={() => 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 (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); go() }}>
<h3>Reset the password of {user.name}</h3>
<label className="muted">Their new password<input type="password" autoFocus value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" /></label>
<label className="muted">Your password, to confirm<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
<div className="muted small">They are signed out everywhere and sign in again with the new one.</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions"><button type="submit" className="btn on" disabled={!cur || !pw}>Reset</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
</form>
</div>
)
}
// A password typed into a modal; null when cancelled.
function askPassword(prompt: string): Promise<string | null> {
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(<PasswordDialog prompt={prompt} onDone={done} />)
})
}
function PasswordDialog({ prompt, onDone }: { prompt: string; onDone: (v: string | null) => void }) {
const [v, setV] = useState('')
useEscape(() => onDone(null))
return (
<div className="modal-back" onClick={() => onDone(null)}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onDone(v) }}>
<div>{prompt}</div>
<input type="password" autoFocus value={v} onChange={(e) => setV(e.target.value)} autoComplete="current-password" />
<div className="svc-actions"><button type="submit" className="btn danger" disabled={!v}>Confirm</button><span className="spacer" /><button type="button" className="btn" onClick={() => onDone(null)}>Cancel</button></div>
</form>
</div>
)
}
// 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 // 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. // 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 }) { function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) {
+1
View File
@@ -249,6 +249,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span> <span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} /> <span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} /> <Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
<Btn className="link" onClick={logout}>logout</Btn> <Btn className="link" onClick={logout}>logout</Btn>
</header> </header>
+15 -4
View File
@@ -54,7 +54,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
return r.json() 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 } 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 // 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 // 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 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 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 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 } export interface FileInfo extends FileEntry { path: string; text: boolean }
@@ -190,16 +194,23 @@ export const api = {
setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true), setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'), dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p), saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
login: (password: string) => req<void>('POST', '/api/login', { password }, true), login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true),
users: () => req<User[]>('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<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
logout: () => req<void>('POST', '/api/logout', undefined, true), logout: () => req<void>('POST', '/api/logout', undefined, true),
meHere: () => req<Me>('GET', '/api/me', undefined, true), meHere: () => req<Me>('GET', '/api/me', undefined, true),
me: () => req<Me>('GET', '/api/me'), me: () => req<Me>('GET', '/api/me'),
machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true), machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true),
addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, true), addMachine: (name: string, url: string, username: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, username, password }, true),
updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true), updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true), updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true), changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true), tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
docker: () => req<DockerInfo>('GET', '/api/docker'), docker: () => req<DockerInfo>('GET', '/api/docker'),
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`), dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),