// 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 := "" if cfg.PasswordHash == "" || o.passwordStdin { pw, gen, err := choosePassword(o.passwordStdin) if err != nil { return err } if cfg.PasswordHash, err = auth.HashPassword(pw); err != nil { return err } 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. Run it under your 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 password (shown once; change it in Settings): %s", 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) } } } 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() }