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:
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user