Answers a direct ask: offsite mirroring existed only as a REMOTE_TYPE/
REMOTE_ARGS placeholder in backup.conf with a comment pointing at
`kopia repository sync-to --help` — no interactive setup at all, B2 or
otherwise.
Checked before building anything: Kopia's dedicated `sync-to b2`
provider is marked [DEPRECATED] on kopia.io's own command reference.
B2 also offers an S3-compatible endpoint (s3.<region>.backblazeb2.com,
same application key works as the access/secret key pair), and Kopia's
`sync-to s3` provider isn't deprecated — so this targets that path
instead of building on a command on its way out.
What's now automated vs. guided, deliberately split:
- Bucket creation and the application key are walked through as console
steps, not automated. Object Lock specifically is a one-time,
bucket-creation-only decision with a real tradeoff (undeletable-by-
design vs. genuinely can't delete early) that shouldn't be silently
flipped either way by a script on someone's behalf.
- Once the operator has a bucket + endpoint + scoped application key
(B2 requires a key scoped to one bucket, not the account master key —
noted in the walkthrough), this becomes mechanical: run a
`sync-to s3 --dry-run` against the just-created 'default' repo to
verify the credentials actually work, and only then write
REMOTE_TYPE=s3 / REMOTE_ARGS into backup.conf. A bad bucket name or
key leaves REMOTE_TYPE at "none" with a clear error instead of saving
a broken config that fails silently at 2am.
- Encryption isn't a separate step — Kopia already encrypts client-side
with the repository password set earlier in this same flow; called
that out explicitly since it was asked about as if it needed its own
setup step.
Also fixed a regression the new prompt would otherwise have caused:
backup.sh has no update/fresh distinction and re-asks everything on
every run, so an already-configured offsite mirror is now read back
from the existing backup.conf and preserved by default — answering "no"
on a re-run no longer silently resets REMOTE_TYPE to "none".
Verified the control flow (not just bash -n) against a mock kopia
binary and stubbed prompts: good credentials wire up REMOTE_TYPE/
REMOTE_ARGS correctly, a rejected credential leaves REMOTE_TYPE at
"none" rather than saving something broken, an existing configured
value survives a "no" answer on re-run, and blank fields skip cleanly
without attempting a dry-run at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Answers a direct ask: the automated restore-verify test
(extras/test_backup_kopia.sh — verifies the latest snapshot, restores it
over a moved-aside copy, compares, rolls back, reports PASS/FAIL, sends
an ntfy notification) was already fully non-interactive and already
wired to a systemd timer/cron fallback by install_backup() — it just
had no schedule choice at all, hardcoded to weekly (Saturday 03:00).
Every service in this test stops briefly while its data gets moved
aside and restored back, same interruption profile as the main backup
job — so the schedule is a real tradeoff (more frequent verification vs.
more frequent blips), not a free "always pick the most frequent" choice.
Gave it the same Weekly/Monthly/Custom shape the main backup schedule
prompt above it already offers, instead of a single hardcoded option.
Also added an explicit "run the first test now?" prompt right after
scheduling it — otherwise choosing Monthly means waiting up to a month
before finding out whether the test even works, rather than getting
that initial confirmation immediately and then settling into the
chosen cadence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Found while adding a live scanner to the coturn-slot code: WEB_PORT and
CALLS_UDP_PORT were scanned unconditionally, before the reinstall-mode
prompt even ran and before anything stopped the currently-running
container. On an "Update" run that meant find_free_port would see this
instance's OWN already-published port as occupied and silently shift it
to the next free one — every plain update could have moved the service's
port out from under already-configured Caddy routes, bookmarks, and the
Calls plugin's client config, without the operator asking for that.
services/asterisk.sh already gets this right for WEB_ADMIN_PORT: update
reads the existing port back from .env (no rescan), fresh scans from the
plain default only after stopping the old container. Brought Mattermost
in line with the same shape — the port resolution moved from before the
reinstall-mode block to after it, so MODE is known and, for a fresh
install/"Full reinstall", the old containers are already stopped by the
time it scans.
WEB_PORT/CALLS_UDP_PORT are now also written to .env directly (they
weren't before), with a fallback to parse them from the existing
MM_SERVICESETTINGS_LISTENADDRESS / docker-compose.yml port mapping for
installs made before this change — so an update on an already-running
instance doesn't regress just because its .env predates the new
variables. Verified the explicit-var, fallback-parse, and priority-order
(explicit wins over fallback) cases against a mock before shipping.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Requested check: the slot-allocation scheme added in the previous commit
only checked against OTHER mattermost*/.env files on the box, not
against what's actually listening. A slot whose numbers happen to be
free by that bookkeeping could still be squatted by something this
script doesn't track (a manually-run process, an unrelated service) —
this box already learned that lesson once, from Asterisk and
Mattermost's embedded coturn ranges overlapping without either side
knowing.
Only a NEWLY assigned slot gets the live check — an already-cached slot
(read back from this instance's own .env) is trusted as-is, since a live
conflict on an already-configured, already-running instance's own port
is a real problem to report, not something to silently route around by
moving that instance's TURN port out from under it.
Can't scan the full 200-port relay range port-by-port (large ranges use
the offset scheme instead of scanning per CLAUDE.md's port-collision
section) — checks the control port plus both relay-range boundaries as
the practical middle ground. Verified against a mock: a candidate slot
whose control port is already bound gets skipped in favor of the next
free one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Follow-up to the Asterisk/Mattermost relay-range overlap fix: that fix
only handled the two-service collision, and left a documented gap for
what happens when a second (or third...) Mattermost instance also falls
back to embedded coturn — they'd have collided with each other on the
same fixed 3479/49253-49452 numbers, same bug, different pair.
find_free_port-style scanning doesn't work for the relay range itself —
it's a scan for a single free port, not a free contiguous 200-port
block — so this follows the same fixed-offset-per-instance approach
CLAUDE.md documents for traccar.sh's large port range instead. Each
instance gets an integer slot (control port = 3479 + slot, relay range
= 49253 + slot*200 through +199) computed once as the smallest slot
number not already claimed by another mattermost*/.env on the box, then
cached in that instance's own .env as EMBEDDED_COTURN_SLOT so it reads
back the same value on every later update or full reinstall instead of
potentially landing on a different slot (which would silently move an
already-configured instance's TURN port out from under it — the same
"never touch what's already the box's answer" rule everything else in
update mode already follows).
Verified the allocation logic against a mock: first instance gets slot
0, a second gets slot 1 without stepping on the first, both instances
keep their own slot across a simulated re-run, and a third new instance
correctly lands on the next free slot (2) rather than reusing either.
Threaded the computed port/range through every place that used to
hardcode 3479/49253/49452: the coturn compose block, the UFW rule
(now also labeled with the instance suffix, matching this file's other
UFW comments), and the Calls-plugin TURN config text in the generated
README/System-Console instructions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Confirmed live on a box that retired the shared coturn service in favor
of each service running its own dedicated/embedded coturn permanently:
Mattermost's embedded-coturn fallback used relay range 49153-49352,
which overlaps Asterisk's embedded coturn range (49152-49252) by ~100
UDP ports. Both run network_mode: host, so with shared coturn out of
the picture this is the exact same collision CLAUDE.md documents as the
original, already-fixed-once bug that the shared coturn service was
built to solve in the first place — reintroduced here because
Mattermost's embedded-coturn fallback path apparently never got checked
against Asterisk's numbers when it was written.
Moved Mattermost's embedded relay range to 49253-49452 (same 200-port
width, now contiguous with and non-overlapping Asterisk's 49152-49252).
Updated the docker-compose command flags, the matching UFW rule, and
added a comment explaining the offset so it doesn't drift back into
collision — and noting the known residual gap this doesn't cover: two
Mattermost instances *both* falling back to embedded coturn at once
would still collide with each other on these same fixed numbers. Not
fixed here since it requires more than one Mattermost instance to be
running without shared coturn at the same time, which isn't this box's
situation; flagged in-code for whoever hits it.
Also made asterisk.sh's generated README port table stop unconditionally
claiming a TURN relay range it isn't actually publishing when the shared
coturn service (not this install's own container) is fronting TURN
instead — it now branches on USE_EMBEDDED_COTURN, which the function
already receives as a parameter.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
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
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
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
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
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
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
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
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
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
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
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
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
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
$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
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
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
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
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
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
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
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
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
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