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:
co-authored by
Claude Fable 5.1
parent
db214ebc2b
commit
2b54a06821
@@ -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.<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
|
||||
`/` 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
|
||||
|
||||
@@ -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 <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:
|
||||
|
||||
| | |
|
||||
@@ -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 <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
|
||||
(`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.
|
||||
|
||||
|
||||
+120
-19
@@ -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 <user> 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 <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
|
||||
}
|
||||
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 <user>")
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
+196
-64
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ func (m *Manager) List() []Status {
|
||||
// Add pairs with a node: pin its certificate (trust on first use), trade the node's
|
||||
// password for a token named after this hub, and store the node. The password is
|
||||
// used once and never kept.
|
||||
func (m *Manager) Add(ctx context.Context, name, rawURL, password string) (Status, error) {
|
||||
func (m *Manager) Add(ctx context.Context, name, rawURL, username, password string) (Status, error) {
|
||||
if !nameRe.MatchString(name) {
|
||||
return Status{}, errors.New("name: lowercase letters, digits and dashes only")
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func (m *Manager) Add(ctx context.Context, name, rawURL, password string) (Statu
|
||||
}
|
||||
n.Fingerprint = fp
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"password": password, "name": "hub:" + m.hubName})
|
||||
body, _ := json.Marshal(map[string]string{"username": username, "password": password, "name": "hub:" + m.hubName})
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/tokens", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Transport: transportFor(n), Timeout: 15 * time.Second}).Do(req)
|
||||
|
||||
+12
-3
@@ -175,14 +175,23 @@ func Install(args []string, version string) error {
|
||||
return fmt.Errorf("--tls %q: want auto or off", o.tls)
|
||||
}
|
||||
generated := ""
|
||||
if cfg.PasswordHash == "" || o.passwordStdin {
|
||||
cfg.MigrateUsers(acct.name)
|
||||
if len(cfg.Users) == 0 || o.passwordStdin {
|
||||
pw, gen, err := choosePassword(o.passwordStdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.PasswordHash, err = auth.HashPassword(pw); err != nil {
|
||||
h, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// the first account is named after the OS user the service runs as; --password-stdin
|
||||
// on an existing install resets that first account
|
||||
if len(cfg.Users) == 0 {
|
||||
cfg.Users = []config.User{{Name: acct.name, Hash: h, Created: time.Now()}}
|
||||
} else {
|
||||
cfg.Users[0].Hash = h
|
||||
}
|
||||
if gen {
|
||||
generated = pw
|
||||
}
|
||||
@@ -257,7 +266,7 @@ func Install(args []string, version string) error {
|
||||
say("\nInstalled, but %s isn't answering yet. Check: %s", url, map[string]string{"systemd": "journalctl -u hamlab -n 30", "OpenRC": "tail /var/log/hamlab.log"}[initSys])
|
||||
}
|
||||
if generated != "" {
|
||||
say("Login password (shown once; change it in Settings): %s", generated)
|
||||
say("Login: user %s, password (shown once; change it in Settings): %s", cfg.Users[0].Name, generated)
|
||||
}
|
||||
if host, _, _ := net.SplitHostPort(cfg.Listen); isLoopback(host) {
|
||||
say("It only listens on this machine. For LAN access: hamlab install --listen 0.0.0.0:%s", port)
|
||||
|
||||
+53
-8
@@ -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
|
||||
}
|
||||
|
||||
+11
-3
@@ -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 (
|
||||
<div className="center">
|
||||
<form className="login" onSubmit={submit}>
|
||||
<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} />
|
||||
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
@@ -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
|
||||
<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>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>}
|
||||
<div className="svc-actions">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+156
-5
@@ -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<TokenInfo[] | null>(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 }) {
|
||||
</div>
|
||||
</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}>
|
||||
<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 again <input type="password" value={pw2} onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" required /></label>
|
||||
{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>
|
||||
|
||||
<Sessions />
|
||||
<Users me={me} />
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
||||
<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>
|
||||
@@ -134,7 +138,7 @@ export default function Settings({ me }: { me: Me }) {
|
||||
<div className="row" key={t.name}>
|
||||
<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="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>
|
||||
</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
|
||||
// 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 }) {
|
||||
|
||||
@@ -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={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
||||
+15
-4
@@ -54,7 +54,7 @@ async function req<T>(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<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
|
||||
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
|
||||
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),
|
||||
meHere: () => req<Me>('GET', '/api/me', undefined, true),
|
||||
me: () => req<Me>('GET', '/api/me'),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
||||
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
|
||||
|
||||
Reference in New Issue
Block a user