Files
hamlab/internal/setup/setup.go
T
Mitchell-JohnsonandClaude Fable 5.1 2b54a06821 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>
2026-09-22 18:20:30 -07:00

695 lines
20 KiB
Go

// Package setup is `hamlab install` and `hamlab uninstall`: the binary installs itself, so
// a new machine needs nothing but this one file. It copies itself into place, writes the
// config, sets the login password and registers a service with whichever init the host
// runs (systemd or OpenRC), as root or through sudo/doas.
package setup
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/tls"
_ "embed"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"golang.org/x/term"
"hamlab/internal/auth"
"hamlab/internal/config"
)
//go:embed hamlab.service
var systemdUnit string
//go:embed hamlab.openrc
var openrcScript string
const (
unitPath = "/etc/systemd/system/hamlab.service"
openrcPath = "/etc/init.d/hamlab"
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
)
type options struct {
user, listen, tls, prefix, data string
passwordStdin, openFirewall bool
noStart bool
}
func say(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
// elevate re-runs this command as root through sudo or doas (which may prompt), naming
// the invoking user so the service still runs as them.
func elevate(sub string, args []string, keepUser bool) error {
self, err := os.Executable()
if err != nil {
return err
}
for _, tool := range []string{"sudo", "doas"} {
path, err := exec.LookPath(tool)
if err != nil {
continue
}
full := []string{tool, self, sub}
if u, err := user.Current(); err == nil && keepUser && !hasFlag(args, "user") {
full = append(full, "--user", u.Username)
}
full = append(full, args...)
return syscall.Exec(path, full, os.Environ())
}
return errors.New("this needs root, and neither sudo nor doas is installed: run it as root")
}
func hasFlag(args []string, name string) bool {
for _, a := range args {
a = strings.TrimLeft(a, "-")
if a == name || strings.HasPrefix(a, name+"=") {
return true
}
}
return false
}
// Install is `hamlab install [flags]`.
func Install(args []string, version string) error {
var o options
fs := flag.NewFlagSet("install", flag.ContinueOnError)
fs.StringVar(&o.user, "user", "", "user the console runs as; terminals are this user's shell (default: you)")
fs.StringVar(&o.listen, "listen", "", "listen address, e.g. 0.0.0.0:8100 (default: keep, else 127.0.0.1:8100)")
fs.StringVar(&o.tls, "tls", "", `"auto" serves HTTPS with a self-signed certificate, "off" plain HTTP (default: keep)`)
fs.StringVar(&o.prefix, "prefix", "/usr/local/bin", "directory the binary is installed into")
fs.StringVar(&o.data, "data", "", "config and state directory (default: ~/.config/hamlab of the user)")
fs.BoolVar(&o.passwordStdin, "password-stdin", false, "read the login password from stdin instead of prompting")
fs.BoolVar(&o.openFirewall, "open-firewall", false, "open the listen port in firewalld/ufw when one is active")
fs.BoolVar(&o.noStart, "no-start", false, "install and enable, but don't start the service")
if err := fs.Parse(args); err != nil {
return err
}
if os.Geteuid() != 0 {
return elevate("install", args, true)
}
sbinPath()
// who
name := o.user
for _, env := range []string{"SUDO_USER", "DOAS_USER"} {
if name == "" {
name = os.Getenv(env)
}
}
if name == "" {
name = "root"
}
acct, err := lookupUser(name)
if err != nil {
return err
}
// where
dataDir := o.data
if dataDir == "" {
dataDir = filepath.Dir(existingConfig(acct.home))
}
cfgPath := filepath.Join(dataDir, "config.json")
// binary
dest := filepath.Join(o.prefix, "hamlab")
self, err := os.Executable()
if err != nil {
return err
}
self, _ = filepath.EvalSymlinks(self)
if err := os.MkdirAll(o.prefix, 0o755); err != nil {
return err
}
if self != dest {
if err := CopyAtomic(self, dest); err != nil {
return fmt.Errorf("install binary: %w", err)
}
// builds for other architectures sit beside it so a hub can push them to matching machines
sibs, _ := filepath.Glob(filepath.Join(filepath.Dir(self), "hamlab-linux-*"))
for _, sib := range sibs {
arch := strings.TrimPrefix(filepath.Base(sib), "hamlab-linux-")
if sib == self || strings.Contains(arch, ".") {
continue
}
if err := CopyAtomic(sib, dest+"-"+arch); err != nil {
say("warning: %s: %v", sib, err)
}
}
}
restorecon(dest)
say("binary %s (%s)", dest, version)
// config + password
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
if o.listen != "" {
if _, _, err := net.SplitHostPort(o.listen); err != nil {
return fmt.Errorf("--listen %q: want host:port", o.listen)
}
cfg.Listen = o.listen
}
switch o.tls {
case "":
case "auto":
cfg.TLS = "auto"
case "off", "none":
cfg.TLS = ""
default:
return fmt.Errorf("--tls %q: want auto or off", o.tls)
}
generated := ""
cfg.MigrateUsers(acct.name)
if len(cfg.Users) == 0 || o.passwordStdin {
pw, gen, err := choosePassword(o.passwordStdin)
if err != nil {
return err
}
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
}
}
made := firstMissing(dataDir) // e.g. ~/.config, when this is the first thing to use it
if err := cfg.Save(); err != nil {
return err
}
if err := chownData(dataDir, made, acct); err != nil {
return err
}
say("config %s", cfgPath)
// service
vars := map[string]string{"@USER@": acct.name, "@GROUP@": acct.group, "@HOME@": acct.home, "@BIN@": dest, "@CONFIG@": cfgPath,
"@GROUPS@": journalGroups()}
initSys := ""
switch {
case isDir("/run/systemd/system"):
initSys = "systemd"
unit := fill(systemdUnit, vars)
if vars["@GROUPS@"] == "" {
unit = dropLines(unit, "SupplementaryGroups=")
}
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
return err
}
restorecon(unitPath)
if err := runAll([][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "hamlab.service"}}); err != nil {
return err
}
if !o.noStart {
if err := run("systemctl", "restart", "hamlab.service"); err != nil {
return err
}
}
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
initSys = "OpenRC"
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
return err
}
if err := run("rc-update", "add", "hamlab", "default"); err != nil {
say("warning: rc-update: %v", err)
}
if !o.noStart {
if err := run("rc-service", "hamlab", "restart"); err != nil {
return err
}
}
}
if initSys != "" {
say("service hamlab (%s), runs as %s, starts at boot", initSys, acct.name)
}
if acct.name != "root" {
quietSudoers()
}
port := firewall(cfg.Listen, o.openFirewall)
scheme := "http"
if cfg.TLS == "auto" {
scheme = "https"
}
url := scheme + "://" + browseAddr(cfg.Listen)
switch {
case initSys == "":
say("\nNo systemd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
case o.noStart:
case healthy(cfg.Listen, cfg.TLS == "auto"):
say("\nHam Lab is up: %s", url)
default:
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: 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)
}
return nil
}
// Uninstall is `hamlab uninstall [--purge]`.
func Uninstall(args []string) error {
fs := flag.NewFlagSet("uninstall", flag.ContinueOnError)
purge := fs.Bool("purge", false, "also delete the config and state directory")
prefix := fs.String("prefix", "/usr/local/bin", "directory the binary was installed into")
if err := fs.Parse(args); err != nil {
return err
}
if os.Geteuid() != 0 {
return elevate("uninstall", args, false)
}
sbinPath()
dataDir := ""
if b, err := os.ReadFile(unitPath); err == nil {
dataDir = configDirFrom(string(b), "Environment=HAMLAB_CONFIG=")
_ = run("systemctl", "disable", "--now", "hamlab.service")
os.Remove(unitPath)
_ = run("systemctl", "daemon-reload")
}
if b, err := os.ReadFile(openrcPath); err == nil {
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
_ = run("rc-service", "hamlab", "stop")
_ = run("rc-update", "del", "hamlab", "default")
os.Remove(openrcPath)
}
os.Remove(sudoersPath)
bins, _ := filepath.Glob(filepath.Join(*prefix, "hamlab-*"))
for _, b := range append(bins, filepath.Join(*prefix, "hamlab")) {
os.Remove(b)
}
say("Ham Lab removed.")
switch {
case dataDir == "":
case *purge:
if err := purgeData(dataDir); err != nil {
return err
}
default:
say("Config and state kept in %s (uninstall --purge deletes them).", dataDir)
}
return nil
}
// sbinPath makes sure the admin tools resolve: a root shell reached with plain `su` keeps
// the user's PATH, which on Debian has no sbin in it.
func sbinPath() {
os.Setenv("PATH", os.Getenv("PATH")+":/usr/local/sbin:/usr/sbin:/sbin:/usr/local/bin:/usr/bin:/bin")
}
// ownFiles is everything Ham Lab writes into its data directory.
var ownFiles = []string{"config.json", "config.json.tmp", "state.json", "state.json.tmp", "state.json.corrupt", "cert.pem", "key.pem", "hamlab.prev", "hamlab.new"}
// purgeData deletes Ham Lab's data. The whole directory goes only when it is unmistakably
// ours (named "hamlab"); a --data pointed at a shared directory such as a home loses
// just the files Ham Lab wrote.
func purgeData(dir string) error {
if filepath.Base(dir) == "hamlab" {
say("Deleted %s.", dir)
return os.RemoveAll(dir)
}
for _, f := range ownFiles {
os.Remove(filepath.Join(dir, f))
}
say("Deleted Ham Lab's files in %s (the directory isn't Ham Lab's alone, so it stays).", dir)
return nil
}
func configDirFrom(text, key string) string {
for _, line := range strings.Split(text, "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), key); ok {
if v = strings.Trim(v, `"'`); strings.HasSuffix(v, "/config.json") && filepath.Dir(v) != "/" {
return filepath.Dir(v)
}
}
}
return ""
}
// ---- pieces ----
type account struct {
name, group, home string
uid, gid int
}
func lookupUser(name string) (account, error) {
a := account{name: name}
if u, err := user.Lookup(name); err == nil {
a.uid, _ = strconv.Atoi(u.Uid)
a.gid, _ = strconv.Atoi(u.Gid)
a.home = u.HomeDir
} else if out, err2 := exec.Command("getent", "passwd", name).Output(); err2 == nil { // LDAP/SSSD accounts
f := strings.Split(strings.TrimSpace(string(out)), ":")
if len(f) < 6 {
return a, fmt.Errorf("no such user: %s", name)
}
a.uid, _ = strconv.Atoi(f[2])
a.gid, _ = strconv.Atoi(f[3])
a.home = f[5]
} else {
return a, fmt.Errorf("no such user: %s", name)
}
if a.home == "" || !isDir(a.home) {
return a, fmt.Errorf("user %s has no home directory (%q); pick another with --user", name, a.home)
}
a.group = strconv.Itoa(a.gid)
if g, err := user.LookupGroupId(a.group); err == nil {
a.group = g.Name
}
return a, nil
}
// existingConfig returns the config path for a home directory: whichever already exists,
// else the default for a new install.
func existingConfig(home string) string {
for _, p := range config.Candidates(home) {
if exists(p) {
return p
}
}
return config.Candidates(home)[0]
}
// journalGroups lists the groups that can read the system journal and exist here. A unit
// naming a group the host doesn't have fails to start, and "adm" is not universal.
func journalGroups() string {
var have []string
for _, g := range []string{"systemd-journal", "adm"} {
if _, err := user.LookupGroup(g); err == nil {
have = append(have, g)
}
}
return strings.Join(have, " ")
}
func choosePassword(fromStdin bool) (pw string, generated bool, err error) {
switch {
case fromStdin:
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
pw = strings.TrimRight(line, "\r\n")
case term.IsTerminal(int(os.Stdin.Fd())):
fmt.Fprint(os.Stderr, "Login password for the web console (12+ characters, empty to generate one): ")
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", false, err
}
if len(p1) > 0 {
fmt.Fprint(os.Stderr, "Again: ")
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", false, err
}
if !bytes.Equal(p1, p2) {
return "", false, errors.New("passwords do not match")
}
}
pw = string(p1)
}
if pw == "" && !fromStdin {
return randomPassword(), true, nil
}
if len(pw) < 12 {
return "", false, errors.New("password must be at least 12 characters")
}
return pw, false, nil
}
func randomPassword() string {
const alphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no look-alikes
b := make([]byte, 20)
_, _ = rand.Read(b)
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return string(b)
}
// CopyAtomic puts src at dst with mode 0755 by writing beside it and renaming, so dst is
// always either the old file or the whole new one, and a running dst is never written to.
func CopyAtomic(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := filepath.Join(filepath.Dir(dst), "."+filepath.Base(dst)+".new")
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
_, err = io.Copy(out, in)
if err == nil {
err = out.Sync()
}
if cerr := out.Close(); err == nil {
err = cerr
}
if err == nil {
err = os.Chmod(tmp, 0o755)
}
if err == nil {
err = os.Rename(tmp, dst)
}
if err != nil {
os.Remove(tmp)
}
return err
}
// firstMissing returns the topmost directory that creating dir would have to make, or "".
func firstMissing(dir string) string {
missing := ""
for d := filepath.Clean(dir); d != "/" && d != "." && !exists(d); d = filepath.Dir(d) {
missing = d
}
return missing
}
// chownData hands Ham Lab's data to the account that runs it. Directories this install
// created are ours to hand over whole. In one that already existed (a --data pointed at
// something shared), only the files Ham Lab writes change owner: a recursive chown of
// somebody else's tree can't be undone.
func chownData(dataDir, made string, a account) error {
if made != "" {
return chownTree(made, a)
}
if filepath.Base(dataDir) == "hamlab" {
return chownTree(dataDir, a)
}
for _, f := range ownFiles {
if p := filepath.Join(dataDir, f); exists(p) {
if err := os.Lchown(p, a.uid, a.gid); err != nil {
return err
}
}
}
return nil
}
func chownTree(dir string, a account) error {
return filepath.Walk(dir, func(p string, _ os.FileInfo, err error) error {
if err != nil {
return err
}
return os.Lchown(p, a.uid, a.gid)
})
}
func fill(tmpl string, vars map[string]string) string {
for k, v := range vars {
tmpl = strings.ReplaceAll(tmpl, k, v)
}
return tmpl
}
func dropLines(text, prefix string) string {
var keep []string
for _, l := range strings.Split(text, "\n") {
if !strings.HasPrefix(l, prefix) {
keep = append(keep, l)
}
}
return strings.Join(keep, "\n")
}
// quietSudoers keeps the daemon's read-only polling (SMART, UPS, journal) out of the auth
// log: at 250 ms refresh rates the sudo session lines would otherwise dominate it.
func quietSudoers() {
if !isDir("/etc/sudoers.d") || !have("visudo") {
return
}
var cmds []string
for _, bin := range []string{"smartctl", "pwrstat", "journalctl", "tail", "cat", "ls", "crontab"} {
for _, dir := range []string{"/usr/sbin", "/usr/bin", "/sbin", "/bin"} {
if exists(dir + "/" + bin) {
cmds = append(cmds, dir+"/"+bin)
}
}
}
for _, bin := range []string{"zpool", "zfs"} { // listing only: scrubs and snapshots still log
for _, dir := range []string{"/usr/sbin", "/sbin", "/usr/bin", "/bin"} {
if exists(dir + "/" + bin) {
cmds = append(cmds, dir+"/"+bin+" list *", dir+"/"+bin+" status *")
}
}
}
if len(cmds) == 0 {
return
}
text := "# Ham Lab: don't log the daemon's read-only polling commands (written by hamlab install)\n" +
"Cmnd_Alias HAMLAB_QUIET = " + strings.Join(cmds, ", ") + "\n" +
"Defaults!HAMLAB_QUIET !syslog, !pam_session, !pam_setcred\n"
tmp := sudoersPath + ".new"
if os.WriteFile(tmp, []byte(text), 0o440) != nil {
return
}
if exec.Command("visudo", "-cf", tmp).Run() != nil {
os.Remove(tmp)
say("warning: sudoers snippet failed validation; skipped")
return
}
_ = os.Rename(tmp, sudoersPath)
}
// firewall reports (and with open, fixes) an active host firewall in front of a
// non-loopback listen address. It returns the port.
func firewall(listen string, open bool) string {
host, port, _ := net.SplitHostPort(listen)
if isLoopback(host) {
return port
}
switch {
case have("firewall-cmd") && exec.Command("firewall-cmd", "--state").Run() == nil:
if open {
if err := runAll([][]string{{"firewall-cmd", "--quiet", "--permanent", "--add-port=" + port + "/tcp"}, {"firewall-cmd", "--quiet", "--reload"}}); err != nil {
say("warning: firewalld: %v", err)
} else {
say("firewall firewalld: opened %s/tcp", port)
}
} else {
say("firewall firewalld is active and will block port %s. Open it: hamlab install --open-firewall", port)
}
case have("ufw"):
out, _ := exec.Command("ufw", "status").Output()
if !bytes.Contains(out, []byte("Status: active")) {
break
}
if open {
if err := run("ufw", "allow", port+"/tcp"); err != nil {
say("warning: ufw: %v", err)
} else {
say("firewall ufw: opened %s/tcp", port)
}
} else {
say("firewall ufw is active and will block port %s. Open it: hamlab install --open-firewall", port)
}
}
return port
}
func isLoopback(host string) bool {
ip := net.ParseIP(host)
return host == "localhost" || (ip != nil && ip.IsLoopback())
}
// browseAddr turns a listen address into one a browser can open.
func browseAddr(listen string) string {
host, port, err := net.SplitHostPort(listen)
if err != nil {
return listen
}
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
host = "localhost"
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil && !n.IP.IsLoopback() {
host = n.IP.String()
break
}
}
}
}
return net.JoinHostPort(host, port)
}
// healthy polls /healthz for up to 10 s.
func healthy(listen string, https bool) bool {
host, port, _ := net.SplitHostPort(listen)
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
host = "127.0.0.1"
}
scheme := "http"
if https {
scheme = "https"
}
c := &http.Client{Timeout: 2 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} // self-signed; reachability only
for i := 0; i < 20; i++ {
if resp, err := c.Get(scheme + "://" + net.JoinHostPort(host, port) + "/healthz"); err == nil {
resp.Body.Close()
return resp.StatusCode < 500
}
time.Sleep(500 * time.Millisecond)
}
return false
}
// restorecon fixes the SELinux label of a file we created, where SELinux exists.
func restorecon(path string) {
if have("restorecon") {
_ = exec.Command("restorecon", "-F", path).Run()
}
}
func run(args ...string) error {
out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
if err != nil {
if msg := strings.TrimSpace(string(out)); msg != "" {
return fmt.Errorf("%s: %s", strings.Join(args, " "), msg)
}
return fmt.Errorf("%s: %w", strings.Join(args, " "), err)
}
return nil
}
func runAll(cmds [][]string) error {
for _, c := range cmds {
if err := run(c...); err != nil {
return err
}
}
return nil
}
func have(bin string) bool { _, err := exec.LookPath(bin); return err == nil }
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
func isDir(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }