Merge pull request #205 from outis1one/claude/sip-voip-integration-atsins
Claude/sip voip integration atsins
This commit is contained in:
@@ -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 $2–5/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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user