Make the messaging permission flag live-editable via the dashboard

get_all_permissions()/write_permission() now handle messaging alongside
tier/allowed_numbers as one save action, and the Security Dashboard's PSTN
Trunk table gets a Messaging checkbox column - no more needing to re-run
pstn-trunk.sh's CLI just to change who can use internal SIP texting,
matching how tier/allowed_numbers/personal_did already worked.

Tested that messaging correctly survives tier changes and personal-DID
assignment/removal on the same extension (independent axes, as intended).

Still explicitly not done, and said so in both READMEs rather than
implying otherwise now that there's a nice UI for it: the actual SIP
MESSAGE dialplan wiring that would make Asterisk enforce this flag. That
gap hasn't changed - only the permission storage/UI layer around it has.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
This commit is contained in:
Claude
2026-07-23 05:27:58 +00:00
parent 3705fb5fcc
commit 8291eba55e
3 changed files with 83 additions and 38 deletions
+9 -2
View File
@@ -416,8 +416,15 @@ generator output. Fixed by quoting every value in that heredoc.
`messaging=yes` flag per extension in `pstn-permissions.conf`, `messaging=yes` flag per extension in `pstn-permissions.conf`,
independent of the PSTN calling tiers (an extension can be independent of the PSTN calling tiers (an extension can be
internal-tier for calling and still messaging-enabled, or vice versa), internal-tier for calling and still messaging-enabled, or vice versa),
prompted at install time. **Not done**: the actual dialplan wiring that prompted at install time AND now a checkbox right in the Security
would make Asterisk *enforce* this flag on inbound `MESSAGE` requests. Dashboard's PSTN Trunk permissions table (alongside tier/approved-
numbers) — no need to re-run the CLI installer just to change who can
message. Confirmed it correctly survives tier changes and personal-DID
assignment/removal on the same extension (this is what surfaced the
tier=internal section-wipe bug fixed above). **Not done**: the actual
dialplan wiring that would make Asterisk *enforce* this flag on
inbound `MESSAGE` requests — this flag currently does nothing at the
Asterisk level yet, it's groundwork.
Reasoned through but deliberately not shipped: Easy Asterisk dispatches Reasoned through but deliberately not shipped: Easy Asterisk dispatches
messages through the same `[intercom]` context calls use (no messages through the same `[intercom]` context calls use (no
`message_context` override), and whether a hand-written pattern there `message_context` override), and whether a hand-written pattern there
+22 -10
View File
@@ -1816,17 +1816,29 @@ Asterisk's native SIP \`MESSAGE\` support (extension-to-extension texting —
no carrier SMS, no PSTN, no cost) is gated by a \`messaging=yes\` flag per no carrier SMS, no PSTN, no cost) is gated by a \`messaging=yes\` flag per
extension in \`pstn-permissions.conf\`, independent of the PSTN calling extension in \`pstn-permissions.conf\`, independent of the PSTN calling
tiers above — off by default, same "opt in" posture. Currently enabled for: tiers above — off by default, same "opt in" posture. Currently enabled for:
${MESSAGING_EXTS:-none}. ${MESSAGING_EXTS:-none}. Live-editable any time via the Security
Dashboard's "PSTN Trunk" tab (a checkbox per extension, right in the same
table as the calling tiers) — no need to re-run this installer just to
change who can message.
**Known gap:** this installer writes the permission flag (live-editable, **Won't show up in Easy Asterisk's own web admin, by design** — same as
same mechanism as the calling tiers), but the actual SIP \`MESSAGE\` routing the PSTN calling tiers, this is a permission this repo layers on top,
dialplan wiring depends on how Easy Asterisk's own generated not an Easy Asterisk feature, so it's only manageable here or via the
\`extensions.conf\`/\`pjsip.conf\` route inbound messages, which needs to be Security Dashboard.
verified against a live install before it's safely automated here — shipping
a guessed pattern risked either silently not working or interfering with **Known gap:** the flag above is real and live-editable, but the actual
call-routing precedence in the same \`[intercom]\` context. Treat the SIP \`MESSAGE\` routing dialplan wiring — does Asterisk actually deliver/
permission flag as ready for a dashboard/CLI-managed allow-list once that gate a message using this flag — depends on how Easy Asterisk's own
routing is confirmed, not as fully wired yet. generated \`extensions.conf\`/\`pjsip.conf\` route inbound messages, which
needs to be verified against a live install before it's safely automated
here. Shipping a guessed pattern risked either silently not working or
interfering with call-routing precedence in the same \`[intercom]\`
context, so it hasn't been guessed at. If you want this working end to
end, the fastest path is checking a few things on a live box (e.g.
whether an endpoint has \`message_context\` set, and what happens when you
send a test SIP MESSAGE to one) so the dialplan gate can be built against
real behavior instead of assumption — ask if you want to walk through
that.
## Personal numbers ## Personal numbers
+52 -26
View File
@@ -214,13 +214,17 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly.
never shows real-looking-but-unenforced defaults. When installed: the never shows real-looking-but-unenforced defaults. When installed: the
outbound/inbound concurrent-call caps, and every known extension (parsed outbound/inbound concurrent-call caps, and every known extension (parsed
from \`pjsip.conf\`) with its current permission tier (internal / from \`pjsip.conf\`) with its current permission tier (internal /
restricted / full) and, for restricted, its approved numbers — all restricted / full), for restricted its approved numbers, and its
editable live, no Asterisk restart, no reinstall. Writes directly to internal-SIP-messaging flag (independent of the calling tier — see
\`pstn-limits.conf\` / \`pstn-permissions.conf\`, which the dialplan reads \`services/pstn-trunk.sh\`'s "Known gap" note on messaging for what this
fresh on every call. The spend-cap kill-switch and international-calling flag does and doesn't do yet) — all editable live, no Asterisk restart,
allow-list are deliberately **not** managed here — CLI-only, via no reinstall. Also manages personal-number assignments (DID -> owner
\`sudo ./setup.sh pstn-trunk\` — since both are more security-sensitive extension), additive to the shared trunk DID. Writes directly to
than what this tab already exposes. \`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`,
which the dialplan reads fresh on every call. The spend-cap kill-switch
and international-calling allow-list are deliberately **not** managed
here — CLI-only, via \`sudo ./setup.sh pstn-trunk\` — since both are more
security-sensitive than what this tab already exposes.
- Link to the Asterisk web admin itself (doesn't embed it, just links out). - Link to the Asterisk web admin itself (doesn't embed it, just links out).
## Manage ## Manage
@@ -986,12 +990,13 @@ def _read_permissions_cp():
def get_all_permissions(): def get_all_permissions():
"""{ext: {"tier": ..., "allowed_numbers": "num|num|..."}} for every """{ext: {"tier": ..., "allowed_numbers": "num|num|...", "messaging":
extension with a non-internal tier on record. Extensions with no section bool}} for every extension with a non-default record. Extensions with
are implicitly "internal" — the dialplan's AST_CONFIG() lookup treats a no section are implicitly "internal"/messaging-disabled — the
missing section as empty/denied the same way, so there's nothing to dialplan's AST_CONFIG() lookup treats a missing section/key as empty/
return for them here; the UI fills in "internal" as the default for any denied the same way, so there's nothing to return for them here; the
known extension (from list_extensions()) not present in this dict.""" UI fills in the defaults for any known extension (from
list_extensions()) not present in this dict."""
cp = _read_permissions_cp() cp = _read_permissions_cp()
result = {} result = {}
for section in cp.sections(): for section in cp.sections():
@@ -1000,12 +1005,17 @@ def get_all_permissions():
result[section] = { result[section] = {
"tier": cp.get(section, "tier", fallback="internal"), "tier": cp.get(section, "tier", fallback="internal"),
"allowed_numbers": cp.get(section, "allowed_numbers", fallback=""), "allowed_numbers": cp.get(section, "allowed_numbers", fallback=""),
"messaging": cp.getboolean(section, "messaging", fallback=False),
} }
return result return result
def write_permission(ext, tier, numbers_raw): def write_permission(ext, tier, numbers_raw, messaging_enabled=False):
"""Saves one extension's tier + (for restricted) approved-number list. """Saves one extension's tier + (for restricted) approved-number list +
messaging flag in one action — messaging is an independent axis from
the calling tier (see pstn-trunk.sh's file-level comment: an extension
can be internal-tier for calling and still messaging-enabled, or vice
versa), so it's set/cleared regardless of which tier branch runs below.
Numbers are normalized to a pipe-separated list of 11-digit US numbers — Numbers are normalized to a pipe-separated list of 11-digit US numbers —
pipe, not comma, because the dialplan uses this value directly as a pipe, not comma, because the dialplan uses this value directly as a
REGEX() alternation pattern (see services/pstn-trunk.sh's file-level REGEX() alternation pattern (see services/pstn-trunk.sh's file-level
@@ -1030,15 +1040,12 @@ def write_permission(ext, tier, numbers_raw):
# personal_did assigned, and those must survive a tier change back # personal_did assigned, and those must survive a tier change back
# to internal. Confirmed live as a real bug: cp.remove_section(ext) # to internal. Confirmed live as a real bug: cp.remove_section(ext)
# here used to silently discard both whenever tier was set to # here used to silently discard both whenever tier was set to
# internal. Only remove the section itself once nothing else is # internal.
# left in it.
if cp.has_section(ext): if cp.has_section(ext):
if cp.has_option(ext, "tier"): if cp.has_option(ext, "tier"):
cp.remove_option(ext, "tier") cp.remove_option(ext, "tier")
if cp.has_option(ext, "allowed_numbers"): if cp.has_option(ext, "allowed_numbers"):
cp.remove_option(ext, "allowed_numbers") cp.remove_option(ext, "allowed_numbers")
if not cp.options(ext):
cp.remove_section(ext)
else: else:
if not cp.has_section(ext): if not cp.has_section(ext):
cp.add_section(ext) cp.add_section(ext)
@@ -1048,6 +1055,19 @@ def write_permission(ext, tier, numbers_raw):
elif cp.has_option(ext, "allowed_numbers"): elif cp.has_option(ext, "allowed_numbers"):
cp.remove_option(ext, "allowed_numbers") cp.remove_option(ext, "allowed_numbers")
if messaging_enabled:
if not cp.has_section(ext):
cp.add_section(ext)
cp.set(ext, "messaging", "yes")
elif cp.has_section(ext) and cp.has_option(ext, "messaging"):
cp.remove_option(ext, "messaging")
# Drop the section entirely once nothing (tier, numbers, messaging,
# personal_did) is left in it — only reached this way when tier is
# internal, messaging is off, and no personal_did was ever assigned.
if cp.has_section(ext) and not cp.options(ext):
cp.remove_section(ext)
ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, cp) ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, cp)
if not ok: if not ok:
return False, err return False, err
@@ -1330,7 +1350,10 @@ INDEX_HTML = """<!doctype html>
<b>full</b> — internal, plus any US number. <b>full</b> — internal, plus any US number.
Changes apply live, on the next call — no Asterisk restart needed. Changes apply live, on the next call — no Asterisk restart needed.
</p> </p>
<table id="pstn-table"><thead><tr><th>Ext</th><th>Name</th><th>Tier</th><th>Approved numbers (restricted only)</th><th></th></tr></thead><tbody></tbody></table> <p class="muted">
<b>Messaging</b> — Asterisk's native internal SIP texting (no carrier SMS, no PSTN, no cost), independent of the calling tier. Note: this flag is live-editable here, but whether Asterisk actually delivers/gates messages using it depends on dialplan wiring not yet verified against a live install — see this service's README.
</p>
<table id="pstn-table"><thead><tr><th>Ext</th><th>Name</th><th>Tier</th><th>Approved numbers (restricted only)</th><th>Messaging</th><th></th></tr></thead><tbody></tbody></table>
<div id="pstn-msg" class="muted" style="margin-top:0.5rem"></div> <div id="pstn-msg" class="muted" style="margin-top:0.5rem"></div>
</div> </div>
<div class="card"> <div class="card">
@@ -1544,7 +1567,7 @@ async function loadPstnPermissions() {
const tbody = document.querySelector("#pstn-table tbody"); const tbody = document.querySelector("#pstn-table tbody");
if (!exts.length) { if (!exts.length) {
tbody.innerHTML = '<tr><td colspan=5 class=muted>No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).</td></tr>'; tbody.innerHTML = '<tr><td colspan=6 class=muted>No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).</td></tr>';
return; return;
} }
tbody.innerHTML = exts.map(e => `<tr data-ext="${esc(e.ext)}"> tbody.innerHTML = exts.map(e => `<tr data-ext="${esc(e.ext)}">
@@ -1558,6 +1581,7 @@ async function loadPstnPermissions() {
</select> </select>
</td> </td>
<td><input type="text" class="pstn-numbers" value="${esc(e.allowed_numbers)}" placeholder="15551234567,15559876543" ${e.tier === "restricted" ? "" : "disabled"}></td> <td><input type="text" class="pstn-numbers" value="${esc(e.allowed_numbers)}" placeholder="15551234567,15559876543" ${e.tier === "restricted" ? "" : "disabled"}></td>
<td style="text-align:center"><input type="checkbox" class="pstn-messaging" ${e.messaging ? "checked" : ""}></td>
<td><button class="action" onclick="savePstnPermission('${esc(e.ext)}')">Save</button></td> <td><button class="action" onclick="savePstnPermission('${esc(e.ext)}')">Save</button></td>
</tr>`).join(""); </tr>`).join("");
@@ -1572,9 +1596,10 @@ async function savePstnPermission(ext) {
const row = document.querySelector(`#pstn-table tr[data-ext="${ext}"]`); const row = document.querySelector(`#pstn-table tr[data-ext="${ext}"]`);
const tier = row.querySelector(".pstn-tier").value; const tier = row.querySelector(".pstn-tier").value;
const numbers = row.querySelector(".pstn-numbers").value; const numbers = row.querySelector(".pstn-numbers").value;
const messaging = row.querySelector(".pstn-messaging").checked;
const res = await fetch("/api/pstn-permissions", { const res = await fetch("/api/pstn-permissions", {
method: "POST", headers: {"Content-Type": "application/json"}, method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({ext: ext, tier: tier, allowed_numbers: numbers}), body: JSON.stringify({ext: ext, tier: tier, allowed_numbers: numbers, messaging: messaging}),
}); });
const data = await res.json(); const data = await res.json();
document.getElementById("pstn-msg").textContent = (data.message || (data.ok ? "Saved" : "Failed")) + " (extension " + ext + ")"; document.getElementById("pstn-msg").textContent = (data.message || (data.ok ? "Saved" : "Failed")) + " (extension " + ext + ")";
@@ -1669,9 +1694,9 @@ class Handler(BaseHTTPRequestHandler):
perms = get_all_permissions() perms = get_all_permissions()
extensions = [] extensions = []
for e in list_extensions(): for e in list_extensions():
p = perms.get(e["ext"], {"tier": "internal", "allowed_numbers": ""}) p = perms.get(e["ext"], {"tier": "internal", "allowed_numbers": "", "messaging": False})
extensions.append({"ext": e["ext"], "name": e["name"], extensions.append({"ext": e["ext"], "name": e["name"], "tier": p["tier"],
"tier": p["tier"], "allowed_numbers": p["allowed_numbers"]}) "allowed_numbers": p["allowed_numbers"], "messaging": p["messaging"]})
self._json({"extensions": extensions}) self._json({"extensions": extensions})
elif self.path == "/api/pstn-limits": elif self.path == "/api/pstn-limits":
self._json(get_limits()) self._json(get_limits())
@@ -1702,7 +1727,8 @@ class Handler(BaseHTTPRequestHandler):
self._json(ban_asn(payload.get("asn", ""))) self._json(ban_asn(payload.get("asn", "")))
elif self.path == "/api/pstn-permissions": elif self.path == "/api/pstn-permissions":
ok, message = write_permission( ok, message = write_permission(
payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", "") payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", ""),
bool(payload.get("messaging", False))
) )
self._json({"ok": ok, "message": message}) self._json({"ok": ok, "message": message})
elif self.path == "/api/pstn-limits": elif self.path == "/api/pstn-limits":