diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index c6b54d4..ec6b488 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -416,8 +416,15 @@ generator output. Fixed by quoting every value in that heredoc. `messaging=yes` flag per extension in `pstn-permissions.conf`, independent of the PSTN calling tiers (an extension can be internal-tier for calling and still messaging-enabled, or vice versa), - prompted at install time. **Not done**: the actual dialplan wiring that - would make Asterisk *enforce* this flag on inbound `MESSAGE` requests. + prompted at install time AND now a checkbox right in the Security + 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 messages through the same `[intercom]` context calls use (no `message_context` override), and whether a hand-written pattern there diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index a2f1978..10dec8c 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -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 extension in \`pstn-permissions.conf\`, independent of the PSTN calling 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, -same mechanism as the calling tiers), but the actual SIP \`MESSAGE\` routing -dialplan wiring depends on how Easy Asterisk's own 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. Treat the -permission flag as ready for a dashboard/CLI-managed allow-list once that -routing is confirmed, not as fully wired yet. +**Won't show up in Easy Asterisk's own web admin, by design** — same as +the PSTN calling tiers, this is a permission this repo layers on top, +not an Easy Asterisk feature, so it's only manageable here or via the +Security Dashboard. + +**Known gap:** the flag above is real and live-editable, but the actual +SIP \`MESSAGE\` routing dialplan wiring — does Asterisk actually deliver/ +gate a message using this flag — depends on how Easy Asterisk's own +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 diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 6bb851a..c58328b 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -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 outbound/inbound concurrent-call caps, and every known extension (parsed from \`pjsip.conf\`) with its current permission tier (internal / - restricted / full) and, for restricted, its approved numbers — all - editable live, no Asterisk restart, no reinstall. Writes directly to - \`pstn-limits.conf\` / \`pstn-permissions.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. + restricted / full), for restricted its approved numbers, and its + internal-SIP-messaging flag (independent of the calling tier — see + \`services/pstn-trunk.sh\`'s "Known gap" note on messaging for what this + flag does and doesn't do yet) — all editable live, no Asterisk restart, + no reinstall. Also manages personal-number assignments (DID -> owner + extension), additive to the shared trunk DID. Writes directly to + \`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). ## Manage @@ -986,12 +990,13 @@ def _read_permissions_cp(): def get_all_permissions(): - """{ext: {"tier": ..., "allowed_numbers": "num|num|..."}} for every - extension with a non-internal tier on record. Extensions with no section - are implicitly "internal" — the dialplan's AST_CONFIG() lookup treats a - missing section as empty/denied the same way, so there's nothing to - return for them here; the UI fills in "internal" as the default for any - known extension (from list_extensions()) not present in this dict.""" + """{ext: {"tier": ..., "allowed_numbers": "num|num|...", "messaging": + bool}} for every extension with a non-default record. Extensions with + no section are implicitly "internal"/messaging-disabled — the + dialplan's AST_CONFIG() lookup treats a missing section/key as empty/ + denied the same way, so there's nothing to return for them here; the + UI fills in the defaults for any known extension (from + list_extensions()) not present in this dict.""" cp = _read_permissions_cp() result = {} for section in cp.sections(): @@ -1000,12 +1005,17 @@ def get_all_permissions(): result[section] = { "tier": cp.get(section, "tier", fallback="internal"), "allowed_numbers": cp.get(section, "allowed_numbers", fallback=""), + "messaging": cp.getboolean(section, "messaging", fallback=False), } return result -def write_permission(ext, tier, numbers_raw): - """Saves one extension's tier + (for restricted) approved-number list. +def write_permission(ext, tier, numbers_raw, messaging_enabled=False): + """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 — pipe, not comma, because the dialplan uses this value directly as a 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 # to internal. Confirmed live as a real bug: cp.remove_section(ext) # here used to silently discard both whenever tier was set to - # internal. Only remove the section itself once nothing else is - # left in it. + # internal. if cp.has_section(ext): if cp.has_option(ext, "tier"): cp.remove_option(ext, "tier") if cp.has_option(ext, "allowed_numbers"): cp.remove_option(ext, "allowed_numbers") - if not cp.options(ext): - cp.remove_section(ext) else: if not cp.has_section(ext): cp.add_section(ext) @@ -1048,6 +1055,19 @@ def write_permission(ext, tier, numbers_raw): elif cp.has_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) if not ok: return False, err @@ -1330,7 +1350,10 @@ INDEX_HTML = """ full — internal, plus any US number. Changes apply live, on the next call — no Asterisk restart needed.

-
ExtNameTierApproved numbers (restricted only)
+

+ Messaging — 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. +

+
ExtNameTierApproved numbers (restricted only)Messaging
@@ -1544,7 +1567,7 @@ async function loadPstnPermissions() { const tbody = document.querySelector("#pstn-table tbody"); if (!exts.length) { - tbody.innerHTML = 'No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).'; + tbody.innerHTML = 'No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).'; return; } tbody.innerHTML = exts.map(e => ` @@ -1558,6 +1581,7 @@ async function loadPstnPermissions() { + `).join(""); @@ -1572,9 +1596,10 @@ async function savePstnPermission(ext) { const row = document.querySelector(`#pstn-table tr[data-ext="${ext}"]`); const tier = row.querySelector(".pstn-tier").value; const numbers = row.querySelector(".pstn-numbers").value; + const messaging = row.querySelector(".pstn-messaging").checked; const res = await fetch("/api/pstn-permissions", { 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(); document.getElementById("pstn-msg").textContent = (data.message || (data.ok ? "Saved" : "Failed")) + " (extension " + ext + ")"; @@ -1669,9 +1694,9 @@ class Handler(BaseHTTPRequestHandler): perms = get_all_permissions() extensions = [] for e in list_extensions(): - p = perms.get(e["ext"], {"tier": "internal", "allowed_numbers": ""}) - extensions.append({"ext": e["ext"], "name": e["name"], - "tier": p["tier"], "allowed_numbers": p["allowed_numbers"]}) + p = perms.get(e["ext"], {"tier": "internal", "allowed_numbers": "", "messaging": False}) + extensions.append({"ext": e["ext"], "name": e["name"], "tier": p["tier"], + "allowed_numbers": p["allowed_numbers"], "messaging": p["messaging"]}) self._json({"extensions": extensions}) elif self.path == "/api/pstn-limits": self._json(get_limits()) @@ -1702,7 +1727,8 @@ class Handler(BaseHTTPRequestHandler): self._json(ban_asn(payload.get("asn", ""))) elif self.path == "/api/pstn-permissions": 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}) elif self.path == "/api/pstn-limits":