Two concrete asks: "a QR code generator for sipnetic... displayed on the
security website" and a real download for the baresip kiosk client
instead of just pointing at docs.
Sipnetic QR: ea_device_sipnetic_string() builds Sipnetic's own documented
account-string format (n=/u=/d=/p=/dt=, semicolon-separated, from
https://www.sipnetic.com/qr-codes) from the exact same data the existing
device-details panel already reads back out of pjsip.conf -- unlike
ea_device_provisioning()'s deliberately generic plain-text file (written
because no vendor XML format could be verified), this one has real
documentation to build against. Rendered client-side with
davidshimjs/qrcodejs (MIT, wraps Kazuhiko Arase's original QRCode for
JavaScript) embedded verbatim with its license header intact -- no CDN
call, no new Python dependency, same self-contained approach as the rest
of this page. New "Sipnetic QR code" button on each extension's detail
panel; verified the embedded library actually renders (not just parses)
via a real jsdom run producing real QR cell output.
Kiosk installer download: _secdash_copy_kiosk_installer copies
vendor/easy-asterisk/easy-asterisk-v0.10.0.sh alongside the deployed app
at install time (both update and fresh-install paths) and a new
/download/kiosk-client-installer.sh route serves it -- linked directly
from the Ring Groups card's help text instead of only being reachable by
manually finding the file in the repo. Copied at install time rather
than read live, since a standalone run of this one file (no full repo
clone -- explicitly supported here) has no vendor/ directory to read
from; missing source degrades to a clean 404 on that one link, not an
install failure.
Verified: bash -n, py_compile on the extracted embedded app.py, node
--check on the extracted embedded JS (including the newly-embedded QR
library), and the account-string output checked directly against
Sipnetic's own documented example format.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
Rename, member add/remove, and DID assignment could all already be
changed after a room existed -- type and timeout could only be set once,
at creation, with no way to flip an existing Ring group to Page (or back)
without deleting and recreating it, losing its members/DID assignment in
the process. Reported directly: "I can't edit the ring group to change
it to a page group."
Turns the Timeout/Type columns into inline-editable controls (matching
the same select/input the creation form already uses) with a Save button
per row, backed by a new ea_update_room_settings() that rewrites just
those two fields in rooms.conf, leaving name/members/DID untouched --
same read-modify-write pattern ea_rename_room() already uses.
Verified: bash -n, py_compile on the extracted embedded app.py, node
--check on the extracted embedded JS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
The user asked for the security webpage to actually explain how to set
these up, not just this conversation. Adds a "How this works" disclosure
to the Ring Groups card (matching the existing help-block pattern already
used on Extensions and Personal numbers) covering:
- Ring vs Page's actual difference (first-answer-wins hunt group vs
Asterisk signaling auto-answer via SIP headers).
- Mixing an auto-answering device with normally-ringing phones needs no
new group type or per-member setting -- auto-answer lives in the
device's own SIP client config, so a plain Ring group already dials
everyone at once and an auto-answer-configured device just picks up
faster than a human can.
New docs/kiosk-paging-setup.md walks through the concrete path for a
dedicated always-on auto-answer device: Easy Asterisk's own baresip-based
"kiosk" client, installed on a separate small Linux machine (not the
Asterisk server's Docker container -- the vendor script explicitly skips
baresip install when it detects Docker locally). Also notes plainly that
a browser/WebRTC auto-answer option doesn't exist on this server today
(no WSS transport configured) and would be new infrastructure work, not
a quick addition.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
The panel kept showing "no domain set" and "TURN not configured" on a box
that has both. The cause was reading the wrong file: I had it reading the
host-side .env, a 600 file needing an ACL grant, a ProtectSystem exception
and a systemd Environment= line to even locate — three things that each had
to be right, and weren't.
The vendored web admin gets this right because it reads
/etc/easy-asterisk/config, written by the container's own entrypoint. That is
mounted on the host inside ASTERISK_EA_CONFIG_DIR, a directory this dashboard
is already granted read access to for rooms.conf. So it now reads that first,
falls back to .env, and finally to `docker exec cat` of the same file — each
source only filling gaps the previous left. No new permissions, and the value
shown is what Asterisk is actually running with rather than what the
installer asked for.
Also adds the requested provisioning file: a "Download settings" link per
extension serving a plain text file with server, username, password, display
name, transport, port, SRTP, a ready-made SIP URI, and TURN server/user/
password. Deliberately not a vendor-specific format — Sipnetic, Linphone,
Zoiper and Groundwire each want a different one and none could be verified
from here, and a confidently-wrong .xml is worse than a file you can read. It
warns in-file when the extension is UDP-only, when the cert is self-signed,
and that it contains a password.
The panel gains the SIP URI and says when the address shown is the host IP
rather than a configured domain.
Fixes a JS syntax error introduced with that note: an apostrophe escaped for
Python's benefit left a bare quote inside a single-quoted JS string, which
broke the whole page script — the table rendered empty. Now checked properly
by extracting the script and running `node --check` over it, which catches
this class of fault directly instead of inferring it from missing elements.
Verified with only Easy Asterisk's config present and .env absent entirely —
the state that failed before: domain, TURN server, user and password all
resolve, the download serves with the right filename, and the page has no
errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
Three separate faults, reported together as "the old web admin made
extensions correctly and this doesn't".
1. Every write through ea_docker_write left pjsip.conf owned by root. `tee`
runs as root inside the container while Asterisk runs as `asterisk` and
expects to own its own config — the vendored admin's add_device() chowns
it back immediately after writing, and services/asterisk.sh's device
migration does too. This was the one writer in the project that didn't,
and is the most likely reason an extension created here behaves
differently from one created in the vendored admin. Now chowned after
every write, with a scoped sudoers entry for it, and a warning logged if
the chown itself fails rather than passing silently.
2. The dashboard's update path rewrote the systemd unit and then restarted
the service without daemon-reload, so systemd kept running the cached
unit. Any Environment= line added since the last FRESH install was written
to disk and ignored — which is exactly how a box with DOMAIN_NAME and TURN
both set in .env still reported "no domain set" and "TURN not configured".
3. The connection panel rendered at the top of the card, so on any table long
enough to scroll, the answer appeared off-screen above the row that was
clicked. It is now a table row injected directly beneath its own
extension, toggled by the same button, and it survives the transport and
password actions by reopening after the reload they trigger.
Also: LAN devices now get ice_support=yes when a TURN server is configured,
matching the vendored admin (a device given TURN credentials but no ICE can't
use the relay); the panel reports the device type, so a Mobile extension can
be confirmed as such; and an unreadable .env now says which of "path not
set", "file missing", "permission denied" or "TURN_SERVER empty" applies
instead of the flat "not configured" that covered all four.
Verified: the three .env failure states each produce their own message; the
detail row lands directly after its anchor, only one is ever open, it clears
on re-render and reopens rather than sticking closed; no duplicate or missing
element IDs and no JS errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
Making the transport a visible, equally-weighted choice meant new users had
to know the answer before they could get one right — and the wrong answer
fails in the least diagnosable way available: a UDP-only endpoint doesn't
refuse a TLS registration, it ignores it, so the phone times out and nothing
is logged anywhere.
The add form now creates Remote/FQDN (TLS 5061) extensions without asking.
TLS is listed first in the markup so it is the default before any script
runs, and the JS no longer switches it to LAN on a box with no domain — that
box is not better served by UDP, it just needs the phone told to trust a
self-signed certificate, which the disclosure now says at the point of
choosing.
Transport and auto-answer moved behind an "Advanced…" toggle, leaving name,
extension and category as the whole form. The toggle resets on cancel and
after a successful add so it doesn't stay open across uses.
Verified in the browser on two fixtures — with and without DOMAIN_NAME set —
that the panel starts hidden, the transport reads fqdn untouched in both
cases, and each shows the right hint when opened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
Adding an extension from the dashboard gave back a password and nothing else
— no server, no TURN credentials, and no indication of which transport the
endpoint had actually been written with. A correctly-created extension and
one that could never register looked identical.
Each row gets an info button opening the full set: SIP server, username,
password, transport and port, and the TURN server/user/password. The password
is read back from pjsip.conf rather than regenerated, so re-pairing a handset
no longer means deleting and recreating the extension. The same panel can
switch an extension between LAN (UDP 5060) and Remote/FQDN (TLS 5061) and
reset its password in place, keeping its category, room membership and PSTN
permissions.
That transport choice is the likeliest cause of a phone that looks right and
never registers: an endpoint written transport=transport-udp will not answer
a TLS registration and the phone just times out. The add form now defaults to
Remote/FQDN whenever DOMAIN_NAME is set, rather than always LAN.
Reading TURN details needs Asterisk's .env, which is chmod 600 — the
installer now grants the service user read on that one file via ACL and lists
it in the unit's ReadOnlyPaths, since ProtectSystem=strict would otherwise
hide it. The UI says so plainly if the file still isn't readable.
Also fixes a real gap: ea_add_device is a third, independent writer of
endpoint blocks alongside the two vendor paths that services/asterisk.sh
patches, and it was not emitting message_context=sip-messaging — so an
extension created from this dashboard silently had no internal SIP messaging
while one created from the vendor admin did.
Verified against a fixture: transport round-trips TLS↔LAN without
accumulating duplicate transport/media_encryption/ice_support keys, password
reset rewrites only the target extension's auth section, delete still works
after edits, and the browser shows the populated panel with the add form
defaulting to fqdn when a domain is configured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
The previous fix (trimming the Name comparison) was real but not the
actual culprit here. loadExtensions()'s fallback for a device with no
pstn-permissions.conf entry — the normal, default state for any
extension never granted PSTN access — set restrict: "none", which
isn't one of the dropdown's five valid values (full/restricted/
restricted-in/restricted-out/internal). The <select> silently fell
back to its first option ("full") while the underlying model kept
"none", so the mismatch reappeared identically on every load
regardless of caching, matching a persistent bug survives hard
refresh and a private window. Use "internal" instead, matching the
documented default and the value the dropdown actually supports.
rowEdits() compared the Name field's trimmed DOM value against the
untrimmed model value — any device whose stored name has stray
leading/trailing whitespace (e.g. from a manual pjsip.conf edit)
permanently failed that comparison on every load, showing the
unsaved-changes bar with nothing actually changed. Trim both sides.
Adds a Personal number column directly on the Ring Groups table (same
underlying write as the Personal Numbers card's owner picker, just
surfaced where it's actually needed instead of requiring a separate
trip) and closes the gap that group ownership previously left open:
a DID assigned to a group only rang members on inbound calls, with no
way for members to actually use it outbound.
_reconcile_group_cid_members() now makes a Ring Group's DID double as
every current member's outbound Caller-ID override in
pstn-permissions.conf, wired into every path that can change either
side of that relationship: assigning/reassigning/removing the group's
DID, joining/leaving the group, renaming it (repoints
pstn-personal-dids.conf's owner instead of leaving a stale reference,
members' Caller-ID untouched since neither the DID nor membership
changed), and deleting it (unassigns the DID entirely, clearing it
from every remaining member).
An individual extension's own personal_did always wins over a group's
- a member only ever inherits the group's DID into an empty
personal_did, and only ever loses an inherited one that still matches
the group's current DID, so a real individual assignment is never
clobbered. Verified end-to-end (create/assign/join/leave/rename/
delete, plus the individual-wins precedence case) against a local
harness before committing, given how much the outbound Caller-ID path
already burned this session.
Rooms' original flow was always create-empty-then-add-members-one-at-
a-time in the table afterward; the merge kept that flow and dropped
Groups' actual "check boxes, name it, done" creation UX, which is what
was actually asked for. Add-form now has a member picker so a Ring
Group can be built with its full membership in one step; existing
groups still use the per-row add/remove control for later changes.
Rooms already had the one thing Groups lacked — a real, dialable Easy
Asterisk extension with live ring/page dialplan logic — while Groups'
only unique capability was personal-DID ownership. Rebuilding that
ownership mechanism a second time under the Groups name would have
meant duplicating Rooms' dialplan-generation code; instead Rooms
(renamed "Ring Groups" for clarity) gains personal-DID ownership, and
the standalone Groups card/API/backend is removed entirely.
pstn-trunk.sh's existing group-owned-personal-DID machinery
(pstn-personal-group-ring.sh, reading pstn-groups.conf by name) is
untouched — a new sync_room_group_mirror() keeps that file in step
with a Ring Group's live membership automatically on every
create/rename/delete/member change, so pstn-trunk.sh never needs to
learn rooms.conf's format. Room names now validate against the same
pattern write_group() requires, so a room can never end up with a
name that would silently fail to sync once assigned a DID.
The PSTN-restart nudge now also fires on a Ring Group edit, but only
when that specific group currently owns a personal DID — most Ring
Groups never do, and prompting on every ordinary membership tweak
would just be noise.
"Auto-answer for everyone" is the existing Page (intercom) room type,
not a new control. Messaging stays exactly what it already was:
strictly per-extension, unrelated to Ring Group/personal-DID
membership.
Categories was a whole CRUD system (its own file, its own card, its
own API routes) for something that only ever had one functional
effect: tagging a device "mobile" to enable RTP NAT-keepalive tuning,
plus a per-category auto-answer default. Replaced with a single
"Mobile/cellular device" checkbox directly on the add-extension form
and each extension row, writing the same underlying category string
Easy Asterisk's device format already expects ("mobile" or
"standard") without a separate category registry to manage. Removes
ea_list_categories/ea_create_category/ea_delete_category/
ea_rename_category, their /api/ea-categories* routes, the Categories
card, and the now-unused categories.conf sudoers grant.
Rooms stays as-is — unlike Categories, it's the only actually-dialable
ring/page group in this dashboard (a real Easy Asterisk extension that
rings or pages members live), which the dashboard-only Groups feature
cannot replace.
The "Commit changes (restart Asterisk)" button was already wired
correctly, but lived in a passive banner that was easy to miss after
a save, letting an admin keep testing against Asterisk's stale
AST_CONFIG() read without realizing a restart was needed. Every
PSTN-affecting save now also prompts immediately ("Restart Asterisk
now?"), once per logical action (a single save, or one whole batch),
with the banner kept as a fallback for "not now".
Also:
- Extensions card is now collapsible like the other cards, open by
default.
- Concurrent-call caps card removed from the dashboard UI (the
underlying dialplan cap and pstn-limits.conf are untouched, just no
longer editable from this page).
- Categories/Rooms and Groups/Personal numbers are now visually
grouped under section headers, with a note clarifying that a Room
is a real dialable ring-group extension while a Group is a
dashboard-only bulk-action convenience, since both being "named
sets of extensions" invited exactly that confusion.
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 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 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
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.
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.
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
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.
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 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
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
- 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
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).
- 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.