Commit Graph
710 Commits
Author SHA1 Message Date
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 c53060ee89 Revert unsafe from_user removal, use PAI/RPID for per-call outbound CID
PJSIP's From-header username comes exclusively from the endpoint's
static from_user — it never falls back to the channel's CALLERID(num).
With from_user unset, Asterisk fell back to its own hardcoded default
("asterisk"), which the provider doesn't recognize as an account DID
and rejected outright: every outbound call went straight to busy with
the callee never ringing.

Restore the static from_user (required for the provider to accept the
call at all) and instead relay the per-call CALLERID(num) — personal_did
or the shared DID — via send_pai/send_rpid, which populate the
P-Asserted-Identity/Remote-Party-ID headers from the live channel
Caller-ID independent of from_user. This is the standard mechanism for
presenting a per-call Caller-ID over a trunk with one fixed From user.
2026-07-25 18:08:12 +00:00
Claude 95b52d606f Fix outbound PSTN caller ID always showing the shared trunk DID
The pjsip endpoint pinned a static from_user to the shared trunk DID,
which silently wins over CALLERID(num) for the SIP From header on
outbound calls. The dialplan's per-extension personal_did lookup
still set CALLERID(num) correctly, but every call still went out
showing the same DID regardless. Leaving from_user unset lets PJSIP
fall back to the channel's caller ID number, mirroring the inbound
side's existing no-static-callerid fix.
2026-07-25 17:56:11 +00:00
Outis 9785ce30a7 Merge pull request #233 from outis1one/claude/asterisk-digital-ocean-consolidate-9dsrse
Claude/asterisk digital ocean consolidate 9dsrse
2026-07-25 13:45:47 -04: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 0eb09eec9c Match the real Anveo SMS tab, and document provider-continuity steps
The SMS tab has one control — a "Forward to URL" checkbox and field, with
SAVE/RETURN buttons where RETURN discards. The instructions described a
destination dropdown that isn't there. Also flags that the generated URLs are
long (~90 chars relay, ~150+ direct) and worth re-opening the tab to confirm
they saved whole.

Adds a provider-risk section: the realistic way to lose the number is account
action or a lapsed balance rather than the company folding, so keep the
balance small, save a recent invoice offline (porting out needs a signed LOA
plus the latest bill, which you can't download once an account is closed),
and note that Anveo states it does not block port-outs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
2026-07-25 09:57:48 +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 b1e74666c6 Park the pre-merge droplet installer in attic/ as a rollback path
The unified services/asterisk.sh has not been run on real hardware yet, so
keep the previous services/asterisk-digital-ocean.sh available verbatim until
it has. attic/ sits outside setup.sh's services/*.sh glob, so the copy never
self-registers, never appears in the menu, and only runs if invoked directly
(it kept its own standalone bootstrap: sudo bash attic/asterisk-digital-ocean.sh).

Both scripts agree on where an existing droplet install lives
(~/docker/asterisk-digital-ocean, easy-asterisk-do containers), so moving
between them doesn't relocate anything.

attic/README.md is explicit that this is a way to get the old installer back,
not an undo button — what actually protects an existing install is --dry-run,
choosing update/cancel over fresh at the reinstall prompt, and a snapshot —
and that the copy should be deleted once the merge is confirmed, since two
copies of the same logic is the problem the merge removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
2026-07-25 09:20:33 +00:00
Claude e1c3203d88 Add sms-inbound: verification codes from a VoIP DID to ntfy push
New service for one narrow job — getting SMS verification codes sent to a
VoIP number onto a phone with no SIM. Deliberately not a texting app: no
outbound path (Anveo Direct has none; that needs an Anveo Retail account, and
a free texting app covers sending), and messages arrive as push notifications
rather than being routed into Asterisk as SIP MESSAGE, since a code you read
and type is better served by a notification than a softphone chat thread.

Two modes, both driven entirely from the provider's "forward SMS to URL" box:

- direct — the provider calls ntfy itself; nothing installed here. ntfy
  accepts GET publishing at /{topic}/(publish|send|trigger) with message and
  title as query params, and auth via ?auth= holding base64url (unpadded) of
  the literal "Bearer <token>" — confirmed against ntfy's server.go and
  server_auth.go rather than its docs.
- relay — a stdlib systemd service, Caddy-fronted on its own domain with no
  Authelia (the provider can't log in; a random 32-char token in the path is
  the secret). Buys two things direct mode can't have: an unescaped "&" in a
  message body survives intact, because the relay takes everything after the
  last message= verbatim instead of parse_qs — which is why the generated URL
  always puts the message placeholder last — and no ntfy credentials sit in a
  third party's web portal.

Verification codes are bearer credentials, so: a 24-char random topic name
(the repo's ntfy defaults to auth-default-access: read-write, making the topic
name the read credential), constant-time token compare, a 60/min rate limit,
and the relay logs sender/recipient/length but never the message body.

The Anveo guide gains a section covering the two things that actually decide
whether codes arrive: short-code support (Anveo has it, unusually — VoIP.ms
does not except for Google) and Anveo's carrier-sourced *mobile* DIDs, which
are classified as mobile in the lookups that reject VoIP numbers at signup.
Also documents MMS and group texts being out of reach, and why the native
Messages app never sees any of this.

Verified against a stub ntfy: plain OTP, encoded "&", unencoded "&", "+" as
space, wrong token (404), missing message (400) and the rate limit (57x204
then 429) all behave; both installer modes were run end to end in a sandbox
and their generated URLs, settings files and READMEs checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
2026-07-25 02:13:39 +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 8b843ca1c1 Fold asterisk-digital-ocean into asterisk with droplet auto-detection
services/asterisk-digital-ocean.sh was a near-verbatim copy of
services/asterisk.sh — same vendor refresh, compose template, messaging
dialplan, presence alerts, UFW rules and dashboard/trunk chaining, with the
helper functions renamed _asterisk_do_*. Two copies meant every fix had to
land twice, and several never did.

There is now one `asterisk` service. It reads the DigitalOcean metadata
service and asks either way (so a droplet with metadata blocked, or another
provider's public VM, can still opt in), then gates the genuinely
droplet-specific behaviour on that one answer: swapfile for low-RAM plans,
public-FQDN-only setup with no LAN/VLAN prompts, a Caddy site block pinned
to that FQDN, the remote-Authelia option, and the doctl Cloud Firewall.

Two things that were droplet-only for no real reason now apply everywhere:
the entrypoint patch that writes security-level events to logs/full, and
the logrotate config for that file. Without them the Security Dashboard's
Security Log tab and CrowdSec's Asterisk acquisition were silently empty on
every home/LAN install; crowdsec.sh now detects either install directory.

Existing droplets are left alone: an install at ~/docker/asterisk-digital-ocean
keeps its directory and easy-asterisk-do container names, since its Caddyfile
block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN trunk all
name those exactly. New installs use ~/docker/asterisk / easy-asterisk.
`sudo ./setup.sh asterisk-digital-ocean` still works via a new SERVICE_ALIAS
map in setup.sh, without a second menu entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
2026-07-25 01:06:38 +00:00
Outis 15d70356ac Merge pull request #232 from outis1one/claude/group-call-busy-signal-e8usba
Fix inbound PSTN calls: dialplan context never loaded, Caller-ID never real
2026-07-24 16:31:48 -04:00
Claude a45b9dc08e docs: clarify that ONE inbound SIP Trunk covers every Anveo DID
The step 4 heading said "once per DID" and the intro said steps 3-7 repeat
per DID, both of which overstate the work. The body already noted the trunk
could be reused, but the heading is what gets read first - and it actively
misleads toward creating one trunk object per number.

One inbound SIP Trunk serves the whole account: the $[E164]$ placeholder is
substituted with each DID's own number at call time, and the Asterisk side
matches whatever arrives via _X. and resolves it through
pstn-personal-dids.conf. Same for the outbound Service Trunk in step 3,
which the body already flagged as skippable for DIDs 2+.

Rewrites the heading, adds an explicit up-front note on why one suffices,
and corrects the intro to name which steps actually repeat per number.
2026-07-24 20:21:23 +00:00
Claude 8113d4bb2f Record the confirmed-live results of the group-ring debugging session
A group-owned personal DID is now working end to end against a real
inbound call: it rings every authorized group member simultaneously, first
to answer wins, and members whose own tier/allowed_numbers don't authorize
the caller are correctly skipped.

Updates the code comments that were written before that confirmation
existed (the Busy(15) block's "NOT yet confirmed live", and the group-ring
script's unverified-SHELL() note), and documents all four fixes plus the
"Commit Changes" caveat in the Anveo setup guide's bugs-fixed section so
the next person hitting one-ring-then-busy has the trail.

Also clears the guide's "Still open" section: the 2-number account cap no
longer applies, and the interactive walkthrough it described as unbuilt
was built (_pstn_anveo_walkthrough).
2026-07-24 20:13: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
Outis 18727611be Merge pull request #231 from outis1one/claude/group-call-busy-signal-e8usba
pstn-trunk: strip a leading + before normalizing Caller-ID/whitelist …
2026-07-24 13:49:00 -04: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
Outis fc5b566f55 Merge pull request #230 from outis1one/claude/group-call-busy-signal-e8usba
pstn-trunk: stop pinning the endpoint's static callerid to the DID it…
2026-07-24 13:09:59 -04:00
Claude 5c8c04700e pstn-trunk: stop pinning the endpoint's static callerid to the DID itself
Live trace confirmed every inbound call's CALLERID(num) came back identical
to the DID that was dialed, regardless of who actually called - meaning
every restricted-tier allowed_numbers check was structurally impossible to
satisfy, since the "caller ID" Asterisk saw was never the real one.

The [pstn-trunk] endpoint had a static `callerid=<DID>` default (redundant
for outbound - the dialplan already sets CALLERID(num) on the calling
channel before Dial(), which is what actually gets presented) with no
trust_id_inbound setting. Removed the static default and added
trust_id_inbound=yes so real caller identity the provider sends
(P-Asserted-Identity/Remote-Party-ID, in addition to the From header) is
honored for inbound calls instead of falling back to the DID.
2026-07-24 17:02:01 +00:00
Outis c7a928d463 Merge pull request #229 from outis1one/claude/group-call-busy-signal-e8usba
pstn-trunk: split inbound dialplan into its own #include'd file
2026-07-24 12:07:55 -04:00
Claude 2e1b9539dc pstn-trunk: split inbound dialplan into its own #include'd file
Root cause found via live debugging: pstn-trunk-dialplan.conf mixed two
contexts in one file - outbound patterns continuing [intercom], then a
[from-pstn-trunk] header appearing partway through the same file. On the
reporting box this caused the ENTIRE file to silently fail to load: `dialplan
show intercom` showed zero of the outbound NANP patterns, and `dialplan show
from-pstn-trunk` reported the context didn't exist, with no warning anywhere
(config log, full log, or the reload command's own output).

messaging-dialplan.conf, #include'd the same way right after [intercom],
loaded fine - the working structural difference is that its first real line
IS its own context header, never trying to continue [intercom] first.

Split pstn-trunk-dialplan.conf into that same working shape:
- pstn-trunk-dialplan.conf keeps only the outbound content (continues
  [intercom], unchanged from before).
- pstn-trunk-inbound-dialplan.conf is new, and starts with [from-pstn-trunk]
  as its first non-comment line - nothing before it.

Both _pstn_patch_vendor_files (generator patch) and _pstn_ensure_live_includes
(direct live-file patch) now add a second #include line for the new file.
_pstn_ensure_live_includes also now verifies after reload that
'from-pstn-trunk' actually has a matching extension, and warns loudly if not,
instead of unconditionally reporting success regardless of whether the
reload actually worked.
2026-07-24 16:00:45 +00:00
Outis ccc8076cca Merge pull request #228 from outis1one/claude/group-call-busy-signal-e8usba
pstn-trunk: play Busy(15) on all inbound denial exits, not just Hangup()
2026-07-24 11:14:54 -04:00
Claude 36681970af pstn-trunk: play Busy(15) on all inbound denial exits, not just Hangup()
pstn_in_denied, the personal-DID owner denial, and pstn_personal_denied_group
fell straight to a bare Hangup() on an unanswered channel instead of
Busy(15) like every other denial path in this file (outbound tier/number/
intl/killswitch). A bare Hangup() doesn't map to "486 Busy Here" the way
Busy() does, so a denied inbound call (wrong tier, number not on the
approved list, group membership mismatch) could sound like a generic
call-failed/fast-busy rather than a clean busy signal - easy to mistake
for a broken permission check when the real issue is a config mismatch.

Also added a NoOp() logging the resolved group name, caller ID, and
PSTN_RING_LIST right after the group-ring SHELL() call, so the next test
of a group-assigned personal number shows directly (via the Asterisk
console/full log) whether the ring list came back empty - and if so, which
of group membership, tier, or allowed_numbers to check next - instead of
requiring another guess-and-recheck cycle.
2026-07-24 15:11:37 +00:00
Outis 52ab3002b1 Merge pull request #227 from outis1one/claude/sip-voip-integration-atsins
Close remaining 10-vs-11-digit gaps in PSTN number input
2026-07-24 10:41:52 -04: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
Outis 4f4c9f3051 Merge pull request #226 from outis1one/claude/sip-voip-integration-atsins
Claude/sip voip integration atsins
2026-07-24 10:37:25 -04:00
Claude 10576ea59c Fix inbound restricted-tier checks failing when caller ID lacks leading "1"
allowed_numbers is always stored 11-digit (dashboard's NUMBER_RE requires
exactly 11 digits), but nothing normalized the inbound CALLERID(num) to
match. Confirmed live: Anveo delivered a bare 10-digit caller ID
(5557654321) for a call to a group-owned personal DID whose members'
allowed_numbers were correctly stored as 15557654321 — every restricted-
tier REGEX comparison failed on a plain digit-count mismatch, not because
the number was actually unauthorized. Outbound already guards against the
mirror-image case (_NXXNXXXXXX prepends "1" before any tier check); this
adds the equivalent for inbound via a new PSTN_CALLERID_NORM dialplan
variable, used everywhere CALLERID(num) previously fed a restricted-tier
comparison: the shared ring-group's per-member block, the single-owner
personal DID check, and the group-owned personal DID's ring script
invocation (which also gets a defensive normalize of its own, since it's
useful to invoke by hand for testing).

Verified by regenerating the actual group-ring script and running it
against the exact config dump and caller ID from the live failing call —
now correctly resolves all three group members instead of an empty list.
2026-07-24 14:35:41 +00:00
Claude dd25782ef1 pstn-trunk: drop redundant container restart from update-in-place path
install_pstn-trunk's "update" branch unconditionally restarted the
asterisk container, then immediately called _pstn_ensure_live_includes,
which already patches the live pjsip.conf/extensions.conf #include lines
and reloads res_pjsip/dialplan — the restart accomplished nothing the
reload doesn't, while dropping any calls already in progress. Confirmed
live as a real double-restart when this runs chained from
asterisk-digital-ocean.sh, which had just rebuilt/restarted the same
container moments earlier.

Fresh installs keep their existing (still restart-first) apply step
unchanged — only "update in place" loses the redundant restart.
2026-07-24 14:24:45 +00:00
Outis 261ed3dc74 Merge pull request #225 from outis1one/claude/sip-voip-integration-atsins
Fix group-ring script's INI parser silently returning empty on every …
2026-07-24 09:47:35 -04:00
Claude 228add0937 Fix group-ring script's INI parser silently returning empty on every lookup
_ini_get() in _pstn_write_personal_group_ring_script split on '=' and
compared $1 == key without trimming whitespace, but configparser.write()
(used to write pstn-groups.conf and pstn-permissions.conf) pads '=' with
spaces by default. Every lookup — including the group's own members= line —
silently returned empty, so group-owned personal DID ring-groups never
actually rang anyone regardless of member tier/whitelist config. Trim
whitespace around both the extracted key and value before comparing.

Also switch the script's final echo to printf to remove any ambiguity in
the dialplan's SHELL()-empty-string check.
2026-07-24 13:44:06 +00:00
Outis 0254f6e51e Merge pull request #224 from outis1one/claude/sip-voip-integration-atsins
Fix secdash access to Asterisk config: use ACLs, not chown-fragile gr…
2026-07-24 09:28:11 -04: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
Outis af9dcfe47f Merge pull request #223 from outis1one/claude/sip-voip-integration-atsins
Claude/sip voip integration atsins
2026-07-24 08:53:08 -04:00
Claude ff61e8f733 Chain Security Dashboard + PSTN trunk setup into the Asterisk install flow
asterisk.sh and asterisk-digital-ocean.sh now offer, at the end of both
their fresh-install and update-mode paths, to also set up the Security
Dashboard and configure a real PSTN trunk in the same run - one script
walks through the whole stack instead of needing to separately remember
and run `sudo ./setup.sh security-dashboard` / `sudo ./setup.sh pstn-trunk`
afterward.

Both target services keep their own register_service call and stay fully
independently invocable - this is purely an additive convenience layer
(_asterisk_offer_dashboard_and_trunk / _asterisk_do_offer_dashboard_and_trunk),
not a replacement. An already-installed piece is silently refreshed
(install_security-dashboard/install_pstn-trunk each already have their own
update/fresh/cancel reinstall-mode gate, so calling them again just does
the right thing); a not-yet-installed piece gets one y/n instead of every
detailed prompt firing.

Guarded with declare -F so a standalone `sudo bash asterisk.sh` copy (no
sibling services/*.sh files sourced) skips both cleanly with an
explanatory message instead of erroring on an undefined function.

Verified: full sourcing simulation resolves all four install_* functions
correctly, `setup.sh --dry-run --unattended asterisk` and
`asterisk-digital-ocean` both complete cleanly end-to-end, and
`setup.sh --list` still shows all four services as independently
selectable.

Documented the pattern in CLAUDE.md under a new "Chaining into another
service from within your own" section for future contributors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-24 12:38:45 +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
Outis 6faa60f832 Merge pull request #222 from outis1one/claude/sip-voip-integration-atsins
Dashboard UI cleanup: column sorting, iframe fix confirmed, messaging…
2026-07-23 23:48:50 -04: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
Outis 20e7fafd6a Merge pull request #221 from outis1one/claude/sip-voip-integration-atsins
Claude/sip voip integration atsins
2026-07-23 23:11:17 -04: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
Outis 3e729f0559 Merge pull request #220 from outis1one/claude/sip-voip-integration-atsins
Claude/sip voip integration atsins
2026-07-23 21:42:11 -04:00
Claude f5e93ec35b Build the interactive Anveo Direct account-setup walkthrough
Replaces the static wall of warnings with a real 5-step guided walkthrough
(account/funding, DID ordering, both trunk objects, confirmed rate),
pausing for confirmation between each step and showing this box's actual
public IP inline for direct copy-paste into Anveo's Authorized IP
Addresses and SIP Trunk forwarding fields. Built directly from
docs/anveo-direct-setup-guide.md now that the whole flow is confirmed
working end-to-end, so a fresh run shouldn't need the trial-and-error
session that guide was written from.
2026-07-24 01:29:06 +00:00
Claude 9c9a299479 Add a confirmed-working, step-by-step Anveo Direct + Easy Asterisk setup guide
Documents the actual end-to-end sequence that got a real DID working
(account setup, DID ordering, both trunk objects, droplet configuration,
permission setup, testing), including the real confirmed values (the
$0.00388/min rate, the port-5060 SIP URI forwarding format, the two
separate trunk-object types) and a summary of every real bug hit and
fixed along the way. Linked from the existing planning-notes doc.
2026-07-24 01:10:35 +00:00