Commit Graph
100 Commits
Author SHA1 Message Date
Claude c2e02f78ae asterisk vendor: always append TURN port even when TURN_SERVER is host-only
entrypoint.sh's turn_server fallback only appended :TURN_PORT when TURN_SERVER
was completely unset — a TURN_SERVER carried over from an older install (or
set to a bare host by hand) passed straight through with no port, so the
Sipnetic QR export's "st=turn:user:pass@host" field ended up missing the
port entirely. Append it whenever the configured value has no colon at all,
not just when it's empty.
2026-08-13 02:50:20 +00:00
Claude ca239a3886 Retire the shared coturn service — every WebRTC/SIP service now runs its own
Shared coturn (services/coturn.sh, ensure_coturn_user in lib/common.sh) is no
longer an installable or usable option anywhere in this repo. It's moved to
attic/coturn.sh (with tools/coturn-test-check.sh alongside it), which is
outside setup.sh's services/*.sh glob, so it never registers, never appears
in the menu, and `sudo ./setup.sh coturn` now fails with "unknown service".

Asterisk and Mattermost each already had an opt-out to run their own
dedicated coturn instead of the shared one; that opt-out is now the only
behavior — the shared-coturn preference, the opt-out prompt, and every
ensure_coturn_user() call site are gone. find_free_coturn_range()
(lib/common.sh) is what makes unconditional dedicated coturn safe: it scans
every coturn-owning service's own .env on the box for already-claimed relay
ranges and picks one that can't collide, so Asterisk + any number of
Mattermost instances can each run their own coturn on one box without the
relay-port collisions this repo's coturn history warns about.

Existing installs still pointed at a shared coturn container are left
running as-is on `update` (no silent migration attempt against a service
that no longer exists to heal against) — a full/fresh reinstall is the
migration path, which generates a new dedicated coturn with fresh
credentials and says so.

Also updates CLAUDE.md's coturn guidance for future service authors,
attic/README.md with the retirement rationale, and stale
services/coturn.sh path references in services/asterisk.sh,
tools/pstn-test-check.sh, README.md, and docs/vps-sizing-recommendations.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crt4ymNEHEbWqscB1qvZgC
2026-08-13 02:12:32 +00:00
Claude d835e0d734 mattermost, asterisk: dynamic, collision-safe dedicated coturn ranges
Mattermost's own embedded-coturn fallback hardcoded the same relay range
(49153-49352) for every instance, with no per-instance offset -- running
two Mattermost instances without the shared coturn service (or one
alongside Asterisk's own dedicated coturn, now possible via the prior
commit) would silently reproduce the exact pre-merge collision bug this
repo's coturn history warns about, just among Mattermost instances
instead of Asterisk/Mattermost.

Adds find_free_coturn_range() (lib/common.sh, standalone-mode-stubbed in
both services matching the existing port_in_use/find_free_port
convention): a coturn relay range can't be collision-checked with live
socket scanning the way a single port can -- coturn only opens ports
inside its configured range on demand, so an idle range looks the same
as an unclaimed one to ss/netstat. The only reliable check is reading
what every other coturn-owning service's .env on the box actually
claims (COTURN_MAX_PORT for the shared instance, TURN_MAX_PORT for each
dedicated one) and picking a range starting safely past the highest.

Also adds Mattermost's own opt-out prompt for the shared coturn
preference, matching the one just added to Asterisk (fresh-install-only,
never re-asked on update, same as every other coturn-shape decision in
that file). An update now explicitly preserves its existing dedicated
range from .env rather than silently recomputing a new one.

Verified end-to-end: a shared instance + Asterisk's dedicated coturn +
two independent Mattermost instances, each discovering and avoiding
every range already claimed by the others, land on entirely
non-overlapping port blocks.
2026-08-13 00:55:31 +00:00
Claude 85fc172377 asterisk: avoid relay-port collision between dedicated and shared coturn
Answers "can Asterisk run its own coturn while Mattermost keeps using
the shared one" -- yes, but not safely until now: Asterisk's dedicated
coturn hardcoded relay ports 49152-49252, entirely inside the shared
instance's own default range (49152-49452). Running both on the same
box (now possible via the previous commit's opt-out prompt) recreated
the exact pre-merge collision this repo's coturn history warns about.

When a shared instance is present, read its actual configured
COTURN_MAX_PORT from ~/docker/coturn/.env and pick a dedicated range
starting safely past it, so the two can never overlap regardless of
what the shared instance was configured with. No shared instance on
the box means no collision risk, so the historical 49152-49252 default
is left untouched in that case.

Threaded the computed range through every place it was previously
hardcoded: the coturn container's own --min-port/--max-port, the UFW
rule, the DigitalOcean Cloud Firewall rule list, the non-DO firewall
reminder, and the generated README's port table. Verified the
range-shift arithmetic directly: a shared instance configured up to
49452 shifts the dedicated range to 49502-49602 (clear); no shared
instance leaves it at the original default.
2026-08-13 00:27:33 +00:00
Claude 3abc46d8a9 asterisk: add opt-out for shared coturn on fresh reinstall
ensure_coturn_user always preferred the shared coturn service with no
override once it was reachable -- there was no way to deliberately run
Asterisk's own dedicated coturn again short of stopping the shared
service outright (which would also break every other consumer, e.g.
Mattermost Calls). Useful for reproducing an older install's exact
shape when troubleshooting anything that might be specific to the
coturn-sharing path. Only offered when a shared instance actually
exists, and only reachable via an explicit fresh reinstall, matching
this repo's existing rule that coturn shape never changes silently on
an update.
2026-08-13 00:13:09 +00:00
Claude 65b7964411 asterisk: mount Caddy's cert store regardless of coturn mode
The read-only bind mount that lets Asterisk's entrypoint auto-sync a
real Let's Encrypt cert from Caddy (instead of falling back to
self-signed) was gated on USE_EMBEDDED_COTURN == true. That condition
conflated two unrelated things: Asterisk's own SIP transport-tls cert
(what this mount is actually for) and coturn's separate TURNS
capability (which the shared coturn service genuinely doesn't support,
but is irrelevant here).

Confirmed live: on a shared-coturn install with a real Caddy-issued
cert already sitting on disk for DOMAIN_NAME, Asterisk kept generating
a self-signed cert on every restart anyway, because /caddy-data was
never mounted into the container -- sync_caddy_cert() had no cert store
to find. Most SIP/TLS clients refuse a self-signed cert outright with
no clear error, which was the actual cause of a "port's open, cert
domain matches, registration still silently fails" case where every
other layer (firewall, coturn reachability, DNS, cert CN/SAN) had
already checked out clean.
2026-08-12 22:11:36 +00:00
Claude b76230b341 asterisk: regenerate self-signed TLS cert when DOMAIN_NAME changes
Vendor's entrypoint.sh only regenerates the self-signed cert if the file
is missing or lacks a SAN extension -- it never checks whether the SAN
actually matches the currently configured DOMAIN_NAME. Since
/etc/asterisk/certs is a bind-mounted host directory, neither an update
nor a full reinstall ever wipes it, so a domain entered once (even a
placeholder, or one later changed) sticks in the cert indefinitely.

Confirmed live: a box kept presenting a cert for a stale, originally-
entered domain long after DOMAIN_NAME had changed and a full reinstall
had run in between. Most SIP/TLS clients refuse a mismatched cert
outright with no clear error, which was the actual cause of a
"port's open but registration still fails" case -- firewall, coturn, and
DNS had all already checked out clean.

Patches the vendored entrypoint.sh (same guarded-sed pattern as the
existing logger.conf patch) to also regenerate when the existing cert's
SAN doesn't include the current DOMAIN_NAME. Verified against a scratch
copy: missing cert regenerates, a cert already matching the domain is
left alone, a mismatched domain now correctly regenerates and then
stabilizes.
2026-08-12 21:33:24 +00:00
Claude 893ab19759 asterisk: warn non-DO public installs about provider-side firewalls
FQDN mode was already available outside DigitalOcean detection (the
home/LAN path's "Networking mode" menu offers it), but only DO installs
got any reminder about a network-edge firewall sitting in front of the
box -- non-DO public VPS installs got no equivalent, and UFW being wide
open gives no signal that a separate provider-managed firewall exists at
all.

Confirmed live on an IONOS VPS: UFW allowed every SIP/TURN/RTP port,
Asterisk's own PJSIP logger showed zero incoming packets, and nothing in
the installer's own output pointed at the cause -- IONOS's own network
firewall (Cloud Panel -> Networking -> Firewall Policies) only allowed
22/80/443/8443/8447 and silently dropped the rest before it ever reached
the box.

Adds _asterisk_remind_non_do_firewall(), fired whenever a fresh install
sets a public FQDN without being in DO/droplet mode: same port list as
what UFW just opened, plus a pointer at the IONOS console location as a
concrete example other providers can generalize from.
2026-08-12 21:09:25 +00:00
Claude 2b926ebf0b security-dashboard: widen main container for the Extensions table
The previous "box too narrow" fix targeted the QR popup, but the actual
complaint (confirmed by screenshot) was the Extensions table itself --
ten columns (Ext/Name/Mobile/Status/Transport/PSTN/Whitelist/Messaging/
Voicemail/actions) forced .table-wrap's horizontal scrollbar even on a
normal desktop viewport because main was capped at 1180px. Bumped to
1600px; verified via headless render at 1280-1920px that the table no
longer overflows.
2026-08-12 20:32:33 +00:00
Claude 5d0b6355b1 security-dashboard: put TURN creds in the Sipnetic QR, widen the popup
- ea_device_sipnetic_string() now sets Sipnetic's documented st= field to
  an explicit turn:user:pass@host:port URI built from the same
  TURN_SERVER/TURN_USERNAME/TURN_PASSWORD Asterisk itself reads from its
  .env (the shared VPS coturn on a droplet, or whichever coturn Asterisk
  is actually configured against). Previously the QR carried no TURN
  info at all, silently falling back to Sipnetic's own default STUN
  server instead -- registration/media then depends on whatever got
  typed in by hand instead of what Asterisk is actually using.
- Popup widened (192px content -> 320px card) and the QR rendered at 3x
  its displayed resolution (physical size unchanged): the longer
  TURN-inclusive account string needs a denser code, and verified via a
  headless render + OpenCV/pyzbar decode that the extra module density
  needs the resolution bump to stay reliably scannable.
- Restored (and expanded) the plain-text-credentials warning that was
  dropped when the box became a modal, now covering TURN creds too.
2026-08-12 20:10:10 +00:00
Claude 779afcba62 security-dashboard: add white quiet zone around Sipnetic QR code
The QR popup's code was unreadable by real scanners: qrcodejs draws
modules edge-to-edge with no margin of its own, so the code sat directly
against the modal's dark background with no quiet zone. Verified with a
headless render + pyzbar/OpenCV decode that the raw generated image had
the code running to its edge and failed OpenCV's detector outright, while
wrapping it in a 20px white padded frame (still ~2in overall) fixed it.
2026-08-12 19:50:53 +00:00
Claude f0d34a0028 security-dashboard: show extension Sipnetic QR code in a popup modal
Converts the existing inline QR toggle on the Extensions tab's detail
panel into a small (2in square) modal popup with an X close button,
click-outside, and Escape-to-close, instead of an expanding inline box.
2026-08-12 19:33:55 +00:00
Claude aa65b5ef5b Make "Full reinstall" a real teardown for asterisk, mattermost, and coturn
Extends the security-dashboard prototype to the shared-coturn trio, since
these three are exactly the case that pattern was built for — a fresh
reinstall of any of them today just overwrote files in place without
stopping old containers first, and coturn's own fresh path never made an
informed choice about the consumer credentials/database it happens to
leave alone (safe today, but by omission rather than design).

- asterisk.sh / mattermost.sh: "Full reinstall" now stops the existing
  containers (`docker compose down`) before falling through to the normal
  install flow, and asks a single explicit question — delete stored data
  (PBX config/spool/voicemail for Asterisk; Postgres db/uploads/config/
  plugins for Mattermost) — defaulting to preserve. Their shared-coturn
  TURN credential is deliberately left alone either way (reused from
  cache via ensure_coturn_user(), same as update) — it's not this
  service's own data, and coturn already handles that continuity.
  Mattermost's existing "_db_has_data" check already reads the
  filesystem to decide whether to reuse or regenerate DB_PASS, so the
  wipe/preserve choice composes with that for free — no separate flag
  needed. Asterisk's warns to re-run pstn-trunk afterward if data is
  wiped, since that's what actually goes stale (its dialplan patch),
  not the fabricated "AMI secret" framing an earlier draft of this
  warning used before I checked the actual code.

- coturn.sh: "Full reinstall" now lists which consumers are currently
  registered (from users/*.env) and asks explicitly whether to also
  wipe TURN credentials and the user database, instead of silently
  preserving them as an unexamined side effect of never deleting the
  directory. Defaults to preserve. If the operator does choose to wipe,
  the running container is restarted afterward — it holds the old,
  now-deleted turndb file open, so new turnadmin writes to the fresh
  file would otherwise go unseen until a restart anyway. Every affected
  consumer already self-heals a missing credential on its own next
  Update run via ensure_coturn_user()'s existing cache-miss path — no
  changes needed there, just confirmed it covers this case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-12 13:07:38 +00:00
Claude 7a1f09a0f7 Rename reinstall-mode prompt; make security-dashboard's "Full reinstall" a real teardown
Two-part change discussed and scoped in this session before touching
anything:

1. Rename "Reinstall in place" (r) -> "Update" (u) and "Full install" (f)
   -> "Full reinstall" everywhere the prompt appears: lib/common.sh's
   shared prompt_reinstall_mode(), plus the three services that carry
   their own duplicated standalone-stub copy of it for standalone
   execution (asterisk.sh, coturn.sh, wordpress.sh — per this repo's
   documented standalone-bootstrap pattern). Internal state values
   (update/fresh/cancel) are unchanged, so no other service's case
   statement needed touching. docs/anveo-direct-setup-guide.md's `r`
   reference updated to `u` to match. attic/asterisk-digital-ocean.sh
   deliberately left alone — this repo's own policy is to not backport
   fixes into attic/.

2. security-dashboard.sh's "Full reinstall" now does a real teardown
   before reinstalling — stops and removes the systemd unit, sudoers
   grant, Caddy site block, and secdash system user, then proceeds
   through the normal fresh-install flow — instead of just overwriting
   files in place while leaving the old service running underneath.
   Prototype for a pattern discussed for other services later: split the
   destructive question out explicitly ("also delete
   dashboard-admins.conf — per-admin extension scoping?", default n) so
   full reinstall doesn't silently discard state a plain "start over"
   request wouldn't expect to lose. Verified the backup/restore mechanics
   (mktemp, copy out before teardown, copy back after) against a mock
   under `set -u` for both the preserve and wipe paths before shipping.

Update mode was already the strongest existing example of surfacing
newer optional prompts (its "Reconfigure Caddy protection?" /
"Reconfigure per-admin scoping?" sub-prompts already cover every setting
fresh-install offers) — no changes needed there for this service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-12 12:47:31 +00:00
Claude d55a7cc81a Reach the coturn self-heal check from asterisk.sh's update path too
Direct follow-up to the previous commit's ensure_coturn_user() fix: that
fix is useless for Asterisk specifically unless something actually calls
ensure_coturn_user("asterisk") again, and the update ("Reinstall in
place") branch returns 0 well before the fresh-install path's call to it
— only "Full install" reached it, which re-prompts everything (droplet
detection, domain, etc.) just to fix a credential re-registration.

Added the same call to the update path, gated on NOT having an embedded
coturn (checked via the existing _HAD_EMBEDDED_COTURN detection) — calling
it unconditionally would silently chain-install the shared coturn service
for a box deliberately running Asterisk's own dedicated coturn, exactly
the kind of silent update-time migration CLAUDE.md's coturn guidance
warns against. .env stays untouched either way (self-heal re-registers
with the same cached password, never generates a new one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-12 11:44:02 +00:00
Claude f94f43786f Self-heal orphaned coturn credentials in ensure_coturn_user()
Answers a direct question from this session: no, reinstalling
asterisk/mattermost did NOT fix a coturn user missing from the live
database, because ensure_coturn_user() only ever calls turnadmin -a in
the else branch — reached only when the cache file (users/<consumer>.env)
is MISSING. A stale-but-present cache file (exactly what a coturn
container/volume recreation without preserving ./db leaves behind, per
this session's real diagnosis) looked identical to a healthy one and was
trusted blindly, so every consumer's installer kept silently reusing
credentials that no longer existed in coturn's database.

Now checks the cached username against coturn's actual live user list on
every call, and re-registers it with the same cached password if it's
missing — the same self-heal pattern this repo already applies elsewhere
(Beszel's compose patch, Vaultwarden's SMTP half-state, FMD's chown).
Re-uses the turnadmin -l log-noise filter from tools/coturn-test-check.sh
(a real "user[realm]" line never contains a space; at least one coturn
build writes its own startup log lines to stdout, not stderr, so a bare
2>/dev/null doesn't catch them).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-12 11:13:46 +00:00
Claude cd1d0e3b40 Filter turnadmin -l log noise before parsing usernames
Live run surfaced it: this coturn build writes its own startup log lines
("INFO SQLite connection was closed.", "INFO log file opened: ...") to
turnadmin -l's STDOUT, not stderr — 2>/dev/null never caught them, so
they got parsed as if they were usernames, producing nonsensical
"Database has user '2026-...INFO SQLite connection was closed.'" warnings
on a real run. A genuine "user[realm]" line never contains a space; every
log line does, so filtering on that is a simple, build-independent fix.

Also diagnosed the actual underlying failure this surfaced: coturn's live
user database was genuinely empty (both 'asterisk' and 'mattermost' had
cached credential files but neither was registered in the DB) — exactly
the container/volume-recreated-without-db drift this script's consumer
cross-check exists to catch, confirmed against a real run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-12 03:29:23 +00:00
Claude 8ee018ec10 Distinguish timeout from real TURN test failure, bump timeout to 20s
Latest live run showed the test getting killed by its own `timeout 10`
before turnutils_uclient printed any result — just two startup INFO
lines, no error. That's the coturn/coturn Docker image's turnutils_uclient
(apparently a newer build with structured "LEVEL component: message"
logging, different from the older packaged version available for local
testing) taking longer than 10s to complete, not a real failure.

Bumped both scripts' timeout to 20s, and now check for timeout(1)'s own
exit code (124) separately from a real reported error — reported as WARN
with a suggested manual command to re-run with more time and see the full
result, instead of lumping "still running" in with "actually failed."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-12 02:32:33 +00:00
Claude 1624b76a82 Switch TURN test from -e <peer> to -y — real verification this time
Not another guess: installed coturn locally (apt-get install coturn) and
ran the actual server + turnutils_uclient against it to verify this
before shipping, since the last two rounds shipped based on reading the
usage text alone and both turned out incomplete.

-e 127.0.0.1 satisfies turnutils_uclient's "-e or -y required" check, but
then fails allocation with "channel bind: error 403 (Forbidden IP)" —
services/coturn.sh never sets --allow-loopback-peers, so loopback as a
peer address is correctly rejected by a real coturn instance, and the
previous fix's own comment about "loopback is always reachable" missed
that reachable and permitted aren't the same thing.

-y ("client-to-client") sidesteps this: it negotiates both ends of a real
relay through the server itself, needs no separate peer address, and
works fine over loopback. Verified directly against a real local
instance: exits 0 with real packet-loss/RTT stats on valid credentials,
and correctly fails ("Cannot complete Allocation", exit 255) on a wrong
password — so it's still a meaningful pass/fail, not just "didn't crash."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-12 02:28:22 +00:00
Claude 9f4f779220 Fix "Either -e peer_address or -y must be specified" in TURN allocation test
Another real failure from a live run: turnutils_uclient refuses to run at
all without either -e <peer> or -y — a bare auth-only invocation isn't
enough for it to actually attempt anything. Add -e 127.0.0.1 to both
tools/pstn-test-check.sh's and tools/coturn-test-check.sh's invocations;
loopback is always reachable since the test already runs via `docker exec`
inside the coturn container itself, and it lets the test actually prove
data relays through the allocation, not just that auth succeeded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-12 02:16:24 +00:00
Claude 2ac00c946b Merge remote-tracking branch 'origin/main' into codex/fix-drum-rhythm-game-audio-issues
# Conflicts:
#	services/drum-rhythm-game.sh
2026-08-12 01:55:11 +00:00
Claude 6b199f1b22 Merge remote-tracking branch 'origin/main' into codex/fix-drum-rhythm-game-audio-issues
# Conflicts:
#	services/drum-rhythm-game.sh
2026-08-11 22:15:53 +00:00
Claude 5dbfcbd120 Fix false TURN allocation failure, add attention recap, one-at-a-time reprint
Real bug caught from a live run: the coturn allocation test passed -t -T
(TCP/TLS) to turnutils_uclient, but services/coturn.sh always starts
coturn with --no-tls --no-dtls — requesting an encrypted/TCP transport
against a server that never offered one fails the allocation outright
("Cannot complete Allocation"), misreporting a config problem that didn't
exist. Dropped both flags in both tools/pstn-test-check.sh and
tools/coturn-test-check.sh so the test matches what the server actually
supports (plain UDP).

Also, from user feedback on the same run:
- warn()/fail() now collect their messages into arrays; the Summary
  section prints a "Needs attention" recap of every FAIL/WARN together
  at the end, instead of leaving the user to scroll back through a long
  run to find what needs fixing.
- The softphone-setup block now offers to reprint itself one extension
  at a time (paced with a keypress between each) after the main run, so
  a long device list isn't lost in the scrollback either. Factored the
  per-extension print into print_ext_info() so the full run and this
  reprint can't drift apart. Guarded with `[ -t 0 ]` so it's skipped
  automatically when the script isn't run interactively.

Verified via a fuller mock harness (fake docker/curl/systemctl/getent,
non-TTY stdin) that: the corrected turnutils_uclient invocation reports
success, the recap correctly lists FAIL before WARN, and the interactive
reprint prompt is skipped without hanging when stdin isn't a terminal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 20:53:23 +00:00
Claude bb7aece023 Test Asterisk's own coturn config and print softphone setup info
Two follow-ups on the PSTN health check:

- New "coturn (TURN relay for Asterisk)" section reads Asterisk's own
  TURN_* values from its .env (not re-derived) and runs a live TURN
  allocation against whichever coturn Asterisk is actually configured to
  use — the shared instance, or its own embedded per-Asterisk coturn if
  that's what this box has (detected via the same "grep -q '^  coturn:'
  docker-compose.yml" check CLAUDE.md's migration guidance describes).
  Proves what Asterisk itself would use at call time, complementing
  tools/coturn-test-check.sh's broader multi-consumer check.
- New "Softphone setup" section parses pjsip.conf directly and prints
  per-extension SIP server/username/password/port/transport, plus TURN
  credentials for any extension with ice_support=yes — the same values
  Sipnetic's "Add Account" screen needs, computed here so a client isn't
  installed just to read them out of the Security Dashboard.

Also fixed a bug caught while building a mock test harness to verify both
additions: the extension-registration parser grabbed state via a fixed
field position ($3), silently truncating multi-word states like "Not in
use" down to "Not". Replaced with a regex that captures everything
between the extension and the trailing "N of inf" — verified against both
single- and multi-word states.

And a real syntax bug caught by bash -n before this ever shipped: an
apostrophe inside a ${VAR:-default} expansion ("this box's IP") opens an
unterminated single-quote context even inside double quotes — reworded
to avoid the apostrophe entirely rather than fight bash's parser.

Full mock run (fake docker/curl/systemctl/getent, real pjsip.conf/.env
fixtures matching the actual generated format) confirmed both new
sections and the registration fix all produce correct output end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 20:44:09 +00:00
Claude 5c18cfa364 Add SMS test reminder, "which box handled it" note, and a coturn health check
Three follow-ups from live testing on this session's actual VPS:

- tools/pstn-test-check.sh's SMS section printed the Forward-to-URL value
  to configure but never said what to do next — add the "text this DID,
  then watch journalctl -u sms-inbound -f" step right after it.
- docs/pstn-sms-test-checklist.md: the "which box actually handled this"
  question has a simple answer (a DID's inbound routing targets exactly
  one IP:port, so there's no ambiguity to resolve, only a portal setting
  to confirm) — written up so it doesn't need re-deriving. Also fixed the
  --list example to cd into the repo first; ./setup.sh is a relative path
  and silently fails with "command not found" from any other directory,
  confirmed live in this session.
- New tools/coturn-test-check.sh: health-checks the shared coturn instance
  (services/coturn.sh) and every consumer registered against it (Asterisk,
  any number of Mattermost instances) — container/identity, each cached
  consumer credential cross-checked against coturn's own live user
  database (catches the container/volume-recreated-without-db drift case),
  UFW rules for both the TURN port and the relay range, a capacity
  explanation reasoned from the actual port-range math instead of a guess,
  and a real TURN allocation test per consumer via turnutils_uclient —
  the only way to prove credentials + port range + firewall all actually
  work together, not just that each looks right in isolation. Deliberately
  does not attempt a concurrent load test, since that would consume real
  relay ports other services may be actively using.

Verified the turnadmin -l output parsing, UFW rule matching, and the
empty-array-under-set–u loop pattern against mock data before shipping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 20:32:02 +00:00
Claude efb86fd92d Add provider-portal checklist with real values to the PSTN health check
Server-side config was fully verifiable already; what wasn't is the
provider-account side (Anveo's authorized-IP list, DID routing, SMS
forward-URL) since that lives entirely outside this box. Rather than
leave "go check the portal" as a vague pointer, compute and print the
exact values each portal field needs to match: this box's public IP,
the trunk DID (from .pstn-trunk.env), and the SMS forward URL read
straight from /opt/sms-inbound/settings.env (SMS_FORWARD_URL) instead of
making the user reconstruct or hunt for a value the installer already
generated and stored. Anveo-specific field-by-field checklist when
PROVIDER_NAME matches; generic fallback otherwise.

Verified the .pstn-trunk.env / settings.env sourcing against mock files
matching the real generated format, including the literal $[from]$-style
Anveo placeholders in SMS_FORWARD_URL, which must survive `source` under
`set -u` without triggering bash's legacy $[...] arithmetic expansion —
same guard pattern services/pstn-trunk.sh's own update path already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 20:24:48 +00:00
Claude 52fd75f679 Add automated PSTN/trunk/SMS health-check script
docs/pstn-sms-test-checklist.md's manual steps (registration, trunk
reachability, dialplan contexts, kill-switch state, usage-alert timer
health, recent call/message activity) are all things a script can check
directly instead of re-typed by hand each time — and re-typing them is
exactly what led to the container-name mistake in the prior commit.

tools/pstn-test-check.sh auto-detects the container/directory the same
way the checklist doc now does, runs every automatable check, and prints
PASS/WARN/FAIL per item plus a summary. What it can't cover — actually
placing a call or sending a text — still needs the checklist doc.

Caught during testing against real command output pasted in this
session: the endpoint-parsing loop matched pjsip's own column-header
line ("<Endpoint/CID...>  <State...>") as if it were a real endpoint row,
producing a bogus result. Fixed by skipping any row whose parsed
extension starts with "<".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 20:07:19 +00:00
Claude e03907882c Auto-detect the Asterisk container/dir in the PSTN test checklist
$CONTAINER/$EA_DIR only lived in the shell session where they were typed
by hand — a new terminal or enough time between test steps left them
empty, and an empty $CONTAINER silently collapsed
"docker exec -it $CONTAINER asterisk -rx ..." into "docker exec -it
asterisk -rx ...", failing with "No such container: asterisk" instead of
an obviously-unset-variable error. Confirmed live. Replaced the manual
pick with a docker ps auto-detect so a stale/forgotten variable can't
silently break every command in the checklist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 19:49:02 +00:00
Claude d9f6190da1 Add PSTN/DID/SMS end-to-end test checklist
docs/pstn-calling-voipms-plan.md (design log) and
docs/anveo-direct-setup-guide.md (account + droplet setup) already cover
getting a trunk/DID/SMS working from scratch, but neither is a quick
top-to-bottom checklist for verifying an already-installed setup still
works — registration, trunk reachability, tiers, outbound/inbound calls
(shared DID and personal DID), the spend-cap kill-switch, international
calling, internal SIP messaging, and SMS inbound, in order, with what to
check when each step fails. Pulls known gotchas (Commit Changes required
after dashboard tier edits, mobile vs geographic DIDs for verification
codes, the SIP-based SMS path Anveo doesn't actually offer) from the
existing docs so they're not missed mid-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 18:29:04 +00:00
Claude 8c52376495 Remove stale Caddy block before rewriting it on a fresh security-dashboard reinstall
The fresh-install path called _secdash_configure_caddy directly with no
prior removal, unlike the update/reconfigure path which already calls
_secdash_remove_caddy_block first. Re-running a "Full install" over an
existing dashboard on the same domain therefore appended a second site
block instead of replacing the first — and since Caddy serves whichever
block comes first in the file, the old one (old Authelia address, old
Basic Auth settings) kept winning even after answering the prompts with
new values. Confirmed live: reconfiguring a dashboard from a local to a
remote Authelia address left the old forward_auth target still in effect
until the stale block was deleted by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 18:18:45 +00:00
Claude 142897f84c Self-heal Beszel agent compose files on update
install_beszel() and install_beszel-agent()'s "update" branches only
did a pull+restart, never touching docker-compose.yml — so an
already-installed box would never pick up the systemd/dbus/sensor
mounts or apparmor:unconfined fixes without a manual edit or a
disruptive fresh reinstall.

Add _beszel_patch_agent_compose(), called from both update branches,
that idempotently patches an existing docker-compose.yml with
whichever of the two fixes it's still missing. Anchors on
`network_mode: host` and the docker.sock mount line, both unique to
the beszel-agent service and present in either compose shape
(combined hub+agent or agent-only), so one function covers both
install paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 17:14:12 +00:00
Claude e299b37b0c Add apparmor:unconfined to Beszel Docker agents - dbus mount alone isn't enough
The systemd/dbus mounts added last commit aren't sufficient by themselves
on an AppArmor-enabled host (Ubuntu/Debian by default): the dbus "Hello"
handshake fails with "An AppArmor policy prevents this sender from sending
this message to this recipient", since the container has no AppArmor label
the host's dbus-daemon profile recognizes. Only visible at LOG_LEVEL=debug
- silent otherwise, which is why the mounts alone looked like they should
have worked but didn't. Confirmed live against a real box hitting exactly
this error.

security_opt: apparmor:unconfined is Beszel's own documented fix
(beszel.dev/guide/systemd#apparmor-error) for this exact error string.
Added to both Docker-based agent compose generators (install_beszel's
combined hub+agent, and install_beszel-agent's remote-only variant).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 17:04:15 +00:00
Claude 933b5b00f4 Mount systemd/dbus/sensors into Docker-based Beszel agents for Services/Temp
The hub's "Services" column is systemd unit monitoring (CPU/memory per
unit), and "Temp" is hardware sensor readings — neither is Docker container
stats, which is what the existing docker.sock mount actually provides. A
container is isolated from the host's systemd/dbus and most of /sys by
default, so a Docker-deployed agent silently showed both columns empty,
with nothing anywhere pointing at why. Confirmed live: a natively-installed
agent (no Docker, a plain systemd service) gets both for free just by
running as a normal host process, which is what surfaced the gap — a
Docker-deployed agent sitting right next to it on another box showed
nothing in either column.

Added read-only mounts for /var/run/systemd/private, dbus's
system_bus_socket, and /sys/class/hwmon + /sys/class/thermal to both
Docker-based agent compose generators (install_beszel's combined hub+agent,
and install_beszel-agent's remote-only variant). All four are best-effort:
if a path doesn't exist on a given host, Docker mounts an empty directory
rather than failing the container, so the worst case on an unusual host is
an empty column, not a regression or a crash risk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 16:24:58 +00:00
Claude 88aac103c9 Fix FMD crash-loop: bind-mounted db dir needs UID 1000, not $ACTUAL_USER
fmd-server's image runs as a fixed, non-configurable UID:GID 1000:1000
baked into its own Dockerfile (useradd --uid 1000 fmd-server) - nothing
like PUID/PGID to override it. The install script chowned the bind-mounted
./data dir to $ACTUAL_USER instead, which only happens to work when that
user's host UID is coincidentally 1000. Confirmed live: the container
crash-loops forever on "permission denied" creating its sqlite db
otherwise - same root-cause shape as the Mattermost UID/GID bug fixed
earlier this session, different fixed UID.

Fixed at both points a container start can happen: the fresh-install path
(chown -R 1000:1000 "$FMD_DIR/data" right after the existing $ACTUAL_USER
chown, ordered after it since that one is recursive over the whole
directory and would otherwise overwrite this) and the update path
(previously unguarded - re-asserted before every docker compose up so a
box already stuck in this state self-heals on next update instead of
staying broken forever, same self-heal precedent as the Vaultwarden SMTP
fix earlier this session).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 15:08:39 +00:00
Claude 2a2aba0c9d Add Authelia OIDC provider + register-a-client flow for ActualBudget/Vaultwarden/other apps
Authelia's forward_auth (what this repo already sets up) gates a whole site
behind a login page before the request reaches it. This is the opposite
direction: an app with its own "Enable OpenID"/SSO setting delegating ITS
login to Authelia, via Authelia's separate OIDC PROVIDER feature, which
this repo had no support for at all.

_authelia_ensure_oidc_provider() enables it once, idempotently: generates
an HMAC secret (injected via a _FILE env var, same convention as the
existing jwt/session/storage secrets) and an RSA signing keypair, then
writes identity_providers.oidc into configuration.yml. The RSA private key
has to be inlined as PEM directly in that file — Authelia's jwks schema has
no file-path or env-var option for it — so configuration.yml gets chmod 600
once OIDC is enabled, unlike before when it held no raw secrets.

_authelia_add_oidc_client() registers an app: presets for ActualBudget
(/openid/callback) and Vaultwarden (/identity/connect/oidc-signin, and
confirmed its SSO support is now native/upstream, not fork-only) fill in
the redirect URI automatically; "Other/custom" covers anything else. Each
app gets its own Client ID and a random secret (shown once, only the
pbkdf2 hash is stored), and the output tells the operator exactly what to
paste back into that app's own OpenID dialog or .env — including
Vaultwarden's exact SSO_* env vars, not just generic OIDC endpoint URLs.

Wired into the existing "Authelia already exists" menu as a new option,
alongside "add another protected domain" and "reconfigure from scratch".

Exact CLI output formats, default filenames, and YAML schema were verified
against Authelia's own CLI source/docs (crypto rand's "Random Value: "
label, crypto hash generate pbkdf2's "Random Password:"/"Digest:" labels,
crypto pair rsa generate's private.pem/public.pem defaults) rather than
guessed, since a wrong assumption here means a cryptic startup failure or
broken secret extraction. The YAML manipulation (client-list insertion,
domain extraction from session.cookies) was tested end-to-end against the
real mikefarah/yq binary against a realistic mock config, which caught a
real bug (extracting the wrong awk field for the domain, "domain:" instead
of the actual value) before it shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 14:13:52 +00:00
Claude 59f82c57a6 Self-heal a half-set SMTP_HOST/SMTP_FROM in Vaultwarden's .env
Vaultwarden crash-loops outright if exactly one of SMTP_HOST/SMTP_FROM is
set ("Both SMTP_HOST and SMTP_FROM need to be set for email support
without USE_SENDMAIL"). The fresh-install prompt flow already avoids ever
writing that half-state, but "update" mode deliberately never touches
.env (same rule as everywhere else in this repo), so a box whose .env was
written before that prompt-side fix existed - or hand-edited since - stays
stuck crash-looping on every future update too, since nothing ever
re-checked it. Confirmed live on a real box.

New _vaultwarden_fix_smtp_halfstate() detects the half-set state and
blanks the whole SMTP block (matching what the fresh-install prompt does
when SMTP is skipped) rather than leaving it broken. Called right before
every docker compose up this file does - the update path (previously
unguarded) and the fresh-install start prompt (defense in depth, since
that path is already safe by construction) - so it self-heals regardless
of how a box got into this state.

Audited every other services/*.sh for the same half-set-required-pair
pattern (SMTP, MAIL_*, SMTP_HOST-style naming) - Vaultwarden is the only
one that actually writes paired config where a partial state crashes the
container. Authelia's SMTP is mandatory-with-defaults (a different,
non-crashing risk); Mattermost/frigate-notify only mention SMTP in
generated docs, never in config they write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 14:04:13 +00:00
Claude d1d234b4d2 Fix Gatus false-positive red on Authelia-protected and stale-synced sites
The auto-sync condition "[STATUS] < 400" reads as red for any site behind
Authelia's forward_auth: Gatus's probe is never logged in, so it correctly
gets a 401 back every time — the site is completely healthy, Authelia is
just doing its job, but that 401 fails the condition. Confirmed live: every
site the user actually logs into showed permanently red.

That single condition also had the opposite bug in reserve: on a genuine
outage (connection refused, DNS failure, TLS failure), Gatus reports
[STATUS] as 0, and 0 < 400 is true — a fully unreachable site would have
silently read as "up".

Fixed to two conditions together: "[CONNECTED] == true" (catches the actual
outage case) and "[STATUS] < 500" (accepts any real response, including
401/403/redirects from an auth gate, only failing on Caddy's own 502/503/504
when the backend itself is unreachable).

Also changed the sync loop to refresh conditions on already-synced
endpoints, not just add-missing-ones — the old add-if-missing-only logic
meant this fix would only apply to newly discovered domains, leaving every
already-synced site (which is most of them, on a live box) stuck on the
broken condition forever until removed and re-added by hand. Now every
sync run (every 15 minutes via the existing systemd timer, or the one that
happens immediately on a Gatus reinstall) self-heals all of them.

Verified end-to-end against the real mikefarah/yq binary: an existing
caddy-sync entry gets its conditions rewritten in place, an unrelated
manually-added endpoint is left untouched, and a newly-discovered domain
gets the corrected conditions from the start.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 13:39:02 +00:00
Claude 459bde0f38 Make tab completion + backup pruning setup unconditional in setup.sh
Both were only ever wired up from inside install_base(), so a box that
went straight to a direct single-service install (sudo ./setup.sh
beszel-agent, or any other service) without first explicitly running
`sudo ./setup.sh base` never got either — the direct-install branch exits
before the guided flow's own `run_service base` call is ever reached.
Confirmed live: tab completion doesn't work on a fresh box that installed
beszel-agent first.

Moved the call site to setup.sh itself, right after the --list/--status
early exits (which stay read-only and don't require root) and before every
other branch (configure, --remove, direct install, guided flow) — all of
which are downstream of that point regardless of which one actually runs.
Both helpers are idempotent and already no-prompt by design, so calling
them unconditionally on every invocation is safe; skipped under --dry-run
(with an equivalent [DRY-RUN] message) so a preview run doesn't write real
files.

install_base()'s own calls to both are now fully redundant (base.sh has no
standalone-bootstrap block, so install_base() is only ever reached
downstream of setup.sh's new call site) and removed, along with the two
DRY-RUN preview lines that described them there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 13:27:35 +00:00
Claude f7cc0e5bc4 Add beszel-agent: agent-only Beszel install for remote/homelab boxes
For monitoring a box that isn't the VPS (e.g. a homelab machine): only the
agent needs to run there, and it connects OUTBOUND to the hub over HTTPS
using the same key + universal token flow the hub-side installer already
uses — no VPN, no router port-forwarding, and no FQDN needed on that box,
since nothing on it ever needs to be reached FROM the hub.

New register_service beszel-agent in services/beszel.sh (a second
registration in the same file, precedented by base.sh's base+glow) reuses
_beszel_configure_agent's paste/parse UX for the key/token instead of
duplicating it — that function's signature changed from a bare hub port to
a full login-URL string so both the local-hub path and this new
agent-only path can share it.

Run on the remote box: sudo ./setup.sh beszel-agent

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 13:18:26 +00:00
Claude c719197b20 Admin-scoping setup: show live extension list, auto-include owned DIDs
Two refinements to the per-admin extension scoping added last commit:

- The setup prompt now shows the dashboard's current extensions (pulled
  from its own running /api/pstn-permissions, reusing list_extensions()'s
  already-correct pjsip.conf parsing instead of a second implementation in
  bash) before asking for each admin's list, with a real example built from
  actual extension numbers instead of a generic placeholder. Shown fresh
  for every admin added, one at a time.

- An admin scoped to an extension now automatically sees that extension's
  directly-assigned personal DID's call/text history too, not just its
  internal activity — parse_pstn_calls()/parse_texts() key inbound rows by
  the DID that was dialed, not the owning extension, so without this a
  scoped admin would see their own extension's outbound calls but not
  inbound calls to their own number. New _dids_for_extensions()/
  _admin_scope_for_calls() resolve this per-request from
  pstn-personal-dids.conf's direct (non-ring-group) owner field. Voicemail
  scoping is unaffected — a mailbox is always keyed by extension number
  regardless of which DID rang it.

Also removed a dead DASHBOARD_ADMINS_HEADER Python constant left over from
before the file-writing responsibility settled on the bash side only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 12:55:42 +00:00
Claude d124902b8d Add fail-closed per-admin extension scoping for Calls & Texts and Voicemail
Two admins sharing one dashboard can now each be scoped to their own
extensions on the Calls & Texts and Voicemail tabs, while Security Log,
CrowdSec, and Extensions stay fully visible to both — Authelia already
provides real per-person identity here (Remote-User, forwarded by Caddy's
existing forward_auth/import authelia wiring), this just teaches app.py to
finally read it for these two tabs instead of ignoring it.

New dashboard-admins.conf ([username] -> extensions=), configured via CLI
prompts in security-dashboard.sh (offered at install and on reconfigure),
read-only from app.py's side — no write access needed since the file is
root-managed. allowed_extensions_for_user() is fail-closed by design: an
empty/missing file means unrestricted (today's default, unchanged), but the
moment one admin is configured, every other identity — an unlisted admin, a
typo, or no Authelia identity at all — sees nothing on those two tabs until
added. /voicemail/audio checks the same scope directly (not just the list
route) so a guessed or copied URL can't bypass the filter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 11:57:08 +00:00
Claude 8950810cea Add voicemail: dialplan/mailboxes in Asterisk, Extensions toggle + Voicemail tab with click-to-play in the dashboard
Asterisk side (services/asterisk.sh): a [voicemail-access] context reachable
from every extension (*97 checks your own mailbox, *98<ext> drops a message
into another mailbox directly), gated live via AST_CONFIG() on a new
"voicemail" flag in pstn-permissions.conf. voicemail.conf gets a skeleton
[general]+[default] at install/update, then stays dashboard-owned from
there — mailbox lines are never regenerated wholesale by asterisk.sh once
the file exists, matching every other install-time-vs-dashboard-owned file
split in this repo (.env, firewall rules, etc). Vendor files (entrypoint.sh,
easy-asterisk.sh) get patched the same way messaging-dialplan.conf already
does, including the live-extensions.conf patch for boxes with existing
devices.

While tracing the right #include anchor for this, found and then reverted a
theoretical "fix" to messaging's own #include position: pstn-trunk.sh's own
comment (live-confirmed 2026-07-24) directly contradicts the textbook
Asterisk #include semantics I'd assumed, so the safer move was keeping
messaging's anchor exactly as already verified working and using the same
position for voicemail's own #include.

Dashboard side (services/security-dashboard.sh): write_voicemail()/
_apply_voicemail_flag() toggle the flag and a PIN (generated once, kept
across future toggles), regenerate_voicemail_conf() keeps voicemail.conf's
[default] section in sync, and a module reload takes effect without a full
Asterisk restart. Extensions tab gets a Voicemail column next to Messaging,
showing the PIN once generated. New Voicemail tab lists every mailbox's
messages (parsed from Asterisk's own msgNNNN.txt sidecars) with an inline
<audio> player per row — /voicemail/audio validates ext/msg against strict
regexes plus a resolved-path containment check before ever opening a file.
Dashboard gets read-only ACL + systemd ReadOnlyPaths access to the
voicemail spool dir, and a new sudoers-scoped module-reload command.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 05:58:42 +00:00
Claude 3584ad6499 Make backup pruning fully automatic, no prompt
The safety net (only ever touches disposable *.backup.* files, never
the newest one for any given file) makes this low-stakes enough to
just set up unprompted, the same way tab completion already is —
matches the user's own read on it. Still fully idempotent (skipped if
the timer already exists), so a rerun doesn't re-ask or redo anything.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 04:56:43 +00:00
Claude c50704e1b3 Add automatic tab-completion setup and old config-backup pruning
Two things surfaced from actual use this session:

1. Tab completion (tools/setup-completion.bash, added earlier) required
   manually editing ~/.bashrc — easy to skip or get wrong (confirmed
   live: the source line never actually landed the first time). base
   now wires it in automatically (idempotent, checked by grep first),
   matching how it already touches ~/.bashrc for SSH Host aliases.

2. No pruning existed anywhere for the *.backup.<timestamp> files ~60
   different services create before overwriting a live config
   (Caddyfile, /etc/fstab, etc) — every one of them backs up, none
   clean up, so they accumulate forever on a box reconfigured
   regularly. tools/prune-old-backups.sh prunes by file mtime (not by
   parsing the timestamp out of the filename — robust to the
   %Y%m%d-%H%M%S vs %Y%m%d_%H%M%S inconsistency across services),
   always keeping the single newest backup per distinct file
   regardless of age. Verified both the normal case (mixed old/new,
   prunes only the old ones) and the edge case (every backup for a
   file is old, keeps the newest one anyway) against real fixtures.
   base offers it as a daily systemd timer (prompted, since it deletes
   files — unlike the tab-completion wiring, which doesn't).

Also added logrotate for Caddy's own access logs
(/var/log/caddy/*.log), which had no rotation at all and grow
unbounded on an active box. Uses copytruncate specifically: the log
directory is bind-mounted into the running Caddy container and read
live by CrowdSec, so truncating in place avoids either of them needing
to notice or react to a rotation happening.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 04:48:11 +00:00
Claude cd003dbaf3 Add Gatus auto-sync from Caddyfile, promote ensure_yq to lib/common.sh
Adds one Gatus endpoint per Caddy site block automatically, tagged
group: caddy-sync — the sync only ever adds/removes entries in that
exact group, so anything added by hand (the default external checks,
a custom endpoint) is never touched regardless of what the Caddyfile
looks like. Offered at install time (syncs once immediately) and, if
systemd is available, scheduled via a timer every 15 minutes so a site
added or removed later gets picked up without re-running the installer
— matches the "schedule that checks the Caddyfile" shape asked for.

Domain extraction tracks actual brace depth (reusing the same approach
as remove_service's Caddy block removal) rather than a naive
line-by-line scan, so it correctly skips the global options block and
parenthesized snippet definitions like (authelia) without needing to
special-case them by name.

Verified end-to-end against a real Caddyfile/config.yaml fixture with
the actual mikefarah/yq binary: initial sync adds the right entries
and leaves the default "external" group alone, a second run with no
Caddyfile changes is a true no-op (0 added, 0 removed), and changing
the Caddyfile (removing one site, adding another) correctly adds the
new endpoint and removes only the stale one.

Also fixes a real gap surfaced while building this: ensure_yq (used by
both gatus.sh now and onlyoffice.sh already) checked `command -v yq`
alone, which a box can satisfy with a completely different, incompatible
yq — confirmed live in this environment, Debian/Ubuntu's own `yq`
apt package is kislyuk/yq (a Python jq-wrapper) which silently errors
on mikefarah/yq's `e '.path' file` syntax every caller here depends on.
Now checks the version string actually identifies as mikefarah's
before trusting it, installing to /usr/local/bin (which precedes
/usr/bin on Ubuntu's default PATH) if not. Promoted ensure_yq itself
from onlyoffice.sh (its only previous user) to lib/common.sh now that
gatus.sh needs the same thing, so both share one implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 04:26:08 +00:00
Claude 7c85f326c8 Accept Beszel's own "copy for docker compose" snippet directly
Confirmed live: Beszel's Settings -> Tokens & Fingerprints page surfaces
a "copy for docker compose" shortcut as the prominent way to grab the
key/token — not a bare string — so the previous two-prompt flow (paste
plain key, paste plain token) didn't match what people actually have
in their clipboard. The user pasted that YAML snippet into .env by
hand afterward, using the container's raw KEY/TOKEN names and YAML
`NAME: 'value'` syntax instead of what the compose file's own
${AGENT_KEY:-}/${AGENT_TOKEN:-} substitution actually reads — the
agent then failed with "no key provided" since AGENT_KEY was never
actually set.

_beszel_extract_field pulls KEY/TOKEN out of whatever shape the paste
arrives in — YAML mapping (`KEY: 'value'`), compose list style
(`- KEY=value`), or plain `KEY=value` — regardless of quoting. The
agent prompt now accepts a multi-line paste (the whole snippet, or
just the two lines) instead of asking for two separately pre-extracted
values; if no labeled KEY/TOKEN line is found at all, it falls back to
treating the paste as a bare key and asks for the token separately, so
a Beszel version that really does just show plain strings still works.

Verified all three paths directly: the exact mixed KEY:/TOKEN= paste
the user had, the skip path (blank first line leaves .env untouched),
and the bare-value fallback with no labels at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 04:11:56 +00:00
Claude e10e1e90b4 Fix invalid docker-compose.yml when Beszel is installed with local Caddy
Confirmed live: "networks.beszel-agent additional properties ... not
allowed" — the root-level networks: block (_CADDY_NET_SECTION) was
placed between the two services instead of after both. Since it sits
at 0 indentation, YAML parsed the following beszel-agent: line as a
continuation of the networks: mapping instead of a new services: entry,
so the whole beszel-agent service definition got swallowed as if it
were a (invalid) child of networks.caddy_net. gatus.sh's identical
_CADDY_NET_BLOCK/_CADDY_NET_SECTION pattern never hit this because it
only ever has one service, so the same placement is always the last
content in the file there.

Moved _CADDY_NET_SECTION (the root-level networks: definition) to
after both services; _CADDY_NET_BLOCK (the per-service "join
caddy_net" snippet) stays right after the hub's own volumes, where it
correctly nests under the beszel: service only.

Verified by regenerating the compose file with local Caddy present and
parsing it with PyYAML: services.beszel and services.beszel-agent are
now proper siblings, beszel-agent keeps its image/environment/volumes
keys, beszel's own networks: is scoped to just that service, and the
root networks: definition is separate and correctly placed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:51:02 +00:00
Claude fc8f57ab51 Add bash tab-completion for setup.sh
./setup.sh mat<TAB> now completes to ./setup.sh mattermost, same for
flags. Service names are read fresh from services/*.sh on every
completion — never a hardcoded list, which would go stale the moment
a new service file gets added (matches this repo's own "adding a
service = adding one file, nothing generated" rule from CLAUDE.md).

Verified live: sourced the script and confirmed completions for "mat"
and "--li", and specifically confirmed "bes" resolves to "beszel" —
the service added earlier this same session — with zero changes
needed to the completion script itself, proving the list is genuinely
dynamic rather than something that looked right once and then rotted.

Self-locating via its own BASH_SOURCE path rather than a hardcoded
install directory, so it keeps working regardless of where the repo
is cloned. Works through a leading `sudo` via bash-completion's
standard sudo pass-through (enabled by default on Ubuntu).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:46:09 +00:00
Claude 7938399e99 Add Beszel for lightweight server + Docker monitoring
Answers "what's the best way to see CPU/RAM/disk usage on this box"
(IONOS's own dashboard doesn't expose it) and "does Gatus cover this" —
it doesn't, Gatus is a black-box HTTP check (is the site responding
from the outside), Beszel is white-box host/process monitoring (is the
box under memory/disk pressure, is a container actually running vs.
crash-looping). Complements Gatus rather than replacing it.

Mirrors the hub+agent same-system layout from beszel's own
supplemental/docker/same-system/docker-compose.yml (fetched from the
actual upstream repo, not reconstructed from memory) — hub is the web
dashboard, agent reads /var/run/docker.sock (read-only) to report every
currently-running container automatically, no per-service config
needed as containers get added or removed.

Genuinely a two-phase install: the hub's SSH keypair and universal
token only exist after logging into its web UI once, so this starts
the hub, walks through where to find both values, and finishes wiring
the agent once provided — skipping is fine, a rerun in "update" mode
detects the agent was never connected and offers to finish it.

Verified the generated docker-compose.yml/.env by running the actual
file-writing code path with docker/configure_caddy_for_service mocked
out — confirmed TOKEN/KEY are correctly left as literal
${AGENT_TOKEN:-}/${AGENT_KEY:-} for Docker Compose's own substitution
at "up" time, not prematurely expanded by the heredoc itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:43:58 +00:00
Claude eeaa2e6c64 Accept plain "remove"/"uninstall" as aliases for --remove
./setup.sh filebrowser remove (no dashes) fell through to the normal
install dispatch instead of removing anything, since only the --remove
flag form was recognized. Accept the bare words too — order-independent
either way (./setup.sh remove filebrowser works the same).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:34:57 +00:00
Claude efcad48755 Add a generic service removal command: ./setup.sh <name> --remove
No removal path existed anywhere in this repo — manually removing a
service meant hand-editing docker-compose.yml, the Caddyfile, and UFW
rules yourself, or just leaving orphaned config behind.

remove_service (lib/common.sh) handles the common case: stop/remove
the service's containers (with an explicit y/n on whether to also wipe
data volumes, default no), find and remove its Caddy site block if one
exists, remove any UFW rule tagged with its name, and optionally
delete its ~/docker/<name> directory (default no — keep data as a
safety net unless explicitly confirmed).

The Caddy site block removal (_remove_caddy_site_block) tracks actual
brace depth rather than scanning to the next blank line or EOF — the
same class of bug this repo already hit once with a naive Samba
config edit. Verified against a multi-block test Caddyfile with nested
log{}/header{} blocks: removes exactly the targeted block, leaves
every other block (including ones with their own nested braces)
byte-for-byte intact, and is a safe no-op when nothing matches.

Also fixes the UFW rule-number extraction: ufw status numbered pads
single-digit rule numbers with a leading space ("[ 3]" vs "[10]") to
align columns, which the regex didn't account for — every single-digit
rule would have silently never matched and never gotten deleted.

Wired into setup.sh as a new --remove flag, resolving SERVICE_ALIAS
and validating the name the same way run_service already does.
Scoped to the common case (a Docker service at $DOCKER_DIR/<name> with
a standard configure_caddy_for_service site block); a hand-built Caddy
block or non-standard layout may need manual cleanup for the parts
this can't find. Non-Docker services (base, ssh-key-import, etc.)
report cleanly that they're not handled rather than erroring
confusingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:29:10 +00:00
Claude ad011b2db8 Add check_container_health helper, wired into mattermost.sh as reference
A service's "Started" message after docker compose up -d doesn't mean
the app is actually working — it can still crash-loop (bad DB
password, missing required env var, etc.) with no visible sign until
someone separately runs docker ps -a much later, exactly what happened
repeatedly this session (mattermost, koha-db, homebox, vaultwarden,
filebrowser all showed a clean "Started" message while crash-looping).

check_container_health (lib/common.sh) waits briefly, checks the
container's actual status and restart count via docker inspect, and
prints recent logs automatically if it's not running or has already
restarted — instead of a misleading one-line success message.

Wired into mattermost.sh's own start step as the reference
implementation, guarded by declare -F so standalone runs (no
lib/common.sh sourced) degrade gracefully. Not retrofitted across
every other service in one pass — this establishes the shared helper
so other services can adopt it incrementally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:24:50 +00:00
Claude 74628f3736 Fix vaultwarden SMTP false-positive and mattermost DB password mismatch
vaultwarden: SMTP_PORT defaulted to "587" and SMTP_SECURITY was a
hardcoded "starttls" literal in the .env template, written
unconditionally regardless of whether SMTP_HOST was ever provided.
Confirmed live: skipping SMTP entirely (blank SMTP_HOST) still wrote
real values for those two, and Vaultwarden reads that as "some SMTP
config is present," refusing to start ("Both SMTP_HOST and SMTP_FROM
need to be set") even with host/from genuinely blank. Both now stay
empty unless SMTP_HOST is actually set.

mattermost: DB_PASS/MM_SECRET were only reused from the existing .env
when MODE=update — a "fresh" reinstall always generated a new
POSTGRES_PASSWORD. Confirmed live: choosing fresh after removing only
the mattermost app container (not the whole directory) regenerates the
password in .env while db/'s existing Postgres data still enforces the
OLD one from its first init (the entrypoint skips re-init on existing
data), causing "password authentication failed for user mattermost" on
every start. Whether db/ already has real data is what actually
determines whether the old password is still live, not which reinstall
mode was chosen — reuse the existing secrets whenever db/ is non-empty,
regardless of MODE.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:22:53 +00:00
Claude c177312947 Fix three crash-looping services: koha-db, homebox, vaultwarden
koha-db: compose used MYSQL_ROOT_PASSWORD/MYSQL_DATABASE/MYSQL_USER/
MYSQL_PASSWORD, but this mariadb:11 image version's entrypoint doesn't
recognize MYSQL_ROOT_PASSWORD as any of its accepted root-password
options at all. Confirmed live: "Database is uninitialized and password
option is not specified" on every start, even though DB_ROOT_PASS was
correctly generated and present in .env the whole time. Switched all
four to their MARIADB_* equivalents.

homebox: a newer homebox release requires HBOX_AUTH_API_KEY_PEPPER (at
least 32 bytes) or the container panics on startup — this installer
never set it. Generate one with generate_password 48 and wire it
through .env + the compose environment block.

vaultwarden: the SMTP setup prompts let you enter a host but leave
"SMTP from address" blank (no default), writing a half-configured state
Vaultwarden refuses to start with ("Both SMTP_HOST and SMTP_FROM need
to be set"). Validate after prompting — if SMTP_HOST is set but
SMTP_FROM came back empty, disable SMTP entirely instead of writing a
config known to crash the container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:09:33 +00:00
Claude 77440c8f75 Live-scan Traccar's device-protocol range for collisions, not just Asterisk's ports
The 5000-5150 range was only ever checked against Asterisk's hardcoded
fixed ports (5038/5060/5061) for a first instance — no live scan of the
rest of the range, because the directory-count-based offset mechanism
only triggers for an explicit additional instance.

Confirmed live: this range sat unclaimed at the OS level while this
Traccar instance's container had never actually started, so an
unrelated service's own find_free_port scan found port 5007 genuinely
free (nothing was listening there yet) and took it — invisible to any
check until Traccar itself tried to bind its declared range for the
first time, failing with "port is already allocated".

Add a live scan across the whole intended range (skipping Asterisk's
expected carve-outs at the base 5000-5150 range) and shift by 1000,
same step the multi-instance path already uses, until genuinely clear.
Also fixed the compose-block and README generation, which keyed off
INSTANCE_SUFFIX being empty to decide whether Asterisk's exclusions
were needed — now keyed off whether the range is still the unshifted
default (PROTO_MIN -eq 5000), since a first instance can now end up
shifted too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 02:43:37 +00:00
Claude 8bdf4c0a07 Add explicit UFW rules for Caddy's 80/443 — Docker was silently bypassing UFW
services/caddy.sh never called ufw allow for any of its published ports.
Confirmed live: ufw status showed no rule for 80 or 443 on a box with
UFW active (default deny incoming), yet HTTPS sites were reachable
fine — Docker manipulates iptables directly for published container
ports (the ports: mapping in Caddy's own compose file), which bypasses
UFW's filtering entirely regardless of what ufw status reports.

This wasn't an actual exposure gap — 80/443 are supposed to be open to
everyone, that's the point of a reverse proxy — but it means ufw status
was actively misrepresenting this box's real firewall state on its two
most externally-facing ports, which is exactly the kind of thing that
looks like a problem (and did, when investigating an unrelated
Let's-Encrypt failure) even though nothing was actually unprotected.

Add explicit ufw allow rules for 80/tcp, 443/tcp, and 443/udp (HTTP/3)
so ufw status reflects reality, matching every other service in this
repo managing its own firewall rules instead of relying on undocumented
Docker/iptables interaction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 01:51:04 +00:00
Claude 060f288107 Fix authelia.sh skipping the real Caddy snippet due to a commented example
grep -q "(authelia)" matches caddy.sh's starter Caddyfile's own commented-
out example block ("# (authelia) {", included as documentation), so
authelia.sh believed the real snippet already existed and never wrote
it. Any later service adding `import authelia` to its own site block
then references a snippet that only exists as a comment.

Confirmed live: this takes Caddy down completely, not just the
Authelia-protected site — "Error: adapting config using caddyfile:
File to import not found: authelia" is a load-time failure, so Caddy
restart-loops and every site it fronts goes with it.

Anchor the check to an actual uncommented snippet definition
(^\(authelia\)\s*\{) instead of a bare substring match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 00:21:13 +00:00
Claude 6588e3abf1 Add a way to remove an existing vpn-data-mount without re-adding it
Removal only existed as a side effect of picking the same share again
in the "fully redo this mount" path — there was no direct way to just
remove a mount you no longer want, without walking back through host/
share selection first.

Adds a top-level "Remove any existing VPN data mounts?" prompt that
lists every configured mount by number (via the new
_vdm_list_all_mounts) and lets you remove one or more, reusing the
existing _vdm_remove_mount teardown (decrypt-layer unit, unmount,
credentials file, tagged /etc/fstab entry).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 00:15:30 +00:00
Claude 39e7b2ae6e Fix Mattermost crash-looping with permission denied on config.json
The official mattermost/mattermost-team-edition image runs as a fixed
UID/GID 2000 baked into the image — it does not read PUID/PGID env vars,
that's a LinuxServer.io s6-overlay convention this image doesn't use.
This file set them anyway (computed from ACTUAL_USER's uid/gid), which
did nothing, while the actual host directories (./data, ./logs,
./config, ./plugins) got chowned to ACTUAL_USER instead of 2000:2000.

Confirmed live: the container fails on its very first start with
"could not create config file: open /mattermost/config/config.json:
permission denied" and crash-loops — which then presents as a 502 from
Caddy, an easy trail to follow to the wrong place since Caddy itself
was fine.

Removed the dead PUID/PGID mechanism and chown the app's own volumes to
2000:2000 after the existing ACTUAL_USER chown. db (postgres:15-alpine)
isn't affected — its entrypoint fixes its own volume ownership on
startup. Runs on both fresh installs and "update" reruns, so re-running
the installer on an already-broken instance self-heals it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-10 23:41:41 +00:00
Claude cfd3b04b7b Add a full-redo option for an already-mounted vpn-data-mount share
The previous fix only let an already-mounted share reconfigure its
decrypt layer — there was still no way to change the mount point or
re-enter credentials for a share that's already set up, since the label
prompt was skipped entirely in that path. Add a real choice when an
existing mount is found: reconfigure the decrypt layer in place (as
before), fully redo the mount (tears down the old one via the new
_vdm_remove_mount and falls through to the normal fresh-mount flow,
label pre-filled from the old one), or skip.

_vdm_remove_mount stops/removes any decrypt-layer systemd unit first
(it sits on top of the CIFS mount), then unmounts, removes the
credentials file, and removes the /etc/fstab tag+entry via a fixed
",+1d" range — the tag line plus exactly the one mount line that always
immediately follows it, not an open-ended range to the next blank line
or EOF (the class of bug fixed earlier in this file's history for the
now-removed remote smb.conf-writing code).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-10 23:34:01 +00:00
Claude 304e4b644c Let an already-mounted share be reconfigured instead of blocking on label reuse
Re-running vpn-data-mount for a share that's already mounted hit the
label-uniqueness check with no way through it — picking the same share
always re-prompted for a label, and the existing label was always
already taken by definition, so it just looped rejecting every input.
Confirmed live: reported as an infinite "Label 'data1' is already
used" loop right after this share had already been mounted in an
earlier run.

Detect the existing fstab tag for the same host+share up front and
reconfigure it in place — currently the one thing safe to redo without
touching a working plain mount: the gocryptfs decrypt layer added
previously. Skips the label prompt and remount entirely for a share
that's already set up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-10 23:08:05 +00:00
Claude a23d5d6fc7 Add optional client-side encryption layer for vpn-data-mount
The VPS side of a plain SMB mount necessarily sees plaintext while it's
mounted and in use — that's unavoidable for data a VPS service actually
needs to read. What's avoidable is everything else: a disk image,
backup, or provider-side look at the VPS while the mount isn't actively
in use showing your actual files instead of ciphertext.

tools/gocryptfs-setup-home.sh (new): standalone tool for the home box.
Creates a gocryptfs-encrypted directory and passphrase file; the user
points their existing Samba share's `path =` at the cipherdir (manual
step — same read-only stance on remote Samba config vpn-data-mount.sh
already takes, this tool doesn't touch smb.conf either).

services/vpn-data-mount.sh: after mounting a share over CIFS as before,
optionally offers a gocryptfs decrypt layer on top. Fetches the
passphrase fresh over the same SSH trust already used for share
discovery, pipes it straight into gocryptfs, and never writes it to the
VPS's own disk. A generated systemd unit (via a wrapper script, not one
long quoted ExecStart= one-liner — avoids stacking systemd's own
word-splitting on top of bash -c's) keeps the decrypted view coming back
on boot, re-fetching the passphrase each time rather than caching it.

Fully opt-in and per-share — a plain unencrypted mount works exactly as
before if declined.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-10 23:06:13 +00:00
Claude 9e886ff9a7 Fix CIFS mount error(79) caused by missing nls_utf8 kernel module
The keyutils fix alone didn't resolve it — confirmed live with keyutils
already installed, the same error persisted. Root cause: the hardcoded
iocharset=utf8 mount option requires the kernel's nls_utf8 module, which
some kernels don't ship at all (confirmed live: `modprobe nls_utf8` on a
stock Ubuntu 6.8.0-137-generic VPS kernel returns "FATAL: Module
nls_utf8 not found" — not loadable, not built in). Every such mount
fails with errno 79 (ELIBACC) regardless of credentials, which is why
this recurred identically after the keyutils fix.

Both vpn-data-mount.sh and mount-network-drive.sh now probe with a
harmless `modprobe nls_utf8` before adding the option, and mount without
it (falling back to the kernel's build-time nls_default) with a clear
warning if the module isn't available, instead of hard-failing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-10 20:11:05 +00:00
Claude 2d9501a56c Fix CIFS mount error(79) caused by missing keyutils package
Errno 79 is ELIBACC ("Can not access a needed shared library"), not
ENOKEY as previously assumed — mount.cifs prints glibc's literal
strerror() text for it. It recurred with valid, correctly-captured
credentials because the real cause was never authentication: cifs-utils
hard-depends on the libkeyutils1 library but only Recommends the
keyutils package itself, which ships /sbin/request-key and the
/etc/request-key.d/*.conf handlers the kernel's upcall path invokes.
Minimal cloud VPS images commonly disable install-recommends, so
`apt-get install cifs-utils` alone silently skips it and every mount —
guest or fully credentialed — fails identically.

Install keyutils explicitly wherever cifs-utils is installed:
services/base.sh's unconditional package list, vpn-data-mount.sh's
lazy install-on-mount path, and tools/mount-network-drive.sh's SMB
branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-10 20:00:24 +00:00
Claude 1a259e0895 Fix password prompt silently stripping leading/trailing whitespace
Reported live: mount error(79) again despite already switching to real
credentials + sec=ntlmssp — this time with a Samba password containing
special characters. Root cause confirmed directly: `read -r -s pw1`
without `IFS=` silently strips leading/trailing whitespace even when
reading into a single variable (verified: " P@ss word! " -> "P@ss word!",
10 chars instead of 12). A password with a leading/trailing space —
common from a password manager's copy-paste, or a stray keystroke — got
quietly trimmed on the way into the credentials file, so it no longer
matched what was actually set on the Samba account. That mismatch
surfaces as this same cryptic ENOKEY mount error, not an obvious "wrong
password".

Fixed with IFS= on both reads. Also echo the captured length (never the
password itself) right after entry, so a silently-stripped character is
something you can catch and cross-check yourself before the mount even
attempts, instead of only after it fails.
2026-08-10 19:35:57 +00:00
Claude dd6f1d0a5d Make vpn-data-mount strictly read-only on the remote Samba config
Per direct request: never write to the home box's smb.conf at all, not
even carefully — just discover what's already shared there and mount it.
Removes all remote provisioning (installing Samba, creating/removing
share blocks, resetting smbpasswd accounts) entirely, which also removes
the whole class of bug the previous two fixes were patching around
(destructive section-removal, clobbering another mount's saved password) —
a tool that can't write can't repeat that kind of damage.

New flow: resolve/name the host and bootstrap SSH trust as before, then
read-only list every real share already in the home box's smb.conf
(skipping [global]/[homes]/[printers]/[print$]) via a plain SSH `cat`,
falling back to a sudo'd read only if that comes back empty — still only
ever reading. Presents them as a numbered list and accepts a flexible
selection ('1', '1,3', '1-3', '1 3 5', or combinations), asks once for the
Samba username/password to connect with (reusing a previously-saved
password for the same user+host if one exists), then mounts each picked
share locally over CIFS with its own /etc/fstab entry — same as before.

Verified the selection parser against all the documented formats plus a
mixed comma+range case and garbage/empty input.
2026-08-10 19:24:59 +00:00
Claude a2d3b0a651 Fix smb.conf section removal deleting everything after the target share
Reported live: Samba broke on the home box after this ran. Root cause
confirmed by reproducing it directly: the old removal step used
`sed -i "/^\[share\]$/,/^$/d"` — a range delete from the share's header
through the next BLANK line. A home box whose smb.conf has no blank line
separating sections (common — nothing requires one) means that range
never finds a terminator and sed deletes straight through to end of file,
taking every share defined after the target one down with it. Reproduced
against a 4-section smb.conf with no blank lines: the old approach left
only [global] standing, silently destroying two unrelated, pre-existing
shares that had nothing to do with this tool.

Replaced with an awk pass that removes lines from the target share's own
[header] up to the next `[section]` header or EOF — the actual boundary
of an INI-style section, independent of blank-line formatting. Also now
builds the new config in a scratch file and validates it with `testparm`
before it's ever copied over the live smb.conf; on validation failure it
leaves the existing file untouched and exits instead of restarting smbd
against a config that might not even parse. The existing
smb.conf.backup.<timestamp> step (already present before this fix) is
what the user is recovering the home box with in the meantime.

Verified the fix against the exact reproduction: the same 4-section,
no-blank-line smb.conf now retains all three untouched sections after
removing only the target one.
2026-08-10 19:18:55 +00:00
Claude 9e5a84f4f5 Don't blindly overwrite existing Samba config; stop clobbering shared account passwords
Reported live: the tool unconditionally reconfigured Samba even though
"Samba already installed on the home box" was already correctly detected —
that check only ever covered whether the smbd package exists, never
whether a share for the requested path (or the Samba account itself) was
already set up. Two real problems, not just a UX one:

1. Every run appended/replaced a [share] block and reset the target
   account's password unconditionally, even against a share the user had
   already configured by hand.
2. Since the Samba account is the SSH username (shared across every mount
   from the same home box), setting up a SECOND mount from the same box
   would silently reset the account's password — breaking the FIRST
   mount's already-saved credentials file with no warning.

Now: checks the remote smb.conf for an existing share exporting the exact
requested path first (via a plain SSH+awk query) and offers to reuse it
as-is (prompting for its real credentials, since a Samba password is
stored hashed and can't be read back) instead of overwriting it. If
creating a new share, checks whether this tool already set a password for
the same user+host pair (from another mount) and reuses it instead of
resetting the account; if the account exists with an unknown password
(set up some other way), asks rather than silently clobbering it.

_vdm_find_remote_share/_vdm_find_existing_smb_password/_vdm_prompt_password
are all called via command substitution by their caller, so none of them
call log_info/log_warning/etc. internally — those all write to stdout in
this codebase, which would corrupt the captured value. Verified the awk
share-lookup and the fstab-tag password lookup against sample data.
2026-08-10 19:15:11 +00:00
Claude abd1bcd35d Fix wordpress.sh picking an already-occupied port
Reported live: a fresh site's container failed to start with "address
already in use" on its assigned port. wordpress.sh scanned for a free
port by grepping `docker ps -a`'s port list — that only reflects ports
Docker itself currently has bound, so it's blind to ports held by
non-Docker processes or anything Docker isn't reporting cleanly at that
instant. Every other service in this repo scans with find_free_port
(checks actual OS-level listening sockets via ss) per CLAUDE.md's "Port
collision avoidance" section; wordpress.sh was the one holdout still using
its own weaker check. Switched to the shared helper, already available in
this file's own standalone stub and via lib/common.sh — no new dependency,
just using what was already sitting there unused.
2026-08-10 18:40:06 +00:00
Claude 28252b97d3 Fix vpn-data-mount's guest-mount errno 79 bug, add per-share SMB accounts,
host naming, and chain-in from filebrowser/audiobookshelf/emby

Reported live: "mount error(79): Can not access a needed shared library"
on the local CIFS mount step. That message is misleadingly worded — errno
79 is ENOKEY, not a real missing-library problem, and a plain `guest`
mount with no explicit `sec=` hitting it against a real Samba server is a
known cifs-utils/kernel-cifs rough edge in the anonymous-session keyring
path. Fixed as a side effect of switching away from guest access per
direct request (real per-share Samba accounts, not root/guest, matching
"user accounts for data directories"): each mount now gets a dedicated
Samba account (reusing the SSH username — that Unix account already
exists on the home box) with a generated password, remotely provisioned
via smbpasswd over the same SSH trust, and mounted locally via a
root-only credentials file (same convention tools/mount-network-drive.sh
already uses) plus an explicit sec=ntlmssp instead of guest.

Host naming: entering a raw IP now offers to name it in /etc/hosts, then
uses that name for everything from then on (SSH commands, the CIFS mount
address, and re-runs against the same IP). Deliberately /etc/hosts, not
~/.ssh/config — an SSH Host alias only helps the `ssh` command resolve a
name, mount.cifs never consults ~/.ssh/config at all, so an alias alone
wouldn't get the actual mount using a name. Still offers to also add a
matching SSH Host alias on top (pure convenience — skips typing the
username for interactive ssh use) when services/ssh-config.sh's helpers
are available.

Chain-in: filebrowser/audiobookshelf/emby now offer to run
vpn-data-mount first if their data is on a home box that isn't mounted
yet, and default their own directory prompt to whatever was just mounted
(VDM_LAST_MOUNT_POINT, explicitly unset before each chain call so an
unrelated earlier vpn-data-mount run in the same setup.sh session can't
leak its mount point in as a stale default).
2026-08-10 18:37:59 +00:00
Claude ad1a955096 Document ssh-key-import in the README
New section covering what it does, the public-vs-private-key security
model (only public keys are ever fetched, no outbound capability like
private-repo access is granted), and how to run it standalone via
sudo ./setup.sh ssh-key-import. Placed ahead of the existing SSH Host
aliases section since that section already references key import as
prior context ("after SSH key import, the wizard offers to add...").
2026-08-10 18:16:15 +00:00
Claude 8c5be53950 Extract SSH key import out of base.sh into a standalone, re-runnable service
Was only ever runnable once, buried inside base.sh's required-setup flow —
no way to re-run just this step for a box that already went through base
setup but needs another admin's key added later, or (the immediate case)
a home box for services/vpn-data-mount.sh that only needs this one step.

services/ssh-key-import.sh holds the real logic now (GitHub/Launchpad
import via ssh-import-id, optional password-auth lockdown); base.sh's
_base_setup_ssh chains into it the same way services/asterisk.sh chains
into security-dashboard/pstn-trunk, with a degraded (no import, just
ensures the SSH server itself is running) fallback for a pure standalone
`sudo bash base.sh` run with no sibling files sourced. Independently
runnable via `sudo ./setup.sh ssh-key-import` or `sudo bash
services/ssh-key-import.sh`, and shows up in the whiptail menu under
extras alongside ssh-config. Marked as never showing [installed] in
is_installed()/install_count(), same as ssh-config — it's a repeatable
management action, not a thing with an install state.
2026-08-10 18:10:27 +00:00
Claude 0e42de1cda Add vpn-data-mount: SMB mount from a NetBird-connected home box
Offered right after NetBird setup during required/base setup, matching
the requested flow (base packages -> NetBird -> data mount). Repeatable
by design rather than a one-shot step, since different services can have
data on different home boxes — asks for a home box IP every time and can
be run again for additional boxes/shares.

Flow: test for existing passwordless SSH first (covers "both boxes already
share a key via GitHub import, or any other means" for free — if it
already works, nothing else runs). If not, generate an SSH keypair and
offer ssh-copy-id or a manual/GitHub-import fallback (ssh-import-id, the
same mechanism base.sh's own SSH setup already uses) — needed because a
home box that took base.sh's "disable password login" option won't accept
ssh-copy-id at all. Once passwordless SSH works, use it to remotely
install and configure Samba on the home box for a chosen path, then mount
it locally over CIFS with a tagged /etc/fstab entry.

SMB over NFS/SSHFS per this session's direction: not a "huge" speed gap
for normal use, and SSHFS's own encryption is redundant overhead once the
VPN tunnel already encrypts everything. Guest-accessible (no separate
Samba credentials) since the VPN is the real access control — only
NetBird-connected peers can reach the home box's NetBird IP at all.

Also:
- cifs-utils added to base.sh's always-installed packages, same reasoning
  as Docker/Compose being unconditional there instead of installed lazily
  on first mount.
- is_installed()/install_count() in setup.sh gained a vpn-data-mount case
  (state lives in tagged /etc/fstab entries, not $DOCKER_DIR, since this
  isn't a Docker service) — mirrors wordpress's "count real instances"
  handling rather than a flat 0/1.
- Every SSH call in the new service explicitly runs as $ACTUAL_USER
  (sudo -u), not root — the script itself runs as root throughout, but the
  SSH key lives in $ACTUAL_HOME/.ssh, so a bare `ssh` call would silently
  use root's own ~/.ssh instead and never find it. Caught by review before
  this shipped, not after.
- UNATTENDED mode skips outright with a message instead of spinning
  forever on prompt_text's always-blank default under --unattended, since
  none of this flow's prompts (home box IP, remote path, ...) have a
  sane non-interactive default.
2026-08-10 17:56:50 +00:00
Claude b4a402e399 Drop the header row, tighten name-to-count spacing
Confirmed (again, by rendering into a captured pty and inspecting the
character grid) that whiptail always renders a blank line between the
instructional text and the checklist box itself, with no parameter to
remove it — so a header "directly above the purple box" isn't achievable
no matter how it's built. Per this session's direction: drop the header
line entirely and just tighten the gap between the count and the service
name (was up to 15 chars of mostly blank space from the wide count field
sized to match the now-removed header label; down to ~5).

Verified end-to-end in the same pty harness: rendered the real dialog,
sent actual keystrokes to toggle two items (one plain, one with a
double-digit count), captured the raw whiptail selection output, and
confirmed the existing "extract text after the last space" logic still
pulls the correct plain service names back out.
2026-08-10 16:47:48 +00:00
Claude 3afd7226f2 Pixel-align the header labels with their data columns
Previous commit's leading-space count for the header was an estimate and
visibly off in the follow-up screenshot. Rather than guess again, actually
rendered the dialog into a captured pty (whiptail installed locally,
output fed through pyte to reconstruct the real character grid) and
measured exact column offsets instead of eyeballing.

Root fix: "installed" (9 chars) and "# of installs" (13 chars) are wider
than the underlying data (an "x"-or-blank mark, a 1-2 digit count) — a
narrow data column can never align under a wide label and stay readable,
so it's the data fields that got widened to match the label widths, not
the other way around. Verified alignment holds across installed/
not-installed/double-digit-count rows and at the narrow 78-column width
floor (where the description truncates first now, not the install status —
correct priority, since status is the more critical of the two).
2026-08-10 16:40:18 +00:00
Claude 9bc2e6c510 Move the column header out of the checklist into the non-selectable instruction text
Requested: no checkbox on the header row at all, not just a harmless one.
The previous fake-row header still drew a real [ ] like every other row —
whiptail has no way to suppress that per-row, there's no such thing as a
non-selectable list item in a --checklist.

The instructional text above the list has no checkbox rendering at all
though, since it isn't a list item — moved the header there instead:
"installed" / "# of installs" / "service", spelled out per this session's
request instead of the terse "x"/"#". Spelled-out words can't line up
character-for-character under the 1-2-char data columns below and stay
readable, so the leading spaces are a best-effort approximation, not exact
alignment.

Adjusted the box-height overhead constant (+8 -> +9) since the
instruction text is now two lines instead of one, and dropped the
now-unnecessary sentinel-row filtering from the selection-handling code.
2026-08-10 16:33:32 +00:00
Claude d4a7b5a60e Add a fake header row and size the checklist width to the terminal
Header row: a first, non-functional checklist entry using the exact same
printf field widths as the real rows ("x #  NAME" / "x 1  caddy" / ...),
so it visually reads as column headers for the x/# prefix even though
whiptail has no real header concept. Its sentinel tag ("NAME") is filtered
back out of the selection after the dialog closes, so it's harmless even
if someone checks it and hits <Ok>.

Width: was a flat 78 regardless of the actual terminal, so descriptions
got cut off mid-sentence on anything wider with no way to read the rest
(confirmed from a screenshot — "TURN via the shared coturn s..." trailing
off). Scale with tput cols instead, floored at the old 78 (safe on a plain
80-column terminal) and capped at 160 so a very wide terminal doesn't get
an absurdly wide dialog.
2026-08-10 16:23:35 +00:00
Claude 2a11993c4e Fake dedicated "installed"/"#" columns in the checklist via a fixed-width tag prefix
Requested: separate, non-interactive "installed" (x) and "#" (instance
count) columns ahead of the actual selectable checkbox, with the
description no longer carrying any install-status text at all.

whiptail's checklist only has one interactive element per row — the
checkbox — so there's no such thing as a real extra column, tabbable or
not; the tag and item fields are always just inert display text regardless
of what's in them. The closest real equivalent: bake a fixed-width "x"
(installed) + count prefix into the tag field itself. whiptail pads every
row's tag field to the same width, so it lines up visually like columns
even though it's one string underneath. Extract the plain name back out
before dispatch by taking the last whitespace-separated token, since
service names never contain spaces — robust regardless of the exact
prefix width.

Description field is back to plain SERVICE_DESC text now that install
status lives in the tag prefix instead.
2026-08-10 16:18:34 +00:00
Claude 7f69d1dbee Replace "[installed]" text with an install count "[N]"
"[installed]" was 11 characters of an already-tight 78-column checklist
row, most of the reason the marker had so little room to spare before
whiptail's width truncation silently dropped it (previous commit). "[N]"
says the same thing in 3 characters — and for services that support
CLAUDE.md's multi-instance pattern (a base install plus any number of
"<name>-<suffix>" siblings, e.g. two separate mattermost instances), it's
more informative than a flat "installed": N > 1 means several instances
exist, not just one.

Add install_count() alongside is_installed() in setup.sh: the default case
counts $DOCKER_DIR/<name> plus any $DOCKER_DIR/<name>-* siblings; the
specially-cased services (asterisk, wordpress, etc.) either already count
sites directly (wordpress) or aren't part of the multi-instance pattern, so
they just mirror is_installed() as 0 or 1. Wired into the whiptail
checklist, the non-whiptail plain-text fallback, and --status.
2026-08-10 16:10:31 +00:00
Claude c7f9e5caa1 Add a * marker next to the checkbox for already-installed services
The [installed] text label (previous commit) confirmed working from a
screenshot, but the checkbox itself stays unchecked for installed items by
design — checking it means "install/reinstall this on <Ok>", so
pre-checking every already-installed service would risk a mass reinstall
from just hitting Ok without manually unchecking each one.

Add a second, more immediate cue right next to the checkbox instead:
prefix the item's own tag with "*" when installed (whiptail's checklist
tag is the first column, directly after the checkbox). The "*" is
display-only — stripped back off the selected values before they reach
run_service, so dispatch is unaffected.
2026-08-10 16:03:19 +00:00
Claude 3e75c51d18 Fix "local: can only be used in a function" crash in the category menu
The dynamic checklist-sizing code added in the previous commit used
`local` for its variables, but the category menu loop it lives in is
top-level script code, not inside a function — `local` only works inside
one. Confirmed live: this broke the whiptail menu outright on first
`sudo ./setup.sh` run after pulling ("only be used in a function", then an
unbound-variable error under set -u since the assignment before it never
ran). Drop `local`; these are the same kind of plain loop-scoped variables
every other var in this loop (CHOSEN_CAT, SVCS, CHOICE, SELECTED) already
is.
2026-08-10 14:53:44 +00:00
Claude 3a3833596c Fix filebrowser crash-looping on permission denied opening its database
Confirmed from gtstef/filebrowser's own Dockerfile (_docker/Dockerfile):
the image runs as a fixed non-root user (adduser -u 1000 filebrowser;
USER filebrowser), not root and not remappable via PUID/PGID. The
installer's broad `chown -R $ACTUAL_USER:$ACTUAL_USER "$FB_DIR"` left the
bind-mounted ./data owned by $ACTUAL_USER (root, on a box where the
installer itself runs as root) — UID 1000 inside the container then had no
write access to it, so every start failed with "could not open database:
open /home/filebrowser/data/database.db: permission denied" and the
container crash-looped indefinitely (restart: unless-stopped kept retrying
every ~60s, matching the log timestamps this was diagnosed from).

Re-chown ./data to 1000:1000 specifically, after the broad chown so it
isn't clobbered back to $ACTUAL_USER.
2026-08-10 14:52:48 +00:00
Claude 0cf859704f Fix whiptail checklist silently dropping [installed] on long descriptions
Root cause of the "installed services not shown as installed" report,
confirmed from a screenshot: the [installed] marker was appended AFTER the
service description, and whiptail hard-truncates each checklist row to the
dialog's fixed width (78) with no ellipsis or other sign it happened.
fmd's description alone is 68 characters — adding "  [installed]" pushes
it to 81, past the width, so the marker silently fell off the end. fmd was
actually installed the whole time (confirmed via setup.sh's own pre-wizard
summary and the new --status flag); the checklist just never showed it.

Move the marker to the front of the tag instead, where a long description
can still lose its own tail to truncation but the install status — the
part that actually matters — always survives. Mirrored the same fix into
the non-whiptail plain-text fallback path for consistency.

Also size the checklist's listheight/height to the category instead of a
flat 14 rows: utilities alone has 35+ services, so anything past row 14
was only reachable by scrolling with no on-screen hint more rows existed.
Now scales with the category size, capped to what the actual terminal can
show (tput lines) so it can't request a dialog taller than the screen.
2026-08-10 14:47:35 +00:00
Claude 11a4e249b6 Add missing cancel option to 15 more multi-instance services; add setup.sh --status
Same bug as the previous filebrowser/fmd fix: vaultwarden, immich,
audiobookshelf, homebox, rustdesk, emby, meshcentral, traccar, lyrion,
actualbudget, mealie, joplin, jellyfin, unifi, and ntfy all showed "Manage
that install (update / full reinstall / cancel)" when re-run against an
existing install, but choosing "1) Manage" fell straight through into the
same unconditional fresh-install flow every time regardless of choice —
no way to actually cancel or update in place. Wired all 15 up to
prompt_reinstall_mode, matching the reference pattern in
services/mattermost.sh: update pulls + restarts the existing container
without touching config, cancel leaves the install untouched, fresh falls
through to the existing full-install flow unchanged.

Also add `setup.sh --status`: a plain-text listing of every service with
its install state, using the exact same is_installed() calls the whiptail
checklist's [installed] marker uses. Exists so "is X actually installed"
can be answered by reading terminal output directly, without depending on
a whiptail checklist screen where a narrow/resized terminal can truncate
the "[installed]" suffix off-screen with no visible sign that happened.
2026-08-10 14:39:34 +00:00
Claude 4123662571 Fix fmd's broken Docker image and add missing cancel option to two installers
fmd.sh pointed at nulide/findmydevice, which no longer exists on Docker
Hub — the project has moved twice (nulide/findmydevice ->
gitlab.com/Nulide/findmydeviceserver -> gitlab.com/fmd-foss/fmd-server) and
was rewritten from Node.js to Go+React along the way, confirmed against the
current upstream repo and its GitLab container registry. This means the
service never actually started for anyone who installed it before this fix
("pull access denied for nulide/findmydevice, repository does not exist").

Switch to registry.gitlab.com/fmd-foss/fmd-server:0 (GitLab's own registry
has no "latest" tag; ":0" tracks the current major release the same way
this repo's other services use a floating tag). The old FMD_ADMIN_PASSWORD
model is gone from the app too — replaced with FMD_REGISTRATIONTOKEN
(self-registration gated by a token instead of one shared admin login), and
the database path moved from /fmd/data to /var/lib/fmd-server/db.

Also: filebrowser.sh and fmd.sh both showed "Manage that install (update /
full reinstall / cancel)" when re-run against an existing install, but
choosing "1) Manage" fell straight through into the same unconditional
fresh-install flow every time — no way to actually cancel or update in
place, contradicting both the banner text and the documented
prompt_reinstall_mode contract (CLAUDE.md's "Update vs. fresh reinstall on
rerun"). Wired both up to prompt_reinstall_mode, matching the reference
pattern in services/mattermost.sh. The same gap exists in 15 other
multi-instance services (vaultwarden, immich, audiobookshelf, homebox,
rustdesk, emby, meshcentral, traccar, lyrion, actualbudget, mealie, joplin,
jellyfin, unifi, ntfy) — not fixed here, flagged for a follow-up pass.
2026-08-10 14:25:34 +00:00
Claude 32240e18f9 Fix ensure_coturn_user leaving callers in the wrong directory
install_coturn (services/coturn.sh) cd's into $DOCKER_DIR/coturn and never
restores the caller's original working directory. A consumer that chain-
installs coturn mid-flow (e.g. asterisk.sh, already cd'd into its own
install directory) returned from ensure_coturn_user still sitting in
coturn's directory, then went on to write its own docker-compose.yml/.env
there instead of its own directory — clobbering coturn's compose file and
leaving the consumer's directory without one. The consumer's later
`docker compose up --build` then failed with "Dockerfile: no such file or
directory", since the Dockerfile was correctly in the consumer's directory
but the misplaced compose file (and the build) were not.

ensure_coturn_user now saves/restores the caller's cwd around the
install_coturn call, fixing this for every consumer (asterisk, mattermost).

Also fold cloud-init.sh's contents into a collapsible README section so
it's copy-pasteable straight from the repo instead of requiring a separate
file download.
2026-08-10 03:10:26 +00:00
Claude 613625da09 Fix stale usage comment in cloud-init.sh
The header still told readers to paste the raw GitHub URL, left over from
before we confirmed provider user-data fields run pasted/imported content
directly rather than fetching a URL.
2026-08-10 02:35:34 +00:00
Claude 5cab7fe9c0 Correct cloud-init.sh usage instructions for real provider UIs
IONOS's User Data field takes a Script Type choice (Cloud Config vs Shell
Script) and runs the pasted/imported content directly rather than fetching
a URL. Update the README to say so, add DEBIAN_FRONTEND=noninteractive for
genuine unattended cloud-init execution.
2026-08-10 02:33:26 +00:00
Claude b2b4b6dd19 Add cloud-init.sh for provider install-script/user-data fields
IONOS Cloud Server, DigitalOcean, and Hetzner all offer an "install
script"/user-data field that runs as root with no TTY while the image is
still provisioning, so bootstrap.sh's interactive tail can't run there.

cloud-init.sh clones the repo unattended and drops a one-shot
/etc/profile.d hook that launches the normal whiptail setup.sh wizard on
the first interactive login, then removes itself.
2026-08-10 02:29:02 +00:00
Claude 666d280179 Document IONOS Object Storage pricing
Storage cost matches what was already known from IONOS chat support
(~$0.49/100GB/month). Found the two unknowns from IONOS's own published
price list rather than pricing-comparison sites, which had conflicting
numbers for the API-cost line: API requests (PUT/COPY/POST/LIST/GET/
DELETE) are free with no per-request charge, and outbound transfer is
free up to 2TB/month (shared across the whole IONOS contract, not scoped
to Object Storage alone) before tiered per-GB rates kick in. Relevant to
services/immich.sh's S3 storage engine.
2026-08-10 00:15:33 +00:00
Claude 2fb2a2980d Document the 6vCPU/8GB Tier 3 sizing plan
Settled stack: 3x Mattermost, 1x Traccar (down from 2x to buy back RAM),
2x each of ntfy/mealie/wordpress/actualbudget/audiobookshelf/emby
(music-only + everything)/filebrowser/fmd/homebox/joplin/rustdesk/
vaultwarden, 1x each of asterisk/security-dashboard/sms-inbound (all
three are singleton-by-design, no multi-instance support exists for
them). changedetection and magicmirror x6 dropped — the former never got
the full multi-instance retrofit, the latter's existing pattern caps at
3 instances.

Comes out to ~6.0GB of 8GB (~25% headroom) with RustDesk's relay for
screen sharing, or ~6.4GB (~20% headroom) with MeshCentral instead.
2026-08-10 00:09:49 +00:00
Claude 9e06ed4b83 Bake cross-service port collision avoidance into every service script
With 70+ services sharing a handful of common default ports (emby and
jellyfin both default to 8096, changedetection and frigate both default
to 5000, arm and nextcloud both default to 8080...), nothing previously
checked whether a service's default port was actually free on the host.
Whichever service installed second would silently write a compose file
claiming an already-held port, only failing at `docker compose up` time.

Adds two shared helpers to lib/common.sh:
- port_in_use PORT [PROTO] — true if something's already listening
- find_free_port VARNAME START [PROTO] — scans upward, writes back the
  first free port

Every service that publishes a fixed host port now scans before writing
docker-compose.yml, on every install (not just when adding an explicit
additional instance). On a normal single-install host this is a silent
no-op; it only changes behavior when something else already holds the
port.

- The 19 services already given multi-instance support this session had
  their port scan moved out of the "add instance" branch to run
  unconditionally, since the same collision risk exists on a plain first
  install.
- 20 more services with previously-hardcoded ports gained scanning for
  the first time: archivebox, arm, calibre-web, changedetection,
  drum-rhythm-game, gatus, n8n, nextcloud, onlyoffice, stirling-pdf,
  uptimekuma, portainer, iopaint (both GPU/CPU compose branches), koha
  (paired), syncthing (paired), wg-easy (paired, plus WG_PORT env so
  generated peer configs keep the right Endpoint), homeassistant
  (bridge-mode only — host mode can only warn), frigate and
  frigate-audio (multi-port stacks, moved together).
- caddy.sh is the deliberate exception: 80/443 stay fixed and only warn
  on collision, since silently moving Caddy itself would leave nothing
  listening where any client actually looks.
- authelia.sh needs no change — it has no published host port at all.
- Every service's standalone bootstrap fallback (sudo bash services/x.sh
  with no sibling files) got the same two helpers duplicated into its
  stub block, matching how every other shared helper is already handled
  there.

Documents the full pattern in CLAUDE.md's new "Port collision avoidance"
section, including the quoted-heredoc/backtick-escaping gotcha and the
network_mode:host limitation (can only scan ports the app takes as a
configurable env var).

Verified via bash -n on every changed file, plus functional runs seeding
occupied ports for each collision shape used here (single, paired,
multi-port stacks) and confirming the scan/shift and generated
compose/README output are correct — including the emby/jellyfin,
nextcloud/arm, and frigate/changedetection collision scenarios that
originally motivated this.
2026-08-09 23:55:49 +00:00
Claude b860a8b174 Add multi-instance support to 13 more services
Retrofits the standard multi-instance pattern (documented in CLAUDE.md)
onto actualbudget, filebrowser, fmd, homebox, immich, jellyfin, joplin,
lyrion, meshcentral, ntfy, rustdesk, unifi, and vaultwarden. First
instance of each keeps its original name/paths/ports unchanged; adding a
second instance prompts for a short name and auto-scans for free ports.

Service-specific handling beyond the base pattern:
- joplin, immich, unifi: dedicated Postgres/Mongo container per instance
  (not shared), matching the backup-isolation reasoning in CLAUDE.md.
- meshcentral, unifi: multiple fixed ports scanned/shifted together so
  they stay paired per instance.
- rustdesk: 6-port block shifted by a fixed offset per instance, since
  the image hardcodes its internal ports with no per-port env override.
- jellyfin: DLNA/discovery UDP ports only published for the first
  instance to avoid a host-wide fixed-port conflict.
- lyrion: first instance keeps network_mode: host (required for
  Chromecast/Squeezebox broadcast discovery); additional instances fall
  back to bridge networking with auto-scanned ports, trading away
  zero-config discovery since a second container can't also bind host
  networking's fixed ports.
- magicmirror.sh already had its own working multi-instance pattern
  (upfront instance count, numbered subdirs) and was left as-is.

Verified via bash -n on every changed file, plus scripted functional
runs (fake docker/ss) exercising first + second instance installs for
every port-scanning shape used here (single, dual-paired, quad-paired,
block-offset) and confirming dedicated per-instance DB naming and the
lyrion host->bridge compose output.
2026-08-09 22:53:40 +00:00
Claude 3fc20238af docs: document the multi-instance service pattern in CLAUDE.md
Establishes multi-instance as the default expectation for any service
that stores its own data and isn't inherently single-tenant, not an
opt-in special case -- matching the direction taken this session
(audiobookshelf, emby, mealie, traccar all just got it; mattermost and
wordpress already had it).

Documents the reusable pattern with a code skeleton (first instance
stays plain-named, adding a second introduces suffixed naming with no
further branching downstream), plus the three sharp edges found while
actually building it into four more services rather than just
theorizing about it:
- dedicated-per-instance databases over shared, and why (Kopia's
  generic backup stops a container to snapshot it, so a shared
  instance backs up and restores as one unit covering every instance
  at once -- this is the same reasoning already applied to
  wordpress.sh, now generalized)
- large port ranges shift by an offset instead of being scanned
  port-by-port, including the find -mindepth 1 gotcha discovered
  while building this into traccar.sh
- sidecar tooling that watches Docker labels host-wide (autoheal)
  needs the label itself scoped per instance, not just container names

Also states plainly: verify this kind of port/count logic by actually
running it, not by reading it -- both real bugs it references were
things code review alone missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:58:25 +00:00
Claude d5d979ac31 Add multi-instance support to audiobookshelf, emby, mealie, traccar
Same pattern already established by services/mattermost.sh and
services/wordpress.sh: first instance keeps the plain name/paths/
ports exactly as before (zero behavior change for anyone with a
single instance already installed), and only choosing to add a second
introduces suffixed naming with its own directory, containers, and
ports.

- audiobookshelf.sh, emby.sh, mealie.sh: straightforward -- suffixed
  dir/container name, auto-scanned free host port(s) via `ss`, Caddy
  subdomain default suffixed to avoid collision. emby.sh's existing
  music-only mode is untouched, just correctly parameterized.
- traccar.sh: the harder one -- has its own dedicated Postgres
  container, an autoheal container, and a 150-port device-protocol
  range that can't be scanned port-by-port. Additional instances shift
  the whole range by 1000 (6000-6150, 7000-7150, ...) based on how
  many traccar/traccar-* directories already exist, which never lands
  on Asterisk's fixed ports the way the first instance's range does,
  so no exclusions are needed there. Also scoped the autoheal label
  per-instance (autoheal-traccar-<suffix>) -- autoheal watches by
  Docker label host-wide, not scoped to a compose project, so two
  instances sharing the generic "autoheal" label would each try to
  manage the other's container too.

Found and fixed two real bugs via testing before committing, not just
code review:
- The device-protocol range offset counted existing instances via
  `find $DOCKER_DIR -maxdepth 1 -name 'traccar*'`, which also matches
  $DOCKER_DIR itself if its own basename happens to start with
  "traccar" (true in my test harness, structurally possible in real
  use too) -- fixed with -mindepth 1.
- Verified port auto-scanning actually detects a simulated in-use
  port and increments past it, using a stateful fake `ss` rather than
  trusting the logic by inspection alone.

Verified end-to-end for all four: first instance unchanged from prior
behavior, second instance gets fully distinct dir/containers/ports,
and (traccar specifically) correct DB container, correctly-scoped
autoheal label, and correct shifted port range in the generated
compose file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:57:23 +00:00
Claude 5f36b14f93 mattermost: add PikaPods migration helper (DB dump + files import)
New opt-in prompt on fresh/new installs (skipped on "update" reruns,
where an existing instance is already in real use and importing over
it would be destructive): "Migrating from an existing Mattermost
instance (e.g. PikaPods)?" -- if yes, generates
migrate-from-pikapods.sh in the instance's own directory, same
generated-helper pattern as Immich's import-photos.sh.

Checked PikaPods' own docs before writing this rather than guessing
at their export mechanics: they expose per-pod SFTP (file access) and
a Database-access toggle that hands you an Adminer link for a full
SQL dump -- their own documented backup/migration flow is stop the
pod, SFTP the files, export the DB via Adminer. The generated script
assumes that shape (plain-text SQL dump + a files directory) and says
so in its header, including that PikaPods' exact SFTP layout wasn't
verified against a live pod so the files-argument path needs the
user's own confirmation.

What the script does: stops the mattermost container (leaves the DB
container running), drops and recreates the database owned by the
same existing role -- so .env's credentials are never touched or
regenerated, avoiding the "restored data, mismatched password" bug
class fixed elsewhere in this repo -- imports the dump via psql,
rsyncs the files directory into ./data, restarts. Requires typing
"YES" to proceed since it's destructive to whatever's currently in
the fresh instance's database.

Correctly parameterized per-instance: pulled from install_mattermost's
own MM_CONTAINER/DB_CONTAINER variables, so it's already correct for
either the first instance or an additional named one.

Verified end-to-end: prompt fires correctly at the right point in the
flow, generated script is syntactically valid, and the container
names/paths it's parameterized with match the actual instance being
installed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:45:26 +00:00
Claude 344bdf4a0f docs: settle on 2 WordPress sites, drop actualbudget
Final decision: actualbudget dropped and WordPress site count settled
at 2 (not 4) specifically to restore real headroom after dedicated-
per-site MariaDB made the 4-site case tight. ~1.09GB headroom (~27%)
now, back in the ideal 25-30% range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:37:39 +00:00
Claude a5d57050b3 wordpress: switch to dedicated MariaDB per site (was shared)
Reconsidered after the shared-MariaDB design's real cost became clear:
Kopia's generic backup (services/backup.sh) stops a service's
container to snapshot it, so a shared MariaDB instance would back up
-- and would have to be restored -- as one unit covering every site at
once. Restoring just one site's database to an earlier point meant
restoring the whole shared snapshot to a temporary location first and
manually extracting that site's data back out, not a direct restore.

Each site now gets its own dedicated MariaDB container embedded in its
own docker-compose.yml (same pattern as services/nextcloud.sh) instead
of registering a database on a shared instance:
- Removed _wordpress_ensure_shared_db() and the wordpress-db/
  wordpress_net shared resources entirely.
- Each site's compose file gets a `db` service (container
  <site>-db) on an explicitly-named per-site default network
  (<site>_net), so wp-cli's one-off container reliably joins the
  right network without depending on Docker Compose's implicit
  naming convention.
- DB creation goes through the mariadb image's own MYSQL_DATABASE/
  MYSQL_USER/MYSQL_PASSWORD env vars on first boot (same as
  nextcloud.sh) instead of an imperative `docker exec mysql -e
  "CREATE DATABASE..."` against a shared container.
- Root and site DB passwords are both reused across reruns (read from
  the existing .env), verified via a real update-mode rerun.

Tradeoff, stated in both the script's header comment and the generated
per-site README: more RAM per site (~100-150MB for a full MariaDB
container instead of a slice of one shared instance) in exchange for
independent backup/restore. Data was already fully isolated either way
(separate database + user, always required since WordPress's schema
uses generic table names) -- the shared-vs-dedicated choice was only
ever about the container/process, not the data.

Re-verified end-to-end against the fake docker shim: distinct ports,
distinct dedicated DB containers/networks per site, correct compose/
.env structure, credentials preserved across an update-mode rerun.

docs/vps-sizing-recommendations.md: updated to match -- WordPress
capacity recomputed for dedicated-per-site MariaDB (~580MB headroom at
4 sites, ~976MB at 2, vs. the shared design's ~700MB/~950MB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:23:24 +00:00
Claude e96a257d8f docs: record WordPress decision (2-4 sites, ecommerce-capable, Emby dropped)
Emby traded off for WordPress capacity rather than run alongside it —
still fully built and ready in services/emby.sh, just not part of the
current baseline. Updates the final RAM budget table to swap Emby for
the shared MariaDB + WordPress sites, and notes wg-easy/homebox/
audiobookshelf aren't included in that specific table since they
weren't part of the baseline as most recently stated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:16:00 +00:00
Claude a26d1831ee Add services/wordpress.sh — multi-site WordPress with shared MariaDB
New service: self-hosted WordPress, sized for running several
independent sites the way a hosting company would, not just one blog.

- Multi-site from the start: every site requires a name (no unnamed
  "first instance" special case like mattermost's — there's no
  backward-compat reason to special-case one here) and gets its own
  directory/container/port, but all sites share ONE MariaDB container
  (chain-installed on first site, reused by every other one) instead of
  a dedicated database container per site — same resource-sharing idea
  as services/coturn.sh, just scoped to WordPress's own sites rather
  than shared across different services. Each site gets its own
  database + user within that shared instance.
- E-commerce is just WooCommerce, a normal WordPress plugin — no
  separate infrastructure. PHP memory_limit/upload_max_filesize/
  post_max_size are pre-tuned (256M/64M/64M) so a product-catalog
  import doesn't hit default-image limits on the first try.
- wp-cli (official wordpress:cli image, run as a one-off container
  sharing the site's html volume) does the initial WordPress core
  install non-interactively — title, admin account — so there's no
  browser setup wizard to remember per site. Falls back to printing
  the exact manual command if the site wasn't ready in time.
- Auto-scans for a free host port per site (multiple sites can't all
  bind 8090), matching the "auto-scanned free ports for extras" idea
  already used by mattermost's multi-instance support.
- DB and admin passwords are reused across reruns (checked against the
  DB-password-regeneration bug class already fixed elsewhere in this
  repo, e.g. PR #265) — verified via a real update-mode rerun that the
  credential doesn't change.
- setup.sh: is_installed() gets a wordpress case — every site is named
  from the first one on, so there's never a plain $DOCKER_DIR/wordpress
  directory the default case could match against.
- README.md: added to the utilities services table + copiable list per
  CLAUDE.md's three-step rule for new services. Also fixed `coturn`
  being in the homelab row's prose but missing from the copiable list
  block below it — a pre-existing gap from when coturn.sh was merged.

Verified end-to-end via non-interactive dry runs against a fake docker
shim (no live daemon in this environment): 3 sites installed in
sequence get 3 distinct databases, 3 distinct auto-scanned ports, the
shared DB is only set up once, and an update-mode rerun preserves the
existing DB password rather than regenerating it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 20:57:18 +00:00