Merge pull request #205 from outis1one/claude/sip-voip-integration-atsins

Claude/sip voip integration atsins
This commit is contained in:
Outis
2026-07-22 11:35:01 -04:00
committed by GitHub
3 changed files with 1060 additions and 124 deletions
+211 -19
View File
@@ -59,10 +59,32 @@ file.
- **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off
auto-recharge in the account's Finances settings and outbound calls simply
fail once the balance hits $0 — that's the toll-fraud backstop if the
droplet's Asterisk (`asterisk-digital-ocean`) is ever compromised. This
behavior wasn't verified against a live account — confirm the
auto-recharge toggle still works this way at sign-up time, since billing
UX can change.
droplet's Asterisk (`asterisk-digital-ocean`) is ever compromised.
**Update — read VoIP.ms's actual ToS (not just the wiki) on this.** The
wiki says plainly "only accounts with a balance over $0 are able to send
and receive calls" — new call attempts should be blocked in real time at
$0, and that's still the core assumption this design leans on. But the
ToS separately says the account "may run on a negative balance," that any
negative balance is "immediately due and payable," that VoIP.ms may
suspend an account below a $5 minimum balance (30-day notice first), and
may permanently close it after 30 *consecutive* days negative. Read
together, not a contradiction — two different things:
- **Can new calls start** — real-time balance check, blocked at $0. Core
assumption holds.
- **Can the balance ever read negative** — yes, most plausibly from
recurring fees (DID monthly, E911) landing when the balance is already
near zero, or edge-case settlement of an in-progress call ticking
slightly negative before teardown. Neither is a runaway toll-fraud
scenario; both mean liability isn't cleanly capped at the funded amount
to the exact penny, and the account needs topping up within the 30-day
windows or it gets suspended/closed (an account-status consequence, not
"30 free days of unblocked calling while negative").
- Still not verified against an actual live account — this is a read of
their published wiki + ToS text, not a test. Watch the real balance for
the first month or two after go-live, and don't panic at a small
negative reading — check whether it's a recurring fee or an actual call
spike before assuming the block failed.
- **Scope: US calling only, for now.** No international, no premium-rate
destinations. Enforce this twice — once via whatever dial-plan/prefix
VoIP.ms requires for US routing, and again independently in Asterisk's own
@@ -108,9 +130,20 @@ anyone notices. Two independent layers matter more than either alone:
trunk's own capabilities.
**Important nuance: these two layers bound different things, and neither
alone bounds both.** NANP-only restriction bounds *cost-per-minute* (a
compromised box can only ever reach $0.01/min US numbers, never $25/min
international/premium destinations) — that risk is fully closed. It does
alone bounds both.** NANP-only restriction bounds *cost-per-minute* — but
**NANP-only alone was not actually sufficient**, and this design carried
that gap for a while before it was caught. NANP includes Caribbean/Atlantic
nations and several US territories (Bahamas, Puerto Rico, Dominican
Republic, and 24 others) that dial exactly like a normal 10-digit US number
but bill at international/premium rates — a well-known toll-fraud/
"one-ring scam" vector specifically because the format looks domestic. The
NANP pattern match alone would have let a compromised extension reach every
one of these. **Fixed**: `services/pstn-trunk.sh` now blocks those 27 area
codes explicitly, checked before permission tier, for every extension
regardless of tier — see the dialplan file's own comment and
"Non-US NANP area codes are blocked" in its generated README for the full
list. With that fix in place, cost-per-minute risk actually is closed (a
compromised box can only ever reach genuine US-rate destinations). It does
**not** bound *how fast* the prepaid balance gets burned: nothing stops a
compromised box from opening many concurrent US-destination calls in
parallel and draining the whole balance (e.g. $15 balance ÷ $0.01/min =
@@ -143,7 +176,11 @@ separately from that hourly check.
- An outbound dialplan route matching US numbers only — **implemented**:
`_1NXXNXXXXX` (11-digit NANP with leading 1) and `_NXXNXXXXX` (10-digit,
auto-prefixed with 1), both routed to the trunk. No catch-all `_X.`
pattern.
pattern. **Also implemented**: an explicit block on the 27 NANP area
codes that aren't actually US (Caribbean/Atlantic nations + US
territories — see the toll-fraud nuance above for why this matters),
checked via `REGEX()` against the extracted area code before permission
tier, ahead of every other check.
- **Three-tier permission model — implemented**, superseding an earlier flat
allow-list design. `internal` / `restricted` / `full` per extension, read
live from `pstn-permissions.conf` via `AST_CONFIG()` rather than baked
@@ -202,9 +239,59 @@ separately from that hourly check.
legitimately-registered extension being used for toll fraud — nobody
should assume CrowdSec alone already covers this.
## Provider landscape (for reference — not chosen)
- **SIP.US** — also prepaid, flat per-channel rate, built-in fraud
detection. Considered, not chosen.
## Provider landscape (for reference)
Looked at as alternatives to VoIP.ms specifically because VoIP.ms's own ToS
confirms it *selectively* requires a government ID scan at signup (VPN/proxy
use, certain number types, internal risk scoring — not universal, but real).
None of these were verified to *never* require ID either; KYC requirements
are trending industry-wide (FCC STIR/SHAKEN, Robocall Mitigation Database),
so treat "no ID requirement found" as exactly that, not confirmation.
- **DIDLogic** — **ruled out**: has a $20/month minimum, confirmed directly
by checking their portal (not something search results surfaced). Would
otherwise have needed zero code changes — same IP-auth model and
single-IP-per-regional-POP hostname pattern this service already handles.
- **Anveo Direct** (anveodirect.com — a *different product* from plain
Anveo/anveo.com, its hosted-PBX sibling with monthly subscription tiers;
don't confuse the two, their pricing models aren't comparable) — leading
candidate. ~$0.001/min outbound, no minimum commitment, $25 minimum to
fund the account (close to VoIP.ms's $15). Confirmed via their current
official FAQ (fetched directly, not via search — search results had gone
stale on this exact point):
- IP authentication, no SIP registration — matches this service's
architecture with zero changes needed to the auth model.
- **No dial-prefix requirement** — a stale/outdated forum claim (found via
search) suggested one was needed; the *current* official FAQ's own
sample config shows plain number dialing, nothing prepended. Don't
trust that claim if it resurfaces.
- **Sends inbound signaling from multiple fixed IPs**, not one:
`169.48.232.158`, `204.216.109.55`, `176.9.39.206`, `72.9.149.25` (per
their FAQ as of this writing — confirmed different from what web search
surfaced independently, which was a different, apparently stale set of
5 IPs). This is what prompted the multi-IP `identify` support below.
**Re-confirmed directly with Anveo Direct support** (ticket reply from
MFonk, 7/22/2026): these 4 IPs are still current/accurate.
- STIR/SHAKEN signing is handled by Anveo as intermediate provider for
personal use (originating your own calls, not reselling to customers) —
no FCC registration/certificate needed on our side.
- ~~Still open: does Anveo actually block new outbound calls in real time
at $0 balance~~ **Resolved** — asked their support directly (same
ticket, 7/22/2026): "all calls (incoming and outgoing) will be blocked"
at $0 balance, in real time, not just via the recurring-fee
grace-period mechanism. This also answers the inbound question that
hadn't been asked of VoIP.ms either (see item 6 below): **inbound is
blocked at $0 the same as outbound**, not billed-through unconditionally.
This closes the crux assumption behind the toll-fraud backstop layering
for Anveo Direct specifically — VoIP.ms's own equivalent behavior is
still unverified against a live account (see item 6).
- **SIP.US** — prepaid, but a flat ~$24.95/mo per-channel (unlimited minutes
on that channel) model, not pay-per-minute — **not actually
similarly-priced** at this project's volume (100 min/month costs ~$1-3/mo
pay-per-minute vs. $24.95/mo flat). Notable anyway for automated real-time
fraud detection (kills in-progress unauthorized calls, auto-disables
international calling on suspicious activity) — a provider-side version of
what this repo hand-built in the dialplan, if that trade-off (higher
price, less to maintain yourself) ever appeals.
- Several providers (Nextiva, IDT Express) advertise AI/ML-based fraud
monitoring as a second layer on top of normal billing — an extra net, not
a substitute for a hard prepaid ceiling.
@@ -212,11 +299,42 @@ separately from that hourly check.
prepaid-balance + auto-recharge-off pattern is the de facto mechanism
across the space, VoIP.ms included.
## Multi-provider support beyond VoIP.ms — implemented
`services/pstn-trunk.sh` now generalizes two things that were originally
built VoIP.ms-shaped, driven directly by researching Anveo Direct as an
alternative:
- **Multiple inbound `identify` match IPs**, not just one. The install
prompt still auto-resolves the server hostname to an IP (VoIP.ms's
one-POP-one-hostname model), but now also asks for any *additional* known
source IPs a provider might publish (Anveo Direct's case) — PJSIP allows
repeating `match=` within a single `identify` object to build one match
set against the trunk endpoint, so this needed no new config objects, just
a loop emitting one `match=` line per IP.
- No dial-prefix support was added — turned out not to be needed for Anveo
Direct once verified against their current official FAQ rather than a
stale search result. If a future provider genuinely needs one, that's the
next generalization point (a prefix prepended to `${EXTEN}` before
`Dial()`).
**Bug caught while testing this**: `.pstn-trunk.env` (the structural
settings file "update in place" reapplies) was being written with unquoted
values. Harmless as long as every value happened to be a single word, but a
multi-IP list (always space-separated once there's more than one) or a
multi-word provider name (e.g. "Anveo Direct") broke `source`-ing it
entirely — bash treats the second word as a command to run ("Direct:
command not found"). This was a latent bug that predates the multi-IP work
(a multi-extension ring group has the same shape), only surfaced by
actually exercising the update-mode round trip instead of just inspecting
generator output. Fixed by quoting every value in that heredoc.
## Open items for whoever picks this up next
1. ~~Decide: new `services/pstn-trunk.sh`...~~ Done — separate service file,
generalized to any IP-auth SIP provider (VoIP.ms is just the default).
2. ~~IP auth vs. registration~~ Done — IP authentication, no password stored.
3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`.
3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`. ~~NANP
≠ US gap~~ Done — 27 Caribbean/territory area codes explicitly blocked
(see toll-fraud nuance above); this was a real, live gap in the design
for a while, not a hypothetical.
4. ~~Inbound~~ Done — rings a configurable list of extensions (ring-group
supported), each checked live per-call against its own tier. ~~Permission
model~~ Done — superseded the original flat allow-list with a 3-tier
@@ -231,11 +349,85 @@ separately from that hourly check.
5. ~~Concurrent-call cap~~ Done — both directions now (inbound was a real
gap, since it also costs money per-minute and outbound's cap doesn't
cover it), default 10/10, global not per-extension, live-editable via
`pstn-limits.conf`/web UI. ~~Spend/volume alert~~ Done — ntfy, hourly
threshold + burst check, plus immediate alerts on denied/rejected calls.
6. Verify against a live VoIP.ms account: auto-recharge-off behavior at
sign-up, and that the chosen POP server's actual source IP for inbound
calls matches what `services/pstn-trunk.sh` resolved via DNS at install
time (VoIP.ms's docs mention some redundancy/failover between servers —
if inbound calls ever stop matching the `identify` section, this is the
`pstn-limits.conf`/web UI. ~~Spend/volume alert~~ Done — ntfy, monthly
threshold + hourly burst check, run **every minute** (systemd timer, not
hourly cron — see item 8), plus immediate alerts on denied/rejected
calls.
6. Verify against a live VoIP.ms account (still not done — only their wiki
+ ToS text has been read, see "Decision so far" above for what that
turned up): confirm new outbound calls actually get blocked at $0
balance as documented; watch whether/when the balance goes slightly
negative in normal operation (expected from recurring fees, not
necessarily a sign of a problem) and top up within the 30-day windows
the ToS describes so the account/DID doesn't get suspended or closed.
Also confirm the chosen POP server's actual source IP for inbound calls
matches what `services/pstn-trunk.sh` resolved via DNS at install time
(VoIP.ms's docs mention some redundancy/failover between servers — if
inbound calls ever stop matching the `identify` section, this is the
first thing to check).
7. Anveo Direct specifically has a provider-side "Call Security" setting
(account portal, not exposed through SIP config) — a per-minute rate
cap ("block calls with rate more than $X/min, 0 = block all") and a
maximum call duration. Worth setting on any Anveo Direct account as a
provider-side backstop independent of this repo's own dialplan code —
not automatable from here since it's their web UI, not a config file.
8. ~~Spend-cap kill-switch~~ Done — a genuinely hard stop, not just an
alert: once `pstn-trunk-usage-alert.sh` estimates month-to-date spend
has reached an admin-set cap (prompted at install/update, `0` = disabled),
it writes `tripped=1` to `pstn-trunk-killswitch.conf`, read live by the
dialplan on *every* PSTN call attempt (both directions — internal
Asterisk-to-Asterisk calling is untouched) and blocked immediately with a
loud (`Priority: urgent`) ntfy alert. A separate loud warning fires once
spend reaches 80% of the cap, before it trips. **Does not auto-reset**
requires manually clearing it via the CLI installer (update mode), by
design, so a compromised/careless web session can't quietly re-enable
spend after a trip. Honesty caveat carried over from the original spend
estimate: this is estimate-based (call count/duration × an entered
rate), not real billing data, and only as fresh as the last check — now
every minute (see item 9) rather than hourly, shrinking but not
eliminating the reaction-time gap between an overage happening and
calling actually getting blocked.
9. ~~Hourly cron → per-minute systemd timer~~ Done —
`pstn-trunk-usage.timer`/`.service` (falls back to a cron.d entry if
systemd isn't available), running the same usage-alert script every
minute instead of hourly. Directly motivated by item 8: the periodic
check is now also the kill-switch's enforcement point, so the interval
between checks is the exposure window, and a tighter interval shrinks it.
10. ~~International calling (beyond NANP/US)~~ Done — CLI-only (never the
Security Dashboard web UI, on purpose: this widens which countries can
be dialed/billed to at all, a more security-sensitive control than
who's already allowed to use an already-fixed scope), continent →
country menu (`_pstn_manage_international` in `services/pstn-trunk.sh`),
always asked every run with no way to skip the *question* itself
(though answering "no" leaves the existing allow-list untouched), with
the resulting allow-list printed exactly once right after — not
repeated during the spend-cap prompts, a correction from an earlier
draft of this design. Optional auto-expiry with two ntfy notices (day
of, and at the moment of expiry) and active re-blocking (the periodic
script clears the allow-list once past `expires`, it doesn't just
notify). Dialing uses the US `011` prefix convention (`_011X.` dialplan
pattern); allowed country codes are the REGEX() *pattern* side (admin-
controlled), dialed digits are always the *string* being tested — same
safe direction as every other permission check in this file. Only
`full`-tier extensions can use it regardless of which countries are
allowed.
11. Internal SIP `MESSAGE` (native Asterisk texting, no carrier SMS/cost) —
**partially done**. The permission layer is real and live-editable: a
`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.
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
would take precedence over — or conflict with — Easy Asterisk's own
generated per-device dial patterns in that same context isn't something
that can be safely determined without a live install to test against.
Shipping a guessed pattern risked either silently not working or, worse,
interfering with call-routing precedence for the same extensions.
Treat this the same way as the VoIP.ms live-account verification in
item 6 above: a real gap, flagged rather than papered over, not a
hypothetical. Next step for whoever picks this up: verify message
routing behavior against a live Easy Asterisk container, then wire the
dialplan gate using the existing flag.
+749 -97
View File
File diff suppressed because it is too large Load Diff
+100 -8
View File
@@ -206,13 +206,19 @@ 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** (only if \`services/pstn-trunk.sh\` is installed) — the
- **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) 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.
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
@@ -908,6 +914,18 @@ def write_permission(ext, tier, numbers_raw):
LIMIT_RE = re.compile(r"^\d+$")
def pstn_installed():
"""True only once services/pstn-trunk.sh has actually wired the dialplan
in (pstn-trunk-dialplan.conf existing), not just because base Asterisk is
present — pjsip.conf/extensions.conf exist either way, so extension names
alone can't tell us this. Without this check the tab would show a real
extension list and a default-but-unenforced 10/10 cap even when there is
no PSTN trunk at all."""
if not ASTERISK_CONFIG_DIR:
return False
return os.path.isfile(os.path.join(ASTERISK_CONFIG_DIR, "pstn-trunk-dialplan.conf"))
def get_limits():
"""Current outbound/inbound concurrent-call caps. Defaults (10/10) match
what the dialplan itself falls back to (via AST_CONFIG()+IF()) if this
@@ -973,6 +991,9 @@ INDEX_HTML = """<!doctype html>
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #23262f; }
th { color: #9aa4b2; font-weight: 600; }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: #e6e6e6; }
th.sortable .arrow { opacity: 0.5; font-size: 0.75em; margin-left: 0.25em; }
.sev-Error { color: #ff6b6b; }
.sev-Warning { color: #f5b342; }
.sev-Informational { color: #7fbf7f; }
@@ -1006,7 +1027,15 @@ INDEX_HTML = """<!doctype html>
<div id="tab-crowdsec" style="display:none">
<div class="card">
<h3 style="margin-top:0">Active bans</h3>
<table id="dec-table"><thead><tr><th>IP/Range</th><th>Scenario</th><th>Network / Carrier</th><th>Country</th><th>Duration</th><th>Origin</th><th></th></tr></thead><tbody></tbody></table>
<table id="dec-table"><thead><tr>
<th class="sortable" data-sort="value">IP/Range</th>
<th class="sortable" data-sort="scenario">Scenario</th>
<th class="sortable" data-sort="carrier">Network / Carrier</th>
<th class="sortable" data-sort="country">Country</th>
<th class="sortable" data-sort="duration">Duration</th>
<th class="sortable" data-sort="origin">Origin</th>
<th></th>
</tr></thead><tbody></tbody></table>
</div>
<div class="card">
<h3 style="margin-top:0">Asterisk brute-force ASN exemptions</h3>
@@ -1020,6 +1049,11 @@ INDEX_HTML = """<!doctype html>
</div>
</div>
<div id="tab-pstn" style="display:none">
<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>
</div>
<div id="pstn-installed-cards" style="display:none">
<div class="card">
<h3 style="margin-top:0">Concurrent-call caps</h3>
<p class="muted">A call over either cap gets a busy signal (and an ntfy alert, if enabled) — existing calls are never affected. Changes apply live, on the next call.</p>
@@ -1041,6 +1075,7 @@ INDEX_HTML = """<!doctype html>
<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>
<div id="pstn-msg" class="muted" style="margin-top:0.5rem"></div>
</div>
</div>
</div>
</main>
<script>
@@ -1052,7 +1087,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") { loadPstnLimits(); loadPstnPermissions(); }
if (btn.dataset.tab === "pstn") { loadPstnStatus(); }
});
});
@@ -1070,12 +1105,44 @@ async function loadSecurity() {
}
let lastDecisions = [];
let decSort = { key: null, dir: 1 };
async function loadDecisions() {
const res = await fetch("/api/decisions");
lastDecisions = await res.json();
// Go-style duration strings ("3h59m59.62s", "-1" for permanent) don't sort
// correctly as text, so parse to seconds for the Duration column; permanent
// bans (-1 or unparseable) sort as Infinity, i.e. last in ascending order.
function durationSeconds(s) {
if (!s || s === "-1") return Infinity;
const m = String(s).match(/^(-?\d+h)?(\d+m)?(\d+(?:\.\d+)?s)?$/);
if (!m || !(m[1] || m[2] || m[3])) return Infinity;
const h = parseFloat(m[1]) || 0, mi = parseFloat(m[2]) || 0, se = parseFloat(m[3]) || 0;
return h * 3600 + mi * 60 + se;
}
function decSortValue(d, key) {
switch (key) {
case "carrier": return (d.as_name || d.as_number || "").toLowerCase();
case "duration": return durationSeconds(d.duration);
default: return (d[key] || "").toString().toLowerCase();
}
}
function renderDecisions() {
let rows = lastDecisions.slice();
if (decSort.key) {
rows.sort((a, b) => {
const av = decSortValue(a, decSort.key), bv = decSortValue(b, decSort.key);
if (av < bv) return -1 * decSort.dir;
if (av > bv) return 1 * decSort.dir;
return 0;
});
}
document.querySelectorAll("#dec-table th.sortable .arrow").forEach(a => a.remove());
if (decSort.key) {
const th = document.querySelector(`#dec-table th[data-sort="${decSort.key}"]`);
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${decSort.dir === 1 ? "▲" : "▼"}</span>`);
}
const tbody = document.querySelector("#dec-table tbody");
tbody.innerHTML = lastDecisions.map(d => `<tr>
tbody.innerHTML = rows.map(d => `<tr>
<td>${esc(d.value)}</td>
<td>${esc(d.scenario)}</td>
<td>${d.as_number ? esc(d.as_number) + (d.as_name ? " — " + esc(d.as_name) : "") : ""}</td>
@@ -1089,6 +1156,21 @@ async function loadDecisions() {
</tr>`).join("") || "<tr><td colspan=7 class=muted>No active bans.</td></tr>";
}
document.querySelectorAll("#dec-table th.sortable").forEach(th => {
th.addEventListener("click", () => {
const key = th.dataset.sort;
decSort.dir = (decSort.key === key) ? -decSort.dir : 1;
decSort.key = key;
renderDecisions();
});
});
async function loadDecisions() {
const res = await fetch("/api/decisions");
lastDecisions = await res.json();
renderDecisions();
}
async function unban(id) {
if (!confirm("Unban decision #" + id + "?")) return;
const res = await fetch("/api/decisions/delete", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({id: id})});
@@ -1153,6 +1235,14 @@ async function banAsn(asn) {
loadDecisions();
}
async function loadPstnStatus() {
const res = await fetch("/api/pstn-status");
const data = await res.json();
document.getElementById("pstn-not-installed").style.display = data.installed ? "none" : "";
document.getElementById("pstn-installed-cards").style.display = data.installed ? "" : "none";
if (data.installed) { loadPstnLimits(); loadPstnPermissions(); }
}
async function loadPstnLimits() {
const res = await fetch("/api/pstn-limits");
const data = await res.json();
@@ -1273,6 +1363,8 @@ class Handler(BaseHTTPRequestHandler):
self._json({"extensions": extensions})
elif self.path == "/api/pstn-limits":
self._json(get_limits())
elif self.path == "/api/pstn-status":
self._json({"installed": pstn_installed()})
else:
self._json({"error": "not found"}, 404)