Portable install: the binary installs itself; static builds on five architectures; root via sudo, doas or uid 0; every package manager; older systemd; no lsblk
- hamlab install / uninstall (internal/setup): systemd or OpenRC, elevates through sudo or doas, runs as the invoking user, sets or generates the password, names only journal groups that exist, waits for /healthz, reports an active firewalld/ufw. --purge and the data chown never touch a directory that isn't Ham Lab's alone. - install.sh: curl-able bootstrap (arch detection, SHA256SUMS, offline beside the binaries, noexec /tmp fallback). make release builds dist/ for amd64, arm64, arm (ARMv6), 386, riscv64. Every build is CGO_ENABLED=0; the native one used to link glibc. - internal/priv replaces every literal `sudo -n`: uid 0, sudo or doas. - Updates acts on dnf, zypper, pacman and apk as well as apt; dnf shows installed versions. - Services falls back to the plain systemctl table before systemd 246 and says so on hosts without systemd. Disks falls back to /sys/block without lsblk. - Listener binds with IP_FREEBIND, so a LAN address that isn't up at boot is no failure. - Self-update: the new build must pass `check` against the live config, is put in place by atomic rename, and the daemon re-execs itself (same under any supervisor). - State and config writes are fsynced; a corrupt state.json is set aside, not fatal. - Terminal shutdown no longer empties the saved list, so names come back after a restart by design rather than by winning a race. - Config default is ~/.config/hamlab; an existing ~/apps/hamlab is still found. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
91b7f4f009
commit
488d929733
@@ -0,0 +1,28 @@
|
||||
#!/sbin/openrc-run
|
||||
# Ham Lab web console (written by hamlab install)
|
||||
|
||||
name="hamlab"
|
||||
description="Ham Lab web console"
|
||||
|
||||
export HAMLAB_CONFIG="@CONFIG@"
|
||||
export HOME="@HOME@"
|
||||
export USER="@USER@"
|
||||
|
||||
supervisor="supervise-daemon"
|
||||
command="@BIN@"
|
||||
command_args="serve"
|
||||
command_user="@USER@:@GROUP@"
|
||||
directory="@HOME@"
|
||||
respawn_delay=3
|
||||
respawn_max=0
|
||||
output_log="/var/log/hamlab.log"
|
||||
error_log="/var/log/hamlab.log"
|
||||
|
||||
depend() {
|
||||
need localmount
|
||||
after net firewall
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
checkpath -f -o "@USER@:@GROUP@" -m 0640 /var/log/hamlab.log
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Ham Lab web console
|
||||
After=network.target
|
||||
# never give up restarting
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=@USER@
|
||||
Group=@GROUP@
|
||||
# read the system journal without sudo (keeps the daemon's own log clean)
|
||||
SupplementaryGroups=@GROUPS@
|
||||
WorkingDirectory=-@HOME@
|
||||
Environment=HAMLAB_CONFIG=@CONFIG@
|
||||
ExecStart=@BIN@ serve
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=10
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,678 @@
|
||||
// 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() }
|
||||
@@ -0,0 +1,73 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnitWithoutJournalGroups(t *testing.T) {
|
||||
vars := map[string]string{"@USER@": "ham", "@GROUP@": "ham", "@HOME@": "/home/ham", "@BIN@": "/usr/local/bin/hamlab",
|
||||
"@CONFIG@": "/home/ham/.config/hamlab/config.json", "@GROUPS@": ""}
|
||||
unit := dropLines(fill(systemdUnit, vars), "SupplementaryGroups=")
|
||||
if strings.Contains(unit, "SupplementaryGroups") || strings.Contains(unit, "@") {
|
||||
t.Fatalf("unit still has a groups line or an unfilled placeholder:\n%s", unit)
|
||||
}
|
||||
if !strings.Contains(unit, "ExecStart=/usr/local/bin/hamlab serve") {
|
||||
t.Fatalf("ExecStart missing:\n%s", unit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDirFrom(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Environment=HAMLAB_CONFIG=/home/ham/.config/hamlab/config.json": "/home/ham/.config/hamlab",
|
||||
`export HAMLAB_CONFIG="/root/.config/hamlab/config.json"`: "/root/.config/hamlab",
|
||||
"Environment=HAMLAB_CONFIG=/config.json": "",
|
||||
"Environment=HAMLAB_CONFIG=/etc/passwd": "",
|
||||
} {
|
||||
key := "Environment=HAMLAB_CONFIG="
|
||||
if strings.HasPrefix(in, "export") {
|
||||
key = "export HAMLAB_CONFIG="
|
||||
}
|
||||
if got := configDirFrom(in, key); got != want {
|
||||
t.Errorf("%q: got %q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --purge must never take a shared directory with it.
|
||||
func TestPurgeSparesSharedDirectory(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
os.WriteFile(filepath.Join(home, "config.json"), []byte("{}"), 0o600)
|
||||
os.WriteFile(filepath.Join(home, "thesis.txt"), []byte("irreplaceable"), 0o600)
|
||||
if err := purgeData(home); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "thesis.txt")); err != nil {
|
||||
t.Fatal("purge deleted a file that wasn't Ham Lab's")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "config.json")); err == nil {
|
||||
t.Fatal("purge left config.json behind")
|
||||
}
|
||||
|
||||
own := filepath.Join(t.TempDir(), "hamlab")
|
||||
os.MkdirAll(own, 0o700)
|
||||
os.WriteFile(filepath.Join(own, "config.json"), []byte("{}"), 0o600)
|
||||
if err := purgeData(own); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(own); err == nil {
|
||||
t.Fatal("purge left Ham Lab's own directory behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstMissing(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
if got := firstMissing(filepath.Join(base, "a", "b", "c")); got != filepath.Join(base, "a") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := firstMissing(base); got != "" {
|
||||
t.Errorf("existing dir: got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user