Files
hamlab/cmd/hamlab/main.go
T
2026-10-11 16:54:52 -07:00

609 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ham Lab — Proxmox-style web console for a bare-metal Debian host.
package main
import (
"bufio"
"context"
"encoding/json"
"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/incus"
"hamlab/internal/media"
"hamlab/internal/metrics"
"hamlab/internal/nodes"
"hamlab/internal/priv"
"hamlab/internal/services"
"hamlab/internal/setup"
"hamlab/internal/space"
"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 [user] set a user's password (makes the account if it is new; reads stdin when piped)
users list the accounts
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
virt-export [name…] export Incus instances to the library (what its cron line calls)
docker-update <id> run one image-update schedule (what its cron line calls)
user-rm <user> remove an account and sign it out everywhere
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 "thumb": // privileged helper for the Files tab's previews in root mode (run as root by the daemon)
os.Exit(media.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
case "space-walk": // helper for the Space tab: measure mountpoints, stream the tree back
os.Exit(space.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
case "drm-clients": // privileged helper for the GPU readings: who has the GPU open (run as root by the daemon)
os.Exit(metrics.DRMClientsHelper(os.Stdin, os.Stdout))
case "cpu-energy": // privileged helper for the CPU power reading: the packages' energy counters (run as root by the daemon)
os.Exit(metrics.CPUEnergyHelper(os.Stdin, os.Stdout))
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, flag.Arg(1)); err != nil {
log.Fatal(err)
}
case "docker-update":
if err := dockerUpdateJob(cfg, flag.Arg(1)); err != nil {
log.Fatal(err)
}
case "zfs-snapshot":
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
log.Fatal(err)
}
case "virt-export": // the nightly cron line from the Virtualization tab; optional instance names
if err := virtExport(cfg, flag.Args()[1:]); err != nil {
log.Fatal(err)
}
case "users":
migrateUsers(cfg)
if len(cfg.Users) == 0 {
fmt.Println("no accounts in", cfg.Path())
}
for _, u := range cfg.Users {
seen := "never"
if !u.LastSeen.IsZero() {
seen = u.LastSeen.Local().Format("2006-01-02 15:04")
}
note := ""
if u.Guest { // a temporary look-only login, not one of the machine's accounts
note = " guest, until " + u.Expires.Local().Format("15:04")
if u.Expired(time.Now()) {
note = " guest, expired"
}
}
fmt.Printf("%-20s created %s last seen %s%s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen, note)
}
case "user-rm":
if err := userRm(cfg, flag.Arg(1)); 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)
}
}
// migrateUsers gives a pre-users config its first account, named after this OS user, so
// the CLI sees the same accounts the daemon will.
// dockerUpdateJob pulls a stack's images, and deploys them when the schedule says to. This
// is what the cron line Ham Lab writes actually calls, so image updates keep happening
// whether or not the console is up.
func dockerUpdateJob(cfg *config.Config, id string) error {
if strings.TrimSpace(id) == "" {
return errors.New("usage: hamlab docker-update <schedule-id>")
}
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
if err != nil {
return err
}
var raw json.RawMessage
st.View(func(s *store.State) { raw = s.DockerJobs })
var d struct {
Jobs []api.UpdateJob `json:"jobs"`
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &d); err != nil {
return fmt.Errorf("reading the schedules: %w", err)
}
}
for _, j := range d.Jobs {
if j.ID != id {
continue
}
// A pull that never finishes must not leave a cron job resident until the next reboot.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
out, runErr := api.RunUpdate(ctx, st, j, api.DockerTool())
rec := storage.RunRecord{At: time.Now(), Taken: j.Stack}
if runErr != nil {
rec.Error = runErr.Error()
}
if err := storage.WriteRun(cfg.DataDir(), "docker-"+id, rec); err != nil {
fmt.Fprintln(os.Stderr, "recording the run:", err)
}
fmt.Print(out)
return runErr
}
return fmt.Errorf("no image-update schedule %q — it may have been removed; this cron line is stale", id)
}
// zfsSnapshot runs one snapshot schedule: take it, then expire what has aged out. This is
// what the cron line Ham Lab writes actually calls, so a schedule keeps running whether or
// not the console is up — and keeps running if Ham Lab is never opened again.
//
// It deliberately does not write state.json. The daemon owns that file and would clobber
// anything written from out here; the outcome goes beside it instead, in zfs-runs/.
func zfsSnapshot(cfg *config.Config, id string) error {
if strings.TrimSpace(id) == "" {
return errors.New("usage: hamlab zfs-snapshot <schedule-id>")
}
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
if err != nil {
return err
}
var raw json.RawMessage
st.View(func(s *store.State) { raw = s.ZFS })
var z struct {
Policies []storage.Policy `json:"policies"`
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &z); err != nil {
return fmt.Errorf("reading the schedules: %w", err)
}
}
var p *storage.Policy
for i := range z.Policies {
if z.Policies[i].ID == id {
p = &z.Policies[i]
}
}
if p == nil {
return fmt.Errorf("no snapshot schedule %q — it may have been removed; this cron line is stale", id)
}
res, runErr := storage.New(priv.Available()).Run(context.Background(), p)
rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned}
if runErr != nil {
rec.Error = runErr.Error()
}
if err := storage.WriteRun(cfg.DataDir(), id, rec); err != nil {
fmt.Fprintln(os.Stderr, "recording the run:", err)
}
if runErr != nil {
return runErr
}
fmt.Printf("%s: took %s", p.Name, res.Taken)
if len(res.Pruned) > 0 {
fmt.Printf(", expired %d", len(res.Pruned))
}
fmt.Println()
return nil
}
// virtExport runs the instance exports the Virtualization tab scheduled, as the account Ham
// Lab runs as (which is in incus-admin), and records the run beside the data.
func virtExport(cfg *config.Config, names []string) error {
who := os.Getenv("USER")
if u, err := user.Current(); err == nil && u.Username != "" {
who = u.Username
}
run := incus.New(cfg.DataDir(), who, version).RunExports(context.Background(), names, func(l string) { fmt.Println(l) })
fmt.Printf("exported %d, pruned %d, %.0f s\n", len(run.Exported), len(run.Pruned), run.Seconds)
if len(run.Errors) > 0 {
return errors.New(strings.Join(run.Errors, "; "))
}
return nil
}
func migrateUsers(cfg *config.Config) {
name := os.Getenv("USER")
if u, err := user.Current(); err == nil && u.Username != "" {
name = u.Username
}
if cfg.MigrateUsers(name) {
_ = cfg.Save()
}
}
func userRm(cfg *config.Config, name string) error {
migrateUsers(cfg)
if name == "" {
return errors.New("usage: hamlab user-rm <user>")
}
if len(cfg.Users) <= 1 {
return errors.New("the last account can't be removed")
}
kept := cfg.Users[:0]
found := false
for _, u := range cfg.Users {
if u.Name == name {
found = true
continue
}
kept = append(kept, u)
}
if !found {
return fmt.Errorf("no account %q", name)
}
cfg.Users = kept
if err := cfg.Save(); err != nil {
return err
}
if st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")); err == nil {
auth.NewSessions(st).RevokeUser(name)
_ = st.Update(func(s *store.State) { delete(s.Users, name) })
}
fmt.Fprintln(os.Stderr, "removed", name, "(the running service picks it up at its next config save; restart it to be sure)")
return nil
}
func passwd(cfg *config.Config, name string) error {
migrateUsers(cfg)
if name == "" {
switch len(cfg.Users) {
case 0:
name = os.Getenv("USER")
if u, err := user.Current(); err == nil && u.Username != "" {
name = u.Username
}
case 1:
name = cfg.Users[0].Name
default:
return errors.New("several accounts: hamlab passwd <user>")
}
}
if !auth.UserNameRe.MatchString(name) {
return fmt.Errorf("%q: a user name is 1–32 lower-case letters, digits, . _ or -", name)
}
var p1 []byte
if !term.IsTerminal(int(os.Stdin.Fd())) { // scripted: one line on stdin
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
p1 = []byte(strings.TrimRight(line, "\r\n"))
} else {
fmt.Fprintf(os.Stderr, "New password for %s: ", name)
var err error
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) < auth.MinPasswordLen {
return errors.New("password must be at least 12 characters")
}
h, err := auth.HashPassword(string(p1))
if err != nil {
return err
}
made := true
for i := range cfg.Users {
if cfg.Users[i].Name == name {
cfg.Users[i].Hash = h
made = false
}
}
if made {
cfg.Users = append(cfg.Users, config.User{Name: name, Hash: h, Created: time.Now()})
}
if err := cfg.Save(); err != nil {
return err
}
if made {
fmt.Fprintln(os.Stderr, "account", name, "made in", cfg.Path())
} else {
fmt.Fprintln(os.Stderr, "password of", name, "set in", cfg.Path())
}
fmt.Fprintln(os.Stderr, "the running service reads the config at its next save; restart it to be sure")
return nil
}
func serve(cfg *config.Config) error {
if cfg.PasswordHash == "" && len(cfg.Users) == 0 {
return fmt.Errorf("no account 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"
}