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/
web/node_modules/
web/dist/
dist/
+33 -12
View File
@@ -3,33 +3,54 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
GO ?= go
PREFIX ?= /usr/local/bin
.PHONY: all web build dev deploy clean
# every architecture a release carries; arm is ARMv6 hard-float, so it covers every Raspberry Pi
ARCHES ?= amd64 arm64 arm 386 riscv64
LDFLAGS = -s -w -X main.version=$(VERSION)
# static, no cgo: the same file runs on glibc, musl (Alpine) and anything else with a Linux kernel
GOBUILD = CGO_ENABLED=0 GOOS=linux GOARM=6 $(GO) build -trimpath -ldflags "$(LDFLAGS)"
.PHONY: all web build go dev cross release deploy push-nodes push-nodes-force test clean
all: build
web:
cd web && npm ci --no-audit --no-fund && npm run build
build: web
$(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab
# cross-compile for another Linux box (static binary, no cgo): make build-arm64 / build-amd64
build-%: web
CGO_ENABLED=0 GOOS=linux GOARCH=$* $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab-linux-$* ./cmd/hamlab
build: web go
# backend only (assumes web/dist already built)
go:
$(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab
$(GOBUILD) -o bin/hamlab ./cmd/hamlab
# one other architecture: make build-arm64, build-arm, build-386, build-riscv64, build-amd64
build-%: web
GOARCH=$* $(GOBUILD) -o bin/hamlab-linux-$* ./cmd/hamlab
dev:
cd web && npm run dev
# arm64 sibling for aarch64 machines (Mac mini); the hub pushes it to them
# siblings for every other architecture; installed beside the binary, and the hub pushes
# each managed machine the one that matches it
cross:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab-linux-arm64 ./cmd/hamlab
@for a in $(filter-out $(shell $(GO) env GOARCH),$(ARCHES)); do \
echo "build linux/$$a"; GOARCH=$$a $(GOBUILD) -o bin/hamlab-linux-$$a ./cmd/hamlab || exit 1; done
# dist/: one static binary per architecture, install.sh, SHA256SUMS. Serve the directory
# anywhere (or attach it to a release) and install.sh does the rest.
release: web
rm -rf dist && mkdir -p dist
@for a in $(ARCHES); do \
echo "build linux/$$a"; GOARCH=$$a $(GOBUILD) -o dist/hamlab-linux-$$a ./cmd/hamlab || exit 1; done
sed 's|@VERSION@|$(VERSION)|' install.sh > dist/install.sh && chmod +x dist/install.sh
cd dist && sha256sum hamlab-linux-* install.sh > SHA256SUMS
test:
$(GO) vet ./... && $(GO) test ./...
sh -n install.sh
# installs here (keeping the config, user and password), then updates every managed machine
deploy: build cross
./deploy/install.sh
./bin/hamlab install --prefix $(PREFIX)
@sleep 1; $(PREFIX)/hamlab push-nodes || true
# push the installed binary to every managed machine (what deploy does last)
@@ -41,4 +62,4 @@ push-nodes-force:
$(PREFIX)/hamlab push-nodes --force
clean:
rm -rf bin internal/web/dist web/dist
rm -rf bin dist internal/web/dist web/dist
+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
`hamlab-arm64`, and the hub picks the sibling matching a node's `/api/me.arch` when
pushing — the Mac mini (Fedora Asahi, aarch64, node `mac`) updates like the rest.
- **Portability + install** — **shipped 2026-09-18** (`internal/setup`, `internal/priv`,
`install.sh`, `make release`): the binary installs itself (`hamlab install` /
`uninstall`; systemd or OpenRC; root, sudo or doas), all builds static on amd64, arm64,
arm, 386 and riscv64, `install.sh` as the curl-able bootstrap with checksums. Every
privileged call goes through `priv` instead of a literal `sudo -n`. Updates tab acts on
dnf, zypper, pacman and apk as well as apt. Services list falls back to the plain
table on systemd < 246; Disks falls back to `/sys/block` without `lsblk`. Listener
uses `IP_FREEBIND`. Self-update: pre-flight `check` against the live config, atomic
replace, exec in place. Config default moved to `~/.config/hamlab` (existing
`~/apps/hamlab` still found by the installer and the CLI). Tested in
containers: Debian 12, Ubuntu 22.04, Fedora 40, Rocky 8, openSUSE Leap 15.5, Alpine
3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. Not done: an OpenRC
backend for the Services tab; runit/s6/sysvinit service files; public hosting for
`install.sh` (the Gitea repo is private, so the one-liner needs `HAMLAB_TOKEN`).
- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session
list + revoke, optional NPM access list restricting to WireGuard/LAN.
- **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from
+51 -16
View File
@@ -1,6 +1,6 @@
# Ham Lab
Web-based Linux management console for bare-metal Debian, in the style of the Proxmox
Web-based Linux management console for bare-metal Linux, in the style of the Proxmox
web UI. Named after Ham the cat (see also HamTV).
Status: **v0.1** — persistent web terminals and a live Dashboard tab. Scope and roadmap in
@@ -8,27 +8,62 @@ Status: **v0.1** — persistent web terminals and a live Dashboard tab. Scope an
## Runs on any Linux box
One static Go binary with the UI embedded; no cgo, no Node at runtime, no agents. The
Dashboard reads `/proc` and `/sys` directly (CPU, memory, hwmon or thermal-zone temps,
fans, NICs, block devices, mounts) so it works the same on x86 and ARM, bare metal or VM,
and just leaves a section empty when the hardware has nothing to report. UPS status is
optional and picked up from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is
installed.
One static Go binary with the UI embedded: no cgo, no libc, no Node at runtime, no agents.
The same file runs on Debian, Ubuntu, Fedora, RHEL/Rocky/Alma, openSUSE, Arch and Alpine
(musl), on bare metal, VMs and containers. Builds: `amd64`, `arm64`, `arm` (ARMv6
hard-float and up, so every Raspberry Pi), `386`, `riscv64`.
```sh
make build # web + Go, or: make build-arm64 for a cross-compile
./deploy/install.sh # installs the binary + a systemd unit for the invoking user
hamlab passwd # set the login password (config in ~/apps/hamlab/)
sudo systemctl start hamlab # listens on 127.0.0.1:8100 by default; set "listen" in config.json
sudo ./hamlab-linux-amd64 install # that's the whole install
```
or, from wherever `make release` output is served:
```sh
curl -fsSL "$HAMLAB_URL/install.sh" | sh
curl -fsSL "$HAMLAB_URL/install.sh" | sh -s -- --listen 0.0.0.0:8100 --tls auto
```
`install.sh` only picks the build for `uname -m`, checks it against `SHA256SUMS` and runs
`hamlab install`; run it beside the `hamlab-linux-*` files and it installs offline.
`hamlab install`:
- copies the binary to `/usr/local/bin` (`--prefix`), atomically;
- gets root through `sudo` or `doas` if you aren't root already, and runs the console as
**you** (`--user`), so terminals are your login shell with your rights;
- writes the config to `~/.config/hamlab/` (`--data`; an existing `~/apps/hamlab/` is kept)
and asks for the login password, or generates one and prints it once
(`--password-stdin` for scripts);
- registers a boot-time service with **systemd** or **OpenRC**, naming only groups that
exist on the host, and waits for `/healthz`; with neither init it prints the one line
to put under your own supervisor;
- says so when firewalld or ufw will block the port (`--open-firewall` opens it).
Re-running it upgrades in place and keeps config, user and password. `hamlab uninstall
[--purge]` reverses it.
What adapts to the host, rather than assuming Debian:
| | |
|---|---|
| Root | already root, else passwordless `sudo`, else passwordless `doas`; without any, the console is read-only where root is needed and says so |
| Updates tab | apt, dnf, zypper, pacman, apk: list, refresh, upgrade, install (pacman: all or nothing) |
| Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd |
| Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there |
| Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed |
| Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start |
| Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept |
Building: `make build` (web + Go), `make release` (every architecture, `install.sh` and
`SHA256SUMS` in `dist/`), `make test`.
Terminals run as the user the service runs as, with that user's login shell.
## Managing several machines
Install Ham Lab on each host. On the ones the hub should reach, add `"tls": "auto"` and a
LAN `listen` address to `config.json` (a self-signed certificate is generated on first
start). Then on the hub, open **Machines**, enter a name, the address
Install Ham Lab on each host; on the ones the hub should reach:
`hamlab install --listen <lan-ip>:8100 --tls auto` (a self-signed certificate is generated
on first start). Then on the hub, open **Machines**, enter a name, the address
(`https://host:8100`) and that machine's Ham Lab password once. The hub pins the
certificate, keeps an access token, and every tab works against the chosen machine
through the switcher at the top of the rail.
@@ -36,8 +71,8 @@ through the switcher at the top of the rail.
Updating: `make deploy` on the hub installs the new build there and then pushes it to
every managed machine (`hamlab push-nodes`), which install it and restart. The Machines
page flags machines running an older version and offers the same push per machine.
Mixed architectures are fine: `make deploy` also cross-compiles arm64, and the hub sends
each machine the build for its own architecture.
Mixed architectures are fine: `make deploy` also cross-compiles every other architecture,
and the hub sends each machine the build for its own.
- On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager.
- Installable as a PWA.
+116 -8
View File
@@ -6,7 +6,9 @@ import (
"errors"
"flag"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/signal"
@@ -15,6 +17,7 @@ import (
"runtime"
"runtime/debug"
"strings"
"sync/atomic"
"syscall"
"time"
@@ -28,6 +31,7 @@ import (
"hamlab/internal/metrics"
"hamlab/internal/nodes"
"hamlab/internal/services"
"hamlab/internal/setup"
"hamlab/internal/storage"
"hamlab/internal/store"
"hamlab/internal/tasks"
@@ -42,12 +46,21 @@ var version = "dev"
func main() {
defaultCfg := os.Getenv("HAMLAB_CONFIG")
if defaultCfg == "" {
home, _ := os.UserHomeDir()
defaultCfg = filepath.Join(home, "apps", "hamlab", "config.json")
defaultCfg = config.DefaultPath()
}
cfgPath := flag.String("config", defaultCfg, "path to config.json")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|push-nodes [--force]|fs …]\n")
fmt.Fprint(os.Stderr, `usage: hamlab [-config path] <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.Parse()
@@ -56,8 +69,40 @@ func main() {
if flag.NArg() > 0 {
cmd = flag.Arg(0)
}
if cmd == "fs" { // privileged helper for the Files tab (run via sudo by the daemon)
switch cmd { // commands that don't read the config
case "fs": // privileged helper for the Files tab (run as root by the daemon)
os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
case "install", "uninstall":
var err error
if cmd == "install" {
err = setup.Install(flag.Args()[1:], version)
} else {
err = setup.Uninstall(flag.Args()[1:])
}
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
if err != nil {
fmt.Fprintln(os.Stderr, "hamlab "+cmd+":", err)
os.Exit(1)
}
return
case "replace": // self-update helper (run as root by the daemon): put this binary at a path, atomically
// Only ever over a hamlab binary: this runs as root, and must not be a way to
// overwrite arbitrary files for whoever is allowed to run hamlab as root.
if flag.NArg() != 2 || !filepath.IsAbs(flag.Arg(1)) || !strings.HasPrefix(filepath.Base(flag.Arg(1)), "hamlab") {
fmt.Fprintln(os.Stderr, "hamlab replace: destination must be an absolute path to a hamlab binary")
os.Exit(2)
}
self, err := os.Executable()
if err == nil {
err = setup.CopyAtomic(self, flag.Arg(1))
}
if err != nil {
fmt.Fprintln(os.Stderr, "hamlab replace:", err)
os.Exit(1)
}
return
}
cfg, err := config.Load(*cfgPath)
if err != nil {
@@ -66,6 +111,14 @@ func main() {
switch cmd {
case "version":
fmt.Println("hamlab", version)
case "check": // a new build runs this against the live config before it replaces the old one
if _, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")); err != nil {
log.Fatal(err)
}
if _, err := fs.Stat(web.FS(), "index.html"); err != nil {
log.Fatal("embedded UI missing: ", err)
}
fmt.Println("ok")
case "passwd":
if err := passwd(cfg); err != nil {
log.Fatal(err)
@@ -203,12 +256,28 @@ func serve(cfg *config.Config) error {
return fmt.Errorf("tls: %w", err)
}
}
ln, err := listen(cfg.Listen)
if err != nil {
return err
}
srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, files.Local{}, sudoFS, nm, fingerprint, web.FS(), version)
hs := &http.Server{
Addr: cfg.Listen,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
// After a self-update: become the new binary in place. Same PID, so it works the same
// under systemd, OpenRC, or no supervisor at all. It returns only if the exec failed, by
// which point the listener and the shells are gone; the caller puts the old binary back
// and exits so the supervisor starts it.
var restarting atomic.Bool
srv.Restart = func() error {
restarting.Store(true) // Serve returns once Shutdown is called; serve() must not exit under us
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = hs.Shutdown(ctx)
mgr.Shutdown()
return syscall.Exec(self, os.Args, os.Environ())
}
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
@@ -221,18 +290,57 @@ func serve(cfg *config.Config) error {
}()
if certPath != "" {
log.Printf("hamlab %s listening on https://%s as %s (shell %s) cert %s", version, cfg.Listen, u.Username, shell, fingerprint)
if err := hs.ListenAndServeTLS(certPath, keyPath); !errors.Is(err, http.ErrServerClosed) {
if err := hs.ServeTLS(ln, certPath, keyPath); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
return waitIf(&restarting)
}
log.Printf("hamlab %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell)
if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
if err := hs.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
return err
}
return waitIf(&restarting)
}
// waitIf parks the caller while a restart is replacing the process.
func waitIf(restarting *atomic.Bool) error {
if restarting.Load() {
select {}
}
return nil
}
// listen binds the configured address with IP_FREEBIND, so a LAN address that isn't up yet
// (boot, DHCP still negotiating, cable out) doesn't stop the daemon: it binds now and
// answers as soon as the address appears.
func listen(addr string) (net.Listener, error) {
lc := net.ListenConfig{Control: func(network, _ string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
_ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_FREEBIND, 1)
})
}}
ln, err := lc.Listen(context.Background(), "tcp", addr)
if err != nil {
return nil, fmt.Errorf("listen %s: %w", addr, err)
}
if host, _, _ := net.SplitHostPort(addr); host != "" {
if ip := net.ParseIP(host); ip != nil && !ip.IsUnspecified() && !ip.IsLoopback() && !onInterface(ip) {
log.Printf("listen address %s isn't on any interface yet; bound anyway and waiting for it", host)
}
}
return ln, nil
}
func onInterface(ip net.IP) bool {
addrs, _ := net.InterfaceAddrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) {
return true
}
}
return false
}
// loginShell returns the user's shell from /etc/passwd, falling back to bash if
// present and /bin/sh otherwise. Systemd units don't export $SHELL.
func loginShell(username string) string {
-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
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -32,7 +33,9 @@ import (
"hamlab/internal/files"
"hamlab/internal/metrics"
"hamlab/internal/nodes"
"hamlab/internal/priv"
"hamlab/internal/services"
"hamlab/internal/setup"
"hamlab/internal/storage"
"hamlab/internal/store"
"hamlab/internal/tasks"
@@ -43,6 +46,9 @@ import (
const cookieName = "hamlab_session"
type Server struct {
// Restart replaces the running process with the binary on disk; it only returns on failure.
Restart func() error
cfg *config.Config
sessions *auth.Sessions
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()
if err != nil || !strings.HasPrefix(string(out), "hamlab ") {
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
}
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")
_ = copyFile(exe, prev)
install := exec.CommandContext(ctx, "sudo", "-n", "install", "-m", "0755", tmp, exe)
if errb, err := install.CombinedOutput(); err != nil {
if err2 := os.Rename(tmp, exe); err2 != nil { // no sudo: try in place
jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(errb)))
return
// Atomic replace, by the new binary itself: directly when the directory is ours
// (root, or a user-owned prefix), else as root.
var msg []byte
if err = setup.CopyAtomic(tmp, exe); err != nil && priv.Available() {
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)
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))
jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version})
go func() {
time.Sleep(800 * time.Millisecond)
if exec.Command("sudo", "-n", "systemctl", "restart", "hamlab").Run() != nil {
os.Exit(0) // Restart=always brings the new binary up
if s.Restart != nil {
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"
"sync"
"time"
"hamlab/internal/store"
)
// 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
}
// 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 {
return &Config{Listen: "127.0.0.1:8100", path: path}
}
@@ -77,11 +98,7 @@ func (c *Config) save() error {
if err != nil {
return err
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, c.path)
return store.WriteFileAtomic(c.path, append(b, '\n'), 0o600)
}
// Update applies fn under the lock and saves.
+11 -9
View File
@@ -24,6 +24,8 @@ import (
"strings"
"sync"
"time"
"hamlab/internal/priv"
)
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 {
if m.sudo {
return append([]string{"sudo", "-n"}, args...)
return priv.Wrap(args...)
}
return args
}
@@ -326,7 +328,7 @@ func (m *Manager) readSource(ctx context.Context, s source) (string, error) {
if !m.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") {
return "", nil
}
@@ -334,7 +336,7 @@ func (m *Manager) readSource(ctx context.Context, s source) (string, error) {
}
b, err := os.ReadFile(s.name)
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 string(b), err
@@ -356,7 +358,7 @@ func (m *Manager) sources(ctx context.Context) []source {
users := map[string]bool{}
if m.sudo {
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") {
if n = strings.TrimSpace(n); userRe.MatchString(n) {
users[n] = true
@@ -441,7 +443,7 @@ func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
args = append(args, "--since", "-30d", "-n", "20000")
}
if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...)
args = priv.Wrap(args...)
}
seen := m.logging
_ = 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) {
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "-n", "20000"}
if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...)
args = priv.Wrap(args...)
}
var res []map[string]any
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", "-")
return err
}
_, err := run(ctx, text, "sudo", "-n", "crontab", "-u", s.user, "-")
_, err := run(ctx, text, priv.Wrap("crontab", "-u", s.user, "-")...)
return err
}
if name != "/etc/crontab" && !strings.HasPrefix(name, "/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.lastAt = time.Time{}
m.mu.Unlock()
@@ -708,7 +710,7 @@ func (m *Manager) StartRun(ctx context.Context, j Job) (*Run, error) {
cmdline, stdin := cronPercent(j.Command)
args := []string{"sh", "-c", cmdline}
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}
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+3 -1
View File
@@ -22,6 +22,8 @@ import (
"strings"
"syscall"
"time"
"hamlab/internal/priv"
)
type Entry struct {
@@ -278,7 +280,7 @@ func (Local) Chmod(_ context.Context, path string, perm os.FileMode) error {
type Sudo struct{ Self string }
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:]...)
}
+9 -3
View File
@@ -24,6 +24,8 @@ import (
"sync"
"syscall"
"time"
"hamlab/internal/priv"
)
const (
@@ -732,7 +734,10 @@ func leadingNum(s string) float64 {
func upsPwrstat() *UPS {
out, ok := runOut("pwrstat", "-status")
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
}
}
@@ -1106,8 +1111,9 @@ func Signal(pid int, sig syscall.Signal) error {
} else if !errors.Is(err, syscall.EPERM) {
return err
}
if lookPath("sudo") {
if err := exec.Command("sudo", "-n", "kill", "-"+strconv.Itoa(int(sig)), strconv.Itoa(pid)).Run(); err == nil {
if priv.Available() {
k := priv.Wrap("kill", "-"+strconv.Itoa(int(sig)), strconv.Itoa(pid))
if err := exec.Command(k[0], k[1:]...).Run(); err == 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, " ")
}
+59 -9
View File
@@ -13,6 +13,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"sort"
@@ -21,6 +22,8 @@ import (
"sync"
"syscall"
"time"
"hamlab/internal/priv"
)
type Unit struct {
@@ -65,11 +68,20 @@ type Manager struct {
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 {
m := &Manager{}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
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
// tail ends, whereas a root journalctl under sudo can only be reached with sudo kill.
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).
// Cached for a few seconds because the UI polls it.
func (m *Manager) List(ctx context.Context) ([]Unit, error) {
if !HasSystemd() {
return nil, ErrNoSystemd
}
m.mu.Lock()
if time.Since(m.cacheAt) < 200*time.Millisecond && m.cache != nil { // coalesce simultaneous clients only
c := m.cache
@@ -126,15 +141,18 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) {
if err != nil {
return nil, err
}
var units []struct{ Unit, Load, Active, Sub, Description string }
var units []listedUnit
if err := json.Unmarshal([]byte(out), &units); err != nil {
// 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 {
UnitFile string `json:"unit_file"`
State string `json:"state"`
}
_ = json.Unmarshal([]byte(files), &uf)
var uf []listedFile
if json.Unmarshal([]byte(files), &uf) != nil {
uf = parseFileTable(files)
}
enabled := map[string]string{}
for _, f := range uf {
enabled[f.UnitFile] = f.State
@@ -165,6 +183,37 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) {
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() {
m.mu.Lock()
m.cacheAt = time.Time{}
@@ -243,7 +292,7 @@ func parseEntry(line []byte) (Entry, bool) {
func (m *Manager) journalArgs(unit string, n int) []string {
args := []string{"journalctl", "-u", unit, "-n", strconv.Itoa(n), "-o", "json", "--no-pager", "--all"}
if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...)
args = priv.Wrap(args...)
}
return args
}
@@ -278,7 +327,8 @@ func (m *Manager) Tail(ctx context.Context, unit string, n int, fn func(Entry))
cmd.Cancel = func() error {
pg := "-" + strconv.Itoa(cmd.Process.Pid)
if m.journalSudo { // children are root's; only sudo can signal them
return exec.Command("sudo", "-n", "kill", "-9", "--", pg).Run()
k := priv.Wrap("kill", "-9", "--", pg)
return exec.Command(k[0], k[1:]...).Run()
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
@@ -310,7 +360,7 @@ func (m *Manager) Action(ctx context.Context, action, unit string) error {
}
args := []string{"systemctl", action, unit, "--no-pager"}
if m.sudo {
args = append([]string{"sudo", "-n"}, args...)
args = priv.Wrap(args...)
}
_, err := run(ctx, args...)
m.invalidate()
+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]
Description=Ham Lab web console
After=network.target
# never give up restarting
StartLimitIntervalSec=0
[Service]
Type=simple
User=@USER@
Group=@GROUP@
# read the system journal without sudo (keeps the daemon's own log clean)
SupplementaryGroups=systemd-journal adm
WorkingDirectory=@HOME@
Environment=HAMLAB_CONFIG=@HOME@/apps/hamlab/config.json
ExecStart=@PREFIX@/hamlab serve
SupplementaryGroups=@GROUPS@
WorkingDirectory=-@HOME@
Environment=HAMLAB_CONFIG=@CONFIG@
ExecStart=@BIN@ serve
Restart=always
RestartSec=3
KillMode=control-group
TimeoutStopSec=10
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
+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"
"syscall"
"time"
"hamlab/internal/priv"
)
// ---- block devices ----
@@ -75,15 +77,14 @@ func str(p *string) string {
func Devices(ctx context.Context) ([]Device, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,SIZE,MODEL,SERIAL,VENDOR,ROTA,TRAN,FSTYPE,MOUNTPOINT,LABEL,RM").Output()
if err != nil {
return nil, errors.New("lsblk: " + err.Error())
}
var root struct {
Blockdevices []lsblkNode `json:"blockdevices"`
}
if err := json.Unmarshal(out, &root); err != nil {
return nil, err
// lsblk knows labels and stacked devices (LVM, LUKS, md); without it (Alpine and other
// busybox systems don't ship it) or with one too old for JSON, /sys has the essentials.
out, err := exec.CommandContext(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,SIZE,MODEL,SERIAL,VENDOR,ROTA,TRAN,FSTYPE,MOUNTPOINT,LABEL,RM").Output()
if err != nil || json.Unmarshal(out, &root) != nil {
root.Blockdevices = sysBlockDevices()
}
devs := []Device{}
for _, n := range root.Blockdevices {
@@ -127,6 +128,77 @@ func Devices(ctx context.Context) ([]Device, error) {
return devs, nil
}
// sysBlockDevices builds the same tree lsblk would from /sys/block and /proc/mounts.
func sysBlockDevices() []lsblkNode {
type mnt struct{ point, fstype string }
mounts := map[string]mnt{}
if b, err := os.ReadFile("/proc/mounts"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if f := strings.Fields(line); len(f) >= 3 && strings.HasPrefix(f[0], "/dev/") {
dev := f[0]
if real, err := filepath.EvalSymlinks(dev); err == nil { // /dev/disk/by-uuid/…, /dev/mapper/…
dev = real
}
if _, seen := mounts[filepath.Base(dev)]; !seen {
mounts[filepath.Base(dev)] = mnt{strings.ReplaceAll(f[1], `\040`, " "), f[2]}
}
}
}
}
if b, err := os.ReadFile("/proc/swaps"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if f := strings.Fields(line); len(f) > 0 && strings.HasPrefix(f[0], "/dev/") {
mounts[filepath.Base(f[0])] = mnt{"[SWAP]", "swap"}
}
}
}
sp := func(v string) *string {
if v = strings.TrimSpace(v); v == "" {
return nil
}
return &v
}
node := func(dir, name, typ string) lsblkNode {
n := lsblkNode{Name: name, Type: typ, Size: u64(strings.TrimSpace(readStrFile(dir+"/size"))) * 512}
if m, ok := mounts[name]; ok {
n.Mountpoint, n.FSType = sp(m.point), sp(m.fstype)
}
return n
}
var out []lsblkNode
ents, _ := os.ReadDir("/sys/block")
for _, e := range ents {
name := e.Name()
dir := "/sys/block/" + name
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "zram") ||
strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") || strings.HasPrefix(name, "sr") || strings.HasPrefix(name, "nbd") {
continue
}
n := node(dir, name, "disk")
n.Model, n.Vendor = sp(readStrFile(dir+"/device/model")), sp(readStrFile(dir+"/device/vendor"))
n.Serial = sp(readStrFile(dir + "/device/serial"))
n.Rota = strings.TrimSpace(readStrFile(dir+"/queue/rotational")) == "1"
n.RM = strings.TrimSpace(readStrFile(dir+"/removable")) == "1"
real, _ := filepath.EvalSymlinks(dir)
switch {
case strings.HasPrefix(name, "nvme"):
n.Tran = sp("nvme")
case strings.Contains(real, "/usb"):
n.Tran = sp("usb")
case strings.Contains(real, "/ata"):
n.Tran = sp("sata")
}
subs, _ := os.ReadDir(dir)
for _, sub := range subs {
if _, err := os.Stat(dir + "/" + sub.Name() + "/partition"); err == nil {
n.Children = append(n.Children, node(dir+"/"+sub.Name(), sub.Name(), "part"))
}
}
out = append(out, n)
}
return out
}
var nsRe = regexp.MustCompile(`^(nvme\d+)n\d+$`)
// foldNVMe merges nvme0n1, nvme0n2, … into one physical device "nvme0": one SSD, one
@@ -319,7 +391,7 @@ func (m *Manager) run(ctx context.Context, args ...string) ([]byte, int, error)
args[0] = p
}
if m.sudo {
args = append([]string{"sudo", "-n"}, args...)
args = priv.Wrap(args...)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
+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 (
"encoding/json"
"errors"
"log"
"os"
"sync"
"time"
@@ -35,8 +36,12 @@ func Open(path string) (*Store, error) {
return nil, err
}
if err == nil {
// State is conveniences (sessions, terminal names, layout). A file mangled by a power
// cut must not keep the console down: set it aside and start clean.
if err := json.Unmarshal(b, &s.state); err != nil {
return nil, err
s.state = State{}
_ = os.Rename(path, path+".corrupt")
log.Printf("state: %s unreadable (%v); kept as %s.corrupt, starting fresh", path, err, path)
}
}
if s.state.Sessions == nil {
@@ -65,9 +70,27 @@ func (s *Store) save() error {
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return WriteFileAtomic(s.path, b, 0o600)
}
// WriteFileAtomic writes beside path, syncs, then renames: after a crash the file is the
// old version or the new one, never empty or half-written.
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
return os.Rename(tmp, s.path)
_, err = f.Write(data)
if err == nil {
err = f.Sync()
}
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
+6 -2
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"sort"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -68,6 +69,7 @@ type Manager struct {
shell string
home string
OnChange func([]Info) // called (without locks held) when the list changes
closing atomic.Bool
}
func NewManager(shell, home string) *Manager {
@@ -283,13 +285,15 @@ func (m *Manager) Close(id string) error {
}
func (m *Manager) changed() {
if m.OnChange != nil {
if m.OnChange != nil && !m.closing.Load() {
m.OnChange(m.List())
}
}
// Shutdown hangs up every shell. Called on daemon exit.
// Shutdown hangs up every shell. Called on daemon exit. The saved list is left as it
// was, because that list is what the next start re-opens.
func (m *Manager) Shutdown() {
m.closing.Store(true)
for _, s := range m.List() {
_ = m.Close(s.ID)
}
+173 -33
View File
@@ -2,8 +2,9 @@
// was last refreshed, whether a reboot is pending, and the refresh/upgrade actions,
// which run as background tasks so their output can be followed live.
//
// apt is fully supported. dnf and pacman get a read-only list so the tab still says
// something useful on those hosts; upgrades there are a later addition.
// Supported: apt (Debian, Ubuntu), dnf (Fedora, RHEL family), zypper (openSUSE, SLES),
// pacman (Arch) and apk (Alpine). All of them list, refresh, upgrade and install; pacman
// upgrades everything or nothing, because Arch doesn't support partial upgrades.
package updates
import (
@@ -18,6 +19,7 @@ import (
"strings"
"time"
"hamlab/internal/priv"
"hamlab/internal/tasks"
)
@@ -31,8 +33,9 @@ type Package struct {
}
type Status struct {
Manager string `json:"manager"` // apt | dnf | pacman | ""
Manager string `json:"manager"` // apt | dnf | zypper | pacman | apk | ""
CanUpgrade bool `json:"can_upgrade"`
Selective bool `json:"selective"` // individual packages can be upgraded
Packages []Package `json:"packages"`
SecurityCount int `json:"security_count"`
LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms
@@ -52,7 +55,7 @@ type Manager struct {
func New(sudo bool, tm *tasks.Manager) *Manager {
m := &Manager{sudo: sudo, tasks: tm}
for _, pm := range []string{"apt-get", "dnf", "pacman"} {
for _, pm := range []string{"apt-get", "dnf", "zypper", "pacman", "apk"} {
if _, err := exec.LookPath(pm); err == nil {
m.pm = strings.TrimSuffix(pm, "-get")
break
@@ -63,7 +66,7 @@ func New(sudo bool, tm *tasks.Manager) *Manager {
func (m *Manager) sudoArgs(args ...string) []string {
if m.sudo {
return append([]string{"sudo", "-n"}, args...)
return priv.Wrap(args...)
}
return args
}
@@ -145,9 +148,10 @@ func (m *Manager) Status(ctx context.Context) Status {
if t, ok := m.tasks.RunningOf("updates"); ok {
s.RunningTask = t.ID
}
s.CanUpgrade = m.sudo && m.pm != ""
s.Selective = m.pm != "pacman"
switch m.pm {
case "apt":
s.CanUpgrade = m.sudo
s.Packages = aptUpgradable(ctx)
s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp")
if s.LastRefresh == 0 {
@@ -162,9 +166,18 @@ func (m *Manager) Status(ctx context.Context) Status {
s.Packages = dnfUpgradable(ctx)
s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf")
s.LastUpgrade = dnfLastUpgrade(ctx)
case "zypper":
s.Packages = zypperUpgradable(ctx)
s.LastRefresh = newestMtime("/var/cache/zypp/raw/*", "/var/cache/zypp/raw")
s.LastUpgrade = mtimeMs("/var/log/zypp/history")
case "pacman":
s.Packages = pacmanUpgradable(ctx)
s.LastRefresh = mtimeMs("/var/lib/pacman/sync")
s.LastRefresh = newestMtime("/var/lib/pacman/sync/*.db", "/var/lib/pacman/sync")
s.LastUpgrade = pacmanLastUpgrade()
case "apk":
s.Packages = apkUpgradable(ctx)
s.LastRefresh = newestMtime("/var/cache/apk/APKINDEX.*", "/var/lib/apk/APKINDEX.*")
s.LastUpgrade = mtimeMs("/lib/apk/db/installed")
}
for _, p := range s.Packages {
if p.Security {
@@ -289,7 +302,7 @@ func aptAutoUpgrades(ctx context.Context) bool {
return strings.Contains(text, `"1"`)
}
// ---- dnf / pacman (read-only) ----
// ---- dnf / zypper / pacman / apk ----
func dnfUpgradable(ctx context.Context) []Package {
text, _ := out(ctx, "dnf", "-q", "check-update") // exit 100 when updates exist; output still parses
@@ -305,6 +318,19 @@ func dnfUpgradable(ctx context.Context) []Package {
if pkgs == nil {
return []Package{}
}
// check-update only names the candidate; one rpm query fills in what's installed.
args := append([]string{"rpm", "-q", "--qf", `%{NAME} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`}, names(pkgs)...)
if text, _ := out(ctx, args...); text != "" { // exits non-zero when any name isn't installed (new dependencies)
cur := map[string]string{}
for _, line := range strings.Split(text, "\n") {
if f := strings.Fields(line); len(f) == 2 {
cur[f[0]] = f[1] // several installed (kernels): the last listed is the newest
}
}
for i := range pkgs {
pkgs[i].Current = cur[pkgs[i].Name]
}
}
return pkgs
}
@@ -323,44 +349,148 @@ func pacmanUpgradable(ctx context.Context) []Package {
return pkgs
}
// ---- actions (apt only) ----
// zypperUpgradable parses the table `zypper list-updates` prints:
// S | Repository | Name | Current Version | Available Version | Arch
func zypperUpgradable(ctx context.Context) []Package {
text, _ := out(ctx, "zypper", "--non-interactive", "--quiet", "list-updates")
pkgs := []Package{}
for _, line := range strings.Split(text, "\n") {
f := strings.Split(line, "|")
if len(f) < 5 || strings.TrimSpace(f[0]) != "v" {
continue
}
repo := strings.TrimSpace(f[1])
pkgs = append(pkgs, Package{Name: strings.TrimSpace(f[2]), Current: strings.TrimSpace(f[3]), Candidate: strings.TrimSpace(f[4]),
Suite: repo, Security: strings.Contains(strings.ToLower(repo), "security")})
}
return pkgs
}
var pkgNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]{1,127}$`)
var apkVerRe = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\s+<\s+(\S+)`)
func (m *Manager) start(title string, args ...string) (*tasks.Task, error) {
if m.pm != "apt" {
return nil, errors.New("upgrades are only supported for apt so far")
// apkUpgradable parses `apk version -l '<'`: "musl-1.2.4-r2 < 1.2.5-r0".
func apkUpgradable(ctx context.Context) []Package {
text, _ := out(ctx, "apk", "version", "-l", "<")
pkgs := []Package{}
for _, line := range strings.Split(text, "\n") {
if m := apkVerRe.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
pkgs = append(pkgs, Package{Name: m[1], Current: m[2], Candidate: m[3]})
}
}
return pkgs
}
// pacmanLastUpgrade: the timestamp of the last "upgraded" line in pacman.log.
func pacmanLastUpgrade() int64 {
f, err := os.Open("/var/log/pacman.log")
if err != nil {
return 0
}
defer f.Close()
last := ""
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
for sc.Scan() {
if l := sc.Text(); strings.Contains(l, "[ALPM] upgraded ") && strings.HasPrefix(l, "[") {
if end := strings.IndexByte(l, ']'); end > 1 {
last = l[1:end]
}
}
}
for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} {
if t, err := time.ParseInLocation(layout, last, time.Local); err == nil {
return t.UnixMilli()
}
}
return 0
}
// ---- actions ----
// Never starts with '-', so a name can't be read as an option by any of the managers.
var pkgNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9+._-]{0,127}$`)
func checkNames(pkgs []string) error {
for _, p := range pkgs {
if !pkgNameRe.MatchString(p) {
return errors.New("bad package name: " + p)
}
}
return nil
}
func (m *Manager) start(args ...string) (*tasks.Task, error) {
if m.pm == "" {
return nil, errors.New("no supported package manager on this host")
}
if !m.sudo {
return nil, errors.New("needs passwordless sudo")
return nil, errors.New("needs root: run Ham Lab as root, or give its user passwordless sudo or doas")
}
if t, ok := m.tasks.RunningOf("updates"); ok {
return nil, errors.New("another package task is running: " + t.Title)
}
full := append([]string{"sudo", "-n", "env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)
full := priv.Wrap(append([]string{"env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)...)
cmd := exec.Command(full[0], full[1:]...)
return m.tasks.Start("updates", title, cmd)
return m.tasks.Start("updates", strings.Join(args, " "), cmd)
}
func (m *Manager) Refresh() (*tasks.Task, error) {
return m.start("apt-get update", "apt-get", "update")
switch m.pm {
case "apt":
return m.start("apt-get", "update")
case "dnf":
return m.start("dnf", "-y", "makecache", "--refresh")
case "zypper":
return m.start("zypper", "--non-interactive", "refresh")
case "pacman":
return m.start("pacman", "-Sy", "--noconfirm")
case "apk":
return m.start("apk", "update")
}
return m.start()
}
// Upgrade runs a full dist-upgrade, or only the named packages. Config-file prompts are
// answered "keep the current one" so the run can never block on a question.
// rolling reports a distro whose supported upgrade path is a full distribution upgrade.
func rolling() bool {
b, _ := os.ReadFile("/etc/os-release")
return strings.Contains(string(b), "tumbleweed") || strings.Contains(string(b), "microos")
}
// Upgrade upgrades everything, or only the named packages. Nothing here can block on a
// question: apt keeps the current config file on a conflict, the rest run non-interactive.
func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) {
base := []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"}
if len(pkgs) == 0 {
return m.start("apt-get dist-upgrade", append(base, "dist-upgrade")...)
if err := checkNames(pkgs); err != nil {
return nil, err
}
for _, p := range pkgs {
if !pkgNameRe.MatchString(p) {
return nil, errors.New("bad package name: " + p)
all := len(pkgs) == 0
var args []string
switch m.pm {
case "apt":
args = []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"}
if all {
args = append(args, "dist-upgrade")
} else {
args = append(args, "install", "--only-upgrade")
}
case "dnf":
args = []string{"dnf", "-y", "upgrade"}
case "zypper":
args = []string{"zypper", "--non-interactive", "update"}
if all && rolling() {
args = []string{"zypper", "--non-interactive", "dist-upgrade"}
}
case "pacman":
if !all {
return nil, errors.New("pacman upgrades everything or nothing: Arch doesn't support partial upgrades")
}
args = []string{"pacman", "-Syu", "--noconfirm"}
case "apk":
args = []string{"apk", "upgrade"}
if all {
args = []string{"apk", "upgrade", "--update-cache"}
}
}
args := append(base, "install", "--only-upgrade")
args = append(args, pkgs...)
return m.start("apt-get upgrade "+strings.Join(pkgs, " "), args...)
return m.start(append(args, pkgs...)...)
}
// Install installs packages (used by other tabs for optional tooling like smartmontools).
@@ -368,13 +498,23 @@ func (m *Manager) Install(pkgs []string) (*tasks.Task, error) {
if len(pkgs) == 0 {
return nil, errors.New("no packages")
}
for _, p := range pkgs {
if !pkgNameRe.MatchString(p) {
return nil, errors.New("bad package name: " + p)
if err := checkNames(pkgs); err != nil {
return nil, err
}
var args []string
switch m.pm {
case "apt":
args = []string{"apt-get", "-y", "--no-install-recommends", "install"}
case "dnf":
args = []string{"dnf", "-y", "install"}
case "zypper":
args = []string{"zypper", "--non-interactive", "install", "--no-recommends"}
case "pacman":
args = []string{"pacman", "-S", "--noconfirm", "--needed"}
case "apk":
args = []string{"apk", "add"}
}
args := append([]string{"apt-get", "-y", "--no-install-recommends", "install"}, pkgs...)
return m.start("apt-get install "+strings.Join(pkgs, " "), args...)
return m.start(append(args, pkgs...)...)
}
func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) {
+3 -4
View File
@@ -62,9 +62,8 @@ export default function Updates() {
return (
<div className="dash updates">
{!st.manager && <div className="notice">No supported package manager found (looked for apt, dnf, pacman).</div>}
{st.manager && st.manager !== 'apt' && <div className="notice">{st.manager}: listing only; upgrading from here isn't supported yet.</div>}
{st.manager === 'apt' && !st.sudo && <div className="notice">Read-only: refreshing and upgrading need passwordless sudo on the host.</div>}
{!st.manager && <div className="notice">No supported package manager found (looked for apt, dnf, zypper, pacman, apk).</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.reboot_needed && (
<div className="notice attn">
<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">
<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" 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" />
{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>
+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 UpdatePackage { name: string; current: string; candidate: string; suite?: string; security: boolean; description?: string }
export interface UpdatesStatus { manager: string; can_upgrade: boolean; packages: UpdatePackage[]; security_count: number; last_refresh?: number; last_upgrade?: number; reboot_needed: boolean; reboot_pkgs: string[]; auto_upgrades: boolean; running_task?: string; sudo: boolean }
export interface UpdatesStatus { manager: string; can_upgrade: boolean; selective: boolean; packages: UpdatePackage[]; security_count: number; last_refresh?: number; last_upgrade?: number; reboot_needed: boolean; reboot_pkgs: string[]; auto_upgrades: boolean; running_task?: string; sudo: boolean }
export interface StoragePart { name: string; type: string; fstype?: string; mount?: string; label?: string; size: number; used?: number; fs_size?: number }
export interface StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[]; namespaces?: string[] }