Merge pull request #233 from outis1one/claude/asterisk-digital-ocean-consolidate-9dsrse
Claude/asterisk digital ocean consolidate 9dsrse
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+1095
-592
File diff suppressed because it is too large
Load Diff
@@ -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 ""
|
||||
}
|
||||
@@ -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]}) ==="
|
||||
|
||||
Reference in New Issue
Block a user