Add spend-cap kill-switch, international-calling toggle, per-minute checks

- Replace the hourly cron.d spend/volume check with a per-minute systemd
  timer (cron.d fallback), since it's now also the kill-switch's
  enforcement point and the interval is the exposure window.
- Add a hard monthly spend-cap kill-switch: trips pstn-trunk-killswitch.conf
  (read live by the dialplan, both call directions, internal calling
  unaffected) once estimated spend reaches an admin-set cap, plus a loud
  ntfy warning at 80% of the cap before it trips. Does not auto-reset;
  clearing it is a CLI-only action (update mode) so a compromised/careless
  web session can't quietly re-enable spend after a trip.
- Add a CLI-only (never web-dashboard) international-calling allow-list:
  continent/country menu, always asked every run with the resulting
  allow-list printed exactly once right after, optional auto-expiry with
  day-of and at-expiry ntfy notices, and active re-blocking once expired.
  Dialplan gates the US "011" prefix pattern on it for full-tier extensions.
- Add an independent messaging_enabled flag per extension in
  pstn-permissions.conf for Asterisk's native SIP MESSAGE texting, since
  the risk profile (no cost) differs from PSTN calling permissions. The
  dialplan wiring to enforce it is flagged as a known gap pending live
  verification against Easy Asterisk's message routing, rather than
  shipping a guessed pattern.
- security-dashboard.sh: detect whether pstn-trunk is actually installed
  (not just base Asterisk) and show a clear "not installed" state instead
  of misleading default caps/extensions; add clickable column-header
  sorting to the CrowdSec active-bans table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
This commit is contained in:
Claude
2026-07-22 14:51:19 +00:00
parent 26f3f08304
commit 9b950808cc
3 changed files with 806 additions and 78 deletions
+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)