Commit Graph
969 Commits
Author SHA1 Message Date
Outis a66b82beaa Merge pull request #310 from outis1one/claude/ionos-script-integration-x32ofw
Add provider-portal checklist with real values to the PSTN health check
2026-08-11 16:45:24 -04:00
Claude bb7aece023 Test Asterisk's own coturn config and print softphone setup info
Two follow-ups on the PSTN health check:

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 20:32:02 +00:00
Outis cb1bb9b11b Merge pull request #308 from outis1one/codex/fix-drum-rhythm-game-audio-issues
drum-rhythm-game: inject runtime fixes for Christmas songs, pad audio, and WebAudio resume
2026-08-11 16:29:59 -04:00
Outis 5a0eb84909 Merge pull request #309 from outis1one/claude/ionos-script-integration-x32ofw
Add provider-portal checklist with real values to the PSTN health check
2026-08-11 16:29:34 -04:00
Outis a28e9a5a1c Fix drum rhythm Christmas audio 2026-08-11 16:29:08 -04:00
Claude efb86fd92d Add provider-portal checklist with real values to the PSTN health check
Server-side config was fully verifiable already; what wasn't is the
provider-account side (Anveo's authorized-IP list, DID routing, SMS
forward-URL) since that lives entirely outside this box. Rather than
leave "go check the portal" as a vague pointer, compute and print the
exact values each portal field needs to match: this box's public IP,
the trunk DID (from .pstn-trunk.env), and the SMS forward URL read
straight from /opt/sms-inbound/settings.env (SMS_FORWARD_URL) instead of
making the user reconstruct or hunt for a value the installer already
generated and stored. Anveo-specific field-by-field checklist when
PROVIDER_NAME matches; generic fallback otherwise.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 13:18:26 +00:00
Outis 3d9bcda994 Merge pull request #299 from outis1one/claude/ionos-script-integration-x32ofw
Claude/ionos script integration x32ofw
2026-08-11 08:58:29 -04:00
Claude c719197b20 Admin-scoping setup: show live extension list, auto-include owned DIDs
Two refinements to the per-admin extension scoping added last commit:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:43:58 +00:00
Outis 6a0edd5d87 Merge pull request #293 from outis1one/claude/ionos-script-integration-x32ofw
Accept plain "remove"/"uninstall" as aliases for --remove
2026-08-10 23:37:53 -04:00
Claude eeaa2e6c64 Accept plain "remove"/"uninstall" as aliases for --remove
./setup.sh filebrowser remove (no dashes) fell through to the normal
install dispatch instead of removing anything, since only the --remove
flag form was recognized. Accept the bare words too — order-independent
either way (./setup.sh remove filebrowser works the same).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:34:57 +00:00
Outis 94b317213c Merge pull request #292 from outis1one/claude/ionos-script-integration-x32ofw
Claude/ionos script integration x32ofw
2026-08-10 23:30:32 -04:00
Claude efcad48755 Add a generic service removal command: ./setup.sh <name> --remove
No removal path existed anywhere in this repo — manually removing a
service meant hand-editing docker-compose.yml, the Caddyfile, and UFW
rules yourself, or just leaving orphaned config behind.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 03:22:53 +00:00