Skip to content

Latest commit

 

History

History
498 lines (362 loc) · 102 KB

File metadata and controls

498 lines (362 loc) · 102 KB

Changelog

[Unreleased]

v0.9.23 — 2026-08-24

Fixed

  • step_adguard (menu federver → 10) failed with a Docker name conflict if AdGuard had been stopped (not removed). The idempotency check only looked at running containers (docker ps), so a stopped-but-existing adguard container wasn't recognized — the step fell through to docker run --name adguard, which Docker refused because that name was still taken. Now checks docker ps -a (running or stopped); if it finds the container stopped, it runs docker start adguard instead of trying to create a new one. Audited every other Docker-backed step (syncthing, immich, navidrome, filebrowser, uptime-kuma) — all others use docker compose up -d (idempotent by design) or already checked docker ps -a, so this was an AdGuard-only landmine.
  • _adguard_dns_upstream_guide() (federver → 10 → 2) still printed the broken DoH config after the fix above shipped. Caught by code review: the in-script "show upstream DNS guide" menu option was never updated alongside customer-guide.md, so it walked users through re-entering the exact https://.../dns-query values that cause the unexpected EOF bug below. Now matches the guide: plain DNS to Quad9, Cloudflare+Google fallback.
  • privcloud stop and privcloud update skipped the "not installed" guard privcloud start already has. Running either before privcloud install/config surfaced a raw docker compose env-file error (couldn't find env file: ...) instead of a clear message. Both now check for .env first and print Not set up yet. Run: privcloud install, matching cmd_start.
  • VERSION fallback in privcloud was a hardcoded literal (0.9.5), 17 releases stale. If package.json is ever unreadable, the banner would silently print a specific-looking but wrong version instead of signaling the read failed. Falls back to unknown now.
  • Redundant stderr redirect (&>/dev/null 2>&1) in privcloud's Docker-group re-exec check cleaned up to the single-redirect idiom used elsewhere in the file.

Changed

  • Agent/IDE scratch gitignored and de-tracked. .gitignore now default-denies every dot-directory (.*/), re-admitting only what ships (.github/). Per-machine agent/IDE state (.claude/, .litectx/, .idea/, …) regenerates locally and only added noise and churn; any already-committed copies are removed from tracking (local files kept on disk). Repo hygiene only.

Docs

  • AdGuard upstream DNS-over-HTTPS was silently breaking real browsing — recommendation changed to plain DNS. customer-guide.md's own "Upstream + fallback DNS" guidance told users to set the upstream to https://.../dns-query (DoH). On the live federver box, AdGuard's DoH client to Quad9 (dns10.quad9.net) was failing with unexpected EOF on a large fraction of fresh queries — a known Go HTTP/2 idle-connection-reuse failure mode, not a network or ISP problem (manual one-off curl to the same DoH endpoint succeeded instantly every time; only AdGuard's persistent-connection client hit it). Symptom in the browser: pages loaded their shell but never finished — every new subdomain a page needed (CDN assets, fonts, trackers) sat waiting on a query that silently died. Guide now recommends plain DNS (9.9.9.9 / 149.112.112.112, Parallel requests, fallback 1.1.1.1 / 8.8.8.8) instead of DoH — stateless UDP has no persistent connection to go stale, so the failure mode doesn't exist. Verified fix: 6 fresh cross-domain lookups post-change, all 23–42 ms, zero errors in docker logs adguard afterward. If you copied the old DoH values from an earlier version of this guide, change them — see updated "Upstream + fallback DNS" section.
  • AdGuard "Performance (if browsing feels slow)" section (customer-guide.md). Documents the real cause of laggy browsing after pointing devices at AdGuard: the default Rate limit = 20 (queries/sec per client) silently drops the 30–50+ DNS lookups a modern page fires on first load, so the browser stalls on timeouts. Added under the existing "Upstream + fallback DNS" section with the three knobs to change in Settings → DNS settings — Rate limit 20 → 0, Optimistic caching on, Minimum TTL 0 → 60 — and the measured before/after. Diagnosed on the live federver box: individual lookups were already fast (15–32 ms uncached, <1 ms cached), but a 50-parallel-query burst answered only 20/50 until the rate limit was lifted, then 50/50 (~2.15 s → ~0.23 s). Settings applied live via the AdGuard UI (persisted to /opt/adguard/conf/AdGuardHome.yaml); no repo/service change.

v0.9.22 — 2026-07-03

Changed

  • Immich pinned to its major tag v3 instead of the floating :release (.env.example, privcloud config writer, live server .env). Supersedes the v0.9.21 decision to stay on :release. :v3 and :release resolve to the same image today (both currently v3.0.1), so day-to-day updates are unchanged — Watchtower still auto-applies every v3.x.y patch and minor nightly. The only behavioral difference is the one that matters: when v4.0.0 ships, :release would auto-jump the major (and can break, since a major may need an ordered DB migration — as v3 did), whereas :v3 keeps riding the newest v3.x.y until you deliberately bump v3v4. This is the simplest "keep updating but don't break" posture: hands-free for patches, a one-line manual gate for majors. Immich's own example.env now ships IMMICH_VERSION=v3 for the same reason. Postgres/Redis are already exact-pinned and Uptime-Kuma is on :2, so no stateful service changes engine on its own; stateless services (Navidrome, FileBrowser, Watchtower) stay on :latest.
  • The live server was recreated onto :v3 (picking up v3.0.1), and the pre-migration cold backup was consolidated onto the SSD as ~/immich-backups/postgres-pre-3.0 (a complete, flattened PG14 data dir for physical rollback), alongside the logical dump.

Docs

  • Guide + PRD updated to describe the major-tag pin (majors are a deliberate manual gate, not an overnight surprise) rather than the :release framing.

Ops (live server, no repo change)

  • Removed the throwaway alpine helper image used during the migration's root-owned file copy. Kept the pre-3.0 rollback set for a safety window: tensorchord/pgvecto-rs:pg14-v0.2.0 (old DB engine), immich-server:v2.7.5 + immich-machine-learning:v2.7.5 (old app), and the ~/immich-backups/postgres-pre-3.0 data backup. Together these allow a full rollback to the pre-v3 state; clear them (~6 GB) once v3 has settled. Watchtower's CLEANUP=true only prunes images it replaced, so these manual-migration leftovers were removed/retained by hand.

v0.9.21 — 2026-07-02

Changed

  • Immich database engine moved from pgvecto.rs to VectorChord for Immich v3 (docker-compose.yml). Immich v3.0.0 dropped the pgvecto.rs extension the stack had used since the start (docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0); v3 requires VectorChord. The database service now uses Immich's official bundled image ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0, which ships both vchord and the legacy pgvectors extension so an existing pgvecto.rs data directory auto-migrates on first start under a v2.x server — no manual SQL. Added shm_size: 128mb (VectorChord benefits from more shared memory) and a commented DB_STORAGE_TYPE: 'HDD' hint for installs whose Postgres data lives on a spinning disk rather than SSD. (IMMICH_VERSION was left on :release here; v0.9.22 then pins it to the v3 major tag so a future breaking major can't auto-arrive — see below.)
  • privcloud update diffs the tag actually in use, not a hardcoded :release. The update command snapshotted image IDs for ghcr.io/immich-app/immich-server:release (and -machine-learning) regardless of the configured IMMICH_VERSION; if the stack is ever pinned to a specific version the "before/after" diff compared the wrong tag and mis-reported "already up to date." It now reads IMMICH_VERSION from .env (falling back to release) and diffs :${ver}.

Docs

  • New PRD documenting the v3 / VectorChord database migration (docs/prd-immich-v3-vectorchord.md). Records the breaking change, the two accepted-safety backups (logical pg_dumpall + a cold copy of the data dir), the ordered migration sequence (pin to the last v2 → swap DB image → let v2 auto-migrate to VectorChord → bump to v3), and the rollback path.
  • Guide: new troubleshooting entry "Immich won't start right after a major update" (customer-guide.md). Walks a non-technical user through the VectorChord recovery if a nightly auto-update to a breaking major leaves immich_server down, and updates the Watchtower "accepted tradeoff" note to name Immich majors as the one thing to watch.

v0.9.20 — 2026-06-11

Added

  • One-time Download now also handles a non-writable local destination (setup.sh). v0.9.19 taught Download to read a root-owned source under sudo; live testing then hit the mirror-image wall on the destination — copying a backup onto a USB drive whose mount root is root-owned failed with mkdir "/run/media/<user>/<drive>/…" failed: Permission denied (13) because rsync writes as your user. Download now probes the destination too (is the deepest already-existing ancestor writable by you?) and, when it isn't, writes under local sudo. Local sudo is authorized once up front (sudo -v, or skipped if NOPASSWD/cached) — local tickets persist between commands (same controlling terminal → tty_tickets matches), so the same grant covers the mkdir and the copy. The grant is one-time: if Download had to prompt for it, it drops the ticket (sudo -k) when the copy finishes — providing sudo for this copy without leaving it open; if sudo was already cached/NOPASSWD it's left untouched (not ours to close), restoring the exact pre-copy state. The two ends are independent: source-root-owned, dest-unwritable, both, or neither are each handled.
  • The source-priv and dest-priv paths are now one tar pipelineprintf | ssh [sudo -S] tar -cf - | [sudo -n] tar -xf - — where the reader runs under server sudo only if the source is root-owned and the writer runs under local sudo only if the destination is unwritable. The printf stage is always present (empty, ignored by tar, when the source isn't privileged) so PIPESTATUS indices stay fixed at [0]=printf [1]=read [2]=write. When the writer runs as root it adds --no-same-owner --no-same-permissions, so a server-supplied archive can't restore setuid/setgid bits or foreign ownership onto the laptop (a compromised server can't land a setuid-root file). The plain rsync --progress path is unchanged and still used whenever neither end needs sudo. The server sudo password still goes in over encrypted SSH stdin via a printf builtin (never in ps) and is unset right after; the local sudo password is sudo's own interactive prompt.

Fixed

  • Privileged Download error handling no longer depends on the caller suppressing errexit (setup.sh). The PIPESTATUS check sat after a bare pipeline; under set -e (active in setup.sh) a failed pipeline would abort before the check unless the caller neutralized errexit (which run_step's || rc=$? happens to do). The pipeline is now the left side of an && … || … capture, which is exempt from set -e, so the failure message and clean return 1 are reached regardless of how the function is called — with PIPESTATUS preserved intact in both branches.

v0.9.19 — 2026-06-11

Added

  • One-time Download now copies root-owned sources (e.g. an Immich backup) off the server (setup.sh). federver → 14 → 5 → 2 (Download: server → laptop) ran a plain rsync as your normal SSH user, so a source written by a root process — the scheduled Immich backup tree (…/immich/db/*.sql.gz, …/immich/photos/), which is root-owned because the backup job runs as root — failed with "permission denied." The download now probes the source as your user (find ! -readable -print -quit) and, only when it finds an unreadable path, reads it on the server under sudo: it streams a tar of the tree through a single SSH exec (printf | ssh "sudo -S -p '' tar -cf -" | tar -x), prompting once for the federver sudo password. The password is read silently, passed via a printf builtin (never a process, so never in ps) over the encrypted SSH stdin straight into sudo -S, and unset immediately — never written to disk, a command line, or an env var. Extracted files land owned by you (a non-root tar can't restore root ownership); the original backup stays root-owned, and nothing privileged is left enabled on the server. Normal (user-owned) downloads are unchanged — the probe finds nothing unreadable, so they keep using plain rsync with --progress. A wrong password or unreachable server is caught via PIPESTATUS and reported with the sudo/extract exit codes, leaving nothing half-written. The tar working directory mirrors the plain-rsync folder/contents choice so files land in the same place either way — and a single-file source (which _pick_copy_mode treats as "contents") is sent via its parent dir so it lands in the destination folder rather than tripping an invalid tar -C <file>.
    • Why a tar stream and not "prompt for sudo, then rsync": rsync opens its own separate SSH login for the transfer, and a primed sudo credential does not carry across SSH logins (with the default timestamp_type=tty, sudo keys the cache per parent process when there's no terminal) — so "authenticate first, rsync second" silently fails unless sudo is NOPASSWD. Streaming a tar through one SSH exec keeps the auth and the data in a single session, which is why it works every time without a persistent grant. The server → server direction (option 4) was already privileged via sudo rsync over ssh -t; this brings the same capability to the download direction.

v0.9.18 — 2026-06-09

Docs

  • Documented the two deliberate security tradeoffs under Privacy & security (customer-guide.md). Replaced the "LAN exposure" note with an "Accepted security tradeoffs (by design)" section recording the two things a security audit flags and why they're intentional, not defects: (1) the service UIs are LAN-reachable over plain HTTP — fine because the home LAN is the trust boundary and Tailscale gives encrypted access in and out (Postgres stays unexposed); locking the UIs to Tailscale-only adds friction for little gain. (2) Watchtower auto-updates containers from floating tags — fine because the images (Immich, Navidrome, FileBrowser, AdGuard, Uptime Kuma) are widely adopted and actively maintained, a bad release is rolled back quickly, and auto-updates deliver security patches hands-free; pinning would trade that away. Keeps the practical advice (strong passwords per UI, never port-forward, use Tailscale for remote).

v0.9.17 — 2026-06-09

Fixed

  • "Remove" now handles a cron-only Immich backup, and actually clears the cron line it detects (setup.sh). Two related gaps in _immich_backup_remove: (1) the early guard checked only for the systemd timer file and the managed script, so a backup scheduled solely via a legacy root-cron line (no timer, no /usr/local/bin/immich-backup.sh) reported "No scheduled Immich backup to remove" while Status still showed it scheduled — leaving a backup running that the user thought was gone. The guard now also detects a readable cron line. (2) The cron-removal step matched only the literal string immich-backup, but detection matches what the line runs (immich-backup.sh|immich-db|immich-backup.log) — so a line running e.g. an immich-db dump was detected/listed but never removed. Removal now uses the same pattern, and the post-removal verification checks the timer, the script, and any remaining active cron line before claiming success. Low likelihood (the tool moved off cron to a systemd timer in v0.9.10; only old or hand-made installs hit this), but it closes a false-"removed" path.

v0.9.16 — 2026-06-09

Fixed

  • Corrected stale menu paths left over from the v0.9.13 backup-menu flatten (setup.sh, customer-guide.md). Flattening privcloud's Backup menu turned the old 9 → 2 → N submenu into flat options 9 → 1..5, but a few hints/comments still pointed at the old paths: "Manage it anytime: 9 → 2 (status / run now / remove)" and "Check it: 9 → 2 → 2 (Status)" now point at 9 / 9 → 3; the step_immich_backup_menu comments no longer claim privcloud drives that submenu (it's federver-only since the flatten — privcloud calls the step functions directly); and the customer guide's "privcloud → 9 → 2 … same management submenu" line now describes the flat menu. (The surviving 9 → 2 references all correctly mean "Scheduled - Set up".)

Removed

  • Dead variables in _immich_backup_status (setup.sh). The v0.9.13 table rewrite stopped using sched_human and lastresult, but both were still assigned — lastresult also spent a systemctl show …Result subprocess for a value nothing read. Removed.

v0.9.15 — 2026-06-09

Changed

  • "Remove" now shows exactly what it will delete before asking (setup.sh). _immich_backup_remove (Backup → Scheduled - Remove, and federver → 14 → 6 → 4) jumped straight to "Remove the scheduled Immich backup? [y/N]" without naming which schedule — so it read like a blind delete. It now prints an "About to remove" summary first: the timer (with its cadence and next run), the service, the script path, and any legacy root-cron line it found. There is only one Immich backup schedule by design (the immich-backup.* timer/service/script is single-source across both menus), so this is transparency, not a chooser — and other scheduled tasks (sync jobs, disk-check) are separate and untouched.

v0.9.14 — 2026-06-09

Security

  • Scheduled sync jobs now shell-quote every path before building the command (setup.sh). The upload/download builders in step_sync interpolated picked paths into pre_cmd / rsync_cmd with hand-written quotes, and those strings are both run via bash -c and written verbatim into the generated ~/.local/bin/sync-<job>.sh (which cron/systemd then run). A source/destination path containing a quote, $, backtick, or ; could break out and inject a command. Every interpolation now goes through _shq (printf %q) — the same discipline the rest of the file already used — and rsync gains -s / --protect-args so the remote shell can't re-split paths. Verified on both execution paths (direct run and the persisted job script): a hostile path lands as a single inert rsync argument, and normal paths produce the same command as before. Practical impact was low (the paths come from the operator's own interactive pickers), but the gap is closed.
  • .env.example placeholder password now matches the auto-regenerate guard (.env.example). The template shipped DB_PASSWORD=changeme, but privcloud config's "replace the placeholder with a generated password" check looked for changeme_use_a_real_password — so a database stood up from the bare template kept a trivially-guessable password. The template now carries the recognised placeholder (and a comment explaining it), so the supported install path regenerates it. Blast radius was already bounded — Postgres is not exposed on any host port — but credentials should never default weak. (The guard itself was left unchanged on purpose: making it also match changeme could regenerate the password of an already-initialised changeme database and lock it out.)
  • privcloud .env is created 0600 from the start (privcloud). The config write was create-then-chmod, leaving a brief window where the file holding the DB password was world-readable. It's now written inside a ( umask 077; … ) subshell; the trailing chmod 600 is kept to also tighten a pre-existing file (truncating with > keeps the old mode).
  • privcloud re-quotes its arguments when re-running under sg docker (privcloud). The docker-group re-exec passed "$0 $*" as one shell string, which both word-split arguments and could self-inject. It now keeps the original argv as an array and re-quotes with printf %q, which also fixes a real bug — arguments with spaces (e.g. --album "My Trip") were being split before.

Docs

  • Customer guide documents the LAN exposure / access model (customer-guide.md). A new "LAN exposure and access model" note under Privacy & security spells out that the service UIs are reachable by any device on the home network over plain HTTP, that Tailscale (not port-forwarding) is the way to reach them remotely, and that Postgres is not exposed on any port. (Binding the UIs to localhost-only or adding TLS would change the intended at-home LAN access, so this is documented rather than changed.)

v0.9.13 — 2026-06-09

Changed

  • The privcloud Backup menu is now flat — no One time / Scheduled drill-down (privcloud). The one-time backup and the four scheduled actions sit at one level, in the same aligned -hint style as federver → 14: 1) One-time, 2) Scheduled - Set up, 3) Scheduled - Status, 4) Scheduled - Run now, 5) Scheduled - Remove. Options 2–5 delegate straight to setup.sh's individual steps (step_immich_backup, _immich_backup_status, _immich_backup_run, _immich_backup_remove) — the same functions federver → 14 → 6 drives — so there's no extra submenu and both tools still manage one schedule. The header now reads "Backup privcloud — Immich photos + database" to distinguish privcloud's one-time backup (Immich photos + DB) from the generic rsync at federver → 14 → 5, which is a different operation with the same name.
  • Scheduled-backup Status now uses the same table layout as the server fleet view (setup.sh). _immich_backup_status (privcloud → 9 → 3, the flattened Status action) previously printed a key/value schedule block + a ✓ ran list. It now renders in the identical style as federver → 14 → 1 (_sync_show_status): a Scheduled tasks table (Name · Schedule · When · Type · Note) and a Last runs table (one row per run, newest last, up to 8 — ✓ ok / ✗ failed). Detail the fleet table has no columns for is preserved: next-run + enabled-state ride in the Note column, and the 8-run history becomes the Last-runs rows. Still Immich-only, still points to 14 → 1 for everything else; all four detection branches (systemd timer / legacy cron / OFF / cron-unreadable) render in-table with the actionable guidance kept below.

Fixed

  • Corrected the federver → 14 cross-reference notes in the Scheduled-tasks table (setup.sh). The federver → 14 → 1 status table tagged immich-backup with (14 → 5) and disk-check with (14 → 6), but the menu had since been renumbered — they're → 6 and → 7. The notes (timer row, legacy-cron row, and disk-check row) and a stale code comment now match the live menu. (The guide's copy of these numbers was fixed back in v0.9.4; the table code carried the old values until now.)

v0.9.12 — 2026-06-08

Changed

  • Scheduled photo backup no longer mirrors regenerable or redundant data (setup.sh). The photo rsync previously copied the entire Immich data root (rsync -a, no excludes), which dragged along three things it shouldn't: backups/ (Immich's own automatic DB dumps — redundant, since the scheduled backup already dumps the database into <dest>/immich/db/), and the regenerable caches thumbs/ (thumbnails) and encoded-video/ (transcodes). On a real library these added tens of GB and filled the backup drive, failing the run with No space left on device. The rsync now passes --exclude=/backups/ --exclude=/thumbs/ --exclude=/encoded-video/ (anchored to the data root so they can't match a same-named photo subfolder), keeping the backup to the irreplaceable originals + metadata. After updating, re-run Set up / change schedule (privcloud → 9 → 2 → 1) to regenerate the script. Existing copies of those folders on the destination are not removed automatically — delete <dest>/immich/photos/{backups,thumbs,encoded-video} to reclaim the space (Immich rebuilds thumbs/transcodes after a restore).

v0.9.11 — 2026-06-08

Changed

  • The privcloud menu now behaves like the federver menu — clear screen, run one action, "Press Enter", back to a fresh main menu (privcloud, setup.sh). Every main-menu action (all 9, including backup) runs through a new _run_action wrapper that mirrors setup.sh's run_step: it clears the screen, runs the action, waits for Enter so the output is readable, then redraws a clean menu. Previously actions dumped their output and the menu reprinted right underneath, so it looked like it was "asking again." The backup chooser (cmd_backup) and the scheduled-backup submenu (step_immich_backup_menu, shared with federver → 14 → 6) are now single-pass — they run one action and return to the main menu instead of looping and re-prompting after each action. CLI usage (privcloud status, etc.) is unchanged — no clear/pause there.

v0.9.10 — 2026-06-08

Fixed

  • Scheduled-backup Status now shows the real schedule and the Immich cron jobs, instead of ? (setup.sh). privcloud → 9 → 2 → 2 (and federver → 14 → 6 → 2) printed Schedule: ? because it read the schedule with systemctl show … -p OnCalendar, which is not a queryable property — it always returns empty (the spec is only exposed under TimersCalendar). It now reads OnCalendar via systemctl cat (works wherever the unit lives, including drop-ins). The same wrong call on the federver → 14 → 1 server-jobs view is fixed too.

Changed

  • Status is now Immich-only and detects cron-scheduled backups by behaviour (setup.sh). The screen no longer dumps every root cron job; it reports just the Immich backup and points to federver → 14 → 1 for the full server list. It finds the backup whether it runs via the immich-backup.timer or a root cron line — matching what the line runs (immich-backup.sh / immich-db / immich-backup.log), not the word "immich", so a generically-named line still registers — and lists all matching cron lines (human schedule · raw spec · command). When no timer exists and root cron can't be read without a password, it says so rather than claiming nothing is scheduled; when the log shows past runs but nothing schedules it now, it flags the backup as currently OFF.

v0.9.9 — 2026-06-08

Changed

  • Scheduled-backup Status is now clean tables instead of a raw log dump (setup.sh). privcloud → 9 → 2 → 2 (and federver → 14 → 6 → 2) used to print a wrapping systemctl list-timers line plus 10 raw log lines full of dump file paths. It now shows: a compact schedule block (schedule · next run · enabled · last result), a Recent runs table — one row per run, ✓ ran / ✗ failed + timestamp, no paths (collapses the multi-line per-run log) — and a Scheduled jobs on this server table (NAME · SCHEDULE · TYPE) covering the systemd timer and root cron jobs, so it's clear which is which. Root cron is read non-interactively (no password prompt) and skipped with a pointer to federver → 14 → 1 if locked.

v0.9.8 — 2026-06-08

Changed

  • Scheduled backup now organizes its output under <dest>/immich/ (setup.sh). DB dumps go to <dest>/immich/db/immich-db-*.sql.gz and photos to <dest>/immich/photos/, instead of <dest>/immich-db-*.sql.gz and <dest>/photos/ at the top level. This namespaces the backup so the destination drive can hold other things cleanly. Re-run Set up / change schedule (privcloud → 9 → 2 → 1) to regenerate the backup script with the new paths. Existing top-level photos/ / immich-db-*.sql.gz from an earlier layout are left in place (photos re-sync into the new path; old dumps no longer rotate) — move or delete them if you want a tidy drive.

v0.9.7 — 2026-06-08

Fixed

  • "Remove" no longer reports false success when sudo is declined (setup.sh). Removing the scheduled backup printed ✓ Scheduled Immich backup removed even when every sudo failed (no password / Ctrl-C) — so the schedule was actually still active while claiming to be gone. Root cause: the submenu calls _immich_backup_remove || true, which disables set -e inside the whole function, so the failing sudo rm/systemctl commands didn't stop it reaching the success line. It now (1) acquires sudo once up front with sudo -v and bails cleanly, changing nothing, if that's declined or cancelled, and (2) verifies the timer/service are actually gone before claiming success — otherwise it reports the removal didn't complete and points to Status. Acquiring sudo once also means a single password prompt instead of one per command.
  • "Run now" applies the same up-front sudo check (setup.sh) so cancelling the password no longer leaves a misleading state.

v0.9.6 — 2026-06-08

Docs

  • README now lists Backups in "What you get" (README.md). The new backup capability (one-time or scheduled systemd-timer backups of photos + Immich database, no downtime, external-drive default) wasn't mentioned in the README overview. The PRD (docs/prd-immich-backup.md), CHANGELOG, and customer guide already cover it.

v0.9.5 — 2026-06-08

Fixed

  • The version read no longer aborts the script when package.json is missing (privcloud, setup.sh). VERSION="$(sed … package.json)" runs under set -e; sed exits non-zero on an unreadable file, so a missing package.json aborted the script before the literal fallback could apply — defeating the fallback's whole purpose (a broken/partial checkout would make privcloud unusable). The command substitution is now || true, so it falls through to the literal. (Same set -e + failing-command-substitution class as the _pick_backup_default fix in v0.9.0.)
  • First-run after scheduling can't skip its closing hints (setup.sh). The post-setup _immich_backup_run call is now || true, so a systemctl start failure doesn't abort the step before the "manage it anytime / view log" hints under set -e.

v0.9.4 — 2026-06-08

Changed

  • federver → 14 → 6 now opens the same backup management submenu as privcloud → 9 → 2 (setup.sh, privcloud). It used to jump straight into "set up schedule" with no way to check status or remove it. Both menus now share one step_immich_backup_menu (set up / change · status · run now · remove), so the two entry points are identical and the submenu UI is single-source. privcloud's scheduled option now delegates to that shared menu instead of duplicating it.

Fixed

  • Corrected federver → 14 option numbers in the guide (customer-guide.md). Several references pointed at 14 → 5 for the Immich backup (it's → 6) and 14 → 6 for the disk-space monitor (it's → 7).

v0.9.3 — 2026-06-08

Added

  • Scheduled Immich backup now runs once immediately after setup (setup.sh). enable --now only starts the timer, which doesn't fire until the next scheduled tick (and Persistent=true only catches up a tick missed while the box was off — it never triggers an initial run). So after configuring, it now offers to run the first backup now to confirm it works and seed the backup, instead of waiting up to a week. The run is started with systemctl start --no-block, so it executes under systemd and keeps going even if the SSH session closes.
  • Manage the scheduled backup from privcloud → 9 → 2 (privcloud, setup.sh). The Scheduled option is now a submenu: 1) Set up / change, 2) Status (timer state, next/last run via systemctl list-timers, recent log), 3) Run now (background), 4) Remove (deletes the timer/service/script — _immich_backup_remove — and keeps existing backup files). Status/run/remove delegate to new setup.sh functions so the schedule stays single-source.
  • 0) Back on every backup menu, and the privcloud main menu now loops (privcloud). The interactive menu used to run one command and exit; it now returns to the main menu after each action, so 0) Back in the backup / scheduled / scope menus steps up a level and 0) exit leaves privcloud. (CLI usage like privcloud backup is unchanged — still one-shot.)

v0.9.2 — 2026-06-08

Fixed

  • The version banner now resolves through symlinks (setup.sh, privcloud). v0.9.1 read the version from package.json next to $(dirname "${BASH_SOURCE[0]}"), but the installed federver/privcloud commands are symlinks in /usr/local/bin (bash doesn't resolve symlinks in BASH_SOURCE). So the lookup landed in /usr/local/bin, found no package.json, and silently fell back to a stale literal — the banner read v0.9.0. Both scripts now readlink -f the script path first, so they find the real package.json in the repo regardless of how the command was invoked.

v0.9.1 — 2026-06-08

Fixed

  • The federver and privcloud banners now show the real version (setup.sh, privcloud). Both had their own hardcoded version string that drifted from package.jsonsetup.sh still read FEDERVER_VERSION="0.8.9" after the v0.9.0 release, and privcloud was stuck at VERSION="0.1.0". Both now read the version from package.json (the single source of truth) at runtime, with a fallback, so the menu can't fall behind a release again.

v0.9.0 — 2026-06-08

Added

  • privcloud → 9 (backup) now offers 1) One time or 2) Scheduled (privcloud, setup.sh). The backup command used to do a single manual copy only. It now opens a two-way menu: One time runs an on-demand backup; Scheduled sets up a recurring, no-downtime automatic backup via systemd timer. The scheduled path hands off to federver's step_immich_backup (bash setup.sh --run step_immich_backup), which owns the shared immich-backup.timer/.service and /usr/local/bin/immich-backup.sh — so the privcloud menu and the federver main menu drive one schedule (single source of truth), not two competing ones.
  • The one-time backup now lets you pick scope: 1) Both, 2) Photos only, 3) Database only (privcloud). Previously it always copied both.

Fixed

  • The one-time backup no longer stops Immich (privcloud). It used to run docker compose down so it could cold-copy the Postgres data dir, taking the whole stack offline for the duration. It now backs up with zero downtime: photos via a live rsync of the write-once originals, and the database via an online pg_dumpall | gzip (immich-db-<ts>.sql.gz) while the container keeps running. If Immich happens to already be stopped, it falls back to a cold data-dir copy (also safe) — either way the running state is left untouched. The restore hint adapts to whichever DB method was used (load the SQL dump via psql, or copy the postgres/ dir back).

Security

  • .env is now written chmod 600 on install (privcloud). The config holds DB_PASSWORD but was created with the default umask (world-readable 644). It's now owner-only, matching the backup copies.
  • The Immich API key no longer appears in any process argument list (privcloud). The key-validation curl now passes the header via stdin (-H @-) instead of -H "x-api-key: …", and the upload docker run forwards the key by name (-e IMMICH_API_KEY, value exported into the environment) rather than -e IMMICH_API_KEY=<value>. Neither command line exposes the secret to other local users via ps.
  • Server-side path handling in the sync/backup SSH calls is now injection-safe (setup.sh). The eight directly-executed ssh "… '$path' …" sites embedded user-supplied paths inside single quotes, which broke on paths containing an apostrophe (e.g. Tom's Photos) and allowed shell-command injection into the remote session. Paths are now quoted with a new _shq helper (printf %q). (The two command-builder sites that feed _sync_execute_or_schedule via bash -c/generated scripts are multi-parse and left for a separate args-not-strings refactor.)

Changed

  • The scheduled Immich backup is now full (database + photos), with zero downtime (setup.sh). Previously the timer dumped only the Postgres DB. Each run now also does a live rsync of the photo originals into <dest>/photos/. Both steps are safe while Immich runs: pg_dumpall is transactionally consistent, and UPLOAD_LOCATION holds write-once originals — so the stack is never stopped. Photos sync append-only (never --delete) so an unattended job can't propagate an accidental deletion; DB dumps still rotate by the retention window. The success log line keeps the exact Backup complete phrase the status screen parses, so the Last-runs table is unchanged.
  • The scheduled backup destination now defaults to an external drive (setup.sh). step_immich_backup auto-detects the mounted removable drive (/run/media/*/*, /media/*/*, /mnt/*) with the most free space that isn't on the same filesystem as your photos, and suggests it as the default. If you point the backup at the same drive as the source, it now warns that a single disk failure loses both copies and asks for confirmation.

v0.8.9 — 2026-06-05

Added

  • privcloud → 7 (upload) now asks "Albums or Flat?" after the path (privcloud). After you enter the photo folder, the upload offers two layouts: 1) Albums — pass --album to the Immich CLI so each source subfolder becomes its own album (named after the folder), keeping your existing folder organization; or 2) Flat — everything into the user's timeline with no albums (the previous behavior, still the default on Enter). The choice maps to a single --album flag on the immich upload command. Verified live: the flag is accepted by the CLI (immich-cli v2.7.5) and the upload still authenticates per-user via the API key, so albums land in the library of whoever owns the key.

v0.8.8 — 2026-06-05

Fixed

  • privcloud → 7 (upload) no longer dies with npm: command not found (privcloud). The upload path installed the Immich CLI with npm i -g @immich/cli, but the server has no Node.js/npm — so on a fresh server the feature failed at the install step (./privcloud: line 535: npm: command not found). Installing a whole Node toolchain just for a CLI also runs against the project's lightweight/Docker-only design. The upload now runs the official Immich CLI as a one-shot Docker container (ghcr.io/immich-app/immich-cli, pulled once and cached), reusing the Docker that already runs the stack — no host dependencies. It mounts the chosen photo folder read-only at /import, reaches Immich over --network host at http://localhost:2283/api, and authenticates with the API key via IMMICH_INSTANCE_URL/IMMICH_API_KEY. --security-opt label=disable avoids SELinux relabeling the user's photo folder. Validated live: the containerized CLI connects to the running server (a bad key returns a clean 401, a good key uploads).
  • federver → i now pulls the latest privcloud on the server before launching it (setup.sh). step_immich ran cd ~/privcloud && ./privcloud over SSH with no git pull, so server-side fixes to privcloud never reached the box until something else triggered a pull. It now does GIT_TERMINAL_PROMPT=0 git pull --ff-only -q first, matching the _on_server hop, so the Immich manager is always current.

v0.8.7 — 2026-06-05

Changed

  • The eight curated smart playlists now cap at 250 tracks, up from 50/100 (tools/navidrome/playlists/). Daily Mix, On Repeat, Rediscover (were 50) and 2000s, 90s, Fresh Additions, Surprise Me, Throwback (were 100) all moved to limit: 250 for longer mixes. English+/Arabic+ are unchanged at 2,500. Unlike a name change, a limit is part of the smart-playlist rules and applies on re-import — no delete/recreate needed; the new cap is live and shows on each playlist's next open. Year-scoped lists (decades, Throwback) may still come in under 250 when fewer tracks match — 250 is the cap, not a target.
  • Recommended iOS music client is now Arpeggi instead of Amperfy (customer-guide.md, setup.sh, tools/navidrome/README.md). Arpeggi (App Store, free) is faster and handles playlist rendering noticeably better. Both are Subsonic-compatible, so it's a drop-in swap — same server URL (http://<server-ip>:4533) and Navidrome credentials. Docs and the post-deploy hint now point to Arpeggi.

v0.8.6 — 2026-06-05

Changed

  • The two folder playlists are now lighter, rotating mixes: "🎼 English+" and "🎶 Arabic+" (renamed from "All English"/"All Arabic"), each capped at a random 2,500 tracks (tools/navidrome/playlists/). The unlimited versions from v0.8.5 were too heavy to load in clients — "All English" alone was ~65,000 tracks. Both now use sort: random + limit: 2500, so they pull a fresh random 2,500 from their folder tree. Navidrome reshuffles a smart playlist on access (throttled by ND_SMARTPLAYLISTREFRESHDELAY, default ~5s), so each open gives a new random selection — new music at least daily, with no per-playlist scheduling. Note: Navidrome does not rename or re-cap an existing synced playlist on re-import (the name/rules are bound at creation), so on the live server the old playlists were deleted from the DB and recreated to pick up the new name and limit; a fresh install gets the right values directly from the .nsp files. Smart playlists materialize lazily on first open — verified live at 2,500/2,500 for both once opened.

v0.8.5 — 2026-06-05

Added

  • Two folder-scoped smart playlists — "🎼 All English" and "🎶 All Arabic" (tools/navidrome/playlists/). Each plays everything under one top-level library folder (english/ and arabic/) and all of its subfolders, sorted by artist. They're rule-based, so they auto-pick-up anything added under those folders later — unlike the static english.m3u8/arabic.m3u8 snapshots that already existed. "All Arabic" deliberately matches only the arabic/ tree and not the separate arabic-oldies/ folder (nor holy-quraan/, which is its own top-level folder). Installed live to <MUSIC_LOCATION>/playlists and verified against the library: ~65,405 English tracks, ~4,657 Arabic. Like all repo .nsp templates, they redeploy automatically whenever the music location changes (_install_smart_playlists, setup.sh:1293).

Fixed

  • Smart-playlist folder matching now uses the library-relative path Navidrome actually stores (tools/navidrome/playlists/). Navidrome 0.61 stores media_file.path relative to the library root (e.g. english/Album/track.mp3), not as an absolute container path (/music/english/...). The first cut of the two playlists above anchored on /english/ and /arabic/ with a leading slash, which can never match a path that starts with the folder name → both rendered zero tracks even though they imported cleanly. Rewritten to startsWith on english/ / arabic/ (no leading slash), which also cleanly excludes arabic-oldies/. Smart playlists are evaluated live on access, so the corrected rule populates instantly regardless of list size.

v0.8.4 — 2026-06-05

Fixed

  • The menu no longer hangs when run away from home; laptop→server SSH fails over to Tailscale automatically (setup.sh). SERVER_IP is typically a LAN address (e.g. 192.168.178.180) that only answers at home. Off the home network it's unreachable, and because every ssh call ran with no connect timeout, picking a remote option (e.g. federver → 11, Manage storage) blocked on the kernel's default TCP connect timeout — a minute-plus silent "hang" stuck on Connecting to server.... This was 100% deterministic, not flaky: the LAN IP simply has no route when roaming. Three changes fix it: (1) a global ssh() wrapper adds ConnectTimeout=8 + keepalives (ServerAliveInterval=15, ServerAliveCountMax=4) so no SSH call can hang silently — a dead address now fails in ~8s with a real error, and a dropped roaming link is detected within ~1min instead of freezing the menu; (2) _resolve_server_endpoint probes the LAN address on port 22 at startup (2s timeout) and, when it doesn't answer, switches to a new optional SERVER_HOST_TS config value (the server's Tailscale name/IP, which answers from anywhere on the tailnet) — fast path at home, automatic fallback when away; (3) the remote git pull in the SSH hop now runs with GIT_TERMINAL_PROMPT=0 so it can't block on a hidden credential prompt. The fallback covers all ~40 SSH call sites at once by resolving SERVER_IP a single time before the menu opens, rather than touching each call. SERVER_HOST_TS lives in the out-of-repo config file (~/.config/federver/config) and the first-run prompt now asks for it (optional, press Enter to skip), so nothing identifying is committed. Validated live from a roaming connection: LAN probe fails in 2s, falls over to the tailnet address, and the full hop (connect → git pull--run) succeeds.

v0.8.3 — 2026-06-05

Fixed

  • Navidrome no longer imports hundreds of stray playlists from the music library (docker-compose.yml, setup.sh). By default Navidrome scans the entire music folder for playlist files and imports each one; a legacy collection with a playlist.m3u in nearly every album folder turned into ~500 junk playlists (513 total in the DB) that buried the 7–8 curated smart playlists. The navidrome service now sets ND_PLAYLISTS_PATH: playlists, so Navidrome imports playlists only from <music>/playlists and ignores every .m3u/.m3u8 scattered through the album tree. _install_smart_playlists now writes the curated .nsp templates into that lowercase playlists folder (was Playlists) so the script and Navidrome agree on one location instead of creating a duplicate case-variant folder that got imported twice.

Added

  • fresh-additions.nsp smart playlist (tools/navidrome/playlists/). "🆕 Fresh Additions" — music added to the library in the last 90 days (dateAdded), newest first. Smart playlists are rule-based, so it auto-populates as new music is added, with no manual upkeep. It was already deployed on the server but missing from the repo, so a reinstall would have dropped it.

v0.8.2 — 2026-06-05

Fixed

  • Storage → Status (federver → 11 → 1) no longer crashes the whole step on the Disk-usage table (setup.sh). The awk that formats df -h wrote mp=mp(i>6?" ":"")$i to rebuild a space-containing mountpoint; awk parses mp( as a call to a function named mp, and with no such function it died at runtime with attempt to use non-function 'mp' in function call the instant it hit any /dev/* row. The error surfaced under the "Disk usage" heading and made the menu step exit non-zero → ✗ [11] Manage storage — FAILED. Rewritten to seed mp=$6 and append the remaining fields (for(i=7;i<=NF;i++) mp=mp" "$i) — same space-safe behaviour for mountpoints like "New Backup", without the function-call ambiguity.
  • USB partitions no longer show up under "Internal drives" in Storage → Status (setup.sh). The Internal/USB split filtered each lsblk row by its transport (TRAN), but TRAN=usb is reported only on the parent disk (sdb), not its partitions (sdb1) — so a USB partition with an empty TRAN leaked into the Internal list even though its disk was correctly placed under USB. Status now resolves the full set of USB device names (each USB disk plus its partitions, via lsblk -rno NAME /dev/<disk>) once up front and filters the Internal listing against that set, so a USB drive and all its partitions are classified together. Validated on the live server (simulated, since the drive was detached at the time).

v0.8.1 — 2026-06-05

Fixed

  • A failed USB unmount (federver → 11 → 3) now tells you what's holding the drive, instead of printing a command and quitting (setup.sh). When umount failed with target is busy, the menu printed See what: sudo fuser -mv <mp> and returned — leaving the user to run the diagnostic themselves and decode the output. In practice the blocker is rarely just the two services the warning names: a container bind-mount, a desktop app (e.g. Pragha left playing a track off the drive), or any shell cd'd into the mount will all pin it. The failure path now runs the diagnosis itself via the new _storage_show_busy helper and prints, in plain words, every holder it finds: Docker containers whose bind-mounts sit on the drive (each tagged with "stop it: federver → 7 → 3") and every process with a file open or cwd there (PID + command line, via sudo fuser -m). Only the *busy*/*in use* branch triggers the scan; other umount errors keep the original short guidance. Validated live against the server — it correctly fingered a stray pragha process and named the service containers when they were the ones binding /mnt/data.
  • Unmount USB (federver → 11 → 3) no longer lists bare disks or reports a fake success (setup.sh). The picker built its list with lsblk -rno NAME,MOUNTPOINT,SIZE | awk '$2!=""'; for an unmounted parent disk the empty MOUNTPOINT field collapsed under -r, so the size landed in $2 and the whole disk (e.g. /dev/sdb) showed up as a "mounted drive". Picking it ran umount /dev/sdb — not a mountpoint — which failed, but 2>/dev/null || true swallowed the error and ✓ Unmounted printed anyway (fstab survived only thanks to a UUID guard). The list is now built from findmnt per partition, so only genuinely mounted partitions appear; umount's exit code is checked and a failure reports the real error plus sudo fuser -mv <mp> to find what's holding the drive; the automount unit is stopped first so it can't silently remount; and fstab is backed up and only reported as changed when it actually was.

v0.8.0 — 2026-06-05

Changed

  • Copy step in every backup/sync flow now asks intent in plain words and previews the result as a tree, instead of exposing rsync's trailing-slash rule (setup.sh). The old "Copy folder / Copy contents" prompt made users mentally map an abstract choice (really src vs src/) onto what would land on disk, with only a "Tip" line as a safety net — the #1 source of messy directories. The shared _pick_copy_mode helper now asks "How should X be copied? 1) The whole folder 2) Only its contents" and then draws a tree of exactly what the destination will look like for that choice, e.g. /mnt/data/nbackup/ └── New Backup/ ├── ashry …. You confirm by seeing the layout, not by remembering slash semantics. The top-level listing is capped at 6 entries (… and N more), and for a root-owned 0700 source it can't read without sudo it shows (can't list without sudo — everything will still be copied) rather than a misleading empty tree. Applied once to the helper, so it fixes all six flows at once: the four one-time backup directions (federver → 14 → 5) and both scheduled upload/download jobs (federver → 14 → 2). Trailing-slash handling is unchanged (the pickers already strip them and the helper controls the slash), so behaviour is identical — only the presentation is clearer.
  • Immich backup (privcloud → 9) confirm now shows the same destination tree, for visual consistency (privcloud). This is a separate program from federver and never had the folder-vs-contents ambiguity — its layout is fixed (<dest>/privcloud-backup/photos/ + /postgres/, always contents-into-subfolder) and its only question is Append vs Mirror. The confirm just now previews the structure (├── photos/ (114 GB), └── postgres/ (598 MB)) before you commit, matching the look of the federver flows.

Fixed

  • Status (federver → s) now shows every mounted drive, not just three hardcoded paths (setup.sh). The Disk section ran df -h / /home /mnt/data — a fixed list — so any drive mounted elsewhere (e.g. a "New Backup" disk at /run/media/<user>/New Backup) was invisible, even though it was installed and mounted. It now enumerates all real block-device filesystems (df -h filtered to /dev/*, excluding loop/tmpfs/overlay/docker pseudo-mounts and de-duped by device), so newly added internal or USB drives appear automatically. The existing Internal-vs-USB split (by lsblk TRAN=usb) is unchanged and now classifies the extra rows too. Also fixed the row formatter to handle mountpoints with spaces (e.g. "New Backup"), which previously would have been truncated at the first space. Same hardcoded-path fix applied to Storage → Status's disk-usage line (federver → 11 → 1).

Added

  • Server-to-server option in the one-time backup wizard (federver → 14 → 5 → 4). A fourth direction, Server: server → server, copies between two paths on federver itself — e.g. backing up /mnt/data (the HDD) to a backup USB plugged into the server — without routing the bytes through the laptop. It reuses the existing server-path and copy-mode pickers, mirrors the download flow (no source-size preview, since that would need a passwordless sudo du), and runs sudo rsync on the server over a single ssh -t session. sudo is required because Immich's data is 0700 root-owned after the v0.7.4 hardening; the -t flag gives the interactive sudo prompt a tty, and single-quoted paths handle spaces (e.g. a drive labelled "New Backup"). The destination is typed (with a hint pointing at /run/media/<user>/<label>) and mkdir -p'd before the copy.

Documentation

  • Recorded the backup/sync copy-UX redesign (docs/prd-backup-sync-ux.md). Captures the trailing-slash problem, the options weighed (keep-both-show-result vs single-rule vs reword), and the decision (intent-first wording + tree preview, applied to the one shared helper). The customer guide's Sync/Backup section was updated to describe the plain-words choice + tree preview and the new server→server direction.

v0.7.5 — 2026-06-04

Fixed

  • Services that bind-mount the data drive no longer fail silently after a drive swap, and the menus stop reporting false success (setup.sh). Replacing the USB HDD left a stale /etc/fstab line from the old drive sitting above the correct one — two entries for the same /mnt/data mount point — so systemd's x-systemd.automount generator bound the wrong device and every access died with Docker's mkdir /mnt/data: no such device. The autofs stub it leaves behind still made mountpoint -q return true, so every menu path reported success while nothing actually mounted: Mount USB said "✓ Already mounted", and Start/Deploy printed "✓ complete" right after Docker printed the failure. Four fixes: (1) _storage_mount (federver → 11 → 2) now detects and offers to remove stale/duplicate fstab entries for the same mount point (timestamped backup first), then mounts and verifies a real /dev/* device is present via findmnt instead of trusting mountpoint -q; (2) new _require_data_mount guard runs as a pre-flight before _services_action start and step_deploy, blocking with clear guidance ("mount it first: federver → 11 → 2") rather than letting Docker write empty dirs to the system disk under the unmounted stub; (3) _services_action start/stop/restart now check Docker's actual exit code and print ✗ failed with a logs pointer instead of an unconditional ✓ complete, and step_deploy reports real docker compose failures; (4) Storage → Status (federver → 11 → 1) flags any /mnt/* data path whose disk isn't really mounted. The guard pokes the automount and retries briefly so a slow-spin-up disk isn't a false positive.

Documentation

  • Recorded the Headscale evaluation and the decision to stay on hosted Tailscale (docs/prd-headscale.md). Considered swapping Tailscale's proprietary coordination server for a self-hosted Headscale (hwdsl2/docker-headscale) to make the stack OSS end to end. Declined: no substantial driver. The Tailscale client is already open source — only the coordination server is the SaaS piece, so the gain is narrow — and self-hosting it requires a public, TLS-terminated HTTPS endpoint reachable by every device, which directly violates our "expose nothing at home, tunnel everything through the tailnet" posture and adds attack surface, new failure modes (we'd own control-plane uptime; key expiry/re-auth/DNS pushes break during an outage), iOS re-auth friction, and reverse-proxy/DDNS complexity that cuts against the lightweight-over-complex rule. The free tier (100 devices/3 users) isn't a constraint we're near. The PRD also notes the load-bearing AdGuard "Override local DNS" path is reproducible in Headscale (dns.override_local_dns) and records revisit triggers — and that if ever adopted it belongs on a VPS, never on federver.

v0.7.4 — 2026-05-23

Security

  • Removed hardcoded server identity from the repo (setup.sh). SERVER_USER and SERVER_IP were baked in as literals — which, on a public repo, both leaked the maintainer's setup and meant anyone else who cloned it had federver trying to SSH to the wrong box. They now resolve at runtime: environment variables → a local config file at ${XDG_CONFIG_HOME:-~/.config}/federver/config → a one-time interactive prompt that persists the answers (written with umask 077). New _require_server_config helper runs once on the first laptop invocation, before the menu. EOF/non-interactive stdin exits with a clear message instead of an opaque set -e death. Also degenericised the remaining personal values: the sync source-picker no longer hardcodes $HOME/PycharmProjects / /stuff (now Documents/Pictures/Downloads) and USB mounts glob /run/media/$USER and /media/$USER (was /run/media/hamr); guide examples use <user>@host and a generic IP.
  • Fixed shell command injection in the FileBrowser password set/reset (setup.sh). The password was interpolated into a sg docker -c "… --password '$pass' …" string; a single quote in the password broke out of the quoting and ran arbitrary commands (confirmed with a PoC). The password is now passed through the container's environment (docker run -e FB_NEW_PASS … --entrypoint sh -c '… "$FB_NEW_PASS" …') and never appears in any shell string — special characters can't break it or inject, and the value no longer shows up in ps. Verified injection-safe end-to-end against the real filebrowser image.
  • Password-set failures are no longer swallowed. Both the deploy and reset paths redirected the users update result to /dev/null and unconditionally reported success — a silent failure would leave the default credentials active while printing a password that doesn't work. They now check the exit status: deploy warns and points at federver → r → 1; reset reports the old password is unchanged. The reset prompt also reads with read -rs (no echo to terminal/scrollback).
  • Tightened data-directory permissions (privcloud). fix_permissions set the Immich upload and Postgres data dirs to 0777, leaving private photos and the database world-readable/writable to every local user. Now 0700 — the containers run as root and read/write regardless of owner, so nothing breaks (Postgres already forces 0700 on its data dir, so the old 777 there was a misleading no-op).
  • Other hardening. The FileBrowser password file (~/.privcloud/filebrowser.pass) and the .env copy written into a backup are created/locked to 0600 (the latter holds the DB password); /etc/wireguard is chmod 700 before any private key is written, closing the brief world-readable window between tee and chmod.

v0.7.3 — 2026-05-21

Changed

  • All smart playlists now exclude the Holy Quraan folder. Added { "notContains": { "filepath": "Holy Quraan" } } to every .nsp so Quran recitations never get shuffled into the music playlists (they remain browsable/playable in Navidrome directly). Field filepath and operator notContains confirmed against Navidrome's model/criteria source. tools/navidrome/README.md documents the pattern for excluding any other folder.

v0.7.2 — 2026-05-21

Changed

  • Replaced Fresh Additions with year-based playlists. Fresh Additions keyed off each file's "date added," which Navidrome derives from the file modification time — and on a static library of older files (timestamps from 1998–2007) nothing ever registers as recent, so the playlist was permanently empty. Removed it and added three year-tag-driven shuffles instead: 📅 90s (1990–1999), 📅 2000s (2000–2009), and 🕰️ Throwback (everything before 2010). These read the song's year ID3 tag rather than the file date, so they suit a fixed nostalgic collection. They depend on year tags being present; if empty, the library needs tagging (e.g. MusicBrainz Picard). Surprise Me remains the tag- and history-independent always-works shuffle.

v0.7.1 — 2026-05-21

Changed

  • Loosened smart-playlist thresholds so they populate on a fresh or lightly-played library. Daily Mix now matches loved or played ≥1× (was >2); On Repeat matches playCount >1 (was >3); Rediscover triggers after not-played-in-30-days (was 180); Fresh Additions widened to a 90-day window (was 30). The strict defaults left every history-based playlist empty until a library had weeks of plays. Note: Fresh Additions keys off the file's "date added," not the song's release year — a static library of older files with old timestamps may still not register as "fresh" regardless of window.

v0.7.0 — 2026-05-21

Added

  • Navidrome smart playlists — auto-installed, no code, no API (tools/navidrome/playlists/*.nsp). Five ready-to-use .nsp rule files that Navidrome turns into self-updating playlists built from your own library and play history: Daily Mix (loved or well-played tracks not heard in the last 7 days, shuffled), Rediscover (stuff you used to play but haven't in 6 months), On Repeat (most-played of the last month), Fresh Additions (added in the last 30 days), and Surprise Me (full-library shuffle). They read data Navidrome already tracks — playCount, lastPlayed, loved, dateAdded — and re-evaluate on each scan, so no scripts or API calls are involved. New _install_smart_playlists helper in setup.sh copies them into $MUSIC_LOCATION/Playlists/ automatically at the end of Deploy (seeded before Navidrome starts, so its startup scan finds them) and after Storage → Change music location (so they follow the folder when it moves — the files live inside the music folder, so a moved folder would otherwise lose them). No menu entry; it just happens. tools/navidrome/README.md documents the rule format; docs/prd-music-recommendations.md records the ListenBrainz (Tier 2) and custom-cron (Tier 3) follow-ups, plus the honest caveat that random sort reshuffles per load rather than producing a fixed daily set.

Changed

  • Deploy prompt explains what it does and how to back out (federver → 7 → 6). The "Base data path" step now opens with plain-language text: that it starts/updates the service containers, that it's safe to re-run and does NOT erase photos/music/files, that unchanged services are left running untouched, and what the "base data path" actually is (the one parent folder where all data lives — pick once, press Enter to keep the default). The prompt itself reads [Enter = /mnt/data, 0 = cancel], and typing 0 returns cleanly to the menu. Previously the only way out was Ctrl-C, which killed the ssh -t session to the server and painted a misleading ✗ FAILED banner even though nothing had broken.

Fixed

  • Redeploy no longer silently reverts a custom music location. step_deploy rewrote every location from the base data path unconditionally, so a MUSIC_LOCATION set via Storage → Change music location got clobbered back to <base>/media/My Music the next time anyone pressed Enter through Deploy — and the smart-playlist files living inside that folder would appear to vanish with it. Deploy now detects a custom music folder (comparing against both the base-derived default and the .env.example default, so fresh installs aren't falsely flagged) and asks Keep it? [Enter = keep, or type 'reset'] before changing anything.

v0.6.1 — 2026-05-19

Changed

  • README restructured for non-tech readers. Goal: someone with no terminal experience can land on the repo and understand within one screen what they need, what they'll do, and what they'll get. Tagline + 1-line description first, then Two ways to run it (Immich-only vs Full home server) with the actual menu screenshots inline (docs/images/menu-privcloud.png, docs/images/menu-federver.png) instead of ASCII renderings — visuals do the orienting that 30 lines of text used to. Below that, Setting up the full server has a 5-bullet "What you need" prereq block (mini PC, laptop on same network with macOS/Linux/WSL, USB stick, ethernet, ~30 min, optional Tailscale) and a 5-step "What you'll do" skim that ends with a link to the customer-guide click-by-click. Service list rewritten in plain English ("Phone photo backup with face recognition" instead of 2283 — Immich). Removed from README: the three ASCII menu blocks (laptop + server federver + privcloud), the fedvpn menu, the 25-row Quick reference table, the Files list, the bash/dry-run paragraph. Net: README dropped from 194 lines to ~95.
  • customer-guide.md absorbs the relocated bits. New Dry-run mode subsection under Server setup walkthrough (before BIOS setup). New Quick reference section at the end of the doc — menu numbers corrected from the old README table (WireGuard 11→2 and Syncthing 14→2 were stale; current menu has WireGuard at 9 and Syncthing at 12). New Files in the repo section at the end. The guide's existing "Two modes" framing collapsed from 15 lines to 4 (kept for direct-link readers but dropped the "Good for / Needs" detail the README's prereqs now establish). Everything else in the guide left alone — the text-form federver menu, the privcloud CLI table, the Immich feature list, and the Server setup walkthrough remain since they serve a different purpose than the README's at-a-glance framing (searchable reference vs visual skim). Clean handoff: README orients in <1 screen, guide drills in.

v0.6.0 — 2026-05-11

Added

  • One-time backup wizard (federver → 14 → 5). New menu option for ad-hoc rsync transfers that run once and disappear — no cron line, no timer unit, no job script saved to ~/.local/bin. Three directions, labeled to match the existing "Sync - New" flow: Upload (laptop → server), Download (server → laptop), Local (laptop → laptop, e.g. backup to a USB drive plugged into the laptop). Reuses the same source/destination pickers as option 2 — /run/media/hamr/* USB mounts auto-listed with size, server's /mnt/data USB exposed in the server presets, plus the copy-folder-vs-contents picker so the destination path layout is explicit. Shows a preview block with source size + final destination path and asks Proceed? [y/N] before touching anything. Built for the "I want to back something up right now and not have it run again automatically" case — distinct from option 2's scheduled jobs.

Changed

  • Menu #14 reorganised with category prefixes. Entries now read Status (1), Sync - New (2), Sync - Edit job (3), Sync - Delete a job (4), Backup - One-time (5), Backup - Immich DB (scheduled) (6), Monitor - Disk space (7). Three categories — Sync (rsync between laptop and server), Backup (one-time rsync + scheduled Postgres dump), Monitor (heartbeat) — make the menu scannable at a glance and disambiguate the new one-time backup from the recurring Immich one. Status keeps its bare name because it spans every category. The (scheduled) tag on Immich DB makes the contrast with option 5's ad-hoc backup explicit without relying on the dim hint text. Dispatch functions and behavior unchanged.
  • Path-picker helpers lifted out of step_sync to module scope. _list_local_sources, _pick_local_path, _pick_server_path, and _pick_copy_mode are now top-level functions instead of nested inside the Sync - New wizard, so the new one-time backup wizard reuses them verbatim without duplicating ~140 lines. Pure refactor — behavior of option 2 is unchanged.
  • package.json added at repo root. Single source of truth for the project version, license, repository URL, and keywords. README version badge re-pointed from github/v/tag/... to github/package-json/v/... so it tracks package.json (updates immediately on push) instead of waiting for a git tag. setup.sh:FEDERVER_VERSION and package.json:version are now both at 0.6.0; bump both on release.

v0.5.1 — 2026-05-09

Fixed

  • USB drives no longer silently skip mounting on boot, leaving an empty stub directory that looks like data loss. New fstab entries written by _storage_mount (federver → 11 → 2) now use nofail,x-systemd.automount,x-systemd.device-timeout=10s instead of defaults,nofail. The old options told systemd to keep going if the USB enumeration race made the drive unavailable at fstab-mount time — and never retry — so users opened /mnt/data/media/My Music after a reboot or runtime hot-unplug and saw an empty directory on the root NVMe disk under the unmounted stub. Looked exactly like data loss; was actually intact data on an unmounted disk. x-systemd.automount mounts on first access (dodging the race) and survives replug; nofail keeps boot resilient; device-timeout=10s bounds the wait. After writing the entry the script now also runs systemctl daemon-reload so the new automount unit registers without a reboot.
  • Storage Status (federver → 11 → 1) detects fstab entries written by older versions and offers in-place repair. New _storage_check_fstab_options runs at the bottom of Status. If any non-comment fstab line uses the old defaults,nofail options, Status appends a yellow warning naming the affected mount points and prompts Repair now? [Y/n]. Y backs up /etc/fstab to fstab.bak.YYYYMMDD-HHMMSS, rewrites every matching line in place, runs daemon-reload, unmounts the affected paths, and mount -a. Lets existing users (anyone running the old script) self-heal without sed surgery.

v0.5.0 — 2026-05-05

Changed

  • Menu reshape: steps 8 + 9 folded, "Manage sync" → "Manage sync and backups" (federver → 14). Steps 8 (Setup backups + disk monitoring) and 9 (Configure log rotation) removed from the main menu. Top-level entries drop from 17 → 15. Renumbered: Tailscale 10→8, WireGuard 11→9, AdGuard 12→10, storage 13→11, Syncthing 14→12, remote desktop 15→13, Manage sync 16→14, Save to pass 17→15. "Run all (3-9)" becomes "Run all (3-7)". The two halves of step 8 — Immich DB backup and disk-space monitor — are now wizards inside #14 (options 5 and 6), so they live next to the sync jobs they conceptually belong with and show up in the same Scheduled tasks + Last runs tables. Step 9 (log rotation) is now invoked automatically at the end of step 7's deploy flow — it's Docker hygiene, not a user choice. README.md and customer-guide.md updated for new numbering throughout.
  • Immich DB backup migrated from cron to systemd timer (federver → 14 → 5). New wizard: pick destination (default unchanged), pick cadence (daily → keep 7 / weekly + day → keep 21 = 3 weeks). Generates /etc/systemd/system/immich-backup.{service,timer} with Persistent=true (catches up missed runs after a server-off window — the key gain over cron, which silently skips them), Restart=on-failure with RestartSec=30min and StartLimitBurst=3 (3 retries within 3h before giving up until next scheduled fire), and RandomizedDelaySec=15min to avoid all-at-once load. The script's pg_dumpall | gzip core is unchanged; output still goes to /var/log/immich-backup.log so the Last-runs parser keeps working. Re-running the wizard tears down any legacy step-8 cron line first, so users migrate cleanly. The Scheduled tasks table now reads OnCalendar from the timer when present, falls back to the cron line tagged "(legacy cron)" otherwise.
  • Disk-space monitor wizard (federver → 14 → 6) refuses silent-fail installs. Old step 8 always installed the cron line, even when the user pressed Enter on the Kuma URL prompt — leaving /usr/local/bin/disk-check.sh without PUSH_URL, so cron ran every 5 min but Kuma never got a heartbeat (monitor stayed dead, no error visible). New wizard: if the URL is skipped, it removes any half-installed state (cron line + script) and tells you to come back when you have one. If provided, it installs script + cron + fires an immediate heartbeat so Kuma turns green within ~6 min. The pre-existing Kuma "Disk Space" monitor setup instructions move into the wizard intro.

Fixed

  • Last-runs server-log SSH had no tty, sudo silently failed. The non-tty SSH I added in v0.4.0 used sudo test/stat/tail on /var/log/*.log, but with no tty sudo can't prompt and exits non-zero. Every server row rendered as "never" — masking real state (a healthy immich-backup looked dead; a disk-check with no PUSH_URL looked green via the "no alerts" branch). Logs are mode 644 root-owned by default, so the unprivileged SSH user can read them directly. Dropped sudo from the test/stat/tail calls. (Already shipped in c3be917.)

v0.4.0 — 2026-05-05

Added

  • "Last runs" table on the sync Status screen (federver → 16 → 1). Second table appears under the existing "Scheduled tasks" table, showing each scheduled job's last execution time and outcome at a glance — no more drilling into Edit → View log to find out whether last night's backup succeeded. Laptop sync jobs (cron + timer) parse the Sync finished: <name> (exit N) marker the script template emits at exit, so success shows ✓ ok and failures show ✗ exit N. immich-backup uses the server log mtime + tail-grep for Backup complete. disk-check is special-cased: its log only grows on threshold breaches, so an empty/missing log renders as ✓ no alerts (no fake "last run" timestamp). Reuses the SSH sudo timestamp the function already primes — one extra SSH call for both server logs, no extra password prompt.
  • Edit sync job + systemd-timer schedule option (federver → 16 → 3). Manage-sync menu reshaped to four top-level items: Status, New sync, Edit sync job, Delete. Picking Edit lists all jobs (cron + timer, active + paused) and opens a per-job submenu: change schedule, pause/resume (toggle adapts to current state), run now, view last log (tail 30 or journalctl --user for timers), delete. Changing schedule re-enters the same picker used at creation — switching between cron and systemd-timer flavors in place is supported (the old side is torn down first). The schedule picker gained option 4) Once a week, any time which creates a ~/.config/systemd/user/sync-*.{service,timer} pair with OnCalendar=Sat|Sun|weekly, Persistent=true, and RandomizedDelaySec=6h. loginctl enable-linger runs automatically so timers fire when you're not logged in. This is the right pick for weekly laptop jobs that shouldn't be lost if the machine was off at the specific cron minute — cron has no catch-up semantics; systemd timers do. Status screen now also lists timer-backed laptop jobs alongside cron ones. Old standalone Pause/Resume/Run-now entries removed from the top-level menu since Edit subsumes them.
  • AdGuard upstream + fallback DNS guide (federver → 12 → 2). New submenu option on the AdGuard management screen prints a recommended DNS config table: upstream = Cloudflare + Quad9 DoH, bootstrap = 1.1.1.1 + 9.9.9.9, fallback = Google 8.8.8.8 + 8.8.4.4, load balancing = parallel requests. Same block also auto-prints at the end of a successful fresh install (after the Tailscale guide). Point is resilience — three independent providers over encrypted DoH, so if AdGuard's primary upstream flakes the fallback catches it and traffic keeps resolving. Same table added to the AdGuard section in customer-guide.md.

Changed

  • Menu header redesign — version + tagline, no more === border. Replaces the four-line ======================================== block with a three-line header: federver vX.Y.Z — Fedora XFCE Server Manager · <laptop|server> (server/laptop colored inline), tagline One box. Every service. No cloud overlords., and a thin separator. New FEDERVER_VERSION constant at the top of setup.sh is the single source of truth — bump it on release and the header tracks. Server menu drops the redundant "Running from: server" line (header already shows it). Saves one screen row vs. the old banner and gives the tool a recognizable identity instead of generic ASCII bars.
  • Wizard exits: 0) Back in menus, q in text prompts. Every numbered menu now shows 0) Back as the last item (was 0) Cancel); the 0 value is handled explicitly instead of falling through to "Invalid choice." Free-text prompts inside multi-step flows (New sync path pickers, copy-mode picker, job-name prompt) accept q to bail out — hint is printed inline, e.g. Choose [number, path, q=back]. Return codes propagate up so the whole wizard exits cleanly in one keystroke. Exit is one-shot (drops back to the main federver menu, not one step up) because these wizards are shallow and users want out, not to navigate. Replaces Ctrl+C as the only escape hatch mid-flow.

Fixed

  • Sync job name parsing. The status table showed job names on two lines because the regex matched both the script path (sync-pycharmprojects.sh) and the log path (sync-jobs/pycharmprojects.log). Added a (?=\.sh) lookahead so only the script filename is captured.
  • Copy mode clarity. The folder/contents picker now shows the actual resulting path layout for each option, plus a tip: "if your destination already ends with /FolderName, pick Copy contents."
  • Custom cron cheat sheet + confirmation. Entering a custom cron expression now shows a visual diagram of the 5 fields (minute, hour, day-of-month, month, day-of-week) with examples, translates the expression to English ("every Sunday at 2am"), and confirms before saving. Retries up to 3 times if the user rejects. The schedule section also notes upfront that the job runs once immediately after creation.
  • Cron translator expanded. _cron_to_english now handles day-of-week ("every Monday at 2am"), PM hours ("daily at 5pm"), comma-separated hours ("daily at 9am, 6pm"), midnight, and noon. Previously only handled preset patterns and fell through to the raw expression for anything else.
  • Code review sweep (9 findings). Replaced all 6 remaining source .env calls with the safe _env_get reader (prevents breakage on unquoted values with spaces). Replaced eval with bash -c in sync command execution (removes injection surface). Added sudo -v SSH pre-auth before heredoc captures in save-to-pass and uninstall functions. Removed hardcoded /home/ahassan (→ $SERVER_USER) and /home/hamr (→ $HOME). Fixed _cron_to_english to handle PM hours, midnight, and noon. Removed dead found_any subshell variable and unreachable code in sync job list. Optimized _pick_container from N+1 docker calls to a single batch query.

Changed

  • Unified service management (federver → 7). Start/Stop/Restart now show a unified picker with laptop services (Tailscale, Syncthing) and server containers in one list. Both-sides services appear once and control both laptop + server when picked. Stopped and removed ("not created") containers always visible. "Not created" entries redirect to their install option (12/14). Status view (7→1) renders with ✓/✗ icons + CPU/mem stats matching the top-level Status screen, includes laptop services above server containers. Stop All requires confirmation.
  • Emergency restart on server menu. New e) Emergency: restart all services on the server's reduced menu. Restores systemd-resolved DNS fallback, starts all Docker containers (compose + standalone), re-disables stub once AdGuard is back. Works without SSH/internet/DNS — the "ER lever" for when AdGuard goes down and DNS breaks.
  • Auto-pull on every SSH hop. _on_server now runs git pull --ff-only -q before executing any step. Server always has the latest code. Eliminates stale-checkout issues without manual intervention.
  • Split menu: server shows 3+1 options, laptop shows everything. Running federver on the server now shows only step 1 (Enable SSH), Status, and Power — the three things you'd actually do at the server console. Any other input prints "Run federver from your laptop for the full menu." Running from the laptop shows the full menu as before. Replaces every _on_laptop redirect message, every "use option 14 instead" special case, and every FAILED banner when picking a laptop-only option on the server. One structural decision at the top instead of dozens of guardrails scattered through submenus.
  • Syncthing + Tailscale lifecycle actions now affect both sides. If the install touched both laptop and server, Start/Stop/Restart/Uninstall do too. Syncthing submenu (from laptop): Start/Stop/Restart both = laptop systemd service + server Docker container in one action. Uninstall both = laptop dnf remove + server container removal + firewall cleanup, with typed-name confirmation. Tailscale submenu (from laptop): Connect/Disconnect/Restart both = tailscale up/down/systemctl restart tailscaled on both sides. Uninstall both = logout + disable + remove on both, phones untouched. When run directly on the server, both fall through to server-only submenus (unchanged). WireGuard stays server-only — server-only install, server-only lifecycle.

v0.3.0 — 2026-04-16

Changed

  • Service Stop/Start simplified (federver → 7, 14). Collapsed Stop/Suspend/Resume into two actions: Stop (docker update --restart=no + docker stop — stays off across reboots) and Start (docker update --restart=unless-stopped + docker start — stays running across reboots). Restart just bounces the container without changing restart policy. Applies to Manage services submenu and Syncthing submenu. Removes the separate Suspend/Resume menu entries — Stop and Start now handle restart-policy management automatically.
  • Manage sync replaces Sync files (federver → 16). Menu label, README, and customer guide updated to reflect the expanded scope: one-shot transfers, scheduled cron jobs, and file deletion.
  • AdGuard install (federver → 12) three big UX fixes:
    • No more port-3000 wizard detour. The install pre-seeds a minimal /opt/adguard/conf/AdGuardHome.yaml that sets http.address: 0.0.0.0:80 and nothing else. AdGuard's native first-run wizard then runs on port 80 directly (instead of its hardcoded 3000 default), so the user opens http://<server-ip> in a browser, clicks through AdGuard's own setup wizard there, and is done. No firewall shuffle for port 3000, no credentials collected in the terminal, no htpasswd / bcrypt dependency. Keeps all of AdGuard's defaults (DNS upstreams, filter lists, etc.) because AdGuard's wizard sets them itself.
    • systemd-resolved cleanup now explains itself. Previously ran silently — scary if you have a custom DNS setup. Now prints a yellow block describing exactly what will change (drop-in file path, resolv.conf symlink, systemd-resolved restart), notes that local name resolution still works, and asks Disable the stub listener and continue? [Y/n] before touching anything. Cancel path is safe — no changes made.
    • Tailscale is pre-checked, not post-checked. Previously the "install Tailscale" warning only appeared at the end of the install, after the system was already changed. Now the step detects Tailscale up front and, if missing, offers 1) Cancel and install Tailscale first (recommended), 2) Continue anyway (manual per-device DNS), 0) Cancel. No wasted work for users who hit the step before installing Tailscale. Re-running the step when AdGuard is already up also prints the Tailscale DNS guide if Tailscale is detected, so you can still get to the admin-console instructions without a fresh install.
    • Tailscale DNS guidance wording corrected to match the actual admin UI (DNS tab → Nameservers → Global nameservers → Add nameserver → Custom, then toggle Override local DNS after saving).
  • Status screen (s) rewritten for triage-first reading order. Dropped the Management section (boilerplate commands that never change). Added a Server block with uptime and 1/5/15m load average, a Memory block with RAM used/total/available and Swap when non-zero, a Disk block split into Internal and USB sub-sections (so you can see at a glance which drive is cold-backup-candidate), and per-container CPU% and memory usage next to each container line (from docker stats --no-stream). Sections ordered Server → Memory → Disk → Containers → Service URLs → Data paths — identity, resources, processes, endpoints, storage. Service URLs now also include AdGuard and Syncthing when those containers are up. Container-row classification fixed: containers without a Docker HEALTHCHECK (AdGuard, Syncthing) used to get a yellow !! because the status string didn't contain healthy; now a plain Up X counts as OK (), only unhealthy/exit/restart states flag as failure.
  • Auto-routing refinements. _on_laptop failure now prints an actionable message listing the three laptop-only steps (Sync files / Save to pass / SSH key auth) and their reasons, plus the exact exit + cd + ./setup.sh commands. step_power (p) no longer SSH-hops when already on the server — runs sudo shutdown/sudo reboot locally. step_ssh (step 1) gains an SSH_CLIENT/SSH_CONNECTION guard so running the bootstrap step inside an SSH session fails fast with "SSH already works, skip to step 2", plus a heuristic guard against running it on a known laptop hostname (while still allowing fresh-install defaults like fedora / localhost*).
  • WireGuard submenu (federver → 11) gains a Status-first option (1) Status) that runs sudo wg show wg0 and prints the interface state, listen port, and per-peer handshake timestamps, endpoints, and rx/tx bytes. Matches the Status-first pattern of the Tailscale, Services, and Storage submenus. Also removed a stray clear in the Linux-peer setup flow that was wiping scrollback of the peer config you had just copied.
  • Disk-check monitor (federver → 8) made smoother to set up. Cron cadence dropped from hourly to every 5 minutes so Kuma sees a pulse well before the interval expires. The step now also fires the disk-check script once immediately after installing it, so the Kuma monitor turns green right away instead of waiting for the first cron tick. The wizard tells you the exact values to paste into Kuma's monitor form (Heartbeat Interval 360, Retry Interval 60, Max Retries 2) so a fresh setup no longer needs a "why is this red?" debugging round. Re-running step 8 drops any existing disk-check cron line before re-adding it, so old hourly installs migrate cleanly.
  • Storage status display (federver → 13 → 1) reorganised around the data / media / immich mental model. Previously listed four cryptic rows (Files / Music / Immich / Database) and showed red not set when a path was unset in .env — even though the container might be running fine off a Docker volume. Now it falls back to docker inspect and reports the actual mount source — a real host path when bind-mounted, (Docker volume: <name>) or (Docker anonymous volume) when not. No more alarming "not set" rows on a working system.

Added

  • Uninstall actions on every Extras submenu (federver → 10 Tailscale, 11 WireGuard, 12 AdGuard, 14 Syncthing, 15 Remote desktop). Each has a red DELETE <service> block listing exactly what will happen, what breaks afterwards, and what's kept. Confirmation requires typing the literal service name (adguard, syncthing, tailscale, wireguard, or xrdp) — blank Enter cancels cleanly. Stops + removes the container/service, closes only the firewall ports that service opened, reverts service-specific system changes (AdGuard: re-enables the systemd-resolved stub listener; RDP: re-enables lightdm), and keeps /opt/<service> and /etc/wireguard/ config directories intact so a reinstall picks up where you left off. Core privcloud services are never touched.
  • Manage services (federver → 7) lifecycle is now per-container. Start, Stop, Restart, Suspend, and Resume each show a numbered list of containers with "All" as option 0 — pick one or everything. Suspend = docker update --restart=no + stop (stays down across reboots until resumed). Resume = docker update --restart=unless-stopped + start. Submenu goes from 6 entries to 9 without losing the old Start/Stop/Restart all behaviour, now accessed via "All" in the picker.
  • Manage Syncthing option (federver14). Real-time bidirectional folder sync between devices, peer-to-peer. When run from the laptop, the step installs + configures both sides in one shot: laptop gets sudo dnf install -y syncthing + systemctl --user enable --now syncthing, then the step SSHes to the server and launches syncthing/syncthing:latest as a Docker container with --network=host, --restart=unless-stopped, STGUIADDRESS=0.0.0.0:8384 (so the web UI is LAN-reachable — default binds to 127.0.0.1 which is useless with host networking). Firewall opens 8384/tcp, 22000/tcp+udp, 21027/udp. Reads .env via a safe parser and bind-mounts the three semantic privcloud paths — data (FILES_LOCATION), media (MEDIA_LOCATION), immich (parent of UPLOAD_LOCATION) — each at the same host path inside the container, so the UI's Add Folder dialog shows real disk paths with no mental remapping. Reads both Device IDs (laptop + server) and prints them for pairing. Robust against the Syncthing 2.x CLI restructure (old --device-id flag was removed) — the helper tries several command shapes and falls back to reading config.xml directly. Re-running once installed opens a submenu: refresh status, show Device ID, show sync paths, reapply paths from .env (rebuilds the container with fresh mounts while keeping pairings + folder shares intact), start, stop, restart, logs. Tools menu renumbered: Sync files 15→16, Save to pass 16→17. Remote desktop 14→15.
  • Save to pass now also captures the Syncthing identity: device ID (under privcloud/syncthing/device_id), config.xml (folder shares, peer list, GUI credentials), and the cert.pem + key.pem pair that IS the node's cryptographic identity. Losing cert/key means re-pairing every client, so these are critical to back up. Status screen and 7 Manage services → Status now also list Syncthing under Service URLs (local + Tailscale) when the container is running, matching the existing AdGuard conditional.
  • Tailscale laptop auto-install. federver → 10 now mirrors the Syncthing two-sided pattern: when run from the laptop, it sudo dnf installs Tailscale + enables tailscaled locally, runs sudo tailscale up (auth URL appears in the terminal, click + approve), then SSHes to the server for the server-side install. Phones and non-Fedora systems still get the existing informational pointers — they can't be automated from here.
  • Stale-checkout guardrail in the --run CLI dispatcher. When the laptop's setup.sh adds a new step function and the server's checkout hasn't been pulled yet, the _on_server SSH hop used to fall through to bash's cryptic "command not found" trace. The dispatcher now checks declare -F for the requested step name first and, if the function isn't defined, prints an actionable message telling the user to cd ~/privcloud && git pull on the server and re-run the menu option.
  • _env_get safe reader for .env values. Shell's built-in source splits on whitespace and chokes on unquoted values with spaces (e.g. MUSIC_LOCATION=/mnt/data/media/My MusicMusic: command not found). The new reader greps one line, strips optional surrounding quotes, and preserves spaces verbatim. Used by the Syncthing helpers; other source .env callsites stay as-is since they only read vars that don't contain spaces in practice.
  • ./setup.sh --dry-run mode. Walks the full menu and prints each state-changing command (sudo ..., sg docker ..., curl ..., rsync ..., tailscale up|down) instead of executing it. Read-only queries (hostname, tailscale ip, docker ps) still run so the display logic works. The dry-run flag propagates across the _on_server SSH hop, so running ./setup.sh --dry-run from the laptop still gives you a safe walkthrough of any server-side option. Menu header shows a "DRY RUN" banner whenever active.
  • federver → 7 → 1 (Services status) now also shows the Tailscale / MagicDNS URL list alongside the LAN URL list when Tailscale is up. Fixes the missing http://federver:PORT addresses that used to only appear in the top-level Status screen.
  • Immich i letter shortcut in the federver main menu. Opens the privcloud CLI directly (via SSH when run from the laptop). Replaces the static "Run: privcloud ..." note that previously sat under the Immich section — now it's an actual menu action like s, p, r.
  • federver → 6 (Manage firewall) gains list/status/add/remove/defaults submenu. Previously a single "configure firewall" action that re-applied a hard-coded set. Now you can open/close arbitrary ports or services without editing the script.
  • federver → 7 (Manage services) gains status/start/stop/restart/logs/redeploy submenu covering every Docker container (Immich + Navidrome + FileBrowser + Uptime Kuma + Watchtower + AdGuard). Deploy is option 6 in the submenu, still the fresh-install flow.
  • federver → 10 (Manage Tailscale) detects existing installs and opens a status/up/down/re-auth submenu. Fresh installs still run the original guided flow. Shows the current tailnet IP, MagicDNS hostname, and peer list.
  • save to pass now also saves /opt/adguard/conf/AdGuardHome.yaml under privcloud/adguard/config. Preserves the AdGuard admin user + bcrypt password hash, custom filter lists, and client settings across restores.
  • Install AdGuard Home option (federver12). Network-wide DNS ad/tracker blocker as a standalone Docker container with --network=host, --restart=unless-stopped, and persistent volumes under /opt/adguard. Handles the systemd-resolved stub-listener conflict on port 53 automatically (drops DNSStubListener=no into a resolved.conf.d override), opens the firewall for 53/udp, 53/tcp, 80/tcp, walks the user through the manual setup wizard on port 3000, then closes 3000 once the admin UI moves to port 80. Detects Tailscale and prints exact click-by-click steps to set the tailnet global nameserver to federver's tailnet IP with "Override local DNS" on — the one routing path that reliably covers laptops + iPhones at home and roaming. (Ziggo Connect Box DHCP DNS overrides, manual per-device DNS on Linux, and iOS Wi-Fi DNS overrides were all evaluated and rejected — Ziggo firmware rejects LAN IPs, Linux leaks via IPv6 router advertisements, iOS bypasses via Private Relay.)
  • WireGuard Remove peer option in setup.sh11. Lists current peers by name, pick a number, confirms, deletes the [Peer] block from wg0.conf and the client .conf, then hot-reloads. Closes the gap where revoking a lost device required a full reinstall.
  • Storage: separate Change Immich location option (federver136). Immich paths are now independent from FileBrowser and Navidrome — changing one never affects the others.
  • Reset password option (federverr). Resets credentials for FileBrowser, Immich, Navidrome, or Uptime Kuma. FileBrowser and Immich reset password only (data kept). Navidrome and Uptime Kuma wipe data and restart fresh.
  • New MUSIC_LOCATION env var for Navidrome music library path.

Changed

  • Menu: existing entries absorbed new capabilities instead of adding top-level options. Rather than growing the main menu with separate "manage docker", "manage firewall", "manage tailscale" entries, each existing option now opens a submenu when re-run. Follows the same pattern 11 Install WireGuard and 13 Manage storage already used. Top menu stays the same size; functionality roughly triples.
  • Location-aware routing. Every menu option now runs on the right machine automatically. Server commands (3–15, r, a) SSH into the server when run from the laptop. Laptop commands (2, 16, 17, p) warn if run from the server. Status works from both. No more "wrong machine" mistakes.
  • Menu header shows Running from: laptop or Running from: server based on hostname.
  • Status fetches server data via SSH when run from the laptop — shows real hostname, IPs, data paths, containers, and disk instead of laptop info.
  • Tailscale URLs now use federver hostname (MagicDNS) instead of raw IPs everywhere — status, deploy, remote desktop, docs.
  • Navidrome music volume no longer mounted read-only — allows playlist management via .m3u8 files.
  • File sync (federver15) redesigned: accepts files and directories, strips quotes and trailing slashes, copy folder vs contents mode, delete option for laptop or server, cancel at every step, 3-attempt retry on invalid input. Uses ssh -t + chown for server permissions instead of sudo rsync.
  • Replaced Jellyfin with Navidrome for music streaming. Navidrome is lighter, supports background playback and offline caching via Subsonic-compatible apps (recommended: Amperfy on iOS).
  • WireGuard add/remove now hot-reload via wg syncconf instead of systemctl restart wg-quick@wg0. Other connected peers stay up during config changes (previously every peer dropped for a few seconds on every add). Falls back to restart if wg syncconf is unavailable.
  • Storage menu split into three path options: music (Navidrome), data (FileBrowser), Immich. Previously "Change media location" controlled both Jellyfin and FileBrowser, and there was no separate Immich option.

Removed

  • Jellyfin media server. Videos can be played by downloading from FileBrowser and opening in VLC.

Fixed

  • Power management (federverp) now runs shutdown/reboot over SSH on the server instead of locally. Previously would shut down the laptop if run from there.
  • USB drive detection now finds partitions on USB disks, not just the parent device. Fixes status/mount/unmount showing "none detected" when drives were plugged in.

Security

  • FileBrowser admin password is now randomly generated per-deploy (16-char) and saved to ~/.privcloud/filebrowser.pass (mode 0600). Removes the hardcoded privcloud credential that was published in README and customer-guide.
  • dnf-automatic now applies security updates only, with kernel packages explicitly excluded (kernel, kernel-core, kernel-modules*, kernel-devel, kernel-headers). A headless home server should never auto-reboot into an untested kernel while the owner is away. Update kernels manually with sudo dnf upgrade kernel + reboot when you're home.

v0.2.1 — 2026-04-06

Added

  • Save to pass (option 15) — backs up all server config to pass password manager
    • Runs from laptop, SSHes into server to fetch data
    • Saves: hostname, local IP, Tailscale IP, SSH keys, service URLs, .env, docker-compose.yml, WireGuard configs
    • All entries overwritten on each save (idempotent)

Changed

  • Option 12 "Mount USB drive" replaced with "Manage storage" sub-menu:
    • Status: shows drives, mounts, current paths, disk usage
    • Mount USB: auto-detects USB drives (filters by TRAN=usb, no guessing)
    • Unmount USB: safely unmount and remove from fstab
    • Change media location: updates Jellyfin, redeploys
    • Change data location: updates Immich photo/DB paths, redeploys
  • FileBrowser mounts FILES_LOCATION (base data path) — browse media, files, and immich
  • Jellyfin mounts MEDIA_LOCATION only — media files only
  • Replaced DATA_ROOT with FILES_LOCATION in .env
  • Status display: separated media path from Immich paths
  • FileBrowser password auto-set to privcloud during deploy (no more random passwords)
  • Deploy no longer sets unused DATA_ROOT

Fixed

  • Jellyfin :ro flag removed from media mount
  • FileBrowser upload now works (correct ownership on media dir)
  • FileBrowser password no longer lost on container recreate

v0.2.0 — 2026-04-05

Added

  • setup.sh — full server setup menu (federver command)
    • SSH, auto-login, hostname setup
    • SSH key auth (copies key, disables password login)
    • System update and auto-updates (dnf5)
    • Docker installation
    • Firewall (local ports + Tailscale trusted)
    • Tailscale remote access with guided setup
    • USB drive mount (permanent via fstab)
    • Service deployment (Immich, Navidrome, FileBrowser, Watchtower, Uptime Kuma)
    • WireGuard VPN (iptables NAT, auto key gen, QR codes, device-specific instructions, add/show peers)
    • Remote desktop via xrdp (disables local display, RDP from any device)
    • Daily Immich DB backup (cron at 3am)
    • Hourly disk space monitoring with Uptime Kuma push alerts (above 85%)
    • Docker log rotation
    • File sync between laptop and server (upload/download)
    • Status dashboard (URLs, IPs, containers, disk)
    • Power management (shutdown/restart)
  • fedvpn — WireGuard client CLI for laptop (setup/start/stop/status, IPv6 leak fix)
  • Jellyfin, FileBrowser, Watchtower, Uptime Kuma v2 in docker-compose.yml
  • privcloud status now shows container health and recent errors
  • Colored output with success/fail banners
  • federver and privcloud commands available globally
  • Reorganized menu: Initial setup (1-5), Services (6-9), Extras (10-13), Tools (14)

Fixed

  • WireGuard: use iptables with detected interface instead of firewall-cmd (NAT routing)
  • WireGuard: use local IP as endpoint (Tailscale IP creates routing loops)
  • fedvpn: disable IPv6 on connect to prevent tunnel bypass
  • fedvpn: use sudo for config file checks (root-owned)
  • Uptime Kuma: use server IP not localhost in monitors (Docker networking)
  • FileBrowser: read generated password from docker logs
  • Postgres permissions: document never to chown postgres directory

v0.1.0 — 2026-03-06

Added

  • Interactive CLI menu (./privcloud)
  • install — checks Docker, Docker Compose, pulls Immich images, sets up storage
  • start — starts all containers, waits for API, shows connection URLs
  • stop — stops containers, shuts down Docker daemon if nothing else running
  • status — full diagnostics (system, docker, storage, containers, network)
  • config — change photo storage location
  • Auto-detect and patch SELinux volume flags (Fedora/RHEL)
  • Auto-fix Docker group permissions (Linux)
  • Auto-start Docker daemon via sudo/pkexec
  • Auto-generate database credentials on first install
  • Docker Compose setup for Immich (server, ML, Redis, PostgreSQL)
  • Works on Linux, macOS, and WSL