package main import ( "embed" "flag" "fmt" "io" "os" "os/exec" "os/user" "path/filepath" "regexp" "strconv" "strings" "time" ) // The systemd units ship inside the binary (source of truth: deploy/systemd), so // `lchat bootstrap` can provision a fresh box with no separate files to copy. // //go:embed deploy/systemd/lchat-router.service var unitRouter string //go:embed deploy/systemd/lchat@.service var unitInstance string //go:embed deploy/systemd/lchat-bot@.service var unitBot string // The webhook-ingest sidecar (incoming webhooks, docs/webhooks.md). Installs // everywhere like the bot template; a host only runs one after `add-hooks` // stages its /data/-hooks.env and enables the unit. // //go:embed deploy/systemd/lchat-hooks@.service var unitHooks string // The push relay + home server are optional sibling services (one each per box). // Their units install alongside the rest but stay inert until enabled with a // /data/{push,home}.env present, so a box that runs neither is unaffected. // //go:embed deploy/systemd/lchat-push.service var unitPush string //go:embed deploy/systemd/lchat-home.service var unitHome string // The connect broker (OAuth relay, docs/integrations.md) is the third sibling // service; like push/home it installs everywhere and stays inert until an // operator stages /data/connect.env and enables it. This unit was hand-made on // the box before it lived here — keep the two in step. // //go:embed deploy/systemd/lchat-connect.service var unitConnect string // The links redirector (11ch.at short links, issue #905) is also a sibling // service, but unlike push/home it's a subcommand of this same binary, not a // separate one, and holds no secrets or database. The unit installs // everywhere; only the box declared the short-link apex actually RUNS it // (`bootstrap --links-apex `, converged from the registry thereafter — // see syncLinks). Exactly one box in a fleet is the apex, so every other box // keeps the unit written and disabled. // //go:embed deploy/systemd/lchat-links.service var unitLinks string // The control daemon + cloud provisioner (self-serve space creation — see // docs/cloud-provisioner.md). Like push/home they install everywhere but stay // inert: controld needs an operator enable (its token file is minted below); // cloud additionally needs /data/cloud.env. // //go:embed deploy/systemd/lchat-control.service var unitControl string //go:embed deploy/systemd/lchat-cloud.service var unitCloud string // The trial-tunnel gateway (`lchat try` — see docs/tunnel-try.md). Installs // everywhere, stays inert until enabled; it only matters on a box that fronts // a wildcard domain. // //go:embed deploy/systemd/lchat-tunnel.service var unitTunnel string // The public-web fetch service (`lchat fetch run` — see docs/fetch.md). Installs // everywhere, stays inert until an operator enables it and fronts it with // `add-service`; instances opt in by pointing LCHAT_FETCH_URL at that route. // //go:embed deploy/systemd/lchat-fetch.service var unitFetch string // The auto-updater (updater.go): a root oneshot fired hourly by its timer. // Enabled by bootstrap — a pass with no reachable release feed is a no-op, so // it's safe everywhere, and a box provisioned tomorrow auto-updates without a // runbook step anyone has to remember. // //go:embed deploy/systemd/lchat-update.service var unitUpdate string //go:embed deploy/systemd/lchat-update.timer var unitUpdateTimer string // The public status collectors (docs/status.md): two root oneshots, each fired // by a 5-minute timer — the SSH access ledger and the fleet hardening posture. // They ride the binary (source: deploy/) for the same reason the units do: // with the fleet hardened (operator SSH over WireGuard only, root login off, // /opt/lchat + /etc/systemd/system root-owned) there's no root shell to scp // them onto a box, so the binary must place them itself. See installCollectors. // //go:embed deploy/ssh-ledger-collect.sh var scriptSSHLedger string //go:embed deploy/hardening-posture.sh var scriptHardening string // The main publish collector (fleet + books + the viewer-page uploads) rides // the binary too, for the same no-root-scp reason — so its per-page upload set // stays reconciled with main on every `bootstrap --collectors-only`, instead of // drifting stale on the box (which is how funding/roadmap uploads went missing). // Only the SCRIPT is embedded; its 5-minute timer is installed separately by the // script's own `install` verb (docs/status.md), which bootstrap leaves alone. // //go:embed deploy/status-publish.sh var scriptStatusPublish string //go:embed deploy/systemd/lchat-ssh-ledger.service var unitSSHLedger string //go:embed deploy/systemd/lchat-ssh-ledger.timer var unitSSHLedgerTimer string //go:embed deploy/systemd/lchat-hardening.service var unitHardening string //go:embed deploy/systemd/lchat-hardening.timer var unitHardeningTimer string // statusPagesFS carries the static status viewer pages the collectors upload — // the .html, the shared status.css, and the static page scripts: the shared // masthead+nav builder (status-nav.js — every page mounts it; forgetting it // here renders the whole family navless, hence the embed test) and the // funding page's normative waterfall.js. // The JSON those pages fetch (fleet, carbon, ssh, hardening, contributors, …) // is collector- and operator-generated and lives mutably on the box, so it is // deliberately NOT embedded: baking it in would clobber live data with a stale // snapshot on every deploy. // //go:embed web/status/*.html web/status/status.css web/status/status-nav.js web/status/waterfall.js var statusPagesFS embed.FS // serviceUser is the unprivileged account every unit runs as (see the hardened // units in deploy/systemd). bootstrap creates it and hands it the data + runtime // dirs; the router keeps CAP_NET_BIND_SERVICE to still bind :80/:443. const serviceUser = "lchat" // tmpfilesConf keeps /run/lchat across reboots (/run is tmpfs); bootstrap also // creates it now. Owned by the service user so the non-root units can bind their // sockets there. const tmpfilesConf = "d /run/lchat 0750 " + serviceUser + " " + serviceUser + " -\n" // ensureServiceUser creates the locked, home-less system user the units run as, // idempotently. func ensureServiceUser() { if _, err := user.Lookup(serviceUser); err == nil { return } out, err := exec.Command("useradd", "--system", "--user-group", "--no-create-home", "--shell", "/usr/sbin/nologin", serviceUser).CombinedOutput() if err != nil { die("bootstrap: create user %s: %v: %s", serviceUser, err, strings.TrimSpace(string(out))) } } // chownToService recursively gives a path to the service user (best-effort exec of // chown -R, so it also fixes ownership of any data already present). var chownToService = func(path string) { out, err := exec.Command("chown", "-R", serviceUser+":"+serviceUser, path).CombinedOutput() if err != nil { die("bootstrap: chown %s: %v: %s", path, err, strings.TrimSpace(string(out))) } } // ensureControlToken mints the control daemon's bearer token into a ROOT-only // file, idempotently. Root-only is the point: every service shares the lchat // uid, so this token — not socket permissions — is what separates "may create // instances" (the cloud unit, via LoadCredential) from everything else. func ensureControlToken() error { if _, err := os.Stat(controlTokenPath); err == nil { return nil } tok, err := randToken() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(controlTokenPath), 0o700); err != nil { return err } if err := os.WriteFile(controlTokenPath, []byte(tok+"\n"), 0o600); err != nil { return err } fmt.Printf(" minted %s (root-only)\n", controlTokenPath) return nil } // writeFile writes content to path at mode, dying on failure. bootstrap is a // one-shot root command, so a half-written unit/script means a half-provisioned // box: fail loud rather than limp on. mode is 0644 for units + pages, 0755 for // the executable collector scripts. func writeFile(path, content string, mode os.FileMode) { if err := os.WriteFile(path, []byte(content), mode); err != nil { die("bootstrap: write %s: %v", path, err) } fmt.Printf(" wrote %s\n", path) } // collectorTimers are the status collectors' timer units. They're oneshot + // timer (not long-running services), so `enable --now` is the right verb — it // enables the timer and fires one pass now, with no active service to leave // stale the way `--now` would for the router. var collectorTimers = []string{"lchat-ssh-ledger.timer", "lchat-hardening.timer"} // writeCollectorFiles lays down the box-managed status collectors: the two root // oneshot scripts (executable) + their four systemd units, the main publish // script (executable; its timer is installed by the script's own `install` // verb, not here), and the static viewer pages the collectors upload. It writes // files ONLY — the caller reloads systemd and enables the timers — so full // bootstrap (one daemon-reload covering every unit) and the `--collectors-only` // refresh share exactly this install logic. // // appDir is the on-box install root (/opt/lchat): each unit's ExecStart points at // /