PJSIP's From-header username comes exclusively from the endpoint's
static from_user — it never falls back to the channel's CALLERID(num).
With from_user unset, Asterisk fell back to its own hardcoded default
("asterisk"), which the provider doesn't recognize as an account DID
and rejected outright: every outbound call went straight to busy with
the callee never ringing.
Restore the static from_user (required for the provider to accept the
call at all) and instead relay the per-call CALLERID(num) — personal_did
or the shared DID — via send_pai/send_rpid, which populate the
P-Asserted-Identity/Remote-Party-ID headers from the live channel
Caller-ID independent of from_user. This is the standard mechanism for
presenting a per-call Caller-ID over a trunk with one fixed From user.
The pjsip endpoint pinned a static from_user to the shared trunk DID,
which silently wins over CALLERID(num) for the SIP From header on
outbound calls. The dialplan's per-extension personal_did lookup
still set CALLERID(num) correctly, but every call still went out
showing the same DID regardless. Leaving from_user unset lets PJSIP
fall back to the channel's caller ID number, mirroring the inbound
side's existing no-static-callerid fix.
The behaviour was right but the vocabulary wasn't: the previous commit
replaced full/restricted/internal with a new none/open/out/in/both naming,
when the ask was to extend the existing tier dropdown rather than supplant it.
The dropdown now reads: full, restricted (both ways), restricted incoming,
restricted outgoing, internal. The stored values match — internal,
restricted, full, restricted-in, restricted-out — so the original three keep
their own names in the config file and migrating a legacy install is now
near-identity for them (tier=full becomes restrict=full, tier=restricted
becomes restrict=restricted, which is what that tier already meant).
restricted incoming = dials anywhere, only whitelisted numbers get through.
restricted outgoing = anyone can call in, may only dial the whitelist. Named
in parallel rather than as "full incoming", so the two sit next to each other
in the list without needing the parenthetical to tell them apart.
No behavioural change: the derived tier_out/allowed_out/tier_in/allowed_in
the dialplan reads are unchanged, so the dialplan itself is untouched.
Verified: legacy migration mapping the original tiers to their same-named
modes; both new modes round-tripping through the dashboard and compiling to
the right derived keys (restricted-in sets allowed_in only, restricted-out
sets allowed_out only); whitelist disabled on full and internal; and the
group-ring helper still ringing restricted-incoming only for a whitelisted
caller while restricted-outgoing rings for everyone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
The previous commit gave each extension two independent lists — numbers it
may dial and caller IDs that may reach it. That was more than asked for: the
whitelist is one set of numbers per extension, and what varies is which
direction(s) it constrains.
pstn-permissions.conf now has an authored pair, 'restrict' and
'allowed_numbers', where restrict is one of:
none no PSTN at all open unrestricted both ways
out may only dial the list in may only be called by the list
both the list applies both ways
tier_out/allowed_out/tier_in/allowed_in are now derived from that pair rather
than authored directly, and remain what the dialplan reads — so the dialplan
is unchanged from the previous commit and stays tested. 'tier' still mirrors
tier_out for rollback. Keeping the compiled keys means the file has one place
a human edits and one place Asterisk reads, which is the same authored/
compiled split a named-number-list feature would need later.
The migration handles both prior shapes: a genuinely legacy single-tier file
(full becomes open, restricted becomes both — reproducing what the old
dialplan did), and the short-lived two-list shape from the previous commit
(inferred back to a mode, preferring the more restrictive reading). Still
idempotent, still backs up first.
The dashboard drops from two dropdowns and two fields to one of each, with
the whitelist greyed out for the modes that don't use one, and the PSTN
column sorting by how much reach a mode grants rather than alphabetically.
Verified: migration from both shapes; the dashboard round-trip writing
restrict=in with the list compiled to allowed_in only; and the group-ring
helper across all four modes — Restrict inbound rings only for a whitelisted
caller, Restrict outbound rings for everyone, Restrict both rings only for
its own list, No PSTN never rings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
One tier and one allowed_numbers list governed both directions, with the same
list matched against dialled numbers going out and caller IDs coming in.
Those are different sets, so "dial anyone but only accept calls from a short
list" — and its reverse — were not expressible at all.
pstn-permissions.conf now carries tier_out/allowed_out and tier_in/allowed_in.
The dialplan reads them in the four places that gate a call: the outbound
NANP pattern, the international leg, each ring-group member block, and
personal-DID inbound routing — plus the group-ring shell helper. Internal
extension-to-extension calling and ring groups remain ungated by either, as
before.
Existing installs are migrated in place by _pstn_migrate_permissions_split,
which copies the old single tier into both directions (reproducing the box's
current behaviour exactly), backs the file up first, and is idempotent. It
runs alongside the dialplan write rather than after a reload, since the new
dialplan reading un-migrated keys would fail closed and deny every call.
tier/allowed_numbers keep being written as a mirror of the outbound values so
that rolling back to a pre-split pstn-trunk.sh degrades to the old semantics
instead of breaking.
The dashboard's Extensions table gains Outbound/Can-dial and Inbound/Can-be-
called-by columns, each number field enabled only by its own tier, tier
columns sorting by permissiveness rather than alphabetically, and a save that
posts the whole permission record so an unchanged direction isn't reset.
Verified: the migration on a hand-written config (idempotent on rerun,
messaging and personal_did preserved); generated dialplan and group-ring
helper reading the split keys; and the group-ring helper resolving a real
three-extension case — out=full/in=restricted rings only for a whitelisted
caller, out=restricted/in=full rings for everyone, in=internal never rings —
plus the browser round-trip writing tier_out=full with tier_in=restricted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
The SMS tab has one control — a "Forward to URL" checkbox and field, with
SAVE/RETURN buttons where RETURN discards. The instructions described a
destination dropdown that isn't there. Also flags that the generated URLs are
long (~90 chars relay, ~150+ direct) and worth re-opening the tab to confirm
they saved whole.
Adds a provider-risk section: the realistic way to lose the number is account
action or a lapsed balance rather than the company folding, so keep the
balance small, save a recent invoice offline (porting out needs a signed LOA
plus the latest bill, which you can't download once an account is closed),
and note that Anveo states it does not block port-outs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
The UI is inlined in app.py, so an upgrade changes the HTML behind a URL that
never changes and carried no cache headers. A browser holding the previous
page after an update is indistinguishable from the update having failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
The unified services/asterisk.sh has not been run on real hardware yet, so
keep the previous services/asterisk-digital-ocean.sh available verbatim until
it has. attic/ sits outside setup.sh's services/*.sh glob, so the copy never
self-registers, never appears in the menu, and only runs if invoked directly
(it kept its own standalone bootstrap: sudo bash attic/asterisk-digital-ocean.sh).
Both scripts agree on where an existing droplet install lives
(~/docker/asterisk-digital-ocean, easy-asterisk-do containers), so moving
between them doesn't relocate anything.
attic/README.md is explicit that this is a way to get the old installer back,
not an undo button — what actually protects an existing install is --dry-run,
choosing update/cancel over fresh at the reinstall prompt, and a snapshot —
and that the copy should be deleted once the merge is confirmed, since two
copies of the same logic is the problem the merge removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
New service for one narrow job — getting SMS verification codes sent to a
VoIP number onto a phone with no SIM. Deliberately not a texting app: no
outbound path (Anveo Direct has none; that needs an Anveo Retail account, and
a free texting app covers sending), and messages arrive as push notifications
rather than being routed into Asterisk as SIP MESSAGE, since a code you read
and type is better served by a notification than a softphone chat thread.
Two modes, both driven entirely from the provider's "forward SMS to URL" box:
- direct — the provider calls ntfy itself; nothing installed here. ntfy
accepts GET publishing at /{topic}/(publish|send|trigger) with message and
title as query params, and auth via ?auth= holding base64url (unpadded) of
the literal "Bearer <token>" — confirmed against ntfy's server.go and
server_auth.go rather than its docs.
- relay — a stdlib systemd service, Caddy-fronted on its own domain with no
Authelia (the provider can't log in; a random 32-char token in the path is
the secret). Buys two things direct mode can't have: an unescaped "&" in a
message body survives intact, because the relay takes everything after the
last message= verbatim instead of parse_qs — which is why the generated URL
always puts the message placeholder last — and no ntfy credentials sit in a
third party's web portal.
Verification codes are bearer credentials, so: a 24-char random topic name
(the repo's ntfy defaults to auth-default-access: read-write, making the topic
name the read credential), constant-time token compare, a 60/min rate limit,
and the relay logs sender/recipient/length but never the message body.
The Anveo guide gains a section covering the two things that actually decide
whether codes arrive: short-code support (Anveo has it, unusually — VoIP.ms
does not except for Google) and Anveo's carrier-sourced *mobile* DIDs, which
are classified as mobile in the lookups that reject VoIP numbers at signup.
Also documents MMS and group texts being out of reach, and why the native
Messages app never sees any of this.
Verified against a stub ntfy: plain OTP, encoded "&", unencoded "&", "+" as
space, wrong token (404), missing message (400) and the rate limit (57x204
then 429) all behave; both installer modes were run end to end in a sandbox
and their generated URLs, settings files and READMEs checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
The tab consolidation fixed what the page showed but not how it read. With
two extensions and nothing else configured it rendered ~1900px tall: six
cards all expanded, three paragraphs of prose before the first control, eight
Save buttons with no indication of which rows had been touched, browser
prompt() dialogs for renames, and a nine-column table with no scroll
container.
Interaction:
- Name and Category are edited in place and feed one batched save. Edited
rows get a highlight and a left rail; a sticky bar reports the count and
Save changes commits only those rows, routing each field to the endpoint it
needs (rename/category through the container, tier/numbers/messaging
through the permissions file — or the messaging-only endpoint with no
trunk). Discard reverts; leaving with edits pending warns. Delete keeps its
own per-row control since it is destructive.
- Categories, Rooms, Groups, Caps and Personal numbers became <details>
sections with item counts, so the tab opens on the extensions table.
Explanations moved behind "what this means" disclosures.
- Add-extension is behind a button; the one-time device password gets a
persistent dismissible callout rather than a line that scrolls away.
Everything else reports through toasts, replacing six inline message divs.
Presentation: a token-based stylesheet (spacing/radius/colour scale), sticky
translucent header, sticky table headers, status as a colour-coded pill,
focus-visible outlines, primary/secondary/danger button hierarchy, and a
.table-wrap that owns horizontal overflow. Security Log and CrowdSec cards
picked up the same card-body padding and scroll wrappers.
Verified in Chromium against a fixture Asterisk config at 1280px and 390px:
full layout opens at ~700px tall with five collapsed sections, editing two
rows marks both and shows "2 extensions edited", saving persists and clears
the dirty state, per-extension failures are reported individually rather than
swallowed, the bare no-container/no-trunk layout still collapses to
Ext/Name/Messaging with two cards and saves via the messaging-only endpoint,
and at 390px the page does not scroll horizontally while the table does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
Three tabs listed the same extensions three different ways: Asterisk Admin
as devices with category/status/transport, Extensions as a row of messaging
checkboxes, PSTN Trunk as permission tiers with a duplicate Messaging column
that wrote the same flag. Changing one extension meant knowing which of the
three owned the setting you wanted.
There is now one Extensions tab with one extensions table, merged from
pjsip.conf (via /api/pstn-permissions, which always works) and /api/ea-devices
where the Easy Asterisk container is reachable, keyed by extension so a row
known to only one source still shows. Capabilities add columns rather than
nav buttons: Category/Status/Transport are .ea-only, Tier/Approved-numbers
are .pstn-only, and both classes start on <body> so nothing flashes before
/api/ea-status and /api/pstn-status answer. Categories, Rooms, Groups,
Concurrent-call caps and Personal numbers are cards under the same tab, gated
the same way.
Per-row Save picks its write path: tier + approved numbers + messaging via
/api/pstn-permissions with a trunk installed, messaging alone via
/api/pstn-messaging without one — which is what that endpoint has always
been for. No backend changes; the standalone messaging-chips card and the
duplicate Messaging column are both gone.
Verified in Chromium against a fixture Asterisk config: full layout renders
nine columns and six cards, the bare layout collapses to Ext/Name/Messaging
with two cards, and both save paths write pstn-permissions.conf correctly
(messaging-only leaves tier and allowed_numbers untouched).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
services/asterisk-digital-ocean.sh was a near-verbatim copy of
services/asterisk.sh — same vendor refresh, compose template, messaging
dialplan, presence alerts, UFW rules and dashboard/trunk chaining, with the
helper functions renamed _asterisk_do_*. Two copies meant every fix had to
land twice, and several never did.
There is now one `asterisk` service. It reads the DigitalOcean metadata
service and asks either way (so a droplet with metadata blocked, or another
provider's public VM, can still opt in), then gates the genuinely
droplet-specific behaviour on that one answer: swapfile for low-RAM plans,
public-FQDN-only setup with no LAN/VLAN prompts, a Caddy site block pinned
to that FQDN, the remote-Authelia option, and the doctl Cloud Firewall.
Two things that were droplet-only for no real reason now apply everywhere:
the entrypoint patch that writes security-level events to logs/full, and
the logrotate config for that file. Without them the Security Dashboard's
Security Log tab and CrowdSec's Asterisk acquisition were silently empty on
every home/LAN install; crowdsec.sh now detects either install directory.
Existing droplets are left alone: an install at ~/docker/asterisk-digital-ocean
keeps its directory and easy-asterisk-do container names, since its Caddyfile
block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN trunk all
name those exactly. New installs use ~/docker/asterisk / easy-asterisk.
`sudo ./setup.sh asterisk-digital-ocean` still works via a new SERVICE_ALIAS
map in setup.sh, without a second menu entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
The step 4 heading said "once per DID" and the intro said steps 3-7 repeat
per DID, both of which overstate the work. The body already noted the trunk
could be reused, but the heading is what gets read first - and it actively
misleads toward creating one trunk object per number.
One inbound SIP Trunk serves the whole account: the $[E164]$ placeholder is
substituted with each DID's own number at call time, and the Asterisk side
matches whatever arrives via _X. and resolves it through
pstn-personal-dids.conf. Same for the outbound Service Trunk in step 3,
which the body already flagged as skippable for DIDs 2+.
Rewrites the heading, adds an explicit up-front note on why one suffices,
and corrects the intro to name which steps actually repeat per number.
A group-owned personal DID is now working end to end against a real
inbound call: it rings every authorized group member simultaneously, first
to answer wins, and members whose own tier/allowed_numbers don't authorize
the caller are correctly skipped.
Updates the code comments that were written before that confirmation
existed (the Busy(15) block's "NOT yet confirmed live", and the group-ring
script's unverified-SHELL() note), and documents all four fixes plus the
"Commit Changes" caveat in the Anveo setup guide's bugs-fixed section so
the next person hitting one-ring-then-busy has the trail.
Also clears the guide's "Still open" section: the 2-number account cap no
longer applies, and the interactive walkthrough it described as unbuilt
was built (_pstn_anveo_walkthrough).
Confirmed live this session: writes to pstn-permissions.conf,
pstn-groups.conf, and pstn-personal-dids.conf land on disk immediately, but
AST_CONFIG() in the dialplan sometimes kept returning a stale value (e.g.
a personal DID's old owner after reassigning it to a group) until the
Asterisk container was fully restarted - not just dialplan/module reload.
This contradicts the "live, no restart needed" premise the rest of this
tab's copy relies on, so surfacing it explicitly beats letting admins
discover it by trial and error.
Adds a "Commit Changes (Restart Asterisk)" button and banner to the PSTN
Trunk tab, shown after any save on that tab (permissions, limits, groups,
personal DIDs) and warning via beforeunload if left uncommitted. Backend
restart_asterisk_container() runs `docker restart` on the Easy Asterisk
container through the same scoped-sudoers/run_sudo mechanism the native
Easy Asterisk Admin tab already uses for its own docker exec calls, so this
needed one new sudoers line, not a new privilege model.
Live trace confirmed: once trust_id_inbound=yes started surfacing real
caller identity (previous commit), Anveo delivers it "+E.164" style (e.g.
"+15551234567") instead of the bare digits the old callerid-fallback path
produced. PSTN_CALLERID_NORM's existing "add a leading 1 if length is 10"
check never fires for a 12-character "+"-prefixed value and never strips
the "+" either, so it can never match an 11-digit, digits-only
allowed_numbers entry no matter how correctly the number is whitelisted -
a correctly-configured restricted-tier extension or group member would
still always get busy.
Fixed in three places: the inbound dialplan's PSTN_CALLERID_NORM
computation (new PSTN_CID_RAW step strips a leading "+" first), the
group-ring shell script's own defensive re-normalization, and the
dashboard's admin-input-side normalizers (_normalize_nanp_number,
_normalize_personal_did_input) so pasting a number straight out of a
phone's call log (which naturally includes the "+") works too instead of
being silently dropped.
Live trace confirmed every inbound call's CALLERID(num) came back identical
to the DID that was dialed, regardless of who actually called - meaning
every restricted-tier allowed_numbers check was structurally impossible to
satisfy, since the "caller ID" Asterisk saw was never the real one.
The [pstn-trunk] endpoint had a static `callerid=<DID>` default (redundant
for outbound - the dialplan already sets CALLERID(num) on the calling
channel before Dial(), which is what actually gets presented) with no
trust_id_inbound setting. Removed the static default and added
trust_id_inbound=yes so real caller identity the provider sends
(P-Asserted-Identity/Remote-Party-ID, in addition to the From header) is
honored for inbound calls instead of falling back to the DID.
Root cause found via live debugging: pstn-trunk-dialplan.conf mixed two
contexts in one file - outbound patterns continuing [intercom], then a
[from-pstn-trunk] header appearing partway through the same file. On the
reporting box this caused the ENTIRE file to silently fail to load: `dialplan
show intercom` showed zero of the outbound NANP patterns, and `dialplan show
from-pstn-trunk` reported the context didn't exist, with no warning anywhere
(config log, full log, or the reload command's own output).
messaging-dialplan.conf, #include'd the same way right after [intercom],
loaded fine - the working structural difference is that its first real line
IS its own context header, never trying to continue [intercom] first.
Split pstn-trunk-dialplan.conf into that same working shape:
- pstn-trunk-dialplan.conf keeps only the outbound content (continues
[intercom], unchanged from before).
- pstn-trunk-inbound-dialplan.conf is new, and starts with [from-pstn-trunk]
as its first non-comment line - nothing before it.
Both _pstn_patch_vendor_files (generator patch) and _pstn_ensure_live_includes
(direct live-file patch) now add a second #include line for the new file.
_pstn_ensure_live_includes also now verifies after reload that
'from-pstn-trunk' actually has a matching extension, and warns loudly if not,
instead of unconditionally reporting success regardless of whether the
reload actually worked.
pstn_in_denied, the personal-DID owner denial, and pstn_personal_denied_group
fell straight to a bare Hangup() on an unanswered channel instead of
Busy(15) like every other denial path in this file (outbound tier/number/
intl/killswitch). A bare Hangup() doesn't map to "486 Busy Here" the way
Busy() does, so a denied inbound call (wrong tier, number not on the
approved list, group membership mismatch) could sound like a generic
call-failed/fast-busy rather than a clean busy signal - easy to mistake
for a broken permission check when the real issue is a config mismatch.
Also added a NoOp() logging the resolved group name, caller ID, and
PSTN_RING_LIST right after the group-ring SHELL() call, so the next test
of a group-assigned personal number shows directly (via the Asterisk
console/full log) whether the ring list came back empty - and if so, which
of group membership, tier, or allowed_numbers to check next - instead of
requiring another guess-and-recheck cycle.
Full audit of every phone-number comparison/storage point across
pstn-trunk.sh, security-dashboard.sh, asterisk.sh, asterisk-digital-ocean.sh,
and the vendored easy-asterisk base script, prompted by the inbound
Caller-ID normalization fix — the same digit-count mismatch was also
possible on the admin-input side, just silent instead of loud:
- security-dashboard.sh's write_permission(): a 10-digit whitelist entry
was silently DROPPED (NUMBER_RE required exactly 11 digits), with no
warning unless every entry in the field was invalid — a mixed
10-digit + 11-digit list saved "successfully" while quietly losing the
10-digit one. Now normalizes any bare 10-digit token to 11-digit instead
of discarding it (_normalize_nanp_number).
- write_personal_did(): required exactly 10 digits, rejecting an
11-digit entry outright with a clear (but avoidable) error. Now accepts
either and normalizes to the canonical 10-digit storage form
(_normalize_personal_did_input).
- pstn-trunk.sh's TRUNK_DID install prompt: same fix, strips a leading
"1" instead of aborting the install over it.
Everything else checked out clean: outbound dialed-number matching
already normalizes via the existing _NXXNXXXXXX pattern (adds "1" before
any tier check), the area-code/country-code international gates aren't
phone numbers so digit-count doesn't apply, and asterisk.sh/
asterisk-digital-ocean.sh/the vendored base script have no phone-number
comparison logic at all — this class of bug only lives in the PSTN
permission/whitelist layer this project added on top.
allowed_numbers is always stored 11-digit (dashboard's NUMBER_RE requires
exactly 11 digits), but nothing normalized the inbound CALLERID(num) to
match. Confirmed live: Anveo delivered a bare 10-digit caller ID
(5557654321) for a call to a group-owned personal DID whose members'
allowed_numbers were correctly stored as 15557654321 — every restricted-
tier REGEX comparison failed on a plain digit-count mismatch, not because
the number was actually unauthorized. Outbound already guards against the
mirror-image case (_NXXNXXXXXX prepends "1" before any tier check); this
adds the equivalent for inbound via a new PSTN_CALLERID_NORM dialplan
variable, used everywhere CALLERID(num) previously fed a restricted-tier
comparison: the shared ring-group's per-member block, the single-owner
personal DID check, and the group-owned personal DID's ring script
invocation (which also gets a defensive normalize of its own, since it's
useful to invoke by hand for testing).
Verified by regenerating the actual group-ring script and running it
against the exact config dump and caller ID from the live failing call —
now correctly resolves all three group members instead of an empty list.
install_pstn-trunk's "update" branch unconditionally restarted the
asterisk container, then immediately called _pstn_ensure_live_includes,
which already patches the live pjsip.conf/extensions.conf #include lines
and reloads res_pjsip/dialplan — the restart accomplished nothing the
reload doesn't, while dropping any calls already in progress. Confirmed
live as a real double-restart when this runs chained from
asterisk-digital-ocean.sh, which had just rebuilt/restarted the same
container moments earlier.
Fresh installs keep their existing (still restart-first) apply step
unchanged — only "update in place" loses the redundant restart.
_ini_get() in _pstn_write_personal_group_ring_script split on '=' and
compared $1 == key without trimming whitespace, but configparser.write()
(used to write pstn-groups.conf and pstn-permissions.conf) pads '=' with
spaces by default. Every lookup — including the group's own members= line —
silently returned empty, so group-owned personal DID ring-groups never
actually rang anyone regardless of member tier/whitelist config. Trim
whitespace around both the extracted key and value before comparing.
Also switch the script's final echo to printf to remove any ambiguity in
the dialplan's SHELL()-empty-string check.
Confirmed live on a real droplet: secdash could read pjsip.conf fine but
/api/pstn-status kept returning false even though pstn-trunk-dialplan.conf
genuinely existed. Root cause: the Asterisk container's own entrypoint runs
`chown -R asterisk:asterisk /etc/asterisk` on every container start, and
the numeric UID/GID it resolves to inside the container coincidentally
collided with unrelated host system accounts (config/asterisk ended up
owned by messagebus:uuidd on this box) - silently reverting whatever group
membership _secdash_grant_asterisk_access had granted secdash at install
time. This wasn't a one-time misconfiguration; it would have silently
broken again on every future container restart.
Switched the grant mechanism from chmod + usermod group membership to
POSIX ACLs (setfacl) - chown doesn't touch ACL entries (only chmod
recalculates the ACL mask, and nothing in this flow chmods after install),
so the grant survives the container's own maintenance chown. Default ACLs
(-d) also make newly-created files (a regenerated dialplan, a fresh
personal-DID entry) inherit the same access automatically.
Also added _secdash_grant_ancestor_traversal, which grants execute-only ACL
traversal up the directory tree - needed on any box where DOCKER_DIR sits
under a restrictive parent (e.g. /root on some cloud images defaults to
700, blocking a non-root secdash from ever reaching deeper directories no
matter what those directories themselves grant). Falls back to the old
chmod/group approach with a warning if the 'acl' package is somehow
unavailable (it's installed automatically otherwise).
Verified with a real non-root system test user against a fixture
reproducing the exact failure (a leaf directory with no "other" access,
owned by an unrelated user/group): confirmed blocked before the fix,
confirmed read+write access after, and confirmed access survives a
simulated `chown -R` (the container restart scenario) plus correct
inheritance onto a freshly-created file afterward - all without re-running
the grant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
asterisk.sh and asterisk-digital-ocean.sh now offer, at the end of both
their fresh-install and update-mode paths, to also set up the Security
Dashboard and configure a real PSTN trunk in the same run - one script
walks through the whole stack instead of needing to separately remember
and run `sudo ./setup.sh security-dashboard` / `sudo ./setup.sh pstn-trunk`
afterward.
Both target services keep their own register_service call and stay fully
independently invocable - this is purely an additive convenience layer
(_asterisk_offer_dashboard_and_trunk / _asterisk_do_offer_dashboard_and_trunk),
not a replacement. An already-installed piece is silently refreshed
(install_security-dashboard/install_pstn-trunk each already have their own
update/fresh/cancel reinstall-mode gate, so calling them again just does
the right thing); a not-yet-installed piece gets one y/n instead of every
detailed prompt firing.
Guarded with declare -F so a standalone `sudo bash asterisk.sh` copy (no
sibling services/*.sh files sourced) skips both cleanly with an
explanatory message instead of erroring on an undefined function.
Verified: full sourcing simulation resolves all four install_* functions
correctly, `setup.sh --dry-run --unattended asterisk` and
`asterisk-digital-ocean` both complete cleanly end-to-end, and
`setup.sh --list` still shows all four services as independently
selectable.
Documented the pattern in CLAUDE.md under a new "Chaining into another
service from within your own" section for future contributors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Supersedes the previous commit's reverse-proxy approach entirely: instead
of Caddy routing to Easy Asterisk's own separate vendored web admin
process, the dashboard now reimplements that admin's functionality
natively - one process, one page, real tab-switching, no separate app to
proxy, patch, or embed. Reverts asterisk.sh/asterisk-digital-ocean.sh's
WEBADMIN_BASE_PATH vendor patching and Caddy-skip logic back to their
pre-proxy state (confirmed identical via diff) since neither is needed
anymore.
security-dashboard.sh additions:
- ea_* functions covering full device/category/room parity with
vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's own web admin: list/add/
delete/rename/change-category for devices, list/create/delete/rename for
categories, list/create/delete/rename/add-member/remove-member for
rooms, plus live registered/unregistered status. Reads go straight
through the host-side bind-mounted config files (same as the existing
list_extensions() already does for pjsip.conf); writes go through
`docker exec -i <container> tee <path>` instead of a direct host-side
write, since Easy Asterisk's container writes these files as its own
internal user and a host-side write would just be fighting that
ownership again on the next container restart.
- Found and fixed a real bug (inherited from the vendored admin's own
template, not introduced here): a plain non-mobile LAN device leaves
both the keepalive and ice template lines empty, producing two
consecutive blank lines inside the endpoint's pjsip.conf stanza instead
of one - which broke the delete/rename/category-change parsers' "blank
line ends this device's block" boundary detection, leaving an orphaned
tail of config behind on delete. Fixed by building the endpoint block
from a filtered line list instead of positional template blanks.
Confirmed via a full synthetic add/rename/category-change/delete cycle
against realistic pjsip.conf/categories.conf/rooms.conf fixtures (with
docker exec mocked to a local file) - round-trips back to the original
fixture correctly.
- New plumbing: _secdash_grant_asterisk_access grants read-only access to
categories.conf/rooms.conf's directory (separate from pjsip.conf's,
confirmed against the vendored source - /etc/easy-asterisk/*, not
/etc/asterisk/*); _secdash_write_sudoers adds six exact (no wildcards)
docker-exec sudoers entries scoped to the one Asterisk container
actually installed, validated live with visudo -c; _secdash_write_systemd_unit
passes the new ASTERISK_EA_CONFIG_DIR/ASTERISK_EA_CONTAINER env vars and
adds the config dir to ReadOnlyPaths, validated live with
systemd-analyze verify.
- New UI: Asterisk Admin tab with Devices/Categories/Rooms cards, sortable
tables matching the existing style, inline category-reassignment
dropdowns, and per-room member chips with an inline add-member picker.
Nav button visibility now checks live container reachability
(/api/ea-status) instead of just Asterisk-install detection.
Still unverified: the actual `docker exec` calls (module reload, dialplan
rebuild, live status) against a real running Easy Asterisk container -
only the file-parsing/transformation logic itself has been exercised, via
mocked writes, not the real container plumbing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
No more cross-origin iframe: the Security Dashboard now reverse-proxies
the real Asterisk web admin natively at /asterisk-admin/ on its own
domain via Caddy's handle_path, instead of embedding a separate site in
a frame. One domain, one login wall, for both.
- services/asterisk.sh / services/asterisk-digital-ocean.sh: patch the
vendored web admin's one hardcoded absolute API path
(`const API_BASE = '/api'`, confirmed via the real vendored source to
be the only absolute-path reference anywhere in its HTML/JS - no other
hrefs, no login-page redirect, plain HTTP Basic Auth instead) so it
resolves correctly when mounted under a sub-path, via a new
WEBADMIN_BASE_PATH env var threaded through entrypoint.sh. Verified
against the real vendored file: patched output is
'/asterisk-admin/api' with the env var set, unchanged '/api' without
it. Skip each service's own dedicated admin Caddy domain when the
Security Dashboard is already installed, since it fronts the admin
instead.
- services/security-dashboard.sh: _secdash_configure_caddy now accepts
the admin's port and Asterisk's own directory/domain, path-routes
/asterisk-admin/* alongside the dashboard's own handle{} block, and
writes WEB_ADMIN_BASE_PATH into Asterisk's .env + restarts that
container once proxying is confirmed live. Defaults the dashboard's
own domain prompt to the droplet's DOMAIN_NAME when detected, since
Caddy's SIP-TLS cert sync already depends on serving that exact
domain. Removed the old CSP frame-ancestors patching and the iframe
itself; the nav is now a plain link, shown only once the proxy is
confirmed wired up.
- Fixed _secdash_remove_caddy_block's marker match to tolerate the
dashboard's reverse_proxy line now living one indent level deeper
(inside its own handle{} block) - verified against a synthetic
Caddyfile that it still finds and removes exactly the right block.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Restructure the nav so it reflects what's actually installed on this box,
letting one dashboard URL cover everything from a bare LAN Asterisk box up
to a full droplet with a trunk and CrowdSec:
- Security Log and a new Extensions tab (Groups + Internal SIP messaging,
split out of the old "PSTN Trunk" tab) are always available - they only
need Asterisk itself, not a trunk or CrowdSec.
- Asterisk Admin, PSTN Trunk, and CrowdSec each check their own live
install state on every page load and hide their own nav button entirely
when not present, instead of showing an empty/placeholder tab.
- Add crowdsec_installed() (checks for /usr/bin/cscli) and a
/api/crowdsec-status endpoint, mirroring the existing pstn_installed()/
/api/pstn-status pattern.
This fixes the earlier design where messaging/groups management lived
inside the PSTN Trunk tab even though both work with plain Asterisk and no
trunk at all - hiding that tab would have taken them down with it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
- Security Log tab: replace the per-column text filter row with clickable
sortable column headers (same pattern as the CrowdSec bans table).
- PSTN Trunk tab: add sortable headers to the permissions, personal-DID
(numeric on DID), and groups tables.
- Move the "Internal SIP messaging" card to the bottom of the PSTN tab and
replace its per-row table+Save-button layout with a checkbox chip row
and a single Save-changes button.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
The dashboard's Personal Numbers card now accepts a group (stored as
"@GroupName", unambiguous against a same-named numeric extension) as well
as a plain extension. A group-owned DID rings every CURRENT member whose
own tier/approved-numbers authorize the caller, computed fresh on every
call by a generated pstn-personal-group-ring.sh (invoked via the
dialplan's SHELL() function) rather than unrolled at install time, since
group membership can change any time via the dashboard with no reinstall
- unlike the shared ring-group, which is fixed at install/update time.
Applies the identical per-member permission check the shared ring-group
already bakes into the dialplan, just computed in a plain shell loop
against the same two config files - group ownership doesn't bypass the
tier/approved-numbers model. Tested standalone against mock config data
(full/restricted/internal mix, matching/non-matching caller, empty and
nonexistent groups) - all four cases behaved correctly. The dialplan's own
SHELL() invocation is still unverified against a real call.
Group ownership never touches pstn-permissions.conf's personal_did
(outbound Caller-ID override) field, since there's no single extension to
hang that on for a group.
Replaces the static wall of warnings with a real 5-step guided walkthrough
(account/funding, DID ordering, both trunk objects, confirmed rate),
pausing for confirmation between each step and showing this box's actual
public IP inline for direct copy-paste into Anveo's Authorized IP
Addresses and SIP Trunk forwarding fields. Built directly from
docs/anveo-direct-setup-guide.md now that the whole flow is confirmed
working end-to-end, so a fresh run shouldn't need the trial-and-error
session that guide was written from.
Documents the actual end-to-end sequence that got a real DID working
(account setup, DID ordering, both trunk objects, droplet configuration,
permission setup, testing), including the real confirmed values (the
$0.00388/min rate, the port-5060 SIP URI forwarding format, the two
separate trunk-object types) and a summary of every real bug hit and
fixed along the way. Linked from the existing planning-notes doc.
Confirmed live: PSTN_PERSONAL_OWNER came back empty despite a real
assignment existing, because the Security Dashboard stores personal DIDs
as 10 digits (PERSONAL_DID_RE = ^\d{10}$) while Anveo's inbound INVITE
delivers the called number as 11-digit E.164 (with leading 1) in ${EXTEN}
- confirmed via PSTN_DID_CALLED=15557776655 in the live test. AST_CONFIG()
looking up an 11-digit key never found the 10-digit section.
Normalizes to 10 digits before the lookup (strips a leading digit only
when the string is actually 11 characters, so this doesn't misfire against
a provider that already sends 10). Also fixed the dashboard's own DID
input placeholder, which contradicted its own 10-digit validation error.
Confirmed live: a ring-group list with a repeated extension ("201 301 313
312 313") generated duplicate ring<ext>/skip<ext> priority labels in the
same [from-pstn-trunk] extension. Asterisk doesn't error on this - it
silently resolves Goto() to the wrong occurrence and loops between the two
blocks forever, so the call never reaches the actual Dial() and nothing
ever rings, with no error to point at the cause. Dedupes (first-occurrence
order preserved) regardless of how the duplicate got in there.
Confirmed live: Dial() actually placed the call to "PJSIP/pstn_check_busy@
pstn-trunk" instead of the dialed number. Root cause is systemic, not one
line — Goto(label,1) to a *different named extension* (as opposed to a
same-extension priority label, which _pstn_ring_member_block already knew
to avoid) resets ${EXTEN} to that label's own name. Every helper extension
downstream of a Goto (pstn_check_allow_out, pstn_check_busy,
pstn_intl_check_country, pstn_intl_country_denied, pstn_personal_inbound)
was reading ${EXTEN} expecting the originally-dialed number and getting
its own extension name instead.
This wasn't just cosmetic: pstn_check_allow_out's REGEX approved-number
check compared against the label name too, meaning restricted-tier
extensions could never successfully place a call regardless of whether
the number was actually approved.
Fixed by capturing the real dialed number into PSTN_DIALED (outbound) /
PSTN_DID_CALLED (inbound) at each entry point, before any Goto, and using
that instead of ${EXTEN} in every downstream extension.
Confirmed live: "Unknown or unavailable item requested: 'peername'" left
PSTN_CALLER empty, which made AST_CONFIG() error ("requires a category")
and PSTN_TIER come back empty too — every call fell through to "no PSTN
permission" and got busy-signaled regardless of the extension's actual
tier. Replaced with CUT(CHANNEL,/,2) then CUT(...,-,1), extracting the
endpoint name from the channel name itself (e.g. "PJSIP/201-00000006" ->
"201") — a plain string operation with no dependency on which channel
function items this particular Asterisk build supports.
_1NXXNXXXXX / _NXXNXXXXX were one digit short (10/9 characters instead of
the correct 11/10 for a full NANP number: 1 + area code(3) + exchange(3) +
subscriber(4)). Asterisk's exact-length pattern matching never matched a
real dialed number as a result — confirmed live via "extension not found
in context 'intercom'" on every outbound test call, traced by dumping
`dialplan show intercom` and counting characters against the actual
pattern. This was the real root cause underneath everything else fixed
along the way (stale settings file, the #include not reaching the live
config) — those were real bugs too, but this one was still there
underneath all of them.
setup.sh runs with set -uo pipefail, and source-ing a settings file with
any stray unexpanded/unset variable reference (confirmed live on an older
deployed copy) was fatal to the entire script, not just that one prompt.
Wraps the source in set +u/-u and falls back to sane defaults for anything
missing, but still hard-errors with a clear message if TRUNK_SERVER/
TRUNK_DID themselves are missing, rather than silently writing a broken
trunk config.
Ports the internal SIP MESSAGE dialplan enforcement, extension presence
(online/offline) ntfy alerts, and the live-config-include patching from
asterisk-digital-ocean.sh, for feature parity between the two Asterisk
flavors. The pstn-trunk.sh live-include fix already applied to both, since
that's shared code parametrized by which flavor is installed.
Removed the full/restricted-extension, approved-numbers pool/whiptail,
messaging-extensions, and personal-DID-assignment prompts from the
installer. All four are already live-editable, no-restart-needed settings
the Security Dashboard's PSTN Trunk tab manages end to end — the CLI wall
of prompts (that then needed a full reinstall to change) just duplicated
that with more friction. Every extension now starts at internal tier (no
PSTN, no messaging) until granted via the dashboard. Kept: provider/DID
setup, concurrency caps, and the inbound ring-group (basic trunk wiring,
not a permission). Also updated the generated README and CLI summary to
match, and refreshed the stale "known gap" messaging section that predated
the dialplan enforcement built earlier this session.
Confirmed live via a real failed test call ("extension not found in context
'intercom'"): Easy Asterisk's entrypoint only regenerates pjsip.conf/
extensions.conf if they don't already exist. Patching the vendor generator
functions (as both pstn-trunk.sh and the earlier SIP MESSAGE work did) only
takes effect on a future full regeneration — a box that already has devices
configured, the normal case, never triggers one on a plain restart, so the
#include lines never actually reached the live files despite the generator
patch succeeding.
Both installers now also patch the live pjsip.conf/extensions.conf directly
(same anchors, idempotent) and force a reload, so the trunk/messaging
dialplan actually takes effect immediately regardless of whether Easy
Asterisk ever regenerates its config on its own.
0.00388/min for standard US-to-US domestic, confirmed against Anveo's own
Prime rate card CSV (the route set the outbound trunk's LCR config actually
pulls from) rather than the earlier ~0.001 ballpark guess. Defaults to it
automatically when the Anveo Direct quick-pick was chosen.
Restricted extensions previously required retyping the full 11-digit
number list from scratch at each extension's own prompt. Now offers
entering a pool once, then a whiptail checklist per extension to pick a
subset (falls back to typing numbers directly, or 'all' for the whole
pool, when whiptail isn't available or the run is unattended).
User feedback: reading the tier list (internal/restricted/full) right before
"Full-PSTN extensions:" read as ambiguous about whether to type the tier
word or extension numbers. Reworded both prompts to say "extension NUMBERS"
explicitly and added inline examples (e.g. '999 213').
Confirmed live: pressing Enter on any "(y/n): y"-style prompt set the
variable to an empty string instead of the stated default, since prompt_yn
had no ${response:-$default} fallback (prompt_text already had one). Every
downstream [[ "$VAR" =~ ^[Yy]$ ]] check treated "just press Enter" as no.
Also shows the default value in the prompt text itself for both helpers,
since neither displayed it before.
The dialing-prefix ambiguity is resolved: Anveo's own trunk edit screen has
no required-field marker on Dialing Prefix, confirming it's optional. Also
documents the separate Outbound Service Trunk vs. inbound SIP Trunk
distinction discovered while walking through a real account setup.
Confirmed against a live install's pjsip.conf/extensions.conf that every
endpoint falls back to context=intercom for messaging (message_context
blank), and that [intercom] owns one exact-match per-device dial pattern
regenerated on every dialplan rebuild. Rather than risk racing that, every
endpoint now gets message_context=sip-messaging (patched into both of Easy
Asterisk's device-creation code paths, plus a one-time migration for
existing devices), routing messages to their own [sip-messaging] context
gated on the existing pstn-permissions.conf messaging flag.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
- ntfy.sh: auth-default-access was deny-all, silently blocking both publish
and anonymous subscribe on every topic once an auth-file is present.
Default to read-write (same model as public ntfy.sh) so alerts from
crowdsec.sh/pstn-trunk.sh actually get delivered.
- crowdsec.sh: add update/fresh/cancel reinstall gating — reruns previously
re-asked every optional prompt (ASN exempt, geo-allowlist, ntfy, remote
LAPI) unconditionally with no way to just refresh in place.
- asterisk-digital-ocean.sh: add optional ntfy alerts on extension
registration going offline/online, checked every 2 minutes via systemd
timer (cron.d fallback), offered on both fresh install and update.
- security-dashboard.sh: replace the outbound-only Asterisk Web Admin link
with an embedded, lazy-loaded iframe tab, with a best-effort Caddy
frame-ancestors patch and an always-available "open in new tab" fallback.
Groups: a new always-available "Groups" card in the PSTN tab (same place
as Internal SIP messaging, no dependency on a PSTN trunk being installed)
lets you name a set of extensions and bulk-enable/disable messaging for
all of them at once. Purely a management-layer convenience - pstn-groups.conf
is never read by the dialplan, which only ever looks at per-extension keys
in pstn-permissions.conf. Applying a group action just calls the same
write_messaging() each individual checkbox uses, once per current member.
Editing membership never retroactively changes anything already applied,
and deleting a group never touches members' own settings - confirmed with
tests covering create/apply/edit-membership/re-apply/delete.
Security Log: added a per-column filter row (Time/Event/Account/Remote/
Severity), live as you type, filtering client-side against the
already-fetched events rather than re-querying - persists correctly across
the existing 30-second auto-refresh.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Messaging has no dependency on a PSTN trunk existing (no cost, no
carrier, no DID), but the whole PSTN Trunk tab - including the messaging
checkbox added last commit - was hidden behind pstn_installed(), which
only becomes true once services/pstn-trunk.sh's dialplan is actually
wired in. That meant enabling messaging required going through full SIP
trunk/provider setup first for no real reason.
Added a standalone "Internal SIP messaging" card that's always visible in
the tab regardless of trunk status, backed by a new write_messaging()
that only touches the messaging key (leaving tier/allowed_numbers/
personal_did untouched) and creates pstn-permissions.conf from scratch if
it doesn't exist yet. Confirmed the systemd unit's ReadWritePaths and the
group/chmod grants already covered this - both are set up whenever a base
Asterisk install is detected, independent of pstn-trunk - so no
permission-layer changes were needed, only the dashboard's own artificial
UI gate.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
get_all_permissions()/write_permission() now handle messaging alongside
tier/allowed_numbers as one save action, and the Security Dashboard's PSTN
Trunk table gets a Messaging checkbox column - no more needing to re-run
pstn-trunk.sh's CLI just to change who can use internal SIP texting,
matching how tier/allowed_numbers/personal_did already worked.
Tested that messaging correctly survives tier changes and personal-DID
assignment/removal on the same extension (independent axes, as intended).
Still explicitly not done, and said so in both READMEs rather than
implying otherwise now that there's a nice UI for it: the actual SIP
MESSAGE dialplan wiring that would make Asterisk enforce this flag. That
gap hasn't changed - only the permission storage/UI layer around it has.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Prompts now start with a 1/2/3 choice (Anveo Direct / VoIP.ms / manual)
that pre-fills already-known values - Anveo Direct's sbc.anveo.com
hostname and its 4 published signaling IPs, so they don't need retyping
from memory - plus provider-specific account-setup reminders. Every
value stays editable at each subsequent prompt; this only changes
defaults, not behavior, so existing manual-entry users see no change.
Flagged rather than silently trusted: Anveo Direct's own Outbound Trunks
page documents dialing as [PREFIX]PHONENUMBER@sbc.anveo.com, which
contradicts an earlier no-prefix-needed finding from their FAQ. The
dialplan still dials the bare number (matching the older finding) - the
quick-pick prints a loud warning to verify the Prefix field can be left
blank before relying on this, rather than guessing and hardcoding prefix
handling without live confirmation either way.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Multiple DIDs can now share one trunk/account, each assigned to a specific
extension - additive to the existing shared trunk DID/ring-group, which
keeps working unchanged for everyone regardless of what's assigned here.
- New pstn-personal-dids.conf (DID -> owner extension), read live by the
dialplan for inbound routing: a call to a personal DID routes straight
to its owner, checked against the owner's own tier/approved-numbers, no
ring-group fallback since it's that extension's own line.
- New personal_did= field per extension in pstn-permissions.conf: the
outbound Caller-ID override, used by pstn_check_busy (the shared exit
point for both domestic and international dialing) instead of the
shared trunk DID when the calling extension has one assigned.
- Both files kept in sync automatically by one write path - CLI prompt at
install/update, or a new "Personal numbers" card in the Security
Dashboard's PSTN Trunk tab - rather than requiring hand-editing both
consistently. Reassigning a DID or giving an extension a new one cleanly
drops the stale side of the old mapping.
- Assigning a DID to an internal-tier extension is accepted but warned
about (won't ring anyone until also granted full/restricted tier),
matching this repo's permissive-with-warnings style rather than
blocking the action outright.
Also fixed a real bug found while building this: security-dashboard.sh's
write_permission() did cp.remove_section(ext) whenever tier was set to
"internal", silently discarding any messaging=yes or personal_did=
already set on that extension. Now removes only the tier/allowed_numbers
keys, dropping the section only once nothing else remains in it.
Separately, de-anchored pstn-trunk.sh's install prompts from VoIP.ms as
the implicit default (provider name default, server-hostname example
wording, rate-prompt wording) now that Anveo Direct is an equally
confirmed, tested provider option.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
parse_security_log() did f.readlines() on ASTERISK_LOG before slicing the
last 5000 lines - that reads the ENTIRE file into memory first. That log
is Asterisk's unrotated console/security output, and this tab polls it
every 30 seconds from the browser. Confirmed live: on a 1GB-RAM droplet
with a 1.4GB log file, this ballooned the dashboard (explicitly meant to
be a lightweight stdlib-only process) to 677MB RSS / 1.8GB peak swap,
which left CrowdSec unable to even start (boot timeout) and directly
contributed to the droplet becoming unresponsive.
Fixed by reading only a bounded ~2MB tail from the end of the file
(seek + fixed-size read) instead of the whole thing - memory use is now
constant regardless of how large the log grows. Tested against a 180MB
synthetic log: memory delta dropped from being proportional to file size
to ~7MB, in 0.05s.
Also added log rotation (services/asterisk-digital-ocean.sh) for that same
file, which had no rotation at all and reached 1.4GB in about 3 days -
copytruncate avoids needing to signal the containerized Asterisk process
to reopen its log handle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
"cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum"
was failing silently (stderr suppressed, "|| true" swallowed the non-zero
exit) because these scenarios are members of the crowdsecurity/asterisk
collection, and cscli refuses to remove/disable a collection member
without --force. The un-exempted hub originals kept running side-by-side
with the ASN-exempt local forks the entire time, independently banning the
same traffic with no ASN awareness at all - confirmed live by a ban
showing scenario "crowdsecurity/asterisk_bf" (the hub original) rather
than "local/asterisk_bf" (the fork) for an ASN that had just been exempted.
Fixed in two places:
- crowdsec.sh's initial setup now passes --force and surfaces a clear
warning (with the manual fix command) if it still fails, instead of
silently continuing.
- security-dashboard's set-asn-exempt.sh helper now re-asserts the forced
disable on every ASN-list save, so an install already affected by this
(from before this fix, or where the step failed for any other reason)
self-heals the next time the ASN list is edited via the dashboard,
without needing to re-run the full CrowdSec installer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
set_asn_exempt() was writing /etc/crowdsec/scenarios/local-asterisk_*.yaml
directly from the dashboard's own process, but those files are root:root
mode 644 and the secdash service user (--shell /usr/sbin/nologin) has no
write grant to them - confirmed by the reported "[Errno 13] Permission
denied" error. Every whitelist/unban attempt through the dashboard was
silently a no-op as far as CrowdSec was concerned, which is why a
carrier's IP kept getting re-banned even after repeatedly trying to
exempt its ASN.
Fixed by routing the edit through a dedicated root-owned helper script
(set-asn-exempt.sh, installed alongside app.py, root:root mode 700) that's
the one thing added to the sudoers whitelist - same pattern every other
CrowdSec-touching action here already uses (cscli via sudo), rather than
loosening the scenario files' own permissions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
- CLI country menu now asks for a per-minute rate when a country is added,
stored alongside its code/name in pstn-intl-allowed.conf. The usage-alert
script buckets each month's outbound calls into domestic vs. per-country
international (longest-code-prefix match on the dialed digits) and prices
each bucket at its own rate instead of one blended flat rate, which badly
under/over-estimated international cost.
- While the kill-switch is tripped, the periodic script now also force-hangs
up any PSTN call already in progress (docker exec + Asterisk's "channel
request hangup" against the trunk's active channels), not just new calls
- closing the gap where an in-progress call just kept running until it
ended naturally. Runs every check while tripped, not only the run that
trips it, to close a start-of-call race.
- Fixed a bug caught by testing: the hangup sweep was nested inside the
"does the call log exist" guard, so it silently never fired if the log
was missing/rotated while tripped. Moved it outside that guard.
- Documented an honest "how bulletproof is this?" breakdown in the
generated README: the provider's own real-time $0-balance block is the
actual hard ceiling (confirmed for Anveo Direct), the kill-switch here is
an estimate-based second layer, not a replacement for funding the prepaid
account at your actual risk tolerance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Support reply (MFonk, 7/22/2026) confirms both open questions: all calls
(incoming and outgoing) are blocked in real time at $0 balance, not just
via the recurring-fee grace-period mechanism, and the 4 signaling IPs
already hardcoded into this doc's research notes are still current.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
- Replace the hourly cron.d spend/volume check with a per-minute systemd
timer (cron.d fallback), since it's now also the kill-switch's
enforcement point and the interval is the exposure window.
- Add a hard monthly spend-cap kill-switch: trips pstn-trunk-killswitch.conf
(read live by the dialplan, both call directions, internal calling
unaffected) once estimated spend reaches an admin-set cap, plus a loud
ntfy warning at 80% of the cap before it trips. Does not auto-reset;
clearing it is a CLI-only action (update mode) so a compromised/careless
web session can't quietly re-enable spend after a trip.
- Add a CLI-only (never web-dashboard) international-calling allow-list:
continent/country menu, always asked every run with the resulting
allow-list printed exactly once right after, optional auto-expiry with
day-of and at-expiry ntfy notices, and active re-blocking once expired.
Dialplan gates the US "011" prefix pattern on it for full-tier extensions.
- Add an independent messaging_enabled flag per extension in
pstn-permissions.conf for Asterisk's native SIP MESSAGE texting, since
the risk profile (no cost) differs from PSTN calling permissions. The
dialplan wiring to enforce it is flagged as a known gap pending live
verification against Easy Asterisk's message routing, rather than
shipping a guessed pattern.
- security-dashboard.sh: detect whether pstn-trunk is actually installed
(not just base Asterisk) and show a clear "not installed" state instead
of misleading default caps/extensions; add clickable column-header
sorting to the CrowdSec active-bans table.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Anveo Direct's "Call Security" rate-cap feature (found while evaluating
their account portal) prompted a closer look at our own NANP dialplan
pattern, which turned out to have a real gap: NANP covers Caribbean/
Atlantic nations and several US territories, not just the US - these dial
exactly like a normal 10-digit US number but bill at international/
premium rates, a well-known toll-fraud/"one-ring scam" vector precisely
because the format looks domestic. The existing _1NXXNXXXXX/_NXXNXXXXX
patterns matched all of them equally, meaning any extension (including
"full" tier) could reach premium Caribbean/territory numbers despite the
whole point of the design being "US only, no premium-rate destinations."
Verified the area code list against multiple sources rather than relying
on training-data recall alone, then added an explicit block on all 27
(Bahamas, Barbados, Anguilla, Antigua & Barbuda, British Virgin Islands,
US Virgin Islands, Cayman Islands, Bermuda, Grenada, Turks & Caicos,
Jamaica, Montserrat, Northern Mariana Islands, Guam, American Samoa, Sint
Maarten, Saint Lucia, Dominica, Saint Vincent, Puerto Rico, Dominican
Republic, Trinidad & Tobago, Saint Kitts & Nevis) - checked via REGEX()
against the extracted area code, before permission-tier checks, so it
applies regardless of tier. Tested the extraction/matching logic against
both blocked and legitimate US area codes.
Updated docs/pstn-calling-voipms-plan.md's toll-fraud reasoning, which had
previously (incorrectly) claimed NANP-only restriction alone fully closed
the cost-per-minute risk - noted as a real, live gap rather than glossing
over it. Also noted Anveo Direct's provider-side Call Security setting
(rate cap + max call duration) as a complementary backstop worth
configuring on their account portal directly, independent of this repo's
code.
VoIP.ms's one-POP-one-hostname model meant a single resolved IP was enough
for the trunk's identify match=, but Anveo Direct (researched as an
alternative after finding VoIP.ms's ToS selectively requires government ID
at signup) sends inbound signaling from a fixed set of published IPs
instead. Generalizes the pjsip include to accept multiple match= lines in
one identify object (PJSIP allows repeating it to build one match set - no
separate config objects needed per IP), with a new install prompt for any
additional known source IPs beyond the auto-resolved one.
No dial-prefix support was added despite earlier research suggesting Anveo
Direct needed one - verified against their current official FAQ (fetched
directly rather than trusting a stale search-result claim) that plain
number dialing works with no prefix.
Fixed a real bug caught while testing the update-mode round trip:
.pstn-trunk.env was written with unquoted values, which breaks `source`
entirely once any value contains a space - a multi-IP list or a
multi-word provider name (e.g. "Anveo Direct") would make bash try to run
the second word as a command. Predates this change (a multi-extension
ring group has the same shape); only surfaced by actually re-sourcing the
file and reapplying, not by inspecting generator output. Fixed by quoting
every value.
Updated docs/pstn-calling-voipms-plan.md with the Anveo Direct/DIDLogic/
SIP.US research findings and what's now implemented vs. still open
(real-time balance enforcement at Anveo specifically is unconfirmed).
The user found ToS language stating the account "may run on a negative
balance" and describing a 30-day-notice-then-suspend / 30-consecutive-
days-then-close process - worth reading against the wiki's "balance must
be over $0 to call" claim the whole toll-fraud design leans on.
Reconciled: these describe two different things, not a contradiction.
New call attempts should still be blocked in real time at $0 (the wiki's
claim, and the core assumption this design needs). The ToS's negative-
balance language most plausibly covers recurring fees (DID/E911) landing
when balance is already near zero, and in-progress-call settlement edge
cases - not a window where fraud keeps dialing while negative. Still not
verified against a live account either way.
Adds an inbound concurrent-call cap mirroring the existing outbound one -
outbound alone didn't protect against an inbound call-flood, which also
costs money per-minute on VoIP.ms. Both defaults bumped from 3 to 10.
Moves the cap numbers themselves out of static dialplan text and into a new
pstn-limits.conf, read live via AST_CONFIG() the same way permission tiers
already are - changing either cap takes effect on the next call, no
Asterisk restart, no reinstall. "update in place" never touches this file,
matching the existing protection for pstn-permissions.conf/.env/firewall/
Caddy config.
Adds a concurrency-caps card to security-dashboard.sh's "PSTN Trunk" tab,
above the existing permissions table, so both caps are visible and editable
from the same web page. Tested against a real running instance of the
Python app: default fallback when the file doesn't exist yet, save/persist,
invalid-input rejection, and a bash-to-Python round trip on the generated
file format.
Inbound dialplan ordering mirrors outbound's existing pattern: permission
check (is any ring-group member authorized for this caller) before the
concurrency check, consistent with outbound's tier-check-then-busy-check
order.
Reworks the outbound permission model from a flat allow-list into three
per-extension tiers (internal / restricted / full), addressing the ask for
extensions that can only reach pre-approved numbers plus extensions with
full US calling, while internal extension-to-extension dialing and ring
groups stay ungated for everyone regardless of tier.
Permissions now live in pstn-permissions.conf, read by the dialplan via
Asterisk's AST_CONFIG() on every call instead of being baked into static
dialplan text - editing that file takes effect on the next call, no
Asterisk restart and no re-running the installer. "update in place" mode
never touches this file (same protection this repo's update-mode
convention already gives .env/firewall/Caddy config); only a "fresh"
reinstall (with confirmation) or the web UI change it.
Adds a "PSTN Trunk" tab to services/security-dashboard.sh: lists every
extension (parsed from pjsip.conf) with its live tier and approved numbers,
editable with no restart - this is what makes the tier model actually
manageable day to day. Extracted the dashboard's systemd-unit writing into
its own function so "update" mode refreshes it too (previously only fresh
installs did), and generalized both the dashboard and the trunk service to
detect either asterisk-digital-ocean or the home/LAN asterisk install.
Inbound ring-group membership now checks each member's tier live per call
via an unrolled per-member dialplan block (full always rings, restricted
only if the caller's number is approved, internal never rings) rather than
a single static Dial() string.
Caught and fixed two real bugs during testing against a sandboxed vendor
copy and a live instance of the (stdlib-only) Python dashboard app:
- Asterisk Goto/GotoIf argument parsing: ring<ext>/skip<ext> are named
priorities within the same extension (declared via "same => n(label),..."),
not separate exten => entries, so jumping to them needs the single-argument
Goto(label) form - the two-argument Goto(label,1) form used initially
addresses a different, nonexistent extension named "label" instead.
- A security-relevant REGEX() direction issue: the inbound Caller-ID check
initially interpolated attacker-influenced call data into the PATTERN side
of a REGEX() match rather than the tested-string side, which would let a
crafted Caller-ID forge a match against an unrelated approved-numbers
entry. Fixed by keeping the admin-controlled approved-list as the pattern
and the live call data as the string being tested, consistently on both
the outbound and inbound checks.
Verified end-to-end: dialplan/pjsip generation and vendor-file patching
(idempotent, syntax-checked) as before, plus the new permission-file
round-trip between bash and Python, and the dashboard's new API endpoints
exercised against a real running Python server (extension parsing, tier
changes, number normalization, invalid-input rejection, atomic file writes).
Generalizes services/pstn-trunk.sh (renamed from voipms-trunk.sh in the
prior commit) away from VoIP.ms specifics - any IP-authenticated SIP
provider works, VoIP.ms is just the suggested default. Adds:
- Role-based outbound permission: a configurable allow-list of extensions
that may dial PSTN numbers (regex-gated on CHANNEL(peername)), separate
from internal extension-to-extension dialing which stays open to everyone
regardless. Blank list preserves the original "everyone can dial out"
behavior.
- Inbound ring-group: rings a configurable list of extensions instead of a
single hardcoded one.
- ntfy alerts: immediate on denied (unauthorized extension) or rejected
(concurrency cap hit) calls, plus an hourly cron-driven check that alerts
once per month when estimated spend crosses a threshold and every hour
call volume looks like a burst. Uses a self-contained pipe-delimited call
log rather than Asterisk's CDR, to avoid depending on CDR module
availability and CSV comma-quoting.
- Settings persisted to .pstn-trunk.env so "update in place" reapplies
everything from that file instead of fragile re-parsing out of generated
Asterisk config (which had a real bug: update mode was extracting the
wrong Dial(PJSIP/...) line).
Tested end-to-end against a sandboxed copy of the real vendor files:
permission-gate regex, ring-group dial-string construction, ntfy line
injection/removal, and the usage-alert script's threshold/burst/monthly-
dedup logic all verified with synthetic data. Caught and fixed a sed `&`
escaping bug in the ring-group substitution before it shipped (RING_DIAL
contains literal `&` join characters, which sed's replacement syntax
otherwise treats as "insert the match").
Renames services/voipms-trunk.sh to services/pstn-trunk.sh and generalizes
it away from VoIP.ms specifics - any IP-authenticated SIP provider works,
VoIP.ms is just the suggested default. Adds:
- Role-based outbound permission: a configurable allow-list of extensions
that may dial PSTN numbers (regex-gated on CHANNEL(peername)), separate
from internal extension-to-extension dialing which stays open to everyone
regardless. Blank list preserves the original "everyone can dial out"
behavior.
- Inbound ring-group: rings a configurable list of extensions instead of a
single hardcoded one.
- ntfy alerts: immediate on denied (unauthorized extension) or rejected
(concurrency cap hit) calls, plus an hourly cron-driven check that alerts
once per month when estimated spend crosses a threshold and every hour
call volume looks like a burst. Uses a self-contained pipe-delimited call
log rather than Asterisk's CDR, to avoid depending on CDR module
availability and CSV comma-quoting.
- Settings persisted to .pstn-trunk.env so "update in place" reapplies
everything from that file instead of fragile re-parsing out of generated
Asterisk config (which had a real bug: update mode was extracting the
wrong Dial(PJSIP/...) line).
Tested end-to-end against a sandboxed copy of the real vendor files:
permission-gate regex, ring-group dial-string construction, ntfy line
injection/removal, and the usage-alert script's threshold/burst/monthly-
dedup logic all verified with synthetic data. Caught and fixed a sed `&`
escaping bug in the ring-group substitution before it shipped (RING_DIAL
contains literal `&` join characters, which sed's replacement syntax
otherwise treats as "insert the match").
Adds a VoIP.ms SIP trunk on top of asterisk-digital-ocean: IP-authenticated
trunk (no password stored), NANP-only outbound dialplan, a global 3-call
concurrent cap via GROUP()/GROUP_COUNT(), and inbound routing to one
extension. Config lives in its own include files rather than being
appended directly to pjsip.conf/extensions.conf, since Easy Asterisk fully
regenerates both from its own internal state — the includes are patched
into the vendor's generator functions so they survive that regeneration.
Wires the new service into setup.sh's is_installed() and README's services
table, and updates docs/pstn-calling-voipms-plan.md to reflect what's now
implemented vs. still open (spend/volume alerting, live-account
verification).
Inbound (DID) is now decided as wanted, not outbound-only. Adds a cost
estimate table for 100 min/month each direction, and clarifies that
NANP-only restriction bounds cost-per-minute but not burn speed, so the
concurrent-call cap and spend alert are required before funding a live
trunk, not optional hardening.
Captures the toll-fraud / prepaid-cap research and decisions from a design
discussion (provider: VoIP.ms, US-only calling for now) so implementation
can be picked up in a future session without re-deriving the background.
Nothing implemented yet.
- CrowdSec tab: per-ASN "Unwhitelist" (drop from the exempt filter, future
traffic evaluated normally) and "Unwhitelist + Ban" (also immediately
bans every IP CrowdSec has on record for that ASN, for accidental-
whitelist cases) buttons. set_asn_exempt now allows clearing the list
down to zero ASNs, needed to unwhitelist the last remaining entry.
- New sudoers permission (cscli decisions add --ip * --duration * --type
ban --reason *) scoped narrowly, list-form subprocess args only.
- Caddy/Authelia config factored into _secdash_configure_caddy() and a new
_secdash_remove_caddy_block() (whole-block delete-and-regenerate, not
in-place patching) so "update" mode can now offer to reconfigure it.
- Installer offers an independent HTTP Basic Auth layer in front of
Authelia (Caddy basicauth, generated via `caddy hash-password`) so a
future Authelia bug/misconfig alone isn't enough to expose a page that
can delete active security bans.
Currently-exempt ASNs with no active ban (e.g. T-Mobile once its bans
stop firing) had no carrier name to show, since the name lookup only
looked at cscli decisions list (active bans only). Add a
cscli alerts list-based fallback (includes expired/resolved alerts)
and merge it into the name lookup used by /api/asn-exempt.
Confirmed the real cscli decisions list -o json structure live rather
than guessing again: AS number/name and country live on each alert's
"source" object (source.as_number, source.as_name, source.cn), not on
the individual decision. Surfaces this as a "Network / Carrier" and
"Country" column in the bans table, adds a per-row "Exempt ASN" button
that appends straight to the Asterisk brute-force ASN exemption list,
and labels the exempt list's own entries with carrier names (pulled
from current ban data where available) instead of showing bare numbers.
Confirmed live: Caddy (in a container) reaches this via
host.docker.internal, a Docker bridge gateway IP, not localhost — a
loopback-only bind refuses that connection outright ("dial tcp
172.17.0.1:8092: connect: connection refused"), even though curl from
the host itself worked fine on 127.0.0.1. Bind to 0.0.0.0 and rely on
UFW for the actual access scoping instead, matching every other
host-network service in this repo (e.g. the Asterisk web admin, which
already binds this way successfully with the same
ufw_allow_from_caddy_net pattern).
Confirmed live: setup.sh's run_service() calls install_${name} with
no hyphen-to-underscore conversion, so a hyphenated service name needs
a literally-hyphenated function name (install_security-dashboard, not
install_security_dashboard) to be found at all — got this wrong on
first pass by following CLAUDE.md's own (incorrect) guidance, which
said to convert hyphens to underscores. Every other hyphenated service
in the repo (asterisk-digital-ocean, wolf-pair, mail-archiver,
drum-rhythm-game) already keeps hyphens literal; corrected CLAUDE.md
to match actual practice instead of the other way around.
New service, native on the host (not Docker) so it can call cscli and
read Asterisk's security log directly without bridging the
container/host boundary or exposing CrowdSec LAPI credentials to a
containerized frontend.
- Security Log tab: parses ~/docker/asterisk-digital-ocean/logs/full
for SIP auth failures (wrong password, unknown extension, etc.) with
timestamp/account/remote IP, classified by severity.
- CrowdSec tab: current bans via cscli, a delete/unban button per
entry, and ASN-exempt management for the Asterisk brute-force
scenarios (services/crowdsec.sh) without SSHing in.
- Link out to the existing Asterisk web admin (reads its domain from
asterisk-digital-ocean's own .env, doesn't hardcode or embed it).
Runs as a dedicated unprivileged system user (secdash), with sudo
scoped to exactly three commands via /etc/sudoers.d/security-dashboard
(cscli decisions delete --id <digits>, cscli decisions list -o json,
systemctl restart crowdsec) — validated with visudo -c. Listens on
127.0.0.1 only, reachable through Caddy, and refuses to proceed without
explicit confirmation if no Authelia (local or remote) is configured,
since this page can delete active security bans.
Stdlib-only Python (no framework), matching the RAM-conscious pattern
already used for Easy Asterisk's own web admin. All embedded code
(bash, Python, JS) syntax-checked; the generated sudoers rule
validated with visudo -c -f.
Confirmed live: unquoted integer literals in the ASN exclusion filter
(evt.Enriched.ASNNumber in [21928, 14593]) made CrowdSec fatal-crash-loop
at startup with "cannot use string as type int in array" — ASNNumber is
a string field internally despite printing as a bare number in cscli
output, same as IsoCode in the geo-allowlist scenario. Quote each ASN
as a string to match, exactly like the working geo-allowlist pattern.
Confirmed live: a phone roaming WiFi<->mobile on a CGNAT carrier
(Starlink, T-Mobile home internet) got banned by crowdsecurity/asterisk_bf,
either from its own re-registration burst or collaterally from another
customer sharing the same rotating public IP. Forks asterisk_bf and
asterisk_user_enum locally with an ASN exclusion added to their filter,
disabling the hub originals so events aren't double-processed. Scoped
narrowly to Asterisk auth-failure detection only — SSH, web scanning,
and the geo-allowlist scenario are all unaffected, so this doesn't
broadly exempt the carrier from every protection on the box.
Confirmed live: a legitimate SIP device on a CGNAT ISP (Starlink,
T-Mobile home internet) got collaterally banned by
crowdsecurity/asterisk_bf alongside actual bad actors sharing the same
carrier IP. The alert now includes the exact commands (with the banned
IP substituted in) instead of just naming the ban, so recovering from
this doesn't require remembering or looking up cscli syntax.
New "Add another protected domain to this instance" option on re-run,
via add_authelia_domain(): appends a session.cookies entry and an
access_control.rules entry (both YAML lists Authelia natively supports)
plus a Caddy auth.<domain> portal block for the new domain, all on the
same Authelia + Redis container instead of standing up a second full
stack. Each domain gets its own login/session, sharing one user
database — the right fit when a single (possibly upsized) droplet ends
up fronting more than one domain, without doubling the RAM cost of a
second Authelia+Redis instance. Documents both this and the
already-working separate-instance path in CLAUDE.md, with the
per-approach tradeoffs.
Confirms services/authelia.sh's standalone pattern and
asterisk-digital-ocean.sh's local-vs-remote auto-detection already
support a second, fully independent instance on another machine with
no code changes needed. Documents the one real constraint: two
instances must not share the same AUTHELIA_DOMAIN, since the session
cookie scope and the auth.<domain> portal hostname would collide.
Misread the previous request as "add Russia to the allowed list" —
it meant the opposite: Russia should stay excluded, same as the other
high-risk/Eastern Europe entries already left out.
Excludes Bulgaria, Czechia, Hungary, Moldova, Poland, Romania, Slovakia,
and Ukraine per user request, while keeping the Balkans and Baltics
(several of which, e.g. Estonia, don't fit the same risk profile despite
the old Cold-War grouping). Russia added back to the allowed list per
explicit user request.
Opt-in prompt that bans any Caddy-fronted web request from outside an
editable North America + Europe country list, via a local CrowdSec
trigger scenario scoped to the existing "type: caddy" acquisition label.
Uses CrowdSec's bundled GeoLite2 enrichment data (already active with no
extra setup) rather than the firewall bouncer's separate MaxMind-key
country-CIDR feature, so no account signup is needed. SSH is untouched
so a bad edit can't lock out the session running the installer.
The generated auth.<domain> block's bare "reverse_proxy authelia:9091"
let Caddy recompute X-Forwarded-Host from its own incoming request
(always auth.<domain> itself) on every hop through it, overwriting
whatever a forward_auth caller elsewhere had already set for its own
domain. Confirmed live: a remote site's forward_auth check always
evaluated as if it were for the Authelia portal itself (bypass policy),
so 2FA silently never triggered for any domain going through it.
The previous fix used the {host} Caddy placeholder for X-Forwarded-Host,
but confirmed live it still evaluated to the upstream Authelia's own
hostname rather than the original site's — Caddy appears to rewrite the
outgoing request's Host to the upstream target before header_up
placeholders resolve for a scheme-qualified remote upstream, so {host}
echoed back the already-rewritten value. Since this site block only ever
serves one domain, hardcode it instead of depending on placeholder timing.
The remote-Authelia forward_auth block dialed a scheme-qualified upstream
(https://auth.example.com), which is a second Caddy hop. Caddy rewrites the
outgoing Host header to the upstream host for routing, and without an
explicit override X-Forwarded-Host picked up that rewritten value instead
of the original site's host. Authelia was evaluating every protected
domain as auth.example.com itself (bypass policy), so 2FA never triggered
for any domain behind the remote instance. Pin the forwarded headers to
the original request explicitly to fix it.
Both Caddy site block generators (the shared configure_caddy_for_service
helper, and asterisk-digital-ocean.sh's own inline template) wrote
reverse_proxy before the forward_auth/import authelia block. Caddy
doesn't reorder repeats of the same directive within a block — forward_auth
and reverse_proxy are the same directive family internally, so they run in
the order written. With reverse_proxy first, it handled and terminated
every request immediately; the auth check written after it never ran at
all. Full bypass on every domain using either generator with Authelia
protection, regardless of how correct the Authelia access_control rules
themselves were — confirmed live against a config that was otherwise
completely correct (default_policy: deny, explicit wildcard rule covering
the affected domain).
Affects every service that's ever passed `import authelia` or a
forward_auth block through configure_caddy_for_service (asterisk.sh,
wolf-pair.sh, and any future caller), plus asterisk-digital-ocean.sh's
own site block.
Moved the auth block before reverse_proxy in both generators.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Live-confirmed the retry fix from the previous commit wasn't enough:
still "Address already in use" after 20s of retries (10 attempts,
2s backoff), only to succeed on its own sometime after that. That
delay pattern is TIME_WAIT, not a process-death race — and this code
was never going to avoid it, because socketserver.TCPServer defaults
allow_reuse_address to False. (http.server.HTTPServer sets this for
you; the plain base class used here does not.) Without SO_REUSEADDR,
the kernel can refuse to rebind a port with a lingering TIME_WAIT
socket from the previous instance for up to 60s, regardless of
whether that old process is even still alive — which is also why the
entrypoint.sh fix waiting for the process to exit didn't help either.
Set socketserver.TCPServer.allow_reuse_address = True before binding.
This is the standard fix for exactly this symptom. Keeping the retry
loop from the previous commit too, for the (now much smaller) window
where network_mode: host still has no Docker-managed port mapping to
instantly free.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Complements the retry fix on the bind side: pkill only sends SIGTERM
and returns immediately, it doesn't wait for the process to exit and
release its socket. Under network_mode: host there's no Docker-
managed port mapping to tear down, so the next container's bind
attempt was racing however long this process actually took to die —
sometimes still holding the port when the next container started.
Poll for it to actually exit (up to 2s), falling back to SIGKILL if
it's still lingering, before proceeding with the rest of shutdown.
With a clean handoff here, the web admin's own bind-retry (previous
commit) should rarely even need to kick in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Live-confirmed: OSError: [Errno 98] Address already in use on the
web admin's TCPServer bind, right after a container recreate under
network_mode: host. Unlike bridge-mode port publishing, there's no
Docker-managed mapping to instantly free on teardown — the previous
container's own web admin process has to actually die first, and a
fast recreate-right-after-recreate can race that. The process crashed
immediately instead of retrying, so the web admin silently never came
up despite entrypoint.sh correctly launching it.
Retry the bind up to 10 times with a 2s backoff before giving up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
"pjsip reload" was never a valid Asterisk CLI command — confirmed
live: `asterisk -rx "pjsip reload"` returns "No such command 'pjsip
reload'". The real command is "module reload res_pjsip.so".
Every reload-after-change call in the script used the invalid form,
both in the interactive CLI (add/edit/delete device, transport setup,
TLS cert sync) and in every web admin mutation (add_device,
delete_device, rename_device, change_device_category) — all silently
no-op'd, since `asterisk -rx` just prints its own "no such command"
error to a discarded/redirected output and returns normally either
way. Endpoints only ever picked up new pjsip.conf entries after a
full container restart (which re-reads config from scratch at
startup) or a manual `module reload res_pjsip.so` — never from the
web admin's own reload call, live-confirmed: a device stayed
Unavailable with zero registration attempts logged until a manual
reload picked it up immediately.
Global replace across all 11 occurrences.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
wolf-pair has no login of its own — Authelia via Caddy is the only
protection option offered for it — but UFW opened its port to the
whole internet unconditionally, before the Caddy/Authelia prompt even
ran. Same gap just fixed for the Asterisk web admin: reachable
straight over the bare port regardless of Authelia.
Reordered so the Caddy decision happens first, and scope the port to
caddy_net's subnet via ufw_allow_from_caddy_net() instead of leaving
it open to 0.0.0.0/0 when Caddy fronts it locally. Also enables UFW
via ensure_ufw_enabled() like the Asterisk services.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Confirmed live: a bare `ufw delete allow <port>` closes it on every
interface, including the caddy_net bridge — Caddy's own request to
host.docker.internal:PORT is ordinary INPUT-chain traffic as far as
UFW is concerned, not something that bypasses it just because the
source is a local container. Closing the port outright silently took
Caddy's reverse-proxy path down with it.
Added ufw_allow_from_caddy_net() to scope the port to caddy_net's own
subnet instead of leaving it fully closed — reachable from Caddy,
still closed to the public internet. Wired into both
asterisk-digital-ocean.sh and asterisk.sh in place of the plain
delete.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Both defaulted to whatever sorted/listed first (kiosks category,
LAN/VPN UDP transport) — reasonable for a fixed intercom install, but
the common case here is adding a phone over the internet. Default the
category select to "mobile" specifically (not just first-in-list, so
it survives category reordering) and make FQDN/Internet (TLS) the
default transport option instead of LAN/VPN (UDP).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Mirrors the fixes just made in asterisk-digital-ocean.sh:
- Reordered so the Caddy reverse-proxy decision happens before the
UFW rules are built, using the new CADDY_SERVICE_CONFIGURED/
CADDY_SERVICE_MODE signal from configure_caddy_for_service() to
skip opening the web admin port on the LAN when a local Caddy is
already fronting it (still opens it for a remote Caddy machine,
which needs LAN access to reach this host directly).
- Calls the new ensure_ufw_enabled() so UFW actually enforces the
rules this script adds, instead of leaving them queued but inert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
configure_caddy_for_service() previously gave callers no way to know
whether Caddy actually ended up fronting the service, or whether that
was local (reachable only over host.docker.internal) vs remote
(needs network access to this host). Services that also open a host
firewall port for the same thing had no way to correctly skip that
when Caddy is the only intended way in. Now sets
CADDY_SERVICE_CONFIGURED/CADDY_SERVICE_MODE out-params after each
exit point.
Added ensure_ufw_enabled(): flips UFW from inactive to active (no
service in this repo has ever done this — ufw allow rules just sat
unenforced). Always allows SSH first, reading the real port from
sshd_config in case it's non-default, so this can't lock out the
session running the installer. No-ops if UFW is already active.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
UFW and the DO Cloud Firewall both opened the web admin port to
0.0.0.0/0 unconditionally, even when Caddy+Authelia was configured to
protect it on the actual domain. Caddy reaches the container over the
host's internal network (host.docker.internal), not the public
internet, so that direct port was pure attack surface: anyone could
hit http://<droplet-ip>:<port>/clients directly, fully bypassing
Authelia and the built-in web admin auth (which gets disabled
whenever Authelia is handling it instead).
Reordered the install flow so the Caddy reverse-proxy decision is
made before the firewall rules are built, and only open the web
admin port publicly when there's no local Caddy actually fronting
it (no domain, Caddy not installed, proxy declined, or a remote
Caddy machine that needs to reach it over the public IP instead).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
The web admin script (/usr/local/bin/easy-asterisk-webadmin) is
generated on demand by the interactive CLI, but only ever lived in
the container's writable layer — not baked into the image, not
bind-mounted. Every docker compose down/up wiped it, and the
entrypoint's start logic only ran "if the file already exists", so
it silently never started again until someone manually ran the CLI's
Web Admin menu once per recreate.
Added a --write-web-admin-script non-interactive entry point
(same pattern as --rebuild-dialplan) and call it unconditionally
before the existence check, so the web admin comes back on its own
every time the container starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi