Merge pull request #233 from outis1one/claude/asterisk-digital-ocean-consolidate-9dsrse

Claude/asterisk digital ocean consolidate 9dsrse
This commit is contained in:
Outis
2026-07-25 13:45:47 -04:00
committed by GitHub
14 changed files with 3024 additions and 890 deletions
+51 -16
View File
@@ -18,7 +18,7 @@ checklist per group, and calls `install_<name>()` for each selected item.
1. Create `services/<name>.sh` (kebab-case filename)
2. Call `register_service` at the top of the file
3. Define `install_<name>()` — 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/<name>.sh` removes it from the menu, but `sudo ./setup.sh
<name>` 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_<name>()` directly as a
convenience step at the end of its own flow, instead of making the user
remember to separately run `sudo ./setup.sh <other-name>` 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_<name>()` 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
+2 -2
View File
@@ -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
+36
View File
@@ -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.
+128 -5
View File
@@ -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, 36 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
+10 -11
View File
@@ -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 => <ext>,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
+849 -202
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+26 -14
View File
@@ -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
+189 -46
View File
@@ -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
File diff suppressed because it is too large Load Diff
+624
View File
@@ -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 '<topic>' read-only
docker exec -it ntfy ntfy access '*' '<topic>' 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:-<your-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 ""
}
+12
View File
@@ -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]}) ==="