From 488d929733cde10c11257f2f88cdc8a9e60cedd2 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 18 Sep 2026 23:00:20 -0700 Subject: [PATCH] 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 --- .gitignore | 1 + Makefile | 45 +- PLAN.md | 14 + README.md | 67 ++- cmd/hamlab/main.go | 124 +++- deploy/install.sh | 50 -- install.sh | 77 +++ internal/api/server.go | 38 +- internal/config/config.go | 27 +- internal/cron/cron.go | 20 +- internal/files/files.go | 4 +- internal/metrics/metrics.go | 12 +- internal/priv/priv.go | 78 +++ internal/services/services.go | 70 ++- internal/services/tables_test.go | 14 + internal/setup/hamlab.openrc | 28 + {deploy => internal/setup}/hamlab.service | 11 +- internal/setup/setup.go | 678 ++++++++++++++++++++++ internal/setup/setup_test.go | 73 +++ internal/storage/storage.go | 86 ++- internal/storage/sys_test.go | 22 + internal/store/store.go | 31 +- internal/term/manager.go | 8 +- internal/updates/updates.go | 208 +++++-- web/src/Updates.tsx | 7 +- web/src/api.ts | 2 +- 26 files changed, 1617 insertions(+), 178 deletions(-) delete mode 100755 deploy/install.sh create mode 100755 install.sh create mode 100644 internal/priv/priv.go create mode 100644 internal/services/tables_test.go create mode 100644 internal/setup/hamlab.openrc rename {deploy => internal/setup}/hamlab.service (61%) create mode 100644 internal/setup/setup.go create mode 100644 internal/setup/setup_test.go create mode 100644 internal/storage/sys_test.go diff --git a/.gitignore b/.gitignore index 23d18e7..130d50c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bin/ internal/web/dist/ web/node_modules/ web/dist/ +dist/ diff --git a/Makefile b/Makefile index f7145bd..7e8c652 100644 --- a/Makefile +++ b/Makefile @@ -3,33 +3,54 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) GO ?= go PREFIX ?= /usr/local/bin -.PHONY: all web build dev deploy clean +# every architecture a release carries; arm is ARMv6 hard-float, so it covers every Raspberry Pi +ARCHES ?= amd64 arm64 arm 386 riscv64 +LDFLAGS = -s -w -X main.version=$(VERSION) +# static, no cgo: the same file runs on glibc, musl (Alpine) and anything else with a Linux kernel +GOBUILD = CGO_ENABLED=0 GOOS=linux GOARM=6 $(GO) build -trimpath -ldflags "$(LDFLAGS)" + +.PHONY: all web build go dev cross release deploy push-nodes push-nodes-force test clean all: build web: cd web && npm ci --no-audit --no-fund && npm run build -build: web - $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab - -# cross-compile for another Linux box (static binary, no cgo): make build-arm64 / build-amd64 -build-%: web - CGO_ENABLED=0 GOOS=linux GOARCH=$* $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab-linux-$* ./cmd/hamlab +build: web go # backend only (assumes web/dist already built) go: - $(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab + $(GOBUILD) -o bin/hamlab ./cmd/hamlab + +# one other architecture: make build-arm64, build-arm, build-386, build-riscv64, build-amd64 +build-%: web + GOARCH=$* $(GOBUILD) -o bin/hamlab-linux-$* ./cmd/hamlab dev: cd web && npm run dev -# arm64 sibling for aarch64 machines (Mac mini); the hub pushes it to them +# siblings for every other architecture; installed beside the binary, and the hub pushes +# each managed machine the one that matches it cross: - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab-linux-arm64 ./cmd/hamlab + @for a in $(filter-out $(shell $(GO) env GOARCH),$(ARCHES)); do \ + echo "build linux/$$a"; GOARCH=$$a $(GOBUILD) -o bin/hamlab-linux-$$a ./cmd/hamlab || exit 1; done +# dist/: one static binary per architecture, install.sh, SHA256SUMS. Serve the directory +# anywhere (or attach it to a release) and install.sh does the rest. +release: web + rm -rf dist && mkdir -p dist + @for a in $(ARCHES); do \ + echo "build linux/$$a"; GOARCH=$$a $(GOBUILD) -o dist/hamlab-linux-$$a ./cmd/hamlab || exit 1; done + sed 's|@VERSION@|$(VERSION)|' install.sh > dist/install.sh && chmod +x dist/install.sh + cd dist && sha256sum hamlab-linux-* install.sh > SHA256SUMS + +test: + $(GO) vet ./... && $(GO) test ./... + sh -n install.sh + +# installs here (keeping the config, user and password), then updates every managed machine deploy: build cross - ./deploy/install.sh + ./bin/hamlab install --prefix $(PREFIX) @sleep 1; $(PREFIX)/hamlab push-nodes || true # push the installed binary to every managed machine (what deploy does last) @@ -41,4 +62,4 @@ push-nodes-force: $(PREFIX)/hamlab push-nodes --force clean: - rm -rf bin internal/web/dist web/dist + rm -rf bin dist internal/web/dist web/dist diff --git a/PLAN.md b/PLAN.md index 0b2f357..ebafed8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -204,6 +204,20 @@ something long in one, switch away and back, refresh — it's all still there. `bin/hamlab-linux-arm64`, `install.sh` installs it beside the binary as `hamlab-arm64`, and the hub picks the sibling matching a node's `/api/me.arch` when pushing — the Mac mini (Fedora Asahi, aarch64, node `mac`) updates like the rest. +- **Portability + install** — **shipped 2026-09-18** (`internal/setup`, `internal/priv`, + `install.sh`, `make release`): the binary installs itself (`hamlab install` / + `uninstall`; systemd or OpenRC; root, sudo or doas), all builds static on amd64, arm64, + arm, 386 and riscv64, `install.sh` as the curl-able bootstrap with checksums. Every + privileged call goes through `priv` instead of a literal `sudo -n`. Updates tab acts on + dnf, zypper, pacman and apk as well as apt. Services list falls back to the plain + table on systemd < 246; Disks falls back to `/sys/block` without `lsblk`. Listener + uses `IP_FREEBIND`. Self-update: pre-flight `check` against the live config, atomic + replace, exec in place. Config default moved to `~/.config/hamlab` (existing + `~/apps/hamlab` still found by the installer and the CLI). Tested in + containers: Debian 12, Ubuntu 22.04, Fedora 40, Rocky 8, openSUSE Leap 15.5, Alpine + 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. - **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from diff --git a/README.md b/README.md index 9637e55..71f1856 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Ham Lab -Web-based Linux management console for bare-metal Debian, in the style of the Proxmox +Web-based Linux management console for bare-metal Linux, in the style of the Proxmox web UI. Named after Ham the cat (see also HamTV). Status: **v0.1** — persistent web terminals and a live Dashboard tab. Scope and roadmap in @@ -8,27 +8,62 @@ Status: **v0.1** — persistent web terminals and a live Dashboard tab. Scope an ## Runs on any Linux box -One static Go binary with the UI embedded; no cgo, no Node at runtime, no agents. The -Dashboard reads `/proc` and `/sys` directly (CPU, memory, hwmon or thermal-zone temps, -fans, NICs, block devices, mounts) so it works the same on x86 and ARM, bare metal or VM, -and just leaves a section empty when the hardware has nothing to report. UPS status is -optional and picked up from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is -installed. +One static Go binary with the UI embedded: no cgo, no libc, no Node at runtime, no agents. +The same file runs on Debian, Ubuntu, Fedora, RHEL/Rocky/Alma, openSUSE, Arch and Alpine +(musl), on bare metal, VMs and containers. Builds: `amd64`, `arm64`, `arm` (ARMv6 +hard-float and up, so every Raspberry Pi), `386`, `riscv64`. ```sh -make build # web + Go, or: make build-arm64 for a cross-compile -./deploy/install.sh # installs the binary + a systemd unit for the invoking user -hamlab passwd # set the login password (config in ~/apps/hamlab/) -sudo systemctl start hamlab # listens on 127.0.0.1:8100 by default; set "listen" in config.json +sudo ./hamlab-linux-amd64 install # that's the whole install ``` +or, from wherever `make release` output is served: + +```sh +curl -fsSL "$HAMLAB_URL/install.sh" | sh +curl -fsSL "$HAMLAB_URL/install.sh" | sh -s -- --listen 0.0.0.0:8100 --tls auto +``` + +`install.sh` only picks the build for `uname -m`, checks it against `SHA256SUMS` and runs +`hamlab install`; run it beside the `hamlab-linux-*` files and it installs offline. +`hamlab install`: + +- copies the binary to `/usr/local/bin` (`--prefix`), atomically; +- 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); +- 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 +[--purge]` reverses it. + +What adapts to the host, rather than assuming Debian: + +| | | +|---|---| +| Root | already root, else passwordless `sudo`, else passwordless `doas`; without any, the console is read-only where root is needed and says so | +| Updates tab | apt, dnf, zypper, pacman, apk: list, refresh, upgrade, install (pacman: all or nothing) | +| Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd | +| Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there | +| Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed | +| Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start | +| Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept | + +Building: `make build` (web + Go), `make release` (every architecture, `install.sh` and +`SHA256SUMS` in `dist/`), `make test`. + Terminals run as the user the service runs as, with that user's login shell. ## Managing several machines -Install Ham Lab on each host. On the ones the hub should reach, add `"tls": "auto"` and a -LAN `listen` address to `config.json` (a self-signed certificate is generated on first -start). Then on the hub, open **Machines**, enter a name, the address +Install Ham Lab on each host; on the ones the hub should reach: +`hamlab install --listen :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 certificate, keeps an access token, and every tab works against the chosen machine through the switcher at the top of the rail. @@ -36,8 +71,8 @@ through the switcher at the top of the rail. Updating: `make deploy` on the hub installs the new build there and then pushes it to every managed machine (`hamlab push-nodes`), which install it and restart. The Machines page flags machines running an older version and offers the same push per machine. -Mixed architectures are fine: `make deploy` also cross-compiles arm64, and the hub sends -each machine the build for its own architecture. +Mixed architectures are fine: `make deploy` also cross-compiles every other architecture, +and the hub sends each machine the build for its own. - On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager. - Installable as a PWA. diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 5fc379c..cafeacb 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -6,7 +6,9 @@ import ( "errors" "flag" "fmt" + "io/fs" "log" + "net" "net/http" "os" "os/signal" @@ -15,6 +17,7 @@ import ( "runtime" "runtime/debug" "strings" + "sync/atomic" "syscall" "time" @@ -28,6 +31,7 @@ import ( "hamlab/internal/metrics" "hamlab/internal/nodes" "hamlab/internal/services" + "hamlab/internal/setup" "hamlab/internal/storage" "hamlab/internal/store" "hamlab/internal/tasks" @@ -42,12 +46,21 @@ var version = "dev" func main() { defaultCfg := os.Getenv("HAMLAB_CONFIG") if defaultCfg == "" { - home, _ := os.UserHomeDir() - defaultCfg = filepath.Join(home, "apps", "hamlab", "config.json") + defaultCfg = config.DefaultPath() } cfgPath := flag.String("config", defaultCfg, "path to config.json") flag.Usage = func() { - fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|push-nodes [--force]|fs …]\n") + fmt.Fprint(os.Stderr, `usage: hamlab [-config path] + + 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 + check verify the config and state load, then exit + version + push-nodes [--force] hub: send this build to every managed machine + +`) flag.PrintDefaults() } flag.Parse() @@ -56,8 +69,40 @@ func main() { if flag.NArg() > 0 { cmd = flag.Arg(0) } - if cmd == "fs" { // privileged helper for the Files tab (run via sudo by the daemon) + switch cmd { // commands that don't read the config + case "fs": // privileged helper for the Files tab (run as root by the daemon) os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr)) + case "install", "uninstall": + var err error + if cmd == "install" { + err = setup.Install(flag.Args()[1:], version) + } else { + err = setup.Uninstall(flag.Args()[1:]) + } + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + if err != nil { + fmt.Fprintln(os.Stderr, "hamlab "+cmd+":", err) + os.Exit(1) + } + return + case "replace": // self-update helper (run as root by the daemon): put this binary at a path, atomically + // Only ever over a hamlab binary: this runs as root, and must not be a way to + // overwrite arbitrary files for whoever is allowed to run hamlab as root. + if flag.NArg() != 2 || !filepath.IsAbs(flag.Arg(1)) || !strings.HasPrefix(filepath.Base(flag.Arg(1)), "hamlab") { + fmt.Fprintln(os.Stderr, "hamlab replace: destination must be an absolute path to a hamlab binary") + os.Exit(2) + } + self, err := os.Executable() + if err == nil { + err = setup.CopyAtomic(self, flag.Arg(1)) + } + if err != nil { + fmt.Fprintln(os.Stderr, "hamlab replace:", err) + os.Exit(1) + } + return } cfg, err := config.Load(*cfgPath) if err != nil { @@ -66,6 +111,14 @@ func main() { switch cmd { case "version": fmt.Println("hamlab", version) + case "check": // a new build runs this against the live config before it replaces the old one + if _, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")); err != nil { + log.Fatal(err) + } + if _, err := fs.Stat(web.FS(), "index.html"); err != nil { + log.Fatal("embedded UI missing: ", err) + } + fmt.Println("ok") case "passwd": if err := passwd(cfg); err != nil { log.Fatal(err) @@ -203,12 +256,28 @@ func serve(cfg *config.Config) error { return fmt.Errorf("tls: %w", err) } } + ln, err := listen(cfg.Listen) + if err != nil { + return err + } srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, files.Local{}, sudoFS, nm, fingerprint, web.FS(), version) hs := &http.Server{ - Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, } + // After a self-update: become the new binary in place. Same PID, so it works the same + // under systemd, OpenRC, or no supervisor at all. It returns only if the exec failed, by + // which point the listener and the shells are gone; the caller puts the old binary back + // and exits so the supervisor starts it. + var restarting atomic.Bool + srv.Restart = func() error { + restarting.Store(true) // Serve returns once Shutdown is called; serve() must not exit under us + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = hs.Shutdown(ctx) + mgr.Shutdown() + return syscall.Exec(self, os.Args, os.Environ()) + } go func() { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) @@ -221,18 +290,57 @@ func serve(cfg *config.Config) error { }() if certPath != "" { log.Printf("hamlab %s listening on https://%s as %s (shell %s) cert %s", version, cfg.Listen, u.Username, shell, fingerprint) - if err := hs.ListenAndServeTLS(certPath, keyPath); !errors.Is(err, http.ErrServerClosed) { + if err := hs.ServeTLS(ln, certPath, keyPath); !errors.Is(err, http.ErrServerClosed) { return err } - return nil + return waitIf(&restarting) } log.Printf("hamlab %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell) - if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + if err := hs.Serve(ln); !errors.Is(err, http.ErrServerClosed) { return err } + return waitIf(&restarting) +} + +// waitIf parks the caller while a restart is replacing the process. +func waitIf(restarting *atomic.Bool) error { + if restarting.Load() { + select {} + } return nil } +// listen binds the configured address with IP_FREEBIND, so a LAN address that isn't up yet +// (boot, DHCP still negotiating, cable out) doesn't stop the daemon: it binds now and +// answers as soon as the address appears. +func listen(addr string) (net.Listener, error) { + lc := net.ListenConfig{Control: func(network, _ string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + _ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_FREEBIND, 1) + }) + }} + ln, err := lc.Listen(context.Background(), "tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen %s: %w", addr, err) + } + if host, _, _ := net.SplitHostPort(addr); host != "" { + if ip := net.ParseIP(host); ip != nil && !ip.IsUnspecified() && !ip.IsLoopback() && !onInterface(ip) { + log.Printf("listen address %s isn't on any interface yet; bound anyway and waiting for it", host) + } + } + return ln, nil +} + +func onInterface(ip net.IP) bool { + addrs, _ := net.InterfaceAddrs() + for _, a := range addrs { + if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) { + return true + } + } + return false +} + // loginShell returns the user's shell from /etc/passwd, falling back to bash if // present and /bin/sh otherwise. Systemd units don't export $SHELL. func loginShell(username string) string { diff --git a/deploy/install.sh b/deploy/install.sh deleted file mode 100755 index 62c97ba..0000000 --- a/deploy/install.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/sh -# Install the built binary and a systemd unit on this host, then (re)start the service. -# Runs as the user who should own the terminals (their login shell, their sudo rights); -# needs sudo only for the copy into /usr/local/bin and the unit install. -# -# HAMLAB_USER user the daemon runs as (default: the invoking user) -# PREFIX binary directory (default: /usr/local/bin) -set -eu -cd "$(dirname "$0")/.." - -USER_NAME="${HAMLAB_USER:-${SUDO_USER:-$(id -un)}}" -GROUP_NAME="$(id -gn "$USER_NAME")" -HOME_DIR="$(getent passwd "$USER_NAME" | cut -d: -f6)" -PREFIX="${PREFIX:-/usr/local/bin}" -[ -n "$HOME_DIR" ] || { echo "no home dir for $USER_NAME" >&2; exit 1; } - -unit=$(mktemp) -sed -e "s|@USER@|$USER_NAME|g" -e "s|@GROUP@|$GROUP_NAME|g" -e "s|@HOME@|$HOME_DIR|g" -e "s|@PREFIX@|$PREFIX|g" \ - deploy/hamlab.service > "$unit" - -sudo install -m 0755 bin/hamlab "$PREFIX/hamlab" -# other-architecture builds sit beside the binary so the hub can push them to matching machines -for alt in bin/hamlab-linux-*; do - [ -f "$alt" ] && sudo install -m 0755 "$alt" "$PREFIX/hamlab-${alt##*-linux-}" -done -sudo install -m 0644 "$unit" /etc/systemd/system/hamlab.service -rm -f "$unit" -# Keep the daemon's read-only sudo polling (SMART, UPS, journal) out of the journal: with -# 250 ms refresh rates the sudo session lines would otherwise dominate the log. -quiet=$(mktemp) -cat > "$quiet" <<'SUDOERS' -# Ham Lab: don't log the daemon's read-only polling commands (installed by deploy/install.sh) -Cmnd_Alias HAMLAB_QUIET = /usr/sbin/smartctl, /usr/bin/smartctl, /usr/sbin/pwrstat, /usr/bin/journalctl, /usr/bin/tail, /usr/bin/cat, /usr/bin/ls, /usr/bin/crontab -Defaults!HAMLAB_QUIET !syslog, !pam_session, !pam_setcred -SUDOERS -if sudo visudo -cf "$quiet" >/dev/null 2>&1; then - sudo install -m 0440 -o root -g root "$quiet" /etc/sudoers.d/hamlab-quiet -else - echo "warning: sudoers snippet failed validation; skipped" >&2 -fi -rm -f "$quiet" -sudo systemctl daemon-reload -sudo systemctl enable hamlab.service >/dev/null -if [ ! -f "$HOME_DIR/apps/hamlab/config.json" ]; then - echo "No config yet. Set a password, then start: $PREFIX/hamlab passwd && sudo systemctl start hamlab" >&2 - exit 0 -fi -sudo systemctl restart hamlab.service -sleep 1 -systemctl is-active hamlab.service && "$PREFIX/hamlab" version diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..dc636fd --- /dev/null +++ b/install.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# Ham Lab installer: fetches the build for this machine and hands over to `hamlab install`, +# which sets up the binary, config, login password and a boot-time service. +# +# curl -fsSL "$HAMLAB_URL/install.sh" | sh +# curl -fsSL "$HAMLAB_URL/install.sh" | sh -s -- --listen 0.0.0.0:8100 --tls auto +# ./install.sh # beside the hamlab-linux-* files: installs offline +# +# Everything after `--` goes to `hamlab install` (see `hamlab install -h`). +# +# HAMLAB_URL where hamlab-linux- and SHA256SUMS are served from +# HAMLAB_TOKEN access token, when that place is a private Gitea/Forgejo repo +set -eu + +HAMLAB_URL="${HAMLAB_URL:-https://git.bigham.dev/mitchell/hamlab/releases/download/@VERSION@}" + +die() { echo "install.sh: $*" >&2; exit 1; } + +[ "$(uname -s)" = Linux ] || die "Ham Lab runs on Linux; this is $(uname -s)" +case "$(uname -m)" in + x86_64|amd64) arch=amd64 ;; + aarch64|arm64) arch=arm64 ;; + armv6l|armv7l|armv8l|arm) arch=arm ;; + i386|i486|i586|i686) arch=386 ;; + riscv64) arch=riscv64 ;; + *) die "no build for $(uname -m) (have: amd64, arm64, arm, 386, riscv64)" ;; +esac +file="hamlab-linux-$arch" + +tmp=$(mktemp -d 2>/dev/null || mktemp -d -t hamlab) +trap 'rm -rf "$tmp"' EXIT INT TERM + +fetch() { # url dest + if command -v curl >/dev/null 2>&1; then + curl -fsSL ${HAMLAB_TOKEN:+-H "Authorization: token $HAMLAB_TOKEN"} -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then + wget -q ${HAMLAB_TOKEN:+--header="Authorization: token $HAMLAB_TOKEN"} -O "$2" "$1" + else + die "need curl or wget" + fi +} + +here=$(cd "$(dirname "$0")" 2>/dev/null && pwd || echo .) +if [ -f "$here/$file" ]; then + cp "$here/$file" "$tmp/hamlab" + [ -f "$here/SHA256SUMS" ] && cp "$here/SHA256SUMS" "$tmp/SHA256SUMS" +else + echo "Downloading $file" >&2 + fetch "$HAMLAB_URL/$file" "$tmp/hamlab" || die "download failed: $HAMLAB_URL/$file" + fetch "$HAMLAB_URL/SHA256SUMS" "$tmp/SHA256SUMS" 2>/dev/null || rm -f "$tmp/SHA256SUMS" +fi + +if [ -f "$tmp/SHA256SUMS" ] && command -v sha256sum >/dev/null 2>&1; then + want=$(awk -v f="$file" '$2 == f || $2 == "*" f { print $1 }' "$tmp/SHA256SUMS") + got=$(sha256sum "$tmp/hamlab" | awk '{ print $1 }') + [ -n "$want" ] || die "SHA256SUMS has no entry for $file" + [ "$want" = "$got" ] || die "checksum mismatch for $file" +fi + +chmod 0755 "$tmp/hamlab" +bin="$tmp/hamlab" +if ! "$bin" version >/dev/null 2>&1; then + # a noexec /tmp is the usual reason: try from the home directory + alt="${HOME:-/root}/.hamlab-install.$$" + trap 'rm -rf "$tmp" "$alt"' EXIT INT TERM + cp "$bin" "$alt" 2>/dev/null && chmod 0755 "$alt" || true + "$alt" version >/dev/null 2>&1 || die "the $arch build doesn't run on this machine" + bin="$alt" +fi + +# Piped from curl, stdin is this script: take the password prompt from the terminal. +case " $* " in *password-stdin*) piped=0 ;; *) piped=1 ;; esac +if [ "$piped" = 1 ] && [ ! -t 0 ] && [ -r /dev/tty ] && (: /dev/null; then + "$bin" install "$@" = 2 && strings.HasSuffix(f[0], ".service") { + uf = append(uf, listedFile{UnitFile: f[0], State: f[1]}) + } + } + return uf +} + func (m *Manager) invalidate() { m.mu.Lock() m.cacheAt = time.Time{} @@ -243,7 +292,7 @@ func parseEntry(line []byte) (Entry, bool) { func (m *Manager) journalArgs(unit string, n int) []string { args := []string{"journalctl", "-u", unit, "-n", strconv.Itoa(n), "-o", "json", "--no-pager", "--all"} if m.journalSudo { - args = append([]string{"sudo", "-n"}, args...) + args = priv.Wrap(args...) } return args } @@ -278,7 +327,8 @@ func (m *Manager) Tail(ctx context.Context, unit string, n int, fn func(Entry)) cmd.Cancel = func() error { pg := "-" + strconv.Itoa(cmd.Process.Pid) if m.journalSudo { // children are root's; only sudo can signal them - return exec.Command("sudo", "-n", "kill", "-9", "--", pg).Run() + k := priv.Wrap("kill", "-9", "--", pg) + return exec.Command(k[0], k[1:]...).Run() } return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } @@ -310,7 +360,7 @@ func (m *Manager) Action(ctx context.Context, action, unit string) error { } args := []string{"systemctl", action, unit, "--no-pager"} if m.sudo { - args = append([]string{"sudo", "-n"}, args...) + args = priv.Wrap(args...) } _, err := run(ctx, args...) m.invalidate() diff --git a/internal/services/tables_test.go b/internal/services/tables_test.go new file mode 100644 index 0000000..10648f1 --- /dev/null +++ b/internal/services/tables_test.go @@ -0,0 +1,14 @@ +package services + +import "testing" + +func TestTables(t *testing.T) { + u := parseUnitTable(" UNIT LOAD ACTIVE SUB DESCRIPTION\n● bad.service loaded failed failed Bad thing here\n ssh.service loaded active running OpenBSD Secure Shell server\n\nLOAD = x\n12 loaded units listed.\n") + if len(u) != 2 || u[0].Unit != "bad.service" || u[0].Active != "failed" || u[1].Description != "OpenBSD Secure Shell server" { + t.Fatalf("%+v", u) + } + f := parseFileTable("UNIT FILE STATE VENDOR PRESET\nssh.service enabled enabled\nfoo@.service static -\n\n2 unit files listed.\n") + if len(f) != 2 || f[0].State != "enabled" { + t.Fatalf("%+v", f) + } +} diff --git a/internal/setup/hamlab.openrc b/internal/setup/hamlab.openrc new file mode 100644 index 0000000..bb58242 --- /dev/null +++ b/internal/setup/hamlab.openrc @@ -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 +} diff --git a/deploy/hamlab.service b/internal/setup/hamlab.service similarity index 61% rename from deploy/hamlab.service rename to internal/setup/hamlab.service index 1a2a7f3..7242e95 100644 --- a/deploy/hamlab.service +++ b/internal/setup/hamlab.service @@ -1,20 +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=systemd-journal adm -WorkingDirectory=@HOME@ -Environment=HAMLAB_CONFIG=@HOME@/apps/hamlab/config.json -ExecStart=@PREFIX@/hamlab serve +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 diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..e073cc3 --- /dev/null +++ b/internal/setup/setup.go @@ -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() } diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go new file mode 100644 index 0000000..4bcf074 --- /dev/null +++ b/internal/setup/setup_test.go @@ -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) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 656cbff..25f0df3 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -19,6 +19,8 @@ import ( "sync" "syscall" "time" + + "hamlab/internal/priv" ) // ---- block devices ---- @@ -75,15 +77,14 @@ func str(p *string) string { func Devices(ctx context.Context) ([]Device, error) { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - out, err := exec.CommandContext(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,SIZE,MODEL,SERIAL,VENDOR,ROTA,TRAN,FSTYPE,MOUNTPOINT,LABEL,RM").Output() - if err != nil { - return nil, errors.New("lsblk: " + err.Error()) - } var root struct { Blockdevices []lsblkNode `json:"blockdevices"` } - if err := json.Unmarshal(out, &root); err != nil { - return nil, err + // lsblk knows labels and stacked devices (LVM, LUKS, md); without it (Alpine and other + // busybox systems don't ship it) or with one too old for JSON, /sys has the essentials. + out, err := exec.CommandContext(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,SIZE,MODEL,SERIAL,VENDOR,ROTA,TRAN,FSTYPE,MOUNTPOINT,LABEL,RM").Output() + if err != nil || json.Unmarshal(out, &root) != nil { + root.Blockdevices = sysBlockDevices() } devs := []Device{} for _, n := range root.Blockdevices { @@ -127,6 +128,77 @@ func Devices(ctx context.Context) ([]Device, error) { return devs, nil } +// sysBlockDevices builds the same tree lsblk would from /sys/block and /proc/mounts. +func sysBlockDevices() []lsblkNode { + type mnt struct{ point, fstype string } + mounts := map[string]mnt{} + if b, err := os.ReadFile("/proc/mounts"); err == nil { + for _, line := range strings.Split(string(b), "\n") { + if f := strings.Fields(line); len(f) >= 3 && strings.HasPrefix(f[0], "/dev/") { + dev := f[0] + if real, err := filepath.EvalSymlinks(dev); err == nil { // /dev/disk/by-uuid/…, /dev/mapper/… + dev = real + } + if _, seen := mounts[filepath.Base(dev)]; !seen { + mounts[filepath.Base(dev)] = mnt{strings.ReplaceAll(f[1], `\040`, " "), f[2]} + } + } + } + } + if b, err := os.ReadFile("/proc/swaps"); err == nil { + for _, line := range strings.Split(string(b), "\n") { + if f := strings.Fields(line); len(f) > 0 && strings.HasPrefix(f[0], "/dev/") { + mounts[filepath.Base(f[0])] = mnt{"[SWAP]", "swap"} + } + } + } + sp := func(v string) *string { + if v = strings.TrimSpace(v); v == "" { + return nil + } + return &v + } + node := func(dir, name, typ string) lsblkNode { + n := lsblkNode{Name: name, Type: typ, Size: u64(strings.TrimSpace(readStrFile(dir+"/size"))) * 512} + if m, ok := mounts[name]; ok { + n.Mountpoint, n.FSType = sp(m.point), sp(m.fstype) + } + return n + } + var out []lsblkNode + ents, _ := os.ReadDir("/sys/block") + for _, e := range ents { + name := e.Name() + dir := "/sys/block/" + name + if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "zram") || + strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") || strings.HasPrefix(name, "sr") || strings.HasPrefix(name, "nbd") { + continue + } + n := node(dir, name, "disk") + n.Model, n.Vendor = sp(readStrFile(dir+"/device/model")), sp(readStrFile(dir+"/device/vendor")) + n.Serial = sp(readStrFile(dir + "/device/serial")) + n.Rota = strings.TrimSpace(readStrFile(dir+"/queue/rotational")) == "1" + n.RM = strings.TrimSpace(readStrFile(dir+"/removable")) == "1" + real, _ := filepath.EvalSymlinks(dir) + switch { + case strings.HasPrefix(name, "nvme"): + n.Tran = sp("nvme") + case strings.Contains(real, "/usb"): + n.Tran = sp("usb") + case strings.Contains(real, "/ata"): + n.Tran = sp("sata") + } + subs, _ := os.ReadDir(dir) + for _, sub := range subs { + if _, err := os.Stat(dir + "/" + sub.Name() + "/partition"); err == nil { + n.Children = append(n.Children, node(dir+"/"+sub.Name(), sub.Name(), "part")) + } + } + out = append(out, n) + } + return out +} + var nsRe = regexp.MustCompile(`^(nvme\d+)n\d+$`) // foldNVMe merges nvme0n1, nvme0n2, … into one physical device "nvme0": one SSD, one @@ -319,7 +391,7 @@ func (m *Manager) run(ctx context.Context, args ...string) ([]byte, int, error) args[0] = p } if m.sudo { - args = append([]string{"sudo", "-n"}, args...) + args = priv.Wrap(args...) } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() diff --git a/internal/storage/sys_test.go b/internal/storage/sys_test.go new file mode 100644 index 0000000..654fc6e --- /dev/null +++ b/internal/storage/sys_test.go @@ -0,0 +1,22 @@ +package storage + +import ( + "os" + "testing" +) + +// The /sys fallback must find the same disks and partitions lsblk does. +func TestSysBlockDevices(t *testing.T) { + if _, err := os.Stat("/sys/block"); err != nil { + t.Skip("no /sys/block") + } + for _, d := range sysBlockDevices() { + if d.Name == "" || d.Type != "disk" { + t.Fatalf("bad node %+v", d) + } + t.Logf("%s size=%d tran=%s rota=%v parts=%d", d.Name, d.Size, str(d.Tran), d.Rota, len(d.Children)) + for _, c := range d.Children { + t.Logf(" %s size=%d fs=%s mount=%s", c.Name, c.Size, str(c.FSType), str(c.Mountpoint)) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 62a626f..c7eaf3d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5,6 +5,7 @@ package store import ( "encoding/json" "errors" + "log" "os" "sync" "time" @@ -35,8 +36,12 @@ func Open(path string) (*Store, error) { return nil, err } if err == nil { + // State is conveniences (sessions, terminal names, layout). A file mangled by a power + // cut must not keep the console down: set it aside and start clean. if err := json.Unmarshal(b, &s.state); err != nil { - return nil, err + s.state = State{} + _ = os.Rename(path, path+".corrupt") + log.Printf("state: %s unreadable (%v); kept as %s.corrupt, starting fresh", path, err, path) } } if s.state.Sessions == nil { @@ -65,9 +70,27 @@ func (s *Store) save() error { if err != nil { return err } - tmp := s.path + ".tmp" - if err := os.WriteFile(tmp, b, 0o600); err != nil { + return WriteFileAtomic(s.path, b, 0o600) +} + +// WriteFileAtomic writes beside path, syncs, then renames: after a crash the file is the +// old version or the new one, never empty or half-written. +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { return err } - return os.Rename(tmp, s.path) + _, err = f.Write(data) + if err == nil { + err = f.Sync() + } + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, path) } diff --git a/internal/term/manager.go b/internal/term/manager.go index fc53256..e23a79e 100644 --- a/internal/term/manager.go +++ b/internal/term/manager.go @@ -9,6 +9,7 @@ import ( "os/exec" "sort" "sync" + "sync/atomic" "syscall" "time" @@ -68,6 +69,7 @@ type Manager struct { shell string home string OnChange func([]Info) // called (without locks held) when the list changes + closing atomic.Bool } func NewManager(shell, home string) *Manager { @@ -283,13 +285,15 @@ func (m *Manager) Close(id string) error { } func (m *Manager) changed() { - if m.OnChange != nil { + if m.OnChange != nil && !m.closing.Load() { m.OnChange(m.List()) } } -// Shutdown hangs up every shell. Called on daemon exit. +// Shutdown hangs up every shell. Called on daemon exit. The saved list is left as it +// was, because that list is what the next start re-opens. func (m *Manager) Shutdown() { + m.closing.Store(true) for _, s := range m.List() { _ = m.Close(s.ID) } diff --git a/internal/updates/updates.go b/internal/updates/updates.go index 9c7dadd..f6b65f5 100644 --- a/internal/updates/updates.go +++ b/internal/updates/updates.go @@ -2,8 +2,9 @@ // was last refreshed, whether a reboot is pending, and the refresh/upgrade actions, // which run as background tasks so their output can be followed live. // -// apt is fully supported. dnf and pacman get a read-only list so the tab still says -// something useful on those hosts; upgrades there are a later addition. +// Supported: apt (Debian, Ubuntu), dnf (Fedora, RHEL family), zypper (openSUSE, SLES), +// pacman (Arch) and apk (Alpine). All of them list, refresh, upgrade and install; pacman +// upgrades everything or nothing, because Arch doesn't support partial upgrades. package updates import ( @@ -18,6 +19,7 @@ import ( "strings" "time" + "hamlab/internal/priv" "hamlab/internal/tasks" ) @@ -31,8 +33,9 @@ type Package struct { } type Status struct { - Manager string `json:"manager"` // apt | dnf | pacman | "" + Manager string `json:"manager"` // apt | dnf | zypper | pacman | apk | "" CanUpgrade bool `json:"can_upgrade"` + Selective bool `json:"selective"` // individual packages can be upgraded Packages []Package `json:"packages"` SecurityCount int `json:"security_count"` LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms @@ -52,7 +55,7 @@ type Manager struct { func New(sudo bool, tm *tasks.Manager) *Manager { m := &Manager{sudo: sudo, tasks: tm} - for _, pm := range []string{"apt-get", "dnf", "pacman"} { + for _, pm := range []string{"apt-get", "dnf", "zypper", "pacman", "apk"} { if _, err := exec.LookPath(pm); err == nil { m.pm = strings.TrimSuffix(pm, "-get") break @@ -63,7 +66,7 @@ func New(sudo bool, tm *tasks.Manager) *Manager { func (m *Manager) sudoArgs(args ...string) []string { if m.sudo { - return append([]string{"sudo", "-n"}, args...) + return priv.Wrap(args...) } return args } @@ -145,9 +148,10 @@ func (m *Manager) Status(ctx context.Context) Status { if t, ok := m.tasks.RunningOf("updates"); ok { s.RunningTask = t.ID } + s.CanUpgrade = m.sudo && m.pm != "" + s.Selective = m.pm != "pacman" switch m.pm { case "apt": - s.CanUpgrade = m.sudo s.Packages = aptUpgradable(ctx) s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp") if s.LastRefresh == 0 { @@ -162,9 +166,18 @@ func (m *Manager) Status(ctx context.Context) Status { s.Packages = dnfUpgradable(ctx) s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf") s.LastUpgrade = dnfLastUpgrade(ctx) + case "zypper": + s.Packages = zypperUpgradable(ctx) + s.LastRefresh = newestMtime("/var/cache/zypp/raw/*", "/var/cache/zypp/raw") + s.LastUpgrade = mtimeMs("/var/log/zypp/history") case "pacman": s.Packages = pacmanUpgradable(ctx) - s.LastRefresh = mtimeMs("/var/lib/pacman/sync") + s.LastRefresh = newestMtime("/var/lib/pacman/sync/*.db", "/var/lib/pacman/sync") + s.LastUpgrade = pacmanLastUpgrade() + case "apk": + s.Packages = apkUpgradable(ctx) + s.LastRefresh = newestMtime("/var/cache/apk/APKINDEX.*", "/var/lib/apk/APKINDEX.*") + s.LastUpgrade = mtimeMs("/lib/apk/db/installed") } for _, p := range s.Packages { if p.Security { @@ -289,7 +302,7 @@ func aptAutoUpgrades(ctx context.Context) bool { return strings.Contains(text, `"1"`) } -// ---- dnf / pacman (read-only) ---- +// ---- dnf / zypper / pacman / apk ---- func dnfUpgradable(ctx context.Context) []Package { text, _ := out(ctx, "dnf", "-q", "check-update") // exit 100 when updates exist; output still parses @@ -305,6 +318,19 @@ func dnfUpgradable(ctx context.Context) []Package { if pkgs == nil { return []Package{} } + // check-update only names the candidate; one rpm query fills in what's installed. + args := append([]string{"rpm", "-q", "--qf", `%{NAME} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`}, names(pkgs)...) + if text, _ := out(ctx, args...); text != "" { // exits non-zero when any name isn't installed (new dependencies) + cur := map[string]string{} + for _, line := range strings.Split(text, "\n") { + if f := strings.Fields(line); len(f) == 2 { + cur[f[0]] = f[1] // several installed (kernels): the last listed is the newest + } + } + for i := range pkgs { + pkgs[i].Current = cur[pkgs[i].Name] + } + } return pkgs } @@ -323,44 +349,148 @@ func pacmanUpgradable(ctx context.Context) []Package { return pkgs } -// ---- actions (apt only) ---- +// zypperUpgradable parses the table `zypper list-updates` prints: +// S | Repository | Name | Current Version | Available Version | Arch +func zypperUpgradable(ctx context.Context) []Package { + text, _ := out(ctx, "zypper", "--non-interactive", "--quiet", "list-updates") + pkgs := []Package{} + for _, line := range strings.Split(text, "\n") { + f := strings.Split(line, "|") + if len(f) < 5 || strings.TrimSpace(f[0]) != "v" { + continue + } + repo := strings.TrimSpace(f[1]) + pkgs = append(pkgs, Package{Name: strings.TrimSpace(f[2]), Current: strings.TrimSpace(f[3]), Candidate: strings.TrimSpace(f[4]), + Suite: repo, Security: strings.Contains(strings.ToLower(repo), "security")}) + } + return pkgs +} -var pkgNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]{1,127}$`) +var apkVerRe = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\s+<\s+(\S+)`) -func (m *Manager) start(title string, args ...string) (*tasks.Task, error) { - if m.pm != "apt" { - return nil, errors.New("upgrades are only supported for apt so far") +// apkUpgradable parses `apk version -l '<'`: "musl-1.2.4-r2 < 1.2.5-r0". +func apkUpgradable(ctx context.Context) []Package { + text, _ := out(ctx, "apk", "version", "-l", "<") + pkgs := []Package{} + for _, line := range strings.Split(text, "\n") { + if m := apkVerRe.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + pkgs = append(pkgs, Package{Name: m[1], Current: m[2], Candidate: m[3]}) + } + } + return pkgs +} + +// pacmanLastUpgrade: the timestamp of the last "upgraded" line in pacman.log. +func pacmanLastUpgrade() int64 { + f, err := os.Open("/var/log/pacman.log") + if err != nil { + return 0 + } + defer f.Close() + last := "" + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64<<10), 1<<20) + for sc.Scan() { + if l := sc.Text(); strings.Contains(l, "[ALPM] upgraded ") && strings.HasPrefix(l, "[") { + if end := strings.IndexByte(l, ']'); end > 1 { + last = l[1:end] + } + } + } + for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} { + if t, err := time.ParseInLocation(layout, last, time.Local); err == nil { + return t.UnixMilli() + } + } + return 0 +} + +// ---- actions ---- + +// Never starts with '-', so a name can't be read as an option by any of the managers. +var pkgNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9+._-]{0,127}$`) + +func checkNames(pkgs []string) error { + for _, p := range pkgs { + if !pkgNameRe.MatchString(p) { + return errors.New("bad package name: " + p) + } + } + return nil +} + +func (m *Manager) start(args ...string) (*tasks.Task, error) { + if m.pm == "" { + return nil, errors.New("no supported package manager on this host") } if !m.sudo { - return nil, errors.New("needs passwordless sudo") + return nil, errors.New("needs root: run Ham Lab as root, or give its user passwordless sudo or doas") } if t, ok := m.tasks.RunningOf("updates"); ok { return nil, errors.New("another package task is running: " + t.Title) } - full := append([]string{"sudo", "-n", "env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...) + full := priv.Wrap(append([]string{"env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)...) cmd := exec.Command(full[0], full[1:]...) - return m.tasks.Start("updates", title, cmd) + return m.tasks.Start("updates", strings.Join(args, " "), cmd) } func (m *Manager) Refresh() (*tasks.Task, error) { - return m.start("apt-get update", "apt-get", "update") + switch m.pm { + case "apt": + return m.start("apt-get", "update") + case "dnf": + return m.start("dnf", "-y", "makecache", "--refresh") + case "zypper": + return m.start("zypper", "--non-interactive", "refresh") + case "pacman": + return m.start("pacman", "-Sy", "--noconfirm") + case "apk": + return m.start("apk", "update") + } + return m.start() } -// Upgrade runs a full dist-upgrade, or only the named packages. Config-file prompts are -// answered "keep the current one" so the run can never block on a question. +// rolling reports a distro whose supported upgrade path is a full distribution upgrade. +func rolling() bool { + b, _ := os.ReadFile("/etc/os-release") + return strings.Contains(string(b), "tumbleweed") || strings.Contains(string(b), "microos") +} + +// Upgrade upgrades everything, or only the named packages. Nothing here can block on a +// question: apt keeps the current config file on a conflict, the rest run non-interactive. func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) { - base := []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"} - if len(pkgs) == 0 { - return m.start("apt-get dist-upgrade", append(base, "dist-upgrade")...) + if err := checkNames(pkgs); err != nil { + return nil, err } - for _, p := range pkgs { - if !pkgNameRe.MatchString(p) { - return nil, errors.New("bad package name: " + p) + all := len(pkgs) == 0 + var args []string + switch m.pm { + case "apt": + args = []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"} + if all { + args = append(args, "dist-upgrade") + } else { + args = append(args, "install", "--only-upgrade") + } + case "dnf": + args = []string{"dnf", "-y", "upgrade"} + case "zypper": + args = []string{"zypper", "--non-interactive", "update"} + if all && rolling() { + args = []string{"zypper", "--non-interactive", "dist-upgrade"} + } + case "pacman": + if !all { + return nil, errors.New("pacman upgrades everything or nothing: Arch doesn't support partial upgrades") + } + args = []string{"pacman", "-Syu", "--noconfirm"} + case "apk": + args = []string{"apk", "upgrade"} + if all { + args = []string{"apk", "upgrade", "--update-cache"} } } - args := append(base, "install", "--only-upgrade") - args = append(args, pkgs...) - return m.start("apt-get upgrade "+strings.Join(pkgs, " "), args...) + return m.start(append(args, pkgs...)...) } // Install installs packages (used by other tabs for optional tooling like smartmontools). @@ -368,13 +498,23 @@ func (m *Manager) Install(pkgs []string) (*tasks.Task, error) { if len(pkgs) == 0 { return nil, errors.New("no packages") } - for _, p := range pkgs { - if !pkgNameRe.MatchString(p) { - return nil, errors.New("bad package name: " + p) - } + if err := checkNames(pkgs); err != nil { + return nil, err } - args := append([]string{"apt-get", "-y", "--no-install-recommends", "install"}, pkgs...) - return m.start("apt-get install "+strings.Join(pkgs, " "), args...) + var args []string + switch m.pm { + case "apt": + args = []string{"apt-get", "-y", "--no-install-recommends", "install"} + case "dnf": + args = []string{"dnf", "-y", "install"} + case "zypper": + args = []string{"zypper", "--non-interactive", "install", "--no-recommends"} + case "pacman": + args = []string{"pacman", "-S", "--noconfirm", "--needed"} + case "apk": + args = []string{"apk", "add"} + } + return m.start(append(args, pkgs...)...) } func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) { diff --git a/web/src/Updates.tsx b/web/src/Updates.tsx index 8d85aa0..c9f426b 100644 --- a/web/src/Updates.tsx +++ b/web/src/Updates.tsx @@ -62,9 +62,8 @@ export default function Updates() { return (
- {!st.manager &&
No supported package manager found (looked for apt, dnf, pacman).
} - {st.manager && st.manager !== 'apt' &&
{st.manager}: listing only; upgrading from here isn't supported yet.
} - {st.manager === 'apt' && !st.sudo &&
Read-only: refreshing and upgrading need passwordless sudo on the host.
} + {!st.manager &&
No supported package manager found (looked for apt, dnf, zypper, pacman, apk).
} + {st.manager && !st.sudo &&
Read-only: refreshing and upgrading need root. Run Ham Lab as root, or give its user passwordless sudo or doas.
} {st.reboot_needed && (
Reboot required{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}} — use the ⏻ button in the header. @@ -82,7 +81,7 @@ export default function Updates() {
start(api.refreshUpdates)}>Refresh index start(() => api.upgrade([]))}>Upgrade all - start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size}) + start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size}) {st.packages.length > 0 && } diff --git a/web/src/api.ts b/web/src/api.ts index 50ee8c0..8b43fee 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -92,7 +92,7 @@ export interface CronRun { id: string; job_id: string; command: string; user: st export interface TaskInfo { id: string; kind: string; title: string; command: string; started: string; finished?: string; running: boolean; exit_code: number; error?: string; output?: string } export interface UpdatePackage { name: string; current: string; candidate: string; suite?: string; security: boolean; description?: string } -export interface UpdatesStatus { manager: string; can_upgrade: boolean; packages: UpdatePackage[]; security_count: number; last_refresh?: number; last_upgrade?: number; reboot_needed: boolean; reboot_pkgs: string[]; auto_upgrades: boolean; running_task?: string; sudo: boolean } +export interface UpdatesStatus { manager: string; can_upgrade: boolean; selective: boolean; packages: UpdatePackage[]; security_count: number; last_refresh?: number; last_upgrade?: number; reboot_needed: boolean; reboot_pkgs: string[]; auto_upgrades: boolean; running_task?: string; sudo: boolean } export interface StoragePart { name: string; type: string; fstype?: string; mount?: string; label?: string; size: number; used?: number; fs_size?: number } export interface StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[]; namespaces?: string[] }