63774e05009582dfadbbdaee1ff6971446532182
425
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7efd087993 |
Add standalone backup/restore script for Asterisk, independent of Kopia
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. |
||
|
|
6339235781 |
Correct B2 application key guidance: use "All" bucket access, not one bucket
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 |
||
|
|
9c5d8c32f7 |
Reuse the sudo user's SSH key for root; make B2 rejection unambiguous
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 |
||
|
|
350708ae10 |
Resolve ~/.ssh/config aliases before handing a host to Kopia's sync-to sftp
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 |
||
|
|
b99ca32438 |
Support multiple simultaneous offsite mirrors, not just one
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 |
||
|
|
f36cb2c394 |
Show character counts and which field was blank in B2 setup prompts
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 |
||
|
|
ba9c31aeb1 |
Detect Netbird/Tailscale too before offering wg-easy at the DR-spare prompt
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 |
||
|
|
8dd66945dd |
Offer to actually set up VPN + SSH keys at the DR-spare prompt
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 |
||
|
|
90b0508e19 |
Reuse an existing destination's repository password on re-run
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 |
||
|
|
c48ed039e0 |
Guide + automate Backblaze B2 offsite mirror setup in backup.sh
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 |
||
|
|
93d5459a67 |
Make the backup restore-test schedule configurable, add a run-now option
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 |
||
|
|
624ae3d2f3 |
Stop Mattermost's WEB_PORT/CALLS_UDP_PORT rescanning on every update
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 |
||
|
|
cce8147059 |
Live-verify a newly assigned Mattermost coturn slot isn't already bound
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 |
||
|
|
4a09d3d1de |
Give each Mattermost instance's embedded coturn its own port slot
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 |
||
|
|
158df0d545 |
Fix embedded-coturn relay-port overlap between Asterisk and Mattermost
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 |
||
|
|
aa65b5ef5b |
Make "Full reinstall" a real teardown for asterisk, mattermost, and coturn
Extends the security-dashboard prototype to the shared-coturn trio, since these three are exactly the case that pattern was built for — a fresh reinstall of any of them today just overwrote files in place without stopping old containers first, and coturn's own fresh path never made an informed choice about the consumer credentials/database it happens to leave alone (safe today, but by omission rather than design). - asterisk.sh / mattermost.sh: "Full reinstall" now stops the existing containers (`docker compose down`) before falling through to the normal install flow, and asks a single explicit question — delete stored data (PBX config/spool/voicemail for Asterisk; Postgres db/uploads/config/ plugins for Mattermost) — defaulting to preserve. Their shared-coturn TURN credential is deliberately left alone either way (reused from cache via ensure_coturn_user(), same as update) — it's not this service's own data, and coturn already handles that continuity. Mattermost's existing "_db_has_data" check already reads the filesystem to decide whether to reuse or regenerate DB_PASS, so the wipe/preserve choice composes with that for free — no separate flag needed. Asterisk's warns to re-run pstn-trunk afterward if data is wiped, since that's what actually goes stale (its dialplan patch), not the fabricated "AMI secret" framing an earlier draft of this warning used before I checked the actual code. - coturn.sh: "Full reinstall" now lists which consumers are currently registered (from users/*.env) and asks explicitly whether to also wipe TURN credentials and the user database, instead of silently preserving them as an unexamined side effect of never deleting the directory. Defaults to preserve. If the operator does choose to wipe, the running container is restarted afterward — it holds the old, now-deleted turndb file open, so new turnadmin writes to the fresh file would otherwise go unseen until a restart anyway. Every affected consumer already self-heals a missing credential on its own next Update run via ensure_coturn_user()'s existing cache-miss path — no changes needed there, just confirmed it covers this case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
7a1f09a0f7 |
Rename reinstall-mode prompt; make security-dashboard's "Full reinstall" a real teardown
Two-part change discussed and scoped in this session before touching
anything:
1. Rename "Reinstall in place" (r) -> "Update" (u) and "Full install" (f)
-> "Full reinstall" everywhere the prompt appears: lib/common.sh's
shared prompt_reinstall_mode(), plus the three services that carry
their own duplicated standalone-stub copy of it for standalone
execution (asterisk.sh, coturn.sh, wordpress.sh — per this repo's
documented standalone-bootstrap pattern). Internal state values
(update/fresh/cancel) are unchanged, so no other service's case
statement needed touching. docs/anveo-direct-setup-guide.md's `r`
reference updated to `u` to match. attic/asterisk-digital-ocean.sh
deliberately left alone — this repo's own policy is to not backport
fixes into attic/.
2. security-dashboard.sh's "Full reinstall" now does a real teardown
before reinstalling — stops and removes the systemd unit, sudoers
grant, Caddy site block, and secdash system user, then proceeds
through the normal fresh-install flow — instead of just overwriting
files in place while leaving the old service running underneath.
Prototype for a pattern discussed for other services later: split the
destructive question out explicitly ("also delete
dashboard-admins.conf — per-admin extension scoping?", default n) so
full reinstall doesn't silently discard state a plain "start over"
request wouldn't expect to lose. Verified the backup/restore mechanics
(mktemp, copy out before teardown, copy back after) against a mock
under `set -u` for both the preserve and wipe paths before shipping.
Update mode was already the strongest existing example of surfacing
newer optional prompts (its "Reconfigure Caddy protection?" /
"Reconfigure per-admin scoping?" sub-prompts already cover every setting
fresh-install offers) — no changes needed there for this service.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
d55a7cc81a |
Reach the coturn self-heal check from asterisk.sh's update path too
Direct follow-up to the previous commit's ensure_coturn_user() fix: that
fix is useless for Asterisk specifically unless something actually calls
ensure_coturn_user("asterisk") again, and the update ("Reinstall in
place") branch returns 0 well before the fresh-install path's call to it
— only "Full install" reached it, which re-prompts everything (droplet
detection, domain, etc.) just to fix a credential re-registration.
Added the same call to the update path, gated on NOT having an embedded
coturn (checked via the existing _HAD_EMBEDDED_COTURN detection) — calling
it unconditionally would silently chain-install the shared coturn service
for a box deliberately running Asterisk's own dedicated coturn, exactly
the kind of silent update-time migration CLAUDE.md's coturn guidance
warns against. .env stays untouched either way (self-heal re-registers
with the same cached password, never generates a new one).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
c16224f6d0 | Add sourced nursery rhyme tune fixes | ||
|
|
9d2a4291ee | Add sourced classical tune fixes | ||
|
|
8c52376495 |
Remove stale Caddy block before rewriting it on a fresh security-dashboard reinstall
The fresh-install path called _secdash_configure_caddy directly with no prior removal, unlike the update/reconfigure path which already calls _secdash_remove_caddy_block first. Re-running a "Full install" over an existing dashboard on the same domain therefore appended a second site block instead of replacing the first — and since Caddy serves whichever block comes first in the file, the old one (old Authelia address, old Basic Auth settings) kept winning even after answering the prompts with new values. Confirmed live: reconfiguring a dashboard from a local to a remote Authelia address left the old forward_auth target still in effect until the stale block was deleted by hand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
142897f84c |
Self-heal Beszel agent compose files on update
install_beszel() and install_beszel-agent()'s "update" branches only did a pull+restart, never touching docker-compose.yml — so an already-installed box would never pick up the systemd/dbus/sensor mounts or apparmor:unconfined fixes without a manual edit or a disruptive fresh reinstall. Add _beszel_patch_agent_compose(), called from both update branches, that idempotently patches an existing docker-compose.yml with whichever of the two fixes it's still missing. Anchors on `network_mode: host` and the docker.sock mount line, both unique to the beszel-agent service and present in either compose shape (combined hub+agent or agent-only), so one function covers both install paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
e299b37b0c |
Add apparmor:unconfined to Beszel Docker agents - dbus mount alone isn't enough
The systemd/dbus mounts added last commit aren't sufficient by themselves on an AppArmor-enabled host (Ubuntu/Debian by default): the dbus "Hello" handshake fails with "An AppArmor policy prevents this sender from sending this message to this recipient", since the container has no AppArmor label the host's dbus-daemon profile recognizes. Only visible at LOG_LEVEL=debug - silent otherwise, which is why the mounts alone looked like they should have worked but didn't. Confirmed live against a real box hitting exactly this error. security_opt: apparmor:unconfined is Beszel's own documented fix (beszel.dev/guide/systemd#apparmor-error) for this exact error string. Added to both Docker-based agent compose generators (install_beszel's combined hub+agent, and install_beszel-agent's remote-only variant). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
933b5b00f4 |
Mount systemd/dbus/sensors into Docker-based Beszel agents for Services/Temp
The hub's "Services" column is systemd unit monitoring (CPU/memory per unit), and "Temp" is hardware sensor readings — neither is Docker container stats, which is what the existing docker.sock mount actually provides. A container is isolated from the host's systemd/dbus and most of /sys by default, so a Docker-deployed agent silently showed both columns empty, with nothing anywhere pointing at why. Confirmed live: a natively-installed agent (no Docker, a plain systemd service) gets both for free just by running as a normal host process, which is what surfaced the gap — a Docker-deployed agent sitting right next to it on another box showed nothing in either column. Added read-only mounts for /var/run/systemd/private, dbus's system_bus_socket, and /sys/class/hwmon + /sys/class/thermal to both Docker-based agent compose generators (install_beszel's combined hub+agent, and install_beszel-agent's remote-only variant). All four are best-effort: if a path doesn't exist on a given host, Docker mounts an empty directory rather than failing the container, so the worst case on an unusual host is an empty column, not a regression or a crash risk. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
88aac103c9 |
Fix FMD crash-loop: bind-mounted db dir needs UID 1000, not $ACTUAL_USER
fmd-server's image runs as a fixed, non-configurable UID:GID 1000:1000 baked into its own Dockerfile (useradd --uid 1000 fmd-server) - nothing like PUID/PGID to override it. The install script chowned the bind-mounted ./data dir to $ACTUAL_USER instead, which only happens to work when that user's host UID is coincidentally 1000. Confirmed live: the container crash-loops forever on "permission denied" creating its sqlite db otherwise - same root-cause shape as the Mattermost UID/GID bug fixed earlier this session, different fixed UID. Fixed at both points a container start can happen: the fresh-install path (chown -R 1000:1000 "$FMD_DIR/data" right after the existing $ACTUAL_USER chown, ordered after it since that one is recursive over the whole directory and would otherwise overwrite this) and the update path (previously unguarded - re-asserted before every docker compose up so a box already stuck in this state self-heals on next update instead of staying broken forever, same self-heal precedent as the Vaultwarden SMTP fix earlier this session). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
2a2aba0c9d |
Add Authelia OIDC provider + register-a-client flow for ActualBudget/Vaultwarden/other apps
Authelia's forward_auth (what this repo already sets up) gates a whole site behind a login page before the request reaches it. This is the opposite direction: an app with its own "Enable OpenID"/SSO setting delegating ITS login to Authelia, via Authelia's separate OIDC PROVIDER feature, which this repo had no support for at all. _authelia_ensure_oidc_provider() enables it once, idempotently: generates an HMAC secret (injected via a _FILE env var, same convention as the existing jwt/session/storage secrets) and an RSA signing keypair, then writes identity_providers.oidc into configuration.yml. The RSA private key has to be inlined as PEM directly in that file — Authelia's jwks schema has no file-path or env-var option for it — so configuration.yml gets chmod 600 once OIDC is enabled, unlike before when it held no raw secrets. _authelia_add_oidc_client() registers an app: presets for ActualBudget (/openid/callback) and Vaultwarden (/identity/connect/oidc-signin, and confirmed its SSO support is now native/upstream, not fork-only) fill in the redirect URI automatically; "Other/custom" covers anything else. Each app gets its own Client ID and a random secret (shown once, only the pbkdf2 hash is stored), and the output tells the operator exactly what to paste back into that app's own OpenID dialog or .env — including Vaultwarden's exact SSO_* env vars, not just generic OIDC endpoint URLs. Wired into the existing "Authelia already exists" menu as a new option, alongside "add another protected domain" and "reconfigure from scratch". Exact CLI output formats, default filenames, and YAML schema were verified against Authelia's own CLI source/docs (crypto rand's "Random Value: " label, crypto hash generate pbkdf2's "Random Password:"/"Digest:" labels, crypto pair rsa generate's private.pem/public.pem defaults) rather than guessed, since a wrong assumption here means a cryptic startup failure or broken secret extraction. The YAML manipulation (client-list insertion, domain extraction from session.cookies) was tested end-to-end against the real mikefarah/yq binary against a realistic mock config, which caught a real bug (extracting the wrong awk field for the domain, "domain:" instead of the actual value) before it shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
59f82c57a6 |
Self-heal a half-set SMTP_HOST/SMTP_FROM in Vaultwarden's .env
Vaultwarden crash-loops outright if exactly one of SMTP_HOST/SMTP_FROM is
set ("Both SMTP_HOST and SMTP_FROM need to be set for email support
without USE_SENDMAIL"). The fresh-install prompt flow already avoids ever
writing that half-state, but "update" mode deliberately never touches
.env (same rule as everywhere else in this repo), so a box whose .env was
written before that prompt-side fix existed - or hand-edited since - stays
stuck crash-looping on every future update too, since nothing ever
re-checked it. Confirmed live on a real box.
New _vaultwarden_fix_smtp_halfstate() detects the half-set state and
blanks the whole SMTP block (matching what the fresh-install prompt does
when SMTP is skipped) rather than leaving it broken. Called right before
every docker compose up this file does - the update path (previously
unguarded) and the fresh-install start prompt (defense in depth, since
that path is already safe by construction) - so it self-heals regardless
of how a box got into this state.
Audited every other services/*.sh for the same half-set-required-pair
pattern (SMTP, MAIL_*, SMTP_HOST-style naming) - Vaultwarden is the only
one that actually writes paired config where a partial state crashes the
container. Authelia's SMTP is mandatory-with-defaults (a different,
non-crashing risk); Mattermost/frigate-notify only mention SMTP in
generated docs, never in config they write.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
d1d234b4d2 |
Fix Gatus false-positive red on Authelia-protected and stale-synced sites
The auto-sync condition "[STATUS] < 400" reads as red for any site behind Authelia's forward_auth: Gatus's probe is never logged in, so it correctly gets a 401 back every time — the site is completely healthy, Authelia is just doing its job, but that 401 fails the condition. Confirmed live: every site the user actually logs into showed permanently red. That single condition also had the opposite bug in reserve: on a genuine outage (connection refused, DNS failure, TLS failure), Gatus reports [STATUS] as 0, and 0 < 400 is true — a fully unreachable site would have silently read as "up". Fixed to two conditions together: "[CONNECTED] == true" (catches the actual outage case) and "[STATUS] < 500" (accepts any real response, including 401/403/redirects from an auth gate, only failing on Caddy's own 502/503/504 when the backend itself is unreachable). Also changed the sync loop to refresh conditions on already-synced endpoints, not just add-missing-ones — the old add-if-missing-only logic meant this fix would only apply to newly discovered domains, leaving every already-synced site (which is most of them, on a live box) stuck on the broken condition forever until removed and re-added by hand. Now every sync run (every 15 minutes via the existing systemd timer, or the one that happens immediately on a Gatus reinstall) self-heals all of them. Verified end-to-end against the real mikefarah/yq binary: an existing caddy-sync entry gets its conditions rewritten in place, an unrelated manually-added endpoint is left untouched, and a newly-discovered domain gets the corrected conditions from the start. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
459bde0f38 |
Make tab completion + backup pruning setup unconditional in setup.sh
Both were only ever wired up from inside install_base(), so a box that went straight to a direct single-service install (sudo ./setup.sh beszel-agent, or any other service) without first explicitly running `sudo ./setup.sh base` never got either — the direct-install branch exits before the guided flow's own `run_service base` call is ever reached. Confirmed live: tab completion doesn't work on a fresh box that installed beszel-agent first. Moved the call site to setup.sh itself, right after the --list/--status early exits (which stay read-only and don't require root) and before every other branch (configure, --remove, direct install, guided flow) — all of which are downstream of that point regardless of which one actually runs. Both helpers are idempotent and already no-prompt by design, so calling them unconditionally on every invocation is safe; skipped under --dry-run (with an equivalent [DRY-RUN] message) so a preview run doesn't write real files. install_base()'s own calls to both are now fully redundant (base.sh has no standalone-bootstrap block, so install_base() is only ever reached downstream of setup.sh's new call site) and removed, along with the two DRY-RUN preview lines that described them there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
f7cc0e5bc4 |
Add beszel-agent: agent-only Beszel install for remote/homelab boxes
For monitoring a box that isn't the VPS (e.g. a homelab machine): only the agent needs to run there, and it connects OUTBOUND to the hub over HTTPS using the same key + universal token flow the hub-side installer already uses — no VPN, no router port-forwarding, and no FQDN needed on that box, since nothing on it ever needs to be reached FROM the hub. New register_service beszel-agent in services/beszel.sh (a second registration in the same file, precedented by base.sh's base+glow) reuses _beszel_configure_agent's paste/parse UX for the key/token instead of duplicating it — that function's signature changed from a bare hub port to a full login-URL string so both the local-hub path and this new agent-only path can share it. Run on the remote box: sudo ./setup.sh beszel-agent Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
c719197b20 |
Admin-scoping setup: show live extension list, auto-include owned DIDs
Two refinements to the per-admin extension scoping added last commit: - The setup prompt now shows the dashboard's current extensions (pulled from its own running /api/pstn-permissions, reusing list_extensions()'s already-correct pjsip.conf parsing instead of a second implementation in bash) before asking for each admin's list, with a real example built from actual extension numbers instead of a generic placeholder. Shown fresh for every admin added, one at a time. - An admin scoped to an extension now automatically sees that extension's directly-assigned personal DID's call/text history too, not just its internal activity — parse_pstn_calls()/parse_texts() key inbound rows by the DID that was dialed, not the owning extension, so without this a scoped admin would see their own extension's outbound calls but not inbound calls to their own number. New _dids_for_extensions()/ _admin_scope_for_calls() resolve this per-request from pstn-personal-dids.conf's direct (non-ring-group) owner field. Voicemail scoping is unaffected — a mailbox is always keyed by extension number regardless of which DID rang it. Also removed a dead DASHBOARD_ADMINS_HEADER Python constant left over from before the file-writing responsibility settled on the bash side only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
d124902b8d |
Add fail-closed per-admin extension scoping for Calls & Texts and Voicemail
Two admins sharing one dashboard can now each be scoped to their own extensions on the Calls & Texts and Voicemail tabs, while Security Log, CrowdSec, and Extensions stay fully visible to both — Authelia already provides real per-person identity here (Remote-User, forwarded by Caddy's existing forward_auth/import authelia wiring), this just teaches app.py to finally read it for these two tabs instead of ignoring it. New dashboard-admins.conf ([username] -> extensions=), configured via CLI prompts in security-dashboard.sh (offered at install and on reconfigure), read-only from app.py's side — no write access needed since the file is root-managed. allowed_extensions_for_user() is fail-closed by design: an empty/missing file means unrestricted (today's default, unchanged), but the moment one admin is configured, every other identity — an unlisted admin, a typo, or no Authelia identity at all — sees nothing on those two tabs until added. /voicemail/audio checks the same scope directly (not just the list route) so a guessed or copied URL can't bypass the filter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
8950810cea |
Add voicemail: dialplan/mailboxes in Asterisk, Extensions toggle + Voicemail tab with click-to-play in the dashboard
Asterisk side (services/asterisk.sh): a [voicemail-access] context reachable from every extension (*97 checks your own mailbox, *98<ext> drops a message into another mailbox directly), gated live via AST_CONFIG() on a new "voicemail" flag in pstn-permissions.conf. voicemail.conf gets a skeleton [general]+[default] at install/update, then stays dashboard-owned from there — mailbox lines are never regenerated wholesale by asterisk.sh once the file exists, matching every other install-time-vs-dashboard-owned file split in this repo (.env, firewall rules, etc). Vendor files (entrypoint.sh, easy-asterisk.sh) get patched the same way messaging-dialplan.conf already does, including the live-extensions.conf patch for boxes with existing devices. While tracing the right #include anchor for this, found and then reverted a theoretical "fix" to messaging's own #include position: pstn-trunk.sh's own comment (live-confirmed 2026-07-24) directly contradicts the textbook Asterisk #include semantics I'd assumed, so the safer move was keeping messaging's anchor exactly as already verified working and using the same position for voicemail's own #include. Dashboard side (services/security-dashboard.sh): write_voicemail()/ _apply_voicemail_flag() toggle the flag and a PIN (generated once, kept across future toggles), regenerate_voicemail_conf() keeps voicemail.conf's [default] section in sync, and a module reload takes effect without a full Asterisk restart. Extensions tab gets a Voicemail column next to Messaging, showing the PIN once generated. New Voicemail tab lists every mailbox's messages (parsed from Asterisk's own msgNNNN.txt sidecars) with an inline <audio> player per row — /voicemail/audio validates ext/msg against strict regexes plus a resolved-path containment check before ever opening a file. Dashboard gets read-only ACL + systemd ReadOnlyPaths access to the voicemail spool dir, and a new sudoers-scoped module-reload command. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
3584ad6499 |
Make backup pruning fully automatic, no prompt
The safety net (only ever touches disposable *.backup.* files, never the newest one for any given file) makes this low-stakes enough to just set up unprompted, the same way tab completion already is — matches the user's own read on it. Still fully idempotent (skipped if the timer already exists), so a rerun doesn't re-ask or redo anything. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
c50704e1b3 |
Add automatic tab-completion setup and old config-backup pruning
Two things surfaced from actual use this session: 1. Tab completion (tools/setup-completion.bash, added earlier) required manually editing ~/.bashrc — easy to skip or get wrong (confirmed live: the source line never actually landed the first time). base now wires it in automatically (idempotent, checked by grep first), matching how it already touches ~/.bashrc for SSH Host aliases. 2. No pruning existed anywhere for the *.backup.<timestamp> files ~60 different services create before overwriting a live config (Caddyfile, /etc/fstab, etc) — every one of them backs up, none clean up, so they accumulate forever on a box reconfigured regularly. tools/prune-old-backups.sh prunes by file mtime (not by parsing the timestamp out of the filename — robust to the %Y%m%d-%H%M%S vs %Y%m%d_%H%M%S inconsistency across services), always keeping the single newest backup per distinct file regardless of age. Verified both the normal case (mixed old/new, prunes only the old ones) and the edge case (every backup for a file is old, keeps the newest one anyway) against real fixtures. base offers it as a daily systemd timer (prompted, since it deletes files — unlike the tab-completion wiring, which doesn't). Also added logrotate for Caddy's own access logs (/var/log/caddy/*.log), which had no rotation at all and grow unbounded on an active box. Uses copytruncate specifically: the log directory is bind-mounted into the running Caddy container and read live by CrowdSec, so truncating in place avoids either of them needing to notice or react to a rotation happening. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
cd003dbaf3 |
Add Gatus auto-sync from Caddyfile, promote ensure_yq to lib/common.sh
Adds one Gatus endpoint per Caddy site block automatically, tagged group: caddy-sync — the sync only ever adds/removes entries in that exact group, so anything added by hand (the default external checks, a custom endpoint) is never touched regardless of what the Caddyfile looks like. Offered at install time (syncs once immediately) and, if systemd is available, scheduled via a timer every 15 minutes so a site added or removed later gets picked up without re-running the installer — matches the "schedule that checks the Caddyfile" shape asked for. Domain extraction tracks actual brace depth (reusing the same approach as remove_service's Caddy block removal) rather than a naive line-by-line scan, so it correctly skips the global options block and parenthesized snippet definitions like (authelia) without needing to special-case them by name. Verified end-to-end against a real Caddyfile/config.yaml fixture with the actual mikefarah/yq binary: initial sync adds the right entries and leaves the default "external" group alone, a second run with no Caddyfile changes is a true no-op (0 added, 0 removed), and changing the Caddyfile (removing one site, adding another) correctly adds the new endpoint and removes only the stale one. Also fixes a real gap surfaced while building this: ensure_yq (used by both gatus.sh now and onlyoffice.sh already) checked `command -v yq` alone, which a box can satisfy with a completely different, incompatible yq — confirmed live in this environment, Debian/Ubuntu's own `yq` apt package is kislyuk/yq (a Python jq-wrapper) which silently errors on mikefarah/yq's `e '.path' file` syntax every caller here depends on. Now checks the version string actually identifies as mikefarah's before trusting it, installing to /usr/local/bin (which precedes /usr/bin on Ubuntu's default PATH) if not. Promoted ensure_yq itself from onlyoffice.sh (its only previous user) to lib/common.sh now that gatus.sh needs the same thing, so both share one implementation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
7c85f326c8 |
Accept Beszel's own "copy for docker compose" snippet directly
Confirmed live: Beszel's Settings -> Tokens & Fingerprints page surfaces
a "copy for docker compose" shortcut as the prominent way to grab the
key/token — not a bare string — so the previous two-prompt flow (paste
plain key, paste plain token) didn't match what people actually have
in their clipboard. The user pasted that YAML snippet into .env by
hand afterward, using the container's raw KEY/TOKEN names and YAML
`NAME: 'value'` syntax instead of what the compose file's own
${AGENT_KEY:-}/${AGENT_TOKEN:-} substitution actually reads — the
agent then failed with "no key provided" since AGENT_KEY was never
actually set.
_beszel_extract_field pulls KEY/TOKEN out of whatever shape the paste
arrives in — YAML mapping (`KEY: 'value'`), compose list style
(`- KEY=value`), or plain `KEY=value` — regardless of quoting. The
agent prompt now accepts a multi-line paste (the whole snippet, or
just the two lines) instead of asking for two separately pre-extracted
values; if no labeled KEY/TOKEN line is found at all, it falls back to
treating the paste as a bare key and asks for the token separately, so
a Beszel version that really does just show plain strings still works.
Verified all three paths directly: the exact mixed KEY:/TOKEN= paste
the user had, the skip path (blank first line leaves .env untouched),
and the bare-value fallback with no labels at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
e10e1e90b4 |
Fix invalid docker-compose.yml when Beszel is installed with local Caddy
Confirmed live: "networks.beszel-agent additional properties ... not allowed" — the root-level networks: block (_CADDY_NET_SECTION) was placed between the two services instead of after both. Since it sits at 0 indentation, YAML parsed the following beszel-agent: line as a continuation of the networks: mapping instead of a new services: entry, so the whole beszel-agent service definition got swallowed as if it were a (invalid) child of networks.caddy_net. gatus.sh's identical _CADDY_NET_BLOCK/_CADDY_NET_SECTION pattern never hit this because it only ever has one service, so the same placement is always the last content in the file there. Moved _CADDY_NET_SECTION (the root-level networks: definition) to after both services; _CADDY_NET_BLOCK (the per-service "join caddy_net" snippet) stays right after the hub's own volumes, where it correctly nests under the beszel: service only. Verified by regenerating the compose file with local Caddy present and parsing it with PyYAML: services.beszel and services.beszel-agent are now proper siblings, beszel-agent keeps its image/environment/volumes keys, beszel's own networks: is scoped to just that service, and the root networks: definition is separate and correctly placed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
7938399e99 |
Add Beszel for lightweight server + Docker monitoring
Answers "what's the best way to see CPU/RAM/disk usage on this box"
(IONOS's own dashboard doesn't expose it) and "does Gatus cover this" —
it doesn't, Gatus is a black-box HTTP check (is the site responding
from the outside), Beszel is white-box host/process monitoring (is the
box under memory/disk pressure, is a container actually running vs.
crash-looping). Complements Gatus rather than replacing it.
Mirrors the hub+agent same-system layout from beszel's own
supplemental/docker/same-system/docker-compose.yml (fetched from the
actual upstream repo, not reconstructed from memory) — hub is the web
dashboard, agent reads /var/run/docker.sock (read-only) to report every
currently-running container automatically, no per-service config
needed as containers get added or removed.
Genuinely a two-phase install: the hub's SSH keypair and universal
token only exist after logging into its web UI once, so this starts
the hub, walks through where to find both values, and finishes wiring
the agent once provided — skipping is fine, a rerun in "update" mode
detects the agent was never connected and offers to finish it.
Verified the generated docker-compose.yml/.env by running the actual
file-writing code path with docker/configure_caddy_for_service mocked
out — confirmed TOKEN/KEY are correctly left as literal
${AGENT_TOKEN:-}/${AGENT_KEY:-} for Docker Compose's own substitution
at "up" time, not prematurely expanded by the heredoc itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
ad011b2db8 |
Add check_container_health helper, wired into mattermost.sh as reference
A service's "Started" message after docker compose up -d doesn't mean the app is actually working — it can still crash-loop (bad DB password, missing required env var, etc.) with no visible sign until someone separately runs docker ps -a much later, exactly what happened repeatedly this session (mattermost, koha-db, homebox, vaultwarden, filebrowser all showed a clean "Started" message while crash-looping). check_container_health (lib/common.sh) waits briefly, checks the container's actual status and restart count via docker inspect, and prints recent logs automatically if it's not running or has already restarted — instead of a misleading one-line success message. Wired into mattermost.sh's own start step as the reference implementation, guarded by declare -F so standalone runs (no lib/common.sh sourced) degrade gracefully. Not retrofitted across every other service in one pass — this establishes the shared helper so other services can adopt it incrementally. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
74628f3736 |
Fix vaultwarden SMTP false-positive and mattermost DB password mismatch
vaultwarden: SMTP_PORT defaulted to "587" and SMTP_SECURITY was a
hardcoded "starttls" literal in the .env template, written
unconditionally regardless of whether SMTP_HOST was ever provided.
Confirmed live: skipping SMTP entirely (blank SMTP_HOST) still wrote
real values for those two, and Vaultwarden reads that as "some SMTP
config is present," refusing to start ("Both SMTP_HOST and SMTP_FROM
need to be set") even with host/from genuinely blank. Both now stay
empty unless SMTP_HOST is actually set.
mattermost: DB_PASS/MM_SECRET were only reused from the existing .env
when MODE=update — a "fresh" reinstall always generated a new
POSTGRES_PASSWORD. Confirmed live: choosing fresh after removing only
the mattermost app container (not the whole directory) regenerates the
password in .env while db/'s existing Postgres data still enforces the
OLD one from its first init (the entrypoint skips re-init on existing
data), causing "password authentication failed for user mattermost" on
every start. Whether db/ already has real data is what actually
determines whether the old password is still live, not which reinstall
mode was chosen — reuse the existing secrets whenever db/ is non-empty,
regardless of MODE.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
c177312947 |
Fix three crash-looping services: koha-db, homebox, vaultwarden
koha-db: compose used MYSQL_ROOT_PASSWORD/MYSQL_DATABASE/MYSQL_USER/
MYSQL_PASSWORD, but this mariadb:11 image version's entrypoint doesn't
recognize MYSQL_ROOT_PASSWORD as any of its accepted root-password
options at all. Confirmed live: "Database is uninitialized and password
option is not specified" on every start, even though DB_ROOT_PASS was
correctly generated and present in .env the whole time. Switched all
four to their MARIADB_* equivalents.
homebox: a newer homebox release requires HBOX_AUTH_API_KEY_PEPPER (at
least 32 bytes) or the container panics on startup — this installer
never set it. Generate one with generate_password 48 and wire it
through .env + the compose environment block.
vaultwarden: the SMTP setup prompts let you enter a host but leave
"SMTP from address" blank (no default), writing a half-configured state
Vaultwarden refuses to start with ("Both SMTP_HOST and SMTP_FROM need
to be set"). Validate after prompting — if SMTP_HOST is set but
SMTP_FROM came back empty, disable SMTP entirely instead of writing a
config known to crash the container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
77440c8f75 |
Live-scan Traccar's device-protocol range for collisions, not just Asterisk's ports
The 5000-5150 range was only ever checked against Asterisk's hardcoded fixed ports (5038/5060/5061) for a first instance — no live scan of the rest of the range, because the directory-count-based offset mechanism only triggers for an explicit additional instance. Confirmed live: this range sat unclaimed at the OS level while this Traccar instance's container had never actually started, so an unrelated service's own find_free_port scan found port 5007 genuinely free (nothing was listening there yet) and took it — invisible to any check until Traccar itself tried to bind its declared range for the first time, failing with "port is already allocated". Add a live scan across the whole intended range (skipping Asterisk's expected carve-outs at the base 5000-5150 range) and shift by 1000, same step the multi-instance path already uses, until genuinely clear. Also fixed the compose-block and README generation, which keyed off INSTANCE_SUFFIX being empty to decide whether Asterisk's exclusions were needed — now keyed off whether the range is still the unshifted default (PROTO_MIN -eq 5000), since a first instance can now end up shifted too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
8bdf4c0a07 |
Add explicit UFW rules for Caddy's 80/443 — Docker was silently bypassing UFW
services/caddy.sh never called ufw allow for any of its published ports. Confirmed live: ufw status showed no rule for 80 or 443 on a box with UFW active (default deny incoming), yet HTTPS sites were reachable fine — Docker manipulates iptables directly for published container ports (the ports: mapping in Caddy's own compose file), which bypasses UFW's filtering entirely regardless of what ufw status reports. This wasn't an actual exposure gap — 80/443 are supposed to be open to everyone, that's the point of a reverse proxy — but it means ufw status was actively misrepresenting this box's real firewall state on its two most externally-facing ports, which is exactly the kind of thing that looks like a problem (and did, when investigating an unrelated Let's-Encrypt failure) even though nothing was actually unprotected. Add explicit ufw allow rules for 80/tcp, 443/tcp, and 443/udp (HTTP/3) so ufw status reflects reality, matching every other service in this repo managing its own firewall rules instead of relying on undocumented Docker/iptables interaction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
060f288107 |
Fix authelia.sh skipping the real Caddy snippet due to a commented example
grep -q "(authelia)" matches caddy.sh's starter Caddyfile's own commented-
out example block ("# (authelia) {", included as documentation), so
authelia.sh believed the real snippet already existed and never wrote
it. Any later service adding `import authelia` to its own site block
then references a snippet that only exists as a comment.
Confirmed live: this takes Caddy down completely, not just the
Authelia-protected site — "Error: adapting config using caddyfile:
File to import not found: authelia" is a load-time failure, so Caddy
restart-loops and every site it fronts goes with it.
Anchor the check to an actual uncommented snippet definition
(^\(authelia\)\s*\{) instead of a bare substring match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
6588e3abf1 |
Add a way to remove an existing vpn-data-mount without re-adding it
Removal only existed as a side effect of picking the same share again in the "fully redo this mount" path — there was no direct way to just remove a mount you no longer want, without walking back through host/ share selection first. Adds a top-level "Remove any existing VPN data mounts?" prompt that lists every configured mount by number (via the new _vdm_list_all_mounts) and lets you remove one or more, reusing the existing _vdm_remove_mount teardown (decrypt-layer unit, unmount, credentials file, tagged /etc/fstab entry). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
39e7b2ae6e |
Fix Mattermost crash-looping with permission denied on config.json
The official mattermost/mattermost-team-edition image runs as a fixed UID/GID 2000 baked into the image — it does not read PUID/PGID env vars, that's a LinuxServer.io s6-overlay convention this image doesn't use. This file set them anyway (computed from ACTUAL_USER's uid/gid), which did nothing, while the actual host directories (./data, ./logs, ./config, ./plugins) got chowned to ACTUAL_USER instead of 2000:2000. Confirmed live: the container fails on its very first start with "could not create config file: open /mattermost/config/config.json: permission denied" and crash-loops — which then presents as a 502 from Caddy, an easy trail to follow to the wrong place since Caddy itself was fine. Removed the dead PUID/PGID mechanism and chown the app's own volumes to 2000:2000 after the existing ACTUAL_USER chown. db (postgres:15-alpine) isn't affected — its entrypoint fixes its own volume ownership on startup. Runs on both fresh installs and "update" reruns, so re-running the installer on an already-broken instance self-heals it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
cfd3b04b7b |
Add a full-redo option for an already-mounted vpn-data-mount share
The previous fix only let an already-mounted share reconfigure its decrypt layer — there was still no way to change the mount point or re-enter credentials for a share that's already set up, since the label prompt was skipped entirely in that path. Add a real choice when an existing mount is found: reconfigure the decrypt layer in place (as before), fully redo the mount (tears down the old one via the new _vdm_remove_mount and falls through to the normal fresh-mount flow, label pre-filled from the old one), or skip. _vdm_remove_mount stops/removes any decrypt-layer systemd unit first (it sits on top of the CIFS mount), then unmounts, removes the credentials file, and removes the /etc/fstab tag+entry via a fixed ",+1d" range — the tag line plus exactly the one mount line that always immediately follows it, not an open-ended range to the next blank line or EOF (the class of bug fixed earlier in this file's history for the now-removed remote smb.conf-writing code). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
304e4b644c |
Let an already-mounted share be reconfigured instead of blocking on label reuse
Re-running vpn-data-mount for a share that's already mounted hit the label-uniqueness check with no way through it — picking the same share always re-prompted for a label, and the existing label was always already taken by definition, so it just looped rejecting every input. Confirmed live: reported as an infinite "Label 'data1' is already used" loop right after this share had already been mounted in an earlier run. Detect the existing fstab tag for the same host+share up front and reconfigure it in place — currently the one thing safe to redo without touching a working plain mount: the gocryptfs decrypt layer added previously. Skips the label prompt and remount entirely for a share that's already set up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
a23d5d6fc7 |
Add optional client-side encryption layer for vpn-data-mount
The VPS side of a plain SMB mount necessarily sees plaintext while it's mounted and in use — that's unavoidable for data a VPS service actually needs to read. What's avoidable is everything else: a disk image, backup, or provider-side look at the VPS while the mount isn't actively in use showing your actual files instead of ciphertext. tools/gocryptfs-setup-home.sh (new): standalone tool for the home box. Creates a gocryptfs-encrypted directory and passphrase file; the user points their existing Samba share's `path =` at the cipherdir (manual step — same read-only stance on remote Samba config vpn-data-mount.sh already takes, this tool doesn't touch smb.conf either). services/vpn-data-mount.sh: after mounting a share over CIFS as before, optionally offers a gocryptfs decrypt layer on top. Fetches the passphrase fresh over the same SSH trust already used for share discovery, pipes it straight into gocryptfs, and never writes it to the VPS's own disk. A generated systemd unit (via a wrapper script, not one long quoted ExecStart= one-liner — avoids stacking systemd's own word-splitting on top of bash -c's) keeps the decrypted view coming back on boot, re-fetching the passphrase each time rather than caching it. Fully opt-in and per-share — a plain unencrypted mount works exactly as before if declined. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |