Decouple internal SIP messaging management from PSTN trunk installation

Messaging has no dependency on a PSTN trunk existing (no cost, no
carrier, no DID), but the whole PSTN Trunk tab - including the messaging
checkbox added last commit - was hidden behind pstn_installed(), which
only becomes true once services/pstn-trunk.sh's dialplan is actually
wired in. That meant enabling messaging required going through full SIP
trunk/provider setup first for no real reason.

Added a standalone "Internal SIP messaging" card that's always visible in
the tab regardless of trunk status, backed by a new write_messaging()
that only touches the messaging key (leaving tier/allowed_numbers/
personal_did untouched) and creates pstn-permissions.conf from scratch if
it doesn't exist yet. Confirmed the systemd unit's ReadWritePaths and the
group/chmod grants already covered this - both are set up whenever a base
Asterisk install is detected, independent of pstn-trunk - so no
permission-layer changes were needed, only the dashboard's own artificial
UI gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
This commit is contained in:
Claude
2026-07-23 05:38:36 +00:00
parent 8291eba55e
commit 7851e2befd
2 changed files with 96 additions and 17 deletions
+4 -3
View File
@@ -1817,9 +1817,10 @@ 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}. 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.
Dashboard's "PSTN Trunk" tab, in its own always-available "Internal SIP
messaging" card — no need to re-run this installer, and no dependency on
this trunk (or any PSTN trunk at all) being installed, unlike the
calling-permissions table below it in that same tab.
**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,
+92 -14
View File
@@ -208,18 +208,22 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly.
- **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP
CrowdSec has ever recorded for that ASN, for accidental-whitelist cases
where you don't want to wait for it to misbehave again.
- **PSTN Trunk** — detects whether \`services/pstn-trunk.sh\`'s dialplan is
actually installed (\`pstn-trunk-dialplan.conf\` present) and shows a clear
"not installed" message instead of the caps/tiers editor if not, so it
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), 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 Trunk** — an "Internal SIP messaging" card at the top is always
available, whether or not a PSTN trunk has ever been installed: a
checkbox per known extension (parsed from \`pjsip.conf\`) for
Asterisk's native SIP texting, independent of PSTN calling entirely (no
cost, no carrier, no DID, no dependency on \`services/pstn-trunk.sh\`
having been run — see its "Known gap" note on messaging for what this
flag does and doesn't do yet at the Asterisk level). Below that, the
rest of the tab detects whether \`services/pstn-trunk.sh\`'s dialplan is
actually installed (\`pstn-trunk-dialplan.conf\` present) and shows a
clear "not installed" message instead of the calling-permissions editor
if not, so it never shows real-looking-but-unenforced defaults. When
installed: the outbound/inbound concurrent-call caps, and every known
extension's permission tier (internal / restricted / full) and, for
restricted, its approved numbers — 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
@@ -1077,6 +1081,37 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False):
return True, "Saved"
def write_messaging(ext, enabled):
"""Sets/clears just the messaging flag for one extension, leaving any
tier/allowed_numbers/personal_did untouched. This is the write path for
the standalone "Internal SIP messaging" card, which works whether or
not a PSTN trunk has ever been installed — messaging has no dependency
on one (no cost, no carrier, no DID), unlike the calling-permissions
table this dashboard otherwise gates behind pstn_installed(). Creates
pstn-permissions.conf from scratch if it doesn't exist yet."""
if not ASTERISK_CONFIG_DIR:
return False, "No Asterisk install detected on this box"
ext = str(ext).strip()
if not EXTEN_RE.match(ext):
return False, "Invalid extension"
cp = _read_permissions_cp()
if 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")
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
return True, "Saved"
LIMIT_RE = re.compile(r"^\d+$")
@@ -1327,9 +1362,17 @@ INDEX_HTML = """<!doctype html>
</div>
</div>
<div id="tab-pstn" style="display:none">
<div class="card">
<h3 style="margin-top:0">Internal SIP messaging</h3>
<p class="muted">
Asterisk's native SIP texting between extensions — no carrier SMS, no PSTN, no cost, and no dependency on a PSTN trunk being installed at all. Independent of the calling permissions below. 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.
</p>
<table id="msg-table"><thead><tr><th>Ext</th><th>Name</th><th>Enabled</th><th></th></tr></thead><tbody></tbody></table>
<div id="msg-msg" class="muted" style="margin-top:0.5rem"></div>
</div>
<div class="card" id="pstn-not-installed" style="display:none">
<h3 style="margin-top:0">PSTN trunk not installed</h3>
<p class="muted">No PSTN trunk dialplan was found on this box — <code>sudo ./setup.sh pstn-trunk</code> hasn't been run (or its config was removed). Nothing below is enforced yet; install it first, then this tab will manage the real permission tiers and concurrency caps.</p>
<p class="muted">No PSTN trunk dialplan was found on this box — <code>sudo ./setup.sh pstn-trunk</code> hasn't been run (or its config was removed). The calling permissions/caps/personal-numbers below aren't enforced yet; install it first to use them. Internal SIP messaging above works independently of this.</p>
</div>
<div id="pstn-installed-cards" style="display:none">
<div class="card">
@@ -1381,7 +1424,7 @@ document.querySelectorAll(".tab-btn").forEach(btn => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; });
if (btn.dataset.tab === "pstn") { loadPstnStatus(); }
if (btn.dataset.tab === "pstn") { loadPstnStatus(); loadMessaging(); }
});
});
@@ -1544,6 +1587,38 @@ async function loadPstnLimits() {
document.getElementById("limit-in").value = data.max_inbound;
}
// Independent of pstn_installed() — messaging has no dependency on a PSTN
// trunk existing, unlike everything else in this tab, so this loads/saves
// regardless of whether services/pstn-trunk.sh has ever been run.
async function loadMessaging() {
const res = await fetch("/api/pstn-permissions");
const data = await res.json();
const exts = data.extensions || [];
const tbody = document.querySelector("#msg-table tbody");
if (!exts.length) {
tbody.innerHTML = '<tr><td colspan=4 class=muted>No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).</td></tr>';
return;
}
tbody.innerHTML = exts.map(e => `<tr data-ext="${esc(e.ext)}">
<td>${esc(e.ext)}</td>
<td>${esc(e.name)}</td>
<td style="text-align:center"><input type="checkbox" class="msg-enabled" ${e.messaging ? "checked" : ""}></td>
<td><button class="action" onclick="saveMessagingRow('${esc(e.ext)}')">Save</button></td>
</tr>`).join("");
}
async function saveMessagingRow(ext) {
const row = document.querySelector(`#msg-table tr[data-ext="${ext}"]`);
const enabled = row.querySelector(".msg-enabled").checked;
const res = await fetch("/api/pstn-messaging", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({ext: ext, enabled: enabled}),
});
const data = await res.json();
document.getElementById("msg-msg").textContent = (data.message || (data.ok ? "Saved" : "Failed")) + " (extension " + ext + ")";
loadMessaging();
}
document.getElementById("limits-save").addEventListener("click", async () => {
const maxOut = document.getElementById("limit-out").value;
const maxIn = document.getElementById("limit-in").value;
@@ -1740,6 +1815,9 @@ class Handler(BaseHTTPRequestHandler):
elif self.path == "/api/pstn-personal-dids/delete":
ok, message = remove_personal_did(payload.get("did", ""))
self._json({"ok": ok, "message": message})
elif self.path == "/api/pstn-messaging":
ok, message = write_messaging(payload.get("ext", ""), bool(payload.get("enabled", False)))
self._json({"ok": ok, "message": message})
else:
self._json({"error": "not found"}, 404)