Portable install: the binary installs itself; static builds on five architectures; root via sudo, doas or uid 0; every package manager; older systemd; no lsblk
- hamlab install / uninstall (internal/setup): systemd or OpenRC, elevates through sudo or doas, runs as the invoking user, sets or generates the password, names only journal groups that exist, waits for /healthz, reports an active firewalld/ufw. --purge and the data chown never touch a directory that isn't Ham Lab's alone. - install.sh: curl-able bootstrap (arch detection, SHA256SUMS, offline beside the binaries, noexec /tmp fallback). make release builds dist/ for amd64, arm64, arm (ARMv6), 386, riscv64. Every build is CGO_ENABLED=0; the native one used to link glibc. - internal/priv replaces every literal `sudo -n`: uid 0, sudo or doas. - Updates acts on dnf, zypper, pacman and apk as well as apt; dnf shows installed versions. - Services falls back to the plain systemctl table before systemd 246 and says so on hosts without systemd. Disks falls back to /sys/block without lsblk. - Listener binds with IP_FREEBIND, so a LAN address that isn't up at boot is no failure. - Self-update: the new build must pass `check` against the live config, is put in place by atomic rename, and the daemon re-execs itself (same under any supervisor). - State and config writes are fsynced; a corrupt state.json is set aside, not fatal. - Terminal shutdown no longer empties the saved list, so names come back after a restart by design rather than by winning a race. - Config default is ~/.config/hamlab; an existing ~/apps/hamlab is still found. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
91b7f4f009
commit
488d929733
@@ -2,3 +2,4 @@ bin/
|
|||||||
internal/web/dist/
|
internal/web/dist/
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, " ")
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
// 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)
|
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{}
|
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
// Package setup is `hamlab install` and `hamlab uninstall`: the binary installs itself, so
|
||||||
|
// a new machine needs nothing but this one file. It copies itself into place, writes the
|
||||||
|
// config, sets the login password and registers a service with whichever init the host
|
||||||
|
// runs (systemd or OpenRC), as root or through sudo/doas.
|
||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
_ "embed"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
"hamlab/internal/auth"
|
||||||
|
"hamlab/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed hamlab.service
|
||||||
|
var systemdUnit string
|
||||||
|
|
||||||
|
//go:embed hamlab.openrc
|
||||||
|
var openrcScript string
|
||||||
|
|
||||||
|
const (
|
||||||
|
unitPath = "/etc/systemd/system/hamlab.service"
|
||||||
|
openrcPath = "/etc/init.d/hamlab"
|
||||||
|
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
|
||||||
|
)
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
user, listen, tls, prefix, data string
|
||||||
|
passwordStdin, openFirewall bool
|
||||||
|
noStart bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func say(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||||
|
|
||||||
|
// elevate re-runs this command as root through sudo or doas (which may prompt), naming
|
||||||
|
// the invoking user so the service still runs as them.
|
||||||
|
func elevate(sub string, args []string, keepUser bool) error {
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, tool := range []string{"sudo", "doas"} {
|
||||||
|
path, err := exec.LookPath(tool)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full := []string{tool, self, sub}
|
||||||
|
if u, err := user.Current(); err == nil && keepUser && !hasFlag(args, "user") {
|
||||||
|
full = append(full, "--user", u.Username)
|
||||||
|
}
|
||||||
|
full = append(full, args...)
|
||||||
|
return syscall.Exec(path, full, os.Environ())
|
||||||
|
}
|
||||||
|
return errors.New("this needs root, and neither sudo nor doas is installed: run it as root")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFlag(args []string, name string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
a = strings.TrimLeft(a, "-")
|
||||||
|
if a == name || strings.HasPrefix(a, name+"=") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install is `hamlab install [flags]`.
|
||||||
|
func Install(args []string, version string) error {
|
||||||
|
var o options
|
||||||
|
fs := flag.NewFlagSet("install", flag.ContinueOnError)
|
||||||
|
fs.StringVar(&o.user, "user", "", "user the console runs as; terminals are this user's shell (default: you)")
|
||||||
|
fs.StringVar(&o.listen, "listen", "", "listen address, e.g. 0.0.0.0:8100 (default: keep, else 127.0.0.1:8100)")
|
||||||
|
fs.StringVar(&o.tls, "tls", "", `"auto" serves HTTPS with a self-signed certificate, "off" plain HTTP (default: keep)`)
|
||||||
|
fs.StringVar(&o.prefix, "prefix", "/usr/local/bin", "directory the binary is installed into")
|
||||||
|
fs.StringVar(&o.data, "data", "", "config and state directory (default: ~/.config/hamlab of the user)")
|
||||||
|
fs.BoolVar(&o.passwordStdin, "password-stdin", false, "read the login password from stdin instead of prompting")
|
||||||
|
fs.BoolVar(&o.openFirewall, "open-firewall", false, "open the listen port in firewalld/ufw when one is active")
|
||||||
|
fs.BoolVar(&o.noStart, "no-start", false, "install and enable, but don't start the service")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return elevate("install", args, true)
|
||||||
|
}
|
||||||
|
sbinPath()
|
||||||
|
|
||||||
|
// who
|
||||||
|
name := o.user
|
||||||
|
for _, env := range []string{"SUDO_USER", "DOAS_USER"} {
|
||||||
|
if name == "" {
|
||||||
|
name = os.Getenv(env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "root"
|
||||||
|
}
|
||||||
|
acct, err := lookupUser(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// where
|
||||||
|
dataDir := o.data
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = filepath.Dir(existingConfig(acct.home))
|
||||||
|
}
|
||||||
|
cfgPath := filepath.Join(dataDir, "config.json")
|
||||||
|
|
||||||
|
// binary
|
||||||
|
dest := filepath.Join(o.prefix, "hamlab")
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
self, _ = filepath.EvalSymlinks(self)
|
||||||
|
if err := os.MkdirAll(o.prefix, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if self != dest {
|
||||||
|
if err := CopyAtomic(self, dest); err != nil {
|
||||||
|
return fmt.Errorf("install binary: %w", err)
|
||||||
|
}
|
||||||
|
// builds for other architectures sit beside it so a hub can push them to matching machines
|
||||||
|
sibs, _ := filepath.Glob(filepath.Join(filepath.Dir(self), "hamlab-linux-*"))
|
||||||
|
for _, sib := range sibs {
|
||||||
|
arch := strings.TrimPrefix(filepath.Base(sib), "hamlab-linux-")
|
||||||
|
if sib == self || strings.Contains(arch, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := CopyAtomic(sib, dest+"-"+arch); err != nil {
|
||||||
|
say("warning: %s: %v", sib, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restorecon(dest)
|
||||||
|
say("binary %s (%s)", dest, version)
|
||||||
|
|
||||||
|
// config + password
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if o.listen != "" {
|
||||||
|
if _, _, err := net.SplitHostPort(o.listen); err != nil {
|
||||||
|
return fmt.Errorf("--listen %q: want host:port", o.listen)
|
||||||
|
}
|
||||||
|
cfg.Listen = o.listen
|
||||||
|
}
|
||||||
|
switch o.tls {
|
||||||
|
case "":
|
||||||
|
case "auto":
|
||||||
|
cfg.TLS = "auto"
|
||||||
|
case "off", "none":
|
||||||
|
cfg.TLS = ""
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("--tls %q: want auto or off", o.tls)
|
||||||
|
}
|
||||||
|
generated := ""
|
||||||
|
if cfg.PasswordHash == "" || o.passwordStdin {
|
||||||
|
pw, gen, err := choosePassword(o.passwordStdin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.PasswordHash, err = auth.HashPassword(pw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if gen {
|
||||||
|
generated = pw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
made := firstMissing(dataDir) // e.g. ~/.config, when this is the first thing to use it
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := chownData(dataDir, made, acct); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
say("config %s", cfgPath)
|
||||||
|
|
||||||
|
// service
|
||||||
|
vars := map[string]string{"@USER@": acct.name, "@GROUP@": acct.group, "@HOME@": acct.home, "@BIN@": dest, "@CONFIG@": cfgPath,
|
||||||
|
"@GROUPS@": journalGroups()}
|
||||||
|
initSys := ""
|
||||||
|
switch {
|
||||||
|
case isDir("/run/systemd/system"):
|
||||||
|
initSys = "systemd"
|
||||||
|
unit := fill(systemdUnit, vars)
|
||||||
|
if vars["@GROUPS@"] == "" {
|
||||||
|
unit = dropLines(unit, "SupplementaryGroups=")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
restorecon(unitPath)
|
||||||
|
if err := runAll([][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "hamlab.service"}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !o.noStart {
|
||||||
|
if err := run("systemctl", "restart", "hamlab.service"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
|
||||||
|
initSys = "OpenRC"
|
||||||
|
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := run("rc-update", "add", "hamlab", "default"); err != nil {
|
||||||
|
say("warning: rc-update: %v", err)
|
||||||
|
}
|
||||||
|
if !o.noStart {
|
||||||
|
if err := run("rc-service", "hamlab", "restart"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if initSys != "" {
|
||||||
|
say("service hamlab (%s), runs as %s, starts at boot", initSys, acct.name)
|
||||||
|
}
|
||||||
|
if acct.name != "root" {
|
||||||
|
quietSudoers()
|
||||||
|
}
|
||||||
|
|
||||||
|
port := firewall(cfg.Listen, o.openFirewall)
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if cfg.TLS == "auto" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
url := scheme + "://" + browseAddr(cfg.Listen)
|
||||||
|
switch {
|
||||||
|
case initSys == "":
|
||||||
|
say("\nNo systemd or OpenRC here, so nothing starts it at boot. Run it under your supervisor as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
|
||||||
|
case o.noStart:
|
||||||
|
case healthy(cfg.Listen, cfg.TLS == "auto"):
|
||||||
|
say("\nHam Lab is up: %s", url)
|
||||||
|
default:
|
||||||
|
say("\nInstalled, but %s isn't answering yet. Check: %s", url, map[string]string{"systemd": "journalctl -u hamlab -n 30", "OpenRC": "tail /var/log/hamlab.log"}[initSys])
|
||||||
|
}
|
||||||
|
if generated != "" {
|
||||||
|
say("Login password (shown once; change it in Settings): %s", generated)
|
||||||
|
}
|
||||||
|
if host, _, _ := net.SplitHostPort(cfg.Listen); isLoopback(host) {
|
||||||
|
say("It only listens on this machine. For LAN access: hamlab install --listen 0.0.0.0:%s", port)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall is `hamlab uninstall [--purge]`.
|
||||||
|
func Uninstall(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("uninstall", flag.ContinueOnError)
|
||||||
|
purge := fs.Bool("purge", false, "also delete the config and state directory")
|
||||||
|
prefix := fs.String("prefix", "/usr/local/bin", "directory the binary was installed into")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return elevate("uninstall", args, false)
|
||||||
|
}
|
||||||
|
sbinPath()
|
||||||
|
dataDir := ""
|
||||||
|
if b, err := os.ReadFile(unitPath); err == nil {
|
||||||
|
dataDir = configDirFrom(string(b), "Environment=HAMLAB_CONFIG=")
|
||||||
|
_ = run("systemctl", "disable", "--now", "hamlab.service")
|
||||||
|
os.Remove(unitPath)
|
||||||
|
_ = run("systemctl", "daemon-reload")
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(openrcPath); err == nil {
|
||||||
|
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
|
||||||
|
_ = run("rc-service", "hamlab", "stop")
|
||||||
|
_ = run("rc-update", "del", "hamlab", "default")
|
||||||
|
os.Remove(openrcPath)
|
||||||
|
}
|
||||||
|
os.Remove(sudoersPath)
|
||||||
|
bins, _ := filepath.Glob(filepath.Join(*prefix, "hamlab-*"))
|
||||||
|
for _, b := range append(bins, filepath.Join(*prefix, "hamlab")) {
|
||||||
|
os.Remove(b)
|
||||||
|
}
|
||||||
|
say("Ham Lab removed.")
|
||||||
|
switch {
|
||||||
|
case dataDir == "":
|
||||||
|
case *purge:
|
||||||
|
if err := purgeData(dataDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
say("Config and state kept in %s (uninstall --purge deletes them).", dataDir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sbinPath makes sure the admin tools resolve: a root shell reached with plain `su` keeps
|
||||||
|
// the user's PATH, which on Debian has no sbin in it.
|
||||||
|
func sbinPath() {
|
||||||
|
os.Setenv("PATH", os.Getenv("PATH")+":/usr/local/sbin:/usr/sbin:/sbin:/usr/local/bin:/usr/bin:/bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownFiles is everything Ham Lab writes into its data directory.
|
||||||
|
var ownFiles = []string{"config.json", "config.json.tmp", "state.json", "state.json.tmp", "state.json.corrupt", "cert.pem", "key.pem", "hamlab.prev", "hamlab.new"}
|
||||||
|
|
||||||
|
// purgeData deletes Ham Lab's data. The whole directory goes only when it is unmistakably
|
||||||
|
// ours (named "hamlab"); a --data pointed at a shared directory such as a home loses
|
||||||
|
// just the files Ham Lab wrote.
|
||||||
|
func purgeData(dir string) error {
|
||||||
|
if filepath.Base(dir) == "hamlab" {
|
||||||
|
say("Deleted %s.", dir)
|
||||||
|
return os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
for _, f := range ownFiles {
|
||||||
|
os.Remove(filepath.Join(dir, f))
|
||||||
|
}
|
||||||
|
say("Deleted Ham Lab's files in %s (the directory isn't Ham Lab's alone, so it stays).", dir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configDirFrom(text, key string) string {
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(strings.TrimSpace(line), key); ok {
|
||||||
|
if v = strings.Trim(v, `"'`); strings.HasSuffix(v, "/config.json") && filepath.Dir(v) != "/" {
|
||||||
|
return filepath.Dir(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pieces ----
|
||||||
|
|
||||||
|
type account struct {
|
||||||
|
name, group, home string
|
||||||
|
uid, gid int
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupUser(name string) (account, error) {
|
||||||
|
a := account{name: name}
|
||||||
|
if u, err := user.Lookup(name); err == nil {
|
||||||
|
a.uid, _ = strconv.Atoi(u.Uid)
|
||||||
|
a.gid, _ = strconv.Atoi(u.Gid)
|
||||||
|
a.home = u.HomeDir
|
||||||
|
} else if out, err2 := exec.Command("getent", "passwd", name).Output(); err2 == nil { // LDAP/SSSD accounts
|
||||||
|
f := strings.Split(strings.TrimSpace(string(out)), ":")
|
||||||
|
if len(f) < 6 {
|
||||||
|
return a, fmt.Errorf("no such user: %s", name)
|
||||||
|
}
|
||||||
|
a.uid, _ = strconv.Atoi(f[2])
|
||||||
|
a.gid, _ = strconv.Atoi(f[3])
|
||||||
|
a.home = f[5]
|
||||||
|
} else {
|
||||||
|
return a, fmt.Errorf("no such user: %s", name)
|
||||||
|
}
|
||||||
|
if a.home == "" || !isDir(a.home) {
|
||||||
|
return a, fmt.Errorf("user %s has no home directory (%q); pick another with --user", name, a.home)
|
||||||
|
}
|
||||||
|
a.group = strconv.Itoa(a.gid)
|
||||||
|
if g, err := user.LookupGroupId(a.group); err == nil {
|
||||||
|
a.group = g.Name
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// existingConfig returns the config path for a home directory: whichever already exists,
|
||||||
|
// else the default for a new install.
|
||||||
|
func existingConfig(home string) string {
|
||||||
|
for _, p := range config.Candidates(home) {
|
||||||
|
if exists(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config.Candidates(home)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalGroups lists the groups that can read the system journal and exist here. A unit
|
||||||
|
// naming a group the host doesn't have fails to start, and "adm" is not universal.
|
||||||
|
func journalGroups() string {
|
||||||
|
var have []string
|
||||||
|
for _, g := range []string{"systemd-journal", "adm"} {
|
||||||
|
if _, err := user.LookupGroup(g); err == nil {
|
||||||
|
have = append(have, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(have, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func choosePassword(fromStdin bool) (pw string, generated bool, err error) {
|
||||||
|
switch {
|
||||||
|
case fromStdin:
|
||||||
|
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
pw = strings.TrimRight(line, "\r\n")
|
||||||
|
case term.IsTerminal(int(os.Stdin.Fd())):
|
||||||
|
fmt.Fprint(os.Stderr, "Login password for the web console (12+ characters, empty to generate one): ")
|
||||||
|
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if len(p1) > 0 {
|
||||||
|
fmt.Fprint(os.Stderr, "Again: ")
|
||||||
|
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if !bytes.Equal(p1, p2) {
|
||||||
|
return "", false, errors.New("passwords do not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw = string(p1)
|
||||||
|
}
|
||||||
|
if pw == "" && !fromStdin {
|
||||||
|
return randomPassword(), true, nil
|
||||||
|
}
|
||||||
|
if len(pw) < 12 {
|
||||||
|
return "", false, errors.New("password must be at least 12 characters")
|
||||||
|
}
|
||||||
|
return pw, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomPassword() string {
|
||||||
|
const alphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no look-alikes
|
||||||
|
b := make([]byte, 20)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyAtomic puts src at dst with mode 0755 by writing beside it and renaming, so dst is
|
||||||
|
// always either the old file or the whole new one, and a running dst is never written to.
|
||||||
|
func CopyAtomic(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
tmp := filepath.Join(filepath.Dir(dst), "."+filepath.Base(dst)+".new")
|
||||||
|
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
if err == nil {
|
||||||
|
err = out.Sync()
|
||||||
|
}
|
||||||
|
if cerr := out.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = os.Chmod(tmp, 0o755)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = os.Rename(tmp, dst)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstMissing returns the topmost directory that creating dir would have to make, or "".
|
||||||
|
func firstMissing(dir string) string {
|
||||||
|
missing := ""
|
||||||
|
for d := filepath.Clean(dir); d != "/" && d != "." && !exists(d); d = filepath.Dir(d) {
|
||||||
|
missing = d
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// chownData hands Ham Lab's data to the account that runs it. Directories this install
|
||||||
|
// created are ours to hand over whole. In one that already existed (a --data pointed at
|
||||||
|
// something shared), only the files Ham Lab writes change owner: a recursive chown of
|
||||||
|
// somebody else's tree can't be undone.
|
||||||
|
func chownData(dataDir, made string, a account) error {
|
||||||
|
if made != "" {
|
||||||
|
return chownTree(made, a)
|
||||||
|
}
|
||||||
|
if filepath.Base(dataDir) == "hamlab" {
|
||||||
|
return chownTree(dataDir, a)
|
||||||
|
}
|
||||||
|
for _, f := range ownFiles {
|
||||||
|
if p := filepath.Join(dataDir, f); exists(p) {
|
||||||
|
if err := os.Lchown(p, a.uid, a.gid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chownTree(dir string, a account) error {
|
||||||
|
return filepath.Walk(dir, func(p string, _ os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Lchown(p, a.uid, a.gid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fill(tmpl string, vars map[string]string) string {
|
||||||
|
for k, v := range vars {
|
||||||
|
tmpl = strings.ReplaceAll(tmpl, k, v)
|
||||||
|
}
|
||||||
|
return tmpl
|
||||||
|
}
|
||||||
|
|
||||||
|
func dropLines(text, prefix string) string {
|
||||||
|
var keep []string
|
||||||
|
for _, l := range strings.Split(text, "\n") {
|
||||||
|
if !strings.HasPrefix(l, prefix) {
|
||||||
|
keep = append(keep, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(keep, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// quietSudoers keeps the daemon's read-only polling (SMART, UPS, journal) out of the auth
|
||||||
|
// log: at 250 ms refresh rates the sudo session lines would otherwise dominate it.
|
||||||
|
func quietSudoers() {
|
||||||
|
if !isDir("/etc/sudoers.d") || !have("visudo") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cmds []string
|
||||||
|
for _, bin := range []string{"smartctl", "pwrstat", "journalctl", "tail", "cat", "ls", "crontab"} {
|
||||||
|
for _, dir := range []string{"/usr/sbin", "/usr/bin", "/sbin", "/bin"} {
|
||||||
|
if exists(dir + "/" + bin) {
|
||||||
|
cmds = append(cmds, dir+"/"+bin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cmds) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := "# Ham Lab: don't log the daemon's read-only polling commands (written by hamlab install)\n" +
|
||||||
|
"Cmnd_Alias HAMLAB_QUIET = " + strings.Join(cmds, ", ") + "\n" +
|
||||||
|
"Defaults!HAMLAB_QUIET !syslog, !pam_session, !pam_setcred\n"
|
||||||
|
tmp := sudoersPath + ".new"
|
||||||
|
if os.WriteFile(tmp, []byte(text), 0o440) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exec.Command("visudo", "-cf", tmp).Run() != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
say("warning: sudoers snippet failed validation; skipped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Rename(tmp, sudoersPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewall reports (and with open, fixes) an active host firewall in front of a
|
||||||
|
// non-loopback listen address. It returns the port.
|
||||||
|
func firewall(listen string, open bool) string {
|
||||||
|
host, port, _ := net.SplitHostPort(listen)
|
||||||
|
if isLoopback(host) {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case have("firewall-cmd") && exec.Command("firewall-cmd", "--state").Run() == nil:
|
||||||
|
if open {
|
||||||
|
if err := runAll([][]string{{"firewall-cmd", "--quiet", "--permanent", "--add-port=" + port + "/tcp"}, {"firewall-cmd", "--quiet", "--reload"}}); err != nil {
|
||||||
|
say("warning: firewalld: %v", err)
|
||||||
|
} else {
|
||||||
|
say("firewall firewalld: opened %s/tcp", port)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
say("firewall firewalld is active and will block port %s. Open it: hamlab install --open-firewall", port)
|
||||||
|
}
|
||||||
|
case have("ufw"):
|
||||||
|
out, _ := exec.Command("ufw", "status").Output()
|
||||||
|
if !bytes.Contains(out, []byte("Status: active")) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if open {
|
||||||
|
if err := run("ufw", "allow", port+"/tcp"); err != nil {
|
||||||
|
say("warning: ufw: %v", err)
|
||||||
|
} else {
|
||||||
|
say("firewall ufw: opened %s/tcp", port)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
say("firewall ufw is active and will block port %s. Open it: hamlab install --open-firewall", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLoopback(host string) bool {
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
return host == "localhost" || (ip != nil && ip.IsLoopback())
|
||||||
|
}
|
||||||
|
|
||||||
|
// browseAddr turns a listen address into one a browser can open.
|
||||||
|
func browseAddr(listen string) string {
|
||||||
|
host, port, err := net.SplitHostPort(listen)
|
||||||
|
if err != nil {
|
||||||
|
return listen
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
|
||||||
|
host = "localhost"
|
||||||
|
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||||
|
for _, a := range addrs {
|
||||||
|
if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil && !n.IP.IsLoopback() {
|
||||||
|
host = n.IP.String()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthy polls /healthz for up to 10 s.
|
||||||
|
func healthy(listen string, https bool) bool {
|
||||||
|
host, port, _ := net.SplitHostPort(listen)
|
||||||
|
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if https {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
c := &http.Client{Timeout: 2 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} // self-signed; reachability only
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if resp, err := c.Get(scheme + "://" + net.JoinHostPort(host, port) + "/healthz"); err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode < 500
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// restorecon fixes the SELinux label of a file we created, where SELinux exists.
|
||||||
|
func restorecon(path string) {
|
||||||
|
if have("restorecon") {
|
||||||
|
_ = exec.Command("restorecon", "-F", path).Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args ...string) error {
|
||||||
|
out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||||
|
return fmt.Errorf("%s: %s", strings.Join(args, " "), msg)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %w", strings.Join(args, " "), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAll(cmds [][]string) error {
|
||||||
|
for _, c := range cmds {
|
||||||
|
if err := run(c...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func have(bin string) bool { _, err := exec.LookPath(bin); return err == nil }
|
||||||
|
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||||
|
func isDir(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnitWithoutJournalGroups(t *testing.T) {
|
||||||
|
vars := map[string]string{"@USER@": "ham", "@GROUP@": "ham", "@HOME@": "/home/ham", "@BIN@": "/usr/local/bin/hamlab",
|
||||||
|
"@CONFIG@": "/home/ham/.config/hamlab/config.json", "@GROUPS@": ""}
|
||||||
|
unit := dropLines(fill(systemdUnit, vars), "SupplementaryGroups=")
|
||||||
|
if strings.Contains(unit, "SupplementaryGroups") || strings.Contains(unit, "@") {
|
||||||
|
t.Fatalf("unit still has a groups line or an unfilled placeholder:\n%s", unit)
|
||||||
|
}
|
||||||
|
if !strings.Contains(unit, "ExecStart=/usr/local/bin/hamlab serve") {
|
||||||
|
t.Fatalf("ExecStart missing:\n%s", unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDirFrom(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"Environment=HAMLAB_CONFIG=/home/ham/.config/hamlab/config.json": "/home/ham/.config/hamlab",
|
||||||
|
`export HAMLAB_CONFIG="/root/.config/hamlab/config.json"`: "/root/.config/hamlab",
|
||||||
|
"Environment=HAMLAB_CONFIG=/config.json": "",
|
||||||
|
"Environment=HAMLAB_CONFIG=/etc/passwd": "",
|
||||||
|
} {
|
||||||
|
key := "Environment=HAMLAB_CONFIG="
|
||||||
|
if strings.HasPrefix(in, "export") {
|
||||||
|
key = "export HAMLAB_CONFIG="
|
||||||
|
}
|
||||||
|
if got := configDirFrom(in, key); got != want {
|
||||||
|
t.Errorf("%q: got %q want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --purge must never take a shared directory with it.
|
||||||
|
func TestPurgeSparesSharedDirectory(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(home, "config.json"), []byte("{}"), 0o600)
|
||||||
|
os.WriteFile(filepath.Join(home, "thesis.txt"), []byte("irreplaceable"), 0o600)
|
||||||
|
if err := purgeData(home); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(home, "thesis.txt")); err != nil {
|
||||||
|
t.Fatal("purge deleted a file that wasn't Ham Lab's")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(home, "config.json")); err == nil {
|
||||||
|
t.Fatal("purge left config.json behind")
|
||||||
|
}
|
||||||
|
|
||||||
|
own := filepath.Join(t.TempDir(), "hamlab")
|
||||||
|
os.MkdirAll(own, 0o700)
|
||||||
|
os.WriteFile(filepath.Join(own, "config.json"), []byte("{}"), 0o600)
|
||||||
|
if err := purgeData(own); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(own); err == nil {
|
||||||
|
t.Fatal("purge left Ham Lab's own directory behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstMissing(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
if got := firstMissing(filepath.Join(base, "a", "b", "c")); got != filepath.Join(base, "a") {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
if got := firstMissing(base); got != "" {
|
||||||
|
t.Errorf("existing dir: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-33
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
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(append(args, pkgs...)...)
|
||||||
return m.start("apt-get install "+strings.Join(pkgs, " "), args...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) {
|
func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) {
|
||||||
|
|||||||
+3
-4
@@ -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
@@ -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[] }
|
||||||
|
|||||||
Reference in New Issue
Block a user