3 Commits
67 changed files with 3933 additions and 104 deletions

No files matched your search

+1 -1
View File
@@ -250,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
192 of them, under `tests/`. `tests/contracts.manifest` classifies every
193 of them, under `tests/`. `tests/contracts.manifest` classifies every
contract by the capabilities it needs. Run the hermetic set, or grant a
specific external capability when automation needs it:
+64 -14
View File
@@ -4,8 +4,10 @@
#
# The repository carries the catalog: server/vps/<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,
# what the service needs told. A service may also carry helper units (any
# other *.service or *.timer, e.g. a cert refresh) and helper files (scripts,
# a README) beside them. 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.
#
@@ -83,12 +85,12 @@ require_service() {
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.
# The one compose unit 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
for unit in "$SERVER_DIR/$name"/podman-*.service; do
[[ -e "$unit" ]] || continue
printf '%s' "$unit"
return 0
@@ -96,6 +98,44 @@ unit_path_for() {
return 1
}
# Every other unit in the directory: helper services and the timers that fire
# them. They are linked beside the compose unit, and each timer is enabled.
helper_units_for() {
local name="$1" unit
for unit in "$SERVER_DIR/$name"/*.service "$SERVER_DIR/$name"/*.timer; do
[[ -e "$unit" ]] || continue
[[ "$(basename "$unit")" == podman-*.service ]] && continue
printf '%s\n' "$unit"
done
}
# The files linked into ~/Server/<Name>/: every visible top-level file that is
# not a unit. That is compose.yml plus any helper script or README; dotfiles
# are skipped so .env.example (and a stray .env) never become links.
live_files_for() {
local name="$1" file
for file in "$SERVER_DIR/$name"/*; do
[[ -f "$file" ]] || continue
case "$file" in *.service|*.timer) continue ;; esac
printf '%s\n' "$file"
done
}
link_service_files() {
local name="$1" file unit
mkdir -p "$TARGET_DIR/$name" "$UNIT_DIR"
while IFS= read -r file; do
link_file "$file" "$TARGET_DIR/$name/$(basename "$file")"
done < <(live_files_for "$name")
while IFS= read -r unit; do
link_file "$unit" "$UNIT_DIR/$(basename "$unit")"
done < <(helper_units_for "$name")
}
helper_timers_for() {
helper_units_for "$1" | grep '\.timer$' | xargs -r -n1 basename
}
unit_name_for() {
local unit
unit="$(unit_path_for "$1")" || return 1
@@ -111,10 +151,12 @@ unit_installed() {
[[ -L "$UNIT_DIR/$unit_name" ]]
}
# Everything this machine links for the service, so a change to a helper
# script or timer is reported by relink just like a compose change.
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
local name="$1"
{ live_files_for "$name"; unit_path_for "$name" && echo; helper_units_for "$name"; } \
| sort | xargs -r -d '\n' cat 2>/dev/null | sha256sum | cut -d' ' -f1
}
recorded_hash() {
@@ -198,6 +240,7 @@ cmd_enable() {
fi
mkdir -p "$UNIT_DIR"
link_service_files "$name"
link_file "$unit_path" "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload
if systemctl --user enable --now "$unit_name"; then
@@ -207,6 +250,10 @@ cmd_enable() {
err "$unit_name did not start; see: systemctl --user status $unit_name"
exit 1
fi
local timer
for timer in $(helper_timers_for "$name"); do
systemctl --user enable --now "$timer" && ok "Enabled $timer"
done
}
cmd_disable() {
@@ -216,11 +263,15 @@ cmd_disable() {
local unit_name
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
local timer unit
for timer in $(helper_timers_for "$name"); do
systemctl --user disable --now "$timer" 2>/dev/null || true
done
systemctl --user disable --now "$unit_name" 2>/dev/null || true
if [[ -L "$UNIT_DIR/$unit_name" ]]; then
rm "$UNIT_DIR/$unit_name"
for unit in "$UNIT_DIR/$unit_name" $(helper_units_for "$name" | xargs -r -n1 basename | sed "s|^|$UNIT_DIR/|"); do
[[ -L "$unit" ]] && rm "$unit"
done
systemctl --user daemon-reload
fi
ok "$name disabled. ~/Server/$name, its .env and its data were not touched."
}
@@ -267,8 +318,7 @@ cmd_relink() {
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"
link_service_files "$name"
refreshed=$((refreshed + 1))
# Changed since the last time this machine looked, which is what makes a
+40 -2
View File
@@ -8,7 +8,9 @@ screen, plus rootless podman compose services managed as systemd user units.
server/
vps/ The catalog panama-server installs from, one directory
per service: compose.yml, the podman-<name>.service user
unit, and an .env.example naming what the service needs told
unit, and an .env.example naming what the service needs told.
Loose files beside the directories are the VPS's own
host tooling (see "Host tooling" below)
home/ The home server's services in the same shape (see below)
scripts/ update-containers, the nightly image updater
systemd/ The units behind it, linked by setup-server
@@ -25,6 +27,8 @@ installable: panama-server reads `server/vps/` only, so a service moves into
the catalog when it actually moves to podman.
Paths keep their nesting (`server/home/Agents/Gitea` is `~/Server/Agents/Gitea`).
`Media/compact-tv/` is the nightly TV re-encode job that runs beside the Media
stack, with its own README.
Three services are checkouts of upstream repositories. For those, only the
local files are kept, and an `UPSTREAM` file names the repository and the exact
commit the checkout sits at: `PostHog`, `Sentry` and `Plausible`.
@@ -38,7 +42,8 @@ compose and its copy here can differ on those lines alone.
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
directory** and symlinks the service's visible top-level files into it:
`compose.yml`, plus any helper script or README. The units symlink 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
@@ -99,6 +104,39 @@ 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.
A service that needs a scheduled job of its own carries it beside the
compose unit: a helper `<job>.service` and a `<job>.timer` with the same
name, plus the script it runs. Enable links all three and enables the timer.
Disable stops the timer and unlinks the units. `UseSend` is the example: its
`usesend-cert-refresh` timer keeps the SMTP proxy's certificate in step with
Nginx Proxy Manager's.
## Host tooling
Each server's own scripts and runbooks sit as loose files at the top of its
tree. They are reference copies for rebuilding a machine. Nothing installs
them.
- `vps/backup`, `vps/server-backup.service`, `vps/server-backup.timer`: the
VPS's nightly backup, run as a user unit. It dumps every database into
`~/Server/_backup_state/`, then rsyncs `~/Server` to the home server's ZFS
pool with `--fake-super`, so subuid ownership survives the trip.
- `vps/verify-rootless`: the post-reboot health check for the rootless
stack. It covers linger, the unprivileged port floor, unit state and boot
enablement, postgres, endpoints, and admin ports bound to the WireGuard
address.
- `vps/AGENTS.md`: the VPS runbook, with both public IPs redacted.
- `home/RESTORE.md`: how to restore the home server's `~/Server` from the
ZFS backup.
- `home/backup` and `home/systemd/`: the home server's backup trigger and its
root system units (`server-backup` nightly, `media-backup` weekly). The
scripts those units run live in `/usr/local/sbin` on that machine. They are
readable by root alone, so this tree has no copy of them.
The VPS units carry `RequiresMountsFor=/home/gib/Media` because that host
keeps its podman graphroot on an attached volume mounted there. The setting
is harmless on a machine without the mount.
## Nightly updates
`server/scripts/update-containers` pulls images and restarts changed
+1 -1
View File
@@ -19,7 +19,7 @@ x-runner-env: &runner-env
TZ: America/New_York
GITEA_INSTANCE_URL: https://git.gbrown.org
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,build:docker://docker.gitea.com/runner-images:ubuntu-latest"
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: /certs/client
CONFIG_FILE: /data/config.yml
+1 -1
View File
@@ -18,7 +18,7 @@ services:
# restart: unless-stopped
portainer-agent:
image: portainer/agent:2.33.2
image: portainer/agent:latest
container_name: portainer-agent
hostname: portainer-agent
networks:
+3 -3
View File
@@ -43,8 +43,8 @@ volumes:
services:
gbrown-backend:
# Pinned to the build the VPS Convex stacks already run. Bump on purpose.
image: ghcr.io/get-convex/convex-backend@sha256:d715e9ec088784407ca4ba2d3db592702cd328d02c76cdca3852c0018f2a76b4
# Pinned to the build the VPS Convex stacks run (bumped 2026-10-28). Bump on purpose.
image: ghcr.io/convex/convex-backend@sha256:207cf95def42f01614b6e3af0af5c20d0dcaa5ff2adf258ee7bc9555576644fa
container_name: gbrown-backend
hostname: gbrown-backend
networks: ['nginx-bridge']
@@ -70,7 +70,7 @@ services:
gbrown-dashboard:
# Released alongside the backend; pinned with it.
image: ghcr.io/get-convex/convex-dashboard@sha256:f85cf0d0448b9c835ae3df5c7c1c6f0145dd4c8f0f92eb0245daf304efdd9f1a
image: ghcr.io/convex/convex-dashboard@sha256:a55bf8abca1332f787bc4d4a79850182175027be7b70aa230be4389a1d3268e2
container_name: gbrown-dashboard
hostname: gbrown-dashboard
networks: ['nginx-bridge']
+109
View File
@@ -0,0 +1,109 @@
# compact-tv
Sonarr and Radarr each have exactly four quality profiles. Two of them (**Standard** and
**Mini**) also have their files shrunk by `compact-tv.py`, which runs every night.
| Profile | Used for | What gets kept |
|---|---|---|
| **Mini** | shows I demote hard | TV: 720p at 800 kbps (~0.42 GB per hour). Movies: 720p at 1500 kbps |
| **Standard** | **every request from other users (Seerr default)**, and anything I demote | TV: 1080p at 1200 kbps (~0.6 GB per hour). Movies: 1080p at 2500 kbps (~1.2 GB per hour) |
| **HD** | my own library, unchanged | whatever downloads, up to 1080p |
| **UHD** | only me, through Seerr's 4K servers | 4K |
SD sources keep their resolution, at 600 kbps for TV and 900 kbps for movies. Movies get
higher rates because dark scenes, grain and action break down first at low bitrates. Nothing is ever
upscaled. Audio becomes Opus: 96 kbps for stereo, 160 for 5.1, 192 for 7.1. Subtitles,
chapters and attachments are copied as they are. I picked the rates from sample encodes of
Shameless S05E06 (VMAF ~91 for Standard, ~86 for Mini).
## How the profiles work
- **Standard and Mini** are copies of HD that upgrade only toward small releases (see below),
so Sonarr and Radarr never replace a file with a bigger one.
- **Small downloads first.** Both profiles prefer small releases when they grab:
- The "Small Encode" format (MeGusta, PSA, ELiTE, GalaxyTV/RG, YTS/YIFY) scores +3000.
- LQ scores 0. In HD and UHD it scores -10000, which keeps those groups out of HD.
- Standard merges HDTV/WEB/Bluray at each resolution, so the format score decides between
sources rather than the source type.
- Mini merges 720p and 1080p into one group and adds +1000 for 720p.
- Many files therefore arrive already small, and the job skips them.
- **Minimum sizes are shared by every profile.** For 720p I lowered them to 5 MB per minute
and for 1080p to 6 MB per minute (8 for Bluray-1080p), so small encodes are not rejected.
HD still never takes the LQ groups.
- **Seerr:**
- The "Sonarr - HD" and "Radarr" servers default to Standard. Only my admin account sees
the profile picker, so other users always get Standard. I pick HD or UHD per request.
- Other users no longer have "Request 4K".
- **Configarr is opt-in** (`docker compose --profile configarr up configarr`). Its templates
would recreate the old TRaSH profiles and size limits.
- Backups from before all this: `backups/pre-four-profiles-20261028/`. That covers both
apps' profiles, custom formats and size definitions, which title was on which profile,
and Seerr's settings. `setup-small-profiles.py` reapplies the Standard and Mini settings.
## How the job works
The nightly run (00:30 to 08:00) has two phases.
**1. Search first.** It asks Sonarr and Radarr to search titles on Standard or Mini that it
hasn't searched before, biggest first: 5 shows and 20 movies a night, so downloads and
indexer use stay spread out. Standard and Mini upgrade only toward small releases:
- Every allowed quality sits in one group, so no resolution or source upgrade can happen.
- An upgrade needs a custom-format score jump of at least 2500, and upgrades stop at 3000.
Only gaining "Small Encode" clears that bar.
So a search replaces a big file only with a small encode, never with a big HEVC release.
After that, the apps' regular RSS checks keep picking up small releases on their own.
**2. Encode what's left.** A title becomes eligible for encoding 7 days after its search,
and only its files that are still big get encoded. Files that arrive after the title's
search work differently:
- This covers new episodes of ongoing shows, and fresh requests.
- The first night they're seen, they get their own search (one episode or movie at a time).
- They're encoded only if they're still big 3 days later.
Downloads never wait for any of this, so new episodes and requests land in the library as
soon as they download. Only encoding waits. For each file, the job encodes to
`~/.cache/compact-tv/work.mkv` on the NVMe and then checks the result:
- the duration matches the original
- the audio and subtitle track counts match
- the file is smaller
It then renames the new file over the original, keeping the same name with `.mkv`, and asks
Sonarr or Radarr to rescan. Plex picks the change up through its folder watcher, and the
episode keeps its Plex ID, so watch history stays. If a small release shows up later, the
app may still replace an encoded file with it, which costs bandwidth, not GPU.
Other details:
- Each output carries a `COMPACT_TIER` tag. A file is skipped if re-encoding would save less
than 30%. Ruled-out files are cached by size in `state.json`, so they aren't probed every
night. Search dates and failures are kept there too.
- A downloaded HEVC file no bigger than twice the target size counts as done. That is the
small release a search brought in (a MeGusta 1080p episode runs about 2.3 Mbps against
Standard's 1.2), and it is neither re-encoded nor searched again. The job's own tagged
outputs are exempt, so Standard to Mini still works.
- **Skipped:** HDR and Dolby Vision sources (they need tone mapping), subtitle tracks ffprobe
can't identify, and files that have failed twice.
- Speed is about 0.19 hours of GPU per hour of 1080p video (NVENC is the bottleneck). The job
stops starting new encodes at 08:00.
- `cost-report.py` estimates, per title, the GPU hours the job would need against the size of
a fresh small download.
- A `PAUSE` file in this folder makes the nightly run do nothing.
## Going back up
Moving down works: Standard to Mini re-encodes the Standard copy. Moving up does not
restore quality. To get quality back, I set the title to HD and search, which redownloads
it at full size.
## Running it
./compact-tv.py --dry-run # what tonight would do
./compact-tv.py --search-all --limit 0 # search every unsearched title now, encode nothing
touch PAUSE # skip nightly runs until PAUSE is deleted
./compact-tv.py --series "Shameless (US)" --limit 1
systemctl status compact-tv.timer compact-tv.service
tail -f compact-tv.log
The installed units are copies of `compact-tv.service` and `compact-tv.timer` from this
folder. `ExecStart` goes through `/usr/bin/python3` because SELinux won't let systemd
execute a file labeled as home content.
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""Shrink shows and movies that Sonarr/Radarr have on the "Standard" or "Mini" profile.
Demoting something is just picking one of those profiles. Each night this job first asks
Sonarr/Radarr to search a few titles it hasn't searched before. Both profiles upgrade only
toward small releases, so the search swaps whatever big files have a small release for
bandwidth instead of GPU. A week after a title's search, the job re-encodes whatever is
still big to HEVC on the GPU (NVENC) at the tier's bitrate, verifies the result, swaps it in
place of the original, and has Sonarr/Radarr rescan. Plex and Jellyfin pick up changes
through their own folder watchers.
Moving something back up means switching it to HD and searching, which redownloads it at
full size. See README.md.
Usage:
compact-tv.py [--until HH:MM] [--series TITLE] [--limit N] [--dry-run]
--series matches a show or movie title. Runs as gib (files are gib-owned) from
compact-tv.service, nightly.
"""
import argparse, datetime, fcntl, json, os, pathlib, re, shutil, subprocess, sys, time, urllib.request
HERE = pathlib.Path(__file__).resolve().parent
STATE = HERE / "state.json"
LOG = HERE / "compact-tv.log"
WORK = pathlib.Path.home() / ".cache" / "compact-tv" # NVMe, so half-done encodes never land in a ZFS snapshot
VOLUMES = pathlib.Path.home() / "Server/Media/volumes"
# Per app: API port, container path -> host path, and how to list one title's files.
APPS = {
"sonarr": {"port": 8989, "paths": ("/tv/", "/home/gib/Public/TV/"), "titles": "/series",
"files": lambda call, t: call(f"/episodefile?seriesId={t['id']}"),
"rescan": lambda t: {"name": "RescanSeries", "seriesId": t["id"]}},
"radarr": {"port": 7878, "paths": ("/movies/", "/home/gib/Public/Movies/"), "titles": "/movie",
"files": lambda call, t: [t["movieFile"]] if t.get("movieFile") else [],
"rescan": lambda t: {"name": "RescanMovie", "movieId": t["id"]}},
}
# Video bitrate (kbps) per output class, and the widest output each tier allows.
# TV floors come from watching samples: 1200 kbps at 1080p, 800 at 720p, 600 at SD.
# Movies get roughly double: dark scenes, grain and action break down first at low rates,
# and a 2-hour movie at 2500 kbps is still ~2.3 GB (a quarter of a typical 1080p file).
# That also leaves already-small releases (YTS-style, ~1.5-2 GB) under the 30% bar.
# Nothing is ever upscaled: a 720p or SD source keeps its size and uses that class's rate.
TIERS = {
"Standard": {"max_width": 1920, "kbps": {"sonarr": {"1080": 1200, "720": 1200, "sd": 600},
"radarr": {"1080": 2500, "720": 1800, "sd": 900}}},
"Mini": {"max_width": 1280, "kbps": {"sonarr": {"1080": 800, "720": 800, "sd": 600},
"radarr": {"1080": 1500, "720": 1500, "sd": 900}}},
}
MIN_SAVING = 0.30 # leave a file alone unless re-encoding saves at least this much
MAX_FAILURES = 2 # after this many failed attempts a file is skipped for good
# Search first, encode later. Standard/Mini upgrade only toward small releases (see
# setup-small-profiles.py), so asking the *arr to search a title swaps whatever big files
# have a small release, for bandwidth instead of GPU. Each night searches a few titles not
# searched before, biggest first, so downloads and indexer use stay spread out. A title's
# files are only encoded once its search is SEARCH_GRACE_DAYS old.
#
# Files that arrive after their title's search (new episodes, fresh requests) were never
# searched for a small copy, so the job searches that episode or movie on its own first,
# and encodes it only if it is still big FILE_SEARCH_GRACE_DAYS later. Downloads are never
# delayed by any of this; only encoding waits.
SEARCHES_PER_NIGHT = {"sonarr": 5, "radarr": 20}
SEARCH_GRACE_DAYS = 7
FILE_SEARCH_GRACE_DAYS = 3 # small encodes usually appear within hours to a day; 3 leaves room
HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
def log(msg):
line = f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {msg}"
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n")
# ── Sonarr / Radarr ─────────────────────────────────────────────────────────
def arr(app):
"""Return a call(path, body=None) for the app's v3 API."""
key = re.search(r"<ApiKey>([^<]+)", (VOLUMES / app / "config.xml").read_text()).group(1)
ip = subprocess.run(["docker", "inspect", app, "--format",
"{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}"],
capture_output=True, text=True, check=True).stdout.split()[0]
base = f"http://{ip}:{APPS[app]['port']}/api/v3"
def call(path, body=None):
req = urllib.request.Request(base + path, headers={"X-Api-Key": key, "Content-Type": "application/json"},
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
return call
# ── probing and planning ────────────────────────────────────────────────────
def probe(path):
out = subprocess.run(["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)],
capture_output=True, text=True)
return json.loads(out.stdout) if out.returncode == 0 else None
def subtitles(info):
"""Subtitle tracks ffmpeg can carry over. Tracks ffprobe can't identify are dropped."""
return [s for s in info["streams"] if s["codec_type"] == "subtitle" and s.get("codec_name")]
def audio_kbps(channels):
return 96 if channels <= 2 else 160 if channels <= 6 else 192
def plan(info, tier, app):
"""Return (ffmpeg args, estimated output bytes) for one file, or (None, reason) to skip it."""
t = TIERS[tier]
video = next((s for s in info["streams"] if s["codec_type"] == "video"
and not s.get("disposition", {}).get("attached_pic")), None)
if not video:
return None, "no video stream"
if video.get("color_transfer") in HDR_TRANSFERS or any("Dolby Vision" in str(sd) for sd in video.get("side_data_list", [])):
return None, "HDR/Dolby Vision source (not handled yet)"
if (info["format"].get("tags") or {}).get("COMPACT_TIER") == tier:
return None, f"already {tier}"
src_w = int(video["width"])
out_w = min(src_w, t["max_width"])
cls = "1080" if out_w >= 1700 else "720" if out_w >= 1200 else "sd"
vkbps = t["kbps"][app][cls]
duration = float(info["format"]["duration"])
audios = [s for s in info["streams"] if s["codec_type"] == "audio"]
est = int((vkbps + sum(audio_kbps(int(a.get("channels") or 2)) for a in audios)) * 1000 / 8 * duration * 1.03)
if int(info["format"]["size"]) * (1 - MIN_SAVING) < est:
return None, f"already small ({int(info['format']['size']) / 1e9 * 3600 / duration:.2f} GB/h)"
# A downloaded HEVC file within 2x the target is a small release that already replaced a
# big one. Re-encoding it would cost GPU and quality for little space. Our own outputs
# (tagged) still go through, so moving a title from Standard to Mini works.
tags = info["format"].get("tags") or {}
if video.get("codec_name") == "hevc" and "COMPACT_TIER" not in tags and int(info["format"]["size"]) <= 2 * est:
return None, f"small HEVC release ({int(info['format']['size']) / 1e9 * 3600 / duration:.2f} GB/h)"
vf = []
if video.get("field_order") not in (None, "progressive", "unknown"):
vf.append("bwdif")
if out_w < src_w:
vf.append(f"scale={out_w}:-2:flags=lanczos")
args = ["-map", f"0:{video['index']}"]
if vf:
args += ["-filter:v", ",".join(vf)]
args += ["-c:v", "hevc_nvenc", "-preset", "p7", "-tune", "hq", "-profile:v", "main10", "-pix_fmt", "p010le",
"-rc", "vbr", "-multipass", "fullres", "-spatial-aq", "1", "-temporal-aq", "1", "-rc-lookahead", "32",
"-bf", "4", "-b_ref_mode", "middle",
"-b:v", f"{vkbps}k", "-maxrate", f"{vkbps * 2}k", "-bufsize", f"{vkbps * 4}k"]
for i, a in enumerate(audios):
ch = int(a.get("channels") or 2)
layout = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"}.get(ch)
args += ["-map", f"0:{a['index']}", f"-c:a:{i}", "libopus", f"-b:a:{i}", f"{audio_kbps(ch)}k",
f"-filter:a:{i}", f"aformat=channel_layouts={layout or 'stereo'}"]
args += ["-mapping_family", "1"]
for i, s in enumerate(subtitles(info)):
args += ["-map", f"0:{s['index']}", f"-c:s:{i}", "srt" if s["codec_name"] == "mov_text" else "copy"]
args += ["-map", "0:t?", "-c:t", "copy", "-metadata", f"COMPACT_TIER={tier}"]
return args, est
# ── encode, verify, swap ────────────────────────────────────────────────────
def encode(src, args, tier):
WORK.mkdir(parents=True, exist_ok=True)
tmp = WORK / "work.mkv"
tmp.unlink(missing_ok=True)
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-hwaccel", "cuda", "-i", str(src),
*args, "-metadata", f"COMPACT_SOURCE={src.name} ({src.stat().st_size} bytes)", str(tmp)]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
tmp.unlink(missing_ok=True)
raise RuntimeError("ffmpeg failed: " + r.stderr.strip()[-300:])
return tmp
def verify(src_info, tmp):
new = probe(tmp)
if not new:
raise RuntimeError("output does not probe")
a, b = float(src_info["format"]["duration"]), float(new["format"]["duration"])
if abs(a - b) > max(2.0, a * 0.005):
raise RuntimeError(f"duration mismatch {a:.1f}s vs {b:.1f}s")
counts = lambda i: (sum(1 for s in i["streams"] if s["codec_type"] == "audio"), len(subtitles(i)))
if counts(src_info) != counts(new):
raise RuntimeError(f"audio/subtitle track counts {counts(src_info)} -> {counts(new)}")
if int(new["format"]["size"]) >= int(src_info["format"]["size"]):
raise RuntimeError("output is not smaller")
return int(new["format"]["size"])
def swap(src, tmp):
"""Copy the encode next to the original, then rename it over the original in one step."""
final = src.with_suffix(".mkv")
staged = src.with_name(f".{final.name}.compact-tmp")
shutil.copyfile(tmp, staged)
os.replace(staged, final)
if final != src:
src.unlink()
tmp.unlink(missing_ok=True)
return final
# ── main ────────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--until", help="stop starting new encodes after this local time (HH:MM)")
ap.add_argument("--series", help="only this series title")
ap.add_argument("--limit", type=int, help="encode at most N files")
ap.add_argument("--dry-run", action="store_true", help="plan only, change nothing")
ap.add_argument("--search-all", action="store_true",
help="search every not-yet-searched title now instead of a few per night")
opt = ap.parse_args()
if (HERE / "PAUSE").exists() and not opt.dry_run:
log("PAUSE file present, not encoding anything (delete it to resume)")
return
lock = open(HERE / ".lock", "w")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
sys.exit("another compact-tv run is active")
deadline = None
if opt.until:
h, m = map(int, opt.until.split(":"))
now = datetime.datetime.now()
deadline = now.replace(hour=h, minute=m, second=0, microsecond=0)
if deadline <= now:
deadline += datetime.timedelta(days=1)
state = json.loads(STATE.read_text()) if STATE.exists() else {}
for k in ("failures", "searched", "skip"):
state.setdefault(k, {})
save = lambda: STATE.write_text(json.dumps(state, indent=1))
today = datetime.date.today()
work = [] # (app, call, title, tier); TV first, then movies
for app, a in APPS.items():
call = arr(app)
profiles = {p["id"]: p["name"] for p in call("/qualityprofile")}
work += [(app, call, t, profiles[t["qualityProfileId"]]) for t in call(a["titles"])
if profiles.get(t["qualityProfileId"]) in TIERS and (not opt.series or t["title"] == opt.series)]
log(f"=== run start{' [dry run]' if opt.dry_run else ''}: {len(work)} title(s) on "
f"{'/'.join(TIERS)}{f', until {deadline:%H:%M}' if deadline else ''} ===")
# ── 1. search: ask the *arr for small replacements before spending GPU ──
size_of = lambda t: (t.get("statistics") or {}).get("sizeOnDisk") or t.get("sizeOnDisk") or 0
for app in APPS:
fresh = sorted((w for w in work if w[0] == app and f"{app}:{w[2]['id']}" not in state["searched"]
and size_of(w[2]) > 0), key=lambda w: -size_of(w[2]))
fresh = fresh if opt.search_all else fresh[:SEARCHES_PER_NIGHT[app]]
for _, call, t, tier in fresh:
log(f" {'would search' if opt.dry_run else 'search'} {t['title']} ({size_of(t) / 1e9:.0f} GB, {tier})")
if fresh and not opt.dry_run:
call = fresh[0][1]
if app == "sonarr":
for _, _, t, _ in fresh:
call("/command", {"name": "SeriesSearch", "seriesId": t["id"]})
else:
call("/command", {"name": "MoviesSearch", "movieIds": [t["id"] for _, _, t, _ in fresh]})
for _, _, t, _ in fresh:
state["searched"][f"{app}:{t['id']}"] = today.isoformat()
save()
# ── 2. encode what is still big after its search ────────────────────────
age = lambda iso: (today - datetime.date.fromisoformat(iso)).days
def ready(app, ef, title_searched, to_search):
"""True if this big file has had its chance to be replaced by a small download."""
if (ef.get("dateAdded") or "")[:10] <= title_searched: # was on disk when the title was searched
return age(title_searched) >= SEARCH_GRACE_DAYS
key = f"{app}:file:{ef['id']}" # arrived later: search it on its own first
if key not in state["searched"]:
to_search.append(ef)
return False
return age(state["searched"][key]) >= FILE_SEARCH_GRACE_DAYS
def search_files(app, call, show, files):
if opt.dry_run:
log(f" would search {len(files)} newer file(s) of {show['title']}"); return
if app == "sonarr":
ids = {f["id"] for f in files}
eps = [e["id"] for e in call(f"/episode?seriesId={show['id']}") if e.get("episodeFileId") in ids]
call("/command", {"name": "EpisodeSearch", "episodeIds": eps})
else:
call("/command", {"name": "MoviesSearch", "movieIds": [show["id"]]})
for f in files:
state["searched"][f"{app}:file:{f['id']}"] = today.isoformat()
log(f" search {len(files)} newer file(s) of {show['title']} before encoding"); save()
done = saved = 0
for app, call, show, tier in work:
title_searched = state["searched"].get(f"{app}:{show['id']}")
if not title_searched:
continue # its title search comes first, on a later night
container, host = APPS[app]["paths"]
files = sorted(APPS[app]["files"](call, show), key=lambda f: f["path"])
changed, to_search = False, []
for ef in files:
if deadline and datetime.datetime.now() >= deadline:
log("deadline reached, stopping"); break
if opt.limit is not None and done >= opt.limit:
break
src = pathlib.Path(ef["path"].replace(container, host, 1))
if state["failures"].get(str(src), 0) >= MAX_FAILURES or not src.exists():
continue
# Files already ruled out at this tier and size need no re-probe (most of them, most nights).
seen = f"{tier}:{src.stat().st_size}"
if state["skip"].get(str(src)) == seen:
continue
info = probe(src)
if not info:
log(f" SKIP {src.name}: does not probe"); continue
args, est = plan(info, tier, app)
if args is None:
state["skip"][str(src)] = seen # reason in `est`; quiet, since most nights most files are done
continue
if not ready(app, ef, title_searched, to_search):
continue
old = int(info["format"]["size"])
if opt.dry_run:
log(f" would encode {src.name}: {old / 1e6:.0f} MB -> ~{est / 1e6:.0f} MB ({tier})")
done += 1; continue
t0 = time.monotonic()
try:
tmp = encode(src, args, tier)
new = verify(info, tmp)
final = swap(src, tmp)
except Exception as e:
state["failures"][str(src)] = state["failures"].get(str(src), 0) + 1
save()
log(f" FAIL {src.name}: {e}"); continue
done += 1; saved += old - new; changed = True
log(f" {final.name}: {old / 1e6:.0f} MB -> {new / 1e6:.0f} MB "
f"(-{(1 - new / old) * 100:.0f}%, {time.monotonic() - t0:.0f}s, {tier})")
if to_search:
search_files(app, call, show, to_search)
if changed:
call("/command", APPS[app]["rescan"](show))
if (deadline and datetime.datetime.now() >= deadline) or (opt.limit is not None and done >= opt.limit):
break
if not opt.dry_run:
save()
log(f"=== run end: {done} file(s){' planned' if opt.dry_run else ''}, saved {saved / 1e9:.1f} GB ===")
if __name__ == "__main__":
main()
@@ -0,0 +1,17 @@
# Installed to /etc/systemd/system/. Runs as gib because the TV files are gib-owned,
# so the job needs no root. A system unit (rather than a user unit) because gib has
# no lingering session on this headless box. ExecStart goes through python3 because
# SELinux will not let systemd execute a file labeled as home content (user_home_t).
[Unit]
Description=Re-encode TV shows on Sonarr's Compact/Supercompact profiles
Documentation=file:///home/gib/Server/Media/compact-tv/README.md
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
User=gib
Group=gib
ExecStart=/usr/bin/python3 /home/gib/Server/Media/compact-tv/compact-tv.py --until 08:00
Nice=15
IOSchedulingClass=idle
@@ -0,0 +1,11 @@
# Starts after the 00:00 hour and stops starting new encodes at 08:00 (see --until in
# the service), so Plex and Frigate keep the GPU's encoder during the day.
[Unit]
Description=Nightly TV re-encode for Compact/Supercompact shows
[Timer]
OnCalendar=*-*-* 00:30:00
Persistent=false
[Install]
WantedBy=timers.target
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Per-title cost of shrinking everything on Standard/Mini: re-encode vs redownload.
For each show or movie on those profiles, probes every file with compact-tv's own planner
and reports what the nightly job would do (encode, skip as already small, skip as HDR),
the space it would save, and the GPU time it would take. Alongside that it estimates what a
fresh small download would cost instead, using typical small-encode sizes.
GPU time is measured, not guessed: the Shameless pilot took 669 s for 57.5 minutes of
1080p (about 0.19 h of GPU per hour of video). 4K sources decode slower; the 1.5x for them
is an estimate until the first 4K night is logged.
Usage: cost-report.py > report.tsv (also prints a summary to stderr)
"""
import importlib.util, json, pathlib, sys
HERE = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("ct", HERE / "compact-tv.py")
ct = importlib.util.module_from_spec(spec); spec.loader.exec_module(ct)
GPU_H_PER_H = 669 / 3450 # pilot: hours of GPU per hour of 1080p video
UHD_FACTOR = 1.5 # estimate for 2160p sources
SMALL_GB_PER_H = {"Standard": 0.85, "Mini": 0.57} # MeGusta-class 1080p / 720p HEVC, measured on S.W.A.T.
rows = []
for app, a in ct.APPS.items():
call = ct.arr(app)
profiles = {p["id"]: p["name"] for p in call("/qualityprofile")}
container, host = a["paths"]
for t in call(a["titles"]):
tier = profiles.get(t["qualityProfileId"])
if tier not in ct.TIERS:
continue
r = {"app": app, "title": t["title"], "tier": tier, "files": 0, "hours": 0.0, "size": 0,
"enc_files": 0, "enc_size": 0, "enc_est": 0, "gpu_h": 0.0, "hdr_size": 0, "small_size": 0}
for f in a["files"](call, t):
p = pathlib.Path(f["path"].replace(container, host, 1))
info = ct.probe(p) if p.exists() else None
if not info:
continue
size, dur = int(info["format"]["size"]), float(info["format"]["duration"]) / 3600
r["files"] += 1; r["hours"] += dur; r["size"] += size
args, why = ct.plan(info, tier, app)
if args:
w = max((s.get("width") or 0) for s in info["streams"] if s["codec_type"] == "video")
r["enc_files"] += 1; r["enc_size"] += size; r["enc_est"] += why
r["gpu_h"] += dur * GPU_H_PER_H * (UHD_FACTOR if w > 1920 else 1)
elif "HDR" in str(why):
r["hdr_size"] += size
else:
r["small_size"] += size
if r["files"]:
r["redownload_gb"] = round(r["hours"] * SMALL_GB_PER_H[tier], 1)
rows.append(r)
rows.sort(key=lambda r: -(r["enc_size"] - r["enc_est"]))
cols = ["app", "title", "tier", "files", "hours", "size", "enc_files", "enc_size", "enc_est", "gpu_h", "hdr_size",
"small_size", "redownload_gb"]
print("\t".join(cols))
for r in rows:
print("\t".join(str(round(r[c], 2) if isinstance(r[c], float) else r[c]) for c in cols))
tot = lambda k: sum(r[k] for r in rows)
print(f"{len(rows)} titles; encode {tot('enc_files')} files {tot('enc_size')/1e12:.2f} TB -> ~{tot('enc_est')/1e12:.2f} TB, "
f"{tot('gpu_h'):.0f} GPU hours; HDR skipped {tot('hdr_size')/1e12:.2f} TB; already small {tot('small_size')/1e12:.2f} TB",
file=sys.stderr)
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Make Standard and Mini prefer small releases, and upgrade only toward small ones.
Run once per app; it is idempotent, so re-running updates everything in place.
- Adds "Small Encode" (release groups known for small HEVC/x264 encodes) and "720p" /
"1080p" resolution formats. They score 0 in every profile except Standard and Mini.
- HD and UHD are otherwise untouched: they keep LQ at -10000, so they never pick these.
- Standard and Mini:
- All allowed qualities share ONE group, so no "better resolution" or "better source"
upgrade can ever download a big file. Only the custom-format score decides.
- Scores: Small Encode +3000, x265 +2000 (so first grabs still prefer HEVC), LQ 0,
release-tier formats 0 (one big release must never "beat" another big one).
Standard adds +500 for 1080p and +250 for 720p; Mini adds +1000 for 720p and +500 for
1080p. Those preferences stay below the upgrade threshold, so they never cause churn.
- Upgrades are on, but an upgrade needs a score jump of at least 2500, and upgrading
stops at 3000. In practice only gaining "Small Encode" clears that bar: a big H.264
file gets replaced by a small encode, but never by a big HEVC one.
Usage: setup-small-profiles.py <sonarr|radarr>
"""
import json, re, subprocess, sys, urllib.request, pathlib
APP = sys.argv[1]
cfg = pathlib.Path.home() / f"Server/Media/volumes/{APP}/config.xml"
key = re.search(r"<ApiKey>([^<]+)", cfg.read_text()).group(1)
ip = subprocess.run(["docker", "inspect", APP, "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}"],
capture_output=True, text=True, check=True).stdout.split()[0]
base = f"http://{ip}:{8989 if APP == 'sonarr' else 7878}/api/v3"
def call(path, body=None, method=None):
req = urllib.request.Request(base + path, method=method, headers={"X-Api-Key": key, "Content-Type": "application/json"},
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
GROUPS = r"^(MeGusta|PSA|ELiTE|GalaxyTV|GalaxyRG|GalaxyRG265|YIFY|YTS(\.(MX|LT|AG))?)$"
resolution = lambda r: [{"name": f"{r}p", "implementation": "ResolutionSpecification",
"negate": False, "required": True, "fields": [{"name": "value", "value": r}]}]
WANTED = {
"Small Encode": [{"name": "Small encode groups", "implementation": "ReleaseGroupSpecification",
"negate": False, "required": False, "fields": [{"name": "value", "value": GROUPS}]}],
"720p": resolution(720),
"1080p": resolution(1080),
}
TIERS = {
"Standard": {"Small Encode": 3000, "x265 (HD)": 2000, "LQ": 0, "LQ (Release Title)": 0, "1080p": 500, "720p": 250},
"Mini": {"Small Encode": 3000, "x265 (HD)": 2000, "LQ": 0, "LQ (Release Title)": 0, "720p": 1000, "1080p": 500},
}
RELEASE_TIER = re.compile(r"Tier \d+|WEB Scene")
MIN_UPGRADE_JUMP, UPGRADE_UNTIL = 2500, 3000
cfs = {c["name"]: c for c in call("/customformat")}
for name, specs in WANTED.items():
body = {"name": name, "includeCustomFormatWhenRenaming": False, "specifications": specs}
if name in cfs:
body["id"] = cfs[name]["id"]; call(f"/customformat/{body['id']}", body, "PUT")
else:
call("/customformat", body)
cfs = {c["name"]: c["id"] for c in call("/customformat")}
def flatten(items):
"""Every quality in the profile with whether it is allowed, groups expanded, lowest rank first."""
for it in items:
for sub in (it["items"] or [it]):
yield sub["quality"], it["allowed"]
def one_group(profile):
"""Put every allowed quality into a single top-ranked group; leave disallowed ones as they are."""
flat = list(flatten(profile["items"]))
group = {"id": 1100, "name": "Any allowed", "allowed": True,
"items": [{"quality": q, "items": [], "allowed": True} for q, ok in flat if ok]}
profile["items"] = [{"quality": q, "items": [], "allowed": False} for q, ok in flat if not ok] + [group]
profile["cutoff"] = group["id"]
return profile
for prof in call("/qualityprofile"):
have = {f["format"] for f in prof["formatItems"]}
for name in WANTED:
if cfs[name] not in have:
prof["formatItems"].append({"format": cfs[name], "name": name, "score": 0})
scores = TIERS.get(prof["name"])
if scores:
prof = one_group(prof)
prof.update(upgradeAllowed=True, minUpgradeFormatScore=MIN_UPGRADE_JUMP, cutoffFormatScore=UPGRADE_UNTIL)
for f in prof["formatItems"]:
if f["name"] in scores:
f["score"] = scores[f["name"]]
elif RELEASE_TIER.search(f["name"]):
f["score"] = 0
call(f"/qualityprofile/{prof['id']}", prof, "PUT")
if scores:
allowed = [q["quality"]["name"] for it in prof["items"] if it["allowed"] for q in it["items"]]
print(f"{APP} {prof['name']}: one group of {len(allowed)} qualities ({allowed[0]} .. {allowed[-1]}); "
f"upgrades on, jump >= {MIN_UPGRADE_JUMP}, until {UPGRADE_UNTIL}")
print(" nonzero scores: " + ", ".join(f"{f['name']}={f['score']}" for f in prof["formatItems"] if f["score"]))
+5
View File
@@ -427,3 +427,8 @@ services:
# meant a completed sync was immediately restarted -- a hot loop of ~9
# full syncs every 3 minutes. `no` lets it run once per `up` and stop.
restart: "no"
# Opt-in only (`docker compose --profile configarr up configarr`). Its templates
# create the TRaSH profiles and size limits that were removed on 2026-10-28 in
# favour of four hand-managed profiles (Mini/Standard/HD/UHD), so a plain `up`
# must never run it. See Media/compact-tv/README.md.
profiles: [configarr]
+30 -2
View File
@@ -41,8 +41,36 @@ services:
# world is in backups/; upgrades rewrite the world and cannot be undone.
- VERSION=26.1.2
- DIFFICULTY=normal
- MAX_PLAYERS=10
- TYPE=VANILLA
- MAX_PLAYERS=30
# Paper since 2026-10-13. The last vanilla state is backups/gib-26.1.2-vanilla-*.
- TYPE=PAPER
# DonutSMP-style plugin set, pulled from Modrinth on each start. Every entry
# needs a 26.1.2 Paper build; check Modrinth before adding or bumping VERSION.
# Config lives in servers/gib/plugins/<Plugin>/. GrimAC only publishes alpha builds.
- MODRINTH_DOWNLOAD_DEPENDENCIES=required
- |
MODRINTH_PROJECTS=
luckperms
vaultunlocked
essentialsx
essentialsx-spawn
placeholderapi
playerpoints
zmenu
zauctionhouse
ultimateshop
smartspawner
wildrtp
ultimate-teams
asbounty
axafkzone
tab-was-taken
deluxemenus
coreprotect
worldedit
worldguard
grimac:alpha
chunky
- MEMORY=12G
- SIMULATION_DISTANCE=16
- VIEW_DISTANCE=16
+1
View File
@@ -38,6 +38,7 @@ services:
- ENABLE_OAUTH_SIGNUP=true
- OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true
#- ENABLE_OAUTH_ROLE_MANAGEMENT=true
#- OAUTH_ROLES_CLAIM=roles
#- OAUTH_ADMIN_ROLES=admin,internal,superuser
- OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}
- OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}
+140
View File
@@ -0,0 +1,140 @@
# Restoring ~/Server on server.gib from the ZFS-pool backup
Written 2026-09-17. Read this *before* you need it.
## What this protects against
The NVMe/LUKS btrfs volume (`/`, `/home`, and `/var/lib/docker`) dying. The backup lives on
the ZFS pool in the same machine, so it does **not** protect against fire, theft, or the
whole box failing. It is a disk-failure backup, not an offsite one.
## Layout
/home/gib/Public/Server_Backup/Containers/server.gib/current/
**One directory, overwritten daily at 02:00** by `server-backup.timer`. History lives in ZFS:
`Media/Server_Backup` has `zfs-auto-snapshot` keeping **14 dailies**, taken by `cron.daily`
between roughly 03:22 and 03:52. The 02:00 schedule is deliberate — it must finish before
that window, or a snapshot captures a half-written backup as that day's history. It also
staggers against the VPS backup, which pushes into this same pool at 01:00.
Older copies are under the hidden snapshot dir (`snapdir=hidden`, so `ls` will not show it,
but you can `cd` in):
ls /home/gib/Public/Server_Backup/.zfs/snapshot/
ls /home/gib/Public/Server_Backup/.zfs/snapshot/<snap>/Containers/server.gib/current/
The `03-16-2026` and `03-31-2026` directories are leftovers from the old manual script.
## Why this runs as root
This box runs Docker, so most container data under `~/Server` is root-owned. The old script
used `sudo rsync`, which worked but meant it could never be automated without a sudoers
rule. The replacement runs as root via `server-backup.service`, a systemd **system** unit —
already root, so ownership is preserved natively and **no sudoers entry is needed at
runtime**. If you see `/etc/sudoers.d/99-claude-setup` on this machine, it was temporary
setup scaffolding and should be deleted.
(This differs from the VPS, which is rootless podman and needs `rsync --fake-super` to carry
subuid ownership through. Nothing like that is needed here.)
The script lives at `/usr/local/sbin/server-backup`, deliberately **not** in `~/Server` — a
root-executed script inside a user-writable directory is a footgun. `~/Server/backup` is a
thin wrapper that triggers the systemd unit so manual runs behave identically to nightly ones.
## What is in a backup
| Path | What it is |
|---|---|
| `Media/`, `Frigate/`, `Home/`, `OpenWebUI/`, ... | Raw bind-mount data per service |
| `_backup_state/sqlite/<original path>` | Online `.backup` of every SQLite DB — **authoritative** |
| `_backup_state/convex/<container>.zip` | `npx convex export` snapshot of each SQLite-backed Convex deployment (gbrown) — **authoritative** |
> **Use `_backup_state/` for databases, not the raw copies.** Containers keep running during
> the backup, so the raw files are crash-consistent at best. The dumps use SQLite's online
> backup API and are what will actually restore cleanly. That covers the ones that matter:
> Plex's `com.plexapp.plugins.library.db` (watch history, ratings), Jellyfin, Frigate, Home
> Assistant, OpenWebUI, Audiobookshelf, and every *arr config.
>
> **Convex is the exception.** It writes to its SQLite file constantly, so a raw `.backup`
> never completes. Its `_backup_state/convex/` export is the restore source, and the raw
> `db.sqlite3` is not dumped at all.
## What is deliberately NOT backed up
- **`Agents/Gitea/volumes/runner-{1,2,3}/dind` — ~203 GB.** Docker-in-Docker containerd
image layers for the CI runners. Pure cache that churns constantly; a rebuilt runner just
re-pulls it. The parts that matter — `runner-N/data/.runner` (the 648-byte registration
token) and `certs/` — **are** backed up.
> Measured as root. A plain `du` as `gib` reports these as ~13 MB because gib cannot
> descend into them. Do not trust non-root sizes here.
- Docker **named volumes** (`/var/lib/docker/volumes`, 47 in use, ~61 GB): Sentry (13) and
Plausible (4) were consciously excluded — Sentry's event data is not worth restoring and
Plausible had nothing in it. Romm looked like a gap but is not: its anonymous `/romm`
volume is only a parent directory, with `assets/`, `config/` and `resources/` bind-mounted
from `~/Server` and `library/` from the pool.
- **Nextcloud AIO** — has its own borg backup at `~/Public/Nextcloud/Backups`, run by
hand from the AIO admin UI. Last run 2026-09-17 (finished 09-18). Runs are slow and
AIO stops the Nextcloud containers while one is going.
- `node_modules`, build output, `*.log`, `cache`/`tmp` dirs, `*.mp4`/`*.mkv`.
## Restoring
Stop the stack first so nothing overwrites what you restore:
cd ~/Server/<service> && docker compose down # per service, or stop docker entirely
Pull it back — as root, so ownership is restored:
sudo rsync -a --numeric-ids --delete \
/home/gib/Public/Server_Backup/Containers/server.gib/current/ \
/home/gib/Server/
For an older day, use a snapshot path instead:
/home/gib/Public/Server_Backup/.zfs/snapshot/<snap>/Containers/server.gib/current/
Then put the databases back from the dumps. For each one, stop the owning container, copy the
dump over the live file, and match the original ownership. Plex, for example:
docker compose -f ~/Server/Media/compose.yml stop plex
D=~/Server/_backup_state/sqlite/Media/volumes/plex/config/Library/Application\ Support/Plex\ Media\ Server/Plug-in\ Support/Databases
L=~/Server/Media/volumes/plex/config/Library/Application\ Support/Plex\ Media\ Server/Plug-in\ Support/Databases
sudo cp "$D/com.plexapp.plugins.library.db" "$L/"
sudo chown --reference="$L/com.plexapp.dlna.db" "$L/com.plexapp.plugins.library.db"
docker compose -f ~/Server/Media/compose.yml start plex
`chown --reference` copies ownership from a neighbouring file, which saves looking up what
uid each container expects.
For Convex, start the backend (it can be empty) and import the export through it. This
replaces every table with the snapshot:
Z=~/Server/_backup_state/convex/gbrown-backend.zip
KEY=$(docker exec gbrown-backend ./generate_admin_key.sh | tail -1)
docker run --rm -it --network nginx-bridge -v "$Z:/backup.zip:ro" \
-e CONVEX_SELF_HOSTED_URL=http://gbrown-backend:3210 -e CONVEX_SELF_HOSTED_ADMIN_KEY="$KEY" \
docker.io/library/node:22-alpine sh -ec 'mkdir /w && cd /w && echo "{}" > package.json &&
npm install --silent convex && npx convex import --replace-all /backup.zip'
Functions and schema are not in the export. Redeploy them from the app's repo as usual.
After a full restore the CI runners will have no `dind` storage. That is fine — they re-pull
images on the next job. The registration in `runner-N/data/.runner` is intact, so they
reconnect to Gitea without re-registering.
## Checking it is working
systemctl list-timers server-backup.timer
systemctl status server-backup.service
tail -40 ~/Server/logs/backup.log
A healthy run ends with `=== backup complete ===`. Watch for:
- `ZFS history OK` — confirms snapshots are still being taken. If this ever says **WARNING**,
versioning is broken and `current/` is just a mirror: a mistake would propagate with
nothing to roll back to. Fix `zfs-auto-snapshot` before anything else.
- `rsync exit 24` is a NOTE, not a failure — a file changed mid-transfer, normal on a live box.
Optional: put an uptime-kuma push URL in `~/Server/.backup-heartbeat-url` and successful runs
will ping it, turning a silent failure into a down monitor.
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Manual trigger for the daily backup.
#
# The real script lives at /usr/local/sbin/server-backup and runs as ROOT via
# server-backup.service. It is kept out of ~/Server on purpose: ~/Server is
# user-writable, and a root-executed script sitting in a user-writable directory
# is an obvious footgun. Run it through systemd so a manual run behaves exactly
# like the nightly one (same environment, same logging, same lock).
#
# status: systemctl status server-backup.service
# log: tail -f ~/Server/logs/backup.log
# timer: systemctl list-timers server-backup.timer
# restore: see ~/Server/RESTORE.md
exec sudo systemctl start --wait server-backup.service
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Weekly copy of the Media pool to the Backup pool
Documentation=file:///home/gib/Server/RESTORE.md
After=zfs-mount.service
Wants=zfs-mount.service
RequiresMountsFor=/home/gib/Public /home/gib/.local/share/Backup
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/media-backup
# 8.6 TB on the first run, deltas after. Generous, but bounded so a hung rsync
# fails loudly instead of overlapping next week's fire.
TimeoutStartSec=24h
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=multi-user.target
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Weekly Media -> Backup pool copy
[Timer]
# Saturday 05:00. Clear of: the cron.daily zfs-auto-snapshot window (03:22-03:52),
# timeshift (04:00), arr-queue-reconcile (04:31), and both ~/Server backups (01:00, 02:00).
# Saturday specifically because Monday carries the Media scrub and Sunday has
# raid-check (01:00), docker-prune (05:33) and dind-prune (06:03).
# gib also runs Nextcloud's borg by hand roughly monthly and is, in his words,
# not going to be doing that at 5 AM on a Saturday -- so the borg lock should be free.
OnCalendar=Sat *-*-* 05:00:00
RandomizedDelaySec=15m
Persistent=true
Unit=media-backup.service
[Install]
WantedBy=timers.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Back up /home/gib/Server to the ZFS pool
Documentation=file:///home/gib/Server/RESTORE.md
# The destination is on ZFS; do not run before the pool is up.
After=zfs-mount.service docker.service
Wants=zfs-mount.service
RequiresMountsFor=/home/gib/Public
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/server-backup
TimeoutStartSec=8h
# This box is a daily driver running 145 containers. Stay out of the way.
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Daily backup of /home/gib/Server to the ZFS pool
[Timer]
# MUST finish before cron.daily fires zfs-auto-snapshot (~03:22-03:52); a snapshot that
# catches a running rsync stores a partial backup as that day's history.
# 02:00 also staggers against the VPS backup, which pushes into this same pool at 01:00.
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=10m
Persistent=true
Unit=server-backup.service
[Install]
WantedBy=timers.target
+1 -1
View File
@@ -92,7 +92,7 @@ list_units() {
# services therefore resolve no images and are skipped without a log line.
#
# Image tags frequently reference variables from the service's .env, e.g.
# ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
# ghcr.io/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
+1789
View File
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,5 @@
[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
+1
View File
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Beszel
After=network-online.target podman.socket
Wants=network-online.target
+2 -2
View File
@@ -32,7 +32,7 @@ services:
restart: unless-stopped
bill-tracker-backend:
image: ${CONVEX_BACKEND_IMAGE:-ghcr.io/get-convex/convex-backend@sha256:1f2044e3eac463ac78973b136c0baf72d4ada602611d853d6f99f280e29e0a98}
image: ${CONVEX_BACKEND_IMAGE:-ghcr.io/convex/convex-backend@sha256:207cf95def42f01614b6e3af0af5c20d0dcaa5ff2adf258ee7bc9555576644fa}
container_name: bill-tracker-backend
hostname: bill-tracker-backend
domainname: api.billtracker.gbrown.org
@@ -56,7 +56,7 @@ services:
stop_signal: SIGINT
bill-tracker-dashboard:
image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/get-convex/convex-dashboard@sha256:284a2638e0c1a4ec0c2327d8219776f3a426ca5824b81686ae4d9454dc0ce8ed}
image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/convex/convex-dashboard@sha256:a55bf8abca1332f787bc4d4a79850182175027be7b70aa230be4389a1d3268e2}
container_name: bill-tracker-dashboard
hostname: bill-tracker-dashboard
domainname: dashboard.billtracker.gbrown.org
+2 -2
View File
@@ -29,7 +29,7 @@ services:
restart: unless-stopped
convexmonorepo-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
image: ghcr.io/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}
@@ -58,7 +58,7 @@ services:
stop_signal: SIGINT
convexmonorepo-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
image: ghcr.io/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}}
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: iSponsorBlockTV
After=network-online.target podman.socket
Wants=network-online.target
@@ -1,4 +1,5 @@
[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
+3 -3
View File
@@ -19,7 +19,7 @@ services:
- 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_CONVEX_URL=${CONVEX_SERVER_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}
@@ -33,7 +33,7 @@ services:
stdin_open: true
restart: unless-stopped
lashaddict-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
image: ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
@@ -67,7 +67,7 @@ services:
stop_grace_period: 10s
stop_signal: SIGINT
lashaddict-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
image: ghcr.io/convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
+1
View File
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: N8n
After=network-online.target podman-postgresql.service
Wants=network-online.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Nginx Proxy Manager
After=network-online.target podman-postgresql.service
Wants=network-online.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Portainer
After=network-online.target
Wants=network-online.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: PostgreSQL
After=network-online.target
Wants=network-online.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Rustdesk
After=network-online.target podman.socket
Wants=network-online.target
+2 -2
View File
@@ -64,7 +64,7 @@ services:
security_opt:
- label:disable
spoon-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
image: ghcr.io/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}
@@ -93,7 +93,7 @@ services:
stop_grace_period: 10s
stop_signal: SIGINT
spoon-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
image: ghcr.io/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}}
+2 -2
View File
@@ -29,7 +29,7 @@ services:
restart: unless-stopped
stpeteit-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
image: ghcr.io/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}
@@ -58,7 +58,7 @@ services:
stop_signal: SIGINT
stpeteit-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
image: ghcr.io/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}}
+1
View File
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Tools
After=network-online.target podman.socket
Wants=network-online.target
+1
View File
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Uptime
After=network-online.target
Wants=network-online.target
+5 -2
View File
@@ -8,14 +8,17 @@ 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
RUSTFS_ACCESS_KEY=CHANGE_ME
RUSTFS_SECRET_KEY=CHANGE_ME
S3_COMPATIBLE_API_URL=https://storage.usesend.gbrown.org
S3_COMPATIBLE_PUBLIC_URL=https://storage.usesend.gbrown.org/unsend
S3_COMPATIBLE_BUCKET=unsend
SMTP_HOST=smtp.usesend.gbrown.org
SMTP_USER=usesend
USESEND_PORT=3000
+39
View File
@@ -0,0 +1,39 @@
# TLS certificate for the UseSend SMTP proxy
The `usesend/smtp-proxy` image ships a default certificate that **cannot complete a
TLS 1.3 handshake** — OpenSSL rejects it with `no suitable signature algorithm`.
Because the proxy also refuses `AUTH` until `STARTTLS` has succeeded, that default
cert made *all* inbound SMTP impossible. These files replace it.
## What lives in certs/
`fullchain.pem` and `privkey.pem`, copied from NPM's Let's Encrypt store:
~/Server/Nginx_Proxy_Manager/volumes/letsencrypt/live/npm-43/
That certificate carries `*.usesend.gbrown.org`, which is what lets clients validate
the proxy as `smtp.usesend.gbrown.org`. A wildcard matches exactly one label, so
`*.gbrown.org` alone would not cover it.
Clients reach that name two ways, neither of them public DNS:
- containers on `nginx-bridge` get the network alias set in `compose.yml`
- LAN and home-server clients get a UniFi DNS record pointing it at `192.168.2.2`
## Why copies instead of a bind mount
NPM mounts that letsencrypt tree with `:Z`, which gives it a *private* SELinux MCS
category. A second container mounting the same path would relabel it and the two
would fight over it. Copying keeps the stacks independent.
## Keeping the copies fresh
NPM renews on its own schedule, so `usesend-cert-refresh.timer` runs
`refresh-smtp-certs` daily. It copies the cert when NPM's fingerprint changes,
refuses a half-renewed cert/key pair, and restarts the proxy.
If the copies ever expire, every app sending mail through the proxy fails at once,
with a TLS error rather than anything that mentions certificates. Check with:
openssl x509 -in ~/Server/UseSend/certs/fullchain.pem -noout -enddate
systemctl --user list-timers usesend-cert-refresh.timer
+69 -30
View File
@@ -21,25 +21,69 @@ services:
tty: true
stdin_open: true
restart: unless-stopped
minio:
image: minio/minio
# S3-compatible storage for images uploaded in the email editor, following
# upstream's docker/prod/compose.yml (RustFS replaced MinIO, whose images are
# no longer published). The browser uploads straight to S3_COMPATIBLE_API_URL
# and sent emails link to S3_COMPATIBLE_PUBLIC_URL, so NPM proxies
# storage.usesend.gbrown.org -> http://usesend-storage:9000. No host ports:
# the console (9001) is only reachable from inside nginx-bridge.
# Pinned like upstream. Bump the tag deliberately when upstream does.
rustfs:
image: docker.io/rustfs/rustfs:1.0.4
container_name: usesend-storage
hostname: usesend-storage
networks:
- nginx-bridge
env_file: .env
# RustFS runs as uid 10001; keep-id keeps ./volumes/rustfs gib-owned (AGENTS.md §3.2)
userns_mode: keep-id:uid=10001,gid=10001
security_opt:
- no-new-privileges:true
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
labels:
com.centurylinklabs.watchtower.enable: 'true'
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?err}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:?err}
RUSTFS_ADDRESS: 0.0.0.0:9000
RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001
RUSTFS_CONSOLE_ENABLE: 'true'
RUSTFS_OBS_LOGGER_LEVEL: error
TZ: America/New_York
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
- ./volumes/rustfs:/data:Z
command: ["/data"]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
interval: 20s
timeout: 10s
retries: 5
start_period: 40s
restart: unless-stopped
# One-shot: creates the bucket, allows browser uploads from the app (CORS) and
# makes objects publicly readable so images load in sent emails. Reruns on
# every unit start, which is harmless.
rustfs-init:
image: docker.io/amazon/aws-cli:2.34.30
container_name: usesend-storage-init
networks:
- nginx-bridge
entrypoint: ["/bin/sh", "-ec"]
environment:
AWS_ACCESS_KEY_ID: ${RUSTFS_ACCESS_KEY:?err}
AWS_SECRET_ACCESS_KEY: ${RUSTFS_SECRET_KEY:?err}
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: 'true'
BUCKET: ${S3_COMPATIBLE_BUCKET:-unsend}
APP_ORIGIN: ${USESEND_URL:?err}
depends_on:
rustfs:
condition: service_healthy
command:
- |
s3="aws --endpoint-url http://usesend-storage:9000 s3api"
$$s3 head-bucket --bucket "$$BUCKET" >/dev/null 2>&1 || $$s3 create-bucket --bucket "$$BUCKET"
$$s3 put-bucket-cors --bucket "$$BUCKET" --cors-configuration \
'{"CORSRules":[{"AllowedOrigins":["'"$$APP_ORIGIN"'"],"AllowedMethods":["GET","HEAD","PUT"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"],"MaxAgeSeconds":3600}]}'
$$s3 put-bucket-policy --bucket "$$BUCKET" --policy \
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::'"$$BUCKET"'/*"]}]}'
restart: 'no'
smtp-server:
image: usesend/smtp-proxy:latest
container_name: usesend-smtp-server
@@ -47,26 +91,15 @@ services:
networks:
nginx-bridge:
# The proxy REQUIRES STARTTLS before it will accept AUTH, so clients must
# reach it by a name the certificate actually covers. The wildcard is
# *.gbrown.org, which matches ONE label -- hence smtp-usesend, not
# smtp.usesend.
# reach it by a name the certificate actually covers: smtp.usesend.gbrown.org,
# validated by the *.usesend.gbrown.org SAN on npm-43 (see certs/README.md).
#
# Resolution: podman's aardvark-dns answers this name authoritatively for
# containers ON nginx-bridge, handing back the container IP. NPM is not
# involved at all -- it only reverse-proxies HTTP(S), and SMTP ports are
# never published to the host, so the proxy is unreachable from outside.
#
# NOTE: there IS a wildcard *.gbrown.org record in Cloudflare, so this name
# also resolves publicly (to Cloudflare, which serves nothing for it). The
# consequence: if this container is STOPPED, aardvark-dns stops answering,
# the lookup falls through to that public record, and clients dial
# Cloudflare on 587 and hang until timeout instead of failing fast.
# involved at all -- it only reverse-proxies HTTP(S). Cloudflare is not
# involved either: there is deliberately no public DNS record for this name,
# so if the container is stopped, lookups fail fast with NXDOMAIN.
aliases:
# Both names resolve to this container and BOTH validate against npm-43,
# which carries *.gbrown.org and *.usesend.gbrown.org. smtp.usesend is the
# name UseSend's own UI advertises and is the target to standardise on;
# smtp-usesend stays until every consumer has been moved across.
- smtp-usesend.gbrown.org
- smtp.usesend.gbrown.org
env_file: .env
environment:
@@ -83,8 +116,8 @@ services:
# brute-force it, and contradicts the Cloudflare-only posture in §7.
# The `Home` wg interface sits in firewalld's `trusted` zone, so no rule is needed.
# Rootless can bind these because net.ipv4.ip_unprivileged_port_start = 80.
# Consumers on the home server reach it as smtp-usesend.gbrown.org (UniFi DNS maps
# that name to 192.168.2.2) so the *.gbrown.org cert still validates by hostname.
# Consumers on the home server reach it as smtp.usesend.gbrown.org (a UniFi DNS record
# maps that name to 192.168.2.2) so the cert still validates by hostname.
- "192.168.2.2:587:587"
- "192.168.2.2:465:465"
volumes:
@@ -121,6 +154,12 @@ services:
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
- SMTP_HOST=${SMTP_HOST:-smtp.usesend.gbrown.org}
- SMTP_USER=${SMTP_USER:-usesend}
# Editor image uploads (the rustfs service above). Uploads are disabled if any is unset.
- S3_COMPATIBLE_ACCESS_KEY=${RUSTFS_ACCESS_KEY:?err}
- S3_COMPATIBLE_SECRET_KEY=${RUSTFS_SECRET_KEY:?err}
- S3_COMPATIBLE_API_URL=${S3_COMPATIBLE_API_URL:?err}
- S3_COMPATIBLE_PUBLIC_URL=${S3_COMPATIBLE_PUBLIC_URL:?err}
- S3_COMPATIBLE_BUCKET=${S3_COMPATIBLE_BUCKET:-unsend}
labels:
com.centurylinklabs.watchtower.enable: 'true'
depends_on:
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Keep the UseSend SMTP proxy's TLS certificate in step with NPM's Let's Encrypt store.
#
# The proxy refuses AUTH until STARTTLS succeeds, so an expired or mismatched cert
# takes down ALL outbound mail for every app that sends through it -- and it fails
# with a bare TLS error that never mentions certificates. Hence this timer.
#
# Copies rather than bind-mounts because NPM mounts its letsencrypt tree with :Z
# (private SELinux category) and two consumers on one path fight over the label.
#
# Uses npm-43 because it carries *.usesend.gbrown.org, the SAN that validates
# smtp.usesend.gbrown.org. A wildcard matches exactly ONE label, so *.gbrown.org
# alone would not cover it.
set -euo pipefail
SRC=$HOME/Server/Nginx_Proxy_Manager/volumes/letsencrypt/live/npm-43
DST=$HOME/Server/UseSend/certs
CONTAINER=usesend-smtp-server
fp() { openssl x509 -in "$1" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2; }
certpub(){ openssl x509 -in "$1" -noout -pubkey 2>/dev/null | openssl sha256 | awk '{print $2}'; }
keypub() { openssl pkey -in "$1" -pubout 2>/dev/null | openssl sha256 | awk '{print $2}'; }
[ -r "$SRC/fullchain.pem" ] && [ -r "$SRC/privkey.pem" ] || {
echo "source cert unreadable under $SRC -- did NPM move it?" >&2; exit 1; }
src_fp=$(fp "$SRC/fullchain.pem")
dst_fp=$(fp "$DST/fullchain.pem" 2>/dev/null || true)
if [ -n "$dst_fp" ] && [ "$src_fp" = "$dst_fp" ]; then
echo "cert unchanged (${src_fp:0:17}...); nothing to do"
exit 0
fi
# Refuse to install a half-renewed pair: certbot writes the two files separately, so
# a run that lands between them would leave the proxy with a key that is not its cert.
if [ "$(certpub "$SRC/fullchain.pem")" != "$(keypub "$SRC/privkey.pem")" ]; then
echo "source cert and key do not match -- renewal probably mid-flight, retrying next run" >&2
exit 0
fi
echo "cert changed (${dst_fp:0:17}... -> ${src_fp:0:17}...), installing"
# -L dereferences: live/ entries are symlinks into ../../archive/, outside this copy.
cp -L "$SRC/fullchain.pem" "$DST/fullchain.pem.new"
cp -L "$SRC/privkey.pem" "$DST/privkey.pem.new"
chmod 644 "$DST/fullchain.pem.new"
chmod 640 "$DST/privkey.pem.new"
mv -f "$DST/fullchain.pem.new" "$DST/fullchain.pem"
mv -f "$DST/privkey.pem.new" "$DST/privkey.pem"
echo "restarting $CONTAINER"
podman restart "$CONTAINER" >/dev/null
echo "done; cert now valid until $(openssl x509 -in "$DST/fullchain.pem" -noout -enddate | cut -d= -f2)"
@@ -0,0 +1,15 @@
[Unit]
Description=Refresh UseSend SMTP proxy TLS certificate from NPM's Let's Encrypt store
Documentation=file://%h/Server/UseSend/README.md
After=podman-usesend.service
[Service]
Type=oneshot
ExecStart=%h/Server/UseSend/refresh-smtp-certs
# The script calls `podman restart`, which leaves conmon and the container's
# supervising processes inside THIS unit's cgroup. With the default KillMode,
# systemd waits for that cgroup to empty and the unit times out and reports
# `failed` even though ExecStart returned 0 and the cert was installed correctly.
# KillMode=process makes systemd track only the script itself.
KillMode=process
TimeoutStartSec=300
@@ -0,0 +1,12 @@
[Unit]
Description=Daily check for a renewed UseSend SMTP proxy certificate
[Timer]
OnCalendar=daily
# Spread the load and avoid landing exactly when certbot is writing.
RandomizedDelaySec=2h
# Catch up after the host has been off; the cert does not care that we were asleep.
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Vaultwarden
After=network-online.target podman.socket
Wants=network-online.target
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
#
# Daily backup of ~/Server to the home server, as hardlinked dated snapshots.
#
# WHAT THIS FIXES vs. the old version (2026-09-17):
# 1. OWNERSHIP. Rootless podman leaves most container data owned by subuids
# (gitea = 525288, postgres data = the userns root mapping). Plain `rsync -a`
# running as gib cannot chown on the far side, so every previous backup landed
# 100% owned by uid 1000. Restoring that gives you the right bytes with the wrong
# owners: postgres refuses to start unless PGDATA is owned correctly, and gitea
# fails on its own data dir. Fixed with `-M--fake-super`, which makes the REMOTE
# rsync stash mode/uid/gid in a user.rsync.%stat xattr (verified working on the
# home server's ZFS). Restore with --fake-super on the read side to put them back.
# 2. CONSISTENCY. 13 SQLite DBs and a live postgres were copied hot, mid-write. Now
# dumped properly first (sqlite3 .backup / pg_dumpall) into _backup_state/.
# 3. SPACE / HISTORY. Every run was a full ~19G copy into a new dated directory.
# Now it rsyncs into ONE stable directory, `current/`, and versioning is left to
# the home server: Media/Server_Backup is its own ZFS dataset with zfs-auto-snapshot
# keeping 14 dailies. ZFS snapshots are block-level, so a database that changes a
# few pages costs a few pages -- not a whole new copy the way dated dirs (or even
# rsync --link-dest hardlinks, which are per-file) would.
#
# Containers keep running throughout. That is safe *because* of the dumps above --
# the dumps, not the raw data dirs, are the authoritative restore path.
#
# See RESTORE.md for how to bring this back on a new machine.
set -uo pipefail
REMOTE_HOST="[email protected]"
REMOTE_ROOT="/home/gib/Public/Server_Backup/Containers/lilman.gib"
SOURCE="$HOME/Server/"
STATE_DIR="$HOME/Server/_backup_state"
DEST_NAME="current" # One stable directory. ZFS snapshots provide history.
LOG_DIR="$HOME/Server/logs"
LOG="$LOG_DIR/backup.log"
LOCK="${XDG_RUNTIME_DIR:-/tmp}/server-backup.lock"
# Versioning is ZFS's job, not ours -- see the header. We only sanity-check that it is
# actually still happening, so this can never silently degrade into a single mirror with
# no history. Warn if the newest snapshot of the dataset is older than this.
REMOTE_DATASET="Media/Server_Backup"
SNAPSHOT_MAX_AGE_HOURS=48
mkdir -p "$LOG_DIR"
log() { printf '%s %s\n' "$(date +'%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"; }
die() { log "FATAL: $*"; exit 1; }
# Refuse to overlap with a still-running backup rather than corrupting a snapshot.
exec 9>"$LOCK" || die "cannot open lock $LOCK"
flock -n 9 || die "another backup is already running"
EXCLUSIONS=(
# --- build noise -------------------------------------------------------
# NOTE: these are bare patterns, so they match at ANY depth. Today they only
# catch genuine scratch dirs (checked 2026-09-17). If you add a service whose
# real data lives in a dir called cache/build/dist/tmp, it will be silently
# skipped -- re-check this list when adding services.
"node_modules" ".git" "*.log" "*.tmp" ".cache" "cache" "tmp" "temp"
".npm" ".yarn" "dist" "build" "target" ".venv" "venv" "__pycache__"
"*.pyc" ".DS_Store" "Thumbs.db" "vendor" "*.mp4" "*.mkv"
# --- unreadable to gib, and genuinely disposable ------------------------
# Redis/valkey cache dumps: mode 0600 under a subuid because the container
# chowns its own data. Pure cache (usesend + infisical), regenerated on boot.
"dump.rdb"
# Gitea runs WITHOUT userns_mode (s6 needs container-root -- see AGENTS.md §3),
# so these are subuid-owned and mode 0700. Excluding them is what lets this run
# without sudo. Nothing here is needed to restore gitea:
# sessions/ ephemeral web logins; worst case everyone logs in again
# issues.bleve search index, rebuilt from the DB on demand
# Gitea/ssh a 25-byte `environment` file for the git shell
#
# ⚠️ The SSH HOST KEYS are NOT here -- they live in Gitea/volume/ssh, are
# gib-owned, and DO get backed up. Losing those = host-key mismatch for every client.
"sessions" "issues.bleve" "Gitea/ssh"
# Mode 0600 under subuid 525288, so unreadable to gib -- this single file is what
# made every previous run exit 23. It is NOT disposable (it holds the MCP bridge's
# registered OAuth clients), so it is captured via `podman exec` into
# _backup_state/files/ instead of being read off disk.
"BillTracker/data/mcp/bridge.json"
# Stirling-PDF writes this 0600 under subuid 525287 (since its 2026-09-25 update),
# which made every run since exit 23. Excluded rather than captured: losing it only
# means re-entering any credentials stored in Stirling-PDF.
"pdf-tools/extraConfigs/credential-encryption.key"
)
EXCLUDE_ARGS=()
for e in "${EXCLUSIONS[@]}"; do EXCLUDE_ARGS+=( "--exclude=$e" ); done
# ---------------------------------------------------------------------------
# Phase 1: consistent dumps of everything transactional.
# ---------------------------------------------------------------------------
dump_state() {
log "Dumping databases to _backup_state/ ..."
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR"/{postgres,sqlite,files}
printf 'Generated %s by ~/Server/backup. Authoritative restore source -- see RESTORE.md\n' \
"$(date -Is)" > "$STATE_DIR/README"
# Live postgres (serves npm, authentik, usesend, ... -- pg_dumpall gets all of them).
if podman ps --format '{{.Names}}' | grep -qx postgresql; then
if podman exec postgresql pg_dumpall -U npm 2>>"$LOG" | gzip -9 > "$STATE_DIR/postgres/all.sql.gz"; then
log " postgres: $(du -h "$STATE_DIR/postgres/all.sql.gz" | cut -f1)"
else
log " WARNING: pg_dumpall failed -- raw pg_data is still copied, but is a hot copy"
fi
else
log " WARNING: postgresql container not running, skipping pg_dumpall"
fi
# SQLite: .backup uses the online backup API, so this is safe while containers write.
local n=0
while IFS= read -r db; do
# *.db also matches stirling-pdf's H2 database, which is not SQLite. Check magic.
head -c 16 "$db" 2>/dev/null | grep -q "SQLite format 3" || continue
local rel="${db#"$HOME"/Server/}" out
out="$STATE_DIR/sqlite/$rel"
mkdir -p "$(dirname "$out")"
if sqlite3 -cmd ".timeout 30000" "$db" ".backup '$out'" 2>>"$LOG"; then n=$((n+1)); else log " WARNING: sqlite dump failed: $rel"; fi
done < <(find "$HOME/Server" -path "$STATE_DIR" -prune -o \
-type f \( -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' \) -print 2>/dev/null)
log " sqlite: $n databases"
# Files gib cannot read off disk, pulled through their container instead.
if podman ps --format '{{.Names}}' | grep -qx bill-tracker-mcp; then
podman exec bill-tracker-mcp cat /data/bridge.json \
> "$STATE_DIR/files/BillTracker--data--mcp--bridge.json" 2>>"$LOG" \
&& log " bridge.json captured" \
|| log " WARNING: could not capture bridge.json"
fi
}
# ---------------------------------------------------------------------------
# Phase 2: confirm ZFS is still providing history.
#
# We deliberately overwrite one directory instead of keeping dated copies, which
# means ZFS snapshots are the ONLY thing standing between us and "a mirror of a
# mistake". If auto-snapshot ever stops, that must be loud, not silent.
# ---------------------------------------------------------------------------
check_zfs_history() {
local newest now age_h
newest=$(ssh -o BatchMode=yes "$REMOTE_HOST" \
"zfs list -H -p -o creation -t snapshot -s creation -d 1 '$REMOTE_DATASET' 2>/dev/null | tail -1" || true)
if [ -z "$newest" ]; then
log " ⚠️ WARNING: no ZFS snapshots on $REMOTE_DATASET -- this backup has NO history."
log " It is a plain mirror right now. Check zfs-auto-snapshot on the home server."
return
fi
now=$(date +%s)
age_h=$(( (now - newest) / 3600 ))
if [ "$age_h" -gt "$SNAPSHOT_MAX_AGE_HOURS" ]; then
log " ⚠️ WARNING: newest ZFS snapshot is ${age_h}h old (limit ${SNAPSHOT_MAX_AGE_HOURS}h) -- versioning may be broken."
else
log " ZFS history OK (newest snapshot ${age_h}h old, 14 dailies retained)"
fi
}
# ---------------------------------------------------------------------------
# Phase 3: mirror.
# ---------------------------------------------------------------------------
log "=== backup start -> $REMOTE_HOST:$REMOTE_ROOT/$DEST_NAME ==="
ssh -o BatchMode=yes -o ConnectTimeout=15 "$REMOTE_HOST" "mkdir -p '$REMOTE_ROOT/$DEST_NAME'" \
|| die "cannot reach $REMOTE_HOST"
dump_state
check_zfs_history
# --delete/--delete-excluded keep `current` an exact mirror of the source. Anything
# removed here still exists in yesterday's ZFS snapshot, so this is not destructive.
rsync -a --numeric-ids -M--fake-super --delete --delete-excluded \
--human-readable --stats \
"${EXCLUDE_ARGS[@]}" \
--rsync-path="mkdir -p '$REMOTE_ROOT/$DEST_NAME' && rsync" \
"$SOURCE" "$REMOTE_HOST:$REMOTE_ROOT/$DEST_NAME/" 2>&1 | tee -a "$LOG"
status=${PIPESTATUS[0]}
# 24 = "source files vanished during transfer". Normal on a live system, not a failure.
if [ "$status" -ne 0 ] && [ "$status" -ne 24 ]; then
die "rsync failed with exit $status -- 'current' may be half-updated; yesterday's ZFS snapshot is still good"
fi
[ "$status" -eq 24 ] && log "NOTE: rsync exit 24 (files vanished mid-run) -- expected on a live system"
# Optional heartbeat so a silent failure cannot go unnoticed. Create the file with an
# uptime-kuma push URL in it and this pings on success only; absent, nothing happens.
if [ -r "$HOME/Server/.backup-heartbeat-url" ]; then
url=$(< "$HOME/Server/.backup-heartbeat-url")
curl -fsS -m 20 "$url" >/dev/null 2>&1 \
&& log "Heartbeat sent" || log "NOTE: heartbeat ping failed (backup itself was fine)"
fi
log "=== backup complete ==="
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Back up ~/Server to the home server
Documentation=file:///home/gib/Server/RESTORE.md
After=network-online.target
Wants=network-online.target
# The backup reads podman-managed data and shells into containers for dumps.
RequiresMountsFor=/home/gib/Media
[Service]
Type=oneshot
ExecStart=%h/Server/backup
# A stalled rsync should fail loudly, not hang until the next timer fires.
TimeoutStartSec=6h
# This box is IO-bound before it is CPU-bound (~18%% avg60 pressure), so stay
# out of the way of the containers. best-effort/7 rather than idle, which can
# starve the transfer outright when the volume is busy.
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=default.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Daily backup of ~/Server to the home server
[Timer]
# MUST finish before the home server's cron.daily fires zfs-auto-snapshot, which has
# been landing between 03:22 and 03:52. If the snapshot catches a half-finished rsync,
# that day's history entry is a partial backup. 01:00 leaves >2h of margin; the first
# full run took well under that and daily deltas are far smaller.
OnCalendar=*-*-* 01:00:00
# Small jitter only -- must not push us into the snapshot window.
RandomizedDelaySec=10m
# If the VPS was down at 01:00, run once after it comes back rather than
# silently skipping a day.
Persistent=true
Unit=server-backup.service
[Install]
WantedBy=timers.target
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Post-reboot verification for the rootless podman stack.
# Run as gib. Compares against ~/premigration_backup_2026-08-10/post_migration_baseline.txt
set -uo pipefail
fail=0
ok() { printf ' \033[32mOK\033[0m %s\n' "$1"; }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); }
echo "== linger =="
[[ "$(loginctl show-user gib --property=Linger --value 2>/dev/null)" == "yes" ]] \
&& ok "Linger=yes" || bad "linger is OFF - user units will not start at boot"
echo "== unprivileged port start =="
p=$(sysctl -n net.ipv4.ip_unprivileged_port_start 2>/dev/null)
[[ "$p" == "80" ]] && ok "ip_unprivileged_port_start=80" \
|| bad "ip_unprivileged_port_start=$p (NPM cannot bind :80/:443)"
echo "== user units =="
for s in postgresql bang completeuphoria sierraandtyler uptime portainer beszel authentik tools isponsorblocktv vaultwarden infisical rustdesk nginx-proxy-manager gbrown-agent; do
st=$(systemctl --user is-active "podman-$s.service" 2>/dev/null)
[[ "$st" == "active" ]] && ok "$s" || bad "$s is $st"
done
echo "== containers =="
for c in postgresql nginx-proxy-manager uptime portainer bang completeuphoria sierraandtyler beszel beszel-agent authentik-server authentik-worker convertx it-tools pdf-tools bento-pdf iSponsorBlockTV vaultwarden infisical-redis infisical-backend rustdesk-server gbrown-agent; do
if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$c"; then ok "$c"; else bad "$c not running"; fi
done
echo "== postgres health =="
h=$(podman inspect postgresql --format '{{.State.Health.Status}}' 2>/dev/null)
[[ "$h" == "healthy" ]] && ok "postgres healthy" || bad "postgres health=$h"
echo "== endpoints =="
chk() { local n=$1 exp=$2; shift 2; c=$(curl -sk -o /dev/null -w '%{http_code}' "$@" 2>/dev/null)
[[ "$c" == "$exp" ]] && ok "$n -> $c" || bad "$n -> $c (expected $exp)"; }
# Admin ports moved to the WireGuard address 2026-08-12 (AGENTS.md §13.2).
# They no longer listen on localhost, so probe 192.168.2.2.
chk "NPM :81" 200 http://192.168.2.2:81
chk "NPM :80" 200 http://localhost:80
chk "NPM :443" 200 --resolve nginx.gibbyb.com:443:127.0.0.1 https://nginx.gibbyb.com
chk "Uptime" 302 http://192.168.2.2:3001
chk "Portainer" 200 https://192.168.2.2:9443
# ─────────────────────────────────────────────────────────────────────────────
# Boot-enablement check (added 2026-08-12)
#
# Five stacks were once found RUNNING but DISABLED — healthy in `podman ps`, yet they
# would have disappeared on the next reboot. Starting a stack and enabling it are
# separate actions; this catches the gap. See AGENTS.md §13.3.
# ─────────────────────────────────────────────────────────────────────────────
echo
echo "== boot-enablement =="
# podman's own units are meant to stay disabled; n8n is intentionally down.
# podman's own units stay disabled by design; n8n is intentionally down;
# podman-update.service is `static` (the timer activates it, so it is never enabled);
# watchtower was RETIRED 2026-08-17 and must stay disabled (AGENTS.md §13.15).
SKIP='podman-auto-update|podman-restart|podman-clean-transient|podman-kube@|podman-user-wait-network-online|podman-n8n|podman-update|podman-watchtower'
bad=0
for u in $(systemctl --user list-unit-files 'podman-*.service' --no-legend 2>/dev/null | awk '{print $1}'); do
echo "$u" | grep -qE "$SKIP" && continue
state=$(systemctl --user is-enabled "$u" 2>/dev/null)
if [ "$state" != "enabled" ]; then
echo " WILL NOT SURVIVE REBOOT: $u ($state) -> systemctl --user enable $u"
bad=$((bad+1))
fi
done
[ "$bad" -eq 0 ] && echo " all service units enabled"
echo
echo "== nightly image updates (replaces watchtower — AGENTS.md §13.15) =="
if [ "$(systemctl --user is-enabled podman-update.timer 2>/dev/null)" = "enabled" ]; then
echo " timer enabled, next: $(systemctl --user list-timers podman-update.timer --no-pager 2>/dev/null | awk 'NR==2{print $1,$2,$3,$4}')"
else
echo " WARNING: podman-update.timer is NOT enabled — nothing will update"
fi
if [ "$(systemctl --user is-active podman-watchtower.service 2>/dev/null)" = "active" ]; then
echo " WARNING: watchtower is RUNNING — it recreates containers outside their pod (§13.13)"
fi
# A container outside its compose pod is the signature of a watchtower-style recreate.
podless=""
for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
[ -z "$(podman inspect "$c" --format '{{.Pod}}' 2>/dev/null)" ] && podless="$podless $c"
done
[ -n "$podless" ] && echo " PODLESS (rebuild with systemctl --user restart):$podless" || echo " all containers in their pod"
echo
echo "== admin ports bound to 0.0.0.0 (should be 192.168.2.2 — AGENTS.md §13.2) =="
podman ps --format '{{.Names}} {{.Ports}}' | grep -E '0\.0\.0\.0:(81|80-81|3001|9443|5432)(->|-)' \
| sed 's/^/ EXPOSED: /' || echo " none"
echo
if [[ $fail -eq 0 ]]; then
printf '\033[32mALL CHECKS PASSED\033[0m\n'
else
printf '\033[31m%d CHECK(S) FAILED\033[0m\n' "$fail"
echo "Investigate: journalctl --user -u podman-<name>.service -n 50"
fi
exit $fail
+59 -21
View File
@@ -23,10 +23,13 @@
# failure mode than leaving two lines for them to paste once.
#
# opencode has no CLI for this either, but its config is plain JSON, so this
# stage merges two keys into it with jq: one mcp entry per server row, and the
# rules directory under instructions, because opencode reads ~/.claude/CLAUDE.md
# and ~/.claude/skills on its own but not ~/.claude/rules. Every other key in
# that file is left as it was.
# stage writes it whole from user/agents/opencode/config.json -- the tracked
# template carrying everything that is not a secret -- plus the MCP block built
# here from the server rows. The whole file is generated rather than merged into
# because the point of the template is that every machine ends up with the same
# opencode experience, and a merged file drifts the moment one machine is edited
# by hand. The output says so at the top, and anything worth changing goes in
# the template, where it reaches the other machines too.
set -euo pipefail
@@ -37,6 +40,8 @@ PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
MCP_DIR="$PANAMA_PATH/user/agents/mcp"
SERVERS="$MCP_DIR/servers"
ENV_FILE="$MCP_DIR/env"
TEMPLATE="$PANAMA_PATH/user/agents/opencode/config.json"
PANAMA_OLD="$PANAMA_PATH/config/old"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
DECISION="$STATE_DIR/user-content"
@@ -177,29 +182,62 @@ fi
$has_opencode || exit 0
# opencode reads opencode.jsonc or opencode.json, whichever this machine has.
# The file holds tokens once this runs, so it is written private and swapped in
# whole, the way ~/.claude.json already holds them for Claude Code.
# opencode reads opencode.jsonc. The file holds tokens once this runs, so it is
# written private and swapped in whole, the way ~/.claude.json already holds
# them for Claude Code.
OPENCODE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
opencode_config="$OPENCODE_DIR/opencode.jsonc"
[[ -e "$opencode_config" ]] || opencode_config="$OPENCODE_DIR/opencode.json"
rules="$HOME/.claude/rules/*.md"
mkdir -p "$OPENCODE_DIR"
current='{"$schema": "https://opencode.ai/config.json"}'
[[ -e "$opencode_config" ]] && current="$(cat "$opencode_config")"
# jq cannot read JSONC comments. A file that has them is left alone rather
# than rewritten without them.
if ! merged="$(jq --argjson mcp "$opencode_mcp" --arg rules "$rules" \
'.mcp = ((.mcp // {}) + $mcp) | .instructions = (((.instructions // []) - [$rules]) + [$rules])' \
<<<"$current" 2>/dev/null)"; then
warn "Could not parse $opencode_config as JSON; opencode MCP servers not written."
[[ -r "$TEMPLATE" ]] || { warn "No $TEMPLATE; opencode config not written."; exit 0; }
if ! jq -e . "$TEMPLATE" >/dev/null 2>&1; then
warn "$TEMPLATE is not valid JSON; opencode config not written."
exit 0
fi
# Nothing at the destination is destroyed, the promise link-user and link-skills
# make. A file this stage did not write is somebody's own work and goes to
# config/old/ under a name that says where it came from. One carrying the marker
# was written by an earlier run and is replaced in place, so re-running never
# buries its own output and config/old/ does not grow on every upgrade.
MARKER='generated by setup/scripts/link-mcp'
displace() {
local destination="$1" backup
[[ -e "$destination" || -L "$destination" ]] || return 0
if [[ -L "$destination" ]]; then
rm -f "$destination"
return 0
fi
grep -qF "$MARKER" "$destination" 2>/dev/null && return 0
backup="$PANAMA_OLD/mcp-$(printf '%s' "${destination#"$HOME"/}" | tr '/' '-')"
[[ -e "$backup" ]] && backup="$backup.$(date +%s)"
mv "$destination" "$backup"
log "Moved existing $destination to $backup"
}
mkdir -p "$OPENCODE_DIR" "$PANAMA_OLD"
# Two names for one file would leave opencode choosing between them, and the one
# this stage does not write would win or lose on discovery order.
displace "${opencode_config%.jsonc}.json"
displace "$opencode_config"
# The template is portable prose: it names the rules directory as ~/ so it does
# not carry this machine's login. opencode resolves a leading ~/ on its own, but
# expanding it here keeps the generated file readable the way a person would
# have written it by hand when they went looking for where the path came from.
merged="$(jq --argjson mcp "$opencode_mcp" --arg home "$HOME" \
'.mcp = $mcp
| .instructions = [(.instructions // [])[]
| if startswith("~/") then $home + .[1:] else . end]' \
<<<"$(cat "$TEMPLATE")")"
tmp="$(mktemp "$OPENCODE_DIR/.opencode-config.XXXXXX")"
{
printf '// %s\n' "$MARKER"
printf '// Edit user/agents/opencode/config.json in the Panama checkout instead.\n'
printf '// This file is replaced whenever ./install or panama update runs.\n'
printf '%s\n' "$merged"
} >"$tmp"
chmod 600 "$tmp"
printf '%s\n' "$merged" >"$tmp"
mv "$tmp" "$opencode_config"
log "opencode: $(jq -r 'length' <<<"$opencode_mcp") MCP servers and the rules written to $opencode_config."
log "opencode: $(jq -r 'length' <<<"$opencode_mcp") MCP servers and the template written to $opencode_config."
+1
View File
@@ -270,6 +270,7 @@ hermetic tests/setup/launcher-search-contract
hermetic tests/setup/lid-contract
hermetic tests/setup/migrations-contract
hermetic tests/setup/nautilus-extensions-contract
hermetic tests/setup/opencode-config-contract
hermetic tests/setup/package-lists-contract
# Provenance uses local signed fixtures and stubs every network/package operation.
hermetic tests/setup/package-provenance-contract
@@ -59,7 +59,10 @@ SELF_INSTALLED='^(bun|claude|codex|node|npm|pnpm)$'
# fresh machine to keep five aliases company. Where docker is absent the
# aliases fail by naming the missing command, which is the honest outcome.
# Anything added here needs that same property: absence must be loud.
OPTIONAL='^(docker)$'
# opencode is the other kind of optional: link-mcp probes for it with
# `command -v` and writes its config only where someone installed it with
# opencode's own installer, the same way it treats claude.
OPTIONAL='^(docker|opencode)$'
# Provided by a package swap rather than a list entry. ffmpeg cannot be a list
# entry: RPM Fusion's ffmpeg CONFLICTS with the ffmpeg-free that Fedora ships
+13 -9
View File
@@ -3,9 +3,12 @@
# 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
# * a compose.yml, linked into ~/Server along with any helper script
# * exactly one podman-*.service unit -- its filename is the unit's
# identity, so two would be an ambiguity and zero an unenableable service
# identity, so two would be an ambiguity and zero an unenableable service.
# Other *.service and *.timer files are helpers (a cert refresh, say)
# that enable links beside it; each helper service needs a timer or a
# unit that pulls it in, so an orphan helper is flagged
# * 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
@@ -31,20 +34,21 @@ for dir in "$containers_dir"/*/; do
[[ -f "$dir/compose.yml" ]] || note "$name has no compose.yml"
units=("$dir"/*.service)
units=("$dir"/podman-*.service)
if (( ${#units[@]} == 0 )); then
note "$name has no unit file"
note "$name has no podman-<name>.service unit"
continue
elif (( ${#units[@]} > 1 )); then
note "$name has ${#units[@]} unit files; its identity is ambiguous"
note "$name has ${#units[@]} podman-*.service units; 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
for helper in "$dir"/*.service; do
[[ "$helper" == "$unit" ]] && continue
[[ -f "${helper%.service}.timer" ]] \
|| note "$name's helper $(basename "$helper") has no matching .timer"
done
grep -q "^WorkingDirectory=%h/Server/$name\$" "$unit" \
|| note "$name's unit does not work in %h/Server/$name"
+33 -3
View File
@@ -6,9 +6,10 @@
# * 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
# * a completed enable links the tracked files (compose.yml, helper scripts,
# the unit and any helper units), reloads systemd, enables the unit and
# each helper timer, and never links .env.example
# * disable removes the units 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
@@ -55,6 +56,21 @@ RemainAfterExit=yes
[Install]
WantedBy=default.target
UNIT
cat >"$catalog/Example/refresh-example" <<'SH'
#!/usr/bin/env bash
SH
chmod +x "$catalog/Example/refresh-example"
cat >"$catalog/Example/example-refresh.service" <<'UNIT'
[Service]
Type=oneshot
ExecStart=%h/Server/Example/refresh-example
UNIT
cat >"$catalog/Example/example-refresh.timer" <<'UNIT'
[Timer]
OnCalendar=daily
[Install]
WantedBy=timers.target
UNIT
# The stub records every invocation; is-active answers 'inactive' so nothing
# here depends on a real systemd.
@@ -81,6 +97,8 @@ run() {
}
unit_link="$home/.config/systemd/user/podman-example.service"
helper_link="$home/.config/systemd/user/example-refresh.service"
timer_link="$home/.config/systemd/user/example-refresh.timer"
# ── enable stops on placeholders ─────────────────────────────────────────────
@@ -111,6 +129,14 @@ 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'
[[ -L "$home/Server/Example/refresh-example" ]] \
|| note 'enable did not link the helper script into ~/Server'
[[ -e "$home/Server/Example/.env.example" ]] \
&& note 'enable linked .env.example into ~/Server'
[[ -L "$helper_link" && -L "$timer_link" ]] \
|| note 'enable did not link the helper units'
grep -q 'systemctl --user enable --now example-refresh.timer' "$calls" \
|| note 'enable did not enable the helper timer'
# ── relink names a changed definition, restarts nothing ──────────────────────
@@ -136,6 +162,10 @@ run disable Example >/dev/null 2>&1 || note 'disable failed'
[[ -e "$unit_link" ]] \
&& note 'disable left the unit installed'
[[ -e "$helper_link" || -e "$timer_link" ]] \
&& note 'disable left a helper unit installed'
grep -q 'systemctl --user disable --now example-refresh.timer' "$calls" \
|| note 'disable did not stop the helper timer'
grep -q 'systemctl --user disable --now podman-example.service' "$calls" \
|| note 'disable did not stop the unit'
[[ -f "$home/Server/Example/.env" ]] \
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# The opencode config, generated rather than linked.
#
# Every other personal destination is a symlink or a one-time copy, so getting
# one wrong is visible and cheap to undo. This one is written whole by
# setup/scripts/link-mcp, and it is the only file in the set that carries
# secrets -- which is the reason it is generated at all. Three properties are
# worth pinning:
#
# 1. The tracked template is the source of truth and holds no secret. It is
# readable by anyone who finds the repository, so a token in it is a
# disclosure rather than a mistake.
# 2. The generation is faithful: the template's keys survive, the MCP block
# appears, and a leading ~/ is resolved so the output is readable.
# 3. Nothing is destroyed and nothing churns. A file somebody wrote by hand
# goes to config/old/, and running the stage again does not fill it up.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
linker="$repo_dir/setup/scripts/link-mcp"
template="$repo_dir/user/agents/opencode/config.json"
servers="$repo_dir/user/agents/mcp/servers"
findings=()
note() { findings+=("$1"); }
[[ -x "$linker" ]] || { printf 'opencode config contract: %s is not executable\n' "$linker" >&2; exit 1; }
# ── 1. The template holds no secret ──────────────────────────────────────────
if [[ ! -r "$template" ]]; then
note 'user/agents/opencode/config.json is missing, so nothing generates an opencode config'
else
jq -e . "$template" >/dev/null 2>&1 \
|| note 'user/agents/opencode/config.json is not valid JSON'
if grep -qE 'BEGIN [A-Z ]*PRIVATE KEY|sk-ant-[A-Za-z0-9]|ghp_[A-Za-z0-9]{20}|xox[baprs]-[A-Za-z0-9]' "$template" 2>/dev/null; then
note 'user/agents/opencode/config.json carries something that looks like a token'
fi
fi
[[ -r "$servers" ]] || note 'user/agents/mcp/servers is missing, so no MCP servers can be registered'
# ── 2 and 3, against a throwaway home ────────────────────────────────────────
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
checkout="$work/Panama"
mkdir -p "$checkout/setup/scripts" "$checkout/user/agents/mcp/bin" "$checkout/user/agents/opencode"
cp "$linker" "$checkout/setup/scripts/link-mcp"
cp "$template" "$checkout/user/agents/opencode/config.json"
printf '#!/usr/bin/env bash\nexit 0\n' >"$checkout/user/agents/mcp/bin/gamma"
chmod +x "$checkout/user/agents/mcp/bin/gamma"
cat >"$checkout/user/agents/mcp/servers" <<'FIXTURE'
# a comment, then a row that needs a token, one that needs none, and a stdio one
alpha http https://alpha.example/mcp ALPHA_TOKEN
beta http https://beta.example/mcp
gamma stdio bin/gamma
FIXTURE
printf 'ALPHA_TOKEN=sekrit-token-value\n' >"$checkout/user/agents/mcp/env"
# link-mcp registers through whichever runtime it finds, and gives up entirely
# when it finds neither. A stub is enough to prove the config generation.
mkdir -p "$work/bin"
printf '#!/usr/bin/env bash\nexit 0\n' >"$work/bin/opencode"
chmod +x "$work/bin/opencode"
home="$work/home"
state="$work/state"
old="$checkout/config/old"
# Every XDG directory is pinned to the throwaway home. XDG_CONFIG_HOME is
# usually set in the environment this runs from, and leaving it alone would send
# the generated config at the real ~/.config/opencode rather than at the fixture.
mkdir -p "$home/.config/opencode" "$state/panama"
run() {
HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$state" \
PANAMA_PATH="$checkout" PATH="$work/bin:$PATH" \
"$checkout/setup/scripts/link-mcp" >"$work/log" 2>&1
}
generated="$home/.config/opencode/opencode.jsonc"
# A file somebody wrote by hand is there before this stage ever runs.
printf '{"model": "someone-elses-hand-edit"}\n' >"$generated"
printf 'yes\n' >"$state/panama/user-content"
if ! run; then
note "link-mcp failed against a throwaway home: $(tail -1 "$work/log")"
fi
# ── The hand-edit is preserved, not deleted ──────────────────────────────────
if ! grep -rq 'someone-elses-hand-edit' "$old" 2>/dev/null; then
note 'a config written by hand was not preserved in config/old/'
fi
# ── The generation is faithful ───────────────────────────────────────────────
body="$(sed '/^\/\//d' "$generated" 2>/dev/null)"
jq -e . <<<"$body" >/dev/null 2>&1 \
|| note 'the generated opencode config is not valid JSON once its header comment is stripped'
grep -qF 'generated by setup/scripts/link-mcp' "$generated" 2>/dev/null \
|| note 'the generated opencode config does not say who wrote it or where to edit instead'
if ! jq -e --arg home "$home" '.instructions | index($home + "/.claude/rules/*.md")' <<<"$body" >/dev/null 2>&1; then
note 'the generated opencode config did not resolve the ~/ rules path to this home'
fi
jq -e '.permissions | length > 0' <<<"$body" >/dev/null 2>&1 \
|| note 'the template permissions did not reach the generated opencode config'
jq -e '.mcp.alpha.headers.Authorization == "sekrit-token-value"' <<<"$body" >/dev/null 2>&1 \
|| note 'a token-carrying server was registered without its Authorization header'
jq -e '.mcp.beta | has("headers") | not' <<<"$body" >/dev/null 2>&1 \
|| note 'a server that needs no token was given a header anyway'
jq -e --arg launcher "$checkout/user/agents/mcp/bin/gamma" '.mcp.gamma.command == [$launcher]' <<<"$body" >/dev/null 2>&1 \
|| note 'a stdio server was not registered against its launcher in this checkout'
# The file holds tokens, so it is private. Checked on the file rather than on
# the umask, because the umask is not what a later reader sees.
mode="$(stat -c '%a' "$generated" 2>/dev/null)"
[[ "$mode" == 600 ]] || note "the generated opencode config is mode $mode, not 600"
# The template is readable by anybody who finds the repository. The tokens live
# in the generated file and in user/agents/mcp/env, never here.
if grep -qF 'sekrit-token-value' "$template" 2>/dev/null; then
note 'the tracked opencode template carries an MCP token'
fi
# ── Re-running replaces, and does not churn ──────────────────────────────────
before="$(find "$old" -type f 2>/dev/null | wc -l)"
run
after="$(find "$old" -type f 2>/dev/null | wc -l)"
[[ "$before" == "$after" ]] \
|| note 're-running link-mcp buried its own output in config/old/, so it grows every upgrade'
grep -qF 'generated by setup/scripts/link-mcp' "$generated" 2>/dev/null \
|| note 're-running link-mcp did not rewrite the config it generates'
# A machine that never said yes is left alone, the same gate every other
# personal destination obeys.
rm -f "$generated"
printf 'no\n' >"$state/panama/user-content"
run
[[ ! -e "$generated" ]] \
|| note 'an opencode config was generated on a machine that never said yes'
if (( ${#findings[@]} > 0 )); then
printf 'opencode config contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'opencode config contract: PASS\n'
+22 -1
View File
@@ -14,8 +14,12 @@ and one file says where each piece goes.
| --- | --- | --- |
| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md` | Three tools, one file. These were byte-identical copies before this, waiting to disagree. |
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | Personal skills live once in the checkout. Both destinations use `linkdir` so Panama's shipped skills can live beside them. opencode reads both directories itself. |
| `agents/rules/` | `~/.claude/rules` | opencode loads these through `instructions` in its config, which `setup/scripts/link-mcp` writes. |
| `agents/rules/` | `~/.claude/rules` | opencode loads these through `instructions` in its config, which `setup/scripts/link-mcp` generates. |
| `agents/commands/` | `~/.config/opencode/commands` | Claude Code carries its commands as plugins, which opencode cannot read. These are the same commands in opencode's own format. |
| `agents/agents/` | `~/.config/opencode/agents` | The same again for subagents. |
| `agents/opencode/config.json` | read by `setup/scripts/link-mcp` | Not linked. The opencode config is generated from it, because that file also has to carry the MCP servers and their tokens, and a symlink cannot be half written. |
| `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. |
| `bin/pwswitch` | `~/.local/bin/pwswitch` | `pwswitch off` makes sudo passwordless for an agent session, `pwswitch on` restores the password. |
| `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. |
`manifest` is the authority; this table is a summary of it.
@@ -44,6 +48,23 @@ Two consequences, recorded rather than fixed:
A tool that installs directly into a home skill directory creates a
machine-local skill until it is moved into the checkout.
## What link-mcp generates
The opencode config is the one destination here that is generated rather than linked or
copied. It cannot be a symlink because it has to carry the MCP servers and their tokens,
and a token is exactly what cannot live in this repository. So `setup/scripts/link-mcp`
builds the whole file: `agents/opencode/config.json` supplies everything that is not a
secret, `agents/mcp/servers` supplies the server list, and the machine-local
`agents/mcp/env` supplies the tokens.
The result is `~/.config/opencode/opencode.jsonc`, written private and replaced in
place. It opens with a comment saying where to edit it, and that comment is the whole
warning: **hand-edits to the generated file do not survive an upgrade.** Put the change in
`agents/opencode/config.json` and it reaches every machine.
Anything already at that path that this stage did not write moves to `config/old/`, the
same promise everything else here makes.
## It is off unless you say yes
The installer asks, naming the destinations, and the default is no. Nothing here
+64 -1
View File
@@ -105,6 +105,68 @@ and record who decided what, because that history is useful.
- When it comes to personal projects, I am much less strict, but I prefer a title that makes it very clear what the MR is about.
- No confusing or complicated language.
---
# Operating context
Where things live, what is sensitive, and who may see it. This used to sit in one tool's
settings file, so only whichever agent read that file ever knew it.
## Source control and hosting
- I keep my own projects on `git.gbrown.org`, my self-hosted Git host. Ksense work lives in
the client's own GitLab, and that is where the merge requests go.
- `agentchat.gbrown.org` is my agent-to-agent message hub. It is personal infrastructure,
and it is not a place to publish anything sensitive outside the trust boundary.
- I have no internal package registry and no trusted cloud buckets. Nothing gets uploaded
to a bucket.
## Secrets
- Infisical carries staging and development secrets.
- `JIRA_CREDENTIALS` comes from `~/.bashrc`. Jira ticket work always uses my own
credentials, never the Jira credentials staging Infisical carries.
- `.claude/docs/env/prod/.env` and `.claude/docs/env/ci/.env` are read-only, and every
read needs explicit permission. Ask every time, even when I approved the same read
earlier in the same session.
## Sensitive locations
These are mine alone plus the cleared Ksense coworkers named at the end of this file, and
only at a "named and specific" bar, meaning I say which file and why. They never go to a
paste site, a snippet host, or an external tool:
- Jira credentials and the prod/CI env files above.
- `~/.infisical/secrets-backup/`.
- `~/.config/context7/credentials.json`.
- `~/.claude/.credentials.json`.
- Client project env files, such as `~/Documents/Code/GibSend/.env*` and
`~/Documents/Code/StPeteIT/.env*`.
## Network
My personal homelab. Everything under `gbrown.org` is inside the trust boundary:
`plex.`, `git.`, `chat.`, `radarr.`, `immich.`, `jellyfin.`, `home.`, and
`agentchat.`. Local hosts are `macnano.gib` and `nano.gib`, and the LAN is
`192.168.1.2` and `192.168.2.2`. The NanoKVM boards on those two hosts give an agent
console access to the machine, including what SSH cannot reach, so treat that access as
root-equivalent.
## Targets that need my explicit say-so
- Any host, namespace, or resource where `prod` or `production` is a whole word or a whole
name segment. Command Center production is the one I care about most.
- IAM, RBAC, networking, quota, and node-pool resources in any IaC, and anything whose
name or tag carries `prod` or `production`.
## CLIs I expect you to know
- `ctx7` for current library documentation.
- `infisical` for secrets.
- `claude`, `clauded`, and `claudedr` are Claude Code wrappers.
---
# Coworkers & Others that you will be working with & Interacting with when working on Ksense Projects
- Conrad Rohleder - Project Manager
@@ -115,7 +177,8 @@ and record who decided what, because that history is useful.
- For example, he once asked for a JSON blob of our schema so he could better understand it.
- Conrad is awesome & is great to work with.
- He is somewhat skeptical of AI & its ability to complete work.
- Henry Nguyen - Tech Owner (Tech Lead)
- Henry Nguyen - Tech Owner (Tech Lead) for Command Center only
- Henry is only involved in Command Center. On other projects (for example 360 Shop View), don't route reviews or sign-offs through him.
- Henry is who I interact with second most, just behind Conrad.
- Henry reviews all of my code & up until recently, would also write all the dev reviews for all the stories I completed as well. Nowadays, I write them myself & he reviews those too.
- Henry likes work to be very considerate & he always prefers solutions that result in 0 downtime.
+35
View File
@@ -0,0 +1,35 @@
---
description: Simplify recently changed code for clarity while leaving behaviour identical
mode: subagent
---
You simplify code. You never change what it does, only how it does it.
Work only on code changed in this session, or on the specific files you are pointed at.
Anything wider is out of scope unless you are asked for it.
What you do:
- Cut complexity that is not earning its keep: nesting that flattens, abstractions with one
caller, names that need a comment to be understood, values threaded through layers that
never use them.
- Keep the behaviour exactly as it was. Same outputs, same errors, same edge cases, same
failure modes.
- Follow the guidance that governs the code you are touching. Where that guidance and your
own instinct disagree, the guidance wins.
- Prefer explicit over compact. A clear line beats a clever one, and a name that says what
something is beats a shorter name that does not.
- Delete comments that restate the code. Keep comments that explain a reason, and update
them when the code they describe changed.
What you do not do:
- You do not refactor beyond the change. Renaming a public API, reshuffling a module, or
tidying a file you were not asked about is somebody else's job.
- You do not merge concerns into one expression to save a line. Nested ternaries and dense
one-liners are not simplification.
- You do not remove an abstraction just because it has one caller today.
- You do not add tests, types, or documentation as a side effect of simplifying.
Report what you changed and why, briefly. When a simplification you considered would have
changed behaviour, say that you left it alone and why.
+38
View File
@@ -0,0 +1,38 @@
---
description: Review a diff or merge request for real bugs and missed requirements
---
Review the change for real problems. Do not edit anything.
Target: $ARGUMENTS
When the target names a merge request or pull request, review that change. When it names a
file or directory, review what is uncommitted there. When it is empty, review the current
working diff.
How to review:
1. Read the change, then read the code around it. Context you did not read is context you
will misjudge.
2. Look for real problems only: logic that is wrong, a case that was missed, a contract
broken, data lost, an error swallowed, a race introduced, something the change claims to
do that it does not do.
3. Check the change against whatever guidance governs the code it touches. Guidance is
guidance for writing code, so not all of it applies to a review.
4. Read the git history of the lines being modified. A change that looks fine in isolation
often breaks a reason the code is the way it is.
5. Leave these out: style, naming, formatting, import order, missing tests, missing
documentation, and anything a linter, typechecker, or compiler will catch. CI runs those.
Also leave out problems on lines the change did not touch.
Output:
- Order findings by severity, highest first.
- Each finding gets the file and line, what is wrong, and why it matters. Link to code
rather than quoting long stretches of it.
- Say which requirement or guideline is broken when that is the reason.
- When a finding is a judgement call rather than a definite bug, say so plainly instead of
dressing it up as certain.
- If there is nothing worth reporting, say that and stop. Do not invent findings to fill
the space.
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Playwright's MCP server, for driving a real browser: pages, screenshots,
# forms, end-to-end runs. This is what the ticket skill's proof screenshots are
# captured with, so it belongs on every machine rather than only where a Claude
# Code plugin happened to be installed.
#
# link-mcp registers this file as a stdio server. It takes no token, so its
# row in user/agents/mcp/servers names no variable and nothing arrives in
# MCP_TOKEN.
set -euo pipefail
exec npx -y @playwright/mcp@latest
+5
View File
@@ -38,6 +38,11 @@ penpot http https://penpot.gbrown.org/mcp/stream?userToken={TOKEN} PE
# The hosted mcp.sentry.dev only speaks to sentry.io.
sentry-gbrown stdio bin/sentry-gbrown SENTRY_GBROWN_TOKEN
# Playwright's MCP server. Browser automation: pages, screenshots, forms,
# end-to-end runs. The ticket skill captures its proof screenshots this way.
# No token, hence no variable in the last column.
playwright stdio bin/playwright
# T3 Code environments, one per machine. Agents read projects and threads and,
# with more than read-only access, start and message threads there. No token:
# each is an OAuth server that asks for a pairing code from that machine on
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["~/.claude/rules/*.md"],
"permissions": [
{ "action": "read", "resource": "/etc/**", "effect": "allow" },
{ "action": "shell", "resource": "coredumpctl list*", "effect": "allow" },
{ "action": "shell", "resource": "coredumpctl info*", "effect": "allow" },
{ "action": "shell", "resource": "journalctl*", "effect": "allow" },
{ "action": "shell", "resource": "rpm -q*", "effect": "allow" },
{ "action": "shell", "resource": "panama doctor*", "effect": "allow" },
{ "action": "shell", "resource": "lspci*", "effect": "allow" },
{ "action": "shell", "resource": "lsusb*", "effect": "allow" },
{ "action": "shell", "resource": "getent hosts*", "effect": "allow" }
]
}
@@ -0,0 +1,71 @@
---
name: frontend-design
description: Make deliberate visual design choices for new UI or a reshaping of an existing one, and run the mock-then-decide workflow before touching real components.
---
# Frontend Design
Distinctive UI comes from decisions made on purpose. Templated UI comes from decisions made
by habit. This is about catching the second kind before it ships.
## Start from the thing itself
If the brief does not say what the product is, who it is for, and what the screen is
supposed to accomplish, work that out first and confirm it before designing anything. A
dashboard for people reconciling invoices and a booking page for a surf school want
different palettes, different type, different density, and pretending otherwise is how you
end up with the same page twice.
Name the subject in one sentence, name the audience in one, name the job of the screen in
one. Design against those three sentences. When a choice cannot be justified from them, it
is decoration.
## Choices to make deliberately
**Type carries the personality.** Pick the family on purpose rather than reaching for the
usual one. Decide a scale up front and hold to it, so a heading is a heading everywhere and
not a heading in one place and a slightly larger paragraph in another. You rarely need more
than two families.
**Colour is a system, not a pick.** Name the roles before the values: what is background,
what is surface, what is text, what is the one accent. The accent is the only one that gets
to be loud, and it earns that by being used sparingly.
**Structure is information.** Borders, rules, labels, numbering, eyebrows and dividers
should be telling the reader something about the content. If a device is only there to fill
a gap, it is ornament. Numbered markers in particular only make sense when the content is
genuinely a sequence.
**Motion answers the user.** Motion that reacts to what somebody did is useful, because it
shows what changed. Motion that runs on its own is filling space. Do not use continuously
repainting effects such as pulse, shimmer, blur or spinners. They peg the GPU on high
refresh displays and read as generated.
## Tells to avoid
These are the shapes that show up again and again in generated UI, and they are the fastest
way to make a page look like nobody chose it:
- A cream background with a serif display face and a warm terracotta accent.
- A near-black background with one acid green or vermilion accent.
- A headline with exactly one word italicised, bolded or coloured.
- All-caps labels used as decoration rather than because the label is short and needs it.
- A label above every block of content, naming what the content already makes obvious.
- The same fade-and-slide entrance applied to every section, and a hover lift on every card.
- A hero that is a big number, a small label and a gradient, when the subject has something
more characteristic to show.
## Workflow
This is the rule from my preferences, spelled out:
1. Build several genuinely distinct directions, not three variations of one idea. Different
palettes, different type, different structure. If two mocks would be described the same
way in one sentence, they are the same mock.
2. Build them as static pages. Real content, not grey boxes labelled "image here", because
the content is where most of the design decisions turn out to matter.
3. Publish them with a simple http server, report the URL, and stop.
4. Wait for a decision before implementing anything in real components.
Do not edit real components first. Do not keep polishing past the point where a choice
could be made, and do not pick a winner yourself and build it.
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Toggle passwordless sudo for the current user.
# pwswitch on -> sudo asks for a password again (default behavior)
# pwswitch off -> sudo runs without a password
# Works by adding/removing a drop-in file in /etc/sudoers.d.
set -euo pipefail
user="${SUDO_USER:-$USER}"
# "zz-" prefix so it sorts last and overrides earlier rules.
dropin="/etc/sudoers.d/zz-passwordswitch-${user}"
case "${1:-}" in
off)
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp"
# Validate before installing so a bad file can never lock sudo.
sudo visudo -cqf "$tmp"
sudo install -m 0440 -o root -g root "$tmp" "$dropin"
echo "Passwordless sudo ENABLED for ${user}. Run '$(basename "$0") on' when done."
;;
on)
sudo rm -f "$dropin"
sudo -k
echo "Password required for sudo again."
;;
*)
echo "Usage: $(basename "$0") on|off" >&2
exit 1
;;
esac
+13
View File
@@ -46,9 +46,22 @@ link agents/rules ~/.claude/rules
# what the SSH Hosts launcher command reads.
link ssh/config ~/.ssh/config
# ── Scripts ──────────────────────────────────────────────────────────────────
# Personal commands wanted on every machine, desktop or server. ~/.local/bin is
# on PATH in both shell configs. pwswitch toggles passwordless sudo through a
# sudoers.d drop-in, so an agent can run sudo and the password comes back after.
link bin/pwswitch ~/.local/bin/pwswitch
# ── Espanso ──────────────────────────────────────────────────────────────────
# The personal expansion triggers. Copied rather than linked, because this is a
# file each machine may add its own triggers to. It lands before setup-identity
# runs, and that stage only seeds a file that does not exist yet, so what is
# tracked here wins over the name and email the interview would have written.
copy espanso/identity.yml ~/.config/espanso/match/identity.yml
# ── OpenCode ─────────────────────────────────────────────────────────────────
# Claude Code carries its commands and agents as plugins, which opencode cannot
# read. These two directories hold the same capabilities in opencode's own
# format, so the command I type in one tool exists in the other.
linkdir agents/commands ~/.config/opencode/commands
linkdir agents/agents ~/.config/opencode/agents
+2
View File
@@ -1,3 +1,5 @@
Include ~/.ssh/config.local
Host server.gib
HostName server.gib
User gib