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.
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.
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.
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.
Confirms services/authelia.sh's standalone pattern and
asterisk-digital-ocean.sh's local-vs-remote auto-detection already
support a second, fully independent instance on another machine with
no code changes needed. Documents the one real constraint: two
instances must not share the same AUTHELIA_DOMAIN, since the session
cookie scope and the auth.<domain> portal hostname would collide.
Misread the previous request as "add Russia to the allowed list" —
it meant the opposite: Russia should stay excluded, same as the other
high-risk/Eastern Europe entries already left out.
Excludes Bulgaria, Czechia, Hungary, Moldova, Poland, Romania, Slovakia,
and Ukraine per user request, while keeping the Balkans and Baltics
(several of which, e.g. Estonia, don't fit the same risk profile despite
the old Cold-War grouping). Russia added back to the allowed list per
explicit user request.
Opt-in prompt that bans any Caddy-fronted web request from outside an
editable North America + Europe country list, via a local CrowdSec
trigger scenario scoped to the existing "type: caddy" acquisition label.
Uses CrowdSec's bundled GeoLite2 enrichment data (already active with no
extra setup) rather than the firewall bouncer's separate MaxMind-key
country-CIDR feature, so no account signup is needed. SSH is untouched
so a bad edit can't lock out the session running the installer.
The generated auth.<domain> block's bare "reverse_proxy authelia:9091"
let Caddy recompute X-Forwarded-Host from its own incoming request
(always auth.<domain> itself) on every hop through it, overwriting
whatever a forward_auth caller elsewhere had already set for its own
domain. Confirmed live: a remote site's forward_auth check always
evaluated as if it were for the Authelia portal itself (bypass policy),
so 2FA silently never triggered for any domain going through it.
The previous fix used the {host} Caddy placeholder for X-Forwarded-Host,
but confirmed live it still evaluated to the upstream Authelia's own
hostname rather than the original site's — Caddy appears to rewrite the
outgoing request's Host to the upstream target before header_up
placeholders resolve for a scheme-qualified remote upstream, so {host}
echoed back the already-rewritten value. Since this site block only ever
serves one domain, hardcode it instead of depending on placeholder timing.
The remote-Authelia forward_auth block dialed a scheme-qualified upstream
(https://auth.example.com), which is a second Caddy hop. Caddy rewrites the
outgoing Host header to the upstream host for routing, and without an
explicit override X-Forwarded-Host picked up that rewritten value instead
of the original site's host. Authelia was evaluating every protected
domain as auth.example.com itself (bypass policy), so 2FA never triggered
for any domain behind the remote instance. Pin the forwarded headers to
the original request explicitly to fix it.
Both Caddy site block generators (the shared configure_caddy_for_service
helper, and asterisk-digital-ocean.sh's own inline template) wrote
reverse_proxy before the forward_auth/import authelia block. Caddy
doesn't reorder repeats of the same directive within a block — forward_auth
and reverse_proxy are the same directive family internally, so they run in
the order written. With reverse_proxy first, it handled and terminated
every request immediately; the auth check written after it never ran at
all. Full bypass on every domain using either generator with Authelia
protection, regardless of how correct the Authelia access_control rules
themselves were — confirmed live against a config that was otherwise
completely correct (default_policy: deny, explicit wildcard rule covering
the affected domain).
Affects every service that's ever passed `import authelia` or a
forward_auth block through configure_caddy_for_service (asterisk.sh,
wolf-pair.sh, and any future caller), plus asterisk-digital-ocean.sh's
own site block.
Moved the auth block before reverse_proxy in both generators.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Live-confirmed the retry fix from the previous commit wasn't enough:
still "Address already in use" after 20s of retries (10 attempts,
2s backoff), only to succeed on its own sometime after that. That
delay pattern is TIME_WAIT, not a process-death race — and this code
was never going to avoid it, because socketserver.TCPServer defaults
allow_reuse_address to False. (http.server.HTTPServer sets this for
you; the plain base class used here does not.) Without SO_REUSEADDR,
the kernel can refuse to rebind a port with a lingering TIME_WAIT
socket from the previous instance for up to 60s, regardless of
whether that old process is even still alive — which is also why the
entrypoint.sh fix waiting for the process to exit didn't help either.
Set socketserver.TCPServer.allow_reuse_address = True before binding.
This is the standard fix for exactly this symptom. Keeping the retry
loop from the previous commit too, for the (now much smaller) window
where network_mode: host still has no Docker-managed port mapping to
instantly free.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Complements the retry fix on the bind side: pkill only sends SIGTERM
and returns immediately, it doesn't wait for the process to exit and
release its socket. Under network_mode: host there's no Docker-
managed port mapping to tear down, so the next container's bind
attempt was racing however long this process actually took to die —
sometimes still holding the port when the next container started.
Poll for it to actually exit (up to 2s), falling back to SIGKILL if
it's still lingering, before proceeding with the rest of shutdown.
With a clean handoff here, the web admin's own bind-retry (previous
commit) should rarely even need to kick in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Live-confirmed: OSError: [Errno 98] Address already in use on the
web admin's TCPServer bind, right after a container recreate under
network_mode: host. Unlike bridge-mode port publishing, there's no
Docker-managed mapping to instantly free on teardown — the previous
container's own web admin process has to actually die first, and a
fast recreate-right-after-recreate can race that. The process crashed
immediately instead of retrying, so the web admin silently never came
up despite entrypoint.sh correctly launching it.
Retry the bind up to 10 times with a 2s backoff before giving up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
"pjsip reload" was never a valid Asterisk CLI command — confirmed
live: `asterisk -rx "pjsip reload"` returns "No such command 'pjsip
reload'". The real command is "module reload res_pjsip.so".
Every reload-after-change call in the script used the invalid form,
both in the interactive CLI (add/edit/delete device, transport setup,
TLS cert sync) and in every web admin mutation (add_device,
delete_device, rename_device, change_device_category) — all silently
no-op'd, since `asterisk -rx` just prints its own "no such command"
error to a discarded/redirected output and returns normally either
way. Endpoints only ever picked up new pjsip.conf entries after a
full container restart (which re-reads config from scratch at
startup) or a manual `module reload res_pjsip.so` — never from the
web admin's own reload call, live-confirmed: a device stayed
Unavailable with zero registration attempts logged until a manual
reload picked it up immediately.
Global replace across all 11 occurrences.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
wolf-pair has no login of its own — Authelia via Caddy is the only
protection option offered for it — but UFW opened its port to the
whole internet unconditionally, before the Caddy/Authelia prompt even
ran. Same gap just fixed for the Asterisk web admin: reachable
straight over the bare port regardless of Authelia.
Reordered so the Caddy decision happens first, and scope the port to
caddy_net's subnet via ufw_allow_from_caddy_net() instead of leaving
it open to 0.0.0.0/0 when Caddy fronts it locally. Also enables UFW
via ensure_ufw_enabled() like the Asterisk services.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Confirmed live: a bare `ufw delete allow <port>` closes it on every
interface, including the caddy_net bridge — Caddy's own request to
host.docker.internal:PORT is ordinary INPUT-chain traffic as far as
UFW is concerned, not something that bypasses it just because the
source is a local container. Closing the port outright silently took
Caddy's reverse-proxy path down with it.
Added ufw_allow_from_caddy_net() to scope the port to caddy_net's own
subnet instead of leaving it fully closed — reachable from Caddy,
still closed to the public internet. Wired into both
asterisk-digital-ocean.sh and asterisk.sh in place of the plain
delete.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Both defaulted to whatever sorted/listed first (kiosks category,
LAN/VPN UDP transport) — reasonable for a fixed intercom install, but
the common case here is adding a phone over the internet. Default the
category select to "mobile" specifically (not just first-in-list, so
it survives category reordering) and make FQDN/Internet (TLS) the
default transport option instead of LAN/VPN (UDP).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Mirrors the fixes just made in asterisk-digital-ocean.sh:
- Reordered so the Caddy reverse-proxy decision happens before the
UFW rules are built, using the new CADDY_SERVICE_CONFIGURED/
CADDY_SERVICE_MODE signal from configure_caddy_for_service() to
skip opening the web admin port on the LAN when a local Caddy is
already fronting it (still opens it for a remote Caddy machine,
which needs LAN access to reach this host directly).
- Calls the new ensure_ufw_enabled() so UFW actually enforces the
rules this script adds, instead of leaving them queued but inert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
configure_caddy_for_service() previously gave callers no way to know
whether Caddy actually ended up fronting the service, or whether that
was local (reachable only over host.docker.internal) vs remote
(needs network access to this host). Services that also open a host
firewall port for the same thing had no way to correctly skip that
when Caddy is the only intended way in. Now sets
CADDY_SERVICE_CONFIGURED/CADDY_SERVICE_MODE out-params after each
exit point.
Added ensure_ufw_enabled(): flips UFW from inactive to active (no
service in this repo has ever done this — ufw allow rules just sat
unenforced). Always allows SSH first, reading the real port from
sshd_config in case it's non-default, so this can't lock out the
session running the installer. No-ops if UFW is already active.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
UFW and the DO Cloud Firewall both opened the web admin port to
0.0.0.0/0 unconditionally, even when Caddy+Authelia was configured to
protect it on the actual domain. Caddy reaches the container over the
host's internal network (host.docker.internal), not the public
internet, so that direct port was pure attack surface: anyone could
hit http://<droplet-ip>:<port>/clients directly, fully bypassing
Authelia and the built-in web admin auth (which gets disabled
whenever Authelia is handling it instead).
Reordered the install flow so the Caddy reverse-proxy decision is
made before the firewall rules are built, and only open the web
admin port publicly when there's no local Caddy actually fronting
it (no domain, Caddy not installed, proxy declined, or a remote
Caddy machine that needs to reach it over the public IP instead).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
The web admin script (/usr/local/bin/easy-asterisk-webadmin) is
generated on demand by the interactive CLI, but only ever lived in
the container's writable layer — not baked into the image, not
bind-mounted. Every docker compose down/up wiped it, and the
entrypoint's start logic only ran "if the file already exists", so
it silently never started again until someone manually ran the CLI's
Web Admin menu once per recreate.
Added a --write-web-admin-script non-interactive entry point
(same pattern as --rebuild-dialplan) and call it unconditionally
before the existence check, so the web admin comes back on its own
every time the container starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
The "device created" modal only ever showed extension/password/name,
so setting up a client (e.g. Sipnetic) meant hunting down the server
domain, port, and transport separately — and the password is only
ever shown this once, so re-checking it later isn't an option.
Now shows everything a SIP client needs in one place: display name,
server, port, transport, username, password, plus TURN/STUN details
when enabled. The backend reports the actual transport/port used
(the container always forces TLS/FQDN mode regardless of what's
selected in the form, so the frontend no longer has to guess).
Added "Copy All" and "Copy Password" buttons, with a document.
execCommand fallback for contexts where the Clipboard API isn't
available (e.g. plain-HTTP self-signed-cert access).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Devices/rooms trigger a dialplan rebuild themselves via the web admin
now, but that only fixes the problem going forward — endpoints added
before that fix (or by any future path that misses the call) stay
registrable-but-uncallable with no obvious cause until someone thinks
to run --rebuild-dialplan by hand.
Call it unconditionally once Asterisk is up, before the PJSIP
transport check. Cheap and idempotent — it just regenerates
extensions.conf from the current pjsip.conf/rooms.conf state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
The web admin's device functions (add_device, delete_device,
rename_device, change_device_category) only ever called
"asterisk -rx pjsip reload" — they never regenerated
extensions.conf's [intercom] context, so newly added SIP endpoints
could register but could never call each other or dial into rooms
("extension not found in context 'intercom'").
The room functions (create_room, delete_room, rename_room,
update_room_members) already tried to fix this correctly by shelling
out to `easy-asterisk --rebuild-dialplan`, but that flag was never
actually wired up — main() at the bottom of the script ignores all
arguments and always launches the interactive menu, so every one of
those calls was a silent no-op too.
Fixed both: added real --rebuild-dialplan argument handling that
calls the existing rebuild_dialplan() bash function non-interactively,
and added the same subprocess call to the four device functions that
were missing it entirely.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
pjsip show transports was checked once, immediately after "core show
version" first responded — but res_pjsip can take a moment longer to
finish binding its transports, so the check would sometimes read an
empty transport list and print "NOT LOADED" even though transport-tls
came up correctly a second later (confirmed live: TLS SIP traffic on
5061 in the container logs right after the misleading banner).
Poll for up to 10s instead of checking once, matching the existing
core-show-version wait pattern further up the same script.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Renamed services/asterisk-do.sh -> services/asterisk-digital-ocean.sh
(register_service name, install function, install dir, and all prose/
comments) so the whiptail menu shows a clearer, more discoverable name.
Updated the functional cross-references that depend on the old name:
crowdsec.sh's SIP-log auto-detection path and acquisition filename,
caddy.sh's host.docker.internal comment, and the CLAUDE.md/README.md
docs (services table, directory listing, network-wiring example).
Container names, the Docker Compose project name, and the internal
_asterisk_do_* helper function identifiers are left unchanged since
they aren't user-facing and renaming them would add risk for no
benefit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Same pattern already used for NetBird: a simple prompt (default yes,
since these are the two explicitly called out as recommended) right
after the mandatory package/Docker/SSH setup, before the whiptail
menu. Both stay fully optional and available later from the menu
either way — this just surfaces them earlier as a nudge, matching
how most other services in this repo end up wanting a reverse proxy
and something watching for brute-force/scan traffic.
asterisk-do previously offered to auto-install base, Caddy, CrowdSec,
and a numbered extras menu (authelia/ntfy/watchtower/wg-easy/netbird/
backup) on top of its own setup, layering a second install flow on
top of the whiptail menu setup.sh already provides. Strips all of
that back out — asterisk-do now only installs Asterisk + coturn, same
scope as any other service. Caddy/Authelia integration (reverse
proxy, cert sync, SSO) is kept, since it only activates when those
are already installed — no auto-install behind it. CrowdSec SIP
protection still wires up automatically via crowdsec.sh's own
asterisk-do detection, regardless of which one installs first.
Also fixes a real regression from ensure_caddy_network (added
earlier): it created caddy_net via a bare `docker network create`,
which doesn't carry Compose's ownership labels, so Caddy's own
non-external network declaration conflicted with it and failed to
start ("network exists but was not created by compose"). Caddy's
compose file now declares caddy_net as external: true like every
other service, since ensure_caddy_network is the single creator for
all of them, Caddy included.
43 services declare caddy_net as "external: true" in their compose
file, meaning they require it to already exist — but only Caddy's own
compose file actually creates it (authelia.sh was the sole exception,
with its own inline check-and-create). Installing any of the other 42
before Caddy fails outright with "network caddy_net declared as
external, but could not be found."
Adds ensure_caddy_network to lib/common.sh, called from require_docker
(which every install_* function already calls first), so the network
exists regardless of install order without touching each service file.
Removes authelia.sh's now-redundant duplicate of the same check.
Also documents in CLAUDE.md that network_mode: host services (asterisk/
asterisk-do) need host.docker.internal, not localhost, when Caddy
reverse-proxies to them — the fix from the previous commit.
Caddy runs in its own container on the caddy_net bridge network, so
"localhost" in a Caddyfile site block resolves to Caddy's own
container — never the host, and never a sibling container. That
broke every reverse proxy pointed at a network_mode: host service
(confirmed live with asterisk-do's web admin): once nothing else
(like a forward_auth redirect) intercepted the request first, Caddy
couldn't actually reach the upstream.
- services/caddy.sh: add extra_hosts so host.docker.internal resolves
inside the Caddy container (Linux Docker needs this explicitly —
it's automatic only on Docker Desktop).
- lib/common.sh's configure_caddy_for_service: bare-port upstreams
(its documented "host-network service" case) now target
host.docker.internal instead of localhost.
- services/asterisk-do.sh: its self-contained Caddy block (doesn't go
through configure_caddy_for_service) gets the same fix for local
Caddy, and now correctly targets the droplet's public IP instead of
localhost for the remote-Caddy snippet case, which had the same bug.
services/asterisk.sh needs no direct change — it already goes through
configure_caddy_for_service, so it inherits the fix.
If an existing ~/ubuntu-post-install checkout has a broken/SSH-only
origin remote, `git pull --ff-only` fails and the script fell through
to "continuing with existing version" — even when that existing copy
is missing setup.sh entirely, guaranteeing a crash right after. Now
checks for setup.sh post-pull and wipes + re-clones over HTTPS (no SSH
key needed) if it's still missing.