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:
Mitchell-Johnson
2026-09-18 23:00:20 -07:00
co-authored by Claude Fable 5.1
parent 91b7f4f009
commit 488d929733
26 changed files with 1617 additions and 178 deletions
+1
View File
@@ -2,3 +2,4 @@ bin/
internal/web/dist/ internal/web/dist/
web/node_modules/ web/node_modules/
web/dist/ web/dist/
dist/
+33 -12
View File
@@ -3,33 +3,54 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
GO ?= go GO ?= go
PREFIX ?= /usr/local/bin 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 all: build
web: web:
cd web && npm ci --no-audit --no-fund && npm run build cd web && npm ci --no-audit --no-fund && npm run build
build: web build: web go
$(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
# backend only (assumes web/dist already built) # backend only (assumes web/dist already built)
go: 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: dev:
cd web && npm run 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: 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: build cross
./deploy/install.sh ./bin/hamlab install --prefix $(PREFIX)
@sleep 1; $(PREFIX)/hamlab push-nodes || true @sleep 1; $(PREFIX)/hamlab push-nodes || true
# push the installed binary to every managed machine (what deploy does last) # push the installed binary to every managed machine (what deploy does last)
@@ -41,4 +62,4 @@ push-nodes-force:
$(PREFIX)/hamlab push-nodes --force $(PREFIX)/hamlab push-nodes --force
clean: clean:
rm -rf bin internal/web/dist web/dist rm -rf bin dist internal/web/dist web/dist
+14
View File
@@ -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 `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 `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. 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 - **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session
list + revoke, optional NPM access list restricting to WireGuard/LAN. list + revoke, optional NPM access list restricting to WireGuard/LAN.
- **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from - **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from
+51 -16
View File
@@ -1,6 +1,6 @@
# Ham Lab # 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). 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 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 ## Runs on any Linux box
One static Go binary with the UI embedded; no cgo, no Node at runtime, no agents. The One static Go binary with the UI embedded: no cgo, no libc, no Node at runtime, no agents.
Dashboard reads `/proc` and `/sys` directly (CPU, memory, hwmon or thermal-zone temps, The same file runs on Debian, Ubuntu, Fedora, RHEL/Rocky/Alma, openSUSE, Arch and Alpine
fans, NICs, block devices, mounts) so it works the same on x86 and ARM, bare metal or VM, (musl), on bare metal, VMs and containers. Builds: `amd64`, `arm64`, `arm` (ARMv6
and just leaves a section empty when the hardware has nothing to report. UPS status is hard-float and up, so every Raspberry Pi), `386`, `riscv64`.
optional and picked up from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is
installed.
```sh ```sh
make build # web + Go, or: make build-arm64 for a cross-compile sudo ./hamlab-linux-amd64 install # that's the whole install
./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
``` ```
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. Terminals run as the user the service runs as, with that user's login shell.
## Managing several machines ## Managing several machines
Install Ham Lab on each host. On the ones the hub should reach, add `"tls": "auto"` and a Install Ham Lab on each host; on the ones the hub should reach:
LAN `listen` address to `config.json` (a self-signed certificate is generated on first `hamlab install --listen <lan-ip>:8100 --tls auto` (a self-signed certificate is generated
start). Then on the hub, open **Machines**, enter a name, the address 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 (`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 certificate, keeps an access token, and every tab works against the chosen machine
through the switcher at the top of the rail. 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 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 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. 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 Mixed architectures are fine: `make deploy` also cross-compiles every other architecture,
each machine the build for its own 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. - On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager.
- Installable as a PWA. - Installable as a PWA.
+116 -8
View File
@@ -6,7 +6,9 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io/fs"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -15,6 +17,7 @@ import (
"runtime" "runtime"
"runtime/debug" "runtime/debug"
"strings" "strings"
"sync/atomic"
"syscall" "syscall"
"time" "time"
@@ -28,6 +31,7 @@ import (
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/nodes" "hamlab/internal/nodes"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/setup"
"hamlab/internal/storage" "hamlab/internal/storage"
"hamlab/internal/store" "hamlab/internal/store"
"hamlab/internal/tasks" "hamlab/internal/tasks"
@@ -42,12 +46,21 @@ var version = "dev"
func main() { func main() {
defaultCfg := os.Getenv("HAMLAB_CONFIG") defaultCfg := os.Getenv("HAMLAB_CONFIG")
if defaultCfg == "" { if defaultCfg == "" {
home, _ := os.UserHomeDir() defaultCfg = config.DefaultPath()
defaultCfg = filepath.Join(home, "apps", "hamlab", "config.json")
} }
cfgPath := flag.String("config", defaultCfg, "path to config.json") cfgPath := flag.String("config", defaultCfg, "path to config.json")
flag.Usage = func() { 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] <command>
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.PrintDefaults()
} }
flag.Parse() flag.Parse()
@@ -56,8 +69,40 @@ func main() {
if flag.NArg() > 0 { if flag.NArg() > 0 {
cmd = flag.Arg(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)) 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) cfg, err := config.Load(*cfgPath)
if err != nil { if err != nil {
@@ -66,6 +111,14 @@ func main() {
switch cmd { switch cmd {
case "version": case "version":
fmt.Println("hamlab", 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": case "passwd":
if err := passwd(cfg); err != nil { if err := passwd(cfg); err != nil {
log.Fatal(err) log.Fatal(err)
@@ -203,12 +256,28 @@ func serve(cfg *config.Config) error {
return fmt.Errorf("tls: %w", err) 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) srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, files.Local{}, sudoFS, nm, fingerprint, web.FS(), version)
hs := &http.Server{ hs := &http.Server{
Addr: cfg.Listen,
Handler: srv.Handler(), Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second, 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() { go func() {
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
@@ -221,18 +290,57 @@ func serve(cfg *config.Config) error {
}() }()
if certPath != "" { if certPath != "" {
log.Printf("hamlab %s listening on https://%s as %s (shell %s) cert %s", version, cfg.Listen, u.Username, shell, fingerprint) 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 err
} }
return nil return waitIf(&restarting)
} }
log.Printf("hamlab %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell) 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 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 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 // 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. // present and /bin/sh otherwise. Systemd units don't export $SHELL.
func loginShell(username string) string { func loginShell(username string) string {
-50
View File
@@ -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
Executable
+77
View File
@@ -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-<arch> 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/tty) 2>/dev/null; then
"$bin" install "$@" </dev/tty
else
"$bin" install "$@"
fi
+30 -8
View File
@@ -2,6 +2,7 @@
package api package api
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -32,7 +33,9 @@ import (
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/nodes" "hamlab/internal/nodes"
"hamlab/internal/priv"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/setup"
"hamlab/internal/storage" "hamlab/internal/storage"
"hamlab/internal/store" "hamlab/internal/store"
"hamlab/internal/tasks" "hamlab/internal/tasks"
@@ -43,6 +46,9 @@ import (
const cookieName = "hamlab_session" const cookieName = "hamlab_session"
type Server struct { type Server struct {
// Restart replaces the running process with the binary on disk; it only returns on failure.
Restart func() error
cfg *config.Config cfg *config.Config
sessions *auth.Sessions sessions *auth.Sessions
limiter auth.Limiter limiter auth.Limiter
@@ -374,27 +380,43 @@ func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
out, err := exec.CommandContext(ctx, tmp, "version").Output() out, err := exec.CommandContext(ctx, tmp, "version").Output()
if err != nil || !strings.HasPrefix(string(out), "hamlab ") { if err != nil || !strings.HasPrefix(string(out), "hamlab ") {
os.Remove(tmp) os.Remove(tmp)
jsonErr(w, 400, "uploaded file is not a working hamlab binary") jsonErr(w, 400, "uploaded file is not a working hamlab binary for this machine ("+runtime.GOARCH+")")
return return
} }
newVersion := strings.TrimSpace(strings.TrimPrefix(string(out), "hamlab ")) newVersion := strings.TrimSpace(strings.TrimPrefix(string(out), "hamlab "))
// Builds that have `check` must also load this machine's config and state.
if msg, err := exec.CommandContext(ctx, tmp, "-config", s.cfg.Path(), "check").CombinedOutput(); err != nil && !bytes.Contains(msg, []byte("usage:")) {
os.Remove(tmp)
jsonErr(w, 400, "the new build can't start here: "+strings.TrimSpace(string(msg)))
return
}
prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev") prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev")
_ = copyFile(exe, prev) _ = copyFile(exe, prev)
install := exec.CommandContext(ctx, "sudo", "-n", "install", "-m", "0755", tmp, exe) // Atomic replace, by the new binary itself: directly when the directory is ours
if errb, err := install.CombinedOutput(); err != nil { // (root, or a user-owned prefix), else as root.
if err2 := os.Rename(tmp, exe); err2 != nil { // no sudo: try in place var msg []byte
jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(errb))) if err = setup.CopyAtomic(tmp, exe); err != nil && priv.Available() {
return full := priv.Wrap(tmp, "replace", exe)
if msg, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil { // a build from before `replace`
full = priv.Wrap("install", "-m", "0755", tmp, exe)
msg, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput()
} }
} }
os.Remove(tmp) os.Remove(tmp)
if err != nil {
jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(msg)+" "+err.Error()))
return
}
log.Printf("self-update: installed %s (was %s) from %s; restarting", newVersion, s.version, clientIP(r)) log.Printf("self-update: installed %s (was %s) from %s; restarting", newVersion, s.version, clientIP(r))
jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version}) jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version})
go func() { go func() {
time.Sleep(800 * time.Millisecond) time.Sleep(800 * time.Millisecond)
if exec.Command("sudo", "-n", "systemctl", "restart", "hamlab").Run() != nil { if s.Restart != nil {
os.Exit(0) // Restart=always brings the new binary up log.Printf("self-update: exec failed (%v); putting %s back", s.Restart(), s.version)
full := priv.Wrap(prev, "replace", exe)
_ = exec.Command(full[0], full[1:]...).Run()
} }
os.Exit(1) // the supervisor starts whatever is on disk
}() }()
} }
+22 -5
View File
@@ -9,6 +9,8 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"hamlab/internal/store"
) )
// Token is an API token another Ham Lab (a hub) uses to manage this host. Only the // Token is an API token another Ham Lab (a hub) uses to manage this host. Only the
@@ -40,6 +42,25 @@ type Config struct {
mu sync.Mutex mu sync.Mutex
} }
// Candidates lists where config.json may live under a home directory, preferred first:
// the XDG location, then ~/apps/hamlab, which installs before v0.2 used.
func Candidates(home string) []string {
return []string{filepath.Join(home, ".config", "hamlab", "config.json"), filepath.Join(home, "apps", "hamlab", "config.json")}
}
// DefaultPath is the config used when neither -config nor $HAMLAB_CONFIG says otherwise:
// whichever candidate exists, else the preferred one.
func DefaultPath() string {
home, _ := os.UserHomeDir()
c := Candidates(home)
for _, p := range c {
if _, err := os.Stat(p); err == nil {
return p
}
}
return c[0]
}
func Default(path string) *Config { func Default(path string) *Config {
return &Config{Listen: "127.0.0.1:8100", path: path} return &Config{Listen: "127.0.0.1:8100", path: path}
} }
@@ -77,11 +98,7 @@ func (c *Config) save() error {
if err != nil { if err != nil {
return err return err
} }
tmp := c.path + ".tmp" return store.WriteFileAtomic(c.path, append(b, '\n'), 0o600)
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, c.path)
} }
// Update applies fn under the lock and saves. // Update applies fn under the lock and saves.
+11 -9
View File
@@ -24,6 +24,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"hamlab/internal/priv"
) )
type Job struct { type Job struct {
@@ -147,7 +149,7 @@ func runLines(ctx context.Context, fn func(line []byte), args ...string) error {
func (m *Manager) sudoArgs(args ...string) []string { func (m *Manager) sudoArgs(args ...string) []string {
if m.sudo { if m.sudo {
return append([]string{"sudo", "-n"}, args...) return priv.Wrap(args...)
} }
return args return args
} }
@@ -326,7 +328,7 @@ func (m *Manager) readSource(ctx context.Context, s source) (string, error) {
if !m.sudo { if !m.sudo {
return "", errors.New("needs sudo") return "", errors.New("needs sudo")
} }
out, err := run(ctx, "", "sudo", "-n", "crontab", "-u", u, "-l") out, err := run(ctx, "", priv.Wrap("crontab", "-u", u, "-l")...)
if err != nil && strings.Contains(err.Error(), "no crontab") { if err != nil && strings.Contains(err.Error(), "no crontab") {
return "", nil return "", nil
} }
@@ -334,7 +336,7 @@ func (m *Manager) readSource(ctx context.Context, s source) (string, error) {
} }
b, err := os.ReadFile(s.name) b, err := os.ReadFile(s.name)
if err != nil && m.sudo { if err != nil && m.sudo {
out, err2 := run(ctx, "", "sudo", "-n", "cat", s.name) out, err2 := run(ctx, "", priv.Wrap("cat", s.name)...)
return out, err2 return out, err2
} }
return string(b), err return string(b), err
@@ -356,7 +358,7 @@ func (m *Manager) sources(ctx context.Context) []source {
users := map[string]bool{} users := map[string]bool{}
if m.sudo { if m.sudo {
for _, dir := range []string{"/var/spool/cron/crontabs", "/var/spool/cron"} { for _, dir := range []string{"/var/spool/cron/crontabs", "/var/spool/cron"} {
if out, err := run(ctx, "", "sudo", "-n", "ls", "-1", dir); err == nil { if out, err := run(ctx, "", priv.Wrap("ls", "-1", dir)...); err == nil {
for _, n := range strings.Split(out, "\n") { for _, n := range strings.Split(out, "\n") {
if n = strings.TrimSpace(n); userRe.MatchString(n) { if n = strings.TrimSpace(n); userRe.MatchString(n) {
users[n] = true users[n] = true
@@ -441,7 +443,7 @@ func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
args = append(args, "--since", "-30d", "-n", "20000") args = append(args, "--since", "-30d", "-n", "20000")
} }
if m.journalSudo { if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...) args = priv.Wrap(args...)
} }
seen := m.logging seen := m.logging
_ = runLines(ctx, func(line []byte) { _ = runLines(ctx, func(line []byte) {
@@ -478,7 +480,7 @@ func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
func (m *Manager) JobLogs(ctx context.Context, j Job, n int) ([]map[string]any, error) { func (m *Manager) JobLogs(ctx context.Context, j Job, n int) ([]map[string]any, error) {
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "-n", "20000"} args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "-n", "20000"}
if m.journalSudo { if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...) args = priv.Wrap(args...)
} }
var res []map[string]any var res []map[string]any
needle := "(" + j.User + ") CMD (" + j.Command + ")" needle := "(" + j.User + ") CMD (" + j.Command + ")"
@@ -658,13 +660,13 @@ func (m *Manager) WriteSource(ctx context.Context, name, text string) error {
_, err := run(ctx, text, "crontab", "-") _, err := run(ctx, text, "crontab", "-")
return err return err
} }
_, err := run(ctx, text, "sudo", "-n", "crontab", "-u", s.user, "-") _, err := run(ctx, text, priv.Wrap("crontab", "-u", s.user, "-")...)
return err return err
} }
if name != "/etc/crontab" && !strings.HasPrefix(name, "/etc/cron.d/") { if name != "/etc/crontab" && !strings.HasPrefix(name, "/etc/cron.d/") {
return errors.New("refusing to write outside /etc/crontab and /etc/cron.d") return errors.New("refusing to write outside /etc/crontab and /etc/cron.d")
} }
_, err := run(ctx, text, "sudo", "-n", "tee", name) _, err := run(ctx, text, priv.Wrap("tee", name)...)
m.mu.Lock() m.mu.Lock()
m.lastAt = time.Time{} m.lastAt = time.Time{}
m.mu.Unlock() m.mu.Unlock()
@@ -708,7 +710,7 @@ func (m *Manager) StartRun(ctx context.Context, j Job) (*Run, error) {
cmdline, stdin := cronPercent(j.Command) cmdline, stdin := cronPercent(j.Command)
args := []string{"sh", "-c", cmdline} args := []string{"sh", "-c", cmdline}
if j.User != m.self { if j.User != m.self {
args = append([]string{"sudo", "-n", "-u", j.User, "-H"}, args...) args = priv.AsUser(j.User, args...)
} }
r := &Run{ID: jobID("run", strconv.FormatInt(time.Now().UnixNano(), 10)), JobID: j.ID, Command: j.Command, User: j.User, Started: time.Now(), Running: true} r := &Run{ID: jobID("run", strconv.FormatInt(time.Now().UnixNano(), 10)), JobID: j.ID, Command: j.Command, User: j.User, Started: time.Now(), Running: true}
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+3 -1
View File
@@ -22,6 +22,8 @@ import (
"strings" "strings"
"syscall" "syscall"
"time" "time"
"hamlab/internal/priv"
) )
type Entry struct { type Entry struct {
@@ -278,7 +280,7 @@ func (Local) Chmod(_ context.Context, path string, perm os.FileMode) error {
type Sudo struct{ Self string } type Sudo struct{ Self string }
func (s Sudo) cmd(ctx context.Context, args ...string) *exec.Cmd { func (s Sudo) cmd(ctx context.Context, args ...string) *exec.Cmd {
full := append([]string{"sudo", "-n", s.Self, "fs"}, args...) full := priv.Wrap(append([]string{s.Self, "fs"}, args...)...)
return exec.CommandContext(ctx, full[0], full[1:]...) return exec.CommandContext(ctx, full[0], full[1:]...)
} }
+9 -3
View File
@@ -24,6 +24,8 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"hamlab/internal/priv"
) )
const ( const (
@@ -732,7 +734,10 @@ func leadingNum(s string) float64 {
func upsPwrstat() *UPS { func upsPwrstat() *UPS {
out, ok := runOut("pwrstat", "-status") out, ok := runOut("pwrstat", "-status")
if !ok { if !ok {
if out, ok = runOut("sudo", "-n", "pwrstat", "-status"); !ok { if !priv.Available() {
return nil
}
if out, ok = runOut(priv.Wrap("pwrstat", "-status")...); !ok {
return nil return nil
} }
} }
@@ -1106,8 +1111,9 @@ func Signal(pid int, sig syscall.Signal) error {
} else if !errors.Is(err, syscall.EPERM) { } else if !errors.Is(err, syscall.EPERM) {
return err return err
} }
if lookPath("sudo") { if priv.Available() {
if err := exec.Command("sudo", "-n", "kill", "-"+strconv.Itoa(int(sig)), strconv.Itoa(pid)).Run(); err == nil { k := priv.Wrap("kill", "-"+strconv.Itoa(int(sig)), strconv.Itoa(pid))
if err := exec.Command(k[0], k[1:]...).Run(); err == nil {
return nil return nil
} }
} }
+78
View File
@@ -0,0 +1,78 @@
// Package priv is the one place that knows how this daemon gets root: it already is root,
// or it has passwordless sudo, or passwordless doas (Alpine, some BSD-minded setups), or
// it has nothing and the privileged features stay off.
package priv
import (
"context"
"os"
"os/exec"
"strings"
"sync"
"time"
)
var (
once sync.Once
mode string // "root" | "sudo" | "doas" | ""
prefix []string
)
func detect() {
if os.Geteuid() == 0 {
mode = "root"
return
}
for _, tool := range []string{"sudo", "doas"} {
if _, err := exec.LookPath(tool); err != nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
err := exec.CommandContext(ctx, tool, "-n", "true").Run()
cancel()
if err == nil {
mode, prefix = tool, []string{tool, "-n"}
return
}
}
}
// Mode reports how root is reached: "root", "sudo", "doas", or "" when it can't be.
func Mode() string { once.Do(detect); return mode }
// Available reports whether commands can be run as root without a prompt.
func Available() bool { return Mode() != "" }
// Wrap returns args prefixed so they run as root. Without privilege it returns args
// unchanged; callers gate on Available() where that matters.
func Wrap(args ...string) []string {
once.Do(detect)
return append(append([]string{}, prefix...), args...)
}
// AsUser returns args prefixed so they run as another user with that user's HOME.
func AsUser(user string, args ...string) []string {
switch Mode() {
case "sudo":
return append([]string{"sudo", "-n", "-u", user, "-H"}, args...)
case "doas":
return append([]string{"doas", "-n", "-u", user}, args...)
case "root":
if user == "root" {
return args
}
// su exists everywhere (util-linux, shadow, busybox), needs no password from root
// and sets HOME for the target user.
return []string{"su", "-s", "/bin/sh", user, "-c", Quote(args)}
}
return args
}
// Quote joins args into one POSIX shell command line.
func Quote(args []string) string {
q := make([]string, len(args))
for i, a := range args {
q[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
}
return strings.Join(q, " ")
}
+60 -10
View File
@@ -13,6 +13,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"regexp" "regexp"
"sort" "sort"
@@ -21,6 +22,8 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"hamlab/internal/priv"
) )
type Unit struct { type Unit struct {
@@ -65,11 +68,20 @@ type Manager struct {
cacheAt time.Time cacheAt time.Time
} }
// ErrNoSystemd is what the Services tab says on OpenRC, runit and friends.
var ErrNoSystemd = errors.New("this host doesn't run systemd, which the Services tab needs")
// HasSystemd reports whether systemd is the running init (the test sd_booted() uses).
func HasSystemd() bool {
_, err := os.Stat("/run/systemd/system")
return err == nil
}
func New() *Manager { func New() *Manager {
m := &Manager{} m := &Manager{}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
m.sudo = exec.CommandContext(ctx, "sudo", "-n", "true").Run() == nil m.sudo = priv.Available()
// Prefer reading the journal as ourselves: a plain child can be killed cleanly when a // Prefer reading the journal as ourselves: a plain child can be killed cleanly when a
// tail ends, whereas a root journalctl under sudo can only be reached with sudo kill. // tail ends, whereas a root journalctl under sudo can only be reached with sudo kill.
var errb bytes.Buffer var errb bytes.Buffer
@@ -106,6 +118,9 @@ func run(ctx context.Context, args ...string) (string, error) {
// List merges `list-units --all` (runtime state) with `list-unit-files` (enablement). // List merges `list-units --all` (runtime state) with `list-unit-files` (enablement).
// Cached for a few seconds because the UI polls it. // Cached for a few seconds because the UI polls it.
func (m *Manager) List(ctx context.Context) ([]Unit, error) { func (m *Manager) List(ctx context.Context) ([]Unit, error) {
if !HasSystemd() {
return nil, ErrNoSystemd
}
m.mu.Lock() m.mu.Lock()
if time.Since(m.cacheAt) < 200*time.Millisecond && m.cache != nil { // coalesce simultaneous clients only if time.Since(m.cacheAt) < 200*time.Millisecond && m.cache != nil { // coalesce simultaneous clients only
c := m.cache c := m.cache
@@ -126,15 +141,18 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
var units []struct{ Unit, Load, Active, Sub, Description string } var units []listedUnit
if err := json.Unmarshal([]byte(out), &units); err != nil { if err := json.Unmarshal([]byte(out), &units); err != nil {
return nil, fmt.Errorf("parse list-units: %w", err) // systemd before 246 (RHEL 8, Ubuntu 20.04) ignores --output=json here and prints
// the table; the columns have been the same since the beginning.
if units = parseUnitTable(out); len(units) == 0 {
return nil, fmt.Errorf("parse list-units: %w", err)
}
} }
var uf []struct { var uf []listedFile
UnitFile string `json:"unit_file"` if json.Unmarshal([]byte(files), &uf) != nil {
State string `json:"state"` uf = parseFileTable(files)
} }
_ = json.Unmarshal([]byte(files), &uf)
enabled := map[string]string{} enabled := map[string]string{}
for _, f := range uf { for _, f := range uf {
enabled[f.UnitFile] = f.State enabled[f.UnitFile] = f.State
@@ -165,6 +183,37 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) {
return list, nil return list, nil
} }
type listedUnit struct{ Unit, Load, Active, Sub, Description string }
type listedFile struct {
UnitFile string `json:"unit_file"`
State string `json:"state"`
}
// parseUnitTable reads the plain `systemctl list-units` table: UNIT LOAD ACTIVE SUB DESCRIPTION…
func parseUnitTable(text string) []listedUnit {
var units []listedUnit
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(strings.TrimLeft(line, "●*× "))
if len(f) < 4 || !strings.HasSuffix(f[0], ".service") {
continue
}
units = append(units, listedUnit{Unit: f[0], Load: f[1], Active: f[2], Sub: f[3], Description: strings.Join(f[4:], " ")})
}
return units
}
// parseFileTable reads the plain `systemctl list-unit-files` table: UNIT FILE, STATE[, PRESET]
func parseFileTable(text string) []listedFile {
var uf []listedFile
for _, line := range strings.Split(text, "\n") {
if f := strings.Fields(line); len(f) >= 2 && strings.HasSuffix(f[0], ".service") {
uf = append(uf, listedFile{UnitFile: f[0], State: f[1]})
}
}
return uf
}
func (m *Manager) invalidate() { func (m *Manager) invalidate() {
m.mu.Lock() m.mu.Lock()
m.cacheAt = time.Time{} m.cacheAt = time.Time{}
@@ -243,7 +292,7 @@ func parseEntry(line []byte) (Entry, bool) {
func (m *Manager) journalArgs(unit string, n int) []string { func (m *Manager) journalArgs(unit string, n int) []string {
args := []string{"journalctl", "-u", unit, "-n", strconv.Itoa(n), "-o", "json", "--no-pager", "--all"} args := []string{"journalctl", "-u", unit, "-n", strconv.Itoa(n), "-o", "json", "--no-pager", "--all"}
if m.journalSudo { if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...) args = priv.Wrap(args...)
} }
return 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 { cmd.Cancel = func() error {
pg := "-" + strconv.Itoa(cmd.Process.Pid) pg := "-" + strconv.Itoa(cmd.Process.Pid)
if m.journalSudo { // children are root's; only sudo can signal them 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) 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"} args := []string{"systemctl", action, unit, "--no-pager"}
if m.sudo { if m.sudo {
args = append([]string{"sudo", "-n"}, args...) args = priv.Wrap(args...)
} }
_, err := run(ctx, args...) _, err := run(ctx, args...)
m.invalidate() m.invalidate()
+14
View File
@@ -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)
}
}
+28
View File
@@ -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
}
@@ -1,20 +1,23 @@
[Unit] [Unit]
Description=Ham Lab web console Description=Ham Lab web console
After=network.target After=network.target
# never give up restarting
StartLimitIntervalSec=0
[Service] [Service]
Type=simple Type=simple
User=@USER@ User=@USER@
Group=@GROUP@ Group=@GROUP@
# read the system journal without sudo (keeps the daemon's own log clean) # read the system journal without sudo (keeps the daemon's own log clean)
SupplementaryGroups=systemd-journal adm SupplementaryGroups=@GROUPS@
WorkingDirectory=@HOME@ WorkingDirectory=-@HOME@
Environment=HAMLAB_CONFIG=@HOME@/apps/hamlab/config.json Environment=HAMLAB_CONFIG=@CONFIG@
ExecStart=@PREFIX@/hamlab serve ExecStart=@BIN@ serve
Restart=always Restart=always
RestartSec=3 RestartSec=3
KillMode=control-group KillMode=control-group
TimeoutStopSec=10 TimeoutStopSec=10
LimitNOFILE=65536
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+678
View File
@@ -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() }
+73
View File
@@ -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)
}
}
+79 -7
View File
@@ -19,6 +19,8 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"hamlab/internal/priv"
) )
// ---- block devices ---- // ---- block devices ----
@@ -75,15 +77,14 @@ func str(p *string) string {
func Devices(ctx context.Context) ([]Device, error) { func Devices(ctx context.Context) ([]Device, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second) ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel() 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 { var root struct {
Blockdevices []lsblkNode `json:"blockdevices"` Blockdevices []lsblkNode `json:"blockdevices"`
} }
if err := json.Unmarshal(out, &root); err != nil { // lsblk knows labels and stacked devices (LVM, LUKS, md); without it (Alpine and other
return nil, err // 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{} devs := []Device{}
for _, n := range root.Blockdevices { for _, n := range root.Blockdevices {
@@ -127,6 +128,77 @@ func Devices(ctx context.Context) ([]Device, error) {
return devs, nil 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+$`) var nsRe = regexp.MustCompile(`^(nvme\d+)n\d+$`)
// foldNVMe merges nvme0n1, nvme0n2, … into one physical device "nvme0": one SSD, one // 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 args[0] = p
} }
if m.sudo { if m.sudo {
args = append([]string{"sudo", "-n"}, args...) args = priv.Wrap(args...)
} }
ctx, cancel := context.WithTimeout(ctx, 30*time.Second) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
+22
View File
@@ -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))
}
}
}
+27 -4
View File
@@ -5,6 +5,7 @@ package store
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"log"
"os" "os"
"sync" "sync"
"time" "time"
@@ -35,8 +36,12 @@ func Open(path string) (*Store, error) {
return nil, err return nil, err
} }
if err == nil { 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 { 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 { if s.state.Sessions == nil {
@@ -65,9 +70,27 @@ func (s *Store) save() error {
if err != nil { if err != nil {
return err return err
} }
tmp := s.path + ".tmp" return WriteFileAtomic(s.path, b, 0o600)
if err := os.WriteFile(tmp, b, 0o600); err != nil { }
// 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 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)
} }
+6 -2
View File
@@ -9,6 +9,7 @@ import (
"os/exec" "os/exec"
"sort" "sort"
"sync" "sync"
"sync/atomic"
"syscall" "syscall"
"time" "time"
@@ -68,6 +69,7 @@ type Manager struct {
shell string shell string
home string home string
OnChange func([]Info) // called (without locks held) when the list changes OnChange func([]Info) // called (without locks held) when the list changes
closing atomic.Bool
} }
func NewManager(shell, home string) *Manager { func NewManager(shell, home string) *Manager {
@@ -283,13 +285,15 @@ func (m *Manager) Close(id string) error {
} }
func (m *Manager) changed() { func (m *Manager) changed() {
if m.OnChange != nil { if m.OnChange != nil && !m.closing.Load() {
m.OnChange(m.List()) 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() { func (m *Manager) Shutdown() {
m.closing.Store(true)
for _, s := range m.List() { for _, s := range m.List() {
_ = m.Close(s.ID) _ = m.Close(s.ID)
} }
+174 -34
View File
@@ -2,8 +2,9 @@
// was last refreshed, whether a reboot is pending, and the refresh/upgrade actions, // 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. // 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 // Supported: apt (Debian, Ubuntu), dnf (Fedora, RHEL family), zypper (openSUSE, SLES),
// something useful on those hosts; upgrades there are a later addition. // 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 package updates
import ( import (
@@ -18,6 +19,7 @@ import (
"strings" "strings"
"time" "time"
"hamlab/internal/priv"
"hamlab/internal/tasks" "hamlab/internal/tasks"
) )
@@ -31,8 +33,9 @@ type Package struct {
} }
type Status 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"` CanUpgrade bool `json:"can_upgrade"`
Selective bool `json:"selective"` // individual packages can be upgraded
Packages []Package `json:"packages"` Packages []Package `json:"packages"`
SecurityCount int `json:"security_count"` SecurityCount int `json:"security_count"`
LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms
@@ -52,7 +55,7 @@ type Manager struct {
func New(sudo bool, tm *tasks.Manager) *Manager { func New(sudo bool, tm *tasks.Manager) *Manager {
m := &Manager{sudo: sudo, tasks: tm} 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 { if _, err := exec.LookPath(pm); err == nil {
m.pm = strings.TrimSuffix(pm, "-get") m.pm = strings.TrimSuffix(pm, "-get")
break break
@@ -63,7 +66,7 @@ func New(sudo bool, tm *tasks.Manager) *Manager {
func (m *Manager) sudoArgs(args ...string) []string { func (m *Manager) sudoArgs(args ...string) []string {
if m.sudo { if m.sudo {
return append([]string{"sudo", "-n"}, args...) return priv.Wrap(args...)
} }
return args return args
} }
@@ -145,9 +148,10 @@ func (m *Manager) Status(ctx context.Context) Status {
if t, ok := m.tasks.RunningOf("updates"); ok { if t, ok := m.tasks.RunningOf("updates"); ok {
s.RunningTask = t.ID s.RunningTask = t.ID
} }
s.CanUpgrade = m.sudo && m.pm != ""
s.Selective = m.pm != "pacman"
switch m.pm { switch m.pm {
case "apt": case "apt":
s.CanUpgrade = m.sudo
s.Packages = aptUpgradable(ctx) s.Packages = aptUpgradable(ctx)
s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp") s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp")
if s.LastRefresh == 0 { if s.LastRefresh == 0 {
@@ -162,9 +166,18 @@ func (m *Manager) Status(ctx context.Context) Status {
s.Packages = dnfUpgradable(ctx) s.Packages = dnfUpgradable(ctx)
s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf") s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf")
s.LastUpgrade = dnfLastUpgrade(ctx) 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": case "pacman":
s.Packages = pacmanUpgradable(ctx) 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 { for _, p := range s.Packages {
if p.Security { if p.Security {
@@ -289,7 +302,7 @@ func aptAutoUpgrades(ctx context.Context) bool {
return strings.Contains(text, `"1"`) return strings.Contains(text, `"1"`)
} }
// ---- dnf / pacman (read-only) ---- // ---- dnf / zypper / pacman / apk ----
func dnfUpgradable(ctx context.Context) []Package { func dnfUpgradable(ctx context.Context) []Package {
text, _ := out(ctx, "dnf", "-q", "check-update") // exit 100 when updates exist; output still parses 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 { if pkgs == nil {
return []Package{} 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 return pkgs
} }
@@ -323,44 +349,148 @@ func pacmanUpgradable(ctx context.Context) []Package {
return pkgs 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) { // apkUpgradable parses `apk version -l '<'`: "musl-1.2.4-r2 < 1.2.5-r0".
if m.pm != "apt" { func apkUpgradable(ctx context.Context) []Package {
return nil, errors.New("upgrades are only supported for apt so far") 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 { 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 { if t, ok := m.tasks.RunningOf("updates"); ok {
return nil, errors.New("another package task is running: " + t.Title) 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:]...) 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) { 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 // rolling reports a distro whose supported upgrade path is a full distribution upgrade.
// answered "keep the current one" so the run can never block on a question. 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) { 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 err := checkNames(pkgs); err != nil {
if len(pkgs) == 0 { return nil, err
return m.start("apt-get dist-upgrade", append(base, "dist-upgrade")...)
} }
for _, p := range pkgs { all := len(pkgs) == 0
if !pkgNameRe.MatchString(p) { var args []string
return nil, errors.New("bad package name: " + p) 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") return m.start(append(args, pkgs...)...)
args = append(args, pkgs...)
return m.start("apt-get upgrade "+strings.Join(pkgs, " "), args...)
} }
// Install installs packages (used by other tabs for optional tooling like smartmontools). // 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 { if len(pkgs) == 0 {
return nil, errors.New("no packages") return nil, errors.New("no packages")
} }
for _, p := range pkgs { if err := checkNames(pkgs); err != nil {
if !pkgNameRe.MatchString(p) { return nil, err
return nil, errors.New("bad package name: " + p)
}
} }
args := append([]string{"apt-get", "-y", "--no-install-recommends", "install"}, pkgs...) var args []string
return m.start("apt-get install "+strings.Join(pkgs, " "), args...) 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) { func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) {
+3 -4
View File
@@ -62,9 +62,8 @@ export default function Updates() {
return ( return (
<div className="dash updates"> <div className="dash updates">
{!st.manager && <div className="notice">No supported package manager found (looked for apt, dnf, pacman).</div>} {!st.manager && <div className="notice">No supported package manager found (looked for apt, dnf, zypper, pacman, apk).</div>}
{st.manager && st.manager !== 'apt' && <div className="notice">{st.manager}: listing only; upgrading from here isn't supported yet.</div>} {st.manager && !st.sudo && <div className="notice">Read-only: refreshing and upgrading need root. Run Ham Lab as root, or give its user passwordless sudo or doas.</div>}
{st.manager === 'apt' && !st.sudo && <div className="notice">Read-only: refreshing and upgrading need passwordless sudo on the host.</div>}
{st.reboot_needed && ( {st.reboot_needed && (
<div className="notice attn"> <div className="notice attn">
<b>Reboot required</b>{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}</>} — use the ⏻ button in the header. <b>Reboot required</b>{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}</>} — use the ⏻ button in the header.
@@ -82,7 +81,7 @@ export default function Updates() {
<div className="svc-actions"> <div className="svc-actions">
<Btn className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</Btn> <Btn className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</Btn>
<Btn className="btn on" disabled={!canAct || st.packages.length === 0} confirm={`Upgrade all ${st.packages.length} packages?`} onClick={() => start(() => api.upgrade([]))}>Upgrade all</Btn> <Btn className="btn on" disabled={!canAct || st.packages.length === 0} confirm={`Upgrade all ${st.packages.length} packages?`} onClick={() => start(() => api.upgrade([]))}>Upgrade all</Btn>
<Btn className="btn" disabled={!canAct || sel.size === 0} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</Btn> <Btn className="btn" disabled={!canAct || sel.size === 0 || !st.selective} title={st.selective ? undefined : `${st.manager} upgrades everything or nothing`} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</Btn>
<span className="spacer" /> <span className="spacer" />
{st.packages.length > 0 && <button className="btn" onClick={() => setSel(sel.size === st.packages.length ? new Set() : new Set(st.packages.map((p) => p.name)))}>{sel.size === st.packages.length ? 'None' : 'All'}</button>} {st.packages.length > 0 && <button className="btn" onClick={() => setSel(sel.size === st.packages.length ? new Set() : new Set(st.packages.map((p) => p.name)))}>{sel.size === st.packages.length ? 'None' : 'All'}</button>}
<button className="btn" title="reload" disabled={reloading} onClick={load}>{reloading ? <Spinner /> : '↻'}</button> <button className="btn" title="reload" disabled={reloading} onClick={load}>{reloading ? <Spinner /> : '↻'}</button>
+1 -1
View File
@@ -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 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 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 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[] } 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[] }