Panama learns what a server is: from a root login to running containers
A machine's role is now the interview's first question and the one answer Panama records. Servers get the same shell minus the screen: core packages, nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless ports from 80, firewalld, the nginx-bridge network, and a nightly image updater that replaced watchtower for cause. server/containers/ carries junior's 23 compose services -- secrets moved to per-machine .env files that never enter this public repo, every transformed compose proven to render byte-identical to what is live. 'panama server' enables, disables and relinks them; nothing here restarts a running service. 'boot --server' walks a fresh VPS from its root login to a normal install. Five new contracts pin the secrets rule, the catalog's shape, panama-server's behavior, the role plumbing, and the dotfile classification. Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
This commit is contained in:
@@ -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/<Name>/` (TitleCase, matching junior) |
|
||||
| Tracked per service | `compose.yml`, `podman-<name>.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 <verb>` (README's subcommand
|
||||
table and its contract update with it):
|
||||
|
||||
- `list` — every service in `server/containers/`, with enabled/active state.
|
||||
- `enable <Name>` — create `~/Server/<Name>/`; symlink `compose.yml`; seed
|
||||
`~/Server/<Name>/.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 <Name>` — `disable --now`, remove the unit symlink,
|
||||
`daemon-reload`. `~/Server/<Name>` and its data are never touched.
|
||||
- `status [<Name>]` — 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/<Name>/`, 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/<Name>/`.
|
||||
2. Take the **installed** unit (`~/.config/systemd/user/podman-<name>.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/<Name>/.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/<Name>/` carries
|
||||
`compose.yml` + `podman-<name>.service`; every unit's `WorkingDirectory` is
|
||||
`%h/Server/<Name>`; 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.
|
||||
@@ -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/<Name>/ (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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ;;
|
||||
*)
|
||||
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# panama-server – the compose services a server machine runs.
|
||||
#
|
||||
# The repository carries the catalog: server/containers/<Name>/ holds a
|
||||
# compose.yml, a podman-<name>.service user unit, and an .env.example naming
|
||||
# what the service needs told. The machine carries the rest: ~/Server/<Name>/
|
||||
# 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 <Name> link it into ~/Server, seed .env, enable the unit
|
||||
# disable <Name> stop it and remove the unit; data and .env stay put
|
||||
# status [<Name>] 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/<Name> 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 <<EOF
|
||||
${BOLD}$PROGRAM${RESET} – manage the compose services in server/containers/
|
||||
|
||||
${BOLD}Usage:${RESET}
|
||||
$PROGRAM list Every service in the catalog, and its state here
|
||||
$PROGRAM enable <Name> 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 <Name> Stop the unit and unlink it. ~/Server/<Name>,
|
||||
its .env and its data are never touched.
|
||||
$PROGRAM status [<Name>] 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-<name>.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 <Name>"; 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 <Name>"; 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 "$@"
|
||||
@@ -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/tty) 2>/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/tty || username=""
|
||||
username="${username:-gib}"
|
||||
|
||||
if id -u "$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" </dev/tty
|
||||
fi
|
||||
|
||||
# The key that reached root is the key that should reach the user, or the
|
||||
# next SSH login has no way in once root logins are closed below.
|
||||
user_home="$(getent passwd "$username" | cut -d: -f6)"
|
||||
if [[ -s /root/.ssh/authorized_keys && ! -s "$user_home/.ssh/authorized_keys" ]]; then
|
||||
echo "Copying root's authorized_keys to $username"
|
||||
mkdir -p "$user_home/.ssh"
|
||||
cp /root/.ssh/authorized_keys "$user_home/.ssh/authorized_keys"
|
||||
chmod 700 "$user_home/.ssh"
|
||||
chmod 600 "$user_home/.ssh/authorized_keys"
|
||||
chown -R "$username:$username" "$user_home/.ssh"
|
||||
fi
|
||||
|
||||
# Offered rather than imposed, defaulting to yes: a VPS keeps its provider's
|
||||
# web console, so locking password and root logins out of sshd is
|
||||
# recoverable even when it goes wrong. Written as a drop-in so it never
|
||||
# fights the distribution's own sshd_config.
|
||||
SSHD_DROPIN=/etc/ssh/sshd_config.d/90-panama.conf
|
||||
if [[ -f "$SSHD_DROPIN" ]]; then
|
||||
echo "sshd is already hardened ($SSHD_DROPIN)"
|
||||
else
|
||||
printf 'Harden sshd (disable root login and password auth)? [Y/n]: '
|
||||
read -r harden </dev/tty || harden=""
|
||||
if [[ ! "$harden" =~ ^[Nn] ]]; then
|
||||
printf 'PermitRootLogin no\nPasswordAuthentication no\n' >"$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/tty
|
||||
fi
|
||||
|
||||
# git is the one dependency the clone itself needs. Everything else -- gum
|
||||
@@ -54,6 +161,6 @@ fi
|
||||
# The probe actually opens /dev/tty rather than testing -r: a process with no
|
||||
# controlling terminal passes -r and then fails the redirect.
|
||||
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
|
||||
exec "$PANAMA_PATH/install" </dev/tty
|
||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
||||
fi
|
||||
exec "$PANAMA_PATH/install"
|
||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
||||
|
||||
@@ -34,19 +34,25 @@ PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
|
||||
UPGRADE=0
|
||||
FORCE_PACKAGES=0
|
||||
ROLE_PRESET=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--upgrade) UPGRADE=1 ;;
|
||||
--packages) FORCE_PACKAGES=1 ;;
|
||||
--server) ROLE_PRESET=server ;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
usage: install [--upgrade] [--packages]
|
||||
usage: install [--upgrade] [--packages] [--server]
|
||||
|
||||
(no arguments) Build this machine. Asks the interview, runs every stage.
|
||||
--upgrade Update a machine that already exists. Asks nothing, and
|
||||
skips setup-identity and install-hardware.
|
||||
--packages Run install-packages even when the lists are unchanged.
|
||||
Only meaningful with --upgrade; a full install always runs it.
|
||||
--server Answer the interview's role question with 'server' without
|
||||
being asked -- the curl-onto-a-fresh-VPS path. The rest of
|
||||
the interview still runs. Meaningless with --upgrade, which
|
||||
reads the role this machine already recorded.
|
||||
USAGE
|
||||
exit 0 ;;
|
||||
*)
|
||||
@@ -119,9 +125,16 @@ record_packages_hash() {
|
||||
if (( ! UPGRADE )); then
|
||||
bootstrap=()
|
||||
command -v gum >/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 <Name>"
|
||||
else
|
||||
echo "Panama installed. Log out and choose the Hyprland session to start it."
|
||||
fi
|
||||
|
||||
@@ -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-<name>.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 <Name>` creates `~/Server/<Name>/` 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/<Name>/` 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 <Name>` —
|
||||
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/<Name>/` with the three files, modelled on any
|
||||
existing service. The unit is the standard oneshot `podman compose up -d`
|
||||
shape (`WorkingDirectory=%h/Server/<Name>`); 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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,24 @@
|
||||
# TEMPLATE for a rootless user unit.
|
||||
# Install to ~/.config/systemd/user/podman-<name>.service then:
|
||||
# systemctl --user daemon-reload && systemctl --user enable --now podman-<name>.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
|
||||
@@ -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=[email protected]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 '<see this service .env on the machine>';
|
||||
# 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 protected]
|
||||
- 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
|
||||
@@ -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
|
||||
@@ -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://[email protected]/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 <[email protected]>'
|
||||
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=
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 '<see this service .env on the machine>';
|
||||
# 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
|
||||
- [email protected]
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
volumes:
|
||||
- ./volumes/cert/cert.p12:/opt/documenso/cert.p12:Z
|
||||
tty: true
|
||||
stdin_open: true
|
||||
restart: unless-stopped
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=[email protected]
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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://[email protected]/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 <[email protected]>'
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: '<see this service .env on the machine>'
|
||||
#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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
PENPOT_SECRET_KEY=CHANGE_ME
|
||||
PENPOT_DATABASE_PASSWORD=CHANGE_ME
|
||||
@@ -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 '<see this service .env on the machine>';
|
||||
# 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
POSTGRES_USER=npm
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
POSTGRES_NAME=npm
|
||||
POSTGRES_DB=npm
|
||||
@@ -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 <each>, then ALTER DATABASE <each> 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 <dbname> -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME
|
||||
@@ -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=<h1>Welcome to Gib's Rustdesk<h1>
|
||||
- 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
|
||||
@@ -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
|
||||
@@ -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://[email protected]/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 <[email protected]>"
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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://[email protected]/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 <[email protected]>'
|
||||
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=
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
ADMIN_TOKEN=CHANGE_ME
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+238
@@ -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 <image> && systemctl --user restart podman-<name>.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 ]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+155
-112
@@ -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
|
||||
|
||||
+53
-14
@@ -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")"
|
||||
)"
|
||||
|
||||
|
||||
+70
-42
@@ -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
|
||||
|
||||
Executable
+15
@@ -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
|
||||
Executable
+100
@@ -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
|
||||
@@ -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 )) \
|
||||
|
||||
@@ -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' ;;
|
||||
|
||||
Executable
+83
@@ -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/<Name>/, 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'
|
||||
Executable
+76
@@ -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/<Name>, 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-<name>.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'
|
||||
Executable
+160
@@ -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" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'systemctl %s\n' "\$*" >>"$calls"
|
||||
case "\$*" in
|
||||
*is-active*) echo inactive; exit 3 ;;
|
||||
*is-enabled*) exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
cat >"$stub_dir/podman" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'podman %s\n' "\$*" >>"$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'
|
||||
@@ -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 ────────────────────────────────
|
||||
#
|
||||
|
||||
Executable
+56
@@ -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[@]}"
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
Executable
+158
@@ -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'
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user