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
+120 -19
View File
@@ -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