Commit Graph
17 Commits
Author SHA1 Message Date
Claude b10a626b22 Fix ntfy deny-all default, add crowdsec update mode, presence alerts, embedded Asterisk admin tab
- ntfy.sh: auth-default-access was deny-all, silently blocking both publish
  and anonymous subscribe on every topic once an auth-file is present.
  Default to read-write (same model as public ntfy.sh) so alerts from
  crowdsec.sh/pstn-trunk.sh actually get delivered.
- crowdsec.sh: add update/fresh/cancel reinstall gating — reruns previously
  re-asked every optional prompt (ASN exempt, geo-allowlist, ntfy, remote
  LAPI) unconditionally with no way to just refresh in place.
- asterisk-digital-ocean.sh: add optional ntfy alerts on extension
  registration going offline/online, checked every 2 minutes via systemd
  timer (cron.d fallback), offered on both fresh install and update.
- security-dashboard.sh: replace the outbound-only Asterisk Web Admin link
  with an embedded, lazy-loaded iframe tab, with a best-effort Caddy
  frame-ancestors patch and an always-available "open in new tab" fallback.
2026-07-23 14:15:17 +00:00
Claude 3359898b4e Add named extension groups and Security Log column filtering
Groups: a new always-available "Groups" card in the PSTN tab (same place
as Internal SIP messaging, no dependency on a PSTN trunk being installed)
lets you name a set of extensions and bulk-enable/disable messaging for
all of them at once. Purely a management-layer convenience - pstn-groups.conf
is never read by the dialplan, which only ever looks at per-extension keys
in pstn-permissions.conf. Applying a group action just calls the same
write_messaging() each individual checkbox uses, once per current member.
Editing membership never retroactively changes anything already applied,
and deleting a group never touches members' own settings - confirmed with
tests covering create/apply/edit-membership/re-apply/delete.

Security Log: added a per-column filter row (Time/Event/Account/Remote/
Severity), live as you type, filtering client-side against the
already-fetched events rather than re-querying - persists correctly across
the existing 30-second auto-refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-23 06:05:37 +00:00
Claude 7851e2befd Decouple internal SIP messaging management from PSTN trunk installation
Messaging has no dependency on a PSTN trunk existing (no cost, no
carrier, no DID), but the whole PSTN Trunk tab - including the messaging
checkbox added last commit - was hidden behind pstn_installed(), which
only becomes true once services/pstn-trunk.sh's dialplan is actually
wired in. That meant enabling messaging required going through full SIP
trunk/provider setup first for no real reason.

Added a standalone "Internal SIP messaging" card that's always visible in
the tab regardless of trunk status, backed by a new write_messaging()
that only touches the messaging key (leaving tier/allowed_numbers/
personal_did untouched) and creates pstn-permissions.conf from scratch if
it doesn't exist yet. Confirmed the systemd unit's ReadWritePaths and the
group/chmod grants already covered this - both are set up whenever a base
Asterisk install is detected, independent of pstn-trunk - so no
permission-layer changes were needed, only the dashboard's own artificial
UI gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-23 05:38:36 +00:00
Claude 8291eba55e Make the messaging permission flag live-editable via the dashboard
get_all_permissions()/write_permission() now handle messaging alongside
tier/allowed_numbers as one save action, and the Security Dashboard's PSTN
Trunk table gets a Messaging checkbox column - no more needing to re-run
pstn-trunk.sh's CLI just to change who can use internal SIP texting,
matching how tier/allowed_numbers/personal_did already worked.

Tested that messaging correctly survives tier changes and personal-DID
assignment/removal on the same extension (independent axes, as intended).

Still explicitly not done, and said so in both READMEs rather than
implying otherwise now that there's a nice UI for it: the actual SIP
MESSAGE dialplan wiring that would make Asterisk enforce this flag. That
gap hasn't changed - only the permission storage/UI layer around it has.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-23 05:27:58 +00:00
Claude fcb01a4d93 Add personal/per-extension DID assignment ("their own numbers")
Multiple DIDs can now share one trunk/account, each assigned to a specific
extension - additive to the existing shared trunk DID/ring-group, which
keeps working unchanged for everyone regardless of what's assigned here.

- New pstn-personal-dids.conf (DID -> owner extension), read live by the
  dialplan for inbound routing: a call to a personal DID routes straight
  to its owner, checked against the owner's own tier/approved-numbers, no
  ring-group fallback since it's that extension's own line.
- New personal_did= field per extension in pstn-permissions.conf: the
  outbound Caller-ID override, used by pstn_check_busy (the shared exit
  point for both domestic and international dialing) instead of the
  shared trunk DID when the calling extension has one assigned.
- Both files kept in sync automatically by one write path - CLI prompt at
  install/update, or a new "Personal numbers" card in the Security
  Dashboard's PSTN Trunk tab - rather than requiring hand-editing both
  consistently. Reassigning a DID or giving an extension a new one cleanly
  drops the stale side of the old mapping.
- Assigning a DID to an internal-tier extension is accepted but warned
  about (won't ring anyone until also granted full/restricted tier),
  matching this repo's permissive-with-warnings style rather than
  blocking the action outright.

Also fixed a real bug found while building this: security-dashboard.sh's
write_permission() did cp.remove_section(ext) whenever tier was set to
"internal", silently discarding any messaging=yes or personal_did=
already set on that extension. Now removes only the tier/allowed_numbers
keys, dropping the section only once nothing else remains in it.

Separately, de-anchored pstn-trunk.sh's install prompts from VoIP.ms as
the implicit default (provider name default, server-hostname example
wording, rate-prompt wording) now that Anveo Direct is an equally
confirmed, tested provider option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-23 05:03:13 +00:00
Claude 616dcdcfbb Fix Security Log tab loading the entire multi-GB log into memory
parse_security_log() did f.readlines() on ASTERISK_LOG before slicing the
last 5000 lines - that reads the ENTIRE file into memory first. That log
is Asterisk's unrotated console/security output, and this tab polls it
every 30 seconds from the browser. Confirmed live: on a 1GB-RAM droplet
with a 1.4GB log file, this ballooned the dashboard (explicitly meant to
be a lightweight stdlib-only process) to 677MB RSS / 1.8GB peak swap,
which left CrowdSec unable to even start (boot timeout) and directly
contributed to the droplet becoming unresponsive.

Fixed by reading only a bounded ~2MB tail from the end of the file
(seek + fixed-size read) instead of the whole thing - memory use is now
constant regardless of how large the log grows. Tested against a 180MB
synthetic log: memory delta dropped from being proportional to file size
to ~7MB, in 0.05s.

Also added log rotation (services/asterisk-digital-ocean.sh) for that same
file, which had no rotation at all and reached 1.4GB in about 3 days -
copytruncate avoids needing to signal the containerized Asterisk process
to reopen its log handle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-23 04:44:19 +00:00
Claude 78456e6a3a Fix ASN-exempt fork never actually disabling the hub-original scenarios
"cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum"
was failing silently (stderr suppressed, "|| true" swallowed the non-zero
exit) because these scenarios are members of the crowdsecurity/asterisk
collection, and cscli refuses to remove/disable a collection member
without --force. The un-exempted hub originals kept running side-by-side
with the ASN-exempt local forks the entire time, independently banning the
same traffic with no ASN awareness at all - confirmed live by a ban
showing scenario "crowdsecurity/asterisk_bf" (the hub original) rather
than "local/asterisk_bf" (the fork) for an ASN that had just been exempted.

Fixed in two places:
- crowdsec.sh's initial setup now passes --force and surfaces a clear
  warning (with the manual fix command) if it still fails, instead of
  silently continuing.
- security-dashboard's set-asn-exempt.sh helper now re-asserts the forced
  disable on every ASN-list save, so an install already affected by this
  (from before this fix, or where the step failed for any other reason)
  self-heals the next time the ASN list is edited via the dashboard,
  without needing to re-run the full CrowdSec installer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-23 03:44:19 +00:00
Claude 1f7f89b984 Fix ASN-exempt whitelist silently failing with Permission denied
set_asn_exempt() was writing /etc/crowdsec/scenarios/local-asterisk_*.yaml
directly from the dashboard's own process, but those files are root:root
mode 644 and the secdash service user (--shell /usr/sbin/nologin) has no
write grant to them - confirmed by the reported "[Errno 13] Permission
denied" error. Every whitelist/unban attempt through the dashboard was
silently a no-op as far as CrowdSec was concerned, which is why a
carrier's IP kept getting re-banned even after repeatedly trying to
exempt its ASN.

Fixed by routing the edit through a dedicated root-owned helper script
(set-asn-exempt.sh, installed alongside app.py, root:root mode 700) that's
the one thing added to the sudoers whitelist - same pattern every other
CrowdSec-touching action here already uses (cscli via sudo), rather than
loosening the scenario files' own permissions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-23 03:29:51 +00:00
Claude 9b950808cc 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
2026-07-22 14:51:19 +00:00
Claude 876fd6553b Add inbound concurrency cap, bump defaults to 10/10, make caps live-editable
Adds an inbound concurrent-call cap mirroring the existing outbound one -
outbound alone didn't protect against an inbound call-flood, which also
costs money per-minute on VoIP.ms. Both defaults bumped from 3 to 10.

Moves the cap numbers themselves out of static dialplan text and into a new
pstn-limits.conf, read live via AST_CONFIG() the same way permission tiers
already are - changing either cap takes effect on the next call, no
Asterisk restart, no reinstall. "update in place" never touches this file,
matching the existing protection for pstn-permissions.conf/.env/firewall/
Caddy config.

Adds a concurrency-caps card to security-dashboard.sh's "PSTN Trunk" tab,
above the existing permissions table, so both caps are visible and editable
from the same web page. Tested against a real running instance of the
Python app: default fallback when the file doesn't exist yet, save/persist,
invalid-input rejection, and a bash-to-Python round trip on the generated
file format.

Inbound dialplan ordering mirrors outbound's existing pattern: permission
check (is any ring-group member authorized for this caller) before the
concurrency check, consistent with outbound's tier-check-then-busy-check
order.
2026-07-22 00:49:41 +00:00
Claude 3bd952e55d PSTN trunk: 3-tier live permissions + Security Dashboard web UI + dual target
Reworks the outbound permission model from a flat allow-list into three
per-extension tiers (internal / restricted / full), addressing the ask for
extensions that can only reach pre-approved numbers plus extensions with
full US calling, while internal extension-to-extension dialing and ring
groups stay ungated for everyone regardless of tier.

Permissions now live in pstn-permissions.conf, read by the dialplan via
Asterisk's AST_CONFIG() on every call instead of being baked into static
dialplan text - editing that file takes effect on the next call, no
Asterisk restart and no re-running the installer. "update in place" mode
never touches this file (same protection this repo's update-mode
convention already gives .env/firewall/Caddy config); only a "fresh"
reinstall (with confirmation) or the web UI change it.

Adds a "PSTN Trunk" tab to services/security-dashboard.sh: lists every
extension (parsed from pjsip.conf) with its live tier and approved numbers,
editable with no restart - this is what makes the tier model actually
manageable day to day. Extracted the dashboard's systemd-unit writing into
its own function so "update" mode refreshes it too (previously only fresh
installs did), and generalized both the dashboard and the trunk service to
detect either asterisk-digital-ocean or the home/LAN asterisk install.

Inbound ring-group membership now checks each member's tier live per call
via an unrolled per-member dialplan block (full always rings, restricted
only if the caller's number is approved, internal never rings) rather than
a single static Dial() string.

Caught and fixed two real bugs during testing against a sandboxed vendor
copy and a live instance of the (stdlib-only) Python dashboard app:
- Asterisk Goto/GotoIf argument parsing: ring<ext>/skip<ext> are named
  priorities within the same extension (declared via "same => n(label),..."),
  not separate exten => entries, so jumping to them needs the single-argument
  Goto(label) form - the two-argument Goto(label,1) form used initially
  addresses a different, nonexistent extension named "label" instead.
- A security-relevant REGEX() direction issue: the inbound Caller-ID check
  initially interpolated attacker-influenced call data into the PATTERN side
  of a REGEX() match rather than the tested-string side, which would let a
  crafted Caller-ID forge a match against an unrelated approved-numbers
  entry. Fixed by keeping the admin-controlled approved-list as the pattern
  and the live call data as the string being tested, consistently on both
  the outbound and inbound checks.

Verified end-to-end: dialplan/pjsip generation and vendor-file patching
(idempotent, syntax-checked) as before, plus the new permission-file
round-trip between bash and Python, and the dashboard's new API endpoints
exercised against a real running Python server (extension parsing, tier
changes, number normalization, invalid-input rejection, atomic file writes).
2026-07-21 23:59:50 +00:00
Claude 78f4202386 security-dashboard: unwhitelist/ban ASNs, independent Basic Auth layer
- CrowdSec tab: per-ASN "Unwhitelist" (drop from the exempt filter, future
  traffic evaluated normally) and "Unwhitelist + Ban" (also immediately
  bans every IP CrowdSec has on record for that ASN, for accidental-
  whitelist cases) buttons. set_asn_exempt now allows clearing the list
  down to zero ASNs, needed to unwhitelist the last remaining entry.
- New sudoers permission (cscli decisions add --ip * --duration * --type
  ban --reason *) scoped narrowly, list-form subprocess args only.
- Caddy/Authelia config factored into _secdash_configure_caddy() and a new
  _secdash_remove_caddy_block() (whole-block delete-and-regenerate, not
  in-place patching) so "update" mode can now offer to reconfigure it.
- Installer offers an independent HTTP Basic Auth layer in front of
  Authelia (Caddy basicauth, generated via `caddy hash-password`) so a
  future Authelia bug/misconfig alone isn't enough to expose a page that
  can delete active security bans.
2026-07-21 21:29:32 +00:00
Claude 9770232227 security-dashboard: label ASN exemptions from alert history too
Currently-exempt ASNs with no active ban (e.g. T-Mobile once its bans
stop firing) had no carrier name to show, since the name lookup only
looked at cscli decisions list (active bans only). Add a
cscli alerts list-based fallback (includes expired/resolved alerts)
and merge it into the name lookup used by /api/asn-exempt.
2026-07-21 21:19:45 +00:00
Claude 9e98c90a57 Show carrier/ASN and country in the CrowdSec bans table
Confirmed the real cscli decisions list -o json structure live rather
than guessing again: AS number/name and country live on each alert's
"source" object (source.as_number, source.as_name, source.cn), not on
the individual decision. Surfaces this as a "Network / Carrier" and
"Country" column in the bans table, adds a per-row "Exempt ASN" button
that appends straight to the Asterisk brute-force ASN exemption list,
and labels the exempt list's own entries with carrier names (pulled
from current ban data where available) instead of showing bare numbers.
2026-07-21 21:07:41 +00:00
Claude 880bba86ed Fix security-dashboard binding to 127.0.0.1 instead of 0.0.0.0
Confirmed live: Caddy (in a container) reaches this via
host.docker.internal, a Docker bridge gateway IP, not localhost — a
loopback-only bind refuses that connection outright ("dial tcp
172.17.0.1:8092: connect: connection refused"), even though curl from
the host itself worked fine on 127.0.0.1. Bind to 0.0.0.0 and rely on
UFW for the actual access scoping instead, matching every other
host-network service in this repo (e.g. the Asterisk web admin, which
already binds this way successfully with the same
ufw_allow_from_caddy_net pattern).
2026-07-21 20:34:06 +00:00
Claude f45fdc1c96 Fix install function name mismatch for security-dashboard
Confirmed live: setup.sh's run_service() calls install_${name} with
no hyphen-to-underscore conversion, so a hyphenated service name needs
a literally-hyphenated function name (install_security-dashboard, not
install_security_dashboard) to be found at all — got this wrong on
first pass by following CLAUDE.md's own (incorrect) guidance, which
said to convert hyphens to underscores. Every other hyphenated service
in the repo (asterisk-digital-ocean, wolf-pair, mail-archiver,
drum-rhythm-game) already keeps hyphens literal; corrected CLAUDE.md
to match actual practice instead of the other way around.
2026-07-21 20:19:58 +00:00
Claude 009aaa017b Add security-dashboard: Asterisk failed-connections + CrowdSec bans, one page
New service, native on the host (not Docker) so it can call cscli and
read Asterisk's security log directly without bridging the
container/host boundary or exposing CrowdSec LAPI credentials to a
containerized frontend.

- Security Log tab: parses ~/docker/asterisk-digital-ocean/logs/full
  for SIP auth failures (wrong password, unknown extension, etc.) with
  timestamp/account/remote IP, classified by severity.
- CrowdSec tab: current bans via cscli, a delete/unban button per
  entry, and ASN-exempt management for the Asterisk brute-force
  scenarios (services/crowdsec.sh) without SSHing in.
- Link out to the existing Asterisk web admin (reads its domain from
  asterisk-digital-ocean's own .env, doesn't hardcode or embed it).

Runs as a dedicated unprivileged system user (secdash), with sudo
scoped to exactly three commands via /etc/sudoers.d/security-dashboard
(cscli decisions delete --id <digits>, cscli decisions list -o json,
systemctl restart crowdsec) — validated with visudo -c. Listens on
127.0.0.1 only, reachable through Caddy, and refuses to proceed without
explicit confirmation if no Authelia (local or remote) is configured,
since this page can delete active security bans.

Stdlib-only Python (no framework), matching the RAM-conscious pattern
already used for Easy Asterisk's own web admin. All embedded code
(bash, Python, JS) syntax-checked; the generated sudoers rule
validated with visudo -c -f.
2026-07-21 13:10:04 +00:00