diff --git a/CLAUDE.md b/CLAUDE.md index 451aa3c..44804a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ checklist per group, and calls `install_()` for each selected item. 1. Create `services/.sh` (kebab-case filename) 2. Call `register_service` at the top of the file 3. Define `install_()` — keep hyphens **literal** in the function name - (`install_asterisk-digital-ocean`, not `install_asterisk_digital_ocean`). + (`install_pstn-trunk`, not `install_pstn_trunk`). `setup.sh`'s dispatcher calls `install_${name}` with no hyphen→underscore conversion, so the function name must match the service name exactly. Confirmed live: a mismatched underscore here produces @@ -29,6 +29,41 @@ That's it. The menu picks it up on the next run. Also update the **Services table in `README.md`** — add the service name to the appropriate group row so the README stays current. +## Retiring a service name (merging two services) + +Deleting `services/.sh` removes it from the menu, but `sudo ./setup.sh +` then fails outright for anyone with that name in their notes, docs, or +shell history. Add the old name to `SERVICE_ALIAS` in `setup.sh` instead — +`run_service` resolves it to the surviving service, says so once, and runs +that. The alias never gets its own menu entry, which is the whole point. + +`services/asterisk-digital-ocean.sh` was merged into `services/asterisk.sh` +this way: one installer that detects a DigitalOcean droplet (metadata service, +with a y/n either way) and applies the droplet-only extras — swapfile, +public-FQDN-only flow, hand-built Caddy site block, remote Authelia, Cloud +Firewall — behind that one answer. Two lessons worth reusing: + +- **Don't rename a live install's directory or containers.** New installs + land in `~/docker/asterisk` with `easy-asterisk`; a pre-merge droplet keeps + `~/docker/asterisk-digital-ocean` and `easy-asterisk-do`, because its + Caddyfile block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN + trunk all name those exact paths. `_asterisk_resolve_layout()` picks + whichever exists, and every sibling service probes both. +- **Check whether a "flavor-specific" behavior was actually flavor-specific.** + The Asterisk security-logging patch and the `logs/full` logrotate config + were droplet-only purely because that's where they got written first — the + Security Dashboard's Security Log and CrowdSec's Asterisk acquisition were + silently empty on every home/LAN install as a result. Both now apply + everywhere. + +The pre-merge installer is parked at `attic/asterisk-digital-ocean.sh` as a +rollback path until the unified one is confirmed on real hardware. `attic/` +is outside `setup.sh`'s `services/*.sh` glob, so nothing there registers or +runs on its own — see `attic/README.md`, including why it's a way to get the +old script back rather than an undo button. Delete it once the merge is +proven; a second copy of the same logic is what the merge existed to remove, +and fixes are deliberately not backported into it. + ## Minimal Docker service template ```bash @@ -175,15 +210,16 @@ the auth server's own access-control rules say. Confirmed live: this was the actual cause of a "Caddy proxies fine but Authelia never prompts for login" bug, on a site block that otherwise looked completely correct. If a service builds its own site block instead of using this helper (e.g. -`services/asterisk-digital-ocean.sh` does, deliberately, see its own -comment for why), put its auth block first there too. +`services/asterisk.sh` does in droplet mode, deliberately — see +`_asterisk_configure_caddy_public`'s comment for why), put its auth block +first there too. **`forward_auth` to a remote Authelia over a scheme-qualified URL needs explicit `header_up` pins.** A bare `forward_auth authelia:9091` (Authelia on the same Docker network, one hop) is fine relying on Caddy's default `X-Forwarded-*` headers. But `forward_auth https://auth.example.com { ... }` (Authelia on a *different* machine, reached over its own public domain+TLS — -see `services/asterisk-digital-ocean.sh`'s remote-Authelia prompt) is a +see `services/asterisk.sh`'s droplet-mode remote-Authelia prompt) is a second Caddy hop: Caddy rewrites the outgoing request's `Host` header to `auth.example.com` so the remote Caddy can route/SNI-match it, and without an override `X-Forwarded-Host` picks up that rewritten value instead of the @@ -221,9 +257,10 @@ Use this to skip opening a host firewall port for a service Caddy already fronts *locally* (it reaches the service over `host.docker.internal`, not the network) — but still open it when `CADDY_SERVICE_MODE` is `"remote"`, since a remote Caddy machine needs to reach this host over the network -instead. See `services/asterisk.sh` and `services/asterisk-digital-ocean.sh` -for the reference pattern: call `configure_caddy_for_service` *before* -building firewall rules, not after, so the decision is known in time. +instead. See `services/asterisk.sh` for the reference pattern: it decides +the Caddy question *before* building firewall rules, not after, so the +answer is known in time (in droplet mode it hand-builds its own site block +and sets the same flag itself, for the reasons noted above). ### UFW enable @@ -252,8 +289,7 @@ blocks that too and silently breaks the service (confirmed live: closing the web admin port outright took Caddy down with it). Call `ufw_allow_from_caddy_net` right after the `delete` to re-open the port scoped to just `caddy_net`'s subnet — reachable from Caddy, not from the -internet. See `services/asterisk-digital-ocean.sh` and -`services/asterisk.sh` for the pattern. +internet. See `services/asterisk.sh` for the pattern. ### README generation @@ -298,7 +334,7 @@ on the same machine anyway. See `add_authelia_domain()` in `services/authelia.sh **Running a genuinely separate instance (e.g. one per machine).** `services/authelia.sh` runs standalone on any box (`sudo bash authelia.sh`, same pattern as `crowdsec.sh`) and -`asterisk-digital-ocean.sh` already auto-detects a local install (`if [ -d +`asterisk.sh` already auto-detects a local install (`if [ -d "$DOCKER_DIR/authelia" ]`), switching from the remote-Authelia `forward_auth` flow to the local `import authelia` snippet automatically — so a second, fully independent instance on another machine (e.g. a droplet, for resilience if the first machine goes down) works with @@ -429,9 +465,8 @@ rules, or reverse-proxy/SSO config that's already in place. If the vendor-copy or `docker-compose.yml`-generation logic is more than a few lines, factor it into a helper function so the fresh-install path and the update path share one copy instead of drifting apart — see -`_asterisk_do_refresh_vendor_files`/`_asterisk_do_write_compose` in -`services/asterisk-digital-ocean.sh` (and their `_asterisk_*` counterparts in -`services/asterisk.sh`) for the reference pattern. +`_asterisk_refresh_vendor_files`/`_asterisk_write_compose` in +`services/asterisk.sh` for the reference pattern. `cancel` must leave the install completely untouched — it's the default for a reason (a stray Enter on a service you're just checking on shouldn't @@ -443,9 +478,9 @@ would, prompts included. A service can call another service's `install_()` directly as a convenience step at the end of its own flow, instead of making the user remember to separately run `sudo ./setup.sh ` afterward. -`services/asterisk.sh`/`services/asterisk-digital-ocean.sh` do this for +`services/asterisk.sh` does this for `services/security-dashboard.sh` and `services/pstn-trunk.sh` — after -Asterisk itself is installed/updated, each asks once whether to also set up +Asterisk itself is installed/updated, it asks once whether to also set up the dashboard and/or a PSTN trunk (or, if either is already installed, silently re-invokes it so it gets refreshed as part of the same run — its own `prompt_reinstall_mode` gate decides update vs. skip, so this never @@ -501,7 +536,7 @@ it, so as long as your `install_()` calls `require_docker` before `docker compose up` (it always should), the network is guaranteed to exist regardless of whether Caddy itself has been installed yet. -**`network_mode: host` services (e.g. `asterisk`/`asterisk-digital-ocean`) don't join +**`network_mode: host` services (e.g. `asterisk`) don't join `caddy_net` at all** — Caddy reaching them (or anything else on the host network) needs `host.docker.internal:PORT` in the Caddyfile, not `localhost:PORT` or a container name. Caddy's own compose file diff --git a/README.md b/README.md index 6e9e59c..c947c4a 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. | Group | Services | |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `pstn-trunk`, `security-dashboard`, `sunshine` | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` | | `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | @@ -91,8 +91,8 @@ homelab authelia homeassistant asterisk - asterisk-digital-ocean pstn-trunk + sms-inbound security-dashboard sunshine diff --git a/attic/README.md b/attic/README.md new file mode 100644 index 0000000..f57b0e5 --- /dev/null +++ b/attic/README.md @@ -0,0 +1,36 @@ +# attic — frozen copies, kept deliberately out of the way + +Nothing in here is part of the normal system. `setup.sh` globs +`services/*.sh`, so files parked here never self-register, never appear in the +menu, and never run unless you invoke them by hand. + +## `asterisk-digital-ocean.sh` + +The pre-merge droplet installer, exactly as it was before +`services/asterisk-digital-ocean.sh` was folded into `services/asterisk.sh`. +It is here as a rollback path while the unified installer is still unproven on +real hardware — not as a supported second service. + +It keeps its own standalone bootstrap, so it runs on its own: + +```bash +sudo bash attic/asterisk-digital-ocean.sh +``` + +It still targets `~/docker/asterisk-digital-ocean` and the +`easy-asterisk-do` / `easy-asterisk-do-coturn` containers, which is exactly +the layout the merged `services/asterisk.sh` detects and preserves — so the +two agree about where an existing droplet install lives, and switching back +and forth does not move anything. + +**What this copy does and does not protect against.** It is a way to get the +old installer back, not an undo button. If the unified script ever makes a +change you don't want, re-running this one does not reverse it — a +droplet snapshot does. The things that actually keep an existing install safe +are, in order: running `--dry-run` first, choosing `update` (or `cancel`) +rather than `fresh` at the reinstall prompt, and having a snapshot. + +**Delete this once the unified installer has been confirmed on the droplet.** +Two copies of the same logic is the exact problem the merge existed to fix, +and this one will drift the moment `services/asterisk.sh` gets a fix that +isn't backported here — which it deliberately won't be. diff --git a/services/asterisk-digital-ocean.sh b/attic/asterisk-digital-ocean.sh similarity index 100% rename from services/asterisk-digital-ocean.sh rename to attic/asterisk-digital-ocean.sh diff --git a/docs/anveo-direct-setup-guide.md b/docs/anveo-direct-setup-guide.md index ad28055..11534d7 100644 --- a/docs/anveo-direct-setup-guide.md +++ b/docs/anveo-direct-setup-guide.md @@ -1,8 +1,8 @@ # Anveo Direct + Easy Asterisk — confirmed working setup guide This is the exact sequence that got a real Anveo Direct DID working end to -end (both outbound and inbound) with `asterisk-digital-ocean.sh` + -`pstn-trunk.sh`, confirmed live on a real droplet. +end (both outbound and inbound) with `asterisk.sh` + `pstn-trunk.sh`, +confirmed live on a real droplet. **Steps 1, 3 and 4 are one-time account setup** — the outbound Service Trunk (step 3) and the inbound SIP Trunk (step 4) each cover every DID on @@ -13,8 +13,8 @@ in the dashboard, and test. ## 0. Prerequisites -- `asterisk-digital-ocean.sh` (or `asterisk.sh` for a LAN box) already - installed and running, with at least one extension configured. +- `asterisk.sh` already installed and running (droplet or home/LAN — the + installer detects which), with at least one extension configured. - This box's public IP address (`curl -4 ifconfig.me`). ## 1. Anveo Direct account (one-time) @@ -194,9 +194,132 @@ In the Security Dashboard's PSTN Trunk tab: answers or 20 seconds pass. - Watch the live console while testing either direction: ``` - docker exec -it easy-asterisk-do asterisk -rvvv + docker exec -it easy-asterisk asterisk -rvvv + # on a droplet set up before the two Asterisk services were merged, the + # container is named easy-asterisk-do instead ``` +## 8. SMS — receiving verification codes + +Voice and SMS are separate features on an Anveo DID and are configured in +different places. This section covers **receiving** only; see "What about +sending?" below for why. + +### Pick the right kind of number first + +Anveo sells two classes of US DID, and for verification codes the difference +matters more than anything else in this section: + +- **Geographic (default)** — the cheap ones this guide orders in step 2 + ($0.25 setup, $0.15/month). Industry lookups classify these as VoIP. +- **Mobile** — a separate pool sourced from wireless carriers, available + across roughly 20 major US city area codes (released on Anveo Retail first, + then Direct). These are classified as *mobile* in the same databases that + services query when they decide whether to accept your number. Priced above + the geographic ones — check the DID ordering tool for the current rate. + +Plenty of services (Google, WhatsApp, Microsoft, many banks) reject a number +that looks like VoIP at signup, before any message is ever sent. **If codes +are the reason you're buying the number, order a mobile one** — no amount of +correct SMS routing fixes a signup form that refuses the number outright. + +### Short codes + +Most verification codes come from short codes (262966, 32665, ...), and most +VoIP providers don't deliver them at all — VoIP.ms, for instance, doesn't +except for Google, and users there report a large fraction of 2FA codes never +arriving. Anveo is unusual in supporting short-code SMS to its DIDs, which is +the main reason it's worth using for this. + +Not every number in the pool has it enabled, so confirm on your specific DID +(or ask support to turn it on) rather than assuming. + +### Wire it up + +Run the installer and follow what it prints: + +```bash +sudo ./setup.sh sms-inbound +``` + +It generates a long random ntfy topic, then offers two ways for Anveo to +reach it: + +- **Relay (recommended)** — a small systemd service on the droplet receives + Anveo's request and republishes to ntfy properly. Two concrete wins: a + message body containing `&` survives intact (Anveo interpolates the text + into the query string unescaped, so an unencoded `&` otherwise truncates + the message), and your ntfy credentials never get stored in Anveo's portal. +- **Direct** — Anveo calls ntfy itself; nothing runs on the droplet. Simpler, + but the URL you paste into Anveo carries your ntfy token, and the `&` case + loses the tail of the message. + +Then in the Anveo portal: **Phone Numbers → the DID → SMS tab**. The tab has +exactly one control — a **Forward to URL** checkbox and a text field. Tick the +box, paste the string the installer printed into the field, and press **SAVE** +(not RETURN, which discards). Keep the `$[message]$` placeholder **last** in +that URL — that's what makes the unescaped-`&` case recoverable. + +The field has no visible length limit, but the generated URLs are long +(~90 characters in relay mode, ~150+ in direct mode, since that one carries +the ntfy auth parameter). After saving, reopen the tab and confirm the whole +string came back intact rather than truncated — if it didn't, relay mode is +the shorter of the two. + +Send a text to the number from another phone; the notification should arrive +within seconds. `journalctl -u sms-inbound -f` shows sender, recipient and +message length (never the body — these are one-time passcodes and the journal +has a wider audience than the notification does). + +### What about sending? + +Not covered, on purpose. Outbound SMS isn't available on Anveo Direct — Anveo +support directs users to an Anveo **Retail** account for it, which is a +second account to fund and manage. Any of the free texting apps covers +sending without involving this box. + +### MMS and group texts + +Don't plan on either. No VoIP provider delivers MMS over SIP, and MMS to a +VoIP DID generally drops or arrives as a media link through a separate API. +US group texts are MMS, so a SIM-less phone on this number will silently miss +them. + +### The native Messages app never sees these + +Android's Messages app reads the telephony SMS provider, which only the +cellular radio (or whichever app holds the default-SMS-app role) writes to; +iOS lets nothing write to Messages at all. Codes arrive as ntfy push +notifications instead — which, for a passcode you're about to read and type, +is the more useful place anyway. + +## Provider risk and keeping the number + +Anveo is small — founder-run since ~2006, bootstrapped (no outside funding), +around 8 people. The clunky portal reads as a niche business that hasn't +needed to rewrite its UI rather than one in trouble: they shipped a Hosted +STIR/SHAKEN signing service for the June 20 2025 reseller deadline, added +carrier-sourced mobile DIDs, and support answers technical tickets within a +day or two. + +The realistic way to lose a number here isn't insolvency, it's **account +action or a lapsed balance**. There are long-standing reports of accounts +closed without warning, sometimes with prepaid credit still on them, and the +AUP penalises high volumes of very short or non-conversational calls. Three +cheap precautions: + +- **Keep the balance small** — enough for a few months, no more. Auto-recharge + stays off anyway for the toll-fraud reasons above, which conveniently caps + what's at risk. +- **Save a copy of a recent invoice now**, offline. Porting a number out + requires a signed LOA *plus* the latest bill from the losing carrier — and + if the account is already closed you can't download one. +- **Know the exit exists.** Anveo Direct states it does not block or restrict + port-outs (a provider refusing to release numbers is the real warning sign). + The process is at `anveo.com/lnp.asp`: signed authorisation form plus that + invoice, 3–6 weeks, non-refundable porting fee. Service must stay active on + the number until the port completes. + ## Bugs hit and fixed along the way (informational — already fixed) These were all real, confirmed-live bugs in earlier versions of this diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 63f1107..3d68c6a 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -10,9 +10,8 @@ file** — this doc is the design/decision log; that one is the clean how-to. **Implemented** — see `services/pstn-trunk.sh` (run `sudo ./setup.sh -pstn-trunk` after `asterisk-digital-ocean` **or** `asterisk` (home/LAN) is -installed — both are supported, see the file for the static-IP caveat on the -LAN variant). Generic SIP trunk add-on that defaults to VoIP.ms but isn't +pstn-trunk` after `asterisk` is installed — droplet or home/LAN, both are +supported; see the file for the static-IP caveat on the LAN variant). Generic SIP trunk add-on that defaults to VoIP.ms but isn't hardcoded to it — any provider supporting IP authentication works. Covers: - IP-authenticated trunk, US/NANP-only outbound dialplan, no catch-all. @@ -40,7 +39,7 @@ hardcoded to it — any provider supporting IP authentication works. Covers: - A configurable **inbound ring-group** (one extension or several), each member's live tier/approved-numbers checked per inbound call via an unrolled per-member dialplan block (no AGI needed). -- **`services/security-dashboard.sh` integration** — a "PSTN Trunk" tab +- **`services/security-dashboard.sh` integration** — its Extensions tab shows both concurrency caps and every extension (parsed from `pjsip.conf`) with its live tier and approved numbers, all editable with no restart. This is what makes the tier model and caps actually @@ -65,7 +64,7 @@ file. - **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off auto-recharge in the account's Finances settings and outbound calls simply fail once the balance hits $0 — that's the toll-fraud backstop if the - droplet's Asterisk (`asterisk-digital-ocean`) is ever compromised. + droplet's Asterisk is ever compromised. **Update — read VoIP.ms's actual ToS (not just the wiki) on this.** The wiki says plainly "only accounts with a balance over $0 are able to send @@ -173,7 +172,7 @@ estimated spend crosses a threshold, and every hour that call volume in the last hour looks like a burst. Denied/rejected calls alert immediately, separately from that hourly check. -## What it takes technically (asterisk-digital-ocean) +## What it takes technically (asterisk, droplet mode) - A PJSIP trunk: `endpoint` / `aor` / `identify` sections in the pjsip config. **Implemented with IP authentication** (no `auth` section, no SIP password stored anywhere) — see `services/pstn-trunk.sh`. Provider name, @@ -213,7 +212,7 @@ separately from that hourly check. admin-controlled approved-list in the pattern position and the live call data in the tested-string position — worth keeping that direction if this is ever refactored. -- **Web UI — implemented.** `services/security-dashboard.sh`'s "PSTN Trunk" +- **Web UI — implemented.** `services/security-dashboard.sh`'s Extensions tab lists every extension (parsed from `pjsip.conf`, the same marker format Easy Asterisk's own `rebuild_dialplan()` uses) with a tier dropdown and approved-numbers field, saving straight to `pstn-permissions.conf`. @@ -357,9 +356,9 @@ generator output. Fixed by quoting every value in that heredoc. model (internal/restricted/full) managed live via `pstn-permissions.conf` + the Security Dashboard web UI, no reinstall needed to change. ~~Generic Asterisk target~~ Done — - `services/pstn-trunk.sh` now supports either `asterisk-digital-ocean` or - the home/LAN `asterisk` install (the latter with a static-IP caveat for - the provider's IP authentication). Still unresolved: pick pay-per-minute + `services/pstn-trunk.sh` supports the `asterisk` install in either mode, + droplet or home/LAN (the latter with a static-IP caveat for the + provider's IP authentication). Still unresolved: pick pay-per-minute vs. unlimited DID plan on VoIP.ms's side based on real expected volume, and decide on E911 (see cost estimate). 5. ~~Concurrent-call cap~~ Done — both directions now (inbound was a real @@ -444,7 +443,7 @@ generator output. Fixed by quoting every value in that heredoc. `exten => ,1,...` per device, freshly regenerated by Easy Asterisk's own `rebuild_dialplan()` on every dialplan rebuild — exactly the collision this doc worried about. Solved by NOT sharing - `[intercom]`: `services/asterisk-digital-ocean.sh` now explicitly sets + `[intercom]`: `services/asterisk.sh` now explicitly sets `message_context=sip-messaging` on every endpoint (patched into both of Easy Asterisk's device-creation code paths — the CLI menu's bash heredoc and the web admin's Python `add_device()` — so new devices pick diff --git a/services/asterisk.sh b/services/asterisk.sh index ad5f4fb..019ea2b 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -2,6 +2,20 @@ # services/asterisk.sh — Easy Asterisk PBX + coturn TURN server (home intercom/VoIP). # Part of the modular post-install system (sourced by setup.sh). # +# One installer for both deployment shapes. It detects a DigitalOcean droplet +# (via the link-local metadata service, with a y/n fallback if that's blocked) +# and, in droplet mode, swaps in the public-cloud specifics: a swapfile for +# low-RAM plans, a public-FQDN-only flow with no LAN/VLAN prompts, a Caddy +# site block pinned to that one FQDN, an optional remote Authelia, and a +# DigitalOcean Cloud Firewall via doctl. Everything else — vendor files, +# compose, messaging dialplan, presence alerts, UFW, log rotation — is +# identical either way. +# +# This used to be two services (services/asterisk-digital-ocean.sh held a +# near-duplicate copy of the whole file). An existing droplet install at +# ~/docker/asterisk-digital-ocean is detected and kept in place, container +# names included, so the merge doesn't strand it. +# # Can also be run standalone on any machine: # sudo bash asterisk.sh # (Docker must already be installed when run standalone) @@ -218,12 +232,83 @@ CBLOCK fi # ───────────────────────────────────────────────────────────────────────────── -register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (home intercom/VoIP)" 5061 +register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (intercom/VoIP; auto-tunes for a DigitalOcean droplet)" 5061 + +# ── Install layout: directory + container names ──────────────────────────── +# Sets ASTERISK_DIR / ASTERISK_CONTAINER / ASTERISK_COTURN / ASTERISK_PROJECT. +# +# New installs always land in ~/docker/asterisk with the plain container +# names, droplet or not — the DigitalOcean specifics are behaviour, not a +# separate install. But boxes provisioned by the old, separate +# services/asterisk-digital-ocean.sh have a live install at +# ~/docker/asterisk-digital-ocean running containers named easy-asterisk-do / +# easy-asterisk-do-coturn, with a Caddyfile block, UFW rules, a Cloud +# Firewall, CrowdSec acquisition and a PSTN trunk all pointing at those exact +# paths and names. Renaming any of that from under a running deployment would +# break every one of those references at once, so an existing legacy install +# is detected and kept exactly as it is; only new installs get the unified +# naming. Every sibling service in this repo (pstn-trunk, security-dashboard, +# crowdsec) already probes for both directories, so both layouts stay fully +# supported without further special-casing. +_asterisk_resolve_layout() { + if [[ -f "$DOCKER_DIR/asterisk-digital-ocean/docker-compose.yml" ]]; then + ASTERISK_DIR="$DOCKER_DIR/asterisk-digital-ocean" + ASTERISK_CONTAINER="easy-asterisk-do" + ASTERISK_COTURN="easy-asterisk-do-coturn" + ASTERISK_PROJECT="asterisk-do" + else + ASTERISK_DIR="$DOCKER_DIR/asterisk" + ASTERISK_CONTAINER="easy-asterisk" + ASTERISK_COTURN="easy-asterisk-coturn" + ASTERISK_PROJECT="asterisk" + fi +} + +# ── DigitalOcean droplet detection ───────────────────────────────────────── +# Sets IS_DO (true/false), DROPLET_ID and PUBLIC_IP. +# +# A droplet's own id/public IP are readable, unauthenticated, from the +# link-local metadata service — no API token needed for this part. The +# metadata service isn't always reachable (a container, a firewalled +# 169.254.0.0/16, a non-DO cloud that still wants the same public-IP +# treatment), so a miss falls back to asking rather than silently deciding +# for the user. Droplet mode is what gates the swapfile, the public-FQDN-only +# flow, and the Cloud Firewall step further down. +_asterisk_detect_digitalocean() { + local _meta="http://169.254.169.254/metadata/v1" + DROPLET_ID="$(curl -fsS --max-time 2 "$_meta/id" 2>/dev/null || true)" + PUBLIC_IP="$(curl -fsS --max-time 2 "$_meta/interfaces/public/0/ipv4/address" 2>/dev/null || true)" + + echo "" + local _answer="" + if [[ -n "$DROPLET_ID" ]]; then + [[ -z "$PUBLIC_IP" ]] && PUBLIC_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)" + log_success "DigitalOcean droplet detected (id $DROPLET_ID, public IP ${PUBLIC_IP:-unknown})." + log_info "Droplet mode adds: swapfile for low-RAM plans, public-FQDN-only setup (no" + log_info "LAN/VLAN prompts), a Cloud Firewall via doctl, and a remote-Authelia option." + prompt_yn "Set this up as a public droplet? (n = treat it as a home/LAN box) (y/n):" "y" _answer + else + log_info "No DigitalOcean metadata service reachable — assuming a home/LAN box." + log_info "Answer y here anyway if this is a public cloud VM (droplet with metadata" + log_info "blocked, or another provider) that should get the public-IP treatment." + prompt_yn "Set this up as a public cloud box? (y/n):" "n" _answer + fi + + if [[ "$_answer" =~ ^[Yy]$ ]]; then + IS_DO=true + [[ -z "$PUBLIC_IP" ]] && PUBLIC_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)" + [[ -z "$PUBLIC_IP" ]] && PUBLIC_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + [[ -z "$DROPLET_ID" ]] && log_warning "No droplet id — the Cloud Firewall step will print manual rules instead of using doctl." + else + IS_DO=false + DROPLET_ID="" + fi +} # ── Shared: vendor file refresh ──────────────────────────────────────────── # Called from both a fresh install and an "update in place" run, so a single # copy of this logic stays current for both instead of drifting apart. Must -# be called with $PWD already at $EA_DIR. +# be called with $PWD already at $ASTERISK_DIR. _asterisk_refresh_vendor_files() { mkdir -p docker scripts @@ -255,6 +340,50 @@ _asterisk_refresh_vendor_files() { chmod 755 ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh \ ./docker/entrypoint.sh ./docker/coturn-entrypoint.sh \ ./scripts/vpn-diagnostics.sh ./scripts/dns-whitelist.sh + + # Persist security-level logging to a file — vendor's logger.conf only + # sends the "security" level (auth failures, SIP brute-force attempts) to + # the console (Docker stdout), not a file CrowdSec/fail2ban can tail. + # Applies on every box, not just droplets: the Security Dashboard's + # Security Log tab and services/crowdsec.sh's Asterisk acquisition both + # read logs/full, and neither has anything to read without this patch. + if grep -q '^console => notice,warning,error,security$' ./docker/entrypoint.sh; then + sed -i '/^console => notice,warning,error,security$/a full => notice,warning,error,security' \ + ./docker/entrypoint.sh + else + log_warning "entrypoint.sh logger.conf template changed upstream — security events won't be logged to a file. Update the sed patch in this installer." + fi +} + +# ── Shared: log rotation for logs/full (unbounded otherwise) ────────────── +# Confirmed live: with no rotation, this file grew to 1.4GB in about 3 days +# on a busy box (SIP scanning noise is constant on the public internet) — +# a real disk-exhaustion risk on a small droplet, and separately made the +# Security Dashboard balloon to 600+MB RAM/GBs of swap reading it every 30s +# before that was fixed to only read a bounded tail (see +# services/security-dashboard.sh). copytruncate avoids needing to signal +# Asterisk to reopen its log file — it has a long-held file descriptor on +# this path and no reload mechanism this installer can reach from the host. +# +# Not droplet-only: a LAN box reachable from the internet (port-forwarded +# SIP) collects the same scanning noise, and the file is unbounded either +# way now that the security-level logging patch above applies everywhere. +_asterisk_write_logrotate() { + local _ea_dir="$1" + cat > /etc/logrotate.d/asterisk << LOGROTATE +$_ea_dir/logs/full { + size 100M + rotate 5 + compress + missingok + notifempty + copytruncate +} +LOGROTATE + # Supersedes the config the old separate droplet installer wrote. Left in + # place it would rotate the very same path a second time (both files can + # name the same log), so it goes when this one lands. + rm -f /etc/logrotate.d/asterisk-digital-ocean } # ── Shared: extension presence (online/offline) ntfy alerts ──────────────── @@ -266,7 +395,7 @@ _asterisk_refresh_vendor_files() { # parsed defensively (grep for the Avail/Unavail keyword rather than a fixed # column position) specifically because it hasn't been confirmed against a # live install's actual output yet — run -# `docker exec easy-asterisk asterisk -rx "pjsip show contacts"` yourself +# `docker exec asterisk -rx "pjsip show contacts"` yourself # after enabling this to confirm extensions/status actually show up as # expected, same as any other not-yet-live-tested piece in this project. _asterisk_write_presence_alert_script() { @@ -442,7 +571,7 @@ _asterisk_patch_messaging_vendor_files() { # Patches the LIVE file directly instead, so it takes effect immediately # regardless of whether Easy Asterisk ever regenerates it on its own. _asterisk_ensure_live_messaging_include() { - local EA_DIR="$1" + local EA_DIR="$1" CONTAINER_NAME="$2" local EXT_LIVE="$EA_DIR/config/asterisk/extensions.conf" [[ -f "$EXT_LIVE" ]] || return 0 if ! grep -q 'messaging-dialplan.conf' "$EXT_LIVE"; then @@ -451,10 +580,10 @@ _asterisk_ensure_live_messaging_include() { log_success "Patched the messaging #include directly into the live extensions.conf." else log_warning "Couldn't find '[intercom]' in the live extensions.conf — add" - log_warning "'#include messaging-dialplan.conf' manually, then: docker exec easy-asterisk asterisk -rx \"dialplan reload\"" + log_warning "'#include messaging-dialplan.conf' manually, then: docker exec ${CONTAINER_NAME} asterisk -rx \"dialplan reload\"" fi fi - docker exec easy-asterisk asterisk -rx "dialplan reload" &>/dev/null || true + docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true } # One-time migration for devices that already existed before the patch above @@ -493,8 +622,8 @@ _asterisk_migrate_existing_devices_message_context() { # The actual enforcement — gated on the SENDER's own "messaging" flag in # pstn-permissions.conf (the exact file/flag the Security Dashboard's -# "Internal SIP messaging" checkbox writes, independent of whether the PSTN -# trunk is installed), read live via AST_CONFIG() on every message, same +# Messaging column writes, independent of whether the PSTN trunk is +# installed), read live via AST_CONFIG() on every message, same # mechanism pstn-trunk.sh's own dialplan already relies on for permission # tiers — no restart needed to take effect. Off by default: an extension # with no entry, or messaging=no, is denied. UNVERIFIED: MESSAGE(from)'s @@ -544,7 +673,7 @@ _asterisk_remove_presence_timer() { # international-calling step: this is a live-editable extra, not a # structural setting, so it doesn't belong exclusively to one path). _asterisk_run_presence_step() { - local EA_DIR="$1" + local EA_DIR="$1" CONTAINER_NAME="$2" local SETTINGS_FILE="$EA_DIR/.presence-alert.env" local STATE_FILE="$EA_DIR/.presence-alert.state" @@ -596,7 +725,7 @@ ENV return 0 fi - _asterisk_write_presence_alert_script "$EA_DIR/asterisk-presence-alert.sh" "easy-asterisk" "$PRESENCE_NTFY_URL" "$STATE_FILE" + _asterisk_write_presence_alert_script "$EA_DIR/asterisk-presence-alert.sh" "$CONTAINER_NAME" "$PRESENCE_NTFY_URL" "$STATE_FILE" _asterisk_install_presence_timer "$EA_DIR" cat > "$SETTINGS_FILE" << ENV @@ -638,7 +767,7 @@ _asterisk_offer_dashboard_and_trunk() { install_security-dashboard else local _WANT_DASH="" - prompt_yn "Set up the Security Dashboard (Security Log, Extensions, Asterisk Admin, PSTN Trunk, CrowdSec — one page)? (y/n):" "y" _WANT_DASH + prompt_yn "Set up the Security Dashboard (Security Log, Extensions, CrowdSec — one page)? (y/n):" "y" _WANT_DASH [[ "$_WANT_DASH" =~ ^[Yy]$ ]] && install_security-dashboard fi fi @@ -658,17 +787,23 @@ _asterisk_offer_dashboard_and_trunk() { # ── Shared: docker-compose.yml ───────────────────────────────────────────── # Same reasoning as above — one copy of the template used by both fresh -# installs and updates. Must be called with $PWD already at $EA_DIR. +# installs and updates. Must be called with $PWD already at the install dir. # HAS_VLANS_VAL/VLAN_SUBNETS_VAL aren't referenced here — they live only in # .env, which the entrypoint reads at container start. +# +# The heredoc stays quoted so ${TURN_PORT} and friends reach docker compose +# literally (it interpolates them from .env, this script must not). Project +# and container names are therefore substituted afterwards, same placeholder +# trick the Caddy volume line already uses below. _asterisk_write_compose() { + local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3" cat > docker-compose.yml << 'EOF' -name: asterisk +name: PROJECT_NAME_PLACEHOLDER services: asterisk: build: . - container_name: easy-asterisk + container_name: ASTERISK_CONTAINER_PLACEHOLDER network_mode: host depends_on: coturn: @@ -692,7 +827,7 @@ CADDY_VOLUME_PLACEHOLDER coturn: image: coturn/coturn:latest - container_name: easy-asterisk-coturn + container_name: COTURN_CONTAINER_PLACEHOLDER network_mode: host user: root entrypoint: ["/coturn-entrypoint.sh"] @@ -718,6 +853,10 @@ CADDY_VOLUME_PLACEHOLDER EOF + sed -i "s#PROJECT_NAME_PLACEHOLDER#${PROJECT}#; \ + s#ASTERISK_CONTAINER_PLACEHOLDER#${CONTAINER}#; \ + s#COTURN_CONTAINER_PLACEHOLDER#${COTURN_CONTAINER}#" docker-compose.yml + # Share Caddy's cert store (read-only) so the entrypoint can auto-sync a # real Let's Encrypt cert for DOMAIN_NAME instead of falling back to # self-signed. No-op if Caddy isn't installed on this box. @@ -728,15 +867,541 @@ EOF fi } +# ── Shared: swapfile for low-RAM public cloud boxes ──────────────────────── +# DigitalOcean doesn't provision swap by default. Docker + Asterisk + coturn +# fit in 512MB-1GB at idle with little headroom; a swapfile absorbs spikes +# (apt/image pulls, log bursts, a few concurrent calls) instead of the +# kernel OOM-killing a container or the box going unresponsive over SSH. +_asterisk_offer_swapfile() { + local TOTAL_RAM_MB + TOTAL_RAM_MB="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)" + [[ "$TOTAL_RAM_MB" -gt 0 && "$TOTAL_RAM_MB" -le 2048 ]] || return 0 + swapon --show | grep -q . && return 0 + + local FREE_DISK_MB SWAP_MB=2048 + FREE_DISK_MB="$(df -Pm / | awk 'NR==2 {print $4}')" + if [[ "$FREE_DISK_MB" -le $((SWAP_MB + 2048)) ]]; then + log_warning "Not enough free disk for a safe swapfile (${FREE_DISK_MB}MB free) — skipping." + log_warning "Consider a bigger box, or free up disk before installing." + return 0 + fi + + local ADD_SWAP="" + prompt_yn "No swap detected on this ${TOTAL_RAM_MB}MB-RAM box — add a ${SWAP_MB}MB swapfile? (y/n):" "y" ADD_SWAP + [[ "$ADD_SWAP" =~ ^[Yy]$ ]] || return 0 + + fallocate -l "${SWAP_MB}M" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count="$SWAP_MB" status=none + chmod 600 /swapfile + mkswap /swapfile >/dev/null + swapon /swapfile + grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab + grep -q '^vm.swappiness' /etc/sysctl.conf 2>/dev/null || echo 'vm.swappiness=10' >> /etc/sysctl.conf + sysctl -w vm.swappiness=10 >/dev/null 2>&1 + log_success "Swapfile enabled (${SWAP_MB}MB, swappiness=10, persists across reboots)." +} + +# ── Droplet-mode Caddy: web admin on the SAME FQDN used for SIP ──────────── +# Deliberately NOT using configure_caddy_for_service in this mode. Caddy only +# holds a cert for domains it's actively serving, and Asterisk never does ACME +# itself — it mounts Caddy's cert store and copies the cert matching +# DOMAIN_NAME. Proxy the admin on a separate "admin" subdomain and Caddy +# obtains a cert for THAT name instead, the sync finds nothing matching +# DOMAIN_NAME, and SIP TLS silently stays self-signed. The helper would also +# prompt for its own domain, defaulting to ".${SITE_DOMAIN}" — +# which is blank or wrong whenever SITE_DOMAIN isn't set, i.e. every time +# this service is run by name (`sudo ./setup.sh asterisk` skips setup.sh's +# site-defaults wizard). There is exactly one correct domain here, so the +# site block is written directly with no domain prompt to get wrong. +# +# Sets WEB_ADMIN_PUBLIC_ACCESS_NEEDED (out-param) so the firewall steps below +# know whether the bare IP:port still has to be reachable. +_asterisk_configure_caddy_public() { + local DOMAIN_NAME="$1" WEB_ADMIN_PORT_VAL="$2" PUBLIC_IP="$3" + + WEB_ADMIN_PUBLIC_ACCESS_NEEDED=true + + if [[ -z "$DOMAIN_NAME" ]]; then + log_info "No FQDN set — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL} (nothing for Caddy to do)." + return 0 + fi + if [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -z "${CADDY_REMOTE_HOST:-}" ]]; then + log_info "Caddy not installed — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL}, SIP TLS stays self-signed." + return 0 + fi + + local EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth + if [[ "$_use_auth" =~ ^[Yy]$ ]]; then + EXTRA_BLOCK=" import authelia" + # Disable built-in auth since Authelia handles it + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + fi + else + # No local Authelia — offer one running elsewhere (e.g. a homelab). + # There's no shared "(authelia)" Caddy snippet to import in that + # case (authelia.sh only writes one when installing locally), so + # this builds the same forward_auth block inline, targeting the + # remote instance directly instead of the local "authelia:9091" + # container reference. + local _use_remote_auth="" + prompt_yn "Protect the web admin with a remote Authelia instance (e.g. on a homelab)? (y/n):" "n" _use_remote_auth + if [[ "$_use_remote_auth" =~ ^[Yy]$ ]]; then + local _remote_authelia="" + prompt_text " Remote Authelia address — a bare host:port over a private network (e.g. a NetBird mesh IP:9091), or a full https:// URL if it's on its own public domain+TLS:" "" _remote_authelia + if [[ -n "$_remote_authelia" ]]; then + # header_up lines are required here (unlike the local + # "authelia:9091" snippet in services/authelia.sh) because + # this upstream is reached over a second Caddy hop when + # given as a scheme-qualified URL (https://auth.example.com). + # Caddy rewrites the outgoing request's Host header to that + # upstream host so the remote Caddy can route/SNI-match it — + # and without an explicit override, X-Forwarded-Host picks up + # that rewritten value instead of the original site's host. + # Confirmed live: Authelia was evaluating every request as + # if it were for auth.example.com itself (which has + # policy: bypass in access_control.rules), so every domain + # silently passed through with no 2FA prompt regardless of + # its own policy. Pinning these to the original request's + # values fixes it regardless of hop count. + # + # X-Forwarded-Host uses a literal domain, NOT the {host} + # placeholder. Confirmed live: {host} still evaluated to + # the upstream's own hostname (auth.example.com) rather + # than the original site's — Caddy appears to rewrite the + # outgoing request's Host to the upstream target before + # header_up placeholders are resolved for a scheme- + # qualified upstream, so {host} echoes back the already- + # rewritten value instead of the original client-facing + # host. Since this site block only ever serves one domain + # (DOMAIN_NAME), hardcoding it sidesteps the ambiguity + # entirely instead of depending on Caddy's internal + # header-mutation ordering. + EXTRA_BLOCK=" forward_auth ${_remote_authelia} { + uri /api/authz/forward-auth + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + header_up X-Forwarded-Method {method} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host ${DOMAIN_NAME} + header_up X-Forwarded-Uri {uri} + }" + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + log_info "Using remote Authelia at ${_remote_authelia}." + log_info "Verify it's reachable from this box before relying on it — e.g.:" + log_info " curl -I ${_remote_authelia}" + else + log_info "No address entered — skipping Authelia protection." + fi + fi + fi + + echo "" + local WANT_CADDY_PROXY="" + prompt_yn "Reverse-proxy the web admin at https://${DOMAIN_NAME}/ via Caddy? (also gets Asterisk a trusted TLS cert for SIP instead of self-signed) (y/n):" "y" WANT_CADDY_PROXY + [[ "$WANT_CADDY_PROXY" =~ ^[Yy]$ ]] || return 0 + + local _CADDY_MODE="local" + [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -n "${CADDY_REMOTE_HOST:-}" ]] && _CADDY_MODE="remote" + + # Asterisk runs with network_mode: host, so whatever proxies to it + # needs a way to reach the host, not "localhost" (which resolves + # to the proxying container's own netns). A local Caddy container + # reaches the host via host.docker.internal (wired up in + # services/caddy.sh's compose file); a remote Caddy machine needs + # this box's actual public IP instead. + local _PROXY_TARGET="host.docker.internal:${WEB_ADMIN_PORT_VAL}" + [[ "$_CADDY_MODE" == "remote" ]] && _PROXY_TARGET="${PUBLIC_IP}:${WEB_ADMIN_PORT_VAL}" + + local _SITE_BLOCK + _SITE_BLOCK="$(cat << CADDY_BLOCK + +# Asterisk Web Admin +${DOMAIN_NAME} { + # Auth (if any) must come before reverse_proxy — forward_auth is the + # same directive family as reverse_proxy internally, and Caddy doesn't + # reorder repeats of the same directive within a block; it runs them in + # the order they're written. With reverse_proxy first, it would handle + # and terminate every request immediately, so an auth check written + # after it would be dead code that never runs — full bypass regardless + # of what the auth server's own rules say. +${EXTRA_BLOCK} + reverse_proxy ${_PROXY_TARGET} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${DOMAIN_NAME}.log + format json + } +} +CADDY_BLOCK +)" + + if [[ "$_CADDY_MODE" == "local" ]]; then + # Caddy reaches this over the host's internal network — no + # need to keep the port open to the public internet. + WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false + local _CADDYFILE="$DOCKER_DIR/caddy/Caddyfile" + local _CADDY_BACKUP="$_CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" + if [[ -f "$_CADDYFILE" ]]; then + cp "$_CADDYFILE" "$_CADDY_BACKUP" + else + touch "$_CADDYFILE" + fi + if grep -q "^${DOMAIN_NAME}" "$_CADDYFILE" 2>/dev/null; then + log_warning "${DOMAIN_NAME} already in Caddyfile — leaving the existing entry alone." + else + printf '%s\n' "$_SITE_BLOCK" >> "$_CADDYFILE" + log_success "Added ${DOMAIN_NAME} to Caddyfile (backup: $(basename "$_CADDY_BACKUP"))" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + # The template Caddyfile ships with "admin off", so + # `caddy reload` (which needs that same admin API) never + # actually works here. Try it anyway, fall back to a + # restart — confirmed necessary on a real deployment. + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "Web admin accessible at: https://${DOMAIN_NAME}" + elif docker restart caddy &>/dev/null; then + log_success "Caddy restarted to apply changes (reload API is disabled by default)" + log_success "Web admin should be accessible at: https://${DOMAIN_NAME}" + else + log_warning "Reload/restart failed — check: docker logs caddy" + log_info "Manual fix: docker restart caddy" + fi + fi + else + local _SNIPPET_DIR="$DOCKER_DIR/caddy-snippets" + mkdir -p "$_SNIPPET_DIR" + printf '%s\n' "$_SITE_BLOCK" > "$_SNIPPET_DIR/asterisk.caddy" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_SNIPPET_DIR/asterisk.caddy" 2>/dev/null || true + log_success "Snippet saved: $_SNIPPET_DIR/asterisk.caddy" + log_info "Copy to your Caddy machine: scp $_SNIPPET_DIR/asterisk.caddy caddy-host:~/caddy-snippets/" + log_info "Remote Caddy reaches this box over its public IP, so the web admin port stays open below." + fi +} + +# ── DigitalOcean Cloud Firewall (network edge, in front of the droplet) ──── +_asterisk_configure_do_cloud_firewall() { + local DROPLET_ID="$1" WEB_ADMIN_PORT_VAL="$2" WEB_ADMIN_PUBLIC="$3" + + local DO_FW_RULES=( + "protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0" + "protocol:tcp,ports:5060,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:5060,address:0.0.0.0/0,address:::/0" + "protocol:tcp,ports:5061,address:0.0.0.0/0,address:::/0" + ) + if [[ "$WEB_ADMIN_PUBLIC" == true ]]; then + DO_FW_RULES+=("protocol:tcp,ports:${WEB_ADMIN_PORT_VAL},address:0.0.0.0/0,address:::/0") + fi + DO_FW_RULES+=( + "protocol:tcp,ports:8088-8089,address:0.0.0.0/0,address:::/0" + "protocol:tcp,ports:3478,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:3478,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:10000-20000,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:49152-49252,address:0.0.0.0/0,address:::/0" + ) + + echo "" + if [[ -n "$DROPLET_ID" ]] && command -v doctl &>/dev/null && doctl account get &>/dev/null; then + local EXISTING_FW + EXISTING_FW="$(doctl compute firewall list --format ID,DropletIDs --no-header 2>/dev/null \ + | grep -E "(^|[, ])${DROPLET_ID}([, ]|\$)" | awk '{print $1}' | head -1)" + + if [[ -n "$EXISTING_FW" ]]; then + log_warning "A Cloud Firewall (id $EXISTING_FW) is already attached to this droplet — not touching it." + log_warning "Add these inbound rules to it yourself (Networking → Firewalls in the DO console):" + printf ' %s\n' "${DO_FW_RULES[@]}" + else + local DO_FW="" + prompt_yn "Create a DigitalOcean Cloud Firewall for this droplet via doctl now? (y/n):" "y" DO_FW + if [[ "$DO_FW" =~ ^[Yy]$ ]]; then + if doctl compute firewall create \ + --name "asterisk" \ + --droplet-ids "$DROPLET_ID" \ + --inbound-rules "$(IFS=' '; echo "${DO_FW_RULES[*]}")" \ + --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0 protocol:icmp,ports:0,address:0.0.0.0/0,address:::/0" \ + &>/dev/null; then + log_success "Cloud Firewall 'asterisk' created and attached (SSH/22 included so you don't get locked out)." + log_info "Verify it in the DO console — adjust the SSH rule if you use a non-default SSH port." + else + log_warning "doctl firewall create failed — add the rules manually (see README)." + fi + fi + fi + else + log_info "doctl not installed/authenticated — configure a DigitalOcean Cloud Firewall manually:" + log_info "Control Panel → Networking → Firewalls → create, attach to this droplet, allow:" + printf ' %s\n' "${DO_FW_RULES[@]}" + fi +} + +# ── Shared: README ───────────────────────────────────────────────────────── +# One document with a droplet-only section appended in public-cloud mode, so +# the two deployment shapes can't document themselves differently by accident. +_asterisk_write_readme() { + local EA_DIR="$1" CONTAINER="$2" IS_DO="$3" DOMAIN_NAME="$4" PUBLIC_IP="$5" WEB_ADMIN_PORT_VAL="$6" + local _host="${DOMAIN_NAME:-${PUBLIC_IP:-}}" + + { + cat << MD +# Easy Asterisk PBX + coturn + +Self-hosted SIP PBX using Easy Asterisk with a coturn TURN/STUN server for +NAT traversal. Suitable for home intercom, VoIP handsets, and softphones. + +One installer covers both a home/LAN box and a public cloud VM — it detects a +DigitalOcean droplet at install time and adjusts. This install is in +**$( [[ "$IS_DO" == true ]] && echo "public cloud / droplet" || echo "home / LAN" )** mode; re-run +\`sudo ./setup.sh asterisk\` and pick a full reinstall to change that. + +## Manage + +\`\`\`bash +docker compose up -d --build # build image and start +docker compose up -d # start (after initial build) +docker compose down # stop +docker compose logs -f # follow logs +docker compose pull # update coturn image +docker compose up -d --build # rebuild asterisk image +\`\`\` + +## Management script + +\`\`\`bash +docker exec -it ${CONTAINER} easy-asterisk --help +\`\`\` + +Use it to create SIP extensions (Server Settings → Extensions) before +connecting a phone. The Security Dashboard's Extensions tab +(\`services/security-dashboard.sh\`) does the same thing from a browser. + +## SIP client setup + +| Setting | Value | +|-----------------|--------------------------------------| +| SIP server | \`${_host}\` | +| SIP port | 5061 (TLS) / 5060 (UDP) | +| TURN server | \`${_host}:3478\` | +| TURN username | easyasterisk | +| TURN password | see \`.env\` → \`TURN_PASSWORD\` | + +Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave, and +[Sipnetic](https://www.sipnetic.com/) on Android (free, TLS/SRTP + +STUN/TURN/ICE). For a phone to work the same way regardless of network (LAN, +VLAN, remote, no VPN), register it against \`${_host}:5061\` over TLS. Plain +UDP/TCP on 5060 still works for LAN-only devices, but only the FQDN+TLS path +is location-independent. + +If it registers but calls connect with no audio, the RTP/TURN port ranges +below are almost always the cause — check them on every firewall layer. + +## TLS certificate + +Caddy is what actually talks to Let's Encrypt — Asterisk never does ACME +itself. If Caddy is installed and holds a cert for \`DOMAIN_NAME\` (i.e. +there's a Caddyfile site block for that exact hostname), the container mounts +Caddy's cert store read-only and the entrypoint syncs it in automatically on +every start — and re-checks every 12h so renewals get picked up without a +restart. No Caddyfile block for the domain, or no Caddy at all, falls back to +a self-signed cert (phones must be configured to accept it). + +## Web admin + +Access the Easy Asterisk web interface at +\`http://${PUBLIC_IP:-}:${WEB_ADMIN_PORT_VAL}\` or via your configured +reverse-proxy domain. (8081 is the default; if that port was already taken by +something else on this box, the installer picked the next free one instead — +\`WEB_ADMIN_PORT\` in \`.env\` is the actual value.) + +## Internal SIP messaging (no PSTN trunk needed) + +Every extension can send/receive Asterisk's native SIP MESSAGE (no carrier +SMS, no PSTN, no cost) once its "messaging" flag is set to yes in +\`pstn-permissions.conf\` — via the Security Dashboard's Extensions tab, or by +hand. This works independent of \`pstn-trunk.sh\` entirely. Under the hood: +every device endpoint gets \`message_context=sip-messaging\`, routing messages +to a dedicated \`config/asterisk/messaging-dialplan.conf\` context instead of +\`[intercom]\` (which already owns per-device call routing) — this +install/update patches both the device-creation code (so new extensions pick +it up automatically) and any devices that already existed. Confirmed against +a live install's \`pjsip.conf\`/\`extensions.conf\` on 2026-07-23 — the MESSAGE +sender-extraction logic itself is still unconfirmed against real traffic; if +messages silently don't arrive, check +\`docker exec ${CONTAINER} asterisk -rx "core set verbose 3"\` while sending one. + +## Extension presence (online/offline) alerts + +Optional ntfy alert when an extension's SIP registration changes state — +offered on both fresh install and "update in place". Checked every 2 +minutes (systemd timer, cron.d fallback); fires only on a change, never on +every check. + +## Logs + +Asterisk's security-level events (auth failures, SIP brute-force attempts) +are written to \`logs/full\` as well as the container's stdout — that file is +what the Security Dashboard's Security Log tab and CrowdSec's Asterisk +acquisition both read. It's rotated at 100MB (5 generations, compressed) via +\`/etc/logrotate.d/asterisk\`; unrotated it reached 1.4GB in three days on a +publicly reachable box. + +## VLANs / other subnets + +\`.env\` → \`HAS_VLANS\`/\`VLAN_SUBNETS\` lists extra networks (space-separated +CIDRs) this server isn't itself attached to but that phones live on. These +become \`local_net=\` entries in \`pjsip.conf\` so NAT/SDP handling is correct +for those devices (missing entries here is the most common cause of calls +connecting with no audio). To change this after install: + +\`\`\`bash +docker exec -it ${CONTAINER} easy-asterisk +# Server Settings → Configure VLAN/VPN Subnets +\`\`\` + +## Ports + +| Port | Protocol | Purpose | +|---------------|----------|----------------------------------| +| 5060 | UDP/TCP | SIP signalling (unencrypted) | +| 5061 | TCP | SIP over TLS | +| ${WEB_ADMIN_PORT_VAL} | TCP | Easy Asterisk web admin (auto-picked — see \`.env\`) | +| 8088/8089 | TCP | Asterisk HTTP/WS (ARI/AMI) | +| 3478 | UDP/TCP | TURN/STUN (coturn) | +| 10000–20000 | UDP | RTP media streams | +| 49152–49252 | UDP | TURN relay media ports | + +## Data directories (all inside ${EA_DIR}/, included in backup) + +| Directory | Contents | +|----------------------|---------------------------------| +| config/asterisk/ | /etc/asterisk — dialplan, SIP | +| config/easy-asterisk/| /etc/easy-asterisk — web config | +| logs/ | /var/log/asterisk | +| spool/ | /var/spool/asterisk | +| lib/ | /var/lib/asterisk | +MD + + # Droplet-only appendix. Guarded with an `if`, not an early return — + # this block runs in the pipeline's subshell, where a bare `return` + # would only leave the subshell and quietly skip nothing useful. + [[ "$IS_DO" == true ]] && cat << MD + +## DigitalOcean droplet notes + +This install is in public-cloud mode: the installer read the droplet's public +IP from the metadata service, set up a swapfile, offered a Cloud Firewall, and +reverse-proxied the web admin on the same FQDN used for SIP. + +### Droplet sizing + +Asterisk + coturn is light for a handful of SIP extensions and personal use. + +| Plan | vCPU | RAM | Good for | +|--------------------------------|------|-------|----------------------------------------| +| Basic (regular), \$4/mo | 1 | 512 MB | Works — this installer adds a 2GB swapfile automatically to cover it. Fine for a couple of extensions and light personal use. | +| **Basic (regular), \$6/mo — recommended** | 1 | 1 GB | More headroom, still gets an automatic swapfile | +| Basic (regular), \$12/mo | 1 | 2 GB | Comfortable — no swap needed, a handful of concurrent calls | +| Basic (regular), \$24/mo | 2 | 4 GB | Several simultaneous calls, conference bridges, transcoding | + +10 GB SSD (the \$4/mo plan's disk) is enough — this stack isn't storage-heavy, +and the swapfile only takes 2GB of it. Any DO region close to where the +phones actually are is fine; SIP/RTP care about latency more than raw +bandwidth. + +**Swap:** DigitalOcean doesn't provision swap by default, and Docker + +Asterisk + coturn leave little headroom at 512MB–1GB RAM. The installer +detects RAM ≤2GB with no existing swap and offers to add a 2GB swapfile +(persisted in \`/etc/fstab\`) — it's what makes the \$4/mo plan viable instead +of risking an OOM kill under load. + +**OS image:** Ubuntu 24.04 LTS (supported through April 2029) is the safe, +battle-tested choice for Docker + coturn. Ubuntu 26.04 LTS is also available +and supported longer (through 2031) if you'd rather track the newer LTS. + +### DNS + +Point an A record at the droplet's public IP before running the installer: + +\`\`\` +sip.yourdomain.com A ${PUBLIC_IP:-} +\`\`\` + +That one FQDN is used for SIP, the web admin, and the TLS cert — there's no +separate domain to plan for the admin panel. + +### Security + +- **SSH:** key-based auth only, password login disabled — \`services/base.sh\` + in this repo offers to do this for you on first run. Don't skip it; this + box is public. +- **Two firewall layers, same rule set:** + - **DigitalOcean Cloud Firewall** — filters at the network edge, before + traffic reaches the droplet. The installer offers to create one + automatically via \`doctl\` (only if none is already attached to this + droplet — it never overwrites an existing one, to avoid clobbering a + custom SSH allow-list). If \`doctl\` isn't set up, add the same rules as + the Ports table above manually in the DO console (Networking → + Firewalls), plus TCP 22 for SSH. + - **UFW** — host-level, configured automatically by the installer as a + second layer. Keep both in sync; don't let them contradict each other. +- The web admin port is only opened publicly when Caddy isn't fronting it + locally — otherwise it's reachable at \`https://${DOMAIN_NAME:-your-domain}/\` + only, not the bare IP:port. +- **CrowdSec** — SIP brute-force/enumeration protection + (\`crowdsecurity/asterisk\` collection). Not installed by this script — + install it separately (whiptail menu, or \`sudo ./setup.sh crowdsec\`); its + own installer auto-detects this install and wires up SIP protection + regardless of install order. +- DO's paid Droplet Backups, or \`services/borg-backup.sh\` installed + separately, are both options for a rollback path. + +### Other services (installed separately, not by this script) + +This installer only sets up Asterisk + coturn. Everything else — Caddy, +CrowdSec, Authelia, ntfy, watchtower, wg-easy, NetBird, Borg backup — is a +normal service in this repo: pick it from the whiptail menu, or run +\`sudo ./setup.sh \` directly. A few integrate automatically with this +install if already present, no extra config needed: + +- **Caddy** — if installed (locally, or you're on a remote-Caddy setup), the + installer reverse-proxies the web admin on \`DOMAIN_NAME\` and Asterisk syncs + the resulting Let's Encrypt cert for SIP-TLS too. Not installed → + self-signed cert, plain HTTP admin. +- **Authelia** — if installed locally (needs Caddy), or you point the + installer at a remote instance (e.g. a homelab, via NetBird mesh IP or a + public \`https://\` URL), the web admin gets SSO/2FA in front of it. +- **CrowdSec** — see Security above; wires up SIP protection automatically + once installed, regardless of whether it went in before or after this. +MD + } | write_readme "$EA_DIR" +} + install_asterisk() { require_docker || return 1 log_info "Installing Easy Asterisk PBX + coturn..." - local EA_DIR="$DOCKER_DIR/asterisk" + local ASTERISK_DIR ASTERISK_CONTAINER ASTERISK_COTURN ASTERISK_PROJECT + _asterisk_resolve_layout + local EA_DIR="$ASTERISK_DIR" + local CONTAINER="$ASTERISK_CONTAINER" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $EA_DIR with Dockerfile, docker-compose.yml, .env" - echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk" + echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk, patching Asterisk to" + echo "[DRY-RUN] log security events to logs/full (what CrowdSec + the Security Dashboard read)" + echo "[DRY-RUN] Would rotate logs/full at 100MB via /etc/logrotate.d/asterisk" + echo "[DRY-RUN] Would detect a DigitalOcean droplet via its metadata service (asking either way)" + echo "[DRY-RUN] and, in droplet mode, additionally:" + echo "[DRY-RUN] - add a swapfile if RAM <= 2048MB and none exists" + echo "[DRY-RUN] - skip the LAN/VLAN prompts and set up one public FQDN for SIP + web admin" + echo "[DRY-RUN] - reverse-proxy the web admin on that SAME FQDN (needed for SIP cert sync)" + echo "[DRY-RUN] - offer local OR remote Authelia to protect the web admin" + echo "[DRY-RUN] - offer to create a DigitalOcean Cloud Firewall via doctl" echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)" echo "[DRY-RUN] Would open UFW ports: 5060, 5061, , 8088, 8089, 3478, 10000-20000, 49152-49252" echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists" @@ -753,11 +1418,15 @@ install_asterisk() { return 0 fi + [[ "$EA_DIR" == *asterisk-digital-ocean ]] && \ + log_info "Using the existing droplet install at $EA_DIR (containers ${CONTAINER}/${ASTERISK_COTURN}) — left where it is so Caddy, UFW, CrowdSec and the PSTN trunk keep pointing at it." + # ── Existing install? Offer update-in-place instead of a full reinstall ─── - # A fresh install re-runs every prompt (networking mode, domain, VLANs, - # Authelia). An update only refreshes vendor files + docker-compose.yml — - # picking up fixes like this one — and rebuilds, without touching .env, - # UFW, or the Caddy/Authelia config already in place. + # A fresh install re-runs every prompt (droplet detection, networking mode, + # domain, VLANs, firewalls, Authelia). An update only refreshes vendor + # files + docker-compose.yml — picking up fixes like this one — and + # rebuilds, without touching .env, UFW, any Cloud Firewall, or the + # Caddy/Authelia config already in place. if [[ -f "$EA_DIR/docker-compose.yml" && -f "$EA_DIR/.env" ]]; then echo "" log_info "Existing install found at $EA_DIR." @@ -771,10 +1440,11 @@ install_asterisk() { cd "$EA_DIR" || return 1 _asterisk_refresh_vendor_files - _asterisk_write_compose + _asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" + _asterisk_write_logrotate "$EA_DIR" _asterisk_patch_messaging_vendor_files "$EA_DIR" _asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" - _asterisk_ensure_live_messaging_include "$EA_DIR" + _asterisk_ensure_live_messaging_include "$EA_DIR" "$CONTAINER" _asterisk_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" @@ -786,14 +1456,14 @@ install_asterisk() { log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs" fi - _asterisk_run_presence_step "$EA_DIR" + _asterisk_run_presence_step "$EA_DIR" "$CONTAINER" _asterisk_offer_dashboard_and_trunk "$EA_DIR" local _EXISTING_DOMAIN _EXISTING_PORT _EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)" _EXISTING_PORT="$(grep -E '^WEB_ADMIN_PORT=' .env | cut -d= -f2-)" echo "" - log_success "Existing .env, UFW rules, and Caddy/Authelia config were left untouched." + log_success "Existing .env, firewall rules, and Caddy/Authelia config were left untouched." if [[ -n "$_EXISTING_DOMAIN" ]]; then echo " Web admin: https://${_EXISTING_DOMAIN}/" else @@ -813,6 +1483,13 @@ install_asterisk() { esac fi + # ── Public cloud (DigitalOcean droplet) or home/LAN box? ────────────────── + # Everything droplet-specific below hangs off this one answer. + local IS_DO=false DROPLET_ID="" PUBLIC_IP="" + _asterisk_detect_digitalocean + + [[ "$IS_DO" == true ]] && _asterisk_offer_swapfile + mkdir -p "$EA_DIR" mkdir -p "$EA_DIR/config/asterisk" "$EA_DIR/config/easy-asterisk" \ "$EA_DIR/logs" "$EA_DIR/spool" "$EA_DIR/lib" "$EA_DIR/exports" @@ -820,58 +1497,83 @@ install_asterisk() { cd "$EA_DIR" || return 1 _asterisk_refresh_vendor_files + _asterisk_write_logrotate "$EA_DIR" _asterisk_patch_messaging_vendor_files "$EA_DIR" _asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" + _asterisk_ensure_live_messaging_include "$EA_DIR" "$CONTAINER" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" - # ── Networking mode ─────────────────────────────────────────────────────── - echo "" - echo " Networking mode:" - echo " 1) FQDN (recommended) — TLS + TURN relay, every phone connects the" - echo " same way regardless of LAN/VLAN/remote" - echo " 2) LAN-only — no domain, self-signed cert, local network/VPN only" - local HA_NETMODE="" - prompt_text "Choose [1]:" "1" HA_NETMODE + # ── Domain / networking mode ────────────────────────────────────────────── + # A public cloud box is always reachable from anywhere, so there's no + # LAN-only option worth offering and no VLAN to bridge — one FQDN covers + # SIP registration, the web admin, and the TLS cert. A home box gets the + # full choice, plus the VLAN/subnet questions that only matter there. + local DOMAIN_NAME="" HAS_VLANS_VAL="n" VLAN_SUBNETS_VAL="" - local DOMAIN_NAME="" - if [[ "$HA_NETMODE" != "2" ]]; then - prompt_text "FQDN (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank=fall back to LAN-only]:" "" DOMAIN_NAME - [[ -z "$DOMAIN_NAME" ]] && log_warning "No FQDN entered — proceeding in LAN-only mode." - fi + if [[ "$IS_DO" == true ]]; then + echo "" + echo " Point a DNS A record at this box before continuing:" + echo " .${SITE_DOMAIN:-example.com} A ${PUBLIC_IP:-}" + echo "" + echo " This one FQDN covers everything below — SIP registration, the web" + echo " admin, and (via Caddy) the TLS cert Asterisk needs for SIP. There's" + echo " no separate \"admin domain\" to pick later — whatever you enter here" + echo " is what your SIP client (e.g. Sipnetic) will register against." + prompt_text "FQDN for this PBX, e.g. sip.yourdomain.com [blank=self-signed cert, IP-only access]:" "" DOMAIN_NAME + [[ -z "$DOMAIN_NAME" ]] && log_warning "No FQDN entered — using a self-signed cert; phones must trust it manually." + else + echo "" + echo " Networking mode:" + echo " 1) FQDN (recommended) — TLS + TURN relay, every phone connects the" + echo " same way regardless of LAN/VLAN/remote" + echo " 2) LAN-only — no domain, self-signed cert, local network/VPN only" + local HA_NETMODE="" + prompt_text "Choose [1]:" "1" HA_NETMODE - # ── Local networks / VLANs ──────────────────────────────────────────────── - # Feeds HAS_VLANS/VLAN_SUBNETS into .env, which the entrypoint reads to add - # extra local_net= entries in pjsip.conf so phones on those subnets get - # correct NAT/SDP handling (this is what fixes the "no sound" symptom for - # devices on a VLAN the server isn't itself attached to). - echo "" - echo " Detecting networks this host can see..." - local DETECTED_NETS="" - DETECTED_NETS="$(ip -o -f inet addr show scope global 2>/dev/null \ - | awk '{print $2, $4}' \ - | grep -Ev '^(docker|br-|veth|tun|tap|wg)' \ - | awk '{ split($2,a,"/"); split(a[1],o,"."); print o[1]"."o[2]"."o[3]".0/"a[2] }' \ - | sort -u)" - if [[ -n "$DETECTED_NETS" ]]; then - echo " This host is directly attached to:" - echo "$DETECTED_NETS" | sed 's/^/ /' + if [[ "$HA_NETMODE" != "2" ]]; then + prompt_text "FQDN (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank=fall back to LAN-only]:" "" DOMAIN_NAME + [[ -z "$DOMAIN_NAME" ]] && log_warning "No FQDN entered — proceeding in LAN-only mode." + fi + + # ── Local networks / VLANs ──────────────────────────────────────────── + # Feeds HAS_VLANS/VLAN_SUBNETS into .env, which the entrypoint reads to + # add extra local_net= entries in pjsip.conf so phones on those subnets + # get correct NAT/SDP handling (this is what fixes the "no sound" + # symptom for devices on a VLAN the server isn't itself attached to). + echo "" + echo " Detecting networks this host can see..." + local DETECTED_NETS="" + DETECTED_NETS="$(ip -o -f inet addr show scope global 2>/dev/null \ + | awk '{print $2, $4}' \ + | grep -Ev '^(docker|br-|veth|tun|tap|wg)' \ + | awk '{ split($2,a,"/"); split(a[1],o,"."); print o[1]"."o[2]"."o[3]".0/"a[2] }' \ + | sort -u)" + if [[ -n "$DETECTED_NETS" ]]; then + echo " This host is directly attached to:" + echo "$DETECTED_NETS" | sed 's/^/ /' + fi + echo " Phones on OTHER VLANs (this server usually can't see those directly)" + echo " still need to be listed here so their media is treated as local/trusted." + prompt_text "VLAN/VPN subnets, space-separated CIDRs [blank=none]:" "" VLAN_SUBNETS_VAL + [[ -n "$VLAN_SUBNETS_VAL" ]] && HAS_VLANS_VAL="y" fi - echo " Phones on OTHER VLANs (this server usually can't see those directly)" - echo " still need to be listed here so their media is treated as local/trusted." - local VLAN_SUBNETS_VAL="" - prompt_text "VLAN/VPN subnets, space-separated CIDRs [blank=none]:" "" VLAN_SUBNETS_VAL - local HAS_VLANS_VAL="n" - [[ -n "$VLAN_SUBNETS_VAL" ]] && HAS_VLANS_VAL="y" # ── Secrets ─────────────────────────────────────────────────────────────── local TURN_PASSWORD TURN_PASSWORD="$(generate_password 24)" + # A public box always has a usable TURN address (the FQDN if set, else its + # public IP). A LAN box with no FQDN has none — coturn is only reachable + # over the local network, so clients use the server's LAN address directly. local TURN_SERVER_VAL="" - [[ -n "$DOMAIN_NAME" ]] && TURN_SERVER_VAL="${DOMAIN_NAME}:3478" + if [[ "$IS_DO" == true ]]; then + TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478" + elif [[ -n "$DOMAIN_NAME" ]]; then + TURN_SERVER_VAL="${DOMAIN_NAME}:3478" + fi - _asterisk_write_compose + _asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" # ── Pick a free port for the web admin ───────────────────────────────────── # Hardcoding a single number gets fragile fast once several services share @@ -893,16 +1595,27 @@ install_asterisk() { fi # ── .env ────────────────────────────────────────────────────────────────── + local _domain_comment="Set to your FQDN for remote access. Leave empty for LAN-only." + local _vlan_comment="Extra local_net= entries for phones on networks this server isn't +# itself attached to. Space-separated CIDRs." + if [[ "$IS_DO" == true ]]; then + _domain_comment="Public FQDN for this box. Leave empty to fall back to a self-signed +# cert reachable at the public IP (${PUBLIC_IP:-unknown})." + _vlan_comment="A public cloud box has one public NIC, so this is usually irrelevant. +# Only set it if you're bridging phones back in over a VPN (e.g. +# WireGuard/Tailscale) on a subnet this box isn't directly attached to." + fi + cat > .env << ENV # ── Domain ──────────────────────────────────────────────────── -# Set to your FQDN for remote access. Leave empty for LAN-only. +# ${_domain_comment} DOMAIN_NAME=${DOMAIN_NAME} # ── TURN/STUN ───────────────────────────────────────────────── TURN_USERNAME=easyasterisk TURN_PASSWORD=${TURN_PASSWORD} TURN_PORT=3478 -# For LAN-only: TURN_SERVER is empty. For FQDN: set to domain:3478 +# Empty when there's no publicly resolvable address (LAN-only, no FQDN). TURN_SERVER=${TURN_SERVER_VAL} # ── RTP port range ──────────────────────────────────────────── @@ -910,8 +1623,7 @@ RTP_START=10000 RTP_END=20000 # ── VLAN/VPN subnets ────────────────────────────────────────── -# Extra local_net= entries for phones on networks this server isn't -# itself attached to. Space-separated CIDRs. +# ${_vlan_comment} HAS_VLANS=${HAS_VLANS_VAL} VLAN_SUBNETS=${VLAN_SUBNETS_VAL} @@ -919,40 +1631,51 @@ VLAN_SUBNETS=${VLAN_SUBNETS_VAL} # Picked automatically at install time (first free port starting at 8081) — # see WEB_ADMIN_PORT_VAL in services/asterisk.sh if this ever needs to # change again; don't hand-edit without also updating Caddy's Caddyfile and -# any firewall rules to match. +# every firewall layer to match. WEB_ADMIN_PORT=${WEB_ADMIN_PORT_VAL} WEB_ADMIN_AUTH_DISABLED=false ENV chmod 600 .env - # ── Caddy reverse proxy for web admin ───────────────────────────────────── - # Decided before the firewall rules below so they can be scoped - # correctly: if a local Caddy ends up fronting the web admin, there's no - # reason to also expose it on the LAN — Caddy already reaches it over - # the host's internal network (host.docker.internal). - local EXTRA_BLOCK="" - if [ -d "$DOCKER_DIR/authelia" ]; then - local _use_auth="" - prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth - if [[ "$_use_auth" =~ ^[Yy]$ ]]; then - EXTRA_BLOCK=" import authelia" - # Disable built-in auth since Authelia handles it - sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + # ── Caddy reverse proxy for the web admin ───────────────────────────────── + # Decided before the firewall rules below so they can be scoped correctly: + # if a local Caddy ends up fronting the web admin, there's no reason to + # also expose it directly — Caddy already reaches it over the host's + # internal network (host.docker.internal), and leaving the bare IP:port + # open would let anyone bypass Caddy/Authelia entirely. + local WEB_ADMIN_PUBLIC_ACCESS_NEEDED=true + if [[ "$IS_DO" == true ]]; then + _asterisk_configure_caddy_public "$DOMAIN_NAME" "$WEB_ADMIN_PORT_VAL" "$PUBLIC_IP" + else + local EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth + if [[ "$_use_auth" =~ ^[Yy]$ ]]; then + EXTRA_BLOCK=" import authelia" + # Disable built-in auth since Authelia handles it + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + fi + fi + configure_caddy_for_service "Asterisk Web Admin" "${WEB_ADMIN_PORT_VAL}" "asterisk" "$EXTRA_BLOCK" + if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then + WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false fi fi - configure_caddy_for_service "Asterisk Web Admin" "${WEB_ADMIN_PORT_VAL}" "asterisk" "$EXTRA_BLOCK" - # ── UFW firewall rules ──────────────────────────────────────────────────── + # ── UFW firewall rules (host-level) ─────────────────────────────────────── if command -v ufw &>/dev/null; then log_info "Opening UFW ports for Asterisk + coturn..." ufw allow 5060/udp ufw allow 5060/tcp ufw allow 5061/tcp - if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then + if [[ "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" == true ]]; then + ufw allow "${WEB_ADMIN_PORT_VAL}/tcp" + else + # Scoped, not deleted outright: a bare `ufw delete allow` also + # blocks Caddy's own request arriving over the caddy_net bridge. ufw delete allow "${WEB_ADMIN_PORT_VAL}/tcp" 2>/dev/null || true ufw_allow_from_caddy_net "${WEB_ADMIN_PORT_VAL}" - else - ufw allow "${WEB_ADMIN_PORT_VAL}/tcp" fi ufw allow 8088/tcp ufw allow 8089/tcp @@ -964,123 +1687,33 @@ ENV log_success "UFW rules added." fi + # ── DigitalOcean Cloud Firewall (network edge) ──────────────────────────── + [[ "$IS_DO" == true ]] && \ + _asterisk_configure_do_cloud_firewall "$DROPLET_ID" "$WEB_ADMIN_PORT_VAL" "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" + + # ── CrowdSec note ────────────────────────────────────────────────────────── + # Not installed here — select it separately from the whiptail menu, or + # `sudo ./setup.sh crowdsec`. Its own installer (services/crowdsec.sh) + # auto-detects an Asterisk install and wires up SIP brute-force + # protection on its own, in either install order. + if command -v cscli &>/dev/null; then + log_info "CrowdSec is already installed — rerun it to pick up SIP protection for this install:" + log_info " sudo ./setup.sh crowdsec" + elif [[ "$IS_DO" == true ]]; then + log_info "CrowdSec not installed. Recommended for SSH + SIP intrusion prevention on a" + log_info "public box — install it separately (whiptail menu, or 'sudo ./setup.sh crowdsec')." + log_info "It auto-detects this install and wires up SIP protection on its own." + else + log_info "CrowdSec not installed. Worth adding if SIP is reachable from the internet" + log_info "(port-forwarded 5060/5061) — whiptail menu, or 'sudo ./setup.sh crowdsec'." + log_info "It auto-detects this install and wires up SIP protection on its own." + fi + # ── Extension presence (online/offline) ntfy alerts ──────────────────────── - _asterisk_run_presence_step "$EA_DIR" + _asterisk_run_presence_step "$EA_DIR" "$CONTAINER" # ── README ──────────────────────────────────────────────────────────────── - write_readme "$EA_DIR" << 'MD' -# Easy Asterisk PBX + coturn - -Self-hosted SIP PBX using Easy Asterisk with a coturn TURN/STUN server for -NAT traversal. Suitable for home intercom, VoIP handsets, and softphones. - -## Manage - -```bash -docker compose up -d --build # build image and start -docker compose up -d # start (after initial build) -docker compose down # stop -docker compose logs -f # follow logs -docker compose pull # update coturn image -docker compose up -d --build # rebuild asterisk image -``` - -## Management script - -```bash -docker exec -it easy-asterisk easy-asterisk --help -``` - -## SIP client setup - -| Setting | Value | -|-----------------|--------------------------------------| -| SIP server | (LAN) or your FQDN (FQDN) | -| SIP port | 5061 (TLS) / 5060 (UDP) | -| TURN server | :3478 (FQDN mode only) | -| TURN username | easyasterisk | -| TURN password | see .env → TURN_PASSWORD | - -Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave. - -For a phone to work the same way regardless of network (LAN, VLAN, remote, -no VPN), register it against `:5061` over TLS — that's what -FQDN mode is for. Plain UDP/TCP on 5060 still works for LAN-only devices, -but only the FQDN+TLS path is location-independent. - -## VLANs / other subnets - -`.env` → `HAS_VLANS`/`VLAN_SUBNETS` lists extra networks (space-separated -CIDRs) this server isn't itself attached to but that phones live on. These -become `local_net=` entries in `pjsip.conf` so NAT/SDP handling is correct -for those devices (missing entries here is the most common cause of calls -connecting with no audio). To change this after install: - -```bash -docker exec -it easy-asterisk easy-asterisk -# Server Settings → Configure VLAN/VPN Subnets -``` - -## TLS certificate - -If Caddy is installed and already holds a Let's Encrypt cert for -`DOMAIN_NAME` (i.e. there's a Caddyfile site block for that exact hostname), -the container mounts Caddy's cert store read-only and the entrypoint syncs -it in automatically on every start — and re-checks every 12h so renewals -get picked up without a restart. No Caddyfile block for the domain, or no -Caddy at all, falls back to a self-signed cert (phones must be configured -to accept it). - -## Web admin - -Access the Easy Asterisk web interface at http://:8081 -or via your configured reverse-proxy domain. (8081 is the default; if that -port was already taken by something else on this box, the installer picked -the next free one instead — check WEB_ADMIN_PORT in .env for the actual -value.) - -## Data directories (all inside ~/docker/asterisk/, included in backup) - -| Directory | Contents | -|----------------------|---------------------------------| -| config/asterisk/ | /etc/asterisk — dialplan, SIP | -| config/easy-asterisk/| /etc/easy-asterisk — web config | -| logs/ | /var/log/asterisk | -| spool/ | /var/spool/asterisk | -| lib/ | /var/lib/asterisk | - -## Internal SIP messaging (no PSTN trunk needed) - -Every extension can send/receive Asterisk's native SIP MESSAGE (no carrier -SMS, no PSTN, no cost) once its "messaging" flag is set to yes in -\`pstn-permissions.conf\` — via the Security Dashboard's "Internal SIP -messaging" card, or by hand. This works independent of \`pstn-trunk.sh\` -entirely. Under the hood: every device endpoint gets -\`message_context=sip-messaging\`, routing messages to a dedicated -\`config/asterisk/messaging-dialplan.conf\` context instead of \`[intercom]\` -(which already owns per-device call routing) — this install/update patches -both the device-creation code (so new extensions pick it up automatically) -and any devices that already existed. - -## Extension presence (online/offline) alerts - -Optional ntfy alert when an extension's SIP registration changes state — -offered on both fresh install and "update in place". Checked every 2 -minutes (systemd timer, cron.d fallback); fires only on a change, never on -every check. - -## Ports - -| Port | Protocol | Purpose | -|---------------|----------|----------------------------------| -| 5060 | UDP/TCP | SIP signalling (unencrypted) | -| 5061 | TCP | SIP over TLS | -| 8081 | TCP | Easy Asterisk web admin (default — see .env) | -| 8088/8089 | TCP | Asterisk HTTP/WS (ARI/AMI) | -| 3478 | UDP/TCP | TURN/STUN (coturn) | -| 10000–20000 | UDP | RTP media streams | -| 49152–49252 | UDP | TURN relay media ports | -MD + _asterisk_write_readme "$EA_DIR" "$CONTAINER" "$IS_DO" "$DOMAIN_NAME" "$PUBLIC_IP" "$WEB_ADMIN_PORT_VAL" # ── Start ───────────────────────────────────────────────────────────────── echo "" @@ -1095,19 +1728,33 @@ MD _asterisk_offer_dashboard_and_trunk "$EA_DIR" # ── Summary ─────────────────────────────────────────────────────────────── + local _LOCAL_IP + _LOCAL_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost)" echo "" log_success "Easy Asterisk installed at $EA_DIR" if [[ -n "$DOMAIN_NAME" ]]; then - echo " Mode: FQDN ($DOMAIN_NAME)" + echo " Mode: FQDN ($DOMAIN_NAME)$( [[ "$IS_DO" == true ]] && echo ", public cloud" )" echo " TURN server: ${DOMAIN_NAME}:3478" + elif [[ "$IS_DO" == true ]]; then + echo " Mode: IP-only, public cloud (self-signed cert)" + echo " TURN server: ${PUBLIC_IP:-unknown}:3478" else echo " Mode: LAN-only" echo " TURN server: (none — LAN/VPN only)" fi + [[ "$IS_DO" == true ]] && echo " Public IP: ${PUBLIC_IP:-unknown}" echo " SIP port: 5061 (TLS) / 5060 (UDP)" - echo " Web admin: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):${WEB_ADMIN_PORT_VAL}" + echo " Web admin: http://${PUBLIC_IP:-$_LOCAL_IP}:${WEB_ADMIN_PORT_VAL}" echo " Manage: docker compose -f $EA_DIR/docker-compose.yml " - echo " Script: docker exec -it easy-asterisk easy-asterisk --help" + echo " Script: docker exec -it ${CONTAINER} easy-asterisk --help" + if [[ -n "$DOMAIN_NAME" ]] && [[ -d "$DOCKER_DIR/caddy" ]]; then + echo "" + log_info "If Caddy was just installed in this same run, it may still be obtaining the" + log_info "Let's Encrypt cert for ${DOMAIN_NAME} — Asterisk only checks for it at startup" + log_info "and then every 12h. If SIP TLS still shows self-signed after a couple of" + log_info "minutes, pick it up immediately with:" + log_info " docker compose -f $EA_DIR/docker-compose.yml restart asterisk" + fi echo "" } diff --git a/services/authelia.sh b/services/authelia.sh index 613085f..169685d 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -435,7 +435,7 @@ auth.${AUTHELIA_DOMAIN} { # own incoming request (always auth.${AUTHELIA_DOMAIN} itself) and # overwrites the value a forward_auth caller (e.g. a remote site's # "forward_auth https://auth.${AUTHELIA_DOMAIN}" block, see - # services/asterisk-digital-ocean.sh) set for its own domain. Confirmed + # services/asterisk.sh's droplet-mode Caddy block) set for its own domain. Confirmed # live: every forward-auth check evaluated as if it were for # auth.${AUTHELIA_DOMAIN} itself (which has policy: bypass in # access_control.rules so its own login portal isn't gated behind diff --git a/services/caddy.sh b/services/caddy.sh index aeb83b7..126cb65 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -268,7 +268,7 @@ services: labels: - "io.podman.annotations.label/crowdsec.enable=true" # Lets Caddyfile blocks reach services that use network_mode: host - # (e.g. asterisk/asterisk-digital-ocean) via "host.docker.internal:PORT" — Caddy + # (e.g. asterisk) via "host.docker.internal:PORT" — Caddy # itself is on the caddy_net bridge network below, so plain "localhost" # in a site block resolves to Caddy's own container, not the host. extra_hosts: diff --git a/services/crowdsec.sh b/services/crowdsec.sh index eeed3b3..3891613 100644 --- a/services/crowdsec.sh +++ b/services/crowdsec.sh @@ -102,7 +102,7 @@ install_crowdsec() { echo "[DRY-RUN] Would ensure /var/log/caddy exists for log acquisition" echo "[DRY-RUN] Would install collections: sshd, linux, caddy, base-http-scenarios" echo "[DRY-RUN] Would write Caddy acquisition /etc/crowdsec/acquis.d/caddy.yaml" - echo "[DRY-RUN] Would install crowdsecurity/asterisk + write an acquisition if asterisk-digital-ocean is installed" + echo "[DRY-RUN] Would install crowdsecurity/asterisk + write an acquisition if asterisk is installed" echo "[DRY-RUN] Would optionally wire ntfy ban alerts into the default profile" echo "[DRY-RUN] Would optionally register with a remote/central LAPI and disable the local one" echo "[DRY-RUN] Would enable + restart crowdsec and crowdsec-firewall-bouncer" @@ -194,17 +194,26 @@ labels: echo " ✓ Caddy acquisition already exists" fi - # ── 5b. SIP brute-force/enumeration protection, if asterisk-digital-ocean - # is installed (services/asterisk-digital-ocean.sh patches Asterisk to log - # security events — auth failures, registration scanning — to - # $EA_DIR/logs/full. The plain LAN asterisk.sh doesn't emit that file yet, - # so it's intentionally not detected here.) - local ASTERISK_LOG_DIR="$DOCKER_DIR/asterisk-digital-ocean/logs" - if [ -d "$ASTERISK_LOG_DIR" ]; then - echo " Detected asterisk-digital-ocean — installing SIP brute-force/enumeration protection..." + # ── 5b. SIP brute-force/enumeration protection, if Asterisk is installed. + # services/asterisk.sh patches Asterisk to log security events — auth + # failures, registration scanning — to $EA_DIR/logs/full, which is what + # the acquisition below tails. Both directories are probed: a box set up + # before the droplet edition was merged back into `asterisk` still runs + # out of ~/docker/asterisk-digital-ocean. The logging patch used to be + # droplet-only; it now applies to every install, so a home/LAN box gets + # SIP protection here too. + local ASTERISK_LOG_DIR="" + local _ea_candidate + for _ea_candidate in "$DOCKER_DIR/asterisk-digital-ocean" "$DOCKER_DIR/asterisk"; do + [ -d "$_ea_candidate/logs" ] && { ASTERISK_LOG_DIR="$_ea_candidate/logs"; break; } + done + if [ -n "$ASTERISK_LOG_DIR" ]; then + echo " Detected Asterisk at ${ASTERISK_LOG_DIR%/logs} — installing SIP brute-force/enumeration protection..." sudo cscli collections install crowdsecurity/asterisk 2>/dev/null || \ echo " ⚠ crowdsecurity/asterisk collection may already be installed" + # Filename kept as-is so a droplet that already has this acquisition + # isn't given a second one pointing at the same log. local ASTERISK_ACQUIS="/etc/crowdsec/acquis.d/asterisk-digital-ocean.yaml" if [ ! -f "$ASTERISK_ACQUIS" ]; then local ASTERISK_ACQUIS_CONTENT="filenames: @@ -543,7 +552,7 @@ install. The real configuration lives under `/etc/crowdsec`. ## What it does - Detects malicious behaviour (SSH brute force, web scans, SIP brute - force/enumeration if `asterisk-digital-ocean` is installed) by parsing logs. + force/enumeration if `asterisk` is installed) by parsing logs. - Bans offending IPs via the **firewall bouncer** (iptables/nftables). - Pulls **community IP reputation** blocklists so known-bad IPs are blocked before they ever touch your services. @@ -573,9 +582,12 @@ sudo cscli collections list # installed detection collections - Log acquisition (what to watch): `/etc/crowdsec/acquis.d/` - Caddy access logs: `/etc/crowdsec/acquis.d/caddy.yaml` (`/var/log/caddy/*.log` — Caddy writes JSON access logs there) - - Asterisk SIP auth events (if `asterisk-digital-ocean` is installed): - `/etc/crowdsec/acquis.d/asterisk-digital-ocean.yaml` - (`~/docker/asterisk-digital-ocean/logs/full` — auth failures, registration scans) + - Asterisk SIP auth events (if `asterisk` is installed): + `/etc/crowdsec/acquis.d/asterisk-digital-ocean.yaml` (filename kept from + when the droplet edition was its own service, so existing droplets aren't + given a duplicate acquisition) + (`~/docker/asterisk/logs/full`, or `~/docker/asterisk-digital-ocean/logs/full` + on a pre-merge droplet — auth failures, registration scans) - Notifications: `/etc/crowdsec/notifications/` - ntfy ban alerts (if enabled): `/etc/crowdsec/notifications/ntfy.yaml`, wired into `/etc/crowdsec/profiles.yaml` @@ -609,7 +621,7 @@ sudo cscli collections list # installed detection collections list directly in that file, then `sudo systemctl restart crowdsec`. This can block Let's Encrypt's out-of-region ACME validation checks; if a cert renewal fails mysteriously, check here first. -- ASN-exempt Asterisk brute-force scenarios (if enabled, asterisk-digital-ocean +- ASN-exempt Asterisk brute-force scenarios (if enabled, Asterisk installs only): `/etc/crowdsec/scenarios/local-asterisk_bf.yaml` and `local-asterisk_user_enum.yaml` — local forks of the stock hub scenarios with specific carrier ASNs excluded from their filter (the hub originals get diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 2d36529..f032835 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -1,9 +1,12 @@ #!/bin/bash -# services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean -# (or the home/LAN asterisk install): US-only outbound (NANP dialplan -# restriction), independent outbound/inbound concurrent-call caps, a 3-tier -# permission model per extension (internal-only / restricted to pre-approved -# numbers / full US calling), a configurable inbound ring-group, +# services/pstn-trunk.sh — SIP PSTN trunk add-on for services/asterisk.sh: +# US-only outbound (NANP dialplan +# restriction), independent outbound/inbound concurrent-call caps, a +# per-extension permission model built on ONE whitelist plus a mode saying +# which direction(s) it applies to — the original full / restricted / +# internal tiers, plus restricted-in (whitelist gates incoming, dials +# anywhere) and restricted-out (whitelist gates outgoing, anyone can call +# in) — a configurable inbound ring-group, # IP-authenticated trunk (no SIP password stored), ntfy alerts on # denied/rejected calls, and a periodic spend/volume check. # @@ -15,19 +18,19 @@ # works the same way. VoIP.ms and Anveo Direct are both confirmed working; # see docs/pstn-calling-voipms-plan.md for the design/cost background. # -# Requires an existing services/asterisk-digital-ocean.sh OR services/asterisk.sh -# install — this adds a PSTN trunk on top of one of them and does not stand -# alone. Permission tiers AND concurrency caps are managed live (no restart +# Requires an existing services/asterisk.sh install (either directory layout — +# ~/docker/asterisk, or ~/docker/asterisk-digital-ocean on a box set up before +# the droplet edition was merged back in) — this adds a PSTN trunk on top and +# does not stand alone. Permission tiers AND concurrency caps are managed live (no restart # needed) via pstn-permissions.conf / pstn-limits.conf — editable by hand, or -# from services/security-dashboard.sh's "PSTN Trunk" tab if that's installed. +# from services/security-dashboard.sh's Extensions tab if that's installed. # # Part of the modular post-install system (sourced by setup.sh). -register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean/asterisk — US-only, per-extension permission tiers, spend/volume alerts (any IP-authenticated provider — VoIP.ms and Anveo Direct both confirmed)" +register_service pstn-trunk homelab "SIP PSTN trunk for asterisk — US-only, per-extension whitelist restricting inbound and/or outbound, spend/volume alerts (any IP-authenticated provider — VoIP.ms and Anveo Direct both confirmed)" # ── Surviving Easy Asterisk's regeneration ────────────────────────────────── -# Easy Asterisk (the vendor project asterisk-digital-ocean.sh/asterisk.sh -# build on) fully OVERWRITES both pjsip.conf and extensions.conf from its own +# Easy Asterisk (the vendor project services/asterisk.sh builds on) fully OVERWRITES both pjsip.conf and extensions.conf from its own # internal state: # - extensions.conf: rebuilt by rebuild_dialplan() on every container start, # and whenever a device/room is added or removed via the web admin. @@ -41,16 +44,19 @@ register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean/a # the #include itself survive regeneration too, _pstn_patch_vendor_files # (below) patches it into the vendor's *generator functions* — the same # technique this repo already uses for the logger.conf security-logging fix -# in _asterisk_do_refresh_vendor_files (see services/asterisk-digital-ocean.sh). +# in _asterisk_refresh_vendor_files (see services/asterisk.sh). # -# Caveat: if the base asterisk-digital-ocean/asterisk install is later +# Caveat: if the base asterisk install is later # refreshed ("update in place", which re-copies fresh vendor files) # independently of this service, the patch is wiped along with it and needs # reapplying — run this service again (fresh or update mode both reapply it) # after any base install update. # # ── Why permissions are a separate live file, not baked into the dialplan ── -# pstn-permissions.conf holds each extension's tier (internal/restricted/full) +# pstn-permissions.conf holds each extension's 'restrict' mode and its single +# 'allowed_numbers' whitelist (the authored pair), plus the +# tier_out/allowed_out/tier_in/allowed_in derived from them that the dialplan +# actually reads, plus a legacy 'tier' mirror for rollback # and, for restricted, its pipe-separated approved-number list. The dialplan # reads it via Asterisk's AST_CONFIG() function, which re-reads the file from # disk on every call — so editing this file (by hand, or via the Security @@ -267,8 +273,10 @@ EOF # ── Shared: one inbound ring-group member's live permission check ───────── # Emits a block that only adds this extension to PSTN_RING_LIST if it's -# "full" tier, or "restricted" tier AND the inbound Caller-ID is on its -# approved list. Uses a single-quoted heredoc (fully literal — no bash +# "full" INBOUND tier, or "restricted" inbound tier AND the caller ID is on +# allowed_in. The outbound side is not consulted here at all, which is what +# lets "Restrict inbound" mean "dial anywhere, but only these numbers get +# through to me" — and "Restrict outbound" the reverse. Uses a single-quoted heredoc (fully literal — no bash # expansion) captured into a variable, then a pure bash string replace for # the extension number placeholder — safer than sed here since it needs no # escaping at all (the extension is plain digits, but this avoids relying on @@ -282,9 +290,9 @@ _pstn_ring_member_block() { # (bare "?label" / "Goto(label)"), not "label,1" (which addresses a # different, nonexistent extension named "label" instead). block=$(cat << 'MEMBER' - same => n,Set(PSTN_M_TIER=${AST_CONFIG(pstn-permissions.conf,__EXT__,tier)}) + same => n,Set(PSTN_M_TIER=${AST_CONFIG(pstn-permissions.conf,__EXT__,tier_in)}) same => n,GotoIf($["${PSTN_M_TIER}" = "full"]?ring__EXT__) - same => n,Set(PSTN_M_ALLOWED=${AST_CONFIG(pstn-permissions.conf,__EXT__,allowed_numbers)}) + same => n,Set(PSTN_M_ALLOWED=${AST_CONFIG(pstn-permissions.conf,__EXT__,allowed_in)}) same => n,GotoIf($["${PSTN_M_TIER}" = "restricted" & ${REGEX("^(${PSTN_M_ALLOWED})$" ${PSTN_CALLERID_NORM})}=1]?ring__EXT__) same => n,Goto(skip__EXT__) same => n(ring__EXT__),Set(PSTN_RING_LIST=${PSTN_RING_LIST}${PSTN_RING_SEP}PJSIP/__EXT__) @@ -350,10 +358,10 @@ exten => _1NXXNXXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL} to ${EXT same => n,GotoIf($[${REGEX("^(242|246|264|268|284|340|345|441|473|649|658|664|670|671|684|721|758|767|784|787|809|829|849|868|869|876|939)$" ${PSTN_AREA_CODE})} = 1]?pstn_intl_blocked,1) same => n,Set(PSTN_CALLER=${CUT(CHANNEL,/,2)}) same => n,Set(PSTN_CALLER=${CUT(PSTN_CALLER,-,1)}) - same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)}) + same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier_out)}) same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_check_busy,1) same => n,GotoIf($["${PSTN_TIER}" = "restricted"]?pstn_check_allow_out,1) - same => n,NoOp(Denied - ${PSTN_CALLER} has no PSTN permission, tier: ${PSTN_TIER}) + same => n,NoOp(Denied - ${PSTN_CALLER} has no outbound PSTN permission, tier_out: ${PSTN_TIER}) __ALERT_DENY_TIER_LINE__ same => n,Busy(15) same => n,Hangup() @@ -392,9 +400,9 @@ exten => _011X.,1,NoOp(PSTN international outbound call attempt from ${CHANNEL} same => n,GotoIf($["${PSTN_KILLED}" = "1"]?pstn_killed,1) same => n,Set(PSTN_CALLER=${CUT(CHANNEL,/,2)}) same => n,Set(PSTN_CALLER=${CUT(PSTN_CALLER,-,1)}) - same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)}) + same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier_out)}) same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_intl_check_country,1) - same => n,NoOp(Denied intl - ${PSTN_CALLER} tier ${PSTN_TIER} not eligible for international calling) + same => n,NoOp(Denied intl - ${PSTN_CALLER} tier_out ${PSTN_TIER} not eligible for international calling) __ALERT_DENY_INTL_TIER_LINE__ same => n,Busy(15) same => n,Hangup() @@ -410,7 +418,7 @@ __ALERT_DENY_INTL_COUNTRY_LINE__ same => n,Busy(15) same => n,Hangup() -exten => pstn_check_allow_out,1,Set(PSTN_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},allowed_numbers)}) +exten => pstn_check_allow_out,1,Set(PSTN_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},allowed_out)}) same => n,GotoIf($[${REGEX("^(${PSTN_ALLOWED})$" ${PSTN_DIALED})} = 1]?pstn_check_busy,1) same => n,NoOp(Denied - ${PSTN_DIALED} not on ${PSTN_CALLER}'s approved number list) __ALERT_DENY_NUMBER_LINE__ @@ -459,7 +467,7 @@ EOF # zero of the outbound NANP patterns either, and `dialplan show # from-pstn-trunk` reported the context didn't exist at all, with no # warning or error anywhere (config log, full log, or the reload command's -# own output) pointing at why. Meanwhile services/asterisk-digital-ocean.sh's +# own output) pointing at why. Meanwhile services/asterisk.sh's # messaging-dialplan.conf — #include'd via the exact same mechanism, right # after [intercom] in the same extensions.conf — loaded fine every time. # The one structural difference: messaging-dialplan.conf's first real line @@ -608,9 +616,9 @@ __ALERT_KILLED_IN_LINE__ ; restart — see restart_asterisk_container() in services/security-dashboard.sh) ; before AST_CONFIG() actually returns the new value. exten => pstn_personal_inbound,1,GotoIf($["${PSTN_PERSONAL_OWNER:0:1}" = "@"]?pstn_personal_group_ring,1) - same => n,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},tier)}) + same => n,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},tier_in)}) same => n,GotoIf($["${PSTN_OWNER_TIER}" = "full"]?pstn_personal_ring,1) - same => n,Set(PSTN_OWNER_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},allowed_numbers)}) + same => n,Set(PSTN_OWNER_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},allowed_in)}) same => n,GotoIf($["${PSTN_OWNER_TIER}" = "restricted" & ${REGEX("^(${PSTN_OWNER_ALLOWED})$" ${PSTN_CALLERID_NORM})}=1]?pstn_personal_ring,1) same => n,NoOp(Denied - personal DID ${PSTN_DID_CALLED}'s owner ${PSTN_PERSONAL_OWNER} not authorized for this caller) __ALERT_DENY_PERSONAL_LINE__ @@ -734,11 +742,11 @@ IFS=',' read -ra MEMBERS <<< "$MEMBERS_RAW" for _ext in "${MEMBERS[@]}"; do _ext="$(echo "$_ext" | xargs)" [[ -z "$_ext" ]] && continue - _tier="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "tier")" + _tier="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "tier_in")" if [[ "$_tier" == "full" ]]; then RING_LIST="${RING_LIST}${RING_LIST:+&}PJSIP/${_ext}" elif [[ "$_tier" == "restricted" ]]; then - _allowed="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "allowed_numbers")" + _allowed="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "allowed_in")" if [[ -n "$_allowed" ]] && [[ "$CALLER" =~ ^(${_allowed})$ ]]; then RING_LIST="${RING_LIST}${RING_LIST:+&}PJSIP/${_ext}" fi @@ -748,6 +756,102 @@ printf '%s' "$RING_LIST" SCRIPT } +# ── Migration: legacy single tier → 'restrict' mode + derived keys ───────── +# Installs made before the per-direction split have only 'tier' and +# 'allowed_numbers'. The dialplan now reads tier_out/tier_in, so leaving them +# alone would fail closed and silently deny every PSTN call both ways. +# +# Writes the authored 'restrict' key plus the derived tier_out/allowed_out/ +# tier_in/allowed_in, reproducing exactly the behaviour the box already had: +# a legacy tier of "full" becomes restrict=open, "restricted" becomes +# restrict=both (the old single list applied in both directions, which is +# what the old dialplan did), anything else becomes restrict=none. +# +# Idempotent: an extension that already has 'restrict' is left alone, so this +# runs safely on every update. Backs the file up first — unlike most of this +# installer, it edits a file the user may have hand-tuned. +_pstn_migrate_permissions_split() { + local FILE="$1" + [[ -f "$FILE" ]] || return 0 + grep -qE '^[[:space:]]*(tier|tier_out)[[:space:]]*=' "$FILE" || return 0 + grep -qE '^[[:space:]]*restrict[[:space:]]*=' "$FILE" && return 0 + + cp "$FILE" "$FILE.backup.$(date +%Y%m%d-%H%M%S)" + local TMP + TMP="$(mktemp)" + # Buffer per section: 'restrict' depends on the tier, and the whitelist + # may appear on either side of it, so the whole section has to be read + # before any of it can be rewritten. + awk ' + function flush_section() { + if (!have) return + if (header != "") print header + mode = "internal" + if (tier == "full") mode = "full" + else if (tier == "restricted") mode = "restricted" + # An install that predates the split has no tier_out; one made + # between the split and this change may, so honour it if present. + if (tier_out != "" || tier_in != "") { + o = (tier_out != "" ? tier_out : tier) + i = (tier_in != "" ? tier_in : tier) + if (o == "full" && i == "full") mode = "full" + else if (o == "restricted" && i == "restricted") mode = "restricted" + else if (o == "restricted") mode = "restricted-out" + else if (i == "restricted") mode = "restricted-in" + else mode = "internal" + } + print "restrict=" mode + if (nums != "") print "allowed_numbers=" nums + if (mode == "full") { print "tier_out=full"; print "tier_in=full"; print "tier=full" } + else if (mode == "restricted-out") { + print "tier_out=restricted"; print "allowed_out=" nums + print "tier_in=full"; print "tier=restricted" + } + else if (mode == "restricted-in") { + print "tier_out=full"; print "tier_in=restricted" + print "allowed_in=" nums; print "tier=full" + } + else if (mode == "restricted") { + print "tier_out=restricted"; print "allowed_out=" nums + print "tier_in=restricted"; print "allowed_in=" nums + print "tier=restricted" + } + for (k = 1; k <= nkeep; k++) print keep[k] + header = ""; tier = ""; tier_out = ""; tier_in = ""; nums = "" + nkeep = 0; have = 0 + } + /^[ \t]*\[/ { flush_section(); header = $0; have = 1; next } + { + if (!have) { print; next } + line = $0 + key = line; sub(/=.*$/, "", key); gsub(/^[ \t]+|[ \t]+$/, "", key) + val = line + if (index(line, "=") > 0) { sub(/^[^=]*=[ \t]*/, "", val) } else { val = "" } + gsub(/[ \t]+$/, "", val) + if (key == "tier") { tier = val; next } + if (key == "tier_out") { tier_out = val; next } + if (key == "tier_in") { tier_in = val; next } + if (key == "allowed_numbers" || key == "allowed_out" || key == "allowed_in") { + if (nums == "" && val != "") nums = val + next + } + keep[++nkeep] = line + } + END { flush_section() } + ' "$FILE" > "$TMP" + + if [[ -s "$TMP" ]]; then + mv "$TMP" "$FILE" + chmod 664 "$FILE" + log_success "Migrated pstn-permissions.conf to the 'restrict' model (backup saved alongside it)." + log_info "Every extension kept its existing behaviour. Pick which direction(s) each" + log_info "whitelist applies to in the Security Dashboard's Extensions tab." + else + rm -f "$TMP" + log_warning "Permission migration produced an empty file — left the original alone." + fi +} + # ── Shared: initial concurrency limits (fresh install / explicit reset only # — same "update never touches it" protection as pstn-permissions.conf, see # the file-level comment above) ───────────────────────────────────────────── @@ -784,8 +888,26 @@ _pstn_write_permissions_file() { done local _written_exts="" { - echo "; PSTN permission tiers — internal / restricted / full — PLUS two independent" - echo "; axes per extension:" + echo "; PSTN permissions. Each extension has ONE whitelist and a 'restrict' mode" + echo "; saying which direction(s) that whitelist applies to. The first three are" + echo "; the original tiers, unchanged; the last two are the new half-restrictions:" + echo "; full - no whitelist, calls both ways" + echo "; restricted - the whitelist applies BOTH ways" + echo "; internal - no PSTN at all (internal extension calling still works)" + echo "; restricted-in - whitelist gates INCOMING only; may dial anywhere" + echo "; restricted-out - whitelist gates OUTGOING only; anyone may call in" + echo "; 'allowed_numbers' is that whitelist: pipe-separated 11-digit numbers, used" + echo "; directly as a REGEX() alternation (see this file's own comments on why" + echo "; untrusted call data is always the string being tested, never the pattern)." + echo ";" + echo "; 'restrict' + 'allowed_numbers' are the AUTHORED form — the two keys to edit" + echo "; by hand. tier_out/allowed_out/tier_in/allowed_in below them are DERIVED from" + echo "; those and are what the dialplan actually reads; 'tier' is a rollback mirror" + echo "; of tier_out for pre-split versions of this installer. Change the authored" + echo "; keys and re-run this installer (or use the Security Dashboard, which keeps" + echo "; all of them in step) rather than editing the derived ones directly." + echo ";" + echo "; PLUS two further independent axes per extension:" echo "; - 'messaging' for Asterisk's native internal SIP MESSAGE texting (no carrier" echo "; SMS, no PSTN, no cost — a separate axis from PSTN calling, since the risk" echo "; profile is different: an extension can be internal-tier for calling and" @@ -808,6 +930,9 @@ _pstn_write_permissions_file() { local _ext for _ext in $FULL_EXTS; do echo "[$_ext]" + echo "restrict=full" + echo "tier_out=full" + echo "tier_in=full" echo "tier=full" [[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes" [[ -n "${_personal_did_map[$_ext]:-}" ]] && echo "personal_did=${_personal_did_map[$_ext]}" @@ -818,8 +943,13 @@ _pstn_write_permissions_file() { _ext="$1"; local _nums="$2" shift 2 echo "[$_ext]" - echo "tier=restricted" + echo "restrict=restricted" echo "allowed_numbers=${_nums}" + echo "tier_out=restricted" + echo "allowed_out=${_nums}" + echo "tier_in=restricted" + echo "allowed_in=${_nums}" + echo "tier=restricted" [[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes" [[ -n "${_personal_did_map[$_ext]:-}" ]] && echo "personal_did=${_personal_did_map[$_ext]}" echo "" @@ -1458,6 +1588,10 @@ _pstn_apply_settings() { _pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$NTFY_URL" _pstn_write_inbound_dialplan_include "$ASTERISK_DIR/pstn-trunk-inbound-dialplan.conf" "$RING_EXTS" "$NTFY_URL" _pstn_write_personal_group_ring_script "$ASTERISK_DIR/pstn-personal-group-ring.sh" + # Must run alongside the dialplan write, not after a reload: the dialplan + # above reads tier_out/tier_in, so an un-migrated permissions file would + # deny every call in the window between the two. + _pstn_migrate_permissions_split "$ASTERISK_DIR/pstn-permissions.conf" _pstn_write_usage_alert_script "$EA_DIR/pstn-trunk-usage-alert.sh" "$EA_DIR" "$ASTERISK_DIR" \ "$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$MAX_MONTHLY_SPEND" "$NTFY_URL" "$CONTAINER_NAME" ensure_docker_dir_ownership "$ASTERISK_DIR" @@ -1608,7 +1742,7 @@ install_pstn-trunk() { [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would require an existing asterisk-digital-ocean OR asterisk (LAN) install" + echo "[DRY-RUN] Would require an existing asterisk install (droplet or home/LAN)" echo "[DRY-RUN] Would prompt for: known-provider quick-pick (Anveo Direct runs a full 5-step" echo "[DRY-RUN] interactive portal walkthrough — account/funding, DID ordering, both trunk" echo "[DRY-RUN] objects, confirmed rate — pausing for Enter between each; VoIP.ms pre-fills known" @@ -1640,10 +1774,12 @@ install_pstn-trunk() { fi if [[ -z "$EA_DIR" ]]; then - log_error "Neither asterisk-digital-ocean nor asterisk (LAN) is installed — install one first:" - log_error " sudo ./setup.sh asterisk-digital-ocean (recommended — public droplet, static IP)" - log_error " sudo ./setup.sh asterisk (home/LAN — see the static-IP caveat below)" - log_error "This service adds a PSTN trunk on top of one of them; it doesn't stand alone." + log_error "Asterisk is not installed — install it first:" + log_error " sudo ./setup.sh asterisk" + log_error "A public droplet (which that installer detects and tunes for) is the" + log_error "recommended host, since IP authentication wants a static IP — see the" + log_error "caveat below for what that means on a home/LAN box." + log_error "This service adds a PSTN trunk on top of it; it doesn't stand alone." return 1 fi @@ -1652,7 +1788,7 @@ install_pstn-trunk() { log_warning "Using the home/LAN asterisk install. IP authentication needs a STABLE public IP —" log_warning "if this box is behind a dynamic home IP, your provider's IP allow-list goes stale" log_warning "whenever your ISP rotates it, breaking calls until you update it there yourself." - log_warning "A static IP from your ISP avoids that; asterisk-digital-ocean sidesteps it entirely." + log_warning "A static IP from your ISP avoids that; a cloud droplet sidesteps it entirely." fi log_info "Configuring a SIP PSTN trunk for $ASTERISK_KIND (any IP-authenticated provider —" @@ -2089,7 +2225,7 @@ access specifically: Stored in \`config/asterisk/pstn-permissions.conf\`, read **live** by the dialplan via Asterisk's \`AST_CONFIG()\` on every call — editing this file -(by hand, or via the Security Dashboard's "PSTN Trunk" tab, if that service +(by hand, or via the Security Dashboard's Extensions tab, if that service is installed) takes effect on the next call, no restart needed. Re-running this installer in "update" mode never touches this file — only a "fresh" reinstall (with confirmation) or the web UI change it, the same protection @@ -2213,12 +2349,11 @@ Asterisk's native SIP \`MESSAGE\` support (extension-to-extension texting — no carrier SMS, no PSTN, no cost) is gated by a \`messaging=yes\` flag per extension in \`pstn-permissions.conf\`, independent of the PSTN calling tiers above — off by default, same "opt in" posture. Live-editable any -time via the Security Dashboard's "PSTN Trunk" tab, in its own -always-available "Internal SIP messaging" card — no dependency on this -trunk (or any PSTN trunk at all) being installed. +time via the Security Dashboard's Extensions tab, in the Messaging column of +its always-available extensions table — no dependency on this trunk (or any +PSTN trunk at all) being installed. -Actually enforced, not just a flag — \`services/asterisk-digital-ocean.sh\` -(and \`services/asterisk.sh\` for the LAN edition) routes messages through a +Actually enforced, not just a flag — \`services/asterisk.sh\` routes messages through a dedicated \`[sip-messaging]\` dialplan context (separate from \`[intercom]\`'s own per-device call routing, so there's no collision risk) and checks this same flag via \`AST_CONFIG()\` before delivering. One caveat still flagged @@ -2242,11 +2377,19 @@ Stored in \`config/asterisk/pstn-personal-dids.conf\` (DID -> owner, read live by the dialplan for inbound routing) and a \`personal_did=\` field per extension in \`pstn-permissions.conf\` (the outbound Caller-ID override) — both kept in sync automatically by the CLI installer and the Security -Dashboard's "PSTN Trunk" tab, live, no restart needed. +Dashboard's Extensions tab, live, no restart needed. + +## Receiving SMS on the trunk DID + +Not handled by this service — SMS and voice are configured separately at the +provider, and inbound SMS doesn't touch Asterisk at all. \`sudo ./setup.sh +sms-inbound\` sets up verification codes arriving as ntfy push notifications; +see that service's README for the short-code and mobile-DID caveats that +decide whether codes actually get through. ## Managing this from a web UI -If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab +If \`services/security-dashboard.sh\` is installed, its Extensions tab shows the per-extension permission tiers, the outbound/inbound concurrency caps, and personal-number assignments, all editable live — no restart, no reinstall. Install/update it any time with \`sudo ./setup.sh diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 0438726..200f5d0 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -64,16 +64,17 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then fi # ───────────────────────────────────────────────────────────────────────────── -register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + CrowdSec bans (Authelia-protected)" 8092 +register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + extension/trunk management + CrowdSec bans (Authelia-protected)" 8092 install_security-dashboard() { local APP_DIR="/opt/security-dashboard" local DASHBOARD_PORT=8092 local SVC_USER="secdash" - # Either Asterisk flavor works — prefer asterisk-digital-ocean if both - # happen to be installed, matching services/pstn-trunk.sh's own - # preference order for consistency. + # Either install layout works — prefer ~/docker/asterisk-digital-ocean + # (a droplet from before that service was merged into `asterisk`) if both + # happen to exist, matching services/pstn-trunk.sh's own preference order + # for consistency. local ASTERISK_EA_DIR="" if [ -d "$DOCKER_DIR/asterisk-digital-ocean" ]; then ASTERISK_EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" @@ -86,9 +87,10 @@ install_security-dashboard() { # pjsip.conf — see vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's own # CATEGORIES_FILE/ROOMS_FILE constants (/etc/easy-asterisk/*, not # /etc/asterisk/*). ASTERISK_EA_CONTAINER names the actual container to - # `docker exec` into for the native Asterisk Admin tab's writes/CLI - # calls (ea_* functions) — "easy-asterisk-do" for the droplet flavor, - # "easy-asterisk" for LAN, matching each service's own container_name. + # `docker exec` into for the Extensions tab's device writes/CLI calls + # (ea_* functions) — "easy-asterisk", or "easy-asterisk-do" for a droplet + # set up before the two Asterisk services merged, matching whichever + # container_name services/asterisk.sh actually used there. local ASTERISK_EA_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/easy-asterisk}" local ASTERISK_EA_CONTAINER="" if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then @@ -100,17 +102,17 @@ install_security-dashboard() { echo "" echo "┌─────────────────────────────────────────────────────────────────┐" echo "│ SECURITY DASHBOARD │" - echo "│ Asterisk failed-connection log + CrowdSec decisions + PSTN │" - echo "│ trunk permissions, one page. Runs natively on the host (not │" - echo "│ Docker) so it can call cscli and read Asterisk's files │" - echo "│ directly. Authelia-protected. │" + echo "│ Asterisk failed-connection log + one Extensions tab (devices, │" + echo "│ categories, rooms, groups, PSTN tiers, DIDs) + CrowdSec bans, │" + echo "│ one page. Runs natively on the host (not Docker) so it can call │" + echo "│ cscli and read Asterisk's files directly. Authelia-protected. │" echo "└─────────────────────────────────────────────────────────────────┘" echo "" if [ -z "$ASTERISK_EA_DIR" ]; then - log_warning "No asterisk-digital-ocean or asterisk install detected." - log_warning "The Security Log, Extensions, Asterisk Admin, and PSTN Trunk tabs will just be" - log_warning "empty/hidden — CrowdSec's tab still works fine." + log_warning "No Asterisk install detected." + log_warning "The Security Log and Extensions tabs will just be empty — CrowdSec's tab" + log_warning "still works fine." fi if [ "$DRY_RUN" = true ]; then @@ -118,7 +120,7 @@ install_security-dashboard() { echo "[DRY-RUN] Would write $APP_DIR/app.py" echo "[DRY-RUN] Would write /etc/sudoers.d/security-dashboard (scoped cscli/systemctl/set-asn-exempt.sh only)" echo "[DRY-RUN] Would write a systemd unit and start it on 0.0.0.0:$DASHBOARD_PORT (firewalled via UFW, not interface binding)" - echo "[DRY-RUN] Would grant read/write access to the detected Asterisk config dir (for the PSTN Trunk tab)" + echo "[DRY-RUN] Would grant read/write access to the detected Asterisk config dir (for the Extensions tab)" echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for" return 0 fi @@ -202,59 +204,89 @@ protected page. Runs natively on the host (systemd service \`security-dashboard\ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. ## Tabs -The nav only ever shows tabs for things actually present on this box — no -tab for a service you haven't installed. **Security Log** and **Extensions** -are always there (they only need Asterisk itself, detected once at install -time). **Asterisk Admin**, **PSTN Trunk**, and **CrowdSec** each check their -own live install state on every page load and hide their own nav button -entirely if not found, so this one page/URL scales from a bare LAN Asterisk -box (just those first two tabs) up to a full droplet with a trunk and -CrowdSec, without ever showing a tab for something that isn't set up. + +Three tabs: **Security Log**, **Extensions**, **CrowdSec**. The first two are +always there (they only need Asterisk itself, detected once at install time); +CrowdSec checks its own live install state on every page load and hides its +nav button if \`cscli\` isn't found. + +Extensions used to be three separate tabs — *Asterisk Admin*, *Extensions* +and *PSTN Trunk* — which between them listed the same extensions three times: +once as devices with a category/status, once as a row of messaging +checkboxes, and once as permission tiers. They're now one tab with one +extensions table, and each capability adds columns and cards to it instead of +a nav button of its own. That means the page still scales from a bare LAN +Asterisk box up to a full droplet with a trunk, without ever showing a +control for something that isn't set up — you just don't have to remember +which tab a given extension's settings live on. - **Security Log** — parses \`$ASTERISK_LOG_DIR/full\` for SIP auth failures (wrong password, unknown extension, etc.) with timestamp/account/remote IP, sortable per column (click a header to sort, click again to reverse). -- **Asterisk Admin** — a native reimplementation of Easy Asterisk's own - vendored web admin (\`vendor/easy-asterisk/easy-asterisk-v0.10.0.sh\`'s - device/category/room management), not a link or an iframe to that separate - process — one page, one login. Reads \`pjsip.conf\`/\`categories.conf\`/ - \`rooms.conf\` directly (same formats the vendor's own - \`easy-asterisk --rebuild-dialplan\` CLI still generates the dialplan from); - writes go through \`docker exec ... tee\` (root, sudo-gated) instead of a - direct host-side file write, since Easy Asterisk's container writes these - as its own internal user and a host-side write would just be fighting that - ownership again on the next restart. Its nav button only appears once the - live \`/api/ea-status\` check confirms an Asterisk container is actually - reachable. - - **Devices** — add/rename/delete a SIP extension, reassign its category; - live registered/unregistered status per device. +- **Extensions** — one row per extension, merged from \`pjsip.conf\` (which + always works) and, when the Easy Asterisk container is reachable, its own + device list. Columns: Ext, Name, then Category/Status/Transport if that + container is present, then **PSTN** + **Whitelist** if a trunk dialplan is + installed, then Messaging (always: internal SIP texting has no PSTN + dependency at all — no cost, no carrier, no DID). + + Each extension has one whitelist and a mode saying which direction(s) it + applies to: **No PSTN**, **Unrestricted**, **Restrict outbound** (may only + dial the list, anyone can call in), **Restrict inbound** (may dial + anywhere, only the list can call in), or **Restrict both**. The whitelist + field greys out for the two modes that don't use one. Internal + extension-to-extension calling and ring groups are never gated by any of + this. + + Name and Category are edited **in place**; every cell feeds one batched + save. Rows you've touched get a highlight and a left rail, a sticky bar + reports how many are edited, and **Save changes** commits just those rows — + routing each change to the endpoint it needs (rename/category through the + container; tier + approved numbers + messaging through the permissions + file, or the messaging-only endpoint when there's no trunk). **Discard** + reverts to what's on disk, and closing the page with edits pending warns + first. Delete keeps its own control per row, since it's destructive and + must not ride along with a batch. + + Everything below the table — Categories, Rooms, Groups, Concurrent-call + caps, Personal numbers — is a collapsed section with an item count in its + header, so the tab opens on the extensions table rather than on six + expanded cards. Long explanations sit behind "what this means" + disclosures for the same reason. The table scrolls horizontally inside + its own card, so the page never scrolls sideways on a phone. + - **Extensions** — add/rename/delete a SIP extension, reassign its category; + live registered/unregistered status per device. This is a native + reimplementation of Easy Asterisk's own vendored web admin + (\`vendor/easy-asterisk/easy-asterisk-v0.10.0.sh\`'s device/category/room + management), not a link or an iframe to that separate process — one page, + one login. Reads \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\` directly + (same formats the vendor's own \`easy-asterisk --rebuild-dialplan\` CLI + still generates the dialplan from); writes go through + \`docker exec ... tee\` (root, sudo-gated) instead of a direct host-side + file write, since Easy Asterisk's container writes these as its own + internal user and a host-side write would just be fighting that ownership + again on the next restart. Every write reloads PJSIP and/or rebuilds the + dialplan automatically, the same way the vendored admin's own actions do. - **Categories** — device profiles (an auto-answer default + description). - **Rooms** — ring groups/paging groups; add/remove members per room. - - Every write reloads PJSIP and/or rebuilds the dialplan automatically, the - same way the vendored admin's own actions do. -- **Extensions** — always available, independent of any PSTN trunk. A - **Groups** card lets you name a set of extensions and bulk-enable/disable - messaging for all of them at once — a management convenience only, not a - runtime concept: applying an action just writes the same per-extension - \`pstn-permissions.conf\` key each member's own checkbox would, and - membership changes never retroactively affect anything already applied. - An **Internal SIP messaging** card (a checkbox chip per known extension, - independent of PSTN calling entirely — no cost, no carrier, no DID, no - dependency on a PSTN trunk being installed) sits below it. -- **PSTN Trunk** — its nav button only appears once - \`services/pstn-trunk.sh\`'s dialplan is actually installed - (\`pstn-trunk-dialplan.conf\` present), so it never shows a - real-looking-but-unenforced editor. When present: the outbound/inbound - concurrent-call caps, and every known extension's permission tier - (internal / restricted / full) and, for restricted, its approved numbers — - all editable live, no Asterisk restart, no reinstall, sortable per column. - Also manages personal-number assignments (DID -> owner extension or - group), additive to the shared trunk DID. Writes directly to - \`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`, - which the dialplan reads fresh on every call. The spend-cap kill-switch - and international-calling allow-list are deliberately **not** managed - here — CLI-only, via \`sudo ./setup.sh pstn-trunk\` — since both are more - security-sensitive than what this tab already exposes. + - **Groups** — name a set of extensions and bulk-enable/disable messaging + for all of them at once. A management convenience only, not a runtime + concept: applying an action just writes the same per-extension + \`pstn-permissions.conf\` key each member's own checkbox would, and + membership changes never retroactively affect anything already applied. + (A group owning a personal DID *is* evaluated live against current + membership, though — see below.) + - **Concurrent-call caps** and **Personal numbers** appear only once + \`services/pstn-trunk.sh\`'s dialplan is actually installed + (\`pstn-trunk-dialplan.conf\` present), so the page never shows a + real-looking-but-unenforced editor. Caps are the outbound/inbound + concurrent-call limits; personal numbers map a DID to an owner extension + or group, additive to the shared trunk DID. Writes go directly to + \`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`, + which the dialplan reads fresh on every call. The spend-cap kill-switch + and international-calling allow-list are deliberately **not** managed + here — CLI-only, via \`sudo ./setup.sh pstn-trunk\` — since both are more + security-sensitive than what this tab already exposes. - **CrowdSec** — its nav button only appears once \`cscli\` is detected on this host. Current bans (\`cscli decisions list\`), a delete/unban button per entry, carrier/ASN + country columns (sortable per column), and @@ -289,8 +321,8 @@ sudo journalctl -u security-dashboard -f \`systemctl restart crowdsec\`, and \`set-asn-exempt.sh\` (root:root, mode 700, installed alongside \`app.py\` — the one thing that edits CrowdSec's Asterisk-scenario YAMLs, since \`secdash\` has no write access to those - root-owned files directly and shouldn't). Asterisk Admin (only added if an - Asterisk install is detected): \`docker exec -i tee\` against + root-owned files directly and shouldn't). Extension/device management (only + added if an Asterisk install is detected): \`docker exec -i tee\` against exactly \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\`, plus \`asterisk -rx "module reload res_pjsip.so"\`, \`asterisk -rx "pjsip show endpoints"\`, and @@ -386,7 +418,7 @@ _secdash_grant_asterisk_access() { # fresh temp file then renames it into place) — only on the config dir, # not the log dir (no reason for secdash to ever create files there). # _ea_config_dir (categories.conf/rooms.conf) deliberately stays - # read-only — the native Asterisk Admin tab writes those through + # read-only — the Extensions tab writes those through # `docker exec ... tee` instead (see the ea_* functions), not a direct # host-side write, so there's no reason to grant it write access at all. if [ -n "$_config_dir" ] && [ -d "$_config_dir" ]; then @@ -454,13 +486,13 @@ SDSVC _secdash_write_sudoers() { local _svc_user="$1" _ea_container="${2:-}" local _ea_lines="" - # Native Asterisk Admin tab (ea_* functions) — every write goes through + # Extensions tab device management (ea_* functions) — every write goes through # `docker exec -i tee ` instead of a direct # host-side file write (see _secdash_grant_asterisk_access's comment on # why), plus the two Asterisk CLI calls needed after a change and the # live registration-status check. All seven are exact commands, no # wildcards, scoped to the one container actually installed on this box. - # The last line (docker restart) backs the PSTN Trunk tab's "Commit + # The last line (docker restart) backs the Extensions tab's "Commit # Changes" button — see restart_asterisk_container()'s comment for why # that exists (AST_CONFIG() live-reads not always picking up dashboard # edits without a full container restart). @@ -1110,7 +1142,15 @@ def _write_ini_cp(path, header, cp): PERMISSIONS_HEADER = ( - "; PSTN permission tiers - internal / restricted / full - PLUS two\n" + "; PSTN permissions. Each extension has ONE whitelist (allowed_numbers)\n" + "; and a 'restrict' mode saying which direction(s) it applies to:\n" + "; full / restricted (both ways) / internal - the original tiers - plus\n" + "; restricted-in (whitelist gates incoming only) and restricted-out\n" + "; (whitelist gates outgoing only).\n" + "; restrict + allowed_numbers are the authored pair; tier_out/allowed_out\n" + "; and tier_in/allowed_in are DERIVED from them and are what the dialplan\n" + "; reads; 'tier' is a rollback mirror for a pre-split pstn-trunk.sh.\n" + "; PLUS two\n" "; independent per-extension axes: messaging (internal SIP MESSAGE\n" "; texting) and personal_did (outbound Caller-ID override; inbound\n" "; routing for personal DIDs lives in pstn-personal-dids.conf).\n" @@ -1148,73 +1188,151 @@ def _read_permissions_cp(): return cp +RESTRICT_RE = re.compile(r"^(internal|restricted|full|restricted-in|restricted-out)$") + + +def _derive_restrict(tier_out, tier_in): + """Infer the authored mode from the derived per-direction tiers. + + Only needed for a config written before 'restrict' existed. Fails toward + the more restrictive reading: anything that isn't clearly open in a + direction is treated as restricted or none, never widened.""" + if tier_out == "full" and tier_in == "full": + return "full" + if tier_out == "restricted" and tier_in == "restricted": + return "restricted" + if tier_out == "restricted": + return "restricted-out" + if tier_in == "restricted": + return "restricted-in" + return "internal" + + def get_all_permissions(): - """{ext: {"tier": ..., "allowed_numbers": "num|num|...", "messaging": - bool}} for every extension with a non-default record. Extensions with - no section are implicitly "internal"/messaging-disabled — the - dialplan's AST_CONFIG() lookup treats a missing section/key as empty/ - denied the same way, so there's nothing to return for them here; the - UI fills in the defaults for any known extension (from - list_extensions()) not present in this dict.""" + """{ext: {"restrict", "allowed_numbers", "messaging"}} for every extension + with a non-default record. + + Each extension has ONE whitelist and a mode saying which direction(s) it + applies to: the original internal / restricted / full, plus restricted-in + (whitelist gates incoming, dials anywhere) and restricted-out (whitelist + gates outgoing, anyone may call in). That authored pair is + what this returns and what the UI edits; the dialplan reads the derived + tier_out/allowed_out/tier_in/allowed_in that write_permission keeps in + step with it. + + Older configs are read by deriving the mode from whatever they do have — + the per-direction tiers, or the pre-split single 'tier' — so the UI is + correct even on a box where services/pstn-trunk.sh hasn't been re-run. + + Extensions with no section are implicitly no-PSTN/messaging-disabled: the + dialplan's AST_CONFIG() lookup treats a missing section as denied the same + way, so there's nothing to return for them; the UI fills in defaults for + any known extension not present here.""" cp = _read_permissions_cp() result = {} for section in cp.sections(): if not EXTEN_RE.match(section): continue + legacy_tier = cp.get(section, "tier", fallback="internal") + legacy_nums = cp.get(section, "allowed_numbers", fallback="") + restrict = cp.get(section, "restrict", fallback="") + if not RESTRICT_RE.match(restrict): + restrict = _derive_restrict( + cp.get(section, "tier_out", fallback=legacy_tier), + cp.get(section, "tier_in", fallback=legacy_tier), + ) + numbers = legacy_nums + if not numbers: + numbers = (cp.get(section, "allowed_out", fallback="") + or cp.get(section, "allowed_in", fallback="")) result[section] = { - "tier": cp.get(section, "tier", fallback="internal"), - "allowed_numbers": cp.get(section, "allowed_numbers", fallback=""), + "restrict": restrict, + "allowed_numbers": numbers, "messaging": cp.getboolean(section, "messaging", fallback=False), } return result -def write_permission(ext, tier, numbers_raw, messaging_enabled=False): - """Saves one extension's tier + (for restricted) approved-number list + - messaging flag in one action — messaging is an independent axis from - the calling tier (see pstn-trunk.sh's file-level comment: an extension - can be internal-tier for calling and still messaging-enabled, or vice - versa), so it's set/cleared regardless of which tier branch runs below. - Numbers are normalized to a pipe-separated list of 11-digit US numbers - (a bare 10-digit entry gets a leading "1" added, not dropped — see - _normalize_nanp_number) — pipe, not comma, because the dialplan uses - this value directly as a REGEX() alternation pattern (see - services/pstn-trunk.sh's file-level comment on why the untrusted call - data is always the string being tested, never interpolated into the - pattern side).""" +# Which per-direction tiers each authored mode compiles down to. The dialplan +# only ever reads the compiled keys; this table is the single place the +# mapping is defined. +# The first three are the original tiers, unchanged. restricted-in and +# restricted-out are the halves: the whitelist gates one direction while the +# other stays wide open. +_RESTRICT_TIERS = { + "internal": ("internal", "internal"), + "full": ("full", "full"), + "restricted": ("restricted", "restricted"), + "restricted-in": ("full", "restricted"), + "restricted-out": ("restricted", "full"), +} + + +def _set_or_clear(cp, ext, key, value): + if value: + cp.set(ext, key, value) + elif cp.has_option(ext, key): + cp.remove_option(ext, key) + + +def write_permission(ext, restrict, numbers_raw, messaging_enabled=False): + """Saves one extension's PSTN restriction mode, its single whitelist, and + its messaging flag in one action. + + One list, not two: the whitelist is "the numbers this extension deals + with", and the mode says whether that constrains dialling out, being + called, or both. Modes are internal / restricted / full (the original + tiers) plus restricted-in and restricted-out. + + Writes three layers, all derived from those two authored values: + restrict, allowed_numbers what a human edits (and what this reads back) + tier_out/allowed_out, + tier_in/allowed_in what the dialplan reads + tier, allowed_numbers rollback mirror for a pre-split installer + + Messaging is an independent axis (see pstn-trunk.sh's file-level comment: + an extension can have no PSTN at all and still be messaging-enabled, or + vice versa), so it's set/cleared regardless of the mode. + + Numbers normalize to a pipe-separated list of 11-digit US numbers (a bare + 10-digit entry gains a leading "1" rather than being dropped — see + _normalize_nanp_number). Pipe, not comma, because the dialplan uses the + value directly as a REGEX() alternation — see services/pstn-trunk.sh on + why untrusted call data is always the string being tested, never + interpolated into the pattern side.""" if not ASTERISK_CONFIG_DIR: return False, "No Asterisk install detected on this box" ext = str(ext).strip() if not EXTEN_RE.match(ext): return False, "Invalid extension" - if not TIER_RE.match(tier): - return False, "Invalid tier" + if not RESTRICT_RE.match(restrict or ""): + return False, "Invalid restriction mode" tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip()) - clean_numbers = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n] - numbers = "|".join(clean_numbers) + clean = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n] + numbers = "|".join(clean) + tier_out, tier_in = _RESTRICT_TIERS[restrict] cp = _read_permissions_cp() - if tier == "internal": - # Only drop the tier/allowed_numbers keys, NOT the whole section — - # an extension can independently have messaging=yes and/or a - # personal_did assigned, and those must survive a tier change back - # to internal. Confirmed live as a real bug: cp.remove_section(ext) - # here used to silently discard both whenever tier was set to - # internal. + if restrict == "internal": + # Clear the PSTN keys only, never the whole section — messaging and + # personal_did are independent and must survive. (Removing the + # section here was a real, confirmed bug in an earlier version.) if cp.has_section(ext): - if cp.has_option(ext, "tier"): - cp.remove_option(ext, "tier") - if cp.has_option(ext, "allowed_numbers"): - cp.remove_option(ext, "allowed_numbers") + for key in ("restrict", "allowed_numbers", "tier_out", "allowed_out", + "tier_in", "allowed_in", "tier"): + if cp.has_option(ext, key): + cp.remove_option(ext, key) else: if not cp.has_section(ext): cp.add_section(ext) - cp.set(ext, "tier", tier) - if tier == "restricted": - cp.set(ext, "allowed_numbers", numbers) - elif cp.has_option(ext, "allowed_numbers"): - cp.remove_option(ext, "allowed_numbers") + cp.set(ext, "restrict", restrict) + _set_or_clear(cp, ext, "allowed_numbers", numbers if restrict != "full" else "") + cp.set(ext, "tier_out", tier_out) + cp.set(ext, "tier_in", tier_in) + _set_or_clear(cp, ext, "allowed_out", numbers if tier_out == "restricted" else "") + _set_or_clear(cp, ext, "allowed_in", numbers if tier_in == "restricted" else "") + cp.set(ext, "tier", tier_out) if messaging_enabled: if not cp.has_section(ext): @@ -1223,9 +1341,9 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False): elif cp.has_section(ext) and cp.has_option(ext, "messaging"): cp.remove_option(ext, "messaging") - # Drop the section entirely once nothing (tier, numbers, messaging, - # personal_did) is left in it — only reached this way when tier is - # internal, messaging is off, and no personal_did was ever assigned. + # Drop the section entirely once nothing is left in it — only reachable + # when the mode is internal, messaging is off, and no personal_did was + # ever assigned. if cp.has_section(ext) and not cp.options(ext): cp.remove_section(ext) @@ -1233,16 +1351,17 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False): if not ok: return False, err - if tier == "restricted" and not clean_numbers: - return True, "Saved as restricted with an EMPTY approved list — no PSTN number can reach/be reached by it yet." + if restrict not in ("internal", "full") and not clean: + return True, ("Saved, but the whitelist is EMPTY — with this mode that means no PSTN " + "number is permitted in the restricted direction yet.") return True, "Saved" def write_messaging(ext, enabled): """Sets/clears just the messaging flag for one extension, leaving any tier/allowed_numbers/personal_did untouched. This is the write path for - the standalone "Internal SIP messaging" card, which works whether or - not a PSTN trunk has ever been installed — messaging has no dependency + the Extensions tab's Messaging column when there's no PSTN trunk to + save alongside — messaging works whether or not one has been installed — messaging has no dependency on one (no cost, no carrier, no DID), unlike the calling-permissions table this dashboard otherwise gates behind pstn_installed(). Creates pstn-permissions.conf from scratch if it doesn't exist yet.""" @@ -1373,9 +1492,9 @@ def pstn_installed(): """True only once services/pstn-trunk.sh has actually wired the dialplan in (pstn-trunk-dialplan.conf existing), not just because base Asterisk is present — pjsip.conf/extensions.conf exist either way, so extension names - alone can't tell us this. Without this check the tab would show a real - extension list and a default-but-unenforced 10/10 cap even when there is - no PSTN trunk at all.""" + alone can't tell us this. Without this check the Extensions tab would + show tier columns and a default-but-unenforced 10/10 cap even when there + is no PSTN trunk at all.""" if not ASTERISK_CONFIG_DIR: return False return os.path.isfile(os.path.join(ASTERISK_CONFIG_DIR, "pstn-trunk-dialplan.conf")) @@ -1583,7 +1702,7 @@ def remove_personal_did(did): return True, "Removed %s" % did -# ── Easy Asterisk Admin (native — devices, categories, rooms/ring-groups) ── +# ── Easy Asterisk device management (devices, categories, rooms/ring-groups) ── # Full reimplementation of vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's # vendored web admin (its own separate process, normally reached via its own # port/domain) as native code here instead — one tab, one process, no @@ -1651,7 +1770,7 @@ def ea_rebuild_dialplan(): def restart_asterisk_container(): """Restarts the Easy Asterisk container - the "Commit Changes" button on - the PSTN Trunk tab. Confirmed live: dashboard writes to + the Extensions tab. Confirmed live: dashboard writes to pstn-permissions.conf/pstn-groups.conf/pstn-personal-dids.conf land on disk immediately (readable via a plain `cat` right after saving), but AST_CONFIG() in the dialplan sometimes kept returning a stale value @@ -1662,7 +1781,7 @@ def restart_asterisk_container(): throughout), but the restart reliably clears it, so this button exists instead of requiring every admin to rediscover "just restart it" the hard way. Uses the same ASTERISK_EA_CONTAINER/run_sudo mechanism as the - Easy Asterisk Admin tab's own docker exec calls - no new sudoers scope + Extensions tab's own docker exec calls - no new sudoers scope needed beyond the one line added for this.""" if not ASTERISK_EA_CONTAINER: return False, "No Asterisk container detected on this box" @@ -2233,260 +2352,520 @@ INDEX_HTML = """ Security Dashboard - +

Security Dashboard

-

Recent Asterisk SIP security events, newest first. Errors/warnings are real auth failures; informational lines are normal registration traffic.

- - - - - - -
TimeEventAccountRemoteSeverity
+
+

Recent Asterisk SIP security events, newest first. Errors/warnings are real auth failures; informational lines are normal registration traffic.

+
+ + + + + + +
TimeEventAccountRemoteSeverity
+
+
+
@@ -3424,6 +3919,11 @@ class Handler(BaseHTTPRequestHandler): self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) + # The page is inlined into this file, so a dashboard update changes + # the HTML at a URL that never changes. Without this a browser can + # keep serving the previous UI after an upgrade, which looks exactly + # like the upgrade having silently failed. + self.send_header("Cache-Control", "no-store, must-revalidate") self.end_headers() self.wfile.write(body) @@ -3445,9 +3945,11 @@ class Handler(BaseHTTPRequestHandler): perms = get_all_permissions() extensions = [] for e in list_extensions(): - p = perms.get(e["ext"], {"tier": "internal", "allowed_numbers": "", "messaging": False}) - extensions.append({"ext": e["ext"], "name": e["name"], "tier": p["tier"], - "allowed_numbers": p["allowed_numbers"], "messaging": p["messaging"]}) + p = perms.get(e["ext"], {"restrict": "internal", "allowed_numbers": "", "messaging": False}) + extensions.append({"ext": e["ext"], "name": e["name"], + "restrict": p["restrict"], + "allowed_numbers": p["allowed_numbers"], + "messaging": p["messaging"]}) self._json({"extensions": extensions}) elif self.path == "/api/pstn-limits": self._json(get_limits()) @@ -3490,8 +3992,9 @@ class Handler(BaseHTTPRequestHandler): self._json(ban_asn(payload.get("asn", ""))) elif self.path == "/api/pstn-permissions": ok, message = write_permission( - payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", ""), - bool(payload.get("messaging", False)) + payload.get("ext", ""), + payload.get("restrict", ""), payload.get("allowed_numbers", ""), + bool(payload.get("messaging", False)), ) self._json({"ok": ok, "message": message}) elif self.path == "/api/pstn-limits": diff --git a/services/sms-inbound.sh b/services/sms-inbound.sh new file mode 100644 index 0000000..b0e45e2 --- /dev/null +++ b/services/sms-inbound.sh @@ -0,0 +1,624 @@ +#!/bin/bash +# services/sms-inbound.sh — Inbound SMS from a VoIP DID → ntfy push notification. +# Part of the modular post-install system (sourced by setup.sh). +# +# Built for one specific job: getting SMS **verification codes** sent to a +# VoIP number onto a phone that has no SIM. It deliberately does not try to be +# a texting app. Sending is not handled here at all (see the README this +# writes for why), and inbound messages arrive as push notifications rather +# than being routed into Asterisk as SIP MESSAGE — a code you need to read and +# type is better served by a notification than by a chat thread buried in a +# softphone. +# +# Two modes, both configured entirely from the DID provider's own "forward +# incoming SMS to a URL" setting: +# +# direct — the provider calls ntfy itself. No server component at all; the +# installer just prints the URL to paste into the provider portal. +# relay — a small systemd HTTP service on this box receives the provider's +# request and re-publishes to ntfy properly. Costs one more moving +# part, and buys correct handling of messages containing "&", a +# secret that isn't your ntfy token, and no ntfy credentials stored +# in a third party's web portal. +# +# No standalone bootstrap block here, matching services/pstn-trunk.sh — this +# is an add-on for a box the repo already set up, not something you'd curl +# onto a bare machine on its own. + +register_service sms-inbound homelab "Inbound SMS (verification codes) from a VoIP DID → ntfy push" 8093 + +SMS_APP_DIR="/opt/sms-inbound" +SMS_SETTINGS="$SMS_APP_DIR/settings.env" +SMS_SVC_USER="smsrelay" + +# ── ntfy target discovery ────────────────────────────────────────────────── +# A locally-installed ntfy (services/ntfy.sh) is the right default: it keeps +# verification codes on hardware you control instead of a public relay. Its +# base-url is the one authoritative place to read the reachable hostname from, +# since that's what ntfy itself uses to build notification links. +_sms_detect_ntfy_base() { + local _cfg="$DOCKER_DIR/ntfy/config/server.yml" + [[ -f "$_cfg" ]] || return 1 + local _url + _url="$(grep -oP '(?<=base-url: ")[^"]+' "$_cfg" 2>/dev/null || true)" + [[ -n "$_url" && "$_url" != "https://ntfy.example.com" ]] || return 1 + echo "$_url" +} + +# ── The relay ────────────────────────────────────────────────────────────── +# Stdlib only, same reasoning as services/security-dashboard.sh: this shares a +# small droplet with Asterisk, Caddy and CrowdSec and shouldn't cost a +# framework's worth of RAM to forward a few dozen text messages a month. +_sms_write_relay_app() { + local _dir="$1" + mkdir -p "$_dir" + cat > "$_dir/relay.py" << 'PYRELAY' +#!/usr/bin/env python3 +"""Inbound SMS webhook -> ntfy push. + +Receives the HTTP request a DID provider makes when an SMS arrives (Anveo +issues a plain GET with the message interpolated into the query string) and +re-publishes it to ntfy as a POST, which is the part the provider can't do +itself. + +Deliberately minimal: one path, one secret, no state, no database. +""" +import hmac +import os +import time +import urllib.parse +import urllib.request +from collections import deque +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +PORT = int(os.environ.get("SMS_RELAY_PORT", "8093")) +TOKEN = os.environ.get("SMS_RELAY_TOKEN", "") +NTFY_URL = os.environ.get("SMS_NTFY_URL", "") +NTFY_TOKEN = os.environ.get("SMS_NTFY_TOKEN", "") +NTFY_PRIORITY = os.environ.get("SMS_NTFY_PRIORITY", "high") + +# The token is the only thing standing between the public internet and your +# push topic, so cap how fast anyone can hammer it. Well above any real SMS +# volume; low enough that a leaked URL can't be used to spam the phone. +RATE_LIMIT = 60 # requests +RATE_WINDOW = 60 # seconds +_hits = deque() + + +def rate_limited(): + now = time.monotonic() + while _hits and now - _hits[0] > RATE_WINDOW: + _hits.popleft() + if len(_hits) >= RATE_LIMIT: + return True + _hits.append(now) + return False + + +def extract_message(query): + """Pull the message body out of the raw query string. + + Not parse_qs: providers interpolate the message text into the URL without + escaping it, so a body containing "&" (very common in marketing footers — + "Reply STOP & we'll remove you") splits into extra parameters and the + message silently truncates at the ampersand. Taking everything after the + LAST "message=" verbatim sidesteps that entirely, which is why the URL + this installer prints always puts the message parameter last. + """ + for key in ("message=", "text=", "body="): + idx = query.rfind(key) + if idx != -1: + return urllib.parse.unquote_plus(query[idx + len(key):]) + return "" + + +def extract_param(query, name): + """Ordinary parse for the numeric fields, which never contain '&'.""" + # Stop at the message so its contents can't be mistaken for parameters. + head = query + for key in ("message=", "text=", "body="): + idx = head.rfind(key) + if idx != -1: + head = head[:idx] + values = urllib.parse.parse_qs(head).get(name, []) + return values[0] if values else "" + + +def publish(sender, recipient, message): + title = "SMS from {}".format(sender or "unknown") + if recipient: + title += " to {}".format(recipient) + req = urllib.request.Request( + NTFY_URL, + data=message.encode("utf-8"), + method="POST", + headers={ + "Title": title, + "Priority": NTFY_PRIORITY, + "Tags": "incoming_envelope", + "Content-Type": "text/plain; charset=utf-8", + }, + ) + if NTFY_TOKEN: + req.add_header("Authorization", "Bearer " + NTFY_TOKEN) + with urllib.request.urlopen(req, timeout=10) as resp: + return 200 <= resp.status < 300 + + +class Handler(BaseHTTPRequestHandler): + def _respond(self, status): + self.send_response(status) + self.send_header("Content-Length", "0") + self.end_headers() + + def _handle(self): + path, _, query = self.path.partition("?") + # Constant-time compare: the token is a secret, and a naive == + # leaks its prefix to anyone willing to time enough requests. + if not TOKEN or not hmac.compare_digest(path.rstrip("/"), "/sms/" + TOKEN): + self._respond(404) + return + if rate_limited(): + self._respond(429) + return + + message = extract_message(query) + sender = extract_param(query, "from") + recipient = extract_param(query, "to") + if not message: + self._respond(400) + return + + try: + ok = publish(sender, recipient, message) + except Exception as exc: # noqa: BLE001 + print("publish failed: {}".format(exc), flush=True) + self._respond(502) + return + + # Never log the body: these are one-time passcodes, and the journal is + # readable by more people than the push notification is. + print("sms from={} to={} chars={} published={}".format( + sender or "?", recipient or "?", len(message), ok), flush=True) + self._respond(204 if ok else 502) + + def do_GET(self): + self._handle() + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0) or 0) + if length: + self.rfile.read(length) + self._handle() + + def log_message(self, fmt, *args): + pass # the journal already has what we print above + + +def main(): + if not NTFY_URL or not TOKEN: + raise SystemExit("SMS_NTFY_URL and SMS_RELAY_TOKEN must both be set") + ThreadingHTTPServer.allow_reuse_address = True + # 0.0.0.0, not loopback: Caddy runs in a container and reaches this over + # the Docker bridge gateway, which a loopback-only bind refuses. Access is + # scoped by UFW and by the token in the path, not by the bind address — + # same pattern services/security-dashboard.sh uses. + with ThreadingHTTPServer(("0.0.0.0", PORT), Handler) as httpd: + print("sms-inbound relay listening on 0.0.0.0:{}".format(PORT), flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() +PYRELAY + chmod 755 "$_dir/relay.py" +} + +_sms_write_systemd_unit() { + local _port="$1" _token="$2" _ntfy_url="$3" _ntfy_token="$4" + cat > /etc/systemd/system/sms-inbound.service << SMSSVC +[Unit] +Description=Inbound SMS webhook to ntfy relay +After=network.target + +[Service] +Type=simple +User=$SMS_SVC_USER +Group=$SMS_SVC_USER +Environment=SMS_RELAY_PORT=$_port +Environment=SMS_RELAY_TOKEN=$_token +Environment=SMS_NTFY_URL=$_ntfy_url +Environment=SMS_NTFY_TOKEN=$_ntfy_token +ExecStart=/usr/bin/python3 $SMS_APP_DIR/relay.py +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +SMSSVC + systemctl daemon-reload +} + +# Own site block rather than configure_caddy_for_service, for two reasons the +# helper can't accommodate: this endpoint must NOT sit behind Authelia (the +# SMS provider can't log in), and the installer has to know the exact final +# URL to print for the provider portal, which the helper doesn't hand back. +# Everything else — HSTS/nosniff headers, JSON access log, reload-then-restart +# fallback — matches what the helper would have written. +_sms_configure_caddy() { + local _domain="$1" _port="$2" + local _caddyfile="$DOCKER_DIR/caddy/Caddyfile" + + local _site_block + _site_block="$(cat << CBLOCK + +# Inbound SMS webhook (sms-inbound) — deliberately NOT behind Authelia: +# the SMS provider calls this unauthenticated. The secret is the token in +# the request path, checked by the relay itself. +${_domain} { + reverse_proxy host.docker.internal:${_port} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +} +CBLOCK +)" + + if [[ -f "$_caddyfile" ]]; then + cp "$_caddyfile" "$_caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + else + touch "$_caddyfile" + fi + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "${_domain} already in the Caddyfile — leaving the existing entry alone." + return 0 + fi + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added ${_domain} to the Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + : + elif docker restart caddy &>/dev/null; then + log_success "Caddy restarted to apply changes (the reload API is disabled by default)" + else + log_warning "Caddy reload/restart failed — check: docker logs caddy" + fi +} + +_sms_write_readme() { + local _mode="$1" _url="$2" _ntfy_url="$3" _relay_domain="$4" + write_readme "$SMS_APP_DIR" << MD +# Inbound SMS → ntfy + +Gets SMS sent to a VoIP DID onto a phone as a push notification. Built for +**verification codes**, not for conversations. + +Mode: **${_mode}** + +## The URL to paste into your DID provider + +In the provider portal, open the DID's SMS settings and paste this into the +"forward to URL" field (on Anveo: Phone Numbers → the DID → SMS tab, tick the +checkbox, paste, press SAVE — RETURN discards): + +\`\`\` +${_url} +\`\`\` + +Keep the message placeholder **last** in that URL. Providers interpolate the +message text without escaping it, so a body containing \`&\` splits into extra +query parameters; with the message last, everything after it can be read back +verbatim. + +Treat this URL like a password — anyone holding it can push notifications to +your phone. + +## What this does not do + +- **Sending.** There's no outbound path here. On Anveo, outbound SMS needs an + Anveo *Retail* account rather than Anveo Direct; a free texting app covers + the sending side without involving this box at all. +- **MMS.** No VoIP provider delivers MMS over SIP, and MMS to a VoIP DID + generally either drops or arrives as a media link through a separate API. + US **group texts are MMS**, so expect to miss those entirely. +- **Native Messages integration.** Android's Messages app reads the telephony + SMS provider, which only the cellular radio (or the default SMS app) writes + to; iOS lets nothing write to Messages. Codes arrive as ntfy notifications, + which for a passcode you're about to type is the more useful place anyway. + +## Will verification codes actually arrive? + +Two separate hurdles, both outside this box: + +1. **Short codes.** Most codes come from short codes (262966, 32665...). + Anveo supports short-code SMS to its DIDs, which is unusual — VoIP.ms, for + example, does not except for Google. Check that short codes are enabled on + your specific DID; not every number in the pool has it. +2. **VoIP rejection at signup.** Many services refuse a number their lookup + flags as VoIP, before any SMS is sent. Anveo also sells **mobile** DIDs, + sourced from wireless carriers, which are classified as mobile in the + industry databases those checks use — a much better bet for this purpose + than a geographic landline-class DID, at a higher monthly price. If codes + are the whole reason for the number, order a mobile one. + +## Security + +Verification codes are bearer credentials for your accounts. Two things +matter: + +- **The ntfy topic is a secret.** This installer generated a long random topic + name, which makes it unguessable, but the repo's ntfy defaults to + \`auth-default-access: read-write\` — anyone who *learns* the name can read + it. Adding an ntfy access token and restricting the topic is worthwhile: + \`\`\`bash + docker exec -it ntfy ntfy access # show current rules + docker exec -it ntfy ntfy user add --role=user reader + docker exec -it ntfy ntfy access reader '' read-only + docker exec -it ntfy ntfy access '*' '' deny + \`\`\` +- **Don't publish to a public relay.** \`ntfy.sh\` topics are readable by + anyone who knows the name; a self-hosted instance keeps codes on your own + hardware. + +Current ntfy target: \`${_ntfy_url}\` + +## Manage + +\`\`\`bash +systemctl status sms-inbound # relay mode only +journalctl -u sms-inbound -f # from/to and length, never the message body +sudo ./setup.sh sms-inbound # re-run to change settings +\`\`\` + +The relay logs who sent what and how long it was, deliberately never the +message itself — the journal has a wider audience than the notification does. + +$( [[ -n "$_relay_domain" ]] && printf 'Public endpoint: `https://%s` (Caddy → the relay on this box).\n' "$_relay_domain" ) + +## Testing it + +Substitute a real message for the provider's placeholder and call the URL +yourself — no need to wait for a text: + +\`\`\`bash +curl -s -o /dev/null -w '%{http_code}\\n' \\ + "$(printf '%s' "$_url" | sed 's/\$\[from\]\$/15555550123/; s/\$\[to\]\$/15555550199/; s/\$\[message\]\$/test+code+123456/')" +\`\`\` + +$( if [[ "$_mode" == "relay" ]]; then cat << 'RELAYTEST' +**204** means the relay accepted it and ntfy took the message — your phone +should buzz. **404** means the token in the path is wrong, **502** means ntfy +rejected the publish (check the ntfy token and topic), **429** means the rate +limit tripped (60 requests/minute). +RELAYTEST +else cat << 'DIRECTTEST' +**200** means ntfy accepted the publish and your phone should buzz. **401** +or **403** means the `auth=` parameter is wrong or the topic is restricted; +**404** means the topic URL is malformed. +DIRECTTEST +fi ) +MD +} + +install_sms-inbound() { + log_info "Setting up inbound SMS → ntfy..." + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would detect a local ntfy install and reuse its base-url, or prompt for one" + echo "[DRY-RUN] Would generate a long random ntfy topic (verification codes must not land" + echo "[DRY-RUN] on a guessable topic — the repo's ntfy defaults to read-write access)" + echo "[DRY-RUN] Would offer two modes:" + echo "[DRY-RUN] direct — print a provider 'forward SMS to URL' string pointing straight" + echo "[DRY-RUN] at ntfy; no server component installed" + echo "[DRY-RUN] relay — install $SMS_APP_DIR/relay.py as a systemd service, front it with" + echo "[DRY-RUN] Caddy on a domain you'll be prompted for (no Authelia — the SMS" + echo "[DRY-RUN] provider can't log in; a random token in the path is the secret)," + echo "[DRY-RUN] and open the port to caddy_net only" + echo "[DRY-RUN] Would print the exact URL to paste into the DID provider's SMS settings" + echo "[DRY-RUN] Would write $SMS_APP_DIR/README.md covering short codes, mobile DIDs, MMS" + echo "[DRY-RUN] and why the native Messages app never sees these" + return 0 + fi + + # ── Existing install? ───────────────────────────────────────────────────── + if [[ -f "$SMS_SETTINGS" ]]; then + echo "" + log_info "Existing sms-inbound configuration found at $SMS_SETTINGS." + local MODE="" + prompt_reinstall_mode MODE + case "$MODE" in + update) + # shellcheck disable=SC1090 + source "$SMS_SETTINGS" + if [[ "${SMS_MODE:-}" == "relay" ]]; then + _sms_write_relay_app "$SMS_APP_DIR" + chown -R "$SMS_SVC_USER:$SMS_SVC_USER" "$SMS_APP_DIR" 2>/dev/null || true + _sms_write_systemd_unit "${SMS_RELAY_PORT}" "${SMS_RELAY_TOKEN}" "${SMS_NTFY_URL}" "${SMS_NTFY_TOKEN:-}" + systemctl restart sms-inbound \ + && log_success "Relay refreshed and restarted." \ + || log_warning "Restart failed — check: journalctl -u sms-inbound -n 50" + else + log_info "Direct mode — nothing to refresh on this box." + fi + echo "" + log_success "Settings, Caddy and firewall rules were left untouched." + echo " Provider URL: ${SMS_FORWARD_URL}" + echo "" + return 0 + ;; + cancel) + log_info "Leaving the existing setup as-is — nothing changed." + return 0 + ;; + fresh) log_info "Reconfiguring from scratch — every prompt below runs again." ;; + esac + fi + + # ── ntfy target ─────────────────────────────────────────────────────────── + echo "" + local NTFY_BASE="" + if NTFY_BASE="$(_sms_detect_ntfy_base)"; then + log_success "Found a configured local ntfy at $NTFY_BASE — using it." + log_info "Self-hosted is the right answer here: these are verification codes." + else + log_warning "No configured local ntfy found (services/ntfy.sh installs one)." + log_warning "A public relay like ntfy.sh works, but its topics are readable by anyone" + log_warning "who learns the name — a poor place for one-time passcodes." + prompt_text "ntfy base URL [https://ntfy.sh]:" "https://ntfy.sh" NTFY_BASE + fi + NTFY_BASE="${NTFY_BASE%/}" + + # A long random topic, not "sms": with ntfy's default read-write access the + # topic name IS the read credential, so it needs real entropy rather than + # something guessable. + local NTFY_TOPIC="" + NTFY_TOPIC="sms-$(generate_password 24)" + log_info "Generated ntfy topic: $NTFY_TOPIC" + log_info "Subscribe to it in the ntfy app — that's where codes will appear." + + local NTFY_TOKEN_VAL="" + prompt_text "ntfy access token, if your instance requires one for publishing [blank=none]:" "" NTFY_TOKEN_VAL + + local NTFY_TOPIC_URL="${NTFY_BASE}/${NTFY_TOPIC}" + + # ── Mode ────────────────────────────────────────────────────────────────── + echo "" + echo " How should the provider reach ntfy?" + echo " 1) Relay (recommended) — a small service here receives the provider's" + echo " request and republishes properly. Handles '&' in" + echo " message bodies, and your ntfy token never gets" + echo " stored in the provider's web portal." + echo " 2) Direct — the provider calls ntfy itself. Nothing installed" + echo " on this box, but the URL you paste into the portal" + echo " carries your ntfy credentials, and a message" + echo " containing '&' truncates." + local MODE_CHOICE="" + prompt_text "Choose [1]:" "1" MODE_CHOICE + + mkdir -p "$SMS_APP_DIR" + local SMS_MODE="relay" FORWARD_URL="" RELAY_DOMAIN="" RELAY_PORT="" RELAY_TOKEN="" + + if [[ "$MODE_CHOICE" == "2" ]]; then + SMS_MODE="direct" + # ntfy accepts publishing over GET at /{topic}/(publish|send|trigger), + # reading message/title from the query string — which is exactly the + # shape a provider's "forward to URL" feature can produce. Auth, when + # needed, rides in ?auth= as base64url (no padding) of the literal + # Authorization header value. + local _auth_q="" + if [[ -n "$NTFY_TOKEN_VAL" ]]; then + _auth_q="&auth=$(printf 'Bearer %s' "$NTFY_TOKEN_VAL" | basenc --base64url 2>/dev/null | tr -d '=' \ + || printf 'Bearer %s' "$NTFY_TOKEN_VAL" | base64 | tr '+/' '-_' | tr -d '=\n')" + fi + # Message placeholder LAST, so a body containing '&' loses only the + # tail rather than corrupting the title or the auth parameter. + FORWARD_URL="${NTFY_BASE}/${NTFY_TOPIC}/trigger?title=SMS+from+\$[from]\$&priority=high${_auth_q}&message=\$[message]\$" + else + # ── Relay ───────────────────────────────────────────────────────────── + id -u "$SMS_SVC_USER" &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin "$SMS_SVC_USER" + + RELAY_PORT=8093 + local _limit=$((RELAY_PORT + 100)) + while ss -tlnH "sport = :${RELAY_PORT}" 2>/dev/null | grep -q . && [[ "$RELAY_PORT" -lt "$_limit" ]]; do + RELAY_PORT=$((RELAY_PORT + 1)) + done + [[ "$RELAY_PORT" != 8093 ]] && log_info "Port 8093 was taken — the relay will use ${RELAY_PORT}." + + RELAY_TOKEN="$(generate_password 32)" + + _sms_write_relay_app "$SMS_APP_DIR" + chown -R "$SMS_SVC_USER:$SMS_SVC_USER" "$SMS_APP_DIR" + _sms_write_systemd_unit "$RELAY_PORT" "$RELAY_TOKEN" "$NTFY_TOPIC_URL" "$NTFY_TOKEN_VAL" + systemctl enable --now sms-inbound >/dev/null 2>&1 \ + && log_success "Relay service started on port ${RELAY_PORT}." \ + || log_warning "Relay failed to start — check: journalctl -u sms-inbound -n 50" + + # The provider calls this from the public internet, so it needs a real + # certificate — providers generally refuse self-signed targets. + echo "" + local _default_domain="" + [[ -n "${SITE_DOMAIN:-}" && "$SITE_DOMAIN" != "example.com" ]] && _default_domain="sms.${SITE_DOMAIN}" + prompt_text "Public domain for the webhook (A record must point here) [${_default_domain:-required}]:" "$_default_domain" RELAY_DOMAIN + + if [[ -z "$RELAY_DOMAIN" ]]; then + log_warning "No domain entered — the relay is running but nothing can reach it yet." + log_warning "Re-run this service once DNS is ready, or front it with Caddy by hand." + elif [[ -d "$DOCKER_DIR/caddy" ]]; then + _sms_configure_caddy "$RELAY_DOMAIN" "$RELAY_PORT" + else + log_warning "Caddy isn't installed here — proxy https://${RELAY_DOMAIN} to" + log_warning "127.0.0.1:${RELAY_PORT} yourself, with a real certificate." + fi + + if command -v ufw &>/dev/null; then + if [[ -d "$DOCKER_DIR/caddy" ]]; then + # Caddy reaches this over the caddy_net bridge, so the port has + # no business being open to the internet — but a bare `ufw + # delete allow` would block Caddy too (see CLAUDE.md). + ufw delete allow "${RELAY_PORT}/tcp" 2>/dev/null || true + ufw_allow_from_caddy_net "${RELAY_PORT}" + else + ufw allow "${RELAY_PORT}/tcp" + fi + ensure_ufw_enabled + fi + + FORWARD_URL="https://${RELAY_DOMAIN:-}/sms/${RELAY_TOKEN}?from=\$[from]\$&to=\$[to]\$&message=\$[message]\$" + fi + + # ── Persist settings ────────────────────────────────────────────────────── + cat > "$SMS_SETTINGS" << ENV +# Written by services/sms-inbound.sh — re-run that to change any of this. +SMS_MODE="${SMS_MODE}" +SMS_NTFY_URL="${NTFY_TOPIC_URL}" +SMS_NTFY_TOKEN="${NTFY_TOKEN_VAL}" +SMS_RELAY_PORT="${RELAY_PORT}" +SMS_RELAY_TOKEN="${RELAY_TOKEN}" +SMS_RELAY_DOMAIN="${RELAY_DOMAIN}" +# The exact string to paste into the DID provider's "forward SMS to URL" box. +# Secret: anyone holding it can push notifications to your phone. +SMS_FORWARD_URL="${FORWARD_URL}" +ENV + chmod 600 "$SMS_SETTINGS" + + _sms_write_readme "$SMS_MODE" "$FORWARD_URL" "$NTFY_TOPIC_URL" "$RELAY_DOMAIN" + + # ── Summary ─────────────────────────────────────────────────────────────── + echo "" + log_success "Inbound SMS → ntfy configured (${SMS_MODE} mode)." + echo "" + echo " 1. Subscribe to this topic in the ntfy app:" + echo " ${NTFY_TOPIC_URL}" + echo "" + echo " 2. In your DID provider's portal, open the number's SMS settings and" + echo " paste this into the \"forward to URL\" field, exactly:" + echo "" + echo " ${FORWARD_URL}" + echo "" + echo " On Anveo: Phone Numbers → the DID → SMS tab; tick the checkbox," + echo " paste, then press SAVE (RETURN discards). Reopen the tab after" + echo " saving to confirm the whole URL came back — it is a long string." + echo "" + echo " 3. Text the number from another phone. The notification should" + echo " arrive within a few seconds." + echo "" + log_warning "That URL is a secret — anyone with it can push to your phone." + if [[ "$SMS_MODE" == "direct" ]]; then + log_warning "It also carries your ntfy credentials, because the provider talks to ntfy" + log_warning "directly in this mode. Relay mode avoids that if you'd rather it didn't." + fi + echo " Details, caveats and testing: $SMS_APP_DIR/README.md" + echo "" +} diff --git a/setup.sh b/setup.sh index eeeec5a..d1371ea 100755 --- a/setup.sh +++ b/setup.sh @@ -27,6 +27,10 @@ export TERM="${TERM:-xterm-256color}" CATEGORY_ORDER=(base homelab utilities media cameras gaming extras backup) # Service ordering hint within a category (lower = earlier). Default 50. declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 ) +# Retired service names that now resolve to another service. Keeps a name +# that used to work on the command line (and in docs/muscle memory) working +# after a merge, without giving it a second menu entry of its own. +declare -A SERVICE_ALIAS=( [asterisk-digital-ocean]=asterisk ) # ── Parse flags / collect service names ────────────────────────────────────── DRY_RUN=false; UNATTENDED=false; DO_LIST=false @@ -89,7 +93,11 @@ is_installed() { sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;; sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;; + # Either directory counts: boxes set up before the droplet edition was + # merged back into `asterisk` still run out of ~/docker/asterisk-digital-ocean. + asterisk) [ -e "$DOCKER_DIR/asterisk" ] || [ -e "$DOCKER_DIR/asterisk-digital-ocean" ] ;; pstn-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/pstn-trunk-pjsip.conf" ] || [ -f "$DOCKER_DIR/asterisk/config/asterisk/pstn-trunk-pjsip.conf" ] ;; + sms-inbound) [ -f /opt/sms-inbound/settings.env ] ;; ssh-config) false ;; # repeatable management tool, never shows [installed] *) [ -e "$DOCKER_DIR/$1" ] ;; esac @@ -97,6 +105,10 @@ is_installed() { run_service() { local name="$1" + if [ -n "${SERVICE_ALIAS[$name]:-}" ]; then + log_info "'$name' is now part of '${SERVICE_ALIAS[$name]}' — running that instead." + name="${SERVICE_ALIAS[$name]}" + fi if [ -z "${SERVICE_GROUP[$name]:-}" ]; then log_error "Unknown service: $name (try --list)"; return 1; fi declare -F "install_${name}" >/dev/null || { log_error "Service '$name' has no install_${name}"; return 1; } log_info "=== ${name} (${SERVICE_DESC[$name]}) ==="