Commit Graph
740 Commits
Author SHA1 Message Date
Outis cf2d0604b9 Merge pull request #250 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Grant smsrelay's ACL access after the Asterisk restart, not before
2026-07-26 23:31:12 -04:00
Claude 733a583c6a Grant smsrelay's ACL access after the Asterisk restart, not before
Reproduced live: pstn-personal-dids.conf was confirmed readable by
smsrelay right after a manual ACL grant, then unreadable again
("does not exist" in the relay's log -- os.path.isfile() swallows the
PermissionError and just returns False) immediately after the very next
fresh install. The only thing that ran in between was this same
install's own Asterisk container restart (needed to pick up the new AMI
secret).

The ACL grant was sequenced BEFORE that restart. CLAUDE.md documents the
container's entrypoint re-chowning its mounted config directory on every
restart and says chown alone can't touch ACL entries -- true, but
apparently this image's entrypoint also chmods, and chmod recomputes a
directory's ACL mask entry, which can silently weaken a named-user grant
made before it even though the grant's ACL entry itself is untouched.

Fix: do the grant last, after the restart-or-not branch, so nothing left
in this install run can undo it. ensure_docker_dir_ownership() (chown
only, confirmed in lib/common.sh, no chmod) stays where it was --
chow doesn't need this ordering fix, only the ACL grant does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
2026-07-27 03:27:14 +00:00
Outis 39c9f20fa6 Merge pull request #249 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Log rejected sms-inbound webhook requests instead of dropping silently
2026-07-26 23:22:18 -04:00
Claude e59f16556a Log rejected sms-inbound webhook requests instead of dropping silently
A token mismatch returned a bare 404 with no journal line at all, so
"nothing is happening" was indistinguishable from "no request ever
arrived" -- exactly what a stale provider URL looks like after a
RELAY_TOKEN rotation (every full reinstall generates a new one, which
invalidates whatever's still pasted into the DID's SMS tab until it's
updated). Now logs the request's source IP and path length -- never the
attempted path itself, since that's unauthenticated input from whoever
hit the port, no reason to trust or echo it into the journal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
2026-07-27 03:04:37 +00:00
Outis d91143a8b3 Merge pull request #248 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Stop the old sms-inbound instance before a fresh reinstall, not after
2026-07-26 23:00:54 -04:00
Claude 9207daa433 Stop the old sms-inbound instance before a fresh reinstall, not after
The port-taken message on the last run ("Port 8093 was taken — the relay
will use 8094") was this service colliding with itself, not a real
conflict. Fresh-install never stopped the previous run before scanning
for a free port, so it always found its own earlier process still bound
to 8093, silently moved to 8094, and then `systemctl enable --now` was a
no-op against an already-active unit -- meaning the OLD process (holding
the OLD AMI secret and OLD relay token, from before this session's
settings.env fix) kept serving traffic while the freshly-written config
and port sat unused underneath it.

Fix: `systemctl stop sms-inbound` right before the port scan. Now the
scan only reports a real conflict from something else, and the box
should settle back on 8093 (or whatever's actually free) with the
process that's really running matching what was just configured.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
2026-07-27 03:00:30 +00:00
Outis 8fbc1b8cb0 Merge pull request #247 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Fix sms-inbound crashing on every "update" re-run
2026-07-26 22:57:59 -04:00
Claude 868a903683 Fix sms-inbound crashing on every "update" re-run
Root cause of the "line 9: from: unbound variable" crash: SMS_FORWARD_URL
stores Anveo's own template placeholders literally ($[from]$, $[to]$,
$[message]$ -- text Anveo substitutes on its end, not ours). Written into
settings.env double-quoted, that landed in the file as
SMS_FORWARD_URL="...?from=$[from]$&...". Harmless to write, but the
update path `source`s this same file on every re-run, and bash reads
$[from] as legacy arithmetic expansion ($[...] == $((...))) even inside
double quotes -- a bare name in it means "look up variable from", which
is unset, and setup.sh runs under `set -uo pipefail`, so nounset kills
the whole installer before it gets anywhere near the AMI-diagnostics
code from the last two commits.

Fix: single-quote every value in the written settings.env. A source'd
single-quoted assignment never re-expands its contents, so this is safe
regardless of what SMS_FORWARD_URL (or anything else in that file) holds.

This only fixes future writes -- the box that hit this already has a
broken settings.env on disk from before this fix existed, and "update"
mode sources that file before it gets a chance to rewrite it, so it will
crash the same way one more time even after pulling this. Choosing
"full install" instead on the next run skips the source entirely and
regenerates the file correctly quoted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
2026-07-27 02:56:40 +00:00
Outis cbbb587b7e Merge pull request #246 from outis1one/claude/asterisk-caller-id-number-y1b3ls
sms-inbound: log exactly why recipient resolution failed
2026-07-26 22:38:36 -04:00
Claude 3727f375b1 sms-inbound: log exactly why recipient resolution failed
First live test dropped an inbound text with only "no owner found for
this DID, dropped" in the journal -- not enough to tell a config-dir
path problem from an ACL/permission problem from a DID-normalization
mismatch from an actually-unassigned DID, without re-triggering a real
text each time. resolve_recipients() now prints which of those it hit:
config dir unset, file missing (which also silently covers "smsrelay
can't traverse/read config/asterisk" -- os.path.isfile() swallows
PermissionError and just returns False), normalized DID has no section,
section has no owner=, or a group owner has no current members.

Nothing added is more sensitive than what's already visible on the
dashboard itself (config dir path, a DID's own digits, the set of DIDs
that exist) -- never the message body.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
2026-07-27 02:36:00 +00:00
Outis 5328601b7a Merge pull request #245 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Rebuild sms-inbound to deliver texts into Asterisk via AMI, not ntfy
2026-07-26 22:07:06 -04:00
Claude 34638e8400 Rebuild sms-inbound to deliver texts into Asterisk via AMI, not ntfy
SMS-over-SIP is confirmed unavailable on this DID (the account's SMS tab
only offers "Forward to URL", no MESSAGE/INVITE option) so the diagnostic
dialplan from the previous commit is dead weight for delivery purposes,
though pstn-trunk.sh's message_context wiring stays since it's harmless
and costs nothing to leave in place.

Pivots the whole service: same proven HTTP-webhook front end (rate limit,
constant-time token check, message/from/to extraction), but the delivery
target changes from a push notification to a real SIP MESSAGE landing in
the extension's own softphone (Sipnetic), via Asterisk's Manager
Interface. "Direct mode" and all ntfy options are removed entirely per
instruction to fully replace ntfy, not keep both paths.

Recipient resolution reuses the exact on-disk formats
services/security-dashboard.sh already reads/writes: DID -> owner from
pstn-personal-dids.conf, and for a Ring-Group owner ("@Name"), that
group's current members from pstn-groups.conf -- so delivery always
reflects live DID ownership and live group membership, not a snapshot
from assignment time.

AMI access is scoped tightly: a dedicated "smsrelay" manager.conf user
with only the "message" permission class, bound to 127.0.0.1, secret
generated via generate_password. The service account also gets a POSIX
ACL grant (not chmod/group, which the container's entrypoint reverts on
every restart) for read-only access to the two config files above.

Flagged as UNVERIFIED in code comments and will need a live test: the
"message" AMI permission class name and MessageSend's To/From/Body
parameter names are believed correct from documentation but unconfirmed
against a real Asterisk instance. ami_deliver() logs every AMI response
in full specifically so the first real delivery attempt is
self-diagnosing if something here is wrong.

Verified before commit: bash -n on the full file, and py_compile on the
extracted relay.py (configparser/socket/hmac/http.server, all stdlib).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
2026-07-27 01:58:54 +00:00
Outis be46674413 Merge pull request #244 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Fix inbound-calling regression from the SMS-over-SIP include, and the
2026-07-26 21:24:55 -04:00
Claude 1caae68c34 Fix inbound-calling regression from the SMS-over-SIP include, and the
missing SMS-URI print on update

Two real bugs from the previous commit, both confirmed live on an
actual box where inbound calling had been working:

1. Interleaving the new #include between the two existing trunk
   includes broke 'from-pstn-trunk' loading entirely (confirmed via
   the box's own dialplan-reload verification check going from
   passing to failing on the exact run that added the new include).
   Root cause of the underlying quirk still isn't understood — same
   as the original single-file-structure bug this file's own comments
   already document — so the fix is defensive rather than diagnosed:
   append the new include at the very end of the file instead of
   between two already-working ones, minimizing disturbance to
   whatever ordering/adjacency that loader is sensitive to.

2. When the same verification check fails, _pstn_ensure_live_includes
   now automatically falls back to a full container restart and
   re-checks, instead of just warning and leaving inbound calls
   broken until the operator finds this on their own — confirmed live
   this session that a restart (not just dialplan reload) reliably
   fixes a context that's genuinely new to the running process.

3. The SMS-over-SIP URI printout was appended only to
   install_pstn-trunk's fresh-install tail, which "update in place"
   never reaches (it returns early). Factored into
   _pstn_print_sms_over_sip_info and called from both paths, so it
   actually shows up on the far more common update-in-place run.
2026-07-27 01:22:13 +00:00
Outis 3b2ae2d344 Merge pull request #243 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Add SMS-over-SIP inbound (Anveo MESSAGE method), diagnostic-first
2026-07-26 21:15:11 -04:00
Claude f691181248 Add SMS-over-SIP inbound (Anveo MESSAGE method), diagnostic-first
Wires message_context=pstn-sms-inbound onto the trunk endpoint (same
pattern services/asterisk.sh already uses for internal SIP MESSAGE
texting — a dedicated context so an inbound MESSAGE doesn't fall back
to context=from-pstn-trunk, which already owns "_X." for INVITE-based
calls) and writes a new [pstn-sms-inbound] dialplan context.

That context deliberately only logs every plausible source for the
sender number, destination DID, and body (X-ANVEO-SMS-FROM/TO
headers, MESSAGE(from)/(to)/(body)) rather than attempting to parse
and route immediately — Anveo's own documentation for this feature
has already proven unreliable twice this session (a dead SMSbySMS
endpoint, and three mutually-contradicting docs pages for the general
API), so this waits for a real test message to confirm which fields
actually show up before building extraction/routing logic against
them.

The install summary now reads DOMAIN_NAME straight from Easy
Asterisk's own .env (the same FQDN already serving SIP for the voice
trunk) and prints the exact `smshandler@<domain>:5060` URI to paste
into the DID's SMS-over-SIP settings, plus how to watch for the first
test message in the Asterisk console.
2026-07-27 01:12:00 +00:00
Outis 5a5e14230b Merge pull request #242 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Fix the real cause of the persistent "1 extension edited" false positive
2026-07-26 19:20:41 -04: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
Outis 55f613dc4b Merge pull request #241 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Fix persistent "1 extension edited" false positive on page load
2026-07-26 18:56:12 -04: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
Outis 15ca8de452 Merge pull request #240 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Ring Groups: assign a personal DID right on the row, inherited by mem…
2026-07-26 17:47:50 -04: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
Outis e5cfdc7fb8 Merge pull request #239 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Add member checkboxes to Ring Group creation
2026-07-26 16:05:34 -04: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
Outis 25a4bd6302 Merge pull request #238 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Claude/asterisk caller id number y1b3ls
2026-07-26 13:53:05 -04: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
Outis 0903f814ac Merge pull request #237 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Remove Categories management from dashboard, keep Rooms
2026-07-25 22:15:29 -04: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
Outis 4be78aae4a Merge pull request #236 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Improve PSTN commit-changes UX, collapse Extensions, drop call-limit UI
2026-07-25 15:53:47 -04: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
Outis 3300025500 Merge pull request #235 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Revert unsafe from_user removal, use PAI/RPID for per-call outbound CID
2026-07-25 14:09:42 -04: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
Outis a79eea2032 Merge pull request #234 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Fix outbound PSTN caller ID always showing the shared trunk DID
2026-07-25 13:59:33 -04: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