diff --git a/.claude/docs/specs/2026-08-25-server-role.md b/.claude/docs/specs/2026-08-25-server-role.md new file mode 100644 index 0000000..bb72143 --- /dev/null +++ b/.claude/docs/specs/2026-08-25-server-role.md @@ -0,0 +1,230 @@ +# Panama server role: headless installs and versioned containers + +2026-08-25. Designed with Gib across two interview rounds; every decision below +was made explicitly, none inferred. + +## Goal + +Panama becomes the one repo for every Fedora machine Gib runs. A desktop gets +what it gets today. A server — a Hetzner VPS, a future home server — gets the +same shell environment (bash profile, prompt, tmux-on-ssh, agents) minus +everything that needs a screen, plus rootless podman, systemd user units, and a +versioned catalog of compose services that `panama server enable` brings up. +The repo stays public; secrets never enter it. + +## Decisions of record + +| Decision | Choice | +|---|---| +| Repo | One public monorepo. No companion repo. `.gitignore` + a secrets contract keep it safe | +| Role | Asked first in the interview (`desktop` / `server`), persisted to `~/.local/state/panama/role`; `--server` on `boot`/`install` presets it | +| Layout | Top-level `server/`, services under `server/containers//` (TitleCase, matching junior) | +| Tracked per service | `compose.yml`, `podman-.service`, `.env.example`. Nothing else | +| `~/Server` | Real directories. Only `compose.yml` is symlinked into them; units symlink into `~/.config/systemd/user/`. Live `.env` and data stay outside the repo tree entirely | +| Data dirs | Standardized to `./data/`, applied per service at cutover (a stop/rename/start), never at import | +| Enablement | `panama server` subcommand family, not the interview | +| Units | Checked in per service (they legitimately differ), installed as symlinks so edits flow with `panama update` | +| Updates | `panama update` relinks and `daemon-reload`s but never restarts a running service; it names services whose definitions changed | +| Nightly image updates | junior's `update-containers.sh` + `podman-update.timer` adopted into the repo (it replaced watchtower for cause) | +| Codex | Installed everywhere, same pattern as Claude Code | +| Ports | 80/443/81 only. Anything else is opened by hand and documented in the service's folder | +| VPS bootstrap | `boot` run as root walks user-creation/keys/sshd-hardening, each step skipped when already true, then re-runs as the user | +| Out of scope | server.gib (docker; translate later), backup and verify-rootless (own design later), cutover of junior's live services (later, per service) | + +## 1. Machine role + +- New interview question, asked first: "What is this machine?" — `desktop` or + `server` via `gum choose`. `./install --server` (and `boot --server`) records + the answer without asking. +- Persisted to `~/.local/state/panama/role`, plain text, one word. This is the + first durable answer Panama keeps, and deliberately so: the transient-answers + principle exists to keep *personal* data out of durable paths, and a machine + role is a fact about the machine. `install`, `panama update`, `panama doctor` + and future migrations read it. A machine with no role file is a desktop — + every machine that predates this work is one. +- On role=server the interview skips: hardware probes (NVIDIA, Secure Boot, + firmware — and their bootstrap tools; only `gum` is bootstrapped), the + debloat question, and the extras checklist. It keeps: hostname, git + identity, gh sign-in, SSH key, user-content. + +## 2. Stages by role + +Role=desktop is exactly today's behavior. Role=server runs: + +| Stage | Server behavior | +|---|---| +| `install-packages` | Core + server lists (§3). No flatpaks, no COPRs, no desktop repos. Claude Code and Codex install for both roles | +| `link-dotfiles` | Universal dot dirs only: `btop`, `nvim`, `ohmyposh`, `tmux`, `vim`, `panama`. Desktop-only (everything else: `hypr`, `quickshell`, `kitty`, `ghostty`, `espanso`, `gtk-*`, `uwsm`, `vicinae`, `wofi`, `xdg-desktop-portal`) is skipped via an explicit classification list in the stage — a new dot dir must be classified or the contract fails. Bash profile links as today; Firefox/wallpaper/cursor seeding skipped | +| `link-user` | Unchanged (SSH aliases and agent rules are exactly what a server wants) | +| `setup-identity` | Unchanged | +| `setup-server` (new) | §4 | +| `link-server` (new) | Creates `~/Server`, then delegates to `panama-server relink` so link repair has one owner | +| Skipped | `link-skills` (all three shipped skills are desktop-specific today; revisit when a server skill exists), `change-settings`, `link-vicinae-scripts`, `install-hardware` | + +Migrations run on servers too — they are self-guarding no-ops where they do +not apply. `panama doctor` learns the role: on a server it reports service +health via `panama-server status` instead of probing desktop units. + +## 3. Package restructure + +`setup/packages/` gains `core-packages` (both roles) and `server-packages`; +`initial-packages` keeps what is desktop-only. Nothing is added or dropped for +desktops — the union of core + desktop lists equals today's set. + +- **core-packages**: awk, bat, btop, curl, dnf5-plugins, eza, fontconfig, fzf, + gh, git-all, gum, jq, neovim, oh-my-posh, openssh, openssl, podman, + podman-compose, python3-dnf, ripgrep, tmux, unzip, wget, zoxide. + (`podman` moves here from development-packages; `podman-compose` is what + provides `podman compose` on junior — verified, 1.6.0.) +- **server-packages**: nvm (Node via nvm, as on desktop — agents and MCP + servers want it, and Codex installs through npm). +- **initial-packages** (desktop-only remainder): cargo, rustup, flatpak, fwupd, + kitty, ksshaskpass, libselinux-utils, pciutils, python3-neovim, + wireguard-tools, vim-X11 — plus everything else currently there that the + core list did not claim. +- Codex CLI: installed in `install-packages` beside the Claude Code step — + skip when `codex` is on PATH, `npm install -g @openai/codex` (after nvm/node + is available), soft-fail like Claude Code does. +- The package-hash mechanic in `install` is untouched; new files under + `-maxdepth 1` join the hash automatically. + +## 4. `setup-server` stage + +Idempotent, like every stage; each item checks before acting: + +1. `loginctl enable-linger $USER`. +2. `/etc/sysctl.d/99-rootless-ports.conf` → `net.ipv4.ip_unprivileged_port_start=80`, + applied with `sysctl --system` (junior already has exactly this file). +3. firewalld: open 80, 443, 81 (NPM admin portal), permanent + runtime. + Nothing else — reverse-proxy philosophy, ports open only when unavoidable. +4. `podman network create nginx-bridge` if absent (every compose expects it, + `external: true`). +5. Nightly image updates: `update-containers.sh` is adopted into the repo as + `server/scripts/update-containers` (no `.sh`, per repo rule — junior's copy + is imported and its `ExecStart`/log paths updated), with + `server/systemd/podman-update.service` and `.timer` symlinked into + `~/.config/systemd/user/` and the timer enabled. Logs go to + `~/Server/logs/` as today. + +## 5. The `panama server` command + +`bin/panama-server`, surfaced as `panama server ` (README's subcommand +table and its contract update with it): + +- `list` — every service in `server/containers/`, with enabled/active state. +- `enable ` — create `~/Server//`; symlink `compose.yml`; seed + `~/Server//.env` from `.env.example` when the example exists and the + live file does not; if the seeded `.env` still contains placeholder values, + stop there and say what to fill in — do **not** start; otherwise symlink the + unit, `daemon-reload`, `enable --now`. +- `disable ` — `disable --now`, remove the unit symlink, + `daemon-reload`. `~/Server/` and its data are never touched. +- `status []` — unit state plus `podman compose ps` per service. +- `relink` — for every service whose unit symlink exists: refresh both + symlinks, `daemon-reload`, and report services whose definitions changed + since the last run (so the operator restarts deliberately). Called by the + `link-server` stage and by `panama update` on role=server. + +Live `.env` files live in `~/Server//`, not in the repo tree — the repo +carries only `.env.example`. `.gitignore` still covers `.env` everywhere under +`server/` as belt and braces. + +## 6. VPS bootstrap (`boot` as root) + +`boot` gains a root path. When `EUID == 0`, before anything else, walk these +steps, each checked and skipped when already true: + +1. Ask for a username (default `gib`). Create it with `wheel` if absent; if it + exists, use it. +2. Ensure the user has a password (needed for sudo) — `passwd` interactively + if none is set. +3. Copy root's `authorized_keys` to the user when the user has none. +4. Offer sshd hardening (yes/no, default yes): drop-in under + `/etc/ssh/sshd_config.d/` with `PermitRootLogin no`, + `PasswordAuthentication no`, then reload sshd. Skipped when already set. +5. Move/clone the checkout under the user's home, chown it, and re-exec + `install --server` as that user. + +Run as a normal user, `boot` behaves exactly as today (plus passing `--server` +through). + +## 7. Importing junior's services (the first population) + +A one-time operation, performed against junior over SSH, that **never stops, +starts, or restarts anything**. For each dedicated compose dir: + +1. Copy `compose.yml` into `server/containers//`. +2. Take the **installed** unit (`~/.config/systemd/user/podman-.service`) + as truth; diff against the copy in the service dir and flag any divergence. +3. Move inline secrets out: secret-bearing values in the repo copy of + `compose.yml` become `${VAR}` interpolations; extracted values are written + to `~/Server//.env` **on junior** (inert until cutover — the live + compose still carries them inline); `.env.example` with placeholder values + is committed. Services already using `.env`/`env_file` get an + `.env.example` generated from the live `.env` with values blanked. +4. Verify without touching the stack: `podman compose config` rendered from + the repo copy (with the live `.env`) must match the render of the live + compose. Volume paths are kept **verbatim** at import (`volumes/`, + `volume/`, `pg_data/` and all) so this diff can be exact; the `data/` + standardization happens per service at cutover. +5. Commit. The secrets contract (§8) gates every one of these commits. + +**Import list (23):** Adminer, Agentchat, Authentik, Beszel, CalCom, +ConvexMonorepo, Documenso, Gitea, Infisical, ISponsorBlockTV, LashAddict, N8n, +Nginx_Proxy_Manager, Penpot, Portainer, PostgreSQL, Rustdesk, Spoon, StPeteIT, +Tools, Uptime, UseSend, Vaultwarden. CalCom, Documenso, Penpot and N8n are +currently parked (dir present, unit not active) — imported anyway, noted as +parked in the catalog. + +**Excluded:** Watchtower (retired — replaced by the update timer after it took +gitea down; its dir stays on junior until cleanup), Completeuphoria, +sierraandtyler, Bang (source checkouts deployed by CI from their own repos — +Panama owning a second copy of their compose would drift), `ci/`, `logs/`, +and junior's loose scripts other than `update-containers.sh` (§4). + +**Cutover** (per service, later, not this work): stop → rename the volume dir +to `data/` and update the repo compose to match → replace live compose with +the symlink, installed unit with the symlink → `daemon-reload` → start → +verify. Ten minutes a service, done when Gib chooses, favoring quiet hours. + +## 8. Contracts and docs + +New `tests/server/`, same discipline as the rest: + +- `compose-secrets-contract` — fails when any tracked file under `server/` + contains an inline secret-looking value (the `user-content-contract` + pattern), when `.gitignore` stops covering `.env` under `server/`, or when + `git ls-files` shows a tracked `.env`. +- `containers-shape-contract` — every `server/containers//` carries + `compose.yml` + `podman-.service`; every unit's `WorkingDirectory` is + `%h/Server/`; every `${VAR}` used in a compose file appears in that + service's `.env.example`. +- `panama-server-contract` — throwaway `HOME`, stubbed `systemctl`/`podman` on + PATH: `enable` creates the right symlinks and seeds `.env`; placeholder + values block start; `disable` leaves data alone; `relink` reports changed + definitions. +- `role-contract` — the interview asks role first, `--server` presets it, the + role file is written, stages filter correctly per role (via the stage + scripts' static/stubbed seams). +- `dotfile-classification-contract` — every dir in `config/dot/` appears in + exactly one of link-dotfiles' universal/desktop lists. +- README: layout section gains `server/`, the subcommand table gains + `panama server`, the pinned contract count moves — `readme-contract` and the + interview/link-dotfiles contracts that assert today's behavior update in the + same change. + +## Phasing + +1. **Role plumbing** — interview question, role file, `--server`, package + split, stage filters, `setup-server`, boot's root path. +2. **Server skeleton** — `server/` tree, `panama-server`, `link-server`, + update-timer adoption, contracts, README/docs. +3. **Import** — junior's 23 services into `server/containers/` per §7. +4. **Later, separately** — per-service cutover on junior; backup and + verify-rootless design; server.gib docker→podman translation; a real + end-to-end test on a fresh VPS. + +Testing note: contracts for phases 1–2 run with stubs and throwaway HOMEs, but +per standing practice all suite runs batch at the end of the session with +Gib's go-ahead. Phase 3 touches junior only with read commands plus the inert +`.env` writes described in §7. diff --git a/.gitignore b/.gitignore index 56620dd..80fdf73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ # Ignore bash environment variables, and the lock its writer takes. /config/bash/env /config/bash/.env.lock +# Server secrets and state never enter the repo: live .env files belong in +# ~/Server// (only compose.yml is symlinked back here), and data/ is +# the bind-mount convention. Ignored anyway as a seatbelt -- the repo is +# public, and tests/server/compose-secrets-contract is the check that this +# line still exists. +/server/containers/**/.env +/server/containers/**/data/ # Personal espanso triggers (name, email), seeded per-machine by setup-identity. /config/dot/espanso/match/identity.yml # Ignore backups of old config files diff --git a/README.md b/README.md index e851bba..944e21d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,22 @@ It pulls, applies any repairs this machine has not had, and runs the stages below that need no answers. `./install` remains what it is: how a machine is built, and how you change an answer you gave. +The first question the interview asks is what the machine is: a **desktop** +gets everything below; a **server** — a VPS, a headless box — gets the same +shell environment minus everything that needs a screen, plus rootless podman +and the compose services in [`server/`](server/README.md). The answer is the +one thing Panama records durably (`~/.local/state/panama/role`), because +`panama update` asks nothing and still has to know. A fresh server skips the +question entirely: + +```sh +bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot) --server +``` + +That command also works from a brand-new VPS's **root** login: it creates +your user with sudo, moves the SSH key over, offers to harden sshd, and hands +off to a normal install as that user. + `install` asks its questions first and then runs the stages in `setup/scripts/` in order, without stopping again: @@ -43,6 +59,15 @@ in order, without stopping again: | `link-vicinae-scripts` | Publishes the Vicinae script commands | | `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for | | `install-hardware` | NVIDIA, Secure Boot enrollment, Fedora's extras, firmware — each only if it was asked for. Last, because enrollment and firmware are consumed at the next boot | +| `setup-server` | Server role only: linger, unprivileged ports from 80, firewalld (80/443/81), the `nginx-bridge` network, and the nightly image-update timer | +| `link-server` | Server role only: creates `~/Server` and refreshes the links of whatever services this machine has enabled | + +A server runs the shared stages plus its own two, and skips the rest — +`link-skills`, `change-settings`, `link-vicinae-scripts` and +`install-hardware` are desktop concerns. `link-dotfiles` links only the +universal dot dirs there (bash, nvim, tmux, btop and tmux theming, the hook +samples), and `install-packages` takes a short path: the core tools, nvm, +Bun, Claude Code and Codex — no third-party repos, no desktop, no flatpaks. Then `panama migrate` applies any repairs this machine has not had yet. That is the half of an upgrade installing cannot do: the stages above only ever add, so @@ -157,6 +182,9 @@ config/ extensions, linked into ~/.local/share old/ Backups of whatever was replaced (gitignored) wallpapers/ Copied into ~/Pictures/Wallpapers when absent +server/ The server role: compose services (one directory per + service), the nightly image updater, and its units. See + server/README.md skills/ Agent skills for operating this desktop, linked into ~/.claude/skills setup/ @@ -170,7 +198,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -180 of them, under `tests/`. Run the lot, or a subset by pattern: +185 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything @@ -190,6 +218,8 @@ been ones that source text looked fine for. tests/setup/ The installer: the interview, package lists, hardware, extras tests/quickshell/ The shell and its settings pages tests/hypr/ The compositor config +tests/server/ The server role: the service catalog's shape, the secrets + rule that keeps a public repo safe, and panama-server ``` ## Projects @@ -220,6 +250,7 @@ panama migrate # apply repairs this machine has not had yet panama upgrade # re-run ./install from anywhere, interview and all panama apps # choose applications to install, by category panama app # applications no repository carries; build one by name +panama server # the services a server runs: list, enable, disable, status ``` `panama update` and `panama sync` are separate verbs on purpose. One acts on diff --git a/bin/panama b/bin/panama index bc86835..7abd526 100755 --- a/bin/panama +++ b/bin/panama @@ -103,6 +103,8 @@ ${BOLD}Commands:${RESET} install. ${GREEN}app${RESET} Build and install an application that neither dnf nor Flathub carries. With no name, lists what is available. + ${GREEN}server${RESET} The compose services a server machine runs: list, enable, + disable, status, relink. See 'panama server help'. ${GREEN}help${RESET} Show this help (also -h, --help). ${BOLD}Options:${RESET} @@ -892,6 +894,7 @@ main() { migrate) shift; cmd_migrate "$@" ;; app) shift; cmd_app "$@" ;; apps) shift; cmd_apps "$@" ;; + server) shift; exec "$PANAMA_DIR/bin/panama-server" "$@" ;; help|-h|--help|"") usage ;; --version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;; *) diff --git a/bin/panama-server b/bin/panama-server new file mode 100755 index 0000000..80a7f92 --- /dev/null +++ b/bin/panama-server @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# +# panama-server – the compose services a server machine runs. +# +# The repository carries the catalog: server/containers// holds a +# compose.yml, a podman-.service user unit, and an .env.example naming +# what the service needs told. The machine carries the rest: ~/Server// +# is a real directory owning the .env (secrets never sit inside the checkout, +# gitignored or not) and the bind-mounted data. Only the tracked files are +# symlinked, so a `git clean` in the repo can never reach a database. +# +# list every service in the catalog, with its state here +# enable link it into ~/Server, seed .env, enable the unit +# disable stop it and remove the unit; data and .env stay put +# status [] what is actually running +# relink refresh the symlinks for everything enabled here +# +# Verbs act on the unit and the links, never on data: there is deliberately no +# verb here that deletes ~/Server/ or anything in it. + +set -uo pipefail + +PROGRAM="panama server" + +SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}") +PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd) + +# Overridable so the contract can point this at a fixture catalog and a +# throwaway HOME rather than the machine's real services. +SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/containers}" +TARGET_DIR="${PANAMA_SERVER_TARGET:-$HOME/Server}" +UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/server-definitions" + +if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then + BOLD=$(tput bold); RESET=$(tput sgr0) + RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); BLUE=$(tput setaf 4) +else + BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE="" +fi + +info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; } +ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; } +warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; } +err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; } + +usage() { + cat < Link a service into ~/Server, seed its .env from + .env.example, and enable its unit. Stops short of + starting when the .env still holds CHANGE_ME + placeholders, and says which. + $PROGRAM disable Stop the unit and unlink it. ~/Server/, + its .env and its data are never touched. + $PROGRAM status [] What is actually running, per service + $PROGRAM relink Refresh every enabled service's symlinks after a + pull, and name the ones whose definitions changed + (nothing is restarted for you) +EOF +} + +# ── The catalog ────────────────────────────────────────────────────────────── + +services() { + local dir + for dir in "$SERVER_DIR"/*/; do + [[ -d "$dir" ]] || continue + basename "$dir" + done +} + +require_service() { + local name="$1" + if [[ ! -d "$SERVER_DIR/$name" ]]; then + err "No such service: '$name'" + printf 'The catalog has:\n' >&2 + services | sed 's/^/ /' >&2 + exit 1 + fi +} + +# The one unit file a service directory carries. Its filename is its identity +# -- podman-.service, exactly what lands in the systemd user directory -- +# so nothing here invents a name that could drift from the file's. +unit_path_for() { + local name="$1" unit + for unit in "$SERVER_DIR/$name"/*.service; do + [[ -e "$unit" ]] || continue + printf '%s' "$unit" + return 0 + done + return 1 +} + +unit_name_for() { + local unit + unit="$(unit_path_for "$1")" || return 1 + basename "$unit" +} + +# Installed means our symlink is in the systemd user directory. A regular file +# of the same name is a pre-Panama install done by hand -- reported, never +# silently replaced. +unit_installed() { + local unit_name + unit_name="$(unit_name_for "$1")" || return 1 + [[ -L "$UNIT_DIR/$unit_name" ]] +} + +definition_hash() { + local name="$1" unit + unit="$(unit_path_for "$name")" || unit=/dev/null + cat "$SERVER_DIR/$name/compose.yml" "$unit" 2>/dev/null | sha256sum | cut -d' ' -f1 +} + +recorded_hash() { + [[ -r "$STATE_FILE" ]] || return 0 + awk -v name="$1" '$1 == name {print $2}' "$STATE_FILE" +} + +record_hash() { + local name="$1" hash="$2" + mkdir -p "$(dirname "$STATE_FILE")" + { [[ -r "$STATE_FILE" ]] && awk -v name="$name" '$1 != name' "$STATE_FILE"; \ + printf '%s %s\n' "$name" "$hash"; } >"$STATE_FILE.tmp" + mv "$STATE_FILE.tmp" "$STATE_FILE" +} + +# Replace path with a symlink to src. An existing regular file is moved aside +# with its name intact plus a suffix, because the only regular file that can be +# here is a pre-Panama one somebody wrote by hand -- worth keeping to diff. +link_file() { + local src="$1" path="$2" + if [[ -L "$path" ]]; then + [[ "$(readlink -f "$path")" == "$(readlink -f "$src")" ]] && return 0 + rm "$path" + elif [[ -e "$path" ]]; then + mv "$path" "$path.pre-panama" + warn "Kept the existing $(basename "$path") as $(basename "$path").pre-panama" + fi + ln -s "$src" "$path" +} + +# ── Verbs ──────────────────────────────────────────────────────────────────── + +cmd_list() { + local name unit_name state enabled + for name in $(services); do + unit_name="$(unit_name_for "$name")" || { printf '%-24s %s\n' "$name" "no unit file"; continue; } + if unit_installed "$name"; then + state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)" + enabled="enabled" + [[ "$state" == active ]] && state="${GREEN}active${RESET}" || state="${YELLOW}${state:-unknown}${RESET}" + printf '%-24s %s, %b\n' "$name" "$enabled" "$state" + elif [[ -e "$UNIT_DIR/$unit_name" ]]; then + printf '%-24s %s\n' "$name" "installed by hand (not Panama's symlink)" + else + printf '%-24s %s\n' "$name" "-" + fi + done +} + +cmd_enable() { + local name="${1:-}" + [[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM enable "; exit 1; } + require_service "$name" + + local repo_dir="$SERVER_DIR/$name" live_dir="$TARGET_DIR/$name" + [[ -f "$repo_dir/compose.yml" ]] || { err "$name has no compose.yml in the catalog"; exit 1; } + local unit_path unit_name + unit_path="$(unit_path_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; } + unit_name="$(basename "$unit_path")" + + mkdir -p "$live_dir" + link_file "$repo_dir/compose.yml" "$live_dir/compose.yml" + ok "Linked compose.yml → $live_dir/compose.yml" + + # The .env lives with the machine, not the checkout. Seeded once from the + # example and never overwritten -- it is where the person's secrets go. + if [[ -f "$repo_dir/.env.example" && ! -e "$live_dir/.env" ]]; then + cp "$repo_dir/.env.example" "$live_dir/.env" + chmod 600 "$live_dir/.env" + ok "Seeded $live_dir/.env from .env.example" + fi + + # Refusing to start on placeholders, and saying which: a service brought up + # with CHANGE_ME as its database password does not fail loudly, it runs -- + # and what it runs is a service whose password is CHANGE_ME. + if [[ -e "$live_dir/.env" ]] && grep -q 'CHANGE_ME' "$live_dir/.env"; then + warn "$live_dir/.env still has placeholder values:" + grep -n 'CHANGE_ME' "$live_dir/.env" | sed 's/^/ /' + printf 'Fill them in, then run: %s enable %s\n' "$PROGRAM" "$name" + exit 1 + fi + + mkdir -p "$UNIT_DIR" + link_file "$unit_path" "$UNIT_DIR/$unit_name" + systemctl --user daemon-reload + if systemctl --user enable --now "$unit_name"; then + record_hash "$name" "$(definition_hash "$name")" + ok "$name enabled and started ($unit_name)" + else + err "$unit_name did not start; see: systemctl --user status $unit_name" + exit 1 + fi +} + +cmd_disable() { + local name="${1:-}" + [[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM disable "; exit 1; } + require_service "$name" + local unit_name + unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; } + + systemctl --user disable --now "$unit_name" 2>/dev/null || true + if [[ -L "$UNIT_DIR/$unit_name" ]]; then + rm "$UNIT_DIR/$unit_name" + systemctl --user daemon-reload + fi + ok "$name disabled. ~/Server/$name, its .env and its data were not touched." +} + +cmd_status() { + local name="${1:-}" + if [[ -n "$name" ]]; then + require_service "$name" + local unit_name + unit_name="$(unit_name_for "$name")" || { err "$name has no unit file"; exit 1; } + systemctl --user status --no-pager "$unit_name" || true + if [[ -d "$TARGET_DIR/$name" ]]; then + ( cd "$TARGET_DIR/$name" && podman compose ps 2>/dev/null ) || true + fi + return 0 + fi + + # The role's health summary: what is enabled here, and is it actually up. + local any=0 unit_name state + for name in $(services); do + unit_installed "$name" || continue + any=1 + unit_name="$(unit_name_for "$name")" + state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)" + if [[ "$state" == active ]]; then + ok "$name" + else + err "$name is ${state:-unknown}" + fi + done + (( any )) || info "No services enabled on this machine yet. See: $PROGRAM list" + return 0 +} + +cmd_relink() { + local name unit_path unit_name changed=() unmanaged=() refreshed=0 + for name in $(services); do + unit_path="$(unit_path_for "$name")" || continue + unit_name="$(basename "$unit_path")" + + if [[ -e "$UNIT_DIR/$unit_name" && ! -L "$UNIT_DIR/$unit_name" ]]; then + unmanaged+=("$name") + continue + fi + unit_installed "$name" || continue + + link_file "$unit_path" "$UNIT_DIR/$unit_name" + mkdir -p "$TARGET_DIR/$name" + link_file "$SERVER_DIR/$name/compose.yml" "$TARGET_DIR/$name/compose.yml" + refreshed=$((refreshed + 1)) + + # Changed since the last time this machine looked, which is what makes a + # pull actionable: the restart is deliberately yours to run, so the least + # this can do is say which services are running a definition that is no + # longer what the repository says. + local now + now="$(definition_hash "$name")" + if [[ "$(recorded_hash "$name")" != "$now" ]]; then + changed+=("$name") + record_hash "$name" "$now" + fi + done + + systemctl --user daemon-reload 2>/dev/null || true + info "Relinked $refreshed enabled service(s)" + if (( ${#unmanaged[@]} > 0 )); then + warn "Installed by hand, left alone: ${unmanaged[*]}" + fi + if (( ${#changed[@]} > 0 )); then + warn "Definitions changed; restart each when ready:" + for name in "${changed[@]}"; do + printf ' %s: systemctl --user restart %s\n' "$name" "$(unit_name_for "$name")" + done + fi +} + +# ── Dispatcher ─────────────────────────────────────────────────────────────── + +main() { + if [[ ! -d "$SERVER_DIR" ]]; then + err "No service catalog at $SERVER_DIR" + exit 1 + fi + local cmd="${1:-}" + case "$cmd" in + list) shift; cmd_list "$@" ;; + enable) shift; cmd_enable "$@" ;; + disable) shift; cmd_disable "$@" ;; + status) shift; cmd_status "$@" ;; + relink) shift; cmd_relink "$@" ;; + help|-h|--help|"") usage ;; + *) + err "Unknown command: '$cmd'" + echo + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/boot b/boot index 4520bbc..48eb65d 100755 --- a/boot +++ b/boot @@ -3,12 +3,17 @@ # Panama's front door: the one command a fresh Fedora machine needs. # # bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot) +# bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot) --server # # Deliberately dumb, because a copy of this script leaves the repository the # moment somebody curls it -- nothing here can be fixed by re-running # ./install, so there is as little here as possible: get git, get the clone, # hand off. Everything with judgment in it lives in `install`, which is also # where re-runs and upgrades already work. +# +# The one exception to dumb is the root path below, which cannot live in +# `install`: a fresh VPS hands you a root login and nothing else, and the user +# that `install` needs to exist is exactly what has not been created yet. set -euo pipefail @@ -16,17 +21,119 @@ REPO_URL="https://git.gbrown.org/gib/Panama.git" PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" export PANAMA_PATH -# Root would put the clone and every dotfile in root's home and run the -# desktop setup for the wrong user. sudo is used inside where it is needed. -if [[ "$(id -u)" -eq 0 ]]; then - echo "Run this as your own user, not root: the install configures YOUR desktop." >&2 +SERVER=0 +INSTALL_ARGS=() +for arg in "$@"; do + case "$arg" in + --server) SERVER=1; INSTALL_ARGS+=(--server) ;; + *) + printf 'boot: unknown argument: %s\n' "$arg" >&2 + printf 'usage: boot [--server]\n' >&2 + exit 2 ;; + esac +done + +# Panama assumes Fedora's repositories and package names. +if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then + echo "This looks like something other than Fedora; Panama only supports Fedora." >&2 exit 1 fi -# Panama assumes Fedora's repositories, package names, and GNOME base install. -if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then - echo "This looks like something other than Fedora; Panama only supports Fedora Workstation." >&2 - exit 1 +# ── Root ───────────────────────────────────────────────────────────────────── +# +# On a desktop, root is a mistake: the clone and every dotfile would land in +# root's home and configure the wrong user. On a fresh VPS it is the starting +# condition -- Hetzner hands over a root login and nothing else -- so with +# --server this walks the machine from that to a normal Panama install: a +# user with sudo, keys, an optionally hardened sshd, and `install --server` +# running as that user. Every step checks before acting, because the machine +# may be anywhere along this path already: a user half-created by hand, keys +# already copied, sshd already locked down. +if [[ "$(id -u)" -eq 0 ]]; then + if (( ! SERVER )); then + echo "Run this as your own user, not root: the install configures YOUR desktop." >&2 + echo "Setting up a fresh server from its root login is: boot --server" >&2 + exit 1 + fi + + # Everything here asks, and a root shell from `bash <(curl ...)` can have + # the pipe as stdin, so every prompt reads the terminal explicitly. + if ! (exec /dev/null; then + echo "No terminal to ask on; run this from an interactive root shell." >&2 + exit 1 + fi + + printf 'Username for this server [gib]: ' + read -r username /dev/null 2>&1; then + echo "User $username already exists" + # wheel is what makes sudo work on Fedora; a user created by hand may not + # have it, and everything after this depends on it. + id -nG "$username" | grep -qw wheel || usermod -aG wheel "$username" + else + echo "Creating $username with sudo (wheel)" + useradd -m -G wheel "$username" + fi + + # useradd leaves the account locked, and sudo asks for this password -- a + # user who cannot sudo is a user the install cannot run as. + if ! passwd -S "$username" 2>/dev/null | awk '{exit $2 != "PS" && $2 != "P"}'; then + echo "Set a password for $username (sudo will ask for it):" + passwd "$username" "$SSHD_DROPIN" + systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true + echo "Wrote $SSHD_DROPIN; make sure your key works before logging out." + fi + fi + + if ! command -v git >/dev/null 2>&1; then + echo "Installing git, which the clone needs" + dnf install -y git + fi + + # Cloned straight into the user's home and owned by them: this is the + # checkout `panama update` will pull from for the life of the machine, and + # a root-owned .git in a user's home is a wound that never heals. + PANAMA_PATH="$user_home/.local/share/Panama" + if [[ -d "$PANAMA_PATH/.git" ]]; then + echo "Panama is already cloned at $PANAMA_PATH; updating" + runuser -u "$username" -- git -C "$PANAMA_PATH" pull --ff-only \ + || echo "Could not fast-forward; installing from the clone as it is" >&2 + else + runuser -u "$username" -- mkdir -p "$user_home/.local/share" + runuser -u "$username" -- git clone "$REPO_URL" "$PANAMA_PATH" + fi + + echo "Handing off to install as $username" + exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \ + "$PANAMA_PATH/install" --server /dev/null; then - exec "$PANAMA_PATH/install" /dev/null 2>&1 || bootstrap+=(gum) - command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils) - command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil) - command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd) + # The probe tools serve only the hardware questions, which a server is never + # asked -- installing lspci on a VPS to not use it would be the interview + # costing packages the machine has no reason to carry. Only gated when the + # role is already known; a plain ./install on a server still bootstraps them, + # harmlessly, because the role is not known until the interview answers. + if [[ "$ROLE_PRESET" != server ]]; then + command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils) + command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil) + command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd) + fi if (( ${#bootstrap[@]} > 0 )); then echo "Installing what the setup questions are built on: ${bootstrap[*]}" sudo dnf install -y "${bootstrap[@]}" >/dev/null || { @@ -190,16 +203,35 @@ if (( ! UPGRADE )); then PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)" export PANAMA_ANSWERS - if ! "$PANAMA_PATH/setup/scripts/interview"; then + if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then exit 1 fi # shellcheck source=/dev/null source "$PANAMA_ANSWERS" - export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \ - PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA PANAMA_MOK_HASH \ - PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS PANAMA_USER_CONTENT + export PANAMA_ROLE PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL \ + PANAMA_GIT_EDITOR PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA \ + PANAMA_MOK_HASH PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS \ + PANAMA_USER_CONTENT fi +# ── The role ───────────────────────────────────────────────────────────────── +# +# The one interview answer that outlives the run, because every later +# `panama update` runs with no interview and still has to know which machine +# this is. A fresh install records what was just answered; an upgrade reads +# what an earlier install recorded, defaulting to desktop -- which is what +# every machine that predates roles is. Exported so each stage sees the same +# answer through setup/lib/machine-role without re-deriving it. +# shellcheck source=setup/lib/machine-role +source "$PANAMA_PATH/setup/lib/machine-role" +if (( ! UPGRADE )); then + PANAMA_ROLE="${PANAMA_ROLE:-desktop}" + panama_role_record "$PANAMA_ROLE" +else + PANAMA_ROLE="$(panama_role)" +fi +export PANAMA_ROLE + # One password, before anything long runs, and then never again. The stages # call sudo dozens of times across twenty-plus minutes, and the timestamp # expires five minutes after whichever call came last -- so a single dnf step @@ -223,7 +255,19 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then echo "Hostname set to: $(hostname)" fi -STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware) +# One list per role, chosen whole rather than filtered from a superset, so +# what a server runs is readable here rather than derived. A server gets the +# shared stages plus its own two; it never links skills (all three shipped +# skills operate the desktop), never touches gsettings or Vicinae, and has no +# hardware stage -- NVIDIA, Secure Boot and firmware are first-boot desktop +# concerns. setup-server runs after packages (it needs podman and firewalld +# installed) and link-server after that, so the units it links land on a +# machine already able to run them. +if [[ "$PANAMA_ROLE" == server ]]; then + STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity) +else + STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware) +fi # The two an upgrade drops. Both exist only to act on interview answers, and # both are first-run decisions: who you are and what hardware this is. Filtered @@ -297,10 +341,21 @@ fi # It never changes the exit code. On a fresh machine it legitimately reports # things as unconfigured -- no Home Assistant token yet, Nextcloud not signed in # -- and failing an install over those would be crying wolf. -doctor="$PANAMA_PATH/config/dot/quickshell/scripts/panama-doctor" -if [[ -x "$doctor" ]]; then - printf '\n=== health ===\n' - "$doctor" --summary || true +# On a server the quickshell doctor would report a desktop that was never +# installed; what is actually running there is the container services, and +# panama-server status is the check that answers for them. +if [[ "$PANAMA_ROLE" == server ]]; then + server_status="$PANAMA_PATH/bin/panama-server" + if [[ -x "$server_status" ]]; then + printf '\n=== health ===\n' + "$server_status" status || true + fi +else + doctor="$PANAMA_PATH/config/dot/quickshell/scripts/panama-doctor" + if [[ -x "$doctor" ]]; then + printf '\n=== health ===\n' + "$doctor" --summary || true + fi fi # Whatever this particular machine wants doing that Panama should not carry for @@ -318,6 +373,8 @@ fi if (( ${#failed[@]} == 0 )); then if (( UPGRADE )); then echo "Panama is up to date." + elif [[ "$PANAMA_ROLE" == server ]]; then + echo "Panama installed. Enable a service with: panama server enable " else echo "Panama installed. Log out and choose the Hyprland session to start it." fi diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..3f9b45d --- /dev/null +++ b/server/README.md @@ -0,0 +1,82 @@ +# The server role + +What a Panama machine runs when the interview answered `server`: the same +shell environment as every other machine, minus everything that needs a +screen, plus rootless podman compose services managed as systemd user units. + +``` +server/ + containers/ One directory per service: compose.yml, the + podman-.service user unit, and an .env.example + naming what the service needs told + scripts/ update-containers, the nightly image updater + systemd/ The units behind it, linked by setup-server +``` + +## The contract with ~/Server + +The repository carries the definitions; the machine carries the state. +`panama server enable ` creates `~/Server//` as a **real +directory** and symlinks only `compose.yml` into it; the unit symlinks into +`~/.config/systemd/user/`. The `.env` (seeded once from `.env.example`, then +yours) and the bind-mounted `data/` live in `~/Server//` and are never +inside the checkout — so nothing git can do, `clean -fdx` included, can reach +a database. + +Because of that split, compose files must reference secrets as `${VAR}` +interpolations resolved from the `.env` beside them, never inline. +`tests/server/compose-secrets-contract` fails the suite when a tracked file +under `server/` carries anything that looks like a secret: this repository is +public, and the gitignore is a seatbelt, not the brakes. + +Placeholders in `.env.example` are spelled `CHANGE_ME`, exactly — that token +is what `panama server enable` refuses to start on. + +## Conventions + +- Service directories are TitleCase, matching `~/Server` as it has always + looked: `server/containers/Gitea` becomes `~/Server/Gitea`. +- Bind mounts live under `./data/` in new services. Imported services keep + the volume path they were running with until their cutover, because the + repo copy must render identically to the live one — renames happen with + the service stopped, not in git. +- Only 80, 443 and 81 are open (setup-server's doing). Everything else is + reverse-proxied over the `nginx-bridge` network by container name, with no + published ports of its own. A service that truly needs another host port + says so in a README in its directory, and the port is opened by hand. +- Every compose joins `nginx-bridge` (external; created by setup-server). + +## Enabling, updating, cutting over + +```sh +panama server list # the catalog, and what this machine runs +panama server enable Gitea # link, seed .env, enable --now +panama server status # is what is enabled actually up +panama server disable Gitea # stop and unlink; data and .env stay +``` + +`panama update` relinks and reloads but **never restarts a running +service** — it names the ones whose definitions changed and leaves the +restart to you. + +Moving a service that already runs from hand-managed files onto the repo's +(the cutover): stop the unit, standardize the volume directory to `data/` +(updating the repo compose to match), then `panama server enable ` — +it backs up the hand-written files as `*.pre-panama` and links the tracked +ones — and verify it came back up. One service at a time, quiet hours. + +## Adding a service + +Add `server/containers//` with the three files, modelled on any +existing service. The unit is the standard oneshot `podman compose up -d` +shape (`WorkingDirectory=%h/Server/`); the shape contract pins the +details. Secrets go in `.env.example` as `KEY=CHANGE_ME` lines. + +## Nightly updates + +`server/scripts/update-containers` pulls images and restarts changed +services through their units, nightly at midnight via `podman-update.timer`. +It replaced watchtower after watchtower recreated a container outside its +compose pod and took Gitea down for three days — the script's header carries +the full story, plus the SKIP list for services that must only ever be +updated by hand (postgresql, authentik). diff --git a/server/containers/Adminer/compose.yml b/server/containers/Adminer/compose.yml new file mode 100644 index 0000000..e0e51b2 --- /dev/null +++ b/server/containers/Adminer/compose.yml @@ -0,0 +1,44 @@ +# Adminer — database admin UI. VPS (ROOTLESS PODMAN), added 2026-08-12. +# +# On the home server this shipped alongside a MySQL container in the same compose +# file. Here it is standalone and defaults to the shared PostgreSQL instance, which +# is where the 14 databases now live (authentik, infisical_db, n8n, npm, the payload_* +# and convex databases, usesend, lashaddict-payload, spoon_convex...). +# +# MySQL stays on the home server — nothing on the VPS uses it. +# +# It can still reach any other database reachable on nginx-bridge; ADMINER_DEFAULT_SERVER +# only pre-fills the server field on the login form. +# +# ⚠️ SECURITY: Adminer has NO authentication of its own — it is a login form that +# forwards credentials to whichever database you name. Publishing it means exposing a +# database login prompt to the internet. Put it behind authentik forward-auth in NPM +# (same pattern as sonarr/prowlarr on the home server), or restrict it to the LAN by +# giving it only a UniFi record and no Cloudflare record. +# +# Rootless adaptations: no bind mounts (stateless, so no :Z needed), no published +# ports — NPM proxies to http://adminer:8080 over nginx-bridge. +# +# NPM: adminer.gbrown.org -> http://adminer:8080 + +networks: + nginx-bridge: + external: true + +services: + adminer: + image: docker.io/library/adminer:latest + container_name: adminer + hostname: adminer + domainname: adminer.gbrown.org + networks: + - nginx-bridge + environment: + - TZ=America/New_York + # Pre-fills the server field; any nginx-bridge host can still be typed in. + - ADMINER_DEFAULT_SERVER=postgresql + - ADMINER_DESIGN=dracula + labels: + com.centurylinklabs.watchtower.enable: "true" + tty: true + restart: unless-stopped diff --git a/server/containers/Adminer/podman-adminer.service b/server/containers/Adminer/podman-adminer.service new file mode 100644 index 0000000..2794564 --- /dev/null +++ b/server/containers/Adminer/podman-adminer.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Adminer +After=network-online.target podman.socket podman-postgresql.service +Wants=network-online.target podman-postgresql.service +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Adminer +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=300 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Agentchat/compose.yml b/server/containers/Agentchat/compose.yml new file mode 100644 index 0000000..9807ced --- /dev/null +++ b/server/containers/Agentchat/compose.yml @@ -0,0 +1,27 @@ +networks: + nginx-bridge: + external: true # ALWAYS external; see AGENTS.md §5 + +services: + agentchat: + image: git.gbrown.org/gib/agentchat:latest + container_name: agentchat + hostname: agentchat + domainname: agentchat.gbrown.org + networks: ['nginx-bridge'] + ports: + # Loopback only: lets this host's own agent reach the hub at + # http://127.0.0.1:8080 without going through NPM - the LAN access + # list there blocks the VPS's public IP. Unreachable from elsewhere. + - '127.0.0.1:8080:8080' + environment: + - TZ=America/New_York + - AGENTCHAT_DB=/data/agentchat.db + volumes: + - ./data:/data:z # lowercase :z — watchtower recreates DROP the relabel flag, and :Z-category files would lock the new container out (learned 2026-08-13); :z keeps them accessible + labels: ['com.centurylinklabs.watchtower.enable=true'] + restart: unless-stopped + healthcheck: + test: wget -qO- http://localhost:8080/healthz || exit 1 + interval: 30s + start_period: 10s diff --git a/server/containers/Agentchat/podman-agentchat.service b/server/containers/Agentchat/podman-agentchat.service new file mode 100644 index 0000000..c20d960 --- /dev/null +++ b/server/containers/Agentchat/podman-agentchat.service @@ -0,0 +1,24 @@ +# TEMPLATE for a rootless user unit. +# Install to ~/.config/systemd/user/podman-.service then: +# systemctl --user daemon-reload && systemctl --user enable --now podman-.service +# Requires linger: sudo loginctl enable-linger $USER +[Unit] +Description=Podman Compose: agentchat +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Agentchat +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=300 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Authentik/.env.example b/server/containers/Authentik/.env.example new file mode 100644 index 0000000..78cb142 --- /dev/null +++ b/server/containers/Authentik/.env.example @@ -0,0 +1,20 @@ +PG_PASS=CHANGE_ME +AUTHENTIK_SECRET_KEY=CHANGE_ME +AUTHENTIK_ERROR_REPORTING__ENABLED=true +COMPOSE_PORT_HTTP=9000 +COMPOSE_PORT_HTTPS=9443 +AUTHENTIK_TAG=2026.2.2 + +# SMTP Host Emails are sent to +AUTHENTIK_EMAIL__HOST=smtp.mail.me.com +AUTHENTIK_EMAIL__PORT=587 +# Optionally authenticate (don't add quotation marks to your password) +AUTHENTIK_EMAIL__USERNAME=CHANGE_ME +AUTHENTIK_EMAIL__PASSWORD=CHANGE_ME +# Use StartTLS +AUTHENTIK_EMAIL__USE_TLS=true +# Use SSL +AUTHENTIK_EMAIL__USE_SSL=false +AUTHENTIK_EMAIL__TIMEOUT=10 +# Email address authentik will send from, should have a correct @domain +AUTHENTIK_EMAIL__FROM=admin@gbrown.org diff --git a/server/containers/Authentik/compose.yml b/server/containers/Authentik/compose.yml new file mode 100644 index 0000000..a1ddb79 --- /dev/null +++ b/server/containers/Authentik/compose.yml @@ -0,0 +1,93 @@ +# Authentik — VPS (ROOTLESS PODMAN) copy of the home server's auth stack. +# +# This is a 1:1 copy of ~/Server/auth on server.gib, migrated 2026-08-11. The home +# instance is still running and authoritative; this one has an independent database +# restored from a dump taken at migration time, so THE TWO DIVERGE FROM THAT MOMENT ON. +# Do not treat this as a hot standby -- it is a rehearsal/cutover target. +# +# Differences from the home Docker/root version, and why: +# +# :z on ./volumes/media (LOWERCASE, shared) +# Both server and worker mount this same path. `:Z` assigns a PRIVATE SELinux MCS +# category per container, so the second container to start would relabel it and +# lock the first one out. Shared mounts must use `:z`. The per-container mounts +# (server/custom-templates, worker/custom-templates, worker/certs) are exclusive +# and correctly use `:Z`. +# +# No redis anywhere +# authentik 2026.x dropped the Redis dependency (Postgres-backed now). The home +# stack has no redis container and no AUTHENTIK_REDIS__* vars either -- verified, +# not assumed. Do not "helpfully" add one. +# +# Postgres is the shared VPS instance +# AUTHENTIK_POSTGRESQL__HOST=postgresql resolves over nginx-bridge to the same +# container N8n and NPM use. Role + database `authentik` were created there with +# the same PG_PASS as home, so .env needed no edits. +# +# .env is copied verbatim from home and contains AUTHENTIK_SECRET_KEY. That key MUST +# match the one the database was encrypted with, or tokens and stored secrets break. +# It is mode 600 -- never print it, never commit it. +# +# NOT YET SERVING auth.gbrown.org. That DNS record still points home. Cutover = create +# an explicit auth.gbrown.org record pointing at this VPS (an explicit record overrides +# the *.gbrown.org wildcard) plus an NPM proxy host to http://authentik-server:9000. + +networks: + nginx-bridge: + external: true + +services: + server: + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2} + container_name: authentik-server + hostname: authentik-server + domainname: auth.gbrown.org + networks: + - nginx-bridge + command: server + # Bound to the WireGuard address ONLY -- never listens on eth0, so this is not + # reachable from the internet regardless of firewall state. Home's NPM uses it for + # forward-auth (14 proxy hosts point at http://192.168.2.2:9000/outpost.goauthentik.io). + # Do NOT change this to a bare "9000:9000"; that would expose it on the public + # interface. Port 9443 is deliberately NOT published -- portainer already uses it. + ports: + - "192.168.2.2:9000:9000" + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + TZ: America/New_York + env_file: + - .env + volumes: + - ./volumes/server/custom-templates:/templates:Z + - ./volumes/media:/data/media:z + labels: + com.centurylinklabs.watchtower.enable: "true" + restart: unless-stopped + tty: true + + worker: + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2} + container_name: authentik-worker + hostname: authentik-worker + networks: + - nginx-bridge + command: worker + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + TZ: America/New_York + env_file: + - .env + volumes: + - ./volumes/media:/data/media:z + - ./volumes/worker/certs:/certs:Z + - ./volumes/worker/custom-templates:/templates:Z + labels: + com.centurylinklabs.watchtower.enable: "true" + restart: unless-stopped + tty: true diff --git a/server/containers/Authentik/podman-authentik.service b/server/containers/Authentik/podman-authentik.service new file mode 100644 index 0000000..d21d446 --- /dev/null +++ b/server/containers/Authentik/podman-authentik.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Authentik +After=network-online.target podman.socket podman-postgresql.service +Wants=network-online.target podman-postgresql.service +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Authentik +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=600 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Beszel/compose.yml b/server/containers/Beszel/compose.yml new file mode 100644 index 0000000..ac73a25 --- /dev/null +++ b/server/containers/Beszel/compose.yml @@ -0,0 +1,70 @@ +# Beszel — lightweight host + container monitoring, VPS (ROOTLESS PODMAN) edition. +# +# Adapted from the home server's Docker/root version. Differences and why: +# +# docker.sock -> /run/user/1000/podman/podman.sock +# Rootless podman's API socket. It exposes ONLY rootless containers, which is +# exactly the set we want reported. No :Z on the socket mount -- relabeling the +# live socket breaks podman. See AGENTS.md §6. +# +# security_opt: label:disable on the agent +# Required under SELinux Enforcing to read that socket, which systemd recreates +# each boot as user_tmp_t. Same treatment as portainer/uptime. +# +# EXTRA_FILESYSTEMS=/boot dropped +# The home server has /boot on its own 974M partition. This VPS does not -- +# /dev/sda1 is the whole root fs and /boot/efi is a 64M ESP not worth alerting on. +# +# No hostname/domainname on the agent +# Host networking forbids them, same as on the home server. +# +# The hub generates its keypair on first start at ./volumes/hub/id_ed25519.pub. +# That public key is what goes in the agent's KEY below. + +networks: + nginx-bridge: + external: true + +services: + beszel: + image: docker.io/henrygd/beszel:latest + container_name: beszel + hostname: beszel + domainname: beszel.gibbyb.com + networks: ['nginx-bridge'] + # No published port: NPM proxies to http://beszel:8090 over nginx-bridge. + environment: + - TZ=America/New_York + # Lets the hub reach a host-network agent. Under rootless podman this resolves to + # the nginx-bridge gateway (172.18.0.1). Add the VPS in the hub UI with host + # "host.docker.internal", port 45876. + extra_hosts: + - "host.docker.internal:host-gateway" + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/hub:/beszel_data:Z + tty: true + stdin_open: true + restart: unless-stopped + + beszel-agent: + image: docker.io/henrygd/beszel-agent:latest + container_name: beszel-agent + network_mode: host + environment: + - TZ=America/New_York + - PORT=45876 + # This hub's public key. Newer Beszel writes only ./volumes/hub/id_ed25519 (private); + # derive the public half with: ssh-keygen -y -f volumes/hub/id_ed25519 + # It is a PUBLIC key -- safe to keep in this file. + - KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBWNqxKGRltqXJZg+wlDOtICeLo19ftQ6P3P8+uJg8y + security_opt: ['label:disable'] + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/agent:/var/lib/beszel-agent:Z + - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/containers/Beszel/podman-beszel.service b/server/containers/Beszel/podman-beszel.service new file mode 100644 index 0000000..9e3a959 --- /dev/null +++ b/server/containers/Beszel/podman-beszel.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Beszel +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Beszel +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=300 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/CalCom/.env.example b/server/containers/CalCom/.env.example new file mode 100644 index 0000000..055446c --- /dev/null +++ b/server/containers/CalCom/.env.example @@ -0,0 +1,5 @@ +NEXTAUTH_SECRET=CHANGE_ME +CALENDSO_ENCRYPTION_KEY=CHANGE_ME +DATABASE_URL=CHANGE_ME +DATABASE_DIRECT_URL=CHANGE_ME +EMAIL_SERVER_PASSWORD=CHANGE_ME diff --git a/server/containers/CalCom/compose.yml b/server/containers/CalCom/compose.yml new file mode 100644 index 0000000..eaf0a42 --- /dev/null +++ b/server/containers/CalCom/compose.yml @@ -0,0 +1,60 @@ +# Cal.com — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12. +# +# ⚠️ NOT STARTED. No systemd unit is enabled. It has never run on either machine, so +# there is no data to migrate. Bring it up with: +# systemctl --user enable --now podman-calcom.service +# +# BEFORE FIRST START, create its role and database on the SHARED postgres (this stack +# no longer ships its own): +# podman exec -i postgresql psql -U npm <<'SQL' +# CREATE ROLE calcom LOGIN PASSWORD ''; +# CREATE DATABASE calcom OWNER calcom; +# SQL +# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql. +# +# Also unset: EMAIL_SERVER_HOST/USER/PASSWORD are still CHANGE_ME. Cal.com will run, +# but booking confirmations and invitations will not send. +# +# Note Cal.com runs database migrations on first boot and can take several minutes to +# become responsive. The unit allows 900s for this. +# +# Rootless adaptations from the home version: +# - Dropped its private postgres:16-alpine; uses the shared instance (AGENTS.md §2). +# - /etc/localtime mount removed in favour of TZ (AGENTS.md §8). +# - No bind mounts remain, so no :Z is needed. +# +# NPM: proxy cal.gbrown.org -> http://calcom:3000 + +networks: + nginx-bridge: + external: true + +services: + calcom: + image: docker.io/calcom/cal.com:latest + container_name: calcom + hostname: calcom + domainname: cal.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - NEXT_PUBLIC_WEBAPP_URL=https://cal.gbrown.org + - NEXT_PUBLIC_WEBSITE_URL=https://cal.gbrown.org + - NEXTAUTH_URL=https://cal.gbrown.org/api/auth + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY} + # Shared postgres, not a private container + - DATABASE_URL=${DATABASE_URL} + - DATABASE_DIRECT_URL=${DATABASE_DIRECT_URL} + - NEXT_PUBLIC_LICENSE_CONSENT=agree + - ALLOWED_HOSTNAMES="cal.gbrown.org" + - EMAIL_FROM=cal@gbrown.org + - EMAIL_SERVER_HOST=CHANGE_ME + - EMAIL_SERVER_PORT=587 + - EMAIL_SERVER_USER=CHANGE_ME + - EMAIL_SERVER_PASSWORD=${EMAIL_SERVER_PASSWORD} + labels: + com.centurylinklabs.watchtower.enable: "true" + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/containers/CalCom/podman-calcom.service b/server/containers/CalCom/podman-calcom.service new file mode 100644 index 0000000..48ff398 --- /dev/null +++ b/server/containers/CalCom/podman-calcom.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Cal.com +After=network-online.target podman.socket podman-postgresql.service +Wants=network-online.target podman-postgresql.service +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/CalCom +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/ConvexMonorepo/.env.example b/server/containers/ConvexMonorepo/.env.example new file mode 100644 index 0000000..fe79c63 --- /dev/null +++ b/server/containers/ConvexMonorepo/.env.example @@ -0,0 +1,59 @@ +# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. +# Keep this file up-to-date when you add new variables to \`.env\`. +# This file will be committed to version control, so make sure not to have any secrets in it. +# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. + +## Next App ## +NODE_ENV=production +SENTRY_AUTH_TOKEN=CHANGE_ME +PAYLOAD_SECRET=CHANGE_ME +PAYLOAD_DB_URL=CHANGE_ME +NEXT_PUBLIC_SITE_URL=https://convexmonorepo.gbrown.org +NEXT_PUBLIC_CONVEX_URL=https://api.convexmonorepo.gbrown.org # convex-backend:3210 +NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.gbrown.org +NEXT_PUBLIC_SENTRY_DSN=https://425744e3cf5a627d909c7321b2782e3f@sentry.gbrown.org/3 +NEXT_PUBLIC_SENTRY_URL=https://sentry.gbrown.org +NEXT_PUBLIC_SENTRY_ORG=sentry +NEXT_PUBLIC_SENTRY_PROJECT_NAME=convexmonorepo-nextjs + +## Convex ## +CONVEX_SELF_HOSTED_URL=https://api.convexmonorepo.gbrown.org # convex-backend:3210 +CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME +# Convex Auth +CONVEX_SITE_URL=https://convexmonorepo.gbrown.org # convex-backend:3211 +#CONVEX_SITE_URL=https://convexmonorepo.gbrown.org # convex-backend:3211 +USESEND_API_KEY=CHANGE_ME +USESEND_URL=https://usesend.example.com +USESEND_FROM_EMAIL='Convex Admin ' +AUTH_AUTHENTIK_ID=CHANGE_ME +AUTH_AUTHENTIK_SECRET=CHANGE_ME +AUTH_AUTHENTIK_ISSUER=https://auth.gbrown.org/application/o/convexmonorepo/ + +## Docker Compose Variables for Next App ## +NETWORK=nginx-bridge +NEXT_CONTAINER_NAME=convexmonorepo-next +NEXT_DOMAIN=convexmonorepo.gbrown.org +NEXT_PORT=3000 + +## Docker Compose Variables for Self hosted Convex ## +BACKEND_TAG=latest +DASHBOARD_TAG=latest +BACKEND_CONTAINER_NAME=convexmonorepo-backend +DASHBOARD_CONTAINER_NAME=convexmonorepo-dashboard +BACKEND_DOMAIN=convex.convexmonorepo.gbrown.org +DASHBOARD_DOMAIN=dashboard.convexmonorepo.gbrown.org +INSTANCE_NAME=convex +#INSTANCE_SECRET= +CONVEX_CLOUD_ORIGIN=https://api.convexmonorepo.gbrown.org +CONVEX_SITE_ORIGIN=https://convex.convexmonorepo.gbrown.org +NEXT_PUBLIC_DEPLOYMENT_URL=https://api.convexmonorepo.gbrown.org +DISABLE_BEACON=true +REDACT_LOGS_TO_CLIENT=true +DO_NOT_REQUIRE_SSL=true +#POSTGRES_URL= #postgresql://user:password@host:5432/db_name +#BACKEND_PORT= +#DASHBOARD_PORT +#SITE_PROXY_PORT= +#ACTIONS_USER_TIMEOUT_SECS= +#RUST_LOG= +#RUST_BACKTRACE= diff --git a/server/containers/ConvexMonorepo/compose.yml b/server/containers/ConvexMonorepo/compose.yml new file mode 100644 index 0000000..a1837f0 --- /dev/null +++ b/server/containers/ConvexMonorepo/compose.yml @@ -0,0 +1,78 @@ +networks: + nginx-bridge: # Change to network you plan to use + external: true + +services: + convexmonorepo-next: + image: git.gbrown.org/gib/convexmonorepo-next:latest + container_name: convexmonorepo-next + hostname: convexmonorepo-next + domainname: ${NEXT_DOMAIN} + networks: ['${NETWORK:-nginx-bridge}'] + #ports: ['${NEXT_PORT}:${NEXT_PORT}'] + environment: + - NODE_ENV=${NODE_ENV} + - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} + - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}} + - NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${BACKEND_PORT:-3210}} + - NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org} + - NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN} + - NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL} + - NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry} + - NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME} + - PAYLOAD_SECRET=${PAYLOAD_SECRET} + - PAYLOAD_DB_URL=${PAYLOAD_DB_URL} + labels: ['com.centurylinklabs.watchtower.enable=true'] + depends_on: ['convexmonorepo-backend'] + tty: true + stdin_open: true + restart: unless-stopped + + convexmonorepo-backend: + image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest} + container_name: ${BACKEND_CONTAINER_NAME:-convex-backend} + hostname: ${BACKEND_CONTAINER_NAME:-convex-backend} + domainname: ${BACKEND_DOMAIN:-convex.gbrown.org} + networks: ['${NETWORK:-nginx-bridge}'] + #user: '1000:1000' + #ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211'] + volumes: [./data:/convex/data:z] + labels: ['com.centurylinklabs.watchtower.enable=true'] + environment: + - INSTANCE_NAME + #- INSTANCE_SECRET + - CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}} + - CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${SITE_PROXY_PORT:-3211}} + - DISABLE_BEACON=${DISABLE_BEACON:-true} + - REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true} + - DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false} + #- POSTGRES_URL=${POSTGRES_URL} + stdin_open: true + tty: true + restart: unless-stopped + healthcheck: + test: curl -f http://localhost:3210/version + interval: 5s + start_period: 10s + stop_grace_period: 10s + stop_signal: SIGINT + + convexmonorepo-dashboard: + image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest} + container_name: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard} + hostname: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard} + domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.gbrown.org}} + networks: ['${NETWORK:-nginx-bridge}'] + #user: 1000:1000 + #ports: ['${DASHBOARD_PORT:-6791}:6791'] + labels: ['com.centurylinklabs.watchtower.enable=true'] + environment: + - NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${PORT:-3210}} + depends_on: + convexmonorepo-backend: + condition: service_healthy + stdin_open: true + tty: true + restart: unless-stopped + stop_grace_period: 10s + stop_signal: SIGINT diff --git a/server/containers/ConvexMonorepo/podman-convexmonorepo.service b/server/containers/ConvexMonorepo/podman-convexmonorepo.service new file mode 100644 index 0000000..9cfdd2c --- /dev/null +++ b/server/containers/ConvexMonorepo/podman-convexmonorepo.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: ConvexMonorepo +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/ConvexMonorepo +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Documenso/.env.example b/server/containers/Documenso/.env.example new file mode 100644 index 0000000..030b16a --- /dev/null +++ b/server/containers/Documenso/.env.example @@ -0,0 +1,6 @@ +NEXTAUTH_SECRET=CHANGE_ME +NEXT_PRIVATE_ENCRYPTION_KEY=CHANGE_ME +NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=CHANGE_ME +NEXT_PRIVATE_DATABASE_URL=CHANGE_ME +NEXT_PRIVATE_DIRECT_DATABASE_URL=CHANGE_ME +NEXT_PRIVATE_SMTP_PASSWORD=CHANGE_ME diff --git a/server/containers/Documenso/compose.yml b/server/containers/Documenso/compose.yml new file mode 100644 index 0000000..31bd67c --- /dev/null +++ b/server/containers/Documenso/compose.yml @@ -0,0 +1,72 @@ +# Documenso — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12. +# +# ⚠️ NOT STARTED. No systemd unit is enabled for this. It has never run on either +# machine, so there is no data to migrate. Bring it up with: +# systemctl --user enable --now podman-documenso.service +# +# BEFORE FIRST START, two things must happen: +# +# 1. Create its role and database on the SHARED postgres (this stack no longer +# ships its own): +# podman exec -i postgresql psql -U npm <<'SQL' +# CREATE ROLE documenso LOGIN PASSWORD ''; +# CREATE DATABASE documenso OWNER documenso; +# SQL +# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql so a +# rebuild recreates them. +# +# 2. Provide a signing certificate at ./volumes/cert/cert.p12, or Documenso will +# not start. Generate one with: +# openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 3650 -nodes +# openssl pkcs12 -export -out cert.p12 -inkey key.pem -in cert.pem -passout pass: +# +# Also unset: the four NEXT_PRIVATE_SMTP_* values are still CHANGE_ME. Documenso will +# run without working email, but signature invitations will fail to send. +# +# Rootless adaptations from the home version: +# - Dropped its private postgres:16-alpine; uses the shared instance (AGENTS.md §2). +# - :Z on bind mounts (SELinux Enforcing). +# - /etc/localtime mounts removed in favour of TZ (AGENTS.md §8). +# +# NPM: proxy docs.gbrown.org -> http://documenso:3000 + +networks: + nginx-bridge: + external: true + +services: + documenso: + image: docker.io/documenso/documenso:latest + container_name: documenso + hostname: documenso + domainname: docs.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - PORT=3000 + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - NEXT_PRIVATE_ENCRYPTION_KEY=${NEXT_PRIVATE_ENCRYPTION_KEY} + - NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=${NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY} + - NEXT_PUBLIC_WEBAPP_URL=https://docs.gbrown.org + - NEXTAUTH_URL=https://docs.gbrown.org + # Shared postgres, not a private container + - NEXT_PRIVATE_DATABASE_URL=${NEXT_PRIVATE_DATABASE_URL} + - NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL} + - NEXT_PRIVATE_SIGNING_TRANSPORT=local + - NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12 + - NEXT_PRIVATE_SIGNING_PASSPHRASE= + - NEXT_PUBLIC_UPLOAD_TRANSPORT=database + - NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth + - NEXT_PRIVATE_SMTP_HOST=CHANGE_ME + - NEXT_PRIVATE_SMTP_PORT=587 + - NEXT_PRIVATE_SMTP_USERNAME=CHANGE_ME + - NEXT_PRIVATE_SMTP_PASSWORD=${NEXT_PRIVATE_SMTP_PASSWORD} + - NEXT_PRIVATE_SMTP_FROM_NAME=Documenso + - NEXT_PRIVATE_SMTP_FROM_ADDRESS=documenso@gbrown.org + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/cert/cert.p12:/opt/documenso/cert.p12:Z + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/containers/Documenso/podman-documenso.service b/server/containers/Documenso/podman-documenso.service new file mode 100644 index 0000000..79f541b --- /dev/null +++ b/server/containers/Documenso/podman-documenso.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Documenso +After=network-online.target podman.socket podman-postgresql.service +Wants=network-online.target podman-postgresql.service +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Documenso +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Gitea/compose.yml b/server/containers/Gitea/compose.yml new file mode 100644 index 0000000..f20367e --- /dev/null +++ b/server/containers/Gitea/compose.yml @@ -0,0 +1,25 @@ +networks: + nginx-bridge: + external: true +services: + gitea: + image: docker.io/gitea/gitea:latest + container_name: gitea + hostname: gitea + domainname: git.gbrown.org + networks: + - nginx-bridge + ports: + - '2222:22' + environment: + - TZ=America/New_York + - USER_UID=1001 + - USER_GID=1003 + labels: + com.centurylinklabs.watchtower.enable: 'true' + volumes: + - ./volume:/data:Z + - /home/gib/Media/gitea-packages:/data/gitea/packages:Z + - ./ssh:/data/git/.ssh:Z + tty: true + restart: unless-stopped diff --git a/server/containers/Gitea/podman-gitea.service b/server/containers/Gitea/podman-gitea.service new file mode 100644 index 0000000..fe62f10 --- /dev/null +++ b/server/containers/Gitea/podman-gitea.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Gitea +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Gitea +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=600 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/ISponsorBlockTV/compose.yml b/server/containers/ISponsorBlockTV/compose.yml new file mode 100644 index 0000000..f280b97 --- /dev/null +++ b/server/containers/ISponsorBlockTV/compose.yml @@ -0,0 +1,30 @@ +# iSponsorBlockTV — VPS (ROOTLESS PODMAN) port of the home server's +# ~/Server/isponsorblocktv, migrated 2026-08-12. +# +# Why this works off-LAN: the configured device is pinned by `screen_id`, which uses +# YouTube's CLOUD lounge API rather than a direct LAN connection to the Apple TV. So it +# does not need to be on the same network as the TV. (If you ever re-pair a device via +# SSDP/mDNS discovery, that step DOES need LAN access and must be done from home.) +# +# Rootless adaptations: +# - `user: 1000:1000` REPLACED with userns_mode keep-id. Under a rootless userns the +# original would resolve to subuid 525287 and lose access to its own gib-owned data +# directory -- the exact failure that kept n8n broken for two months. See AGENTS.md §3.2. +# - :Z on the data mount (SELinux Enforcing). +# - network_mode: host retained to match home. It works fine rootless. + +services: + isponsorblocktv: + image: ghcr.io/dmunozv04/isponsorblocktv:latest + container_name: iSponsorBlockTV + network_mode: host + userns_mode: "keep-id:uid=1000,gid=1000" + environment: + - TZ=America/New_York + volumes: + - './volumes/isponsorblocktv:/app/data:Z' + labels: + com.centurylinklabs.watchtower.enable: 'true' + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/containers/ISponsorBlockTV/podman-isponsorblocktv.service b/server/containers/ISponsorBlockTV/podman-isponsorblocktv.service new file mode 100644 index 0000000..b490e11 --- /dev/null +++ b/server/containers/ISponsorBlockTV/podman-isponsorblocktv.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: iSponsorBlockTV +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/ISponsorBlockTV +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=600 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Infisical/.env.example b/server/containers/Infisical/.env.example new file mode 100644 index 0000000..a3a44d4 --- /dev/null +++ b/server/containers/Infisical/.env.example @@ -0,0 +1,142 @@ +# Keys +# Required key for platform encryption/decryption ops +# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NEVER BE USED FOR PRODUCTION +ENCRYPTION_KEY=CHANGE_ME + +# JWT +# Required secrets to sign JWT tokens +# THIS IS A SAMPLE AUTH_SECRET KEY AND SHOULD NEVER BE USED FOR PRODUCTION +AUTH_SECRET=CHANGE_ME + +# Postgres creds +POSTGRES_PASSWORD=CHANGE_ME +POSTGRES_USER=infisical_user +POSTGRES_DB=infisical_db + +# Required +DB_CONNECTION_URI=CHANGE_ME + +# Redis +REDIS_URL=redis://infisical-redis:6379 + +# Website URL +# Required +SITE_URL=https://infisical.gbrown.org + +# Mail/SMTP +SMTP_HOST=smtp.mail.me.com +SMTP_PORT=587 +SMTP_FROM_ADDRESS=infisical@gbrown.org +SMTP_FROM_NAME=Infisical Admin +SMTP_USERNAME=CHANGE_ME +SMTP_PASSWORD=CHANGE_ME + +# CICD Integration +CLIENT_ID_GITHUB= +CLIENT_ID_GITHUB_APP= +CLIENT_SLUG_GITHUB_APP= +CLIENT_SECRET_GITHUB= +CLIENT_SECRET_GITHUB_APP= +CLIENT_ID_GITLAB= +CLIENT_SECRET_GITLAB= +CLIENT_PRIVATE_KEY_GITHUB_APP= +CLIENT_APP_ID_GITHUB_APP= + +# Sentry (optional) for monitoring errors +SENTRY_DSN= + +# Infisical Cloud-specific configs +# Ignore - Not applicable for self-hosted version +POSTHOG_HOST= +POSTHOG_PROJECT_API_KEY= + +# SSO-specific variables +CLIENT_ID_GOOGLE_LOGIN= +CLIENT_SECRET_GOOGLE_LOGIN= + +CLIENT_ID_GITHUB_LOGIN= +CLIENT_SECRET_GITHUB_LOGIN= + +CLIENT_ID_GITLAB_LOGIN= +CLIENT_SECRET_GITLAB_LOGIN= + +CAPTCHA_SECRET= + +NEXT_PUBLIC_CAPTCHA_SITE_KEY= + +OTEL_TELEMETRY_COLLECTION_ENABLED=false +OTEL_EXPORT_TYPE=prometheus +OTEL_EXPORT_OTLP_ENDPOINT= +OTEL_OTLP_PUSH_INTERVAL= + +OTEL_COLLECTOR_BASIC_AUTH_USERNAME= +OTEL_COLLECTOR_BASIC_AUTH_PASSWORD= + +PLAIN_API_KEY= +PLAIN_WISH_LABEL_IDS= + +SSL_CLIENT_CERTIFICATE_HEADER_KEY= + +ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT=true + +# App Connections + +# aws assume-role connection +INF_APP_CONNECTION_AWS_ACCESS_KEY_ID= +INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY= + +# github oauth connection +INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET= + +#github app connection +INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET= +INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY= +INF_APP_CONNECTION_GITHUB_APP_SLUG= +INF_APP_CONNECTION_GITHUB_APP_ID= + +#gitlab app connection +INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID= +INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET= + +#github radar app connection +INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET= +INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY= +INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG= +INF_APP_CONNECTION_GITHUB_RADAR_APP_ID= +INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET= + +#gcp app connection +INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL= + +# azure app connections +INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID= +INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET= + +INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID= +INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET= + +INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID= +INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET= + +INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID= +INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET= + +# heroku app connection +INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID= +INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET= + +# datadog +SHOULD_USE_DATADOG_TRACER= +DATADOG_PROFILING_ENABLED= +DATADOG_ENV= +DATADOG_SERVICE= +DATADOG_HOSTNAME= + +# kubernetes +KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN=false + +# ClickHouse (optional) for audit log storage +# CLICKHOUSE_URL=http://infisical:infisical@clickhouse:8123/infisical diff --git a/server/containers/Infisical/compose.yml b/server/containers/Infisical/compose.yml new file mode 100644 index 0000000..3b4563b --- /dev/null +++ b/server/containers/Infisical/compose.yml @@ -0,0 +1,62 @@ +# Infisical — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/infisical, +# migrated 2026-08-12. +# +# CONSOLIDATED ONTO THE SHARED POSTGRES. Home runs a dedicated postgres:14 for infisical; +# here it uses the shared `postgresql` container (PG 17) alongside authentik, n8n and NPM. +# The PG14 dump restored into PG17 cleanly (no extensions, 749 tables, row counts verified +# identical). One postgres to back up, patch and monitor instead of several. +# +# role/database: infisical_user / infisical_db (created in the shared instance) +# DB_CONNECTION_URI in .env points at host `postgresql`, not the old `db` +# REDIS_URL points at `infisical-redis` -- container_name is what netavark resolves +# on nginx-bridge, so the home file's bare `redis` hostname does not work here +# +# redis stays local to this stack: it holds only cache/queue state, starts empty by +# design, and is not worth centralising. +# +# Rootless adaptations: +# - :Z on the redis data mount (SELinux Enforcing). +# - No published ports; NPM proxies to http://infisical-backend:8080 over nginx-bridge. +# - Images start as root and drop privileges internally, so no userns_mode needed. +# +# .env is copied verbatim from home (mode 600) and holds ENCRYPTION_KEY and AUTH_SECRET. +# Those MUST match the database they encrypted -- never regenerate them on migrated data. + +networks: + nginx-bridge: + external: true + +services: + redis: + image: docker.io/library/redis:latest + container_name: infisical-redis + hostname: infisical-redis + networks: ["nginx-bridge"] + env_file: .env + environment: + - ALLOW_EMPTY_PASSWORD=yes + - TZ=America/New_York + volumes: + - ./data/redis:/data:Z + labels: + com.centurylinklabs.watchtower.enable: "true" + restart: unless-stopped + tty: true + + backend: + image: docker.io/infisical/infisical:latest + container_name: infisical-backend + hostname: infisical-backend + domainname: infisical.gbrown.org + networks: ["nginx-bridge"] + env_file: .env + environment: + - NODE_ENV=production + - TZ=America/New_York + labels: + com.centurylinklabs.watchtower.enable: "true" + depends_on: + - redis + restart: unless-stopped + tty: true + stdin_open: true diff --git a/server/containers/Infisical/podman-infisical.service b/server/containers/Infisical/podman-infisical.service new file mode 100644 index 0000000..82e1ffe --- /dev/null +++ b/server/containers/Infisical/podman-infisical.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Infisical +After=network-online.target podman.socket podman-postgresql.service +Wants=network-online.target podman-postgresql.service +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Infisical +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=600 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/LashAddict/.env.example b/server/containers/LashAddict/.env.example new file mode 100644 index 0000000..2ff7038 --- /dev/null +++ b/server/containers/LashAddict/.env.example @@ -0,0 +1,64 @@ +# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. +# Keep this file up-to-date when you add new variables to \`.env\`. +# This file will be committed to version control, so make sure not to have any secrets in it. +# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. + +## Next.js ## +NODE_ENV=production +SENTRY_AUTH_TOKEN=CHANGE_ME +PAYLOAD_SECRET=CHANGE_ME +PAYLOAD_DB_URL=CHANGE_ME +NEXT_PUBLIC_SITE_URL=https://lashaddict.gbrown.org +NEXT_PUBLIC_CONVEX_URL=https://api.lashaddict.gbrown.org # convex-backend:3210 +NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.gbrown.org +NEXT_PUBLIC_SENTRY_DSN=https://ef07354e70281d183668b3e32332e763@sentry.gbrown.org/7 +NEXT_PUBLIC_SENTRY_URL=https://sentry.gbrown.org +NEXT_PUBLIC_SENTRY_ORG=sentry +NEXT_PUBLIC_SENTRY_PROJECT_NAME=lashaddict-next + +## Convex ## +CONVEX_SELF_HOSTED_URL=https://api.lashaddict.gbrown.org # convex-backend:3210 +CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME +# Convex Auth +CONVEX_SITE_URL=https://lashaddict.gbrown.org +USESEND_API_KEY=CHANGE_ME +USESEND_URL=https://usesend.gbrown.org +USESEND_FROM_EMAIL='Admin ' +AUTH_AUTHENTIK_ID=CHANGE_ME +AUTH_AUTHENTIK_SECRET=CHANGE_ME +AUTH_AUTHENTIK_ISSUER=https://auth.gbrown.org/application/o/lashaddict/ + +## Docker Compose Variables for Next App ## +NETWORK=nginx-bridge +NEXT_CONTAINER_NAME=lashaddict-next +NEXT_DOMAIN=lashaddict.gbrown.org +#NEXT_PORT= + +## Docker Compose Variables for Self hosted Convex ## +BACKEND_TAG=latest +DASHBOARD_TAG=latest +BACKEND_CONTAINER_NAME=lashaddict-backend +DASHBOARD_CONTAINER_NAME=lashaddict-dashboard +BACKEND_DOMAIN=convex.lashaddict.gbrown.org +DASHBOARD_DOMAIN=dashboard.lashaddict.gbrown.org +INSTANCE_NAME=lashaddict_convex +INSTANCE_SECRET=CHANGE_ME +CONVEX_CLOUD_ORIGIN=https://api.lashaddict.gbrown.org +CONVEX_SITE_ORIGIN=https://convex.lashaddict.gbrown.org +NEXT_PUBLIC_DEPLOYMENT_URL=https://api.lashaddict.gbrown.org +DISABLE_BEACON=true +REDACT_LOGS_TO_CLIENT=true +DO_NOT_REQUIRE_SSL=true +POSTGRES_URL=CHANGE_ME +#BACKEND_PORT= +#DASHBOARD_PORT +#SITE_PROXY_PORT= +#ACTIONS_USER_TIMEOUT_SECS= +#RUST_LOG= +#RUST_BACKTRACE= + +## Docker Compose Variables for Postgres ## +POSTGRES_CONTAINER_NAME=lashaddict-postgres +POSTGRES_USER=gib +POSTGRES_PASSWORD=CHANGE_ME +POSTGRES_DB=lashaddict-payload diff --git a/server/containers/LashAddict/compose.yml b/server/containers/LashAddict/compose.yml new file mode 100644 index 0000000..cd6e2a3 --- /dev/null +++ b/server/containers/LashAddict/compose.yml @@ -0,0 +1,76 @@ +networks: + nginx-bridge: + external: true +services: + lashaddict-next: + image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest + container_name: ${NEXT_CONTAINER_NAME} + hostname: ${NEXT_CONTAINER_NAME} + domainname: ${NEXT_DOMAIN} + networks: + - ${NETWORK:-nginx-bridge} + environment: + - NODE_ENV=${NODE_ENV} + - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} + - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}} + - NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${BACKEND_PORT:-3210}} + - NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org} + - NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN} + - NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL} + - NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry} + - NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME} + - PAYLOAD_SECRET=${PAYLOAD_SECRET} + - PAYLOAD_DB_URL=${PAYLOAD_DB_URL} + depends_on: + - lashaddict-backend + tty: true + stdin_open: true + restart: unless-stopped + lashaddict-backend: + image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest} + container_name: ${BACKEND_CONTAINER_NAME:-lashaddict-backend} + hostname: ${BACKEND_CONTAINER_NAME:-lashaddict-backend} + domainname: ${BACKEND_DOMAIN:-lashaddict.gbrown.org} + networks: + - ${NETWORK:-nginx-bridge} + volumes: + - ./volumes/convex:/convex/data:z + labels: + - com.centurylinklabs.watchtower.enable=true + environment: + - INSTANCE_NAME=${INSTANCE_NAME} + - INSTANCE_SECRET=${INSTANCE_SECRET} + - CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}} + - CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${SITE_PROXY_PORT:-3211}} + - DISABLE_BEACON=${DISABLE_BEACON:-true} + - REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true} + - DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false} + - POSTGRES_URL=${POSTGRES_URL} + stdin_open: true + tty: true + restart: unless-stopped + healthcheck: + test: curl -f http://localhost:3210/version + interval: 5s + start_period: 10s + stop_grace_period: 10s + stop_signal: SIGINT + lashaddict-dashboard: + image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest} + container_name: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard} + hostname: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard} + domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-lashaddict.gbrown.org}} + networks: + - ${NETWORK:-nginx-bridge} + labels: + - com.centurylinklabs.watchtower.enable=true + environment: + - NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${PORT:-3210}} + depends_on: + lashaddict-backend: + condition: service_healthy + stdin_open: true + tty: true + restart: unless-stopped + stop_grace_period: 10s + stop_signal: SIGINT diff --git a/server/containers/LashAddict/podman-lashaddict.service b/server/containers/LashAddict/podman-lashaddict.service new file mode 100644 index 0000000..bd35241 --- /dev/null +++ b/server/containers/LashAddict/podman-lashaddict.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: LashAddict +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/LashAddict +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/N8n/.env.example b/server/containers/N8n/.env.example new file mode 100644 index 0000000..4074de8 --- /dev/null +++ b/server/containers/N8n/.env.example @@ -0,0 +1,16 @@ +GENERIC_TIMEZONE=America/New_York +TZ=America/New_York +N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true +N8N_RUNNERS_ENABLED=true +N8N_RUNNERS_MODE=external +N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 +N8N_RUNNERS_AUTH_TOKEN=CHANGE_ME +N8N_NATIVE_PYTHON_RUNNER=true +DB_TYPE=postgresdb +DB_POSTGRESDB_DATABASE=n8n +DB_POSTGRESDB_HOST=postgresql +DB_POSTGRESDB_PORT=5432 +DB_POSTGRESDB_USER=npm +DB_POSTGRESDB_PASSWORD=CHANGE_ME +N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679 +N8N_RUNNERS_AUTH_TOKEN=CHANGE_ME diff --git a/server/containers/N8n/compose.yml b/server/containers/N8n/compose.yml new file mode 100644 index 0000000..b79e809 --- /dev/null +++ b/server/containers/N8n/compose.yml @@ -0,0 +1,50 @@ +networks: + nginx-bridge: + external: true +services: + n8n: + image: docker.n8n.io/n8nio/n8n:latest + container_name: n8n + hostname: n8n + domainname: n8n.gbrown.org + networks: ['nginx-bridge'] + #ports: ['5678:5678'] + env_file: [.env] + environment: + - GENERIC_TIMEZONE + - TZ + - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS + - N8N_RUNNERS_ENABLED + - N8N_RUNNERS_MODE + - N8N_RUNNERS_BROKER_LISTEN_ADDRESS + - N8N_RUNNERS_AUTH_TOKEN + - N8N_NATIVE_PYTHON_RUNNER + - DB_TYPE + - DB_POSTGRESDB_DATABASE + - DB_POSTGRESDB_HOST + - DB_POSTGRESDB_PORT + - DB_POSTGRESDB_USER + - DB_POSTGRESDB_PASSWORD + # Rootless: replaces `user: 1000:1000`, which under a rootless userns would have + # resolved to subuid 525287 and lost access to the gib-owned ./data dir. + userns_mode: "keep-id:uid=1000,gid=1000" + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - './data:/home/node/.n8n:Z' + tty: true + stdin_open: true + restart: unless-stopped + + task-runners: + image: n8nio/runners:1.111.0 + container_name: n8n-runners + # Was missing: without this the runner sits on the compose default network and + # cannot resolve N8N_RUNNERS_TASK_BROKER_URI (http://n8n:5679). + networks: ['nginx-bridge'] + env_file: [.env] + environment: + - N8N_RUNNERS_TASK_BROKER_URI + - N8N_RUNNERS_AUTH_TOKEN + depends_on: + - n8n diff --git a/server/containers/N8n/podman-n8n.service b/server/containers/N8n/podman-n8n.service new file mode 100644 index 0000000..3b6b46d --- /dev/null +++ b/server/containers/N8n/podman-n8n.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: N8n +After=network-online.target podman-postgresql.service +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/N8n +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=300 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Nginx_Proxy_Manager/.env.example b/server/containers/Nginx_Proxy_Manager/.env.example new file mode 100644 index 0000000..170c030 --- /dev/null +++ b/server/containers/Nginx_Proxy_Manager/.env.example @@ -0,0 +1,5 @@ +DB_POSTGRES_HOST=postgresql +DB_POSTGRES_PORT=5432 +DB_POSTGRES_USER=npm +DB_POSTGRES_PASSWORD=CHANGE_ME +DB_POSTGRES_NAME=npm diff --git a/server/containers/Nginx_Proxy_Manager/compose.yml b/server/containers/Nginx_Proxy_Manager/compose.yml new file mode 100644 index 0000000..10f6285 --- /dev/null +++ b/server/containers/Nginx_Proxy_Manager/compose.yml @@ -0,0 +1,52 @@ +services: + nginx-proxy-manager: + image: jc21/nginx-proxy-manager:latest + container_name: nginx-proxy-manager + hostname: nginx-proxy-manager + domainname: nginx.gibbyb.com + networks: ['nginx-bridge'] + restart: unless-stopped + ports: + - '80:80' + - '443:443' + # ⚠️ ONLY 81 moves. 80 and 443 above MUST stay on 0.0.0.0 — they are the + # public front door and firewalld already restricts them to Cloudflare ranges. + # 81 is the admin UI and is reached over WireGuard (http://192.168.2.2:81). + - '192.168.2.2:81:81' # Admin Web Port — WireGuard only + #- '21:21' # FTP + #- '22:22' # SSH + #- '25565:25565' # Minecraft + environment: + - TZ=America/New_York + - DB_POSTGRES_HOST + - DB_POSTGRES_PORT + - DB_POSTGRES_USER + - DB_POSTGRES_PASSWORD + - DB_POSTGRES_NAME + labels: + com.centurylinklabs.watchtower.enable: 'true' + volumes: + - ./volumes/data:/data:Z + - ./volumes/letsencrypt:/etc/letsencrypt:Z + #depends_on: [postgresql] + + #postgresql: + #image: postgres:17 + #container_name: nginx-proxy-manager-db + #hostname: nginx-proxy-manager-db + #networks: ['nginx-bridge'] + #environment: + #POSTGRES_USER: 'npm' + #POSTGRES_PASSWORD: '' + #POSTGRES_DB: 'npm' + #labels: + #com.centurylinklabs.watchtower.enable: 'true' + #volumes: + #- ./volumes/postgres:/var/lib/postgresql/data + #restart: unless-stopped + +networks: + # Created out-of-band so all nine stacks agree and there is no create race at boot: + # podman network create --subnet 172.18.0.0/24 nginx-bridge + nginx-bridge: + external: true diff --git a/server/containers/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service b/server/containers/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service new file mode 100644 index 0000000..8bd09df --- /dev/null +++ b/server/containers/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Nginx Proxy Manager +After=network-online.target podman-postgresql.service +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Nginx_Proxy_Manager +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=300 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Penpot/.env.example b/server/containers/Penpot/.env.example new file mode 100644 index 0000000..deace8d --- /dev/null +++ b/server/containers/Penpot/.env.example @@ -0,0 +1,2 @@ +PENPOT_SECRET_KEY=CHANGE_ME +PENPOT_DATABASE_PASSWORD=CHANGE_ME diff --git a/server/containers/Penpot/compose.yml b/server/containers/Penpot/compose.yml new file mode 100644 index 0000000..1cea7b4 --- /dev/null +++ b/server/containers/Penpot/compose.yml @@ -0,0 +1,120 @@ +# Penpot — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12. +# +# ⚠️ NOT STARTED. No systemd unit is enabled. It has never run on either machine, so +# there is no data to migrate. Bring it up with: +# systemctl --user enable --now podman-penpot.service +# +# BEFORE FIRST START, create its role and database on the SHARED postgres (this stack +# no longer ships its own): +# podman exec -i postgresql psql -U npm <<'SQL' +# CREATE ROLE penpot LOGIN PASSWORD ''; +# CREATE DATABASE penpot OWNER penpot; +# SQL +# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql. +# +# NOTE: the home version used postgres:15-alpine with --data-checksums. The shared +# instance is postgres 17 without checksums. Penpot does not care, and since there is +# no existing data this is not a migration — but if you ever DO need checksums, they +# can only be set at initdb time for the whole cluster. +# +# Redis stays local to this stack: Penpot uses it for transient session and +# notification state only, so it is not worth centralising. +# +# Rootless adaptations from the home version: +# - Dropped its private postgres:15-alpine; uses the shared instance (AGENTS.md §2). +# - PENPOT_DATABASE_URI repointed from penpot-db to the shared postgresql. +# - :Z on the assets bind mount. It is shared between frontend and backend, so it +# uses LOWERCASE :z -- uppercase would give each container a private MCS category +# and the second to start would lock the first out (AGENTS.md §6). +# - /etc/localtime mounts removed in favour of TZ (AGENTS.md §8). +# +# Registration is disabled by default (PENPOT_FLAGS disable-registration), so create +# the first account from the backend container before you can log in. +# +# NPM: proxy penpot.gbrown.org -> http://penpot-frontend:8080 + +networks: + nginx-bridge: + external: true + +services: + penpot-frontend: + image: docker.io/penpotapp/frontend:latest + container_name: penpot-frontend + hostname: penpot-frontend + domainname: penpot.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - PENPOT_FLAGS=disable-registration enable-login-with-password disable-smtp + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/assets:/opt/data/assets:z + depends_on: + - penpot-backend + - penpot-exporter + tty: true + stdin_open: true + restart: unless-stopped + + penpot-backend: + image: docker.io/penpotapp/backend:latest + container_name: penpot-backend + hostname: penpot-backend + domainname: penpot.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - PENPOT_FLAGS=disable-registration enable-login-with-password disable-smtp enable-prepl-server + - PENPOT_PUBLIC_URI=https://penpot.gbrown.org + - PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY} + # Shared postgres, not a private container + - PENPOT_DATABASE_URI=postgresql://postgresql/penpot + - PENPOT_DATABASE_USERNAME=penpot + - PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD} + - PENPOT_REDIS_URI=redis://penpot-redis/0 + - PENPOT_ASSETS_STORAGE_BACKEND=assets-fs + - PENPOT_STORAGE_ASSETS_FS_DIRECTORY=/opt/data/assets + - PENPOT_TELEMETRY_ENABLED=false + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/assets:/opt/data/assets:z + depends_on: + - penpot-redis + tty: true + stdin_open: true + restart: unless-stopped + + penpot-exporter: + image: docker.io/penpotapp/exporter:latest + container_name: penpot-exporter + hostname: penpot-exporter + domainname: penpot.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - PENPOT_PUBLIC_URI=http://penpot-frontend:8080 + - PENPOT_REDIS_URI=redis://penpot-redis/0 + labels: + com.centurylinklabs.watchtower.enable: "true" + depends_on: + - penpot-redis + tty: true + stdin_open: true + restart: unless-stopped + + penpot-redis: + image: docker.io/library/redis:7-alpine + container_name: penpot-redis + hostname: penpot-redis + domainname: penpot.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + labels: + com.centurylinklabs.watchtower.enable: "true" + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/containers/Penpot/podman-penpot.service b/server/containers/Penpot/podman-penpot.service new file mode 100644 index 0000000..e6205c8 --- /dev/null +++ b/server/containers/Penpot/podman-penpot.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Penpot +After=network-online.target podman.socket podman-postgresql.service +Wants=network-online.target podman-postgresql.service +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Penpot +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Portainer/compose.yml b/server/containers/Portainer/compose.yml new file mode 100644 index 0000000..46a1dd8 --- /dev/null +++ b/server/containers/Portainer/compose.yml @@ -0,0 +1,31 @@ +services: + portainer: + image: portainer/portainer-ee:latest + container_name: portainer + hostname: portainer + domainname: port.gibbyb.com + networks: + - nginx-bridge + environment: + - TZ=America/New_York + labels: + com.centurylinklabs.watchtower.enable: "true" + ports: + # WireGuard address only — this admin UI has no business on the public + # interface. It has its own login and the Hetzner firewall does not admit this + # port, so this is defence in depth: it removes the dependency on a firewall rule + # set that lives in a web console. See AGENTS.md §13.2. + - 192.168.2.2:9443:9443 + # Required under SELinux Enforcing to reach the rootless podman socket, which + # systemd recreates at each boot with a non-container label. The socket mount + # deliberately has no :Z -- relabeling the live socket would break podman itself. + security_opt: ['label:disable'] + volumes: + - ./volumes/data:/data:Z + - /run/user/1000/podman/podman.sock:/var/run/docker.sock + tty: true + restart: unless-stopped + +networks: + nginx-bridge: + external: true diff --git a/server/containers/Portainer/podman-portainer.service b/server/containers/Portainer/podman-portainer.service new file mode 100644 index 0000000..837e52d --- /dev/null +++ b/server/containers/Portainer/podman-portainer.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Portainer +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Portainer +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=300 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/PostgreSQL/.env.example b/server/containers/PostgreSQL/.env.example new file mode 100644 index 0000000..0631bc3 --- /dev/null +++ b/server/containers/PostgreSQL/.env.example @@ -0,0 +1,4 @@ +POSTGRES_USER=npm +POSTGRES_PASSWORD=CHANGE_ME +POSTGRES_NAME=npm +POSTGRES_DB=npm diff --git a/server/containers/PostgreSQL/compose.yml b/server/containers/PostgreSQL/compose.yml new file mode 100644 index 0000000..4139086 --- /dev/null +++ b/server/containers/PostgreSQL/compose.yml @@ -0,0 +1,80 @@ +networks: + nginx-bridge: + external: true +services: + postgresql: + # pgvector image = stock postgres:17 plus the `vector` extension compiled in. Same + # PostgreSQL 17.10 and same data directory format, so this is a drop-in swap with no + # dump/restore needed. + # + # ⚠️ THE TAG MUST STAY -trixie. This is not cosmetic. + # + # The plain `pgvector/pgvector:pg17` tag is built on Debian 12 (bookworm, glibc 2.36). + # The stock `postgres:17` this cluster was created under is Debian 13 (trixie, + # glibc 2.41). glibc supplies the collation used to order every text index, and it is + # NOT guaranteed stable across versions. Starting on bookworm made all 14 databases + # report: + # WARNING: database "npm" has a collation version mismatch + # DETAIL: created using collation version 2.41, but the OS provides version 2.36 + # An index built under one collation and read under another can silently return wrong + # results — missed rows in range scans, duplicate values slipping past unique + # constraints. It does not error; it just quietly answers incorrectly. + # + # Using the -trixie build keeps glibc at 2.41, matching how the data was written, so + # no REINDEX is required. If you ever must move to a different base, the correct + # procedure is: REINDEX DATABASE , then ALTER DATABASE REFRESH COLLATION + # VERSION — not simply silencing the warning. + # + # The extension is AVAILABLE but not enabled anywhere by default. To use it in a + # database, enable it per-database (it is not cluster-wide): + # podman exec postgresql psql -U npm -d -c 'CREATE EXTENSION vector;' + # + # ⚠️ Do NOT let watchtower update this to a different pg major. It is already in + # WATCHTOWER_DISABLE_CONTAINERS, which is what keeps that from happening. + image: pgvector/pgvector:pg17-trixie + container_name: postgresql + # Rootless: maps host uid 1000 (gib) -> container uid 999 (postgres), so ./pg_data + # stays gib-owned on the host. Postgres' entrypoint supports running as non-root + # provided the data dir ownership matches, which this guarantees. + userns_mode: "keep-id:uid=999,gid=999" + hostname: postgresql + domainname: pg.gbrown.org + networks: + - nginx-bridge + # ⚠️ WIREGUARD ADDRESS ONLY. This was previously '5432:5432', which bound the + # database to every interface including the public one — the only thing preventing + # the internet from reaching a Postgres auth prompt was firewalld plus the Hetzner + # cloud firewall. Two independent firewall rules were the sole barrier in front of + # all 14 databases. + # + # Nothing needs the public binding: every consumer (authentik, infisical, adminer, + # the payload/convex app stacks, gitea) resolves `postgresql` over the nginx-bridge + # container network and never touches the published port at all. This mapping now + # exists purely so you can point a desktop client at it across the tunnel. + # + # Same pattern as watchtower's API and authentik's 9000 — see AGENTS.md §5. + ports: ['192.168.2.2:5432:5432'] + env_file: .env + environment: + - POSTGRES_USER + - POSTGRES_PASSWORD + - POSTGRES_DB + - TZ=America/New_York + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./pg_data:/var/lib/postgresql/data:Z + # Runs ONLY when pg_data is empty (fresh install). Recreates every role and + # database this VPS needs, so the environment can be rebuilt from scratch without + # hand-creating them. Does nothing to an existing database — safe to leave mounted. + # The committed file is 00-roles-and-databases.sql.TEMPLATE with placeholder + # passwords; copy it to .sql and fill in real values from each service's .env. + - ./initdb:/docker-entrypoint-initdb.d:Z + tty: true + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + start_period: 20s + interval: 30s + retries: 5 + timeout: 5s diff --git a/server/containers/PostgreSQL/podman-postgresql.service b/server/containers/PostgreSQL/podman-postgresql.service new file mode 100644 index 0000000..f27370a --- /dev/null +++ b/server/containers/PostgreSQL/podman-postgresql.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: PostgreSQL +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/PostgreSQL +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=300 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Rustdesk/.env.example b/server/containers/Rustdesk/.env.example new file mode 100644 index 0000000..2c57394 --- /dev/null +++ b/server/containers/Rustdesk/.env.example @@ -0,0 +1 @@ +RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME diff --git a/server/containers/Rustdesk/compose.yml b/server/containers/Rustdesk/compose.yml new file mode 100644 index 0000000..217d7ba --- /dev/null +++ b/server/containers/Rustdesk/compose.yml @@ -0,0 +1,89 @@ +# RustDesk Server — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/rustdesk, +# migrated 2026-08-12. +# +# ⚠️ THIS SERVICE CANNOT GO BEHIND CLOUDFLARE'S PROXY. +# RustDesk's ID/relay protocol is raw TCP/UDP on 21115-21119. Cloudflare's proxy only +# carries HTTP/HTTPS; raw TCP needs Spectrum, which is not on this plan. So +# rustdesk.gbrown.org MUST be a grey-cloud (DNS-only) record pointing at +# 178.156.197.55, and these ports must be open in firewalld's public zone. +# +# Consequence, accepted deliberately: this publishes the VPS's real IP. That is +# tolerable because the firewall still restricts 80/443 to Cloudflare ranges, so +# knowing the IP does not grant access to any of the web services. +# +# The server keypair in ./volumes/server/id_ed25519 was carried over from the home +# server. Its public half matches RUSTDESK_API_RUSTDESK_KEY below +# (WLgvHhau6aa5nDPQutTHeQBpIrOEb8aPXByVBWQwkKc=), which is what lets existing clients +# reconnect without being re-paired. NEVER regenerate it. +# +# Rootless adaptations: +# - :Z on both bind mounts (SELinux Enforcing); each is exclusive to this container. +# - All published ports are >1024 so no privileged-port handling is needed. +# - The image starts as root under s6 and drops privileges internally, so no +# userns_mode is required. +# +# PORT REFERENCE (all must be open in the firewall): +# 21114/tcp web UI + API 21117/tcp hbbr relay +# 21115/tcp hbbs NAT type test 21118/tcp websocket (web client) +# 21116/tcp hbbs ID registration 21119/tcp websocket relay +# 21116/udp hbbs heartbeat <-- UDP, easy to forget + +networks: + nginx-bridge: + external: true + +services: + rustdesk-server: + image: docker.io/lejianwen/rustdesk-server-s6:latest + container_name: rustdesk-server + hostname: rustdesk-server + domainname: rustdesk.gbrown.org + networks: ['nginx-bridge'] + ports: + - 21114:21114 + - 21115:21115 + - 21116:21116 + - 21116:21116/udp + - 21117:21117 + - 21118:21118 + - 21119:21119 + environment: + - MUST_LOGIN=Y + - TZ=America/New_York + # SPLIT HOSTNAMES — this is deliberate, do not "simplify" it back to one name. + # relay.gbrown.org DNS-only (grey cloud) -> 178.156.197.55 + # Carries the raw TCP/UDP protocol on 21115-21119. + # Cloudflare's proxy only handles HTTP/HTTPS, so this + # hostname MUST bypass it. That publishes the VPS IP, + # which is acceptable: the firewall still restricts + # 80/443 to Cloudflare ranges, so knowing the IP grants + # no access to any web service. + # rustdesk.gbrown.org Proxied -> NPM -> rustdesk-server:21114 + # The web console and API, over TLS via Cloudflare. + # Keeping the API on the proxied name is what avoids sending login + # credentials over plain HTTP. + # RELAY is read by the s6 run script as `hbbs -r $RELAY`. It is what hbbs + # hands back to clients when P2P hole-punching fails, so it MUST be set here: + # the RUSTDESK_API_* vars below only configure the web console/API, not hbbs. + # Left unset, the image defaults to `relay.example.com`, which resolves to + # nothing -- so every connection needing a relay (i.e. every off-LAN, + # off-WireGuard client behind CGNAT) silently fails. + - RELAY=relay.gbrown.org + - RUSTDESK_API_RUSTDESK_ID_SERVER=relay.gbrown.org + - RUSTDESK_API_RUSTDESK_RELAY_SERVER=relay.gbrown.org + - RUSTDESK_API_RUSTDESK_API_SERVER=https://rustdesk.gbrown.org + - RUSTDESK_API_RUSTDESK_KEY=WLgvHhau6aa5nDPQutTHeQBpIrOEb8aPXByVBWQwkKc= + - RUSTDESK_API_RUSTDESK_JWT_KEY=${RUSTDESK_API_RUSTDESK_JWT_KEY} + - RUSTDESK_API_LANG=en + - RUSTDESK_API_APP_DISABLE_PWD_LOGIN=true + - RUSTDESK_API_ADMIN_TITLE=Gib's Rustdesk + - RUSTDESK_API_ADMIN_HELLO=

Welcome to Gib's Rustdesk

+ - ENCRYPTED_ONLY=1 + - RUSTDESK_API_RUSTDESK_WEBCLIENT_MAGIC_QUERYONLINE=1 + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/server:/data:Z + - ./volumes/api:/app/data:Z + restart: unless-stopped + tty: true diff --git a/server/containers/Rustdesk/podman-rustdesk.service b/server/containers/Rustdesk/podman-rustdesk.service new file mode 100644 index 0000000..3e899a6 --- /dev/null +++ b/server/containers/Rustdesk/podman-rustdesk.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Rustdesk +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Rustdesk +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=600 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Spoon/.env.example b/server/containers/Spoon/.env.example new file mode 100644 index 0000000..62400da --- /dev/null +++ b/server/containers/Spoon/.env.example @@ -0,0 +1,66 @@ +AUTH_AUTHENTIK_ID=CHANGE_ME +AUTH_AUTHENTIK_ISSUER="https://auth.gbrown.org/application/o/spoon/" +AUTH_AUTHENTIK_SECRET=CHANGE_ME +AUTH_GITHUB_ID="Iv23liygrKjd17rba96x" +AUTH_GITHUB_SECRET=CHANGE_ME +BACKEND_CONTAINER_NAME="spoon-backend" +BACKEND_DOMAIN="convex.spoon.gbrown.org" +BACKEND_PORT="3210" +BACKEND_TAG="latest" +CONVEX_CLOUD_ORIGIN="https://api.spoon.gbrown.org" +CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME +CONVEX_SELF_HOSTED_URL="https://api.spoon.gbrown.org" +CONVEX_SITE_ORIGIN="https://convex.spoon.gbrown.org" +CONVEX_SITE_URL="https://spoon.gbrown.org" +DASHBOARD_CONTAINER_NAME="spoon-dashboard" +DASHBOARD_DOMAIN="dashboard.spoon.gbrown.org" +DASHBOARD_PORT="6791" +DASHBOARD_TAG="latest" +DISABLE_BEACON="true" +DO_NOT_REQUIRE_SSL="true" +GITHUB_APP_CLIENT_ID="Iv23liygrKjd17rba96x" +GITHUB_APP_CLIENT_SECRET=CHANGE_ME +GITHUB_APP_ID="4111484" +GITHUB_APP_INSTALLATION_ID="141786602" +GITHUB_APP_OWNER="gibbyb" +GITHUB_APP_PRIVATE_KEY=CHANGE_ME +GITHUB_APP_SLUG="spoon-gbrown" +GITHUB_APP_WEBHOOK_SECRET=CHANGE_ME +INSTANCE_NAME="convex" +NETWORK="nginx-bridge" +NEXT_CONTAINER_NAME="spoon-next" +NEXT_DOMAIN="spoon.gbrown.org" +NEXT_PORT="3000" +NEXT_PUBLIC_CONVEX_URL="https://api.spoon.gbrown.org" +NEXT_PUBLIC_DEPLOYMENT_URL="https://api.spoon.gbrown.org" +NEXT_PUBLIC_PLAUSIBLE_URL="https://plausible.gbrown.org" +NEXT_PUBLIC_SENTRY_DSN="https://cb4fb3ba997b13adb731b8e55f85c1f7@sentry.gbrown.org/8" +NEXT_PUBLIC_SENTRY_ORG="sentry" +NEXT_PUBLIC_SENTRY_PROJECT_NAME="spoon-nextjs" +NEXT_PUBLIC_SENTRY_URL="https://sentry.gbrown.org" +NEXT_PUBLIC_SITE_URL="https://spoon.gbrown.org" +NODE_ENV="production" +POSTGRES_CONTAINER_NAME="spoon-postgres" +POSTGRES_DB="spoon_convex" +POSTGRES_PASSWORD=CHANGE_ME +POSTGRES_PORT="5432" +POSTGRES_URL=CHANGE_ME +POSTGRES_USER="spoon" +REDACT_LOGS_TO_CLIENT="true" +SENTRY_AUTH_TOKEN=CHANGE_ME +SITE_PROXY_PORT="3211" +SPOON_ENCRYPTION_KEY=CHANGE_ME +SPOON_WORKER_TOKEN=CHANGE_ME +USESEND_API_KEY=CHANGE_ME +USESEND_FROM_EMAIL="Spoon Admin " +USESEND_URL="https://usesend.gbrown.org" +SPOON_AGENT_JOB_IMAGE=git.gbrown.org/gib/spoon-agent-job:latest +SPOON_AGENT_JOB_TIMEOUT_MS="1800000" +SPOON_AGENT_MAX_CONCURRENT_JOBS="1" +SPOON_AGENT_NETWORK=nginx-bridge +SPOON_AGENT_RUNTIME=docker +SPOON_AGENT_WORKDIR=/var/lib/spoon-agent/work +SPOON_AGENT_WORKER_ID=production-worker +SPOON_AGENT_WORKER_HTTP_PORT="3921" +SPOON_AGENT_WORKER_INTERNAL_TOKEN=CHANGE_ME +SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921 diff --git a/server/containers/Spoon/compose.yml b/server/containers/Spoon/compose.yml new file mode 100644 index 0000000..40c94e8 --- /dev/null +++ b/server/containers/Spoon/compose.yml @@ -0,0 +1,114 @@ +networks: + nginx-bridge: + external: true +services: + spoon-next: + image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest + container_name: ${NEXT_CONTAINER_NAME} + hostname: ${NEXT_CONTAINER_NAME} + domainname: ${NEXT_DOMAIN} + networks: + - ${NETWORK:-nginx-bridge} + pull_policy: missing + environment: + - NODE_ENV=${NODE_ENV} + - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} + - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}} + - NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}} + - NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org} + - NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN} + - NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL} + - NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry} + - NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME} + - SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921} + - SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN} + - SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN} + depends_on: + - spoon-backend + labels: + - com.centurylinklabs.watchtower.enable=true + tty: true + stdin_open: true + restart: unless-stopped + spoon-agent-worker: + image: git.gbrown.org/gib/spoon-agent-worker:latest + container_name: spoon-agent-worker + hostname: spoon-agent-worker + domainname: worker.${NEXT_DOMAIN:-spoon.gbrown.org} + networks: + - ${NETWORK:-nginx-bridge} + pull_policy: missing + environment: + - GITHUB_APP_ID=${GITHUB_APP_ID} + - GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY} + - NEXT_PUBLIC_CONVEX_URL=https://api.spoon.gbrown.org + - SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker} + - SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-git.gbrown.org/gib/spoon-agent-job:latest} + - SPOON_AGENT_RUNTIME=docker + - SPOON_AGENT_NETWORK=${NETWORK:-nginx-bridge} + - SPOON_AGENT_WORKDIR=/var/lib/spoon-agent/work + - SPOON_AGENT_HOST_WORKDIR=/var/lib/spoon-agent/work + - SPOON_AGENT_WORKER_HTTP_PORT=${SPOON_AGENT_WORKER_HTTP_PORT:-3921} + - SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN} + - SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1} + - SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000} + - SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN} + volumes: + - /run/user/1000/podman/podman.sock:/var/run/docker.sock + - ./volumes/agent-work:/var/lib/spoon-agent/work:z + labels: + - com.centurylinklabs.watchtower.enable=true + tty: true + stdin_open: true + restart: unless-stopped + security_opt: + - label:disable + spoon-backend: + image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest} + container_name: ${BACKEND_CONTAINER_NAME:-spoon-backend} + hostname: ${BACKEND_CONTAINER_NAME:-spoon-backend} + domainname: ${BACKEND_DOMAIN:-convex.spoon.gbrown.org} + networks: + - ${NETWORK:-nginx-bridge} + volumes: + - ./volumes/convex:/convex/data:z + pull_policy: missing + environment: + - INSTANCE_NAME=${INSTANCE_NAME} + - CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}} + - CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${SITE_PROXY_PORT:-3211}} + - DISABLE_BEACON=${DISABLE_BEACON:-true} + - REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true} + - DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false} + - POSTGRES_URL=${POSTGRES_URL} + labels: + - com.centurylinklabs.watchtower.enable=true + stdin_open: true + tty: true + restart: unless-stopped + healthcheck: + test: curl -f http://localhost:3210/version + interval: 5s + start_period: 10s + stop_grace_period: 10s + stop_signal: SIGINT + spoon-dashboard: + image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest} + container_name: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard} + hostname: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard} + domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-spoon.gbrown.org}} + networks: + - ${NETWORK:-nginx-bridge} + pull_policy: missing + environment: + - NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${PORT:-3210}} + depends_on: + spoon-backend: + condition: service_healthy + labels: + - com.centurylinklabs.watchtower.enable=true + stdin_open: true + tty: true + restart: unless-stopped + stop_grace_period: 10s + stop_signal: SIGINT diff --git a/server/containers/Spoon/podman-spoon.service b/server/containers/Spoon/podman-spoon.service new file mode 100644 index 0000000..d0ff251 --- /dev/null +++ b/server/containers/Spoon/podman-spoon.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Spoon +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Spoon +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/StPeteIT/.env.example b/server/containers/StPeteIT/.env.example new file mode 100644 index 0000000..16ae33a --- /dev/null +++ b/server/containers/StPeteIT/.env.example @@ -0,0 +1,58 @@ +# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. +# Keep this file up-to-date when you add new variables to \`.env\`. +# This file will be committed to version control, so make sure not to have any secrets in it. +# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. + +## Next App ## +NODE_ENV=production +SENTRY_AUTH_TOKEN=CHANGE_ME +PAYLOAD_SECRET=CHANGE_ME +PAYLOAD_DB_URL=CHANGE_ME +NEXT_PUBLIC_SITE_URL=https://stpeteit.com +NEXT_PUBLIC_CONVEX_URL=https://api.stpeteit.com # convex-backend:3210 +NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.gbrown.org +NEXT_PUBLIC_SENTRY_DSN=https://f770f8b39a779906ba2da9dc21d4f983@sentry.gbrown.org/5 +NEXT_PUBLIC_SENTRY_URL=https://sentry.gbrown.org +NEXT_PUBLIC_SENTRY_ORG=sentry +NEXT_PUBLIC_SENTRY_PROJECT_NAME=stpeteit-next + +## Convex ## +CONVEX_SELF_HOSTED_URL=https://api.stpeteit.com # convex-backend:3210 +CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME +# Convex Auth +CONVEX_SITE_URL=https://stpeteit.com # convex-backend:3211 +USESEND_API_KEY=CHANGE_ME +USESEND_URL=https://usesend.gbrown.org +USESEND_FROM_EMAIL='St Pete IT Admin ' +AUTH_AUTHENTIK_ID=CHANGE_ME +AUTH_AUTHENTIK_SECRET=CHANGE_ME +AUTH_AUTHENTIK_ISSUER=https://auth.gbrown.org/application/o/stpeteit/ + +## Docker Compose Variables for Next App ## +NETWORK=nginx-bridge +NEXT_CONTAINER_NAME=stpeteit-next +NEXT_DOMAIN=stpeteit.com +#NEXT_PORT=3000 + +## Docker Compose Variables for Self hosted Convex ## +BACKEND_TAG=latest +DASHBOARD_TAG=latest +BACKEND_CONTAINER_NAME=stpeteit-backend +DASHBOARD_CONTAINER_NAME=stpeteit-dashboard +BACKEND_DOMAIN=convex.stpeteit.com +DASHBOARD_DOMAIN=dashboard.stpeteit.com +INSTANCE_NAME=stpeteit-convex +#INSTANCE_SECRET= +CONVEX_CLOUD_ORIGIN=https://api.stpeteit.com +CONVEX_SITE_ORIGIN=https://convex.stpeteit.com +NEXT_PUBLIC_DEPLOYMENT_URL=https://api.stpeteit.com +DISABLE_BEACON=true +REDACT_LOGS_TO_CLIENT=true +DO_NOT_REQUIRE_SSL=true +POSTGRES_URL=CHANGE_ME +#BACKEND_PORT= +#DASHBOARD_PORT +#SITE_PROXY_PORT= +#ACTIONS_USER_TIMEOUT_SECS= +#RUST_LOG= +#RUST_BACKTRACE= diff --git a/server/containers/StPeteIT/compose.yml b/server/containers/StPeteIT/compose.yml new file mode 100644 index 0000000..b2669ab --- /dev/null +++ b/server/containers/StPeteIT/compose.yml @@ -0,0 +1,101 @@ +networks: + nginx-bridge: # Change to network you plan to use + external: true + +services: + stpeteit-next: + image: git.gbrown.org/gib/stpeteit-next:latest + container_name: stpeteit-next + hostname: stpeteit-next + domainname: ${NEXT_DOMAIN} + networks: ['${NETWORK:-nginx-bridge}'] + #ports: ['${NEXT_PORT}:${NEXT_PORT}'] + environment: + - NODE_ENV=${NODE_ENV:-development} + - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} + - PAYLOAD_SECRET=${PAYLOAD_SECRET} + - PAYLOAD_DB_URL=${PAYLOAD_DB_URL} + - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}} + - NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}} + - NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.stpeteit.com} + - NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN} + - NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL} + - NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry} + - NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME} + labels: ['com.centurylinklabs.watchtower.enable=true'] + depends_on: ['stpeteit-backend'] + tty: true + stdin_open: true + restart: unless-stopped + + stpeteit-backend: + image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest} + container_name: ${BACKEND_CONTAINER_NAME:-stpeteit-backend} + hostname: ${BACKEND_CONTAINER_NAME:-stpeteit-backend} + domainname: ${BACKEND_DOMAIN:-convex.stpeteit.com} + networks: ['${NETWORK:-nginx-bridge}'] + #user: '1000:1000' + #ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211'] + volumes: [./data:/convex/data:z] + labels: ['com.centurylinklabs.watchtower.enable=true'] + environment: + - INSTANCE_NAME + #- INSTANCE_SECRET + - CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}} + - CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${SITE_PROXY_PORT:-3211}} + - DISABLE_BEACON=${DISABLE_BEACON:-true} + - REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true} + - DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false} + - POSTGRES_URL=${POSTGRES_URL} + stdin_open: true + tty: true + restart: unless-stopped + healthcheck: + test: curl -f http://localhost:3210/version + interval: 5s + start_period: 10s + stop_grace_period: 10s + stop_signal: SIGINT + + stpeteit-dashboard: + image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest} + container_name: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard} + hostname: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard} + domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.stpete.com}} + networks: ['${NETWORK:-nginx-bridge}'] + #user: 1000:1000 + #ports: ['${DASHBOARD_PORT:-6791}:6791'] + labels: ['com.centurylinklabs.watchtower.enable=true'] + environment: + - NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${PORT:-3210}} + depends_on: + stpeteit-backend: + condition: service_healthy + stdin_open: true + tty: true + restart: unless-stopped + stop_grace_period: 10s + stop_signal: SIGINT + + #convexmonorepo-postgresql: + #image: postgres:17 + #container_name: ${POSTGRES_CONTAINER_NAME:-convexmonorepo-postgres} + #hostname: ${POSTGRES_CONTAINER_NAME:-convexmonorepo-postgres} + #domainname: postgres.${NEXT_DOMAIN:-convexmonorepo.gbrown.org} + #networks: ['${NETWORK:-nginx-bridge}'] + #ports: ['5432:5432'] + #environment: + #- POSTGRES_USER=${POSTGRES_USER:-convexmonorepo} + #- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + #- POSTGRES_DB=${POSTGRES_DB:-convexmonorepo_payload} + #labels: ['com.centurylinklabs.watchtower.enable=true'] + #volumes: ['./volumes/postgres:/var/lib/postgresql/data:Z'] + #tty: true + #stdin_open: true + #restart: unless-stopped + #healthcheck: + #test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + #start_period: 20s + #interval: 30s + #retries: 5 + #timeout: 5s diff --git a/server/containers/StPeteIT/podman-stpeteit.service b/server/containers/StPeteIT/podman-stpeteit.service new file mode 100644 index 0000000..07704f3 --- /dev/null +++ b/server/containers/StPeteIT/podman-stpeteit.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: StPeteIT +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/StPeteIT +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Tools/compose.yml b/server/containers/Tools/compose.yml new file mode 100644 index 0000000..4504afa --- /dev/null +++ b/server/containers/Tools/compose.yml @@ -0,0 +1,88 @@ +# Tools — small self-hosted utilities. VPS (ROOTLESS PODMAN) port of the home server's +# ~/Server/tools stack, migrated 2026-08-12. +# +# Rootless adaptations: +# - :Z on every bind mount (SELinux Enforcing). All mounts here are exclusive to a +# single container, so uppercase :Z is correct -- see AGENTS.md §6. +# - No ports published: NPM proxies to each container by name over nginx-bridge. +# +# All four images start as root and drop privileges internally, so no userns_mode is +# needed (AGENTS.md §3.2). + +networks: + nginx-bridge: + external: true + +services: + convertx: + image: ghcr.io/c4illin/convertx:latest + container_name: convertx + hostname: convertx + domainname: convert.tools.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - ALLOW_UNAUTHENTICATED=true + - ACCOUNT_REGISTRATION=false + - HIDE_HISTORY=true + volumes: + - ./volumes/convertx:/app/data:Z + labels: + com.centurylinklabs.watchtower.enable: "true" + tty: true + restart: unless-stopped + + it-tools: + image: docker.io/corentinth/it-tools:latest + container_name: it-tools + hostname: it-tools + domainname: tools.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + labels: + com.centurylinklabs.watchtower.enable: "true" + tty: true + restart: unless-stopped + + pdf-tools: + image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest + container_name: pdf-tools + hostname: pdf-tools + domainname: pdf.tools.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - LANGS=en_US + - DOCKER_ENABLE_SECURITY=true + volumes: + - ./volumes/pdf-tools/trainingData:/usr/share/tessdata:Z + - ./volumes/pdf-tools/extraConfigs:/configs:Z + - ./volumes/pdf-tools/customFiles:/customFiles:Z + - ./volumes/pdf-tools/logs:/logs/:Z + - ./volumes/pdf-tools/pipeline:/pipeline/:Z + labels: + com.centurylinklabs.watchtower.enable: "true" + tty: true + restart: unless-stopped + + bento-pdf: + image: ghcr.io/alam00000/bentopdf:latest + container_name: bento-pdf + hostname: bento-pdf + domainname: bento.tools.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - PUID=1000 + - PGID=1000 + - VITE_BRAND_NAME="GibPDF" + labels: + com.centurylinklabs.watchtower.enable: "true" + tty: true + restart: unless-stopped + healthcheck: + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8080'] + interval: 30s + timeout: 10s + retries: 3 diff --git a/server/containers/Tools/podman-tools.service b/server/containers/Tools/podman-tools.service new file mode 100644 index 0000000..169256d --- /dev/null +++ b/server/containers/Tools/podman-tools.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Tools +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Tools +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=600 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Uptime/compose.yml b/server/containers/Uptime/compose.yml new file mode 100644 index 0000000..a87460c --- /dev/null +++ b/server/containers/Uptime/compose.yml @@ -0,0 +1,26 @@ +networks: + nginx-bridge: + external: true +services: + uptime: + image: louislam/uptime-kuma:latest + container_name: uptime + hostname: uptime + domainname: uptime.gbrown.org + networks: [nginx-bridge] + environment: + - TZ=America/New_York + volumes: + - ./volume:/app/data:Z + - /run/user/1000/podman/podman.sock:/var/run/docker.sock + labels: + - "com.centurylinklabs.watchtower.enable=true" + security_opt: ['seccomp:unconfined','label:disable'] + # WireGuard address only — this admin UI has no business on the public + # interface. It has its own login and the Hetzner firewall does not admit this + # port, so this is defence in depth: it removes the dependency on a firewall rule + # set that lives in a web console. See AGENTS.md §13.2. + ports: ['192.168.2.2:3001:3001'] + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/containers/Uptime/podman-uptime.service b/server/containers/Uptime/podman-uptime.service new file mode 100644 index 0000000..274780a --- /dev/null +++ b/server/containers/Uptime/podman-uptime.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Uptime +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Uptime +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=300 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/UseSend/.env.example b/server/containers/UseSend/.env.example new file mode 100644 index 0000000..ab7e9fd --- /dev/null +++ b/server/containers/UseSend/.env.example @@ -0,0 +1,22 @@ +API_RATE_LIMIT=1 +AUTH_AUTHENTIK_ID=CHANGE_ME +AUTH_AUTHENTIK_SECRET=CHANGE_ME +AUTH_AUTHENTIK_ISSUER="https://auth.gbrown.org/application/o/gibsend/" +AWS_ACCESS_KEY=CHANGE_ME +AWS_SECRET_KEY=CHANGE_ME +AWS_DEFAULT_REGION=us-east-2 +DATABASE_URL=CHANGE_ME +GITHUB_ID=Ov23liz34colyJed9t9S +GITHUB_SECRET=CHANGE_ME +MINIO_ROOT_USER=usesend +MINIO_ROOT_PASSWORD=CHANGE_ME +NEXTAUTH_SECRET=CHANGE_ME +NEXT_PUBLIC_IS_CLOUD=false +POSTGRES_USER=usesend +POSTGRES_PASSWORD=CHANGE_ME +POSTGRES_DB=usesend +REDIS_URL=redis://usesend-redis:6379 +SMTP_HOST=smtp.usesend.gbrown.org +SMTP_USER=usesend +USESEND_PORT=3000 +USESEND_URL=https://usesend.gbrown.org diff --git a/server/containers/UseSend/compose.yml b/server/containers/UseSend/compose.yml new file mode 100644 index 0000000..ce0daba --- /dev/null +++ b/server/containers/UseSend/compose.yml @@ -0,0 +1,88 @@ +name: usesend +networks: + nginx-bridge: + external: true +services: + redis: + image: redis:7 + container_name: usesend-redis + hostname: usesend-redis + networks: + - nginx-bridge + env_file: .env + labels: + com.centurylinklabs.watchtower.enable: 'true' + volumes: + - ./volumes/redis:/data:Z + command: + - redis-server + - --maxmemory-policy + - noeviction + tty: true + stdin_open: true + restart: unless-stopped + minio: + image: minio/minio + container_name: usesend-storage + hostname: usesend-storage + networks: + - nginx-bridge + env_file: .env + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + labels: + com.centurylinklabs.watchtower.enable: 'true' + volumes: + - ./volumes/minio:/data:Z + entrypoint: sh + command: -c 'mkdir -p /data/unsend && minio server /data --console-address ':9001' --address ':9002'' + tty: true + stdin_open: true + restart: unless-stopped + smtp-server: + image: usesend/smtp-proxy:v1.6.6 + container_name: usesend-smtp-server + hostname: usesend-smtp + networks: + - nginx-bridge + env_file: .env + environment: + - SMTP_AUTH_USERNAME=${SMTP_USER:?err} + - USESEND_BASE_URL=${USESEND_URL:?err} + labels: + com.centurylinklabs.watchtower.enable: 'true' + tty: true + stdin_open: true + restart: unless-stopped + usesend: + image: usesend/usesend:v1.6.6 + container_name: usesend + hostname: usesend + domainname: ${USESEND_URL:?err} + networks: + - nginx-bridge + env_file: .env + environment: + - PORT=${USESEND_PORT:-3000} + - DATABASE_URL=${DATABASE_URL:?err} + - NEXTAUTH_URL=${USESEND_URL:?err} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err} + - AWS_ACCESS_KEY=${AWS_ACCESS_KEY:?err} + - AWS_SECRET_KEY=${AWS_SECRET_KEY:?err} + - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err} + - GITHUB_ID=${GITHUB_ID:?err} + - GITHUB_SECRET=${GITHUB_SECRET:?err} + - REDIS_URL=${REDIS_URL:?err} + - NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false} + - API_RATE_LIMIT=${API_RATE_LIMIT:-1} + - SMTP_HOST=${SMTP_HOST:-smtp.usesend.gbrown.org} + - SMTP_USER=${SMTP_USER:-usesend} + labels: + com.centurylinklabs.watchtower.enable: 'true' + depends_on: + redis: + condition: service_started + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/containers/UseSend/podman-usesend.service b/server/containers/UseSend/podman-usesend.service new file mode 100644 index 0000000..d18e118 --- /dev/null +++ b/server/containers/UseSend/podman-usesend.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: UseSend +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/UseSend +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Vaultwarden/.env.example b/server/containers/Vaultwarden/.env.example new file mode 100644 index 0000000..2dcc48f --- /dev/null +++ b/server/containers/Vaultwarden/.env.example @@ -0,0 +1 @@ +ADMIN_TOKEN=CHANGE_ME diff --git a/server/containers/Vaultwarden/compose.yml b/server/containers/Vaultwarden/compose.yml new file mode 100644 index 0000000..6f9ed39 --- /dev/null +++ b/server/containers/Vaultwarden/compose.yml @@ -0,0 +1,55 @@ +# Vaultwarden — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/vaultwarden, +# staged 2026-08-12. +# +# ⚠️ STAGED, NOT AUTHORITATIVE. The home instance is still live and still serving +# vault.gbrown.org. This copy holds a point-in-time snapshot of the SQLite database. +# DO NOT log into this one and add credentials before cutover -- the two would diverge +# and one side's changes would be lost. +# +# Cutover procedure (do it in this order, it matters): +# 1. stop vaultwarden on the home server (clean SQLite, no torn WAL) +# 2. rsync -a server.gib:~/Server/vaultwarden/volume/ ~/Server/Vaultwarden/volume/ +# 3. systemctl --user restart podman-vaultwarden +# 4. point vault.gbrown.org at the VPS, add the NPM proxy host +# 5. verify a login + an item decrypts, THEN leave home stopped +# +# The SQLite copy taken while home was running is crash-consistent only. Step 1 is what +# makes it clean -- don't skip it. +# +# Rootless adaptations: +# - :Z on the data mount (SELinux Enforcing). +# - /etc/localtime mount dropped in favour of TZ (AGENTS.md §8). +# - No published port; NPM proxies to http://vaultwarden:80 over nginx-bridge. +# - Image starts as root and drops privileges internally, so no userns_mode needed. +# +# NOTE: ADMIN_TOKEN is inline here, matching the home server's file verbatim. It grants +# access to /admin. Worth moving to a mode-600 .env at some point -- compose files get +# rsynced offsite by ~/Server/backup. + +networks: + nginx-bridge: + external: true + +services: + vaultwarden: + image: docker.io/vaultwarden/server:latest + container_name: vaultwarden + hostname: vaultwarden + domainname: vault.gbrown.org + networks: + - nginx-bridge + environment: + - TZ=America/New_York + - DOMAIN=https://vault.gbrown.org + - LOGIN_RATELIMIT_MAX_BURST=10 + - LOGIN_RATELIMIT_SECONDS=60 + - WEB_VAULT_ENABLED=true + - SIGNUPS_ALLOWED=false + - WEBSOCKET_ENABLED=true + - ADMIN_TOKEN=${ADMIN_TOKEN} + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volume:/data:Z + tty: true + restart: unless-stopped diff --git a/server/containers/Vaultwarden/podman-vaultwarden.service b/server/containers/Vaultwarden/podman-vaultwarden.service new file mode 100644 index 0000000..c373993 --- /dev/null +++ b/server/containers/Vaultwarden/podman-vaultwarden.service @@ -0,0 +1,21 @@ +[Unit] +RequiresMountsFor=/home/gib/Media +Description=Podman Compose: Vaultwarden +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Vaultwarden +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=600 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/scripts/update-containers b/server/scripts/update-containers new file mode 100755 index 0000000..c460a95 --- /dev/null +++ b/server/scripts/update-containers @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# update-containers — pull new images and restart the services that changed. +# +# REPLACES WATCHTOWER. Written 2026-08-17 after watchtower took gitea down for +# three days. +# +# WHY NOT WATCHTOWER: podman-compose puts every project in a POD. Watchtower does +# not update through compose — it builds a replacement container from the image +# plus the old container's config, and that replacement lands OUTSIDE the pod. +# On 2026-08-14 it did exactly that to gitea, reported `failed=0 updated=1`, and +# left it returning 502 until someone noticed three days later. +# +# This script never touches containers directly. It pulls the image and then +# restarts the SYSTEMD UNIT, so podman-compose rebuilds the project the same way +# it does at boot — pod, network aliases, published ports and all. +# +# ───────────────────────────────────────────────────────────────────────────── +# USAGE +# update-containers # every service except SKIP (what the timer runs) +# update-containers gitea # one service, e.g. from CI after a push +# update-containers --dry-run # show what WOULD update, change nothing +# update-containers --list # show services and their images, then exit +# +# Runs nightly at 00:00 via podman-update.timer (server/systemd/, enabled by +# setup-server). Log: ~/Server/logs/update-containers.log +# ───────────────────────────────────────────────────────────────────────────── + +set -uo pipefail + +# ═════════════════════════════════════════════════════════════════════════════ +# CONFIG — this is the part you edit +# ═════════════════════════════════════════════════════════════════════════════ + +# Services that must NEVER update unattended. Add a service here to freeze it. +# +# postgresql — restarting it drops all databases at once, and every service +# with them. Minor 17.x bumps are safe in principle but not worth +# doing at midnight unobserved. Also: the image tag is pinned to +# pgvector/pgvector:pg17-trixie for collation reasons — an +# unattended change of base could corrupt text indexes. +# authentik — runs IRREVERSIBLE database migrations on startup. Rolling back +# the image does not roll back the schema; you would need a restore. +# +# Update these two by hand, after a pg_dump: +# podman pull && systemctl --user restart podman-.service +SKIP=(postgresql authentik watchtower) + +# Services built locally rather than pulled. There is no registry image to check, +# so pulling is pointless — they are skipped automatically when no `image:` line +# resolves, but listing them here keeps the log quiet and the intent obvious. +LOCAL_BUILD=(bang completeuphoria sierraandtyler) + +# ⚠️ NOTE ON GITEA: it is deliberately NOT in SKIP. The three-day outage was caused +# by watchtower's recreate mechanism, not by the act of updating — restarting the +# unit is the safe path this script uses. But gitea DOES run database migrations on +# version upgrades. If you would rather approve those yourself, move `gitea` into +# SKIP above; nothing else needs changing. + +SERVER_DIR="$HOME/Server" +LOG_DIR="$SERVER_DIR/logs" +LOG="$LOG_DIR/update-containers.log" + +# ═════════════════════════════════════════════════════════════════════════════ + +mkdir -p "$LOG_DIR" + +DRY_RUN=0 +ONLY="" +case "${1:-}" in + --dry-run) DRY_RUN=1 ;; + --list) LIST=1 ;; + --help|-h) sed -n '19,27p' "$0"; exit 0 ;; + "") ;; + -*) echo "unknown option: $1" >&2; exit 2 ;; + *) ONLY="$1" ;; +esac + +log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"; } + +in_list() { local n="$1"; shift; local x; for x in "$@"; do [ "$x" = "$n" ] && return 0; done; return 1; } + +# Units that are podman-compose services. Excludes podman's own helper units, +# which are not services and must stay disabled. +list_units() { + systemctl --user list-unit-files 'podman-*.service' --no-legend 2>/dev/null \ + | awk '{print $1}' \ + | grep -vE 'podman-(auto-update|restart|clean-transient|kube@|user-wait-network-online|update)' \ + | sed 's/^podman-//; s/\.service$//' \ + | sort +} + +# Images declared in a service's compose.yml. Ignores commented lines and the +# `build:` stanza, which has no pullable image. +# +# Image tags frequently reference variables from the service's .env, e.g. +# ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest} +# git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest +# Those must be expanded or the pull is attempted against a literal "${...}" and +# always fails. We expand in a SUBSHELL so the .env cannot leak into this script's +# environment (these files contain database passwords and API keys). +images_for() { + local dir="$1" + [ -f "$dir/compose.yml" ] || return 0 + grep -oE '^[[:space:]]*image:[[:space:]]*[^[:space:]#]+' "$dir/compose.yml" 2>/dev/null \ + | sed -E 's/^[[:space:]]*image:[[:space:]]*//' \ + | while IFS= read -r img; do + case "$img" in + *'${'*) ( set -a; [ -f "$dir/.env" ] && . "$dir/.env" >/dev/null 2>&1; set +a + eval "printf '%s\\n' \"$img\"" 2>/dev/null ) ;; + *) printf '%s\n' "$img" ;; + esac + done \ + | grep -v '\${' \ + | sort -u +} + +workdir_for() { + systemctl --user show "podman-$1.service" -p WorkingDirectory --value 2>/dev/null +} + +# ── --list ─────────────────────────────────────────────────────────────────── +if [ "${LIST:-0}" = "1" ]; then + for svc in $(list_units); do + printf '%-22s' "$svc" + if in_list "$svc" "${SKIP[@]}"; then echo "SKIP (frozen)"; continue; fi + if in_list "$svc" "${LOCAL_BUILD[@]}"; then echo "SKIP (built locally)"; continue; fi + echo "$(images_for "$(workdir_for "$svc")" | tr '\n' ' ')" + done + exit 0 +fi + +# ── main ───────────────────────────────────────────────────────────────────── +# ${DRY_RUN:+...} would expand even when DRY_RUN=0, because "0" is a non-empty +# string. Build the label explicitly instead. +dry_label=""; [ "$DRY_RUN" -eq 1 ] && dry_label=" [DRY RUN]" +log "=== update run started${ONLY:+ (single service: $ONLY)}$dry_label ===" + +updated=(); failed=(); checked=0 + +for svc in $(list_units); do + [ -n "$ONLY" ] && [ "$svc" != "$ONLY" ] && continue + if [ -z "$ONLY" ]; then + in_list "$svc" "${SKIP[@]}" && { log " $svc: frozen (SKIP list)"; continue; } + in_list "$svc" "${LOCAL_BUILD[@]}" && continue + fi + + # ⚠️ NEVER resurrect a service that is deliberately stopped. `systemctl restart` + # STARTS an inactive unit — without this guard, a new n8n image would silently + # bring n8n back up months after it was intentionally shut down. + if [ "$(systemctl --user is-active "podman-$svc.service")" != "active" ]; then + [ -n "$ONLY" ] && log " $svc: unit is not active — refusing to start it" + continue + fi + + dir="$(workdir_for "$svc")" + [ -d "$dir" ] || { log " $svc: no working directory, skipped"; continue; } + + mapfile -t imgs < <(images_for "$dir") + [ "${#imgs[@]}" -eq 0 ] && continue + + checked=$((checked+1)) + changed=0 + + for img in "${imgs[@]}"; do + # A digest-pinned image can never change; pulling it is wasted work. + case "$img" in *@sha256:*) continue ;; esac + + if ! podman pull -q "$img" >/dev/null 2>&1; then + log " $svc: pull failed for $img (private registry? built locally?)" + continue + fi + disk_id="$(podman image inspect "$img" --format '{{.Id}}' 2>/dev/null)" + [ -n "$disk_id" ] || continue + + # Compare the image on disk against what each RUNNING CONTAINER is actually + # using — not against the pre-pull image ID. + # + # Comparing before/after the pull looks equivalent but is subtly broken: a + # --dry-run (or any earlier pull) fetches the new image, so a later real run + # sees before == after, concludes "no change", and never restarts. The + # service then runs the OLD image forever with the new one sitting on disk, + # invisible. Anchoring on the running container makes the check idempotent + # and self-healing — it reports drift no matter who pulled, or when. + # + # ⚠️ Use `inspect .Image`, NOT `ps --format {{.ImageID}}`. The former returns + # the full 64-char ID matching `image inspect .Id`; the latter returns a + # 12-char short ID, so comparing them is ALWAYS unequal and every service + # looks like it needs an update. + while IFS='|' read -r cname cimgname cimgid; do + [ "$cimgname" = "$img" ] || continue + if [ "$cimgid" != "$disk_id" ]; then + log " $svc: $cname on ${cimgid:0:12} -> new image ${disk_id:0:12} ($img)" + changed=1 + fi + done < <(podman ps --filter "label=com.docker.compose.project=$svc" --format '{{.Names}}' 2>/dev/null \ + | while IFS= read -r n; do + podman inspect "$n" --format '{{.Name}}|{{.ImageName}}|{{.Image}}' 2>/dev/null + done) + done + + [ "$changed" -eq 0 ] && continue + + if [ "$DRY_RUN" -eq 1 ]; then + log " $svc: would restart (dry run)" + updated+=("$svc") + continue + fi + + # THE IMPORTANT LINE: restart the unit so podman-compose rebuilds the project + # (pod, networks, aliases, ports). Never recreate the container directly. + if systemctl --user restart "podman-$svc.service" 2>>"$LOG"; then + sleep 5 + if [ "$(systemctl --user is-active "podman-$svc.service")" = "active" ]; then + log " $svc: RESTARTED ok" + updated+=("$svc") + else + log " $svc: ⚠ RESTARTED BUT UNIT NOT ACTIVE" + failed+=("$svc") + fi + else + log " $svc: ⚠ RESTART FAILED" + failed+=("$svc") + fi +done + +# Reclaim layers the old images left behind. Dangling only — never touches an +# image a container still references. +if [ "$DRY_RUN" -eq 0 ] && [ "${#updated[@]}" -gt 0 ]; then + freed="$(podman image prune -f 2>/dev/null | tail -1)" + [ -n "$freed" ] && log " pruned: $freed" +fi + +log "=== done: $checked checked, ${#updated[@]} updated${updated:+ (${updated[*]})}, ${#failed[@]} failed${failed:+ (${failed[*]})} ===" + +# Non-zero if anything failed, so the systemd unit shows as failed and +# `systemctl --user --failed` surfaces it. +[ "${#failed[@]}" -eq 0 ] diff --git a/server/systemd/podman-update.service b/server/systemd/podman-update.service new file mode 100644 index 0000000..18d8970 --- /dev/null +++ b/server/systemd/podman-update.service @@ -0,0 +1,22 @@ +[Unit] +# Nightly image updates. Replaces watchtower, which recreated containers outside +# their podman-compose pod and took gitea down for three days — see the header +# of server/scripts/update-containers. +Description=Pull new container images and restart changed services +Documentation=https://git.gbrown.org/gib/Panama +# Needs the network and the podman socket path to exist. +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# %h rather than a literal home so the unit works for whichever user linked it. +# The one assumption is the standard checkout path; a machine with PANAMA_PATH +# somewhere else edits this line, and setup-server will say so when the script +# is not where this points. +ExecStart=%h/.local/share/Panama/server/scripts/update-containers +# Pulling ~20 images over a slow registry can take a while; do not kill it early. +TimeoutStartSec=3600 +# The script logs to ~/Server/logs/update-containers.log as well as the journal. +StandardOutput=journal +StandardError=journal diff --git a/server/systemd/podman-update.timer b/server/systemd/podman-update.timer new file mode 100644 index 0000000..2f5c04b --- /dev/null +++ b/server/systemd/podman-update.timer @@ -0,0 +1,16 @@ +[Unit] +Description=Nightly container image updates at midnight +Documentation=https://git.gbrown.org/gib/Panama + +[Timer] +# Midnight local time. +OnCalendar=*-*-* 00:00:00 +# If the machine was off at midnight, run once after it comes back rather than +# skipping the day entirely. +Persistent=true +# Avoid every timer on the box firing at the same instant. +RandomizedDelaySec=300 + +[Install] +# User timers need linger, which setup-server enables. +WantedBy=timers.target diff --git a/setup/lib/machine-role b/setup/lib/machine-role new file mode 100644 index 0000000..265c041 --- /dev/null +++ b/setup/lib/machine-role @@ -0,0 +1,35 @@ +# Which kind of machine this is: 'desktop' or 'server'. Sourced, not run. +# +# The role is the one interview answer Panama keeps. Everything else is +# transient by design because it is personal -- a name, an email -- and +# re-asking costs less than a state file that drifts. The role is neither +# personal nor re-askable: `panama update` runs with no interview at all, and +# a server updated as a desktop would acquire Hyprland, so the answer has to +# outlive the run that gave it. +# +# Precedence: an exported PANAMA_ROLE (a fresh install, where the interview +# just asked) beats the recorded file (every later run), which beats the +# default. The default is desktop because every machine that predates roles +# is one. + +PANAMA_ROLE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/role" + +panama_role() { + local role="${PANAMA_ROLE:-}" + if [[ -z "$role" && -r "$PANAMA_ROLE_FILE" ]]; then + role="$(<"$PANAMA_ROLE_FILE")" + fi + case "$role" in + server) printf 'server' ;; + *) printf 'desktop' ;; + esac +} + +# Anything but 'server' records 'desktop' rather than erroring: the recorded +# file must never hold a value panama_role would refuse to read back. +panama_role_record() { + local role="$1" + [[ "$role" == server ]] || role=desktop + mkdir -p "$(dirname "$PANAMA_ROLE_FILE")" + printf '%s\n' "$role" >"$PANAMA_ROLE_FILE" +} diff --git a/setup/packages/core-packages b/setup/packages/core-packages new file mode 100644 index 0000000..14a27d4 --- /dev/null +++ b/setup/packages/core-packages @@ -0,0 +1,37 @@ +# Every Panama machine, desktop or server. This is the shell environment +# itself -- the tools config/bash/shell and the aliases assume -- plus the +# few things both roles genuinely share. Desktop-only tools live in +# initial-packages; server-only ones in server-packages. +awk +bat +btop +curl +# `dnf copr`, which install-packages needs to enable the Hyprland COPR. A +# machine that has used copr before has it; a fresh Workstation install does +# not, and the failure would cost the entire desktop. +dnf5-plugins +eza +fontconfig +fzf +gh +git-all +gum +jq +neovim +# config/bash/shell initialises the prompt with this. +oh-my-posh +# ssh-keygen, which setup-identity uses to create a key on request. +openssh +openssl +# Rootless containers: the compose services on a server, the speech-to-text +# quadlet and the Containers settings page on a desktop. +podman +# What provides `podman compose` on Fedora; without it every unit's +# ExecStart resolves to an error about a missing external provider. +podman-compose +python3-dnf +ripgrep +tmux +unzip +wget +zoxide diff --git a/setup/packages/development-packages b/setup/packages/development-packages index f82f544..d230527 100644 --- a/setup/packages/development-packages +++ b/setup/packages/development-packages @@ -24,14 +24,11 @@ python3-questionary python3-pyperclip php php-fpm -# Rootless containers, and the backend for the Containers settings page. -podman python3-devel python3-gobject python3-tkinter python3-torch python3-virtualenv -ripgrep ruby sqlite3 # vimx: clipboard-capable vim, which the shipped `vim` alias and the vimrc diff --git a/setup/packages/initial-packages b/setup/packages/initial-packages index e255867..cf7076a 100644 --- a/setup/packages/initial-packages +++ b/setup/packages/initial-packages @@ -1,38 +1,15 @@ -awk -bat -btop +# Desktop machines only. What every machine gets -- the shell environment +# both roles share -- moved to core-packages; this is the remainder that only +# makes sense with a screen, a session, or a person developing at it. cargo -curl -# `dnf copr`, which install-packages needs to enable the Hyprland COPR. A -# machine that has used copr before has it; a fresh Workstation install does -# not, and the failure would cost the entire desktop. -dnf5-plugins -eza # install-packages installs flatpaks with it; Fedora Workstation ships it, # but a minimal install does not. flatpak -fontconfig fwupd -fzf -gh -git-all -gum -jq kitty ksshaskpass libselinux-utils -neovim -# config/bash/shell initialises the prompt with this. -oh-my-posh -# ssh-keygen, which setup-identity uses to create a key on request. -openssh -openssl pciutils -python3-dnf python3-neovim rustup -tmux -unzip -wget wireguard-tools -zoxide diff --git a/setup/packages/server-packages b/setup/packages/server-packages new file mode 100644 index 0000000..118964d --- /dev/null +++ b/setup/packages/server-packages @@ -0,0 +1,13 @@ +# Only on role=server, on top of core-packages. Deliberately short: a server +# is the shell environment plus containers, and almost everything it needs is +# already core. +# +# setup-server opens 80/443/81 through it. Fedora Server ships it; a minimal +# cloud image may not, and a server whose firewall step silently no-ops is a +# server somebody believes is firewalled. +firewalld +# Node comes through nvm here for the same reason as on the desktop -- +# config/bash/shell switches versions per project from .nvmrc -- and because +# install-packages puts Codex on with npm, agents' MCP servers want a node, +# and a system nodejs earlier on PATH would win every switch. +nvm diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 1a2c96f..3eac3d3 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -73,6 +73,148 @@ PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" # shellcheck source=../lib/extras-catalog source "$PANAMA_PATH/setup/lib/extras-catalog" +# Which machine this is. A server takes the short path below: core tools, +# node, the agents -- no third-party repos, no desktop, no flatpaks. +# shellcheck source=../lib/machine-role +source "$PANAMA_PATH/setup/lib/machine-role" +ROLE="$(panama_role)" + +# One list, installed the way every list is installed: --skip-unavailable so a +# single rotted name cannot cost the transaction, then report_missing so a +# skipped name is a warning somebody reads. +install_list() { + local file="$PANAMA_PATH/setup/packages/$1" label="$2" packages + if [[ -f "$file" ]]; then + packages=$(packages_in "$file") + log "Installing $label Packages" + echo -e "Includes the following packages:" + echo -e "$(<"$file")" + sudo dnf install -y --skip-unavailable $packages > /dev/null + report_missing "$file" + log "$label packages installed!" + else + log "Package list was not in specified path: $file" + fi +} + +# --- Node and pnpm, through nvm ---------------------------------------------- +# +# nvm is a shell function rather than a binary, so it has to be sourced before +# it can be used at all -- and its script reads variables that `set -u` above +# treats as fatal, so the strictness is lifted for exactly that source and put +# straight back. +# +# Deliberately not dnf's nodejs: config/bash/shell switches Node per project +# from .nvmrc, and a system Node earlier on PATH would win every switch, leaving +# `nvm use` looking like it did nothing. +# +# pnpm goes inside the nvm-managed Node rather than beside it as its own dnf +# package, so it travels with the version it belongs to instead of outliving it. +setup_node() { + if [[ -s /etc/profile.d/nvm.sh ]]; then + log "Installing the latest Node LTS through nvm" + set +u + # shellcheck source=/dev/null + source /etc/profile.d/nvm.sh + if nvm install --lts >/dev/null 2>&1; then + nvm alias default 'lts/*' >/dev/null 2>&1 || true + npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); } + log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)" + else + log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)") + fi + set -u + else + log "nvm is not installed, so Node was not set up" + fi +} + +# --- Applications no repository packages ------------------------------------- +# +# Everything else Panama installs comes from dnf or Flathub. These do not +# exist in either, so each is an explicit exception with a reason, and each is +# skipped when already present so a re-run costs nothing. +# +# None of them pins a version. sunhat pinned URLs -- upscayl 2.11.5, LACT 0.5.4, +# a fedora-40 RPM -- and every one of them was a 404 within a release cycle. An +# installer that resolves "latest" keeps working; one that names a version rots. +# +# A failure here is logged and stepped over rather than aborting: an +# unreachable third-party host should not cost the rest of the run. + +# Bun: the JavaScript runtime and package manager. No RPM, no flatpak. +install_bun() { + if [[ -x "$HOME/.bun/bin/bun" ]]; then + log "Bun already installed at \"$HOME/.bun/bin/bun\"" + else + log "Installing Bun via curl..." + curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); } + fi +} + +# Claude Code: Anthropic's CLI. The official installer keeps itself updated +# afterwards, so this runs once and then never needs to again. +install_claude_code() { + if command -v claude >/dev/null 2>&1; then + log "Claude Code already installed at \"$(command -v claude)\"" + else + log "Installing Claude Code via the official installer..." + curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || { log "Claude Code install failed; skipping"; softly_failed+=("Claude Code"); } + fi +} + +# Codex: OpenAI's CLI. Distributed through npm, which is why this runs after +# setup_node -- the nvm-managed Node is the one it should land in. +install_codex() { + if command -v codex >/dev/null 2>&1; then + log "Codex already installed at \"$(command -v codex)\"" + elif command -v npm >/dev/null 2>&1; then + log "Installing Codex via npm..." + npm install -g @openai/codex >/dev/null 2>&1 || { log "Codex install failed; skipping"; softly_failed+=("Codex"); } + else + log "npm is not available, so Codex was not installed"; softly_failed+=("Codex") + fi +} + +# --- What was stepped over --------------------------------------------------- +# +# Tolerating a failure is only better than aborting on it if somebody is told. +# The whole point of surviving a soft failure is that the rest gets installed +# anyway -- but a machine missing something should say so once, here, rather +# than be discovered a week later. +report_soft_failures() { + if (( ${#softly_failed[@]} > 0 )); then + log "Installed, but these were stepped over:" + printf ' - %s\n' "${softly_failed[@]}" + log "None of them stops the machine, but this run is not recorded as" + log "complete, so the next 'panama update' tries them again." + # A step that did not complete has not happened. Exiting non-zero is what + # keeps ./install from stamping the packages hash over the gaps -- stamped, + # they would never be retried (the hash-skip would say nothing changed). + exit 1 + fi +} + +# --- The server path --------------------------------------------------------- +# +# Everything a server runs is above this line plus the lists it installs. No +# RPM Fusion, no Terra, no COPR, no multimedia, no flatpaks: those exist for a +# desktop, and every one of them is a network dependency and a failure mode a +# headless machine has no reason to carry. +if [[ "$ROLE" == server ]]; then + echo -e "\n--- Installing packages (server) ---" + log "Updating all packages. This may take a while" + sudo dnf update -y --refresh > /dev/null + install_list core-packages "Core" + install_list server-packages "Server" + setup_node + install_bun + install_claude_code + install_codex + report_soft_failures + exit 0 +fi + echo -e "\n--- Installing Repositories ---" log "Installing RPM Fusion Free and Nonfree Repositories" sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null @@ -105,37 +247,14 @@ echo -e "\n--- Installing relevant packages ---" log "Updating all packages. This may take a while" sudo dnf update -y --refresh > /dev/null -# --- Install all initial packages --- -PACKAGES_FILE="$PANAMA_PATH/setup/packages/initial-packages" -if [[ -f "$PACKAGES_FILE" ]]; then - INITIAL_PACKAGES=$(packages_in "$PACKAGES_FILE") - log "Installing Initial Packages" - echo -e "Includes the following packages:" - echo -e "$(<"$PACKAGES_FILE")" - # --skip-unavailable: dnf5 refuses a whole transaction over one missing - # name, so a single rotted entry in this list used to cost every package - # in it -- and the desktop below never installed. The skipped names are - # reported afterwards rather than silently dropped. - sudo dnf install -y --skip-unavailable $INITIAL_PACKAGES > /dev/null - report_missing "$PACKAGES_FILE" - log "Initial packages installed!" -else - log "Package list was not in specified path: $PACKAGES_FILE" -fi - -# --- Install Desktop Packages --- -DESKTOP_FILE="$PANAMA_PATH/setup/packages/desktop-packages" -if [[ -f "$DESKTOP_FILE" ]]; then - DESKTOP_PACKAGES=$(packages_in "$DESKTOP_FILE") - log "Installing Desktop Packages" - echo -e "Includes the following packages:" - echo -e "$(<"$DESKTOP_FILE")" - sudo dnf install -y --skip-unavailable $DESKTOP_PACKAGES > /dev/null - report_missing "$DESKTOP_FILE" - log "Desktop packages installed!" -else - log "Package list was not in specified path: $DESKTOP_FILE" -fi +# --- Install the shared core, then the desktop-only lists --- +# --skip-unavailable throughout (inside install_list): dnf5 refuses a whole +# transaction over one missing name, so a single rotted entry used to cost +# every package in a list -- and the desktop below never installed. The +# skipped names are reported afterwards rather than silently dropped. +install_list core-packages "Core" +install_list initial-packages "Initial" +install_list desktop-packages "Desktop" # --- Install the Hyprland desktop --- # @@ -212,70 +331,10 @@ else log "Package list was not in specified path: $DEV_FILE" fi -# --- Node and pnpm, through nvm ---------------------------------------------- -# -# nvm is a shell function rather than a binary, so it has to be sourced before -# it can be used at all -- and its script reads variables that `set -u` above -# treats as fatal, so the strictness is lifted for exactly that source and put -# straight back. -# -# Deliberately not dnf's nodejs: config/bash/shell switches Node per project -# from .nvmrc, and a system Node earlier on PATH would win every switch, leaving -# `nvm use` looking like it did nothing. -# -# pnpm goes inside the nvm-managed Node rather than beside it as its own dnf -# package, so it travels with the version it belongs to instead of outliving it. -if [[ -s /etc/profile.d/nvm.sh ]]; then - log "Installing the latest Node LTS through nvm" - set +u - # shellcheck source=/dev/null - source /etc/profile.d/nvm.sh - if nvm install --lts >/dev/null 2>&1; then - nvm alias default 'lts/*' >/dev/null 2>&1 || true - npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); } - log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)" - else - log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)") - fi - set -u -else - log "nvm is not installed, so Node was not set up" -fi - -# --- Applications no repository packages ------------------------------------- -# -# Everything else Panama installs comes from dnf or Flathub. These four do not -# exist in either, so each is an explicit exception with a reason, and each is -# skipped when already present so a re-run costs nothing. -# -# Claude Desktop is the half-exception: a repository does carry it, just not one -# Fedora or Flathub knows about, so what is exceptional there is adding the -# repository rather than installing around one. -# -# None of them pins a version. sunhat pinned URLs -- upscayl 2.11.5, LACT 0.5.4, -# a fedora-40 RPM -- and every one of them was a 404 within a release cycle. An -# installer that resolves "latest" keeps working; one that names a version rots. -# -# A failure here is logged and stepped over rather than aborting: this stage has -# already installed the desktop by this point, and an unreachable third-party -# host should not cost you that. - -# Bun: the JavaScript runtime and package manager. No RPM, no flatpak. -if [[ -x "$HOME/.bun/bin/bun" ]]; then - log "Bun already installed at \"$HOME/.bun/bin/bun\"" -else - log "Installing Bun via curl..." - curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); } -fi - -# Claude Code: Anthropic's CLI. The official installer keeps itself updated -# afterwards, so this runs once and then never needs to again. -if command -v claude >/dev/null 2>&1; then - log "Claude Code already installed at \"$(command -v claude)\"" -else - log "Installing Claude Code via the official installer..." - curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || { log "Claude Code install failed; skipping"; softly_failed+=("Claude Code"); } -fi +setup_node +install_bun +install_claude_code +install_codex # Claude Desktop: Anthropic ships macOS and Windows only, so this is a community # RPM built from the official release. Panama used to build it from source -- it @@ -406,20 +465,4 @@ for extra in ${PANAMA_EXTRAS:-}; do fi done -# --- What was stepped over --------------------------------------------------- -# -# Tolerating a failure is only better than aborting on it if somebody is told. -# This stage now survives a codec swap that finds nothing to swap, and the whole -# point of surviving it is that the desktop gets installed anyway -- but a -# machine missing its video codecs should say so once, here, rather than be -# discovered a week later by a video that will not play. -if (( ${#softly_failed[@]} > 0 )); then - log "Installed, but these were stepped over:" - printf ' - %s\n' "${softly_failed[@]}" - log "None of them stops the desktop, but this run is not recorded as" - log "complete, so the next 'panama update' tries them again." - # A step that did not complete has not happened. Exiting non-zero is what - # keeps ./install from stamping the packages hash over the gaps -- stamped, - # they would never be retried (the hash-skip would say nothing changed). - exit 1 -fi +report_soft_failures diff --git a/setup/scripts/interview b/setup/scripts/interview index e9ff651..21b8b8d 100755 --- a/setup/scripts/interview +++ b/setup/scripts/interview @@ -42,6 +42,24 @@ yes_no() { gum confirm --default=false "$1"; } # ── Machine ────────────────────────────────────────────────────────────────── heading "This machine" + +# The role decides most of what follows: a server is never asked about NVIDIA +# drivers or Steam, and a desktop is never asked about compose services. It is +# also the one answer that outlives the run -- install records it durably, +# because `panama update` asks nothing and still has to know which machine it +# is updating. See setup/lib/machine-role. +# +# PANAMA_ROLE_PRESET is how `./install --server` answers this without a prompt, +# for the curl-onto-a-fresh-VPS path where the caller already said what the +# machine is. An empty or escaped choice falls back to desktop, which is what +# every Panama machine was before roles existed. +role="${PANAMA_ROLE_PRESET:-}" +if [[ -z "$role" ]]; then + role="$(gum choose --header "What is this machine?" "desktop" "server")" || role="" +fi +[[ "$role" == server ]] || role=desktop +record PANAMA_ROLE "$role" + current_hostname="$(hostname)" printf 'Current hostname: %s\n' "$current_hostname" new_hostname="" @@ -94,11 +112,23 @@ record PANAMA_SSH_KEY "$ssh_key" # machine rather than an answer to a hypothetical. A machine with no NVIDIA card # is never asked about drivers, and one with nothing to remove is never asked # about removing it. - -heading "Hardware" +# +# A server is asked none of it. The stages these answers feed -- +# install-hardware, the debloat removal -- do not run on the server path at +# all, and a question whose answer nothing consumes is a control that lies. +# The defaults are still recorded so the answers file has the same shape +# either way. nvidia=no mok_hash="" +installed=() +firmware=no +debloat=no + +if [[ "$role" != server ]]; then + +heading "Hardware" + nvidia_card="$(lspci 2>/dev/null | grep -iE 'vga compatible|3d controller' | grep -i nvidia | sed 's/.*: //' | head -1)" if [[ -n "$nvidia_card" ]]; then @@ -139,13 +169,9 @@ if [[ -n "$nvidia_card" ]]; then else printf 'No NVIDIA card found.\n' fi -record PANAMA_NVIDIA "$nvidia" -record PANAMA_MOK_HASH "$mok_hash" # The stage that removes them owns the list, so there is one copy of it. -debloat=no mapfile -t removable < <("$(dirname "${BASH_SOURCE[0]}")/install-hardware" --debloat-list) -installed=() for package in "${removable[@]}"; do rpm -q "$package" >/dev/null 2>&1 && installed+=("$package") done @@ -154,14 +180,18 @@ if (( ${#installed[@]} > 0 )); then debloat=yes fi fi -record PANAMA_DEBLOAT "$debloat" -firmware=no if command -v fwupdmgr >/dev/null 2>&1; then if yes_no "Update firmware with fwupdmgr?"; then firmware=yes fi fi + +fi # role != server + +record PANAMA_NVIDIA "$nvidia" +record PANAMA_MOK_HASH "$mok_hash" +record PANAMA_DEBLOAT "$debloat" record PANAMA_FIRMWARE "$firmware" # ── Applications ───────────────────────────────────────────────────────────── @@ -175,10 +205,12 @@ record PANAMA_FIRMWARE "$firmware" # applications nobody chose, on a machine whose owner answered a question they # thought was about something else. +extras="" +if [[ "$role" != server ]]; then + heading "Applications" extras_dir="$(dirname "${BASH_SOURCE[0]}")/../packages/extras" -extras="" if [[ -d "$extras_dir" ]]; then mapfile -t categories < <(for file in "$extras_dir"/*; do [[ -f "$file" ]] && basename "$file" @@ -189,6 +221,8 @@ if [[ -d "$extras_dir" ]]; then extras="${extras% }" fi fi + +fi # role != server record PANAMA_EXTRAS "$extras" # ── Personal content ───────────────────────────────────────────────────────── @@ -227,17 +261,22 @@ shown() { [[ -n "$1" ]] && printf '%s' "$1" || printf 'unchanged'; } heading "Ready" gum style --border rounded --padding "0 1" "$( + printf 'Role %s\n' "$role" printf 'Hostname %s\n' "${new_hostname:-"$current_hostname (unchanged)"}" printf 'Git name %s\n' "$(shown "$git_name")" printf 'Git email %s\n' "$(shown "$git_email")" printf 'Git editor %s\n' "$(shown "$git_editor")" printf 'GitHub %s\n' "$([[ "$gh_login" == yes ]] && echo "sign in" || echo "no change")" printf 'SSH key %s\n' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")" - printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")" - printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enroll a key" || echo "no change")" - printf 'Fedora apps %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")" - printf 'Firmware %s\n' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")" - printf 'Extras %s\n' "${extras:-none}" + # Hardware and extras were never asked on a server, and a summary line for + # a question that was not asked reads as a decision that was not made. + if [[ "$role" != server ]]; then + printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")" + printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enroll a key" || echo "no change")" + printf 'Fedora apps %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")" + printf 'Firmware %s\n' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")" + printf 'Extras %s\n' "${extras:-none}" + fi printf 'Personal %s' "$([[ "$user_content" == yes ]] && echo "link user/ into home" || echo "not linked")" )" diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index 0632fd9..40bbd25 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -20,6 +20,13 @@ CONFIG="$HOME/.config" # every symlink below fail silently while the stage still reported success. mkdir -p "$PANAMA_OLD" "$CONFIG" +# Which machine this is. A server links the shell environment -- bash, vim, +# the universal dirs below, the tmux and btop theming, the hook samples -- +# and then stops: everything after the early exit assumes a screen. +# shellcheck source=../lib/machine-role +source "$PANAMA_PATH/setup/lib/machine-role" +ROLE="$(panama_role)" + # --- Bashrc --- echo -e "\n--- Replacing .bashrc ---" # Backup existing .bashrc if it's a regular file @@ -45,8 +52,21 @@ ln -s "$PANAMA_BASH/.bashrc" "$HOME/.bashrc" # # gnome-control-center is a separate matter and stays declared: Panama's own # Settings hands off to it for the panels it deliberately does not own. -dirs=("espanso" "ghostty" "gtk-3.0" "gtk-4.0" "hypr" "kitty" "nvim" \ - "quickshell" "tmux" "uwsm" "vicinae" "wofi" "xdg-desktop-portal") +# +# Split by role, and every directory under config/dot must be claimed by +# exactly one of these three lists -- universal, desktop, or handled (linked +# or consumed some other way below: btop exposes only themes, ohmyposh is +# read in place by config/bash/shell, panama's hook samples are copied, vim +# links a single file). tests/setup/dotfile-classification-contract fails +# when a new directory appears in none of them, because unclassified means +# silently absent from every server. +universal_dirs=("nvim" "tmux") +desktop_dirs=("espanso" "ghostty" "gtk-3.0" "gtk-4.0" "hypr" "kitty" \ + "quickshell" "uwsm" "vicinae" "wofi" "xdg-desktop-portal") +handled_dirs=("btop" "ohmyposh" "panama" "vim") + +dirs=("${universal_dirs[@]}") +[[ "$ROLE" == server ]] || dirs+=("${desktop_dirs[@]}") # --- Vim vimrc --- echo -e "\n--- Setting up vim ---" @@ -107,6 +127,54 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME" fi +# btop reads themes from its own config directory, but OWNS btop.conf -- it +# rewrites that file on exit -- so only the theme files are exposed, per file, +# and the config itself is left to btop. panama-theme-apps edits the single +# color_theme line in place. +BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes" +mkdir -p "$BTOP_THEME_DIR" +for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do + [ -e "$btop_theme_src" ] || continue + btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")" + if [ -L "$btop_theme_dst" ]; then + rm "$btop_theme_dst" + fi + if [ -e "$btop_theme_dst" ]; then + log "Keeping existing btop theme at $btop_theme_dst" + else + ln -s "$btop_theme_src" "$btop_theme_dst" + log "Linked btop theme → $btop_theme_dst" + fi +done + +# Hook samples. Copied rather than symlinked, and only when absent: hooks are +# the user's own scripts, and ~/.config/panama is theirs too -- settings.json +# lives there. A symlinked directory would put their scripts in the repository +# working tree, which is the mistake the gtk bookmarks made. +PANAMA_HOOK_SAMPLES="$PANAMA_DOT/panama/hooks" +USER_HOOK_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks" +if [ -d "$PANAMA_HOOK_SAMPLES" ]; then + mkdir -p "$USER_HOOK_DIR" + for sample in "$PANAMA_HOOK_SAMPLES"/*.sample; do + [ -e "$sample" ] || continue + sample_dst="$USER_HOOK_DIR/$(basename "$sample")" + if [ -e "$sample_dst" ]; then + log "Keeping existing hook sample at $sample_dst" + else + cp "$sample" "$sample_dst" + log "Copied hook sample → $sample_dst" + fi + done +fi + +# A server's dotfiles end here. Everything below assumes a session: lock +# screens, GTK, launcher themes, icons, wallpapers, Firefox chrome, desktop +# entries, quadlets for the desktop's own containers, file associations. +if [ "$ROLE" = server ]; then + log "Server role: desktop dotfiles skipped" + exit 0 +fi + # hyprlock.conf is generated from a template on every color scheme change and # is not committed. Seed it so the FIRST lock of a fresh install is themed -- # without it hyprlock falls back to its own defaults, which is a bare gray @@ -139,26 +207,6 @@ elif [ -r "$HYPRLOCK_TEMPLATE" ]; then fi fi -# btop reads themes from its own config directory, but OWNS btop.conf -- it -# rewrites that file on exit -- so only the theme files are exposed, per file, -# and the config itself is left to btop. panama-theme-apps edits the single -# color_theme line in place. -BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes" -mkdir -p "$BTOP_THEME_DIR" -for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do - [ -e "$btop_theme_src" ] || continue - btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")" - if [ -L "$btop_theme_dst" ]; then - rm "$btop_theme_dst" - fi - if [ -e "$btop_theme_dst" ]; then - log "Keeping existing btop theme at $btop_theme_dst" - else - ln -s "$btop_theme_src" "$btop_theme_dst" - log "Linked btop theme → $btop_theme_dst" - fi -done - # GTK3 has no include mechanism, so its settings.ini is generated whole from a # template rather than layered. Without this, a fresh checkout has a template # and no settings.ini, and GTK3 applications fall back to their built-in theme. @@ -454,26 +502,6 @@ fi # than by symlinking the directory itself, the same way the quadlets and # desktop entries are: Nautilus writes nothing here today, but a directory # symlink into the repository is how machine state ends up in a tracked path. -# Hook samples. Copied rather than symlinked, and only when absent: hooks are -# the user's own scripts, and ~/.config/panama is theirs too -- settings.json -# lives there. A symlinked directory would put their scripts in the repository -# working tree, which is the mistake the gtk bookmarks made. -PANAMA_HOOK_SAMPLES="$PANAMA_DOT/panama/hooks" -USER_HOOK_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks" -if [ -d "$PANAMA_HOOK_SAMPLES" ]; then - mkdir -p "$USER_HOOK_DIR" - for sample in "$PANAMA_HOOK_SAMPLES"/*.sample; do - [ -e "$sample" ] || continue - sample_dst="$USER_HOOK_DIR/$(basename "$sample")" - if [ -e "$sample_dst" ]; then - log "Keeping existing hook sample at $sample_dst" - else - cp "$sample" "$sample_dst" - log "Copied hook sample → $sample_dst" - fi - done -fi - PANAMA_NAUTILUS_DIR="$PANAMA_PATH/config/local/share/nautilus-python/extensions" USER_NAUTILUS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/nautilus-python/extensions" if [ -d "$PANAMA_NAUTILUS_DIR" ]; then diff --git a/setup/scripts/link-server b/setup/scripts/link-server new file mode 100755 index 0000000..2b18ef8 --- /dev/null +++ b/setup/scripts/link-server @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# The server counterpart of link-dotfiles, and deliberately thin: ~/Server is +# created here, and everything about which services are linked into it is +# panama-server's job -- relink refreshes the symlinks of whatever this +# machine has enabled, and enabling something new is a decision a person makes +# with `panama server enable`, not something an installer infers. + +set -euo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" + +mkdir -p "$HOME/Server" + +exec "$PANAMA_PATH/bin/panama-server" relink diff --git a/setup/scripts/setup-server b/setup/scripts/setup-server new file mode 100755 index 0000000..d6e419a --- /dev/null +++ b/setup/scripts/setup-server @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# What makes a Fedora machine able to run rootless compose services. Server +# role only -- ./install never runs this on a desktop. Idempotent throughout: +# every step checks the machine before touching it, so a re-run on a machine +# that already has all of this changes nothing and says so. + +set -euo pipefail + +log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" + +# ── Linger ─────────────────────────────────────────────────────────────────── +# Without it every user unit -- which is every service -- stops at logout and +# starts only at login, which on a server means "runs while somebody is SSHed +# in". Linger is what makes the user session a real init. +if loginctl show-user "$USER" 2>/dev/null | grep -q '^Linger=yes'; then + log "Linger already enabled for $USER" +else + log "Enabling linger for $USER" + sudo loginctl enable-linger "$USER" +fi + +# ── Unprivileged ports from 80 ─────────────────────────────────────────────── +# Rootless containers cannot bind 80/443 while the kernel reserves everything +# below 1024 for root. Lowering the floor to 80 is what lets the reverse proxy +# be a rootless container like everything else. A file in /etc/sysctl.d so it +# survives reboots; sysctl --system so it applies now. +SYSCTL_FILE=/etc/sysctl.d/99-rootless-ports.conf +SYSCTL_WANT='net.ipv4.ip_unprivileged_port_start=80' +if [[ -r "$SYSCTL_FILE" ]] && grep -qx "$SYSCTL_WANT" "$SYSCTL_FILE"; then + log "Unprivileged ports already start at 80 ($SYSCTL_FILE)" +else + log "Allowing unprivileged binds from port 80" + printf '%s\n' "$SYSCTL_WANT" | sudo tee "$SYSCTL_FILE" >/dev/null + sudo sysctl --system >/dev/null +fi + +# ── Firewall ───────────────────────────────────────────────────────────────── +# 80 and 443 because everything is reverse-proxied; 81 for the proxy's own +# admin portal. Deliberately nothing else: a service needing another port open +# documents that in its own folder and it is opened by hand, because a list of +# per-service firewall holes maintained by an installer is a list nobody +# audits. +if systemctl is-active firewalld >/dev/null 2>&1; then + reload_needed=0 + for port in 80 443 81; do + if sudo firewall-cmd --permanent --query-port="${port}/tcp" >/dev/null 2>&1; then + log "Port ${port}/tcp already open" + else + log "Opening port ${port}/tcp" + sudo firewall-cmd --permanent --add-port="${port}/tcp" >/dev/null + reload_needed=1 + fi + done + (( reload_needed )) && sudo firewall-cmd --reload >/dev/null +else + log "firewalld is not active; no ports to open" +fi + +# ── The shared container network ───────────────────────────────────────────── +# Every compose file expects nginx-bridge as an external network: the reverse +# proxy reaches each service by container name across it, and no service needs +# a published port of its own. External means compose will not create it, so +# somebody has to -- this is that somebody. +if podman network exists nginx-bridge 2>/dev/null; then + log "podman network nginx-bridge already exists" +else + log "Creating podman network nginx-bridge" + podman network create nginx-bridge >/dev/null +fi + +# ── Nightly image updates ──────────────────────────────────────────────────── +# server/scripts/update-containers, on a midnight timer. Linked rather than +# copied so a pull updates the machinery with everything else; see the script +# header for why this replaced watchtower. +UNIT_SRC="$PANAMA_PATH/server/systemd" +UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +mkdir -p "$UNIT_DIR" "$HOME/Server/logs" +for unit in podman-update.service podman-update.timer; do + src="$UNIT_SRC/$unit" + dst="$UNIT_DIR/$unit" + [[ -e "$src" ]] || { log "Missing $src; skipping the update timer"; continue; } + if [[ -L "$dst" ]]; then + rm "$dst" + elif [[ -e "$dst" ]]; then + mv "$dst" "$dst.pre-panama" + log "Kept the existing $unit as $unit.pre-panama" + fi + ln -s "$src" "$dst" + log "Linked $unit" +done +systemctl --user daemon-reload +if systemctl --user is-enabled podman-update.timer >/dev/null 2>&1; then + log "podman-update.timer already enabled" +else + log "Enabling podman-update.timer (nightly image updates)" + systemctl --user enable --now podman-update.timer +fi diff --git a/tests/quickshell/declared-assets-contract b/tests/quickshell/declared-assets-contract index a7ddcea..69b50da 100755 --- a/tests/quickshell/declared-assets-contract +++ b/tests/quickshell/declared-assets-contract @@ -202,7 +202,9 @@ done # the GNOME excision was caught by nothing until the array entry was removed too, # which was luck rather than a test. -mapfile -t linked_dirs < <(sed -n '/^dirs=(/,/)/p' "$repo_dir/setup/scripts/link-dotfiles" \ +# The array is assembled per role from universal_dirs and desktop_dirs, so +# those are what get read -- the assembly line itself carries no names. +mapfile -t linked_dirs < <(sed -n '/^universal_dirs=(/,/^handled_dirs=(.*)$/p' "$repo_dir/setup/scripts/link-dotfiles" \ | grep -oE '"[^"]+"' | tr -d '"') (( ${#linked_dirs[@]} > 0 )) \ diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract index eda5b63..4ff42c1 100755 --- a/tests/quickshell/declared-dependencies-contract +++ b/tests/quickshell/declared-dependencies-contract @@ -36,7 +36,7 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio # authselect is on the list for the same reason: it manages Fedora's PAM and # nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the # fingerprint aliases in config/bash can rely on it without declaring it. -BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$' +BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig|sysctl)$' # bootctl and coredumpctl ship in systemd-udev, which every Fedora install # carries -- it is the udev half of systemd, not an optional tool. Declaring @@ -45,11 +45,12 @@ BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|fi SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|coredumpctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$' # Installed by install-packages itself rather than by a package list. Two -# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and -# node, npm and pnpm come from nvm on purpose -- a dnf nodejs earlier on PATH -# would win every per-project `nvm use`, which is the whole point of having nvm. +# reasons, both deliberate: bun, claude and codex have no RPM or flatpak at +# all (codex comes through npm), and node, npm and pnpm come from nvm on +# purpose -- a dnf nodejs earlier on PATH would win every per-project +# `nvm use`, which is the whole point of having nvm. # Anything added here needs a matching install block and a stated reason. -SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$' +SELF_INSTALLED='^(bun|claude|codex|node|npm|pnpm)$' # Tools an alias may lean on without Panama installing them anywhere. The # docker aliases serve the machines that run Docker by deliberate choice; @@ -92,6 +93,7 @@ package_for() { nvim) printf 'neovim' ;; fwupdmgr) printf 'fwupd' ;; dnf4) printf 'python3-dnf' ;; + firewall-cmd) printf 'firewalld' ;; notify-send) printf 'libnotify' ;; wl-copy|wl-paste) printf 'wl-clipboard' ;; ssh-keygen) printf 'openssh' ;; diff --git a/tests/server/compose-secrets-contract b/tests/server/compose-secrets-contract new file mode 100755 index 0000000..9ed6a44 --- /dev/null +++ b/tests/server/compose-secrets-contract @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# This repository is public, and server/ describes real infrastructure. The +# rule that makes that safe has three parts, and each is pinned here because +# each fails silently: +# +# 1. No tracked file under server/ carries a secret. Compose files reference +# secrets as ${VAR} interpolations resolved from the .env beside them on +# the machine; .env.example names the variables with CHANGE_ME in place +# of every value that matters. +# 2. .gitignore keeps .env and data/ out of server/containers/ even when a +# cutover or a mistake puts one there. The live files belong in +# ~/Server//, outside the checkout entirely -- the ignore is a +# seatbelt, and a seatbelt that got deleted should fail loudly. +# 3. Nothing named .env is tracked, full stop. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +server_dir="$repo_dir/server" + +findings=() +note() { findings+=("$1"); } + +[[ -d "$server_dir" ]] || { printf 'compose secrets contract: no server/ directory\n' >&2; exit 1; } + +# ── 1. Tracked content is clean ────────────────────────────────────────────── +# +# Only tracked files: the live .env a cutover briefly leaves in a service +# directory is exactly what the gitignore exists for, and flagging it here +# would punish the ignore for working. + +while IFS= read -r file; do + path="$repo_dir/$file" + [[ -f "$path" ]] || continue + + # A secret-bearing key with a literal value. ${VAR} interpolations, empty + # values, the CHANGE_ME placeholder, and booleans (ALLOW_EMPTY_PASSWORD=yes + # is a switch, not a credential) are the allowed shapes; anything else + # after PASSWORD/SECRET/TOKEN/KEY is treated as a leak. Keys that merely + # configure where a secret lives (a *_FILE path, a key NAME) are not + # values. + while IFS= read -r hit; do + note "$file looks like it carries a secret: ${hit%%[=:]*}" + done < <(grep -inE '(password|secret|token|api_key|private_key|access_key)[a-z0-9_]*[[:space:]]*[:=]' "$path" 2>/dev/null \ + | grep -vE '[:=][[:space:]]*["'"'"']?(\$\{|CHANGE_ME|(true|false|yes|no|[01])["'"'"']?[[:space:]]*$|["'"'"']?[[:space:]]*$)' \ + | grep -viE '(_file|_path|_name|_key_name)[[:space:]]*[:=]' \ + | grep -vE '^[0-9]+:[[:space:]]*#') + + if grep -qE 'BEGIN [A-Z ]*PRIVATE KEY' "$path" 2>/dev/null; then + note "$file contains a private key" + fi + if grep -qE 'sk-ant-[A-Za-z0-9]|ghp_[A-Za-z0-9]{20}|xox[baprs]-[A-Za-z0-9]' "$path" 2>/dev/null; then + note "$file contains something that looks like an API token" + fi +done < <(git -C "$repo_dir" ls-files 'server/') + +# ── 2. The ignore still stands ─────────────────────────────────────────────── +# +# check-ignore consults the real gitignore for a path that need not exist, so +# this asserts the rule rather than the current absence of violations. + +git -C "$repo_dir" check-ignore -q 'server/containers/AnyService/.env' \ + || note '.gitignore no longer covers .env under server/containers/' +git -C "$repo_dir" check-ignore -q 'server/containers/AnyService/data/dump.sql' \ + || note '.gitignore no longer covers data/ under server/containers/' + +# ── 3. No .env is tracked ──────────────────────────────────────────────────── + +while IFS= read -r tracked; do + note "a live .env is tracked: $tracked" +done < <(git -C "$repo_dir" ls-files 'server/**/.env' 'server/.env') + +# ── Report ─────────────────────────────────────────────────────────────────── + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'compose secrets contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'compose secrets contract: PASS\n' diff --git a/tests/server/containers-shape-contract b/tests/server/containers-shape-contract new file mode 100755 index 0000000..5f9a9b9 --- /dev/null +++ b/tests/server/containers-shape-contract @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +# Every service in the catalog has the same shape, because panama-server and +# the update timer both depend on it blindly: +# +# * a compose.yml, which is the only file enable symlinks into ~/Server +# * exactly one podman-*.service unit -- its filename is the unit's +# identity, so two would be an ambiguity and zero an unenableable service +# * a WorkingDirectory of %h/Server/, matching the directory enable +# creates -- a unit pointing anywhere else starts compose against a +# directory that has no .env and no data +# * every ${VAR} the compose interpolates without a default is named in +# .env.example, or the first enable renders a compose full of empty +# strings and the service runs misconfigured rather than failing + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +containers_dir="$repo_dir/server/containers" + +findings=() +note() { findings+=("$1"); } + +[[ -d "$containers_dir" ]] || { printf 'containers shape contract: PASS (no services yet)\n'; exit 0; } + +shopt -s nullglob +for dir in "$containers_dir"/*/; do + name="$(basename "$dir")" + + [[ -f "$dir/compose.yml" ]] || note "$name has no compose.yml" + + units=("$dir"/*.service) + if (( ${#units[@]} == 0 )); then + note "$name has no unit file" + continue + elif (( ${#units[@]} > 1 )); then + note "$name has ${#units[@]} unit files; its identity is ambiguous" + continue + fi + unit="${units[0]}" + + case "$(basename "$unit")" in + podman-*.service) ;; + *) note "$name's unit is not named podman-.service: $(basename "$unit")" ;; + esac + + grep -q "^WorkingDirectory=%h/Server/$name\$" "$unit" \ + || note "$name's unit does not work in %h/Server/$name" + grep -q 'podman compose' "$unit" \ + || note "$name's unit does not run podman compose" + + # ${VAR} without a :- default has nowhere to come from but the .env, and + # the .env is seeded from .env.example -- so a variable the example does + # not name is one the first enable silently renders empty. + [[ -f "$dir/compose.yml" ]] || continue + while IFS= read -r var; do + [[ -n "$var" ]] || continue + if [[ ! -f "$dir/.env.example" ]]; then + note "$name interpolates \${$var} but has no .env.example" + continue + fi + grep -qE "^${var}=" "$dir/.env.example" \ + || note "$name interpolates \${$var}, which .env.example does not name" + done < <(grep -vE '^[[:space:]]*#' "$dir/compose.yml" 2>/dev/null \ + | grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' \ + | sed 's/^\${//; s/}$//' | sort -u) +done + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'containers shape contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'containers shape contract: PASS\n' diff --git a/tests/server/panama-server-contract b/tests/server/panama-server-contract new file mode 100755 index 0000000..8a625d2 --- /dev/null +++ b/tests/server/panama-server-contract @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +# panama-server against a throwaway HOME, a fixture catalog, and a stubbed +# systemctl -- the behaviors that protect a machine's live services: +# +# * enable seeds .env from .env.example and REFUSES to start while a +# CHANGE_ME placeholder survives -- a service brought up with the +# placeholder as its database password runs, wrongly, forever +# * a completed enable links exactly the tracked files (compose.yml and the +# unit), reloads systemd, and enables the unit +# * disable removes the unit and nothing else: the .env and data stay +# * relink refreshes links for enabled services only, and names a service +# whose definition changed since the machine last looked -- without +# restarting anything + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +panama_server="$repo_dir/bin/panama-server" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$panama_server" ]] || { printf 'panama-server contract: %s is not executable\n' "$panama_server" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +home="$work/home" +catalog="$work/catalog" +stub_dir="$work/bin" +calls="$work/calls" +mkdir -p "$home" "$stub_dir" "$catalog/Example" +: >"$calls" + +cat >"$catalog/Example/compose.yml" <<'YML' +services: + example: + image: docker.io/library/nginx:latest + environment: + DB_PASSWORD: ${DB_PASSWORD} +YML +cat >"$catalog/Example/.env.example" <<'ENV' +DB_PASSWORD=CHANGE_ME +ENV +cat >"$catalog/Example/podman-example.service" <<'UNIT' +[Unit] +Description=Podman Compose: Example +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Example +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +[Install] +WantedBy=default.target +UNIT + +# The stub records every invocation; is-active answers 'inactive' so nothing +# here depends on a real systemd. +cat >"$stub_dir/systemctl" <>"$calls" +case "\$*" in + *is-active*) echo inactive; exit 3 ;; + *is-enabled*) exit 1 ;; +esac +exit 0 +STUB +cat >"$stub_dir/podman" <>"$calls" +exit 0 +STUB +chmod +x "$stub_dir/systemctl" "$stub_dir/podman" + +run() { + HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$home/.local/state" \ + PANAMA_SERVER_DIR="$catalog" PANAMA_SERVER_TARGET="$home/Server" \ + PATH="$stub_dir:$PATH" "$panama_server" "$@" +} + +unit_link="$home/.config/systemd/user/podman-example.service" + +# ── enable stops on placeholders ───────────────────────────────────────────── + +run enable Example >/dev/null 2>&1 \ + && note 'enable exited zero with CHANGE_ME still in the .env' + +[[ -f "$home/Server/Example/.env" ]] \ + || note 'enable did not seed .env from .env.example' +[[ -L "$home/Server/Example/compose.yml" ]] \ + || note 'enable did not link compose.yml into ~/Server' +[[ -e "$unit_link" ]] \ + && note 'enable installed the unit despite the placeholder refusal' +grep -q 'systemctl .*enable' "$calls" \ + && note 'enable reached systemctl despite the placeholder refusal' + +# ── a filled .env enables ──────────────────────────────────────────────────── + +printf 'DB_PASSWORD=s3cret\n' >"$home/Server/Example/.env" +: >"$calls" +run enable Example >/dev/null 2>&1 \ + || note 'enable failed with a completed .env' + +[[ -L "$unit_link" ]] \ + || note 'enable did not link the unit into the systemd user directory' +grep -q 'systemctl --user daemon-reload' "$calls" \ + || note 'enable did not daemon-reload' +grep -q 'systemctl --user enable --now podman-example.service' "$calls" \ + || note 'enable did not enable --now the unit' +[[ "$(cat "$home/Server/Example/.env")" == "DB_PASSWORD=s3cret" ]] \ + || note 'enable rewrote an .env that already existed' + +# ── relink names a changed definition, restarts nothing ────────────────────── + +printf ' # a changed line\n' >>"$catalog/Example/compose.yml" +: >"$calls" +relink_out="$(run relink 2>&1)" || note 'relink failed' +grep -q 'Example' <<<"$relink_out" \ + || note 'relink did not name the service whose definition changed' +grep -q 'restart' "$calls" \ + && note 'relink restarted something; the restart is deliberately manual' + +# A second relink with nothing new must not cry wolf. +relink_out="$(run relink 2>&1)" || note 'a repeat relink failed' +grep -q 'restart podman-example' <<<"$relink_out" \ + && note 'relink reports the same change twice' + +# ── disable removes the unit and only the unit ─────────────────────────────── + +mkdir -p "$home/Server/Example/data" +printf 'precious\n' >"$home/Server/Example/data/keep" +: >"$calls" +run disable Example >/dev/null 2>&1 || note 'disable failed' + +[[ -e "$unit_link" ]] \ + && note 'disable left the unit installed' +grep -q 'systemctl --user disable --now podman-example.service' "$calls" \ + || note 'disable did not stop the unit' +[[ -f "$home/Server/Example/.env" ]] \ + || note 'disable removed the .env' +[[ -f "$home/Server/Example/data/keep" ]] \ + || note 'disable removed data' + +# ── an unknown service is a real answer ────────────────────────────────────── + +run enable NoSuchThing >/dev/null 2>&1 \ + && note 'enabling an unknown service exited zero' + +# ── Report ─────────────────────────────────────────────────────────────────── + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'panama-server contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'panama-server contract: PASS\n' diff --git a/tests/setup/desktop-first-contract b/tests/setup/desktop-first-contract index a8b06d8..55cd360 100755 --- a/tests/setup/desktop-first-contract +++ b/tests/setup/desktop-first-contract @@ -34,16 +34,61 @@ hyprland_at="$(line_of '^HYPR_FILE=')" # ── Nothing fragile above the desktop ─────────────────────────────────────── # # Named individually rather than by pattern: each is a command whose failure is -# survivable, and each one above the Hyprland block is a machine that boots to -# nothing. +# survivable, and each one that EXECUTES above the Hyprland block is a machine +# that boots to nothing. +# +# Executes, not appears: the fragile steps live in functions defined near the +# top (the server role calls them without ever reaching a desktop section), +# and a definition runs nothing. So function bodies are excluded from the +# position scan, and the desktop path's calls to those functions are required +# to sit below the desktop instead. -for fragile in 'dnf swap' 'groupupdate' 'group upgrade' 'gstreamer1-plugins' \ - 'flatpak install' 'nvm install' 'curl -fsSL'; do - while read -r at; do - (( at < hyprland_at )) \ - && note "'$fragile' runs at line $at, above the desktop at line $hyprland_at" - done < <(grep -n -- "$fragile" "$installer" | grep -v '^\s*#' | cut -d: -f1) -done +while IFS= read -r finding; do + [[ -n "$finding" ]] && note "$finding" +done < <(python3 - "$installer" "$hyprland_at" <<'PY' +import re, sys + +path, hypr = sys.argv[1], int(sys.argv[2]) +lines = open(path, encoding="utf-8").read().splitlines() + +in_body = False +body = set() +for i, line in enumerate(lines, 1): + if not in_body and re.match(r'^[a-z_]+\(\)\s*\{', line): + in_body = True + body.add(i) + continue + if in_body: + body.add(i) + if line == '}': + in_body = False + +fragile = ['dnf swap', 'groupupdate', 'group upgrade', 'gstreamer1-plugins', + 'flatpak install', 'nvm install', 'curl -fsSL'] +for i, line in enumerate(lines, 1): + if i >= hypr or i in body or line.strip().startswith('#'): + continue + for needle in fragile: + if needle in line: + print(f"'{needle}' runs at line {i}, above the desktop at line {hypr}") + +# The desktop path still has to run the fragile helpers -- below the desktop. +# (The server path calls them above, inside a branch that exits before the +# desktop section; the exit is asserted back in bash.) +for call in ('setup_node', 'install_bun', 'install_claude_code', 'install_codex'): + calls = [i for i, line in enumerate(lines, 1) + if re.match(r'^\s*' + call + r'\s*$', line) and i not in body] + if not calls: + print(f"{call} is never called, so the desktop path skips it") + elif not any(i > hypr for i in calls): + print(f"{call} is only called above the desktop") +PY +) + +# The server branch is what excuses fragile calls above the desktop, and only +# because it never falls through into the desktop section. +sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer" | grep -q '^\s*exit 0' \ + || note 'the server branch does not exit before the desktop section' # ── Everything fragile is actually tolerated ──────────────────────────────── # diff --git a/tests/setup/dotfile-classification-contract b/tests/setup/dotfile-classification-contract new file mode 100755 index 0000000..31a24a0 --- /dev/null +++ b/tests/setup/dotfile-classification-contract @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Every directory under config/dot is claimed by exactly one of +# link-dotfiles' three lists -- universal (both roles), desktop, or handled +# (linked or consumed some other way in that script). The failure this +# prevents is quiet: a new dot directory added to the repo but classified +# nowhere links on no server, or on every server, depending on which list the +# author forgot -- and nothing says so until a machine is missing its config. +# +# The lists are lifted from the script itself rather than restated here, so +# renaming them fails loudly instead of freezing a stale copy. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +link_dotfiles="$repo_dir/setup/scripts/link-dotfiles" +dot_dir="$repo_dir/config/dot" + +findings=() +note() { findings+=("$1"); } + +lists="$(sed -n '/^universal_dirs=(/,/^handled_dirs=(.*)$/p' "$link_dotfiles")" +if [[ -z "$lists" ]]; then + printf 'dotfile classification contract: link-dotfiles no longer defines the three lists\n' >&2 + exit 1 +fi +eval "$lists" + +claimed=("${universal_dirs[@]}" "${desktop_dirs[@]}" "${handled_dirs[@]}") + +for dir in "$dot_dir"/*/; do + name="$(basename "$dir")" + hits=0 + for entry in "${claimed[@]}"; do + [[ "$entry" == "$name" ]] && hits=$((hits + 1)) + done + if (( hits == 0 )); then + note "config/dot/$name is claimed by no list, so servers silently skip or acquire it" + elif (( hits > 1 )); then + note "config/dot/$name is claimed by $hits lists" + fi +done + +for entry in "${claimed[@]}"; do + [[ -d "$dot_dir/$entry" ]] \ + || note "link-dotfiles classifies '$entry', which config/dot does not contain" +done + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'dotfile classification contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'dotfile classification contract: PASS (%d directories classified)\n' "${#claimed[@]}" diff --git a/tests/setup/interview-contract b/tests/setup/interview-contract index 4f01925..6decdbf 100755 --- a/tests/setup/interview-contract +++ b/tests/setup/interview-contract @@ -35,7 +35,9 @@ consumed="$(grep -rhoE '\$\{PANAMA_[A-Z_]+' "$repo_dir"/setup/scripts/* "$instal # which is the akmods certificate path with an override on it. Nothing sets that # override outside the hardware contract, which needs a certificate it is allowed # to create -- enrolling the real one to find out what happens is not a test. -INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT)$' +# PANAMA_ROLE_PRESET is the --server flag on its way INTO the interview, not an +# answer out of it -- the answer it produces is PANAMA_ROLE, which is checked. +INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT|PANAMA_ROLE_PRESET)$' while read -r key; do [[ -n "$key" ]] || continue @@ -67,7 +69,7 @@ grep -qE 'mktemp' "$install_script" \ || note 'install does not create the answers file with mktemp' # Declining must stop the run rather than count as one failed stage among five. -grep -qE 'if ! "\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \ +grep -qE 'if ! [A-Z_="$ ]*"\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \ || note 'install does not treat a declined interview as fatal' # ── 4. A real run, with gum stubbed ────────────────────────────────────────── diff --git a/tests/setup/role-contract b/tests/setup/role-contract new file mode 100755 index 0000000..551ab1c --- /dev/null +++ b/tests/setup/role-contract @@ -0,0 +1,158 @@ +#!/usr/bin/env bash + +# The machine role: the one interview answer Panama keeps. +# +# What is pinned, and why each half matters: +# +# * The interview asks the role first and a server is never asked the +# desktop's questions -- hardware and extras feed stages the server path +# does not run, and a question nothing consumes is a control that lies. +# * `--server` presets the answer without a prompt, because the fresh-VPS +# path runs from a curl with nobody to interview twice. +# * setup/lib/machine-role reads env over file over default, and records +# only values it can read back -- an unreadable role file must degrade to +# desktop, never to an error, because every pre-role machine has none. +# * install runs different stages per role, and the server list must never +# silently reacquire a desktop stage (or the reverse). + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +interview="$repo_dir/setup/scripts/interview" +install_script="$repo_dir/install" +machine_role="$repo_dir/setup/lib/machine-role" +boot="$repo_dir/boot" + +findings=() +note() { findings+=("$1"); } + +# ── The helper, sandboxed ──────────────────────────────────────────────────── + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +role_of() { + env -u PANAMA_ROLE XDG_STATE_HOME="$work/state" ${1:+PANAMA_ROLE="$1"} \ + bash -c "source '$machine_role'; panama_role" +} + +[[ "$(role_of "")" == desktop ]] \ + || note 'with no file and no env, the role is not desktop' + +env XDG_STATE_HOME="$work/state" \ + bash -c "source '$machine_role'; panama_role_record server" +[[ "$(role_of "")" == server ]] \ + || note 'a recorded server role does not read back' + +[[ "$(role_of desktop)" == desktop ]] \ + || note 'an exported PANAMA_ROLE does not win over the recorded file' + +printf 'gibberish\n' >"$work/state/panama/role" +[[ "$(role_of "")" == desktop ]] \ + || note 'a role file with an unknown value does not degrade to desktop' + +env XDG_STATE_HOME="$work/state" \ + bash -c "source '$machine_role'; panama_role_record nonsense" +[[ "$(role_of "")" == desktop ]] \ + || note 'recording an unknown value does not degrade to desktop' + +# ── The interview, per role ────────────────────────────────────────────────── +# +# The same stub the interview contract stands up, plus `choose` so the role +# question is answerable. GUM_STUB_CHOOSE also feeds the extras checklist on +# a desktop run, which is why the desktop case leaves it empty. + +stub_dir="$work/bin" +mkdir -p "$stub_dir" +cat >"$stub_dir/gum" <<'STUB' +#!/usr/bin/env bash +case "$1" in + input) printf '%s\n' "$GUM_STUB_INPUT" ;; + confirm) [[ "$GUM_STUB_CONFIRM" == yes ]] ;; + choose) printf '%s\n' "$GUM_STUB_CHOOSE" ;; + style) shift; printf '%s\n' "${@: -1}" ;; + *) exit 0 ;; +esac +STUB +chmod +x "$stub_dir/gum" + +answers="$work/answers" + +ask_interview() { + : >"$answers" + GUM_STUB_INPUT="x" GUM_STUB_CONFIRM=yes GUM_STUB_CHOOSE="${2:-}" \ + PANAMA_ANSWERS="$answers" PANAMA_ROLE_PRESET="${1:-}" \ + PATH="$stub_dir:$PATH" bash "$interview" >/dev/null 2>&1 +} + +answered() { + ( # shellcheck source=/dev/null + source "$answers"; printf '%s' "${!1:-}" ) +} + +ask_interview "" server \ + || note 'the interview fails when the role question answers server' +[[ "$(answered PANAMA_ROLE)" == server ]] \ + || note 'choosing server does not record PANAMA_ROLE=server' +[[ "$(answered PANAMA_EXTRAS)" == "" ]] \ + || note 'a server was asked the extras question' +[[ "$(answered PANAMA_NVIDIA)" == no ]] \ + || note 'a server run does not record the hardware defaults' + +ask_interview "" "" \ + || note 'the interview fails when the role question is escaped' +[[ "$(answered PANAMA_ROLE)" == desktop ]] \ + || note 'an escaped role question does not default to desktop' + +ask_interview server desktop \ + || note 'the interview fails under a --server preset' +[[ "$(answered PANAMA_ROLE)" == server ]] \ + || note 'PANAMA_ROLE_PRESET=server does not win: the prompt was asked anyway' + +# ── install runs the right stages ──────────────────────────────────────────── +# +# Read from the source rather than run: the stage lists are data, and what +# matters is which names each role's list carries. + +# Only the two literal lists: the upgrade filter reassigns STAGES from a +# variable, which is not a role's stage list. +server_stages="$(sed -n 's/^ STAGES=(\(.*\))$/\1/p' "$install_script" | grep '^install-packages' | head -1)" +desktop_stages="$(sed -n 's/^ STAGES=(\(.*\))$/\1/p' "$install_script" | grep '^install-packages' | tail -1)" + +for stage in install-packages link-dotfiles link-user setup-server link-server setup-identity; do + grep -qw "$stage" <<<"$server_stages" \ + || note "the server stage list is missing $stage" +done +for stage in install-hardware change-settings link-skills link-vicinae-scripts; do + grep -qw "$stage" <<<"$server_stages" \ + && note "the server stage list acquired the desktop stage $stage" +done +for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware; do + grep -qw "$stage" <<<"$desktop_stages" \ + || note "the desktop stage list lost $stage" +done + +grep -q -- '--server)' "$install_script" \ + || note 'install does not accept --server' +grep -q 'panama_role_record' "$install_script" \ + || note 'install never records the role, so panama update cannot know it' + +# ── boot's front door ──────────────────────────────────────────────────────── + +grep -q -- '--server) SERVER=1' "$boot" \ + || note 'boot does not accept --server' +grep -q 'not root' "$boot" \ + || note 'boot no longer refuses a root run without --server' +grep -q 'runuser' "$boot" \ + || note 'the root bootstrap does not hand off to the created user' + +# ── Report ─────────────────────────────────────────────────────────────────── + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'role contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'role contract: PASS\n' diff --git a/tests/setup/skills-contract b/tests/setup/skills-contract index cb88aa8..390460f 100755 --- a/tests/setup/skills-contract +++ b/tests/setup/skills-contract @@ -257,14 +257,23 @@ fi # ── The stage runs, in the one order that gives personal skills precedence ── +# Checked against every literal per-role stage list. The server list carries +# no link-skills at all (its three skills operate the desktop), so the demand +# is: at least one list runs it, and any list that runs it runs it between +# link-dotfiles and link-user. python3 - "$installer" <<'PY' || note 'link-skills is not in STAGES between link-dotfiles and link-user' import re, sys -line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES=")) -stages = re.findall(r"[\w-]+", line) -for name in ("link-dotfiles", "link-skills", "link-user"): - if name not in stages: +lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8") + if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l] +anywhere = False +for line in lines: + stages = re.findall(r"[\w-]+", line) + if "link-skills" not in stages: + continue + anywhere = True + if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"): raise SystemExit(1) -if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"): +if not anywhere: raise SystemExit(1) PY diff --git a/tests/setup/user-content-contract b/tests/setup/user-content-contract index 3edc0a1..f4c5e53 100755 --- a/tests/setup/user-content-contract +++ b/tests/setup/user-content-contract @@ -166,13 +166,20 @@ grep -q 'PANAMA_USER_CONTENT' "$interview" \ || note 'the interview never asks about personal content' # Order matters: link-user must land the tracked espanso identity before -# setup-identity would seed one from the interview answers. +# setup-identity would seed one from the interview answers. Checked in every +# role's literal stage list (the upgrade filter reassigns from a variable and +# is not a list). python3 - "$installer" <<'PY' || note 'link-user does not run before setup-identity' import re, sys -line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES=")) -stages = re.findall(r"[\w-]+", line) -if stages.index("link-user") > stages.index("setup-identity"): +lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8") + if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l] +if not lines: raise SystemExit(1) +for line in lines: + stages = re.findall(r"[\w-]+", line) + if "link-user" in stages and "setup-identity" in stages: + if stages.index("link-user") > stages.index("setup-identity"): + raise SystemExit(1) PY if (( ${#findings[@]} > 0 )); then