Commit Graph
63 Commits
Author SHA1 Message Date
Claude d7d630386c Add search box to CrowdSec Active bans table
Filters by IP/range, scenario, ASN, carrier name, or country in a single
free-text field -- asked for so a phone's current IP or its network/carrier
name can be searched directly instead of scanning the full ban list by eye.
2026-08-17 00:16:18 +00:00
Claude 9045f8f030 Fix DEVICE_MARKER_RE field order so list_extensions() actually finds devices
DEVICE_MARKER_RE expected "; === Device: NAME [AA:marker] (category) ===",
but device_config's own template (further down this file) generates
"; === Device: NAME (category) [AA:marker] ===" -- category parens before
the AA tag, not after. The regex never matched a real device comment, so
list_extensions() silently returned [] for every device on every install,
and /api/pstn-permissions served {"extensions": []} regardless of what was
actually in pstn-permissions.conf. That's why the Extensions tab's
Messaging/Voicemail checkboxes always rendered unchecked after a save +
reload even though the file itself had messaging=yes/voicemail=yes written
correctly -- the JS falls back to an all-default row when the endpoint
returns nothing. ea_list_devices() and the rename-device code parse the
same comment via string-splitting/a differently-shaped regex and were
already correct; this was the one broken parser.
2026-08-16 20:28:06 +00:00
Claude 54f99d8403 security-dashboard: drop the port from Sipnetic QR's TURN server field
Sipnetic's own documented "st" field format is explicit that the value is a
hostname/IP without a port -- its worked example (turn:user:pass@host) has
no port anywhere, even in the URI-with-credentials form. Appending :3478 as
this repo was doing gets silently truncated by the app: confirmed live, the
FQDN came through on scan but the port after it did not.

coturn's listening port in this repo is always the STUN/TURN-conventional
3478 anyway, which is what a portless address implies, so stripping it
before building the st field costs nothing and matches the actual spec.
2026-08-13 03:26:13 +00:00
Claude 2b926ebf0b security-dashboard: widen main container for the Extensions table
The previous "box too narrow" fix targeted the QR popup, but the actual
complaint (confirmed by screenshot) was the Extensions table itself --
ten columns (Ext/Name/Mobile/Status/Transport/PSTN/Whitelist/Messaging/
Voicemail/actions) forced .table-wrap's horizontal scrollbar even on a
normal desktop viewport because main was capped at 1180px. Bumped to
1600px; verified via headless render at 1280-1920px that the table no
longer overflows.
2026-08-12 20:32:33 +00:00
Claude 5d0b6355b1 security-dashboard: put TURN creds in the Sipnetic QR, widen the popup
- ea_device_sipnetic_string() now sets Sipnetic's documented st= field to
  an explicit turn:user:pass@host:port URI built from the same
  TURN_SERVER/TURN_USERNAME/TURN_PASSWORD Asterisk itself reads from its
  .env (the shared VPS coturn on a droplet, or whichever coturn Asterisk
  is actually configured against). Previously the QR carried no TURN
  info at all, silently falling back to Sipnetic's own default STUN
  server instead -- registration/media then depends on whatever got
  typed in by hand instead of what Asterisk is actually using.
- Popup widened (192px content -> 320px card) and the QR rendered at 3x
  its displayed resolution (physical size unchanged): the longer
  TURN-inclusive account string needs a denser code, and verified via a
  headless render + OpenCV/pyzbar decode that the extra module density
  needs the resolution bump to stay reliably scannable.
- Restored (and expanded) the plain-text-credentials warning that was
  dropped when the box became a modal, now covering TURN creds too.
2026-08-12 20:10:10 +00:00
Claude 779afcba62 security-dashboard: add white quiet zone around Sipnetic QR code
The QR popup's code was unreadable by real scanners: qrcodejs draws
modules edge-to-edge with no margin of its own, so the code sat directly
against the modal's dark background with no quiet zone. Verified with a
headless render + pyzbar/OpenCV decode that the raw generated image had
the code running to its edge and failed OpenCV's detector outright, while
wrapping it in a 20px white padded frame (still ~2in overall) fixed it.
2026-08-12 19:50:53 +00:00
Claude f0d34a0028 security-dashboard: show extension Sipnetic QR code in a popup modal
Converts the existing inline QR toggle on the Extensions tab's detail
panel into a small (2in square) modal popup with an X close button,
click-outside, and Escape-to-close, instead of an expanding inline box.
2026-08-12 19:33:55 +00:00
Claude 7a1f09a0f7 Rename reinstall-mode prompt; make security-dashboard's "Full reinstall" a real teardown
Two-part change discussed and scoped in this session before touching
anything:

1. Rename "Reinstall in place" (r) -> "Update" (u) and "Full install" (f)
   -> "Full reinstall" everywhere the prompt appears: lib/common.sh's
   shared prompt_reinstall_mode(), plus the three services that carry
   their own duplicated standalone-stub copy of it for standalone
   execution (asterisk.sh, coturn.sh, wordpress.sh — per this repo's
   documented standalone-bootstrap pattern). Internal state values
   (update/fresh/cancel) are unchanged, so no other service's case
   statement needed touching. docs/anveo-direct-setup-guide.md's `r`
   reference updated to `u` to match. attic/asterisk-digital-ocean.sh
   deliberately left alone — this repo's own policy is to not backport
   fixes into attic/.

2. security-dashboard.sh's "Full reinstall" now does a real teardown
   before reinstalling — stops and removes the systemd unit, sudoers
   grant, Caddy site block, and secdash system user, then proceeds
   through the normal fresh-install flow — instead of just overwriting
   files in place while leaving the old service running underneath.
   Prototype for a pattern discussed for other services later: split the
   destructive question out explicitly ("also delete
   dashboard-admins.conf — per-admin extension scoping?", default n) so
   full reinstall doesn't silently discard state a plain "start over"
   request wouldn't expect to lose. Verified the backup/restore mechanics
   (mktemp, copy out before teardown, copy back after) against a mock
   under `set -u` for both the preserve and wipe paths before shipping.

Update mode was already the strongest existing example of surfacing
newer optional prompts (its "Reconfigure Caddy protection?" /
"Reconfigure per-admin scoping?" sub-prompts already cover every setting
fresh-install offers) — no changes needed there for this service.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 18:18:45 +00:00
Claude c719197b20 Admin-scoping setup: show live extension list, auto-include owned DIDs
Two refinements to the per-admin extension scoping added last commit:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-11 05:58:42 +00:00
Claude d4ca8277a6 security-dashboard: add Calls & Texts tab for PSTN calls and SIP/SMS texts
Surfaces pstn-trunk.sh's existing pstn-trunk-calls.log (already recording
every PSTN call's numbers, just never shown on the dashboard) plus two new
metadata-only logs: sip-messages.log for internal SIP MESSAGE
deliveries/denials (asterisk.sh) and pstn-sms.log for SMS-over-SIP arrivals
(pstn-trunk.sh). No message bodies are ever logged. The dashboard reads all
three via a new /api/pstn-calls and /api/comms-texts pair, sharing a common
bounded tail helper with the Security Log parser.
2026-08-08 02:32:42 +00:00
Claude 9390382bf4 Add Sipnetic QR-scan provisioning and a kiosk client installer download
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
2026-07-29 03:30:13 +00:00
Claude 646b7b6fde Let an existing Ring Group's type/timeout be edited, not just set at creation
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
2026-07-29 03:03:15 +00:00
Claude ca750f5432 Document Ring vs Page and auto-answer kiosks, in the dashboard itself
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
2026-07-28 23:57:42 +00:00
Claude eaca45c83c Read domain/TURN from Easy Asterisk's own config, and offer a settings download
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
2026-07-28 20:19:46 +00:00
Claude 1b3e7a6110 Fix extensions created from the dashboard, and show details by their row
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
2026-07-28 20:08:49 +00:00
Claude 1f6c7cf349 Default new extensions to TLS, with transport behind an Advanced disclosure
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
2026-07-28 19:43:21 +00:00
Claude 11f48ed970 Show connection details per extension, and let them be edited
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
2026-07-28 19:42:13 +00:00
Claude c02b908570 Fix the real cause of the persistent "1 extension edited" false positive
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.
2026-07-26 23:00:18 +00:00
Claude 88b4510383 Fix persistent "1 extension edited" false positive on page load
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.
2026-07-26 22:50:37 +00:00
Claude 5d9f7dad4b Ring Groups: assign a personal DID right on the row, inherited by members
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.
2026-07-26 20:31:00 +00:00
Claude fa22779d61 Add member checkboxes to Ring Group creation
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.
2026-07-26 19:49:22 +00:00
Claude fafa9d9fee Merge Groups into Ring Groups (renamed from Rooms), drop standalone Groups
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.
2026-07-26 16:35:52 +00:00
Claude a37cba242a Default the Security Dashboard to the Extensions tab
Security Log was the landing tab purely by markup order; Extensions
is what actually gets used day to day.
2026-07-26 16:18:04 +00:00
Claude e9ff9b5112 Remove Categories management from dashboard, keep Rooms
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.
2026-07-25 20:33:58 +00:00
Claude c477d11eb7 Improve PSTN commit-changes UX, collapse Extensions, drop call-limit UI
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.
2026-07-25 19:40:20 +00:00
Claude 7baaae2b6e Name the PSTN modes as extensions of the original tiers
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
2026-07-25 10:57:06 +00:00
Claude e96df78ab5 Rework PSTN permissions to one whitelist plus a direction mode
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
2026-07-25 10:51:29 +00:00
Claude 5747ad7fda Split the PSTN permission tier into independent inbound and outbound axes
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
2026-07-25 10:30:11 +00:00
Claude e709413157 Send Cache-Control: no-store with the dashboard page
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
2026-07-25 09:42:57 +00:00
Claude 51d090ee2c Modernize the Extensions tab: batched saves, collapsed sections, inline edit
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
2026-07-25 02:05:35 +00:00
Claude bde7e9d12d Merge dashboard Asterisk Admin, Extensions and PSTN Trunk into one tab
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
2026-07-25 01:14:15 +00:00
Claude 3727eb6951 security-dashboard: add a Commit Changes button to the PSTN Trunk tab
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.
2026-07-24 17:54:08 +00:00
Claude 802b51b34d pstn-trunk: strip a leading + before normalizing Caller-ID/whitelist numbers
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.
2026-07-24 17:47:52 +00:00
Claude 8a99544ee1 Close remaining 10-vs-11-digit gaps in PSTN number input
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.
2026-07-24 14:40:33 +00:00
Claude dd8d20cbc8 Fix secdash access to Asterisk config: use ACLs, not chown-fragile groups
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
2026-07-24 13:26:35 +00:00
Claude 6f5ed30469 Replace Caddy path-proxy with a fully native Asterisk Admin tab
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
2026-07-24 12:14:27 +00:00
Claude d449586dde Replace the Asterisk Admin iframe with a native Caddy path-proxy
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
2026-07-24 11:45:11 +00:00
Claude 4f102b12c1 One dashboard to manage Asterisk: core tabs always on, optional tabs self-hide
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
2026-07-24 05:56:54 +00:00
Claude 17a21c076f Dashboard UI cleanup: column sorting, iframe fix confirmed, messaging chips
- 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
2026-07-24 03:45:13 +00:00
Claude c893751859 Document group-owned personal number assignment
Update the dashboard's own Personal Numbers card description and the
Anveo Direct setup guide to mention assigning a DID to a group instead
of a single extension.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-24 03:03:55 +00:00
Claude dd3ea131ac Allow assigning a personal number to a group, not just a single extension
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.
2026-07-24 03:00:13 +00:00
Claude 3ee3a45367 Fix personal-DID inbound lookup: 10-digit storage vs 11-digit EXTEN mismatch
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.
2026-07-23 22:08:24 +00:00
Claude 3661c8fbca Wire up internal SIP MESSAGE enforcement using a dedicated dialplan context
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
2026-07-23 15:03:03 +00:00
Claude b10a626b22 Fix ntfy deny-all default, add crowdsec update mode, presence alerts, embedded Asterisk admin tab
- 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.
2026-07-23 14:15:17 +00:00
Claude 3359898b4e Add named extension groups and Security Log column filtering
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
2026-07-23 06:05:37 +00:00
Claude 7851e2befd Decouple internal SIP messaging management from PSTN trunk installation
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
2026-07-23 05:38:36 +00:00
Claude 8291eba55e Make the messaging permission flag live-editable via the dashboard
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
2026-07-23 05:27:58 +00:00