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
This commit is contained in:
+174
-159
@@ -226,17 +226,17 @@ which tab a given extension's settings live on.
|
||||
- **Extensions** — one row per extension, merged from \`pjsip.conf\` (which
|
||||
always works) and, when the Easy Asterisk container is reachable, its own
|
||||
device list. Columns: Ext, Name, then Category/Status/Transport if that
|
||||
container is present, then four PSTN columns if a trunk dialplan is
|
||||
installed — **Outbound** tier + "Can dial", and **Inbound** tier + "Can be
|
||||
called by" — then Messaging (always: internal SIP texting has no PSTN
|
||||
container is present, then **PSTN** + **Whitelist** if a trunk dialplan is
|
||||
installed, then Messaging (always: internal SIP texting has no PSTN
|
||||
dependency at all — no cost, no carrier, no DID).
|
||||
|
||||
The two PSTN directions are independent. Each is \`internal\` (no PSTN that
|
||||
way), \`restricted\` (only the numbers beside it) or \`full\` (any US
|
||||
number), and each has its own list, because they hold different things:
|
||||
"Can dial" is numbers this extension may call, "Can be called by" is caller
|
||||
IDs allowed to reach it. Internal extension-to-extension calling and ring
|
||||
groups are never gated by either.
|
||||
Each extension has one whitelist and a mode saying which direction(s) it
|
||||
applies to: **No PSTN**, **Unrestricted**, **Restrict outbound** (may only
|
||||
dial the list, anyone can call in), **Restrict inbound** (may dial
|
||||
anywhere, only the list can call in), or **Restrict both**. The whitelist
|
||||
field greys out for the two modes that don't use one. Internal
|
||||
extension-to-extension calling and ring groups are never gated by any of
|
||||
this.
|
||||
|
||||
Name and Category are edited **in place**; every cell feeds one batched
|
||||
save. Rows you've touched get a highlight and a left rail, a sticky bar
|
||||
@@ -1142,11 +1142,13 @@ def _write_ini_cp(path, header, cp):
|
||||
|
||||
|
||||
PERMISSIONS_HEADER = (
|
||||
"; PSTN permission tiers - internal / restricted / full - set SEPARATELY\n"
|
||||
"; per direction: tier_out/allowed_out gate what an extension may DIAL,\n"
|
||||
"; tier_in/allowed_in gate which CALLER IDs may reach it. 'tier' and\n"
|
||||
"; 'allowed_numbers' mirror the outbound values so a rollback to a\n"
|
||||
"; pre-split pstn-trunk.sh still works; nothing reads them otherwise.\n"
|
||||
"; PSTN permissions. Each extension has ONE whitelist (allowed_numbers)\n"
|
||||
"; and a 'restrict' mode saying which direction(s) it applies to:\n"
|
||||
"; none / open / out (may only dial the list) / in (may only be called\n"
|
||||
"; by the list) / both.\n"
|
||||
"; restrict + allowed_numbers are the authored pair; tier_out/allowed_out\n"
|
||||
"; and tier_in/allowed_in are DERIVED from them and are what the dialplan\n"
|
||||
"; reads; 'tier' is a rollback mirror for a pre-split pstn-trunk.sh.\n"
|
||||
"; PLUS two\n"
|
||||
"; independent per-extension axes: messaging (internal SIP MESSAGE\n"
|
||||
"; texting) and personal_did (outbound Caller-ID override; inbound\n"
|
||||
@@ -1185,25 +1187,45 @@ def _read_permissions_cp():
|
||||
return cp
|
||||
|
||||
|
||||
RESTRICT_RE = re.compile(r"^(none|open|out|in|both)$")
|
||||
|
||||
|
||||
def _derive_restrict(tier_out, tier_in):
|
||||
"""Infer the authored mode from the derived per-direction tiers.
|
||||
|
||||
Only needed for a config written before 'restrict' existed. Fails toward
|
||||
the more restrictive reading: anything that isn't clearly open in a
|
||||
direction is treated as restricted or none, never widened."""
|
||||
if tier_out == "full" and tier_in == "full":
|
||||
return "open"
|
||||
if tier_out == "restricted" and tier_in == "restricted":
|
||||
return "both"
|
||||
if tier_out == "restricted":
|
||||
return "out"
|
||||
if tier_in == "restricted":
|
||||
return "in"
|
||||
return "none"
|
||||
|
||||
|
||||
def get_all_permissions():
|
||||
"""{ext: {"tier_out", "allowed_out", "tier_in", "allowed_in",
|
||||
"messaging"}} for every extension with a non-default record.
|
||||
"""{ext: {"restrict", "allowed_numbers", "messaging"}} for every extension
|
||||
with a non-default record.
|
||||
|
||||
Outbound and inbound are independent axes: tier_out/allowed_out gate what
|
||||
an extension may DIAL, tier_in/allowed_in gate which CALLER IDs may reach
|
||||
it (ring-group membership and personal-DID routing). That's what makes
|
||||
"dial anyone, only accept calls from a list" and "answer anyone, only
|
||||
dial a list" both expressible.
|
||||
Each extension has ONE whitelist and a mode saying which direction(s) it
|
||||
applies to: none (no PSTN), open (unrestricted), out (may only dial the
|
||||
list), in (may only be called by the list), both. That authored pair is
|
||||
what this returns and what the UI edits; the dialplan reads the derived
|
||||
tier_out/allowed_out/tier_in/allowed_in that write_permission keeps in
|
||||
step with it.
|
||||
|
||||
Falls back to the pre-split 'tier'/'allowed_numbers' keys for either
|
||||
direction that hasn't been migrated yet, so the UI reads correctly even
|
||||
on a box where services/pstn-trunk.sh hasn't been re-run — the migration
|
||||
itself lives there, not here.
|
||||
Older configs are read by deriving the mode from whatever they do have —
|
||||
the per-direction tiers, or the pre-split single 'tier' — so the UI is
|
||||
correct even on a box where services/pstn-trunk.sh hasn't been re-run.
|
||||
|
||||
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; the UI
|
||||
fills in defaults for any known extension not present in this dict."""
|
||||
Extensions with no section are implicitly no-PSTN/messaging-disabled: the
|
||||
dialplan's AST_CONFIG() lookup treats a missing section as denied the same
|
||||
way, so there's nothing to return for them; the UI fills in defaults for
|
||||
any known extension not present here."""
|
||||
cp = _read_permissions_cp()
|
||||
result = {}
|
||||
for section in cp.sections():
|
||||
@@ -1211,101 +1233,100 @@ def get_all_permissions():
|
||||
continue
|
||||
legacy_tier = cp.get(section, "tier", fallback="internal")
|
||||
legacy_nums = cp.get(section, "allowed_numbers", fallback="")
|
||||
restrict = cp.get(section, "restrict", fallback="")
|
||||
if not RESTRICT_RE.match(restrict):
|
||||
restrict = _derive_restrict(
|
||||
cp.get(section, "tier_out", fallback=legacy_tier),
|
||||
cp.get(section, "tier_in", fallback=legacy_tier),
|
||||
)
|
||||
numbers = legacy_nums
|
||||
if not numbers:
|
||||
numbers = (cp.get(section, "allowed_out", fallback="")
|
||||
or cp.get(section, "allowed_in", fallback=""))
|
||||
result[section] = {
|
||||
"tier_out": cp.get(section, "tier_out", fallback=legacy_tier),
|
||||
"allowed_out": cp.get(section, "allowed_out", fallback=legacy_nums),
|
||||
"tier_in": cp.get(section, "tier_in", fallback=legacy_tier),
|
||||
"allowed_in": cp.get(section, "allowed_in", fallback=legacy_nums),
|
||||
"restrict": restrict,
|
||||
"allowed_numbers": numbers,
|
||||
"messaging": cp.getboolean(section, "messaging", fallback=False),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _apply_direction(cp, ext, prefix, tier, numbers_raw):
|
||||
"""Write one direction's tier + number list, returning the cleaned list.
|
||||
|
||||
prefix is "out" or "in". Setting a direction to internal drops only that
|
||||
direction's two keys, never the whole section — an extension can
|
||||
independently carry messaging=yes, a personal_did, and a permissive
|
||||
setting in the OTHER direction, all of which must survive. (Removing the
|
||||
section wholesale here was a real, confirmed bug in the single-tier
|
||||
version of this function.)"""
|
||||
tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip())
|
||||
clean = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n]
|
||||
joined = "|".join(clean)
|
||||
|
||||
tier_key = "tier_" + prefix
|
||||
nums_key = "allowed_" + prefix
|
||||
|
||||
if tier == "internal":
|
||||
if cp.has_section(ext):
|
||||
for key in (tier_key, nums_key):
|
||||
if cp.has_option(ext, key):
|
||||
cp.remove_option(ext, key)
|
||||
return clean
|
||||
|
||||
if not cp.has_section(ext):
|
||||
cp.add_section(ext)
|
||||
cp.set(ext, tier_key, tier)
|
||||
if tier == "restricted":
|
||||
cp.set(ext, nums_key, joined)
|
||||
elif cp.has_option(ext, nums_key):
|
||||
cp.remove_option(ext, nums_key)
|
||||
return clean
|
||||
# Which per-direction tiers each authored mode compiles down to. The dialplan
|
||||
# only ever reads the compiled keys; this table is the single place the
|
||||
# mapping is defined.
|
||||
_RESTRICT_TIERS = {
|
||||
"none": ("internal", "internal"),
|
||||
"open": ("full", "full"),
|
||||
"out": ("restricted", "full"),
|
||||
"in": ("full", "restricted"),
|
||||
"both": ("restricted", "restricted"),
|
||||
}
|
||||
|
||||
|
||||
def write_permission(ext, tier_out, allowed_out_raw, tier_in, allowed_in_raw,
|
||||
messaging_enabled=False):
|
||||
"""Saves one extension's outbound tier + numbers, inbound tier + numbers,
|
||||
and messaging flag in one action.
|
||||
def _set_or_clear(cp, ext, key, value):
|
||||
if value:
|
||||
cp.set(ext, key, value)
|
||||
elif cp.has_option(ext, key):
|
||||
cp.remove_option(ext, key)
|
||||
|
||||
The two directions are independent: allowed_out holds numbers this
|
||||
extension may DIAL, allowed_in holds CALLER IDs allowed to reach it. They
|
||||
used to be one field serving both, which made "outbound to anyone,
|
||||
inbound from a short list" impossible to express.
|
||||
|
||||
Messaging is a third independent axis (see pstn-trunk.sh's file-level
|
||||
comment: an extension can be internal for calling and still
|
||||
messaging-enabled, or vice versa), so it's set/cleared regardless of
|
||||
either tier.
|
||||
def write_permission(ext, restrict, numbers_raw, messaging_enabled=False):
|
||||
"""Saves one extension's PSTN restriction mode, its single whitelist, and
|
||||
its messaging flag in one action.
|
||||
|
||||
One list, not two: the whitelist is "the numbers this extension deals
|
||||
with", and the mode says whether that constrains dialling out, being
|
||||
called, or both. Modes are none / open / out / in / both.
|
||||
|
||||
Writes three layers, all derived from those two authored values:
|
||||
restrict, allowed_numbers what a human edits (and what this reads back)
|
||||
tier_out/allowed_out,
|
||||
tier_in/allowed_in what the dialplan reads
|
||||
tier, allowed_numbers rollback mirror for a pre-split installer
|
||||
|
||||
Messaging is an independent axis (see pstn-trunk.sh's file-level comment:
|
||||
an extension can have no PSTN at all and still be messaging-enabled, or
|
||||
vice versa), so it's set/cleared regardless of the mode.
|
||||
|
||||
Numbers normalize to a pipe-separated list of 11-digit US numbers (a bare
|
||||
10-digit entry gains a leading "1" rather than being dropped — see
|
||||
_normalize_nanp_number). Pipe, not comma, because the dialplan uses the
|
||||
value directly as a REGEX() alternation — see services/pstn-trunk.sh on
|
||||
why untrusted call data is always the string being tested, never
|
||||
interpolated into the pattern side.
|
||||
|
||||
'tier'/'allowed_numbers' are also written, mirroring the outbound values,
|
||||
purely so that rolling back to a pre-split pstn-trunk.sh keeps working
|
||||
with the old single-tier semantics. Nothing reads them once the split
|
||||
dialplan is installed."""
|
||||
interpolated into the pattern side."""
|
||||
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"
|
||||
if not TIER_RE.match(tier_out or ""):
|
||||
return False, "Invalid outbound tier"
|
||||
if not TIER_RE.match(tier_in or ""):
|
||||
return False, "Invalid inbound tier"
|
||||
if not RESTRICT_RE.match(restrict or ""):
|
||||
return False, "Invalid restriction mode"
|
||||
|
||||
tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip())
|
||||
clean = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n]
|
||||
numbers = "|".join(clean)
|
||||
tier_out, tier_in = _RESTRICT_TIERS[restrict]
|
||||
|
||||
cp = _read_permissions_cp()
|
||||
clean_out = _apply_direction(cp, ext, "out", tier_out, allowed_out_raw)
|
||||
clean_in = _apply_direction(cp, ext, "in", tier_in, allowed_in_raw)
|
||||
|
||||
# Rollback mirror — outbound values under the pre-split key names.
|
||||
if tier_out == "internal":
|
||||
if restrict == "none":
|
||||
# Clear the PSTN keys only, never the whole section — messaging and
|
||||
# personal_did are independent and must survive. (Removing the
|
||||
# section here was a real, confirmed bug in an earlier version.)
|
||||
if cp.has_section(ext):
|
||||
for key in ("tier", "allowed_numbers"):
|
||||
for key in ("restrict", "allowed_numbers", "tier_out", "allowed_out",
|
||||
"tier_in", "allowed_in", "tier"):
|
||||
if cp.has_option(ext, key):
|
||||
cp.remove_option(ext, key)
|
||||
else:
|
||||
if not cp.has_section(ext):
|
||||
cp.add_section(ext)
|
||||
cp.set(ext, "restrict", restrict)
|
||||
_set_or_clear(cp, ext, "allowed_numbers", numbers if restrict != "open" else "")
|
||||
cp.set(ext, "tier_out", tier_out)
|
||||
cp.set(ext, "tier_in", tier_in)
|
||||
_set_or_clear(cp, ext, "allowed_out", numbers if tier_out == "restricted" else "")
|
||||
_set_or_clear(cp, ext, "allowed_in", numbers if tier_in == "restricted" else "")
|
||||
cp.set(ext, "tier", tier_out)
|
||||
if tier_out == "restricted":
|
||||
cp.set(ext, "allowed_numbers", "|".join(clean_out))
|
||||
elif cp.has_option(ext, "allowed_numbers"):
|
||||
cp.remove_option(ext, "allowed_numbers")
|
||||
|
||||
if messaging_enabled:
|
||||
if not cp.has_section(ext):
|
||||
@@ -1315,8 +1336,8 @@ def write_permission(ext, tier_out, allowed_out_raw, tier_in, allowed_in_raw,
|
||||
cp.remove_option(ext, "messaging")
|
||||
|
||||
# Drop the section entirely once nothing is left in it — only reachable
|
||||
# when both directions are internal, messaging is off, and no
|
||||
# personal_did was ever assigned.
|
||||
# when the mode is none, messaging is off, and no personal_did was ever
|
||||
# assigned.
|
||||
if cp.has_section(ext) and not cp.options(ext):
|
||||
cp.remove_section(ext)
|
||||
|
||||
@@ -1324,14 +1345,9 @@ def write_permission(ext, tier_out, allowed_out_raw, tier_in, allowed_in_raw,
|
||||
if not ok:
|
||||
return False, err
|
||||
|
||||
empty = []
|
||||
if tier_out == "restricted" and not clean_out:
|
||||
empty.append("outbound")
|
||||
if tier_in == "restricted" and not clean_in:
|
||||
empty.append("inbound")
|
||||
if empty:
|
||||
return True, ("Saved, but the " + " and ".join(empty) +
|
||||
" list is restricted and EMPTY — no PSTN number is permitted in that direction yet.")
|
||||
if restrict != "none" and restrict != "open" and not clean:
|
||||
return True, ("Saved, but the whitelist is EMPTY — with this mode that means no PSTN "
|
||||
"number is permitted in the restricted direction yet.")
|
||||
return True, "Saved"
|
||||
|
||||
|
||||
@@ -2675,8 +2691,15 @@ INDEX_HTML = """<!doctype html>
|
||||
<details class="help">
|
||||
<summary>What these columns mean</summary>
|
||||
<p class="muted"><b>Name</b> and <b>Category</b> are editable in place — click, type, then Save. Adding an extension generates a random password and reloads PJSIP + rebuilds the dialplan automatically; the password is shown once, at the top of this card.</p>
|
||||
<p class="muted pstn-only"><b>Outbound</b> and <b>Inbound</b> are independent. Each is <b>internal</b> (no PSTN in that direction — internal extension-to-extension calling and ring groups always keep working either way), <b>restricted</b> (only the numbers listed beside it), or <b>full</b> (any US number). So "dial anyone, only take calls from family" is Outbound full + Inbound restricted, and the reverse is just as valid.</p>
|
||||
<p class="muted pstn-only"><b>Can dial</b> holds numbers this extension is allowed to <i>call</i>; <b>Can be called by</b> holds caller IDs allowed to <i>reach</i> it — different lists, which is why they're separate fields. Each is only editable when its own side is set to restricted. Usually live on the next call; if one doesn't seem to take effect, use "Commit changes" above.</p>
|
||||
<p class="muted pstn-only"><b>PSTN</b> sets how the outside phone network reaches this extension, and the <b>Whitelist</b> beside it is the one list of numbers that mode applies to:</p>
|
||||
<ul class="muted pstn-only" style="margin:0 0 var(--sp-2); padding-left:1.2rem">
|
||||
<li><b>No PSTN</b> — outside calls neither in nor out.</li>
|
||||
<li><b>Unrestricted</b> — dial anyone, anyone can call.</li>
|
||||
<li><b>Restrict outbound</b> — may only dial the whitelist; anyone can call in.</li>
|
||||
<li><b>Restrict inbound</b> — may dial anywhere; only the whitelist can call in.</li>
|
||||
<li><b>Restrict both</b> — the whitelist applies in both directions.</li>
|
||||
</ul>
|
||||
<p class="muted pstn-only">Internal extension-to-extension calling and ring groups are never gated by any of this. Changes are usually live on the next call; if one doesn't seem to take effect, use "Commit changes" above.</p>
|
||||
<p class="muted"><b>Messaging</b> — Asterisk's native SIP texting between extensions: no carrier SMS, no PSTN, no cost, and no dependency on a PSTN trunk at all (which is why this column is here even with no trunk installed). Independent of the calling tier. Enforced live by a dedicated dialplan context — see <code>services/asterisk.sh</code>'s README, including its caveat that the sender-extraction logic still needs real-traffic confirmation. If this box predates that wiring, rerun <code>sudo ./setup.sh asterisk</code>.</p>
|
||||
</details>
|
||||
|
||||
@@ -2687,10 +2710,8 @@ INDEX_HTML = """<!doctype html>
|
||||
<th class="ea-only">Category</th>
|
||||
<th class="sortable ea-only" data-sort="status">Status</th>
|
||||
<th class="ea-only">Transport</th>
|
||||
<th class="sortable pstn-only" data-sort="tier_out">Outbound</th>
|
||||
<th class="pstn-only">Can dial</th>
|
||||
<th class="sortable pstn-only" data-sort="tier_in">Inbound</th>
|
||||
<th class="pstn-only">Can be called by</th>
|
||||
<th class="sortable pstn-only" data-sort="restrict">PSTN</th>
|
||||
<th class="pstn-only">Whitelist</th>
|
||||
<th class="sortable" data-sort="messaging">Messaging</th>
|
||||
<th></th>
|
||||
</tr></thead><tbody></tbody></table>
|
||||
@@ -3170,8 +3191,7 @@ async function loadExtensions() {
|
||||
const byExt = new Map();
|
||||
(permData.extensions || []).forEach(e => byExt.set(e.ext, {
|
||||
ext: e.ext, name: e.name,
|
||||
tier_out: e.tier_out, allowed_out: e.allowed_out,
|
||||
tier_in: e.tier_in, allowed_in: e.allowed_in,
|
||||
restrict: e.restrict, allowed_numbers: e.allowed_numbers,
|
||||
messaging: e.messaging, ea: false, category: "", status: "", transport: "", encryption: "",
|
||||
}));
|
||||
|
||||
@@ -3182,8 +3202,7 @@ async function loadExtensions() {
|
||||
eaDevices.forEach(d => {
|
||||
const row = byExt.get(d.extension) || {
|
||||
ext: d.extension, name: d.name,
|
||||
tier_out: "internal", allowed_out: "",
|
||||
tier_in: "internal", allowed_in: "",
|
||||
restrict: "none", allowed_numbers: "",
|
||||
messaging: false,
|
||||
};
|
||||
row.ea = true;
|
||||
@@ -3205,15 +3224,21 @@ async function loadExtensions() {
|
||||
let extRows = [];
|
||||
let extSort = { key: null, dir: 1 };
|
||||
|
||||
// internal < restricted < full, so sorting a tier column groups by how
|
||||
// permissive it is rather than alphabetically (full would otherwise sort
|
||||
// between the other two).
|
||||
const TIER_ORDER = { internal: 0, restricted: 1, full: 2 };
|
||||
// Sort the PSTN column by how much reach the mode grants, not alphabetically
|
||||
// — "open" would otherwise land between "both" and "in".
|
||||
const RESTRICT_ORDER = { none: 0, both: 1, in: 2, out: 3, open: 4 };
|
||||
const RESTRICT_LABELS = [
|
||||
["none", "No PSTN"],
|
||||
["open", "Unrestricted"],
|
||||
["out", "Restrict outbound"],
|
||||
["in", "Restrict inbound"],
|
||||
["both", "Restrict both"],
|
||||
];
|
||||
|
||||
function extSortValue(e, key) {
|
||||
if (key === "ext") return parseInt(e.ext, 10);
|
||||
if (key === "messaging") return e.messaging ? 1 : 0;
|
||||
if (key === "tier_out" || key === "tier_in") return TIER_ORDER[e[key]] ?? -1;
|
||||
if (key === "restrict") return RESTRICT_ORDER[e[key]] ?? -1;
|
||||
return (e[key] || "").toString().toLowerCase();
|
||||
}
|
||||
|
||||
@@ -3222,18 +3247,21 @@ function setCount(id, n) {
|
||||
if (el) el.textContent = n ? "(" + n + ")" : "";
|
||||
}
|
||||
|
||||
function tierSelect(cls, value) {
|
||||
return `<select class="${cls}">` +
|
||||
["internal", "restricted", "full"].map(t =>
|
||||
`<option value="${t}" ${value === t ? "selected" : ""}>${t}</option>`).join("") +
|
||||
function restrictSelect(value) {
|
||||
return '<select class="ext-restrict">' +
|
||||
RESTRICT_LABELS.map(([v, label]) =>
|
||||
`<option value="${v}" ${value === v ? "selected" : ""}>${esc(label)}</option>`).join("") +
|
||||
"</select>";
|
||||
}
|
||||
|
||||
// The whitelist is meaningless for "no PSTN" and "unrestricted".
|
||||
function restrictUsesList(mode) { return mode === "out" || mode === "in" || mode === "both"; }
|
||||
|
||||
function renderExtensions() {
|
||||
const tbody = document.querySelector("#ext-table tbody");
|
||||
setCount("ext-count", extRows.length);
|
||||
if (!extRows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan=11 class=empty>No extensions found — no Asterisk install detected, or pjsip.conf has no devices yet.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan=9 class=empty>No extensions found — no Asterisk install detected, or pjsip.conf has no devices yet.</td></tr>';
|
||||
updateDirtyState();
|
||||
return;
|
||||
}
|
||||
@@ -3273,10 +3301,8 @@ function renderExtensions() {
|
||||
<td class="ea-only">${catCell}</td>
|
||||
<td class="ea-only">${statusCell}</td>
|
||||
<td class="ea-only muted">${esc(e.transport)}${e.encryption && e.encryption !== "no" ? " / " + esc(e.encryption) : ""}</td>
|
||||
<td class="pstn-only">${tierSelect("ext-tier-out", e.tier_out)}</td>
|
||||
<td class="pstn-only"><input type="text" class="ext-numbers-out" value="${esc(e.allowed_out)}" placeholder="5551234567,5559876543" ${e.tier_out === "restricted" ? "" : "disabled"} style="width:13rem" aria-label="Numbers extension ${esc(e.ext)} may dial"></td>
|
||||
<td class="pstn-only">${tierSelect("ext-tier-in", e.tier_in)}</td>
|
||||
<td class="pstn-only"><input type="text" class="ext-numbers-in" value="${esc(e.allowed_in)}" placeholder="5551234567,5559876543" ${e.tier_in === "restricted" ? "" : "disabled"} style="width:13rem" aria-label="Caller IDs allowed to reach extension ${esc(e.ext)}"></td>
|
||||
<td class="pstn-only">${restrictSelect(e.restrict)}</td>
|
||||
<td class="pstn-only"><input type="text" class="ext-numbers" value="${esc(e.allowed_numbers)}" placeholder="5551234567,5559876543" ${restrictUsesList(e.restrict) ? "" : "disabled"} style="width:16rem" aria-label="Whitelist for extension ${esc(e.ext)}"></td>
|
||||
<td style="text-align:center"><input type="checkbox" class="ext-messaging" ${e.messaging ? "checked" : ""} aria-label="Messaging for extension ${esc(e.ext)}"></td>
|
||||
<td class="actions">
|
||||
<button class="icon ea-only" title="Delete extension ${esc(e.ext)}" onclick="deleteEaDevice('${esc(e.ext)}')">×</button>
|
||||
@@ -3285,14 +3311,11 @@ function renderExtensions() {
|
||||
}).join("");
|
||||
|
||||
tbody.querySelectorAll("tr").forEach(row => {
|
||||
// Each direction's number field follows its own tier, not the other's.
|
||||
[["out"], ["in"]].forEach(([dir]) => {
|
||||
const tierSel = row.querySelector(".ext-tier-" + dir);
|
||||
const numsInput = row.querySelector(".ext-numbers-" + dir);
|
||||
if (tierSel && numsInput) {
|
||||
tierSel.addEventListener("change", () => { numsInput.disabled = tierSel.value !== "restricted"; });
|
||||
}
|
||||
});
|
||||
const modeSel = row.querySelector(".ext-restrict");
|
||||
const numsInput = row.querySelector(".ext-numbers");
|
||||
if (modeSel && numsInput) {
|
||||
modeSel.addEventListener("change", () => { numsInput.disabled = !restrictUsesList(modeSel.value); });
|
||||
}
|
||||
row.querySelectorAll("input, select").forEach(ctl => {
|
||||
ctl.addEventListener("input", updateDirtyState);
|
||||
ctl.addEventListener("change", updateDirtyState);
|
||||
@@ -3312,18 +3335,14 @@ function rowEdits(tr) {
|
||||
if (!model) return null;
|
||||
const nameEl = tr.querySelector(".ext-name");
|
||||
const catEl = tr.querySelector(".ext-category");
|
||||
const tierOutEl = tr.querySelector(".ext-tier-out");
|
||||
const numsOutEl = tr.querySelector(".ext-numbers-out");
|
||||
const tierInEl = tr.querySelector(".ext-tier-in");
|
||||
const numsInEl = tr.querySelector(".ext-numbers-in");
|
||||
const modeEl = tr.querySelector(".ext-restrict");
|
||||
const numsEl = tr.querySelector(".ext-numbers");
|
||||
const msgEl = tr.querySelector(".ext-messaging");
|
||||
const edits = {};
|
||||
if (nameEl && !nameEl.disabled && nameEl.value.trim() !== model.name) edits.name = nameEl.value.trim();
|
||||
if (catEl && catEl.value !== model.category) edits.category = catEl.value;
|
||||
if (tierOutEl && tierOutEl.value !== model.tier_out) edits.tier_out = tierOutEl.value;
|
||||
if (numsOutEl && numsOutEl.value !== model.allowed_out) edits.allowed_out = numsOutEl.value;
|
||||
if (tierInEl && tierInEl.value !== model.tier_in) edits.tier_in = tierInEl.value;
|
||||
if (numsInEl && numsInEl.value !== model.allowed_in) edits.allowed_in = numsInEl.value;
|
||||
if (modeEl && modeEl.value !== model.restrict) edits.restrict = modeEl.value;
|
||||
if (numsEl && numsEl.value !== model.allowed_numbers) edits.allowed_numbers = numsEl.value;
|
||||
if (msgEl && msgEl.checked !== !!model.messaging) edits.messaging = msgEl.checked;
|
||||
return {ext, model, edits, tr, count: Object.keys(edits).length};
|
||||
}
|
||||
@@ -3372,20 +3391,18 @@ document.getElementById("ext-save-all").addEventListener("click", async () => {
|
||||
const r = await postJSON("/api/ea-devices/category", {extension: ext, category: edits.category});
|
||||
if (!r.ok) throw new Error(r.message || "category change failed");
|
||||
}
|
||||
const permKeys = ["tier_out", "allowed_out", "tier_in", "allowed_in", "messaging"];
|
||||
const permKeys = ["restrict", "allowed_numbers", "messaging"];
|
||||
if (permKeys.some(k => k in edits)) {
|
||||
const messaging = "messaging" in edits ? edits.messaging : !!model.messaging;
|
||||
let r;
|
||||
if (pstnInstalled) {
|
||||
// Send the whole permission record, not just the changed fields —
|
||||
// the endpoint rewrites both directions, so omitting an unchanged
|
||||
// one would silently reset it.
|
||||
// the endpoint rewrites all of it, so omitting an unchanged value
|
||||
// would silently reset it.
|
||||
r = await postJSON("/api/pstn-permissions", {
|
||||
ext: ext,
|
||||
tier_out: "tier_out" in edits ? edits.tier_out : model.tier_out,
|
||||
allowed_out: "allowed_out" in edits ? edits.allowed_out : model.allowed_out,
|
||||
tier_in: "tier_in" in edits ? edits.tier_in : model.tier_in,
|
||||
allowed_in: "allowed_in" in edits ? edits.allowed_in : model.allowed_in,
|
||||
restrict: "restrict" in edits ? edits.restrict : model.restrict,
|
||||
allowed_numbers: "allowed_numbers" in edits ? edits.allowed_numbers : model.allowed_numbers,
|
||||
messaging: messaging,
|
||||
});
|
||||
} else {
|
||||
@@ -3918,11 +3935,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
perms = get_all_permissions()
|
||||
extensions = []
|
||||
for e in list_extensions():
|
||||
p = perms.get(e["ext"], {"tier_out": "internal", "allowed_out": "",
|
||||
"tier_in": "internal", "allowed_in": "", "messaging": False})
|
||||
p = perms.get(e["ext"], {"restrict": "none", "allowed_numbers": "", "messaging": False})
|
||||
extensions.append({"ext": e["ext"], "name": e["name"],
|
||||
"tier_out": p["tier_out"], "allowed_out": p["allowed_out"],
|
||||
"tier_in": p["tier_in"], "allowed_in": p["allowed_in"],
|
||||
"restrict": p["restrict"],
|
||||
"allowed_numbers": p["allowed_numbers"],
|
||||
"messaging": p["messaging"]})
|
||||
self._json({"extensions": extensions})
|
||||
elif self.path == "/api/pstn-limits":
|
||||
@@ -3967,8 +3983,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif self.path == "/api/pstn-permissions":
|
||||
ok, message = write_permission(
|
||||
payload.get("ext", ""),
|
||||
payload.get("tier_out", ""), payload.get("allowed_out", ""),
|
||||
payload.get("tier_in", ""), payload.get("allowed_in", ""),
|
||||
payload.get("restrict", ""), payload.get("allowed_numbers", ""),
|
||||
bool(payload.get("messaging", False)),
|
||||
)
|
||||
self._json({"ok": ok, "message": message})
|
||||
|
||||
Reference in New Issue
Block a user