extras/fix_pikapods_dump.py patches two confirmed Adminer PostgreSQL-export
bugs that otherwise make a PikaPods Mattermost migration fail outright:
unquoted enum-label DEFAULT values (Postgres reads the bare label as a
column reference and rejects the CREATE TABLE) and boolean columns
serialized as bare 0/1 instead of true/false (Postgres doesn't implicitly
cast integers to boolean). Boolean columns are discovered by actually
parsing each CREATE TABLE in the dump rather than working from a
hand-curated list — Postgres only reports the first bad column per failed
row, so a list built from error output alone would likely be incomplete.
Already verified earlier this session against a real local Postgres 16
instance; reviewed now for anything needing redaction before committing —
it's a generic text-processing tool with no hostnames, credentials, file
paths, or personal data in it, so nothing needed changing.
Cross-referenced from the generated migrate-from-pikapods.sh's header
comment (services/mattermost.sh) so anyone hitting a CREATE TYPE/CREATE
TABLE or boolean-column import error is pointed at the fix instead of
having to rediscover it.
The generated migrate-from-pikapods.sh (services/mattermost.sh's existing
"Migrating from an existing Mattermost instance?" prompt on fresh installs)
already correctly parameterizes PROJECT_DIR/MM_CONTAINER/DB_CONTAINER per
instance — no bug there. What it missed: after rsync/cp-ing files in from
the export, it never touched ownership, so the imported ./data landed
owned by whoever ran the script instead of the fixed UID 2000
mattermost/mattermost-team-edition runs as. Every file write then failed
with permission denied — confirmed live as the actual cause of a
client-side "stream closed" error on image/file uploads after a real
migration.
Adds chown -R 2000:2000 ./data right after the copy step, and a root
check up front since chowning to an arbitrary UID needs it (docker/psql
access already implied running as root in practice, just never enforced
explicitly). Usage lines updated to say `sudo` to match.
Verified by reconstructing the exact generated script from the real
source heredocs (head + variable substitution + body, the same three
pieces the actual cat/cat>> sequence produces) and syntax-checking the
result — root check and chown both land in the right place, and
PROJECT_DIR/MM_CONTAINER/DB_CONTAINER still resolve correctly per instance.
Root-caused a live "stream closed" image-upload failure to
data/20260814/.../mkdir: permission denied — the volumes weren't owned by
the fixed UID 2000 mattermost/mattermost-team-edition runs as, most likely
left that way by the PikaPods data import. The install script already
chown -R 2000:2000's these on every run (fresh or update), so re-running
the installer would have fixed it — but that still means remembering to
re-run it every time ownership drifts for any reason, including causes
this repo doesn't control (a future migration, a manual restore, anything
that copies files in as a different UID).
Added a small mattermost-fix-perms init container (busybox, chown, exit)
that the mattermost service now depends on via
condition: service_completed_successfully. Runs on every `docker compose
up` — including a plain host reboot, since restart: unless-stopped brings
the stack back on its own — so this self-heals permanently instead of
needing a human to notice and fix it by hand again.
Verified the generated compose file (with representative variable values)
against real `docker compose config`: valid YAML, and the dependency graph
correctly shows mattermost waiting on both db (service_healthy) and
mattermost-fix-perms (service_completed_successfully).
Two independent, requested changes:
- services/pihole.sh: new standalone service, Pi-hole v6 (the image moved
entirely to a TOML-based /etc/pihole config — the old WEBPASSWORD env var
and separate /etc/dnsmasq.d volume are both gone; uses
FTLCONF_webserver_api_password and FTLCONF_dns_listeningMode=ALL
instead). Deliberately not wired into wg-easy or any other VPN — a device
has to be pointed at it manually (per-device or via router DHCP). DNS
itself (53/tcp+udp) is never scanned/moved since shifting it off the
standard port would defeat the point; a port_in_use check warns instead
of blocking, since the common case (systemd-resolved on 127.0.0.53 only)
doesn't actually collide with Pi-hole binding the host's real interfaces.
Web admin UI is Caddy-fronted like everything else in this repo. Added to
the README services table.
- services/wg-easy.sh: default VPN/web ports moved from 51820/51821 to
51830/51831. Netbird's own WireGuard listener also defaults to exactly
51820 — installing both on one box means wg-easy's existing scan-and-move
logic would silently shift its port every time, which is harder to
predict/document than just not starting on the collision in the first
place. The scan itself is unchanged and still moves both ports further if
even the new default is taken.
Tested pihole.sh's full standalone install flow (no-Caddy and
Caddy-present-locally cases) against a mocked environment, validating both
generated docker-compose.yml files with `docker compose config`, and
confirmed the reinstall-mode gate correctly no-ops on a second run in
unattended mode.
Requested after a rerun silently reset DR_SYNC_PATH (fixed separately) —
auditing the rest of install_backup() turned up the same class of bug in
several other places, one of them worse than the one that prompted this:
- Default destination repo path defaulted to $ACTUAL_HOME/backups/... even
when the real configured repo was somewhere else entirely (this user's
actual path is /root/backups/kopia-backup) — accepting the shown default
on a rerun would have pointed the installer at the wrong location.
- Extra (non-"default") destinations weren't preserved AT ALL on a rerun —
skipping "Add more destinations?" silently dropped every extra
destination, and anything mapped to it, from the rewritten backup.conf.
- The per-service destination-assignment prompt always showed "[default]"
regardless of the service's actual existing mapping.
- ntfy URL/token always started blank, silently disabling notifications on
any rerun where they weren't retyped.
- The schedule prompt always defaulted to option 1 (daily 02:00) instead of
reading back whatever OnCalendar was actually already running.
- B2's four sub-fields (bucket/endpoint/key ID/secret) always started
blank even when reconfiguring an already-working REMOTE_TYPE=s3 setup —
a mispaste on any one of the four meant retyping all four blind, since
there was nothing to fall back to per-field (the existing REMOTE_ARGS was
already preserved as a whole on a blank/failed attempt, just not offered
back as individual editable defaults).
All six read the same way: pull the existing value from backup.conf (or,
for the schedule, from the live systemd timer unit — schedule isn't stored
in backup.conf) and use it as the prompt default, so accepting the default
keeps what's already there instead of silently reverting it. Verified all
six against a mock backup.conf + timer fixture with pre-existing values for
every field this touches.
Known remaining gap: KEEP_LATEST (retention count) still isn't read back —
doing so correctly needs the repo already connected, which happens later
in this same function's flow. Flagging rather than rushing a reorder here.
Two stacked bugs, found together when re-running the backup installer to
add an SFTP mirror silently reverted a previously-set absolute
DR_SYNC_PATH back to the script's tilde-based default, which then failed
outright:
1. services/backup.sh never read DR_SYNC_HOST/DR_SYNC_PATH back from an
existing backup.conf before prompting (every other setting in this file
does — passwords, mirrors). Accepting the prompt defaults on a rerun
silently reset both to blank/"~/docker/backup" instead of keeping what
was already configured. Fixed by reading them back the same way
DEST_*_PASSWORD already does.
2. extras/backup_kopia.sh's DR-spare sync wraps the remote path in single
quotes for its `ssh host "mkdir -p '...'"` / `"chmod 600 '.../...'"`
commands. Single-quoting a leading ~ stops the remote shell from
expanding it at all, so it looked for a literal directory named "~"
instead of the home directory — breaking the script's own DEFAULT
DR_SYNC_PATH ("~/docker/backup") for anyone who actually used it.
rsync's own transfer step has separate, correct tilde handling, which is
why the sync itself "succeeded" while the follow-up chmod couldn't find
the file. Fixed with a small _dr_remote_quote() helper that keeps a
leading ~/ outside the quotes while still safely quoting the rest of
the path.
Verified the quoting fix by parsing the exact constructed command string
in bash directly — a plain '~/docker/backup' stays literal (the bug),
~/'docker/backup' correctly expands to $HOME/docker/backup (the fix).
_backup_ensure_root_ssh_key() only ever checked for /root/.ssh/id_ed25519
or id_rsa by exact filename. Root can already SSH to the DR-spare/mirror
host just fine in practice (proven by this same script's own DR-spare sync
succeeding), just via a key with some other name — so the function had no
way to see that and always fell through to offering a copy-from-user-home
or brand-new ssh-keygen, both unnecessary.
Now takes the target host as an optional argument. When given, it tests
root's SSH access to that host as-is first and resolves the actual key via
`ssh -G <host>` (which expands ~/.ssh/config the same way the SFTP-dest
resolution earlier in this file already does) before falling back to the
copy/generate prompts. Both call sites (DR-spare, SFTP mirror) now pass
their respective host.
Verified against a mock ssh: an already-working non-default-named key gets
detected and reused with no prompts, and the original copy/generate
fallback still triggers correctly when SSH genuinely doesn't work yet.
Asterisk's whole state (dialplan, pjsip devices, voicemail, recordings,
.env with its coturn credential, docker-compose.yml) already lives under
one self-contained directory, so asterisk-standalone-backup.sh just tars
it — with stop/restart safety around the tar since voicemail/spool write
continuously, and a move-aside-then-extract restore that rolls back
automatically if extraction fails. Written into the install directory at
both fresh-install and update time via _asterisk_write_standalone_backup_script().
Output defaults to ~/asterisk-backups/, deliberately outside ~/docker/, so
a Kopia backup of the box doesn't also back up a backup-of-itself. Meant
for a quick pre-change snapshot or moving this PBX to a new host without
standing up the full backup stack first.
Documented in the generated README's new "Standalone backup/restore"
section. Tested against a mocked EA_DIR (fake docker/docker compose,
config/spool/voicemail files) confirming backup produces a correct tar and
restore replaces content correctly with rollback on extraction failure.
Confirmed live and cross-checked against a real, documented Kopia issue
(kopia/kopia#5329): the walkthrough previously told the operator to scope
the Application Key to just the bucket they created — the more
security-conservative default, and correct for B2's own S3-compatible
API in general. But Kopia specifically needs the listBuckets capability
even though it only ever touches the one configured bucket, and B2's
basic "Add a New Application Key" web form doesn't expose a way to grant
listBuckets on a bucket-restricted key — only an account-wide ("All")
key gets it through that form. Without it, the connection fails with
B2's unhelpful "Cannot access bucket" error, which doesn't point at the
actual missing capability at all.
Updated the guidance to "All" with the reasoning inline, and a note that
single-bucket scoping is still possible for anyone willing to create the
key via B2's CLI/API directly (b2_create_key with an explicit
capabilities list including listBuckets) rather than the basic web form
this walkthrough is written for.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Two separate fixes from a live report.
1. The DR-spare and SFTP-mirror sections both checked ONLY /root/.ssh
for a key, missing the common case: the person running `sudo
./setup.sh backup` already has a key under their own home directory
(used interactively, quite possibly already authorized on the target
box), while root — who actually runs the scheduled systemd service —
has none. Confirmed live: "the computer has the ssh key for the sudo
user on the box" produced "No SSH key found for root" with no
inline way to do anything about it beyond a pointer to go set one up
elsewhere and re-run.
Factored both call sites into one shared _backup_ensure_root_ssh_key()
that checks root first, then offers to reuse the sudo user's existing
keypair (copied into /root/.ssh with correct ownership/permissions,
root:root 600) before falling back to generating a brand new one —
reusing an existing key can work immediately if it's already
authorized on the target, where a fresh key needs a new ssh-copy-id
round-trip regardless. Verified all three branches (root already has
a key, root has none but the user does and accepts reuse, neither
exists and one gets generated) against a mocked filesystem.
2. The B2 dry-run failure message read like it could be about missing
input even when every field was non-empty — confirmed there's no
code path where non-blank-but-wrong values actually trigger the
separate "Left blank" message (the two are on disjoint branches), but
the dry-run failure text itself didn't rule that out or point at the
actual likely cause. Now echoes back what was entered (bucket,
endpoint, Key ID — never the secret) so it's easy to eyeball against
B2's own confirmation screen, states plainly that this is a rejection
of non-blank input, and names the most likely cause directly: pairing
the Key ID from one Application Key with the Secret from a different
one, which is easy to do after creating more than one while
troubleshooting.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Confirmed: Kopia's sync-to sftp has its own SFTP client and doesn't read
~/.ssh/config the way the system ssh/scp binaries do — so an alias set
up via wg-easy's sync-ssh-aliases.sh (or any ~/.ssh/config Host entry)
worked fine for the DR-spare connectivity check (which shells out to
real ssh) but silently failed for this mirror: a plain @-split on an
alias like "main" (no @ present) produced --host=main, a name that only
resolves inside ~/.ssh/config, not real DNS. The dry-run check correctly
rejected it and the mirror was never saved — no error surfaced beyond
that, so it looked like nothing happened.
Now resolves the destination through `ssh -G` before building the Kopia
flags — the same mechanism ssh itself uses to expand config aliases —
and falls back to the previous plain @-split only if that comes back
empty. Verified against three cases: a bare alias (resolves via a mock
~/.ssh/config Host block), an explicit user@ip (passes through
unchanged), and an unrecognized name (falls back to a sane literal
hostname rather than erroring).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Requested: mirror to Backblaze B2 AND directly to the IONOS spare box
over Tailscale, at the same time, not one or the other. REMOTE_TYPE/
REMOTE_ARGS was hardcoded to a single mirror target — extending it to a
list would have meant redesigning the one thing that already works and
was already verified against real B2 credentials, so this adds a
separate, additive mechanism instead: EXTRA_MIRROR_NAMES, a space-
separated list, with per-entry MIRROR_<name>_TYPE/_ARGS (same argument
shape as REMOTE_ARGS). An existing B2-only backup.conf keeps working
completely unchanged if this new section is skipped.
install_backup() gets a new "ADDITIONAL MIRROR" prompt after the
existing B2 section: offers a direct SFTP mirror (Kopia's sync-to sftp,
not the deprecated b2 provider — same reasoning as the S3/B2 choice
already made), defaults the destination to whatever was typed at the
DR-spare prompt above (same box, same purpose, no reason to ask twice),
checks passwordless SSH and an SSH key exist first, then verifies with a
--dry-run against the just-created 'default' repo before saving it —
same "don't save something broken" discipline as the B2 flow. Verified
against a mock backup.conf that install-side writes and worker-side
reads agree on the exact format, and that reusing an existing mirror
name reconfigures it instead of duplicating it in the name list.
One correction while researching sync-to sftp's flags: unlike plain ssh,
Kopia doesn't shell out to the system SSH client, so it needs an
explicit --keyfile and --known-hosts path rather than picking up
whatever `ssh` already trusts automatically — checked Kopia's own docs
for the exact flags before writing this, same as the earlier S3 case.
extras/backup_kopia.sh's worker loops through EXTRA_MIRROR_NAMES after
the existing REMOTE_TYPE mirror step, running sync-to for each
destination against each additional mirror and folding failures into
the same FAILED_SVCS/notification reporting the primary mirror already
uses. Verified end-to-end against a mock backup.conf and a stubbed
kp_for: both the B2 and the new SFTP mirror get called in sequence with
the correct arguments.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Requested after a live failure: pasting into the hidden Application Key
field silently captured nothing (terminal/SSH-client dependent), and the
only symptom was a generic "one or more fields left blank" warning after
all four prompts had already gone by — no way to tell which field, or
even that the paste itself was the problem rather than something else.
Each of the four fields now echoes its character count right after entry
(never the value for the hidden Application Key field, just its length),
so a failed paste is visible immediately instead of discovered several
prompts later. The blank-field warning now also names exactly which
field(s) were empty instead of a generic message.
Verified against the user's actual reported case: bucket/endpoint/key-ID
entered normally, Application Key came back empty — reproduces as
"(0 characters entered)" on that line and "Left blank: Application Key"
in the warning, both confirmed against a second case where all four
fields are present and it passes through cleanly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Requested: don't push the operator toward installing wg-easy if they
already have a different mesh VPN (Netbird or Tailscale) running —
detect any of the three first, and only offer a choice when none are
present.
Detection checks wg-easy's own directory (this repo's install marker),
then falls back to checking whether the netbird/tailscale binaries exist
AND their systemd services are actually active — not just installed,
since an installed-but-never-connected client isn't a usable path to the
spare box either. wg-easy takes priority if somehow more than one is
present, since it's this repo's own chain-installable option.
When none are detected, offers a numbered choice: wg-easy (chain-installs
via the existing declare -F guard), Netbird, or Tailscale (both via their
official curl-pipe-sh installers — verified the current URLs against
each vendor's own docs rather than guessing, since a wrong URL here would
be a bad thing to ship). Both third-party options still need a manual
follow-up step this script can't complete unattended (Netbird needs a
setup key from the operator's account, Tailscale needs an interactive
auth link) — the success message says so rather than implying the
install alone finishes the job.
Verified the detection branching against all the cases that matter:
nothing present, only wg-easy's directory, only Netbird active, only
Tailscale active, and multiple present at once (wg-easy correctly wins).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Requested improvement: the disaster-recovery spare prompt in backup.sh
already ran a live connectivity check and, on failure, printed manual
instructions (set up wg-easy separately if the spare isn't reachable,
run ssh-keygen/ssh-copy-id yourself) — but never offered to do any of it
right there, even though every piece is safe to automate inline.
Now, when the passwordless SSH check fails:
- If wg-easy isn't installed yet, offers to chain-install it (guarded
with declare -F install_wg-easy, same pattern asterisk.sh already uses
for security-dashboard/pstn-trunk) — covers the common case where the
spare is a home box with no port-forward and no path there at all yet,
not just a missing key.
- If root has no SSH key, offers to generate one (ssh-keygen -t ed25519).
- Offers to run ssh-copy-id against the spare interactively right there
— it prompts for the spare's login password itself, so this script
never touches or sees that password, just invokes the real command
inline instead of telling the operator to go run it themselves after.
- Re-runs the connectivity check after ssh-copy-id succeeds, so the
install flow reports the actual current state instead of the
pre-fix failure message.
Verified the has-a-key detection (the part most likely to have a subtle
&&/|| precedence bug) against all four cases — no key, only id_ed25519,
only id_rsa, both — behaves correctly in each.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Confirmed live: backup.sh has no update/fresh distinction and re-runs
every prompt on every invocation, including the repository password
prompt — which always minted a fresh (typed or auto-generated) password
regardless of whether a repo already existed at that destination's path.
Re-running the installer (to add a destination, configure the new B2
offsite mirror, or just by habit) then fails to connect to the real,
already-populated repo with "invalid repository password", because the
repo's actual password is permanently whatever was set the first time
and nothing read that back.
Each destination's password is now read back from the existing
backup.conf (if that destination name was already configured there)
before falling through to prompt/auto-generate — same pattern already
applied to REMOTE_TYPE/REMOTE_ARGS, EMBEDDED_COTURN_SLOT, and everywhere
else in this session that re-running a script with no update/fresh gate
turned out to silently regenerate something it shouldn't have. Verified
against a mock backup.conf: an existing destination's password is reused
verbatim, and a genuinely new destination name still falls through to
fresh generation correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Both branches independently solved the same Mattermost/Asterisk coturn
relay-port collision problem. Kept main's find_free_coturn_range-based
approach (documented in CLAUDE.md as the canonical pattern, and shared
across every coturn-owning service) over this branch's earlier
Mattermost-only port-slot scheme, and cleaned up the now-unused
_MM_COTURN_PORT/_MM_COTURN_MIN/_MM_COTURN_MAX/EMBEDDED_COTURN_SLOT
references that had auto-merged without conflict markers.
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
Sipnetic's own documented "st" field format is explicit that the value is a
hostname/IP without a port -- its worked example (turn:user:pass@host) has
no port anywhere, even in the URI-with-credentials form. Appending :3478 as
this repo was doing gets silently truncated by the app: confirmed live, the
FQDN came through on scan but the port after it did not.
coturn's listening port in this repo is always the STUN/TURN-conventional
3478 anyway, which is what a portless address implies, so stripping it
before building the st field costs nothing and matches the actual spec.
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
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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
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