Files
hamlab/cmd/hamlab/main.go
T
Mitchell-JohnsonandClaude Fable 5.1 488d929733 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>
2026-09-18 23:00:20 -07:00

364 lines
10 KiB
Go

// Ham Lab — Proxmox-style web console for a bare-metal Debian host.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/signal"
"os/user"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"sync/atomic"
"syscall"
"time"
"golang.org/x/term"
"hamlab/internal/api"
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/cron"
"hamlab/internal/files"
"hamlab/internal/metrics"
"hamlab/internal/nodes"
"hamlab/internal/services"
"hamlab/internal/setup"
"hamlab/internal/storage"
"hamlab/internal/store"
"hamlab/internal/tasks"
hterm "hamlab/internal/term"
"hamlab/internal/tlsauto"
"hamlab/internal/updates"
"hamlab/internal/web"
)
var version = "dev"
func main() {
defaultCfg := os.Getenv("HAMLAB_CONFIG")
if defaultCfg == "" {
defaultCfg = config.DefaultPath()
}
cfgPath := flag.String("config", defaultCfg, "path to config.json")
flag.Usage = func() {
fmt.Fprint(os.Stderr, `usage: hamlab [-config path] <command>
install [flags] install this binary as a service on this machine (install -h for flags)
uninstall [--purge] remove the service and the binary
serve run the console in the foreground (what the service runs)
passwd set the login password
check verify the config and state load, then exit
version
push-nodes [--force] hub: send this build to every managed machine
`)
flag.PrintDefaults()
}
flag.Parse()
cmd := "serve"
if flag.NArg() > 0 {
cmd = flag.Arg(0)
}
switch cmd { // commands that don't read the config
case "fs": // privileged helper for the Files tab (run as root by the daemon)
os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
case "install", "uninstall":
var err error
if cmd == "install" {
err = setup.Install(flag.Args()[1:], version)
} else {
err = setup.Uninstall(flag.Args()[1:])
}
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
if err != nil {
fmt.Fprintln(os.Stderr, "hamlab "+cmd+":", err)
os.Exit(1)
}
return
case "replace": // self-update helper (run as root by the daemon): put this binary at a path, atomically
// Only ever over a hamlab binary: this runs as root, and must not be a way to
// overwrite arbitrary files for whoever is allowed to run hamlab as root.
if flag.NArg() != 2 || !filepath.IsAbs(flag.Arg(1)) || !strings.HasPrefix(filepath.Base(flag.Arg(1)), "hamlab") {
fmt.Fprintln(os.Stderr, "hamlab replace: destination must be an absolute path to a hamlab binary")
os.Exit(2)
}
self, err := os.Executable()
if err == nil {
err = setup.CopyAtomic(self, flag.Arg(1))
}
if err != nil {
fmt.Fprintln(os.Stderr, "hamlab replace:", err)
os.Exit(1)
}
return
}
cfg, err := config.Load(*cfgPath)
if err != nil {
log.Fatal(err)
}
switch cmd {
case "version":
fmt.Println("hamlab", version)
case "check": // a new build runs this against the live config before it replaces the old one
if _, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")); err != nil {
log.Fatal(err)
}
if _, err := fs.Stat(web.FS(), "index.html"); err != nil {
log.Fatal("embedded UI missing: ", err)
}
fmt.Println("ok")
case "passwd":
if err := passwd(cfg); err != nil {
log.Fatal(err)
}
case "push-nodes": // hub CLI: send this binary to every managed machine
exe, _ := os.Executable()
host, _ := os.Hostname()
nm := nodes.New(cfg, host)
force := flag.NArg() > 1 && flag.Arg(1) == "--force"
results := nm.PushAll(context.Background(), exe, runtime.GOARCH, force)
if len(results) == 0 {
fmt.Println("no managed machines in", cfg.Path())
}
failed := 0
for _, r := range results {
if r.OK {
fmt.Printf("%-12s updated to %s\n", r.Name, r.Version)
} else {
failed++
fmt.Printf("%-12s FAILED: %s\n", r.Name, r.Error)
}
}
if failed > 0 {
os.Exit(1)
}
case "serve":
if err := serve(cfg); err != nil {
log.Fatal(err)
}
default:
flag.Usage()
os.Exit(2)
}
}
func passwd(cfg *config.Config) error {
fmt.Fprint(os.Stderr, "New password: ")
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
fmt.Fprint(os.Stderr, "Again: ")
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
if string(p1) != string(p2) {
return errors.New("passwords do not match")
}
if len(p1) < 12 {
return errors.New("password must be at least 12 characters")
}
h, err := auth.HashPassword(string(p1))
if err != nil {
return err
}
cfg.PasswordHash = h
if err := cfg.Save(); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "password set in", cfg.Path())
return nil
}
func serve(cfg *config.Config) error {
if cfg.PasswordHash == "" {
return fmt.Errorf("no password set — run: hamlab -config %s passwd", cfg.Path())
}
if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil {
return err
}
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
if err != nil {
return err
}
sessions := auth.NewSessions(st)
u, err := user.Current()
if err != nil {
return err
}
shell := os.Getenv("SHELL")
if shell == "" {
shell = loginShell(u.Username)
}
mgr := hterm.NewManager(shell, u.HomeDir)
mgr.OnChange = func(list []hterm.Info) {
_ = st.Update(func(s *store.State) {
s.Terminals = s.Terminals[:0]
for _, t := range list {
if t.Running { // an exited shell must not come back from the dead on restart
s.Terminals = append(s.Terminals, store.Terminal{ID: t.ID, Name: t.Name})
}
}
})
}
// Re-open the shells that existed before the last restart (fresh processes,
// same names/order) so the sidebar comes back as it was.
var saved []store.Terminal
st.View(func(s *store.State) { saved = append(saved, s.Terminals...) })
for _, t := range saved {
if _, err := mgr.Create(t.ID, t.Name); err != nil {
log.Printf("restore terminal %q: %v", t.Name, err)
}
}
// Keep the heap tidy: the daemon's live data is tens of MB; without a limit the GC
// happily lets RSS coast at twice that. (Soft limit: exceeding it only means more GC.)
debug.SetMemoryLimit(160 << 20)
col := metrics.NewCollector()
go col.Run(context.Background())
svc := services.New()
cr := cron.New(svc.Sudo(), svc.JournalSudo())
tm := tasks.New()
up := updates.New(svc.Sudo(), tm)
sto := storage.New(svc.Sudo())
self, _ := os.Executable()
var sudoFS files.FS
if svc.Sudo() && self != "" {
sudoFS = files.Sudo{Self: self}
}
host, _ := os.Hostname()
nm := nodes.New(cfg, host)
go nm.Run(context.Background())
certPath, keyPath, fingerprint := "", "", ""
if cfg.TLS == "auto" {
var err error
certPath, keyPath, fingerprint, err = tlsauto.Ensure(cfg.DataDir())
if err != nil {
return fmt.Errorf("tls: %w", err)
}
}
ln, err := listen(cfg.Listen)
if err != nil {
return err
}
srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, files.Local{}, sudoFS, nm, fingerprint, web.FS(), version)
hs := &http.Server{
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
// After a self-update: become the new binary in place. Same PID, so it works the same
// under systemd, OpenRC, or no supervisor at all. It returns only if the exec failed, by
// which point the listener and the shells are gone; the caller puts the old binary back
// and exits so the supervisor starts it.
var restarting atomic.Bool
srv.Restart = func() error {
restarting.Store(true) // Serve returns once Shutdown is called; serve() must not exit under us
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = hs.Shutdown(ctx)
mgr.Shutdown()
return syscall.Exec(self, os.Args, os.Environ())
}
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Print("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = hs.Shutdown(ctx)
mgr.Shutdown()
}()
if certPath != "" {
log.Printf("hamlab %s listening on https://%s as %s (shell %s) cert %s", version, cfg.Listen, u.Username, shell, fingerprint)
if err := hs.ServeTLS(ln, certPath, keyPath); !errors.Is(err, http.ErrServerClosed) {
return err
}
return waitIf(&restarting)
}
log.Printf("hamlab %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell)
if err := hs.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
return err
}
return waitIf(&restarting)
}
// waitIf parks the caller while a restart is replacing the process.
func waitIf(restarting *atomic.Bool) error {
if restarting.Load() {
select {}
}
return nil
}
// listen binds the configured address with IP_FREEBIND, so a LAN address that isn't up yet
// (boot, DHCP still negotiating, cable out) doesn't stop the daemon: it binds now and
// answers as soon as the address appears.
func listen(addr string) (net.Listener, error) {
lc := net.ListenConfig{Control: func(network, _ string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
_ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_FREEBIND, 1)
})
}}
ln, err := lc.Listen(context.Background(), "tcp", addr)
if err != nil {
return nil, fmt.Errorf("listen %s: %w", addr, err)
}
if host, _, _ := net.SplitHostPort(addr); host != "" {
if ip := net.ParseIP(host); ip != nil && !ip.IsUnspecified() && !ip.IsLoopback() && !onInterface(ip) {
log.Printf("listen address %s isn't on any interface yet; bound anyway and waiting for it", host)
}
}
return ln, nil
}
func onInterface(ip net.IP) bool {
addrs, _ := net.InterfaceAddrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) {
return true
}
}
return false
}
// loginShell returns the user's shell from /etc/passwd, falling back to bash if
// present and /bin/sh otherwise. Systemd units don't export $SHELL.
func loginShell(username string) string {
if b, err := os.ReadFile("/etc/passwd"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
f := strings.Split(line, ":")
if len(f) >= 7 && f[0] == username && f[6] != "" {
if _, err := os.Stat(f[6]); err == nil {
return f[6]
}
}
}
}
for _, sh := range []string{"/bin/bash", "/usr/bin/bash", "/bin/zsh", "/bin/sh"} {
if _, err := os.Stat(sh); err == nil {
return sh
}
}
return "/bin/sh"
}