Commit Graph
271 Commits
Author SHA1 Message Date
Claude ff61e8f733 Chain Security Dashboard + PSTN trunk setup into the Asterisk install flow
asterisk.sh and asterisk-digital-ocean.sh now offer, at the end of both
their fresh-install and update-mode paths, to also set up the Security
Dashboard and configure a real PSTN trunk in the same run - one script
walks through the whole stack instead of needing to separately remember
and run `sudo ./setup.sh security-dashboard` / `sudo ./setup.sh pstn-trunk`
afterward.

Both target services keep their own register_service call and stay fully
independently invocable - this is purely an additive convenience layer
(_asterisk_offer_dashboard_and_trunk / _asterisk_do_offer_dashboard_and_trunk),
not a replacement. An already-installed piece is silently refreshed
(install_security-dashboard/install_pstn-trunk each already have their own
update/fresh/cancel reinstall-mode gate, so calling them again just does
the right thing); a not-yet-installed piece gets one y/n instead of every
detailed prompt firing.

Guarded with declare -F so a standalone `sudo bash asterisk.sh` copy (no
sibling services/*.sh files sourced) skips both cleanly with an
explanatory message instead of erroring on an undefined function.

Verified: full sourcing simulation resolves all four install_* functions
correctly, `setup.sh --dry-run --unattended asterisk` and
`asterisk-digital-ocean` both complete cleanly end-to-end, and
`setup.sh --list` still shows all four services as independently
selectable.

Documented the pattern in CLAUDE.md under a new "Chaining into another
service from within your own" section for future contributors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-24 12:38:45 +00:00
Claude 6f5ed30469 Replace Caddy path-proxy with a fully native Asterisk Admin tab
Supersedes the previous commit's reverse-proxy approach entirely: instead
of Caddy routing to Easy Asterisk's own separate vendored web admin
process, the dashboard now reimplements that admin's functionality
natively - one process, one page, real tab-switching, no separate app to
proxy, patch, or embed. Reverts asterisk.sh/asterisk-digital-ocean.sh's
WEBADMIN_BASE_PATH vendor patching and Caddy-skip logic back to their
pre-proxy state (confirmed identical via diff) since neither is needed
anymore.

security-dashboard.sh additions:
- ea_* functions covering full device/category/room parity with
  vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's own web admin: list/add/
  delete/rename/change-category for devices, list/create/delete/rename for
  categories, list/create/delete/rename/add-member/remove-member for
  rooms, plus live registered/unregistered status. Reads go straight
  through the host-side bind-mounted config files (same as the existing
  list_extensions() already does for pjsip.conf); writes go through
  `docker exec -i <container> tee <path>` instead of a direct host-side
  write, since Easy Asterisk's container writes these files as its own
  internal user and a host-side write would just be fighting that
  ownership again on the next container restart.
- Found and fixed a real bug (inherited from the vendored admin's own
  template, not introduced here): a plain non-mobile LAN device leaves
  both the keepalive and ice template lines empty, producing two
  consecutive blank lines inside the endpoint's pjsip.conf stanza instead
  of one - which broke the delete/rename/category-change parsers' "blank
  line ends this device's block" boundary detection, leaving an orphaned
  tail of config behind on delete. Fixed by building the endpoint block
  from a filtered line list instead of positional template blanks.
  Confirmed via a full synthetic add/rename/category-change/delete cycle
  against realistic pjsip.conf/categories.conf/rooms.conf fixtures (with
  docker exec mocked to a local file) - round-trips back to the original
  fixture correctly.
- New plumbing: _secdash_grant_asterisk_access grants read-only access to
  categories.conf/rooms.conf's directory (separate from pjsip.conf's,
  confirmed against the vendored source - /etc/easy-asterisk/*, not
  /etc/asterisk/*); _secdash_write_sudoers adds six exact (no wildcards)
  docker-exec sudoers entries scoped to the one Asterisk container
  actually installed, validated live with visudo -c; _secdash_write_systemd_unit
  passes the new ASTERISK_EA_CONFIG_DIR/ASTERISK_EA_CONTAINER env vars and
  adds the config dir to ReadOnlyPaths, validated live with
  systemd-analyze verify.
- New UI: Asterisk Admin tab with Devices/Categories/Rooms cards, sortable
  tables matching the existing style, inline category-reassignment
  dropdowns, and per-room member chips with an inline add-member picker.
  Nav button visibility now checks live container reachability
  (/api/ea-status) instead of just Asterisk-install detection.

Still unverified: the actual `docker exec` calls (module reload, dialplan
rebuild, live status) against a real running Easy Asterisk container -
only the file-parsing/transformation logic itself has been exercised, via
mocked writes, not the real container plumbing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-24 12:14:27 +00:00
Claude d449586dde Replace the Asterisk Admin iframe with a native Caddy path-proxy
No more cross-origin iframe: the Security Dashboard now reverse-proxies
the real Asterisk web admin natively at /asterisk-admin/ on its own
domain via Caddy's handle_path, instead of embedding a separate site in
a frame. One domain, one login wall, for both.

- services/asterisk.sh / services/asterisk-digital-ocean.sh: patch the
  vendored web admin's one hardcoded absolute API path
  (`const API_BASE = '/api'`, confirmed via the real vendored source to
  be the only absolute-path reference anywhere in its HTML/JS - no other
  hrefs, no login-page redirect, plain HTTP Basic Auth instead) so it
  resolves correctly when mounted under a sub-path, via a new
  WEBADMIN_BASE_PATH env var threaded through entrypoint.sh. Verified
  against the real vendored file: patched output is
  '/asterisk-admin/api' with the env var set, unchanged '/api' without
  it. Skip each service's own dedicated admin Caddy domain when the
  Security Dashboard is already installed, since it fronts the admin
  instead.
- services/security-dashboard.sh: _secdash_configure_caddy now accepts
  the admin's port and Asterisk's own directory/domain, path-routes
  /asterisk-admin/* alongside the dashboard's own handle{} block, and
  writes WEB_ADMIN_BASE_PATH into Asterisk's .env + restarts that
  container once proxying is confirmed live. Defaults the dashboard's
  own domain prompt to the droplet's DOMAIN_NAME when detected, since
  Caddy's SIP-TLS cert sync already depends on serving that exact
  domain. Removed the old CSP frame-ancestors patching and the iframe
  itself; the nav is now a plain link, shown only once the proxy is
  confirmed wired up.
- Fixed _secdash_remove_caddy_block's marker match to tolerate the
  dashboard's reverse_proxy line now living one indent level deeper
  (inside its own handle{} block) - verified against a synthetic
  Caddyfile that it still finds and removes exactly the right block.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-24 11:45:11 +00:00
Claude 4f102b12c1 One dashboard to manage Asterisk: core tabs always on, optional tabs self-hide
Restructure the nav so it reflects what's actually installed on this box,
letting one dashboard URL cover everything from a bare LAN Asterisk box up
to a full droplet with a trunk and CrowdSec:

- Security Log and a new Extensions tab (Groups + Internal SIP messaging,
  split out of the old "PSTN Trunk" tab) are always available - they only
  need Asterisk itself, not a trunk or CrowdSec.
- Asterisk Admin, PSTN Trunk, and CrowdSec each check their own live
  install state on every page load and hide their own nav button entirely
  when not present, instead of showing an empty/placeholder tab.
- Add crowdsec_installed() (checks for /usr/bin/cscli) and a
  /api/crowdsec-status endpoint, mirroring the existing pstn_installed()/
  /api/pstn-status pattern.

This fixes the earlier design where messaging/groups management lived
inside the PSTN Trunk tab even though both work with plain Asterisk and no
trunk at all - hiding that tab would have taken them down with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-24 05:56:54 +00:00
Claude 17a21c076f Dashboard UI cleanup: column sorting, iframe fix confirmed, messaging chips
- Security Log tab: replace the per-column text filter row with clickable
  sortable column headers (same pattern as the CrowdSec bans table).
- PSTN Trunk tab: add sortable headers to the permissions, personal-DID
  (numeric on DID), and groups tables.
- Move the "Internal SIP messaging" card to the bottom of the PSTN tab and
  replace its per-row table+Save-button layout with a checkbox chip row
  and a single Save-changes button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-24 03:45:13 +00:00
Claude c893751859 Document group-owned personal number assignment
Update the dashboard's own Personal Numbers card description and the
Anveo Direct setup guide to mention assigning a DID to a group instead
of a single extension.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-24 03:03:55 +00:00
Claude dd3ea131ac Allow assigning a personal number to a group, not just a single extension
The dashboard's Personal Numbers card now accepts a group (stored as
"@GroupName", unambiguous against a same-named numeric extension) as well
as a plain extension. A group-owned DID rings every CURRENT member whose
own tier/approved-numbers authorize the caller, computed fresh on every
call by a generated pstn-personal-group-ring.sh (invoked via the
dialplan's SHELL() function) rather than unrolled at install time, since
group membership can change any time via the dashboard with no reinstall
- unlike the shared ring-group, which is fixed at install/update time.

Applies the identical per-member permission check the shared ring-group
already bakes into the dialplan, just computed in a plain shell loop
against the same two config files - group ownership doesn't bypass the
tier/approved-numbers model. Tested standalone against mock config data
(full/restricted/internal mix, matching/non-matching caller, empty and
nonexistent groups) - all four cases behaved correctly. The dialplan's own
SHELL() invocation is still unverified against a real call.

Group ownership never touches pstn-permissions.conf's personal_did
(outbound Caller-ID override) field, since there's no single extension to
hang that on for a group.
2026-07-24 03:00:13 +00:00
Claude f5e93ec35b Build the interactive Anveo Direct account-setup walkthrough
Replaces the static wall of warnings with a real 5-step guided walkthrough
(account/funding, DID ordering, both trunk objects, confirmed rate),
pausing for confirmation between each step and showing this box's actual
public IP inline for direct copy-paste into Anveo's Authorized IP
Addresses and SIP Trunk forwarding fields. Built directly from
docs/anveo-direct-setup-guide.md now that the whole flow is confirmed
working end-to-end, so a fresh run shouldn't need the trial-and-error
session that guide was written from.
2026-07-24 01:29:06 +00:00
Claude 3ee3a45367 Fix personal-DID inbound lookup: 10-digit storage vs 11-digit EXTEN mismatch
Confirmed live: PSTN_PERSONAL_OWNER came back empty despite a real
assignment existing, because the Security Dashboard stores personal DIDs
as 10 digits (PERSONAL_DID_RE = ^\d{10}$) while Anveo's inbound INVITE
delivers the called number as 11-digit E.164 (with leading 1) in ${EXTEN}
- confirmed via PSTN_DID_CALLED=15557776655 in the live test. AST_CONFIG()
looking up an 11-digit key never found the 10-digit section.

Normalizes to 10 digits before the lookup (strips a leading digit only
when the string is actually 11 characters, so this doesn't misfire against
a provider that already sends 10). Also fixed the dashboard's own DID
input placeholder, which contradicted its own 10-digit validation error.
2026-07-23 22:08:24 +00:00
Claude b5b0edef62 Dedupe RING_EXTS before generating the inbound dialplan
Confirmed live: a ring-group list with a repeated extension ("201 301 313
312 313") generated duplicate ring<ext>/skip<ext> priority labels in the
same [from-pstn-trunk] extension. Asterisk doesn't error on this - it
silently resolves Goto() to the wrong occurrence and loops between the two
blocks forever, so the call never reaches the actual Dial() and nothing
ever rings, with no error to point at the cause. Dedupes (first-occurrence
order preserved) regardless of how the duplicate got in there.
2026-07-23 20:54:43 +00:00
Claude a797c9845f Fix EXTEN corruption across every Goto to a named dialplan extension
Confirmed live: Dial() actually placed the call to "PJSIP/pstn_check_busy@
pstn-trunk" instead of the dialed number. Root cause is systemic, not one
line — Goto(label,1) to a *different named extension* (as opposed to a
same-extension priority label, which _pstn_ring_member_block already knew
to avoid) resets ${EXTEN} to that label's own name. Every helper extension
downstream of a Goto (pstn_check_allow_out, pstn_check_busy,
pstn_intl_check_country, pstn_intl_country_denied, pstn_personal_inbound)
was reading ${EXTEN} expecting the originally-dialed number and getting
its own extension name instead.

This wasn't just cosmetic: pstn_check_allow_out's REGEX approved-number
check compared against the label name too, meaning restricted-tier
extensions could never successfully place a call regardless of whether
the number was actually approved.

Fixed by capturing the real dialed number into PSTN_DIALED (outbound) /
PSTN_DID_CALLED (inbound) at each entry point, before any Goto, and using
that instead of ${EXTEN} in every downstream extension.
2026-07-23 20:24:02 +00:00
Claude 4b080dd845 Fix PSTN_CALLER extraction — CHANNEL(peername) isn't valid on this Asterisk build
Confirmed live: "Unknown or unavailable item requested: 'peername'" left
PSTN_CALLER empty, which made AST_CONFIG() error ("requires a category")
and PSTN_TIER come back empty too — every call fell through to "no PSTN
permission" and got busy-signaled regardless of the extension's actual
tier. Replaced with CUT(CHANNEL,/,2) then CUT(...,-,1), extracting the
endpoint name from the channel name itself (e.g. "PJSIP/201-00000006" ->
"201") — a plain string operation with no dependency on which channel
function items this particular Asterisk build supports.
2026-07-23 20:14:03 +00:00
Claude 4457b471d9 Fix off-by-one in the NANP outbound dial patterns
_1NXXNXXXXX / _NXXNXXXXX were one digit short (10/9 characters instead of
the correct 11/10 for a full NANP number: 1 + area code(3) + exchange(3) +
subscriber(4)). Asterisk's exact-length pattern matching never matched a
real dialed number as a result — confirmed live via "extension not found
in context 'intercom'" on every outbound test call, traced by dumping
`dialplan show intercom` and counting characters against the actual
pattern. This was the real root cause underneath everything else fixed
along the way (stale settings file, the #include not reaching the live
config) — those were real bugs too, but this one was still there
underneath all of them.
2026-07-23 19:57:15 +00:00
Claude 09113f4e41 Don't let a malformed .pstn-trunk.env crash the whole install under set -u
setup.sh runs with set -uo pipefail, and source-ing a settings file with
any stray unexpanded/unset variable reference (confirmed live on an older
deployed copy) was fatal to the entire script, not just that one prompt.
Wraps the source in set +u/-u and falls back to sane defaults for anything
missing, but still hard-errors with a clear message if TRUNK_SERVER/
TRUNK_DID themselves are missing, rather than silently writing a broken
trunk config.
2026-07-23 17:46:16 +00:00
Claude 02bf2fe3a2 Mirror SIP messaging + presence alerts to asterisk.sh (LAN edition)
Ports the internal SIP MESSAGE dialplan enforcement, extension presence
(online/offline) ntfy alerts, and the live-config-include patching from
asterisk-digital-ocean.sh, for feature parity between the two Asterisk
flavors. The pstn-trunk.sh live-include fix already applied to both, since
that's shared code parametrized by which flavor is installed.
2026-07-23 17:19:31 +00:00
Claude a33b8e7b23 Simplify pstn-trunk.sh: point to the dashboard instead of CLI permission prompts
Removed the full/restricted-extension, approved-numbers pool/whiptail,
messaging-extensions, and personal-DID-assignment prompts from the
installer. All four are already live-editable, no-restart-needed settings
the Security Dashboard's PSTN Trunk tab manages end to end — the CLI wall
of prompts (that then needed a full reinstall to change) just duplicated
that with more friction. Every extension now starts at internal tier (no
PSTN, no messaging) until granted via the dashboard. Kept: provider/DID
setup, concurrency caps, and the inbound ring-group (basic trunk wiring,
not a permission). Also updated the generated README and CLI summary to
match, and refreshed the stale "known gap" messaging section that predated
the dialplan enforcement built earlier this session.
2026-07-23 17:16:42 +00:00
Claude 3f033da635 Fix trunk/messaging #include lines never reaching an existing install's live config
Confirmed live via a real failed test call ("extension not found in context
'intercom'"): Easy Asterisk's entrypoint only regenerates pjsip.conf/
extensions.conf if they don't already exist. Patching the vendor generator
functions (as both pstn-trunk.sh and the earlier SIP MESSAGE work did) only
takes effect on a future full regeneration — a box that already has devices
configured, the normal case, never triggers one on a plain restart, so the
#include lines never actually reached the live files despite the generator
patch succeeding.

Both installers now also patch the live pjsip.conf/extensions.conf directly
(same anchors, idempotent) and force a reload, so the trunk/messaging
dialplan actually takes effect immediately regardless of whether Easy
Asterisk ever regenerates its config on its own.
2026-07-23 17:10:13 +00:00
Claude 67ded2eb38 Use the confirmed Anveo Direct Prime rate as the RATE_PER_MIN default
0.00388/min for standard US-to-US domestic, confirmed against Anveo's own
Prime rate card CSV (the route set the outbound trunk's LCR config actually
pulls from) rather than the earlier ~0.001 ballpark guess. Defaults to it
automatically when the Anveo Direct quick-pick was chosen.
2026-07-23 16:55:22 +00:00
Claude fdd2aaa1d3 Add shared approved-numbers pool + whiptail multi-select per extension
Restricted extensions previously required retyping the full 11-digit
number list from scratch at each extension's own prompt. Now offers
entering a pool once, then a whiptail checklist per extension to pick a
subset (falls back to typing numbers directly, or 'all' for the whole
pool, when whiptail isn't available or the run is unattended).
2026-07-23 16:49:19 +00:00
Claude 16da6c1372 Clarify PSTN tier prompts — enter extension numbers, not tier names
User feedback: reading the tier list (internal/restricted/full) right before
"Full-PSTN extensions:" read as ambiguous about whether to type the tier
word or extension numbers. Reworded both prompts to say "extension NUMBERS"
explicitly and added inline examples (e.g. '999 213').
2026-07-23 16:44:35 +00:00
Claude 4c0326ca5f Update Anveo Direct dial-prefix warning with live-confirmed findings
The dialing-prefix ambiguity is resolved: Anveo's own trunk edit screen has
no required-field marker on Dialing Prefix, confirming it's optional. Also
documents the separate Outbound Service Trunk vs. inbound SIP Trunk
distinction discovered while walking through a real account setup.
2026-07-23 16:05:52 +00:00
Claude 3661c8fbca Wire up internal SIP MESSAGE enforcement using a dedicated dialplan context
Confirmed against a live install's pjsip.conf/extensions.conf that every
endpoint falls back to context=intercom for messaging (message_context
blank), and that [intercom] owns one exact-match per-device dial pattern
regenerated on every dialplan rebuild. Rather than risk racing that, every
endpoint now gets message_context=sip-messaging (patched into both of Easy
Asterisk's device-creation code paths, plus a one-time migration for
existing devices), routing messages to their own [sip-messaging] context
gated on the existing pstn-permissions.conf messaging flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-23 15:03:03 +00:00
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 3705fb5fcc Add Anveo Direct/VoIP.ms known-provider quick-pick to pstn-trunk.sh
Prompts now start with a 1/2/3 choice (Anveo Direct / VoIP.ms / manual)
that pre-fills already-known values - Anveo Direct's sbc.anveo.com
hostname and its 4 published signaling IPs, so they don't need retyping
from memory - plus provider-specific account-setup reminders. Every
value stays editable at each subsequent prompt; this only changes
defaults, not behavior, so existing manual-entry users see no change.

Flagged rather than silently trusted: Anveo Direct's own Outbound Trunks
page documents dialing as [PREFIX]PHONENUMBER@sbc.anveo.com, which
contradicts an earlier no-prefix-needed finding from their FAQ. The
dialplan still dials the bare number (matching the older finding) - the
quick-pick prints a loud warning to verify the Prefix field can be left
blank before relying on this, rather than guessing and hardcoding prefix
handling without live confirmation either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-23 05:08:30 +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 2d9dd80271 Add per-country international rates and active-hangup on kill-switch trip
- CLI country menu now asks for a per-minute rate when a country is added,
  stored alongside its code/name in pstn-intl-allowed.conf. The usage-alert
  script buckets each month's outbound calls into domestic vs. per-country
  international (longest-code-prefix match on the dialed digits) and prices
  each bucket at its own rate instead of one blended flat rate, which badly
  under/over-estimated international cost.
- While the kill-switch is tripped, the periodic script now also force-hangs
  up any PSTN call already in progress (docker exec + Asterisk's "channel
  request hangup" against the trunk's active channels), not just new calls
  - closing the gap where an in-progress call just kept running until it
  ended naturally. Runs every check while tripped, not only the run that
  trips it, to close a start-of-call race.
- Fixed a bug caught by testing: the hangup sweep was nested inside the
  "does the call log exist" guard, so it silently never fired if the log
  was missing/rotated while tripped. Moved it outside that guard.
- Documented an honest "how bulletproof is this?" breakdown in the
  generated README: the provider's own real-time $0-balance block is the
  actual hard ceiling (confirmed for Anveo Direct), the kill-switch here is
  an estimate-based second layer, not a replacement for funding the prepaid
  account at your actual risk tolerance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
2026-07-22 15:52:08 +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 26f3f08304 Block non-US NANP area codes - NANP != US, a real gap the design carried
Anveo Direct's "Call Security" rate-cap feature (found while evaluating
their account portal) prompted a closer look at our own NANP dialplan
pattern, which turned out to have a real gap: NANP covers Caribbean/
Atlantic nations and several US territories, not just the US - these dial
exactly like a normal 10-digit US number but bill at international/
premium rates, a well-known toll-fraud/"one-ring scam" vector precisely
because the format looks domestic. The existing _1NXXNXXXXX/_NXXNXXXXX
patterns matched all of them equally, meaning any extension (including
"full" tier) could reach premium Caribbean/territory numbers despite the
whole point of the design being "US only, no premium-rate destinations."

Verified the area code list against multiple sources rather than relying
on training-data recall alone, then added an explicit block on all 27
(Bahamas, Barbados, Anguilla, Antigua & Barbuda, British Virgin Islands,
US Virgin Islands, Cayman Islands, Bermuda, Grenada, Turks & Caicos,
Jamaica, Montserrat, Northern Mariana Islands, Guam, American Samoa, Sint
Maarten, Saint Lucia, Dominica, Saint Vincent, Puerto Rico, Dominican
Republic, Trinidad & Tobago, Saint Kitts & Nevis) - checked via REGEX()
against the extracted area code, before permission-tier checks, so it
applies regardless of tier. Tested the extraction/matching logic against
both blocked and legitimate US area codes.

Updated docs/pstn-calling-voipms-plan.md's toll-fraud reasoning, which had
previously (incorrectly) claimed NANP-only restriction alone fully closed
the cost-per-minute risk - noted as a real, live gap rather than glossing
over it. Also noted Anveo Direct's provider-side Call Security setting
(rate cap + max call duration) as a complementary backstop worth
configuring on their account portal directly, independent of this repo's
code.
2026-07-22 13:22:46 +00:00
Claude c8cc2271a7 Support providers with multiple inbound signaling IPs (e.g. Anveo Direct)
VoIP.ms's one-POP-one-hostname model meant a single resolved IP was enough
for the trunk's identify match=, but Anveo Direct (researched as an
alternative after finding VoIP.ms's ToS selectively requires government ID
at signup) sends inbound signaling from a fixed set of published IPs
instead. Generalizes the pjsip include to accept multiple match= lines in
one identify object (PJSIP allows repeating it to build one match set - no
separate config objects needed per IP), with a new install prompt for any
additional known source IPs beyond the auto-resolved one.

No dial-prefix support was added despite earlier research suggesting Anveo
Direct needed one - verified against their current official FAQ (fetched
directly rather than trusting a stale search-result claim) that plain
number dialing works with no prefix.

Fixed a real bug caught while testing the update-mode round trip:
.pstn-trunk.env was written with unquoted values, which breaks `source`
entirely once any value contains a space - a multi-IP list or a
multi-word provider name (e.g. "Anveo Direct") would make bash try to run
the second word as a command. Predates this change (a multi-extension
ring group has the same shape); only surfaced by actually re-sourcing the
file and reapplying, not by inspecting generator output. Fixed by quoting
every value.

Updated docs/pstn-calling-voipms-plan.md with the Anveo Direct/DIDLogic/
SIP.US research findings and what's now implemented vs. still open
(real-time balance enforcement at Anveo specifically is unconfirmed).
2026-07-22 03:58:41 +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 1e2a3743ab Rework PSTN trunk: role-based permissions, ring-groups, ntfy spend alerts
Generalizes services/pstn-trunk.sh (renamed from voipms-trunk.sh in the
prior commit) away from VoIP.ms specifics - any IP-authenticated SIP
provider works, VoIP.ms is just the suggested default. Adds:

- Role-based outbound permission: a configurable allow-list of extensions
  that may dial PSTN numbers (regex-gated on CHANNEL(peername)), separate
  from internal extension-to-extension dialing which stays open to everyone
  regardless. Blank list preserves the original "everyone can dial out"
  behavior.
- Inbound ring-group: rings a configurable list of extensions instead of a
  single hardcoded one.
- ntfy alerts: immediate on denied (unauthorized extension) or rejected
  (concurrency cap hit) calls, plus an hourly cron-driven check that alerts
  once per month when estimated spend crosses a threshold and every hour
  call volume looks like a burst. Uses a self-contained pipe-delimited call
  log rather than Asterisk's CDR, to avoid depending on CDR module
  availability and CSV comma-quoting.
- Settings persisted to .pstn-trunk.env so "update in place" reapplies
  everything from that file instead of fragile re-parsing out of generated
  Asterisk config (which had a real bug: update mode was extracting the
  wrong Dial(PJSIP/...) line).

Tested end-to-end against a sandboxed copy of the real vendor files:
permission-gate regex, ring-group dial-string construction, ntfy line
injection/removal, and the usage-alert script's threshold/burst/monthly-
dedup logic all verified with synthetic data. Caught and fixed a sed `&`
escaping bug in the ring-group substitution before it shipped (RING_DIAL
contains literal `&` join characters, which sed's replacement syntax
otherwise treats as "insert the match").
2026-07-21 23:38:08 +00:00
Claude 6b0e378123 Rework PSTN trunk: role-based permissions, ring-groups, ntfy spend alerts
Renames services/voipms-trunk.sh to services/pstn-trunk.sh and generalizes
it away from VoIP.ms specifics - any IP-authenticated SIP provider works,
VoIP.ms is just the suggested default. Adds:

- Role-based outbound permission: a configurable allow-list of extensions
  that may dial PSTN numbers (regex-gated on CHANNEL(peername)), separate
  from internal extension-to-extension dialing which stays open to everyone
  regardless. Blank list preserves the original "everyone can dial out"
  behavior.
- Inbound ring-group: rings a configurable list of extensions instead of a
  single hardcoded one.
- ntfy alerts: immediate on denied (unauthorized extension) or rejected
  (concurrency cap hit) calls, plus an hourly cron-driven check that alerts
  once per month when estimated spend crosses a threshold and every hour
  call volume looks like a burst. Uses a self-contained pipe-delimited call
  log rather than Asterisk's CDR, to avoid depending on CDR module
  availability and CSV comma-quoting.
- Settings persisted to .pstn-trunk.env so "update in place" reapplies
  everything from that file instead of fragile re-parsing out of generated
  Asterisk config (which had a real bug: update mode was extracting the
  wrong Dial(PJSIP/...) line).

Tested end-to-end against a sandboxed copy of the real vendor files:
permission-gate regex, ring-group dial-string construction, ntfy line
injection/removal, and the usage-alert script's threshold/burst/monthly-
dedup logic all verified with synthetic data. Caught and fixed a sed `&`
escaping bug in the ring-group substitution before it shipped (RING_DIAL
contains literal `&` join characters, which sed's replacement syntax
otherwise treats as "insert the match").
2026-07-21 23:37:27 +00:00
Claude 2ac2982e38 Add voipms-trunk service: US-only outbound PSTN, max 3 concurrent calls
Adds a VoIP.ms SIP trunk on top of asterisk-digital-ocean: IP-authenticated
trunk (no password stored), NANP-only outbound dialplan, a global 3-call
concurrent cap via GROUP()/GROUP_COUNT(), and inbound routing to one
extension. Config lives in its own include files rather than being
appended directly to pjsip.conf/extensions.conf, since Easy Asterisk fully
regenerates both from its own internal state — the includes are patched
into the vendor's generator functions so they survive that regeneration.

Wires the new service into setup.sh's is_installed() and README's services
table, and updates docs/pstn-calling-voipms-plan.md to reflect what's now
implemented vs. still open (spend/volume alerting, live-account
verification).
2026-07-21 23:13:59 +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
Claude c7dac67234 Fix fatal CrowdSec crash-loop in ASN-exempt Asterisk scenarios
Confirmed live: unquoted integer literals in the ASN exclusion filter
(evt.Enriched.ASNNumber in [21928, 14593]) made CrowdSec fatal-crash-loop
at startup with "cannot use string as type int in array" — ASNNumber is
a string field internally despite printing as a bare number in cscli
output, same as IsoCode in the geo-allowlist scenario. Quote each ASN
as a string to match, exactly like the working geo-allowlist pattern.
2026-07-21 12:57:00 +00:00
Claude c2d74f9520 Add optional ASN-exempt Asterisk brute-force scenarios to crowdsec.sh
Confirmed live: a phone roaming WiFi<->mobile on a CGNAT carrier
(Starlink, T-Mobile home internet) got banned by crowdsecurity/asterisk_bf,
either from its own re-registration burst or collaterally from another
customer sharing the same rotating public IP. Forks asterisk_bf and
asterisk_user_enum locally with an ASN exclusion added to their filter,
disabling the hub originals so events aren't double-processed. Scoped
narrowly to Asterisk auth-failure detection only — SSH, web scanning,
and the geo-allowlist scenario are all unaffected, so this doesn't
broadly exempt the carrier from every protection on the box.
2026-07-21 12:47:48 +00:00
Claude 6dd5bbc2ce Include the check/unban commands directly in CrowdSec ntfy alerts
Confirmed live: a legitimate SIP device on a CGNAT ISP (Starlink,
T-Mobile home internet) got collaterally banned by
crowdsecurity/asterisk_bf alongside actual bad actors sharing the same
carrier IP. The alert now includes the exact commands (with the banned
IP substituted in) instead of just naming the ban, so recovering from
this doesn't require remembering or looking up cscli syntax.
2026-07-21 12:36:02 +00:00
Claude f7db82333a Add authelia.sh support for protecting multiple apex domains
New "Add another protected domain to this instance" option on re-run,
via add_authelia_domain(): appends a session.cookies entry and an
access_control.rules entry (both YAML lists Authelia natively supports)
plus a Caddy auth.<domain> portal block for the new domain, all on the
same Authelia + Redis container instead of standing up a second full
stack. Each domain gets its own login/session, sharing one user
database — the right fit when a single (possibly upsized) droplet ends
up fronting more than one domain, without doubling the RAM cost of a
second Authelia+Redis instance. Documents both this and the
already-working separate-instance path in CLAUDE.md, with the
per-approach tradeoffs.
2026-07-21 03:03:33 +00:00