Add sms-inbound: verification codes from a VoIP DID to ntfy push

New service for one narrow job — getting SMS verification codes sent to a
VoIP number onto a phone with no SIM. Deliberately not a texting app: no
outbound path (Anveo Direct has none; that needs an Anveo Retail account, and
a free texting app covers sending), and messages arrive as push notifications
rather than being routed into Asterisk as SIP MESSAGE, since a code you read
and type is better served by a notification than a softphone chat thread.

Two modes, both driven entirely from the provider's "forward SMS to URL" box:

- direct — the provider calls ntfy itself; nothing installed here. ntfy
  accepts GET publishing at /{topic}/(publish|send|trigger) with message and
  title as query params, and auth via ?auth= holding base64url (unpadded) of
  the literal "Bearer <token>" — confirmed against ntfy's server.go and
  server_auth.go rather than its docs.
- relay — a stdlib systemd service, Caddy-fronted on its own domain with no
  Authelia (the provider can't log in; a random 32-char token in the path is
  the secret). Buys two things direct mode can't have: an unescaped "&" in a
  message body survives intact, because the relay takes everything after the
  last message= verbatim instead of parse_qs — which is why the generated URL
  always puts the message placeholder last — and no ntfy credentials sit in a
  third party's web portal.

Verification codes are bearer credentials, so: a 24-char random topic name
(the repo's ntfy defaults to auth-default-access: read-write, making the topic
name the read credential), constant-time token compare, a 60/min rate limit,
and the relay logs sender/recipient/length but never the message body.

The Anveo guide gains a section covering the two things that actually decide
whether codes arrive: short-code support (Anveo has it, unusually — VoIP.ms
does not except for Google) and Anveo's carrier-sourced *mobile* DIDs, which
are classified as mobile in the lookups that reject VoIP numbers at signup.
Also documents MMS and group texts being out of reach, and why the native
Messages app never sees any of this.

Verified against a stub ntfy: plain OTP, encoded "&", unencoded "&", "+" as
space, wrong token (404), missing message (400) and the rate limit (57x204
then 429) all behave; both installer modes were run end to end in a sandbox
and their generated URLs, settings files and READMEs checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
This commit is contained in:
Claude
2026-07-25 02:13:39 +00:00
parent 51d090ee2c
commit e1c3203d88
5 changed files with 719 additions and 1 deletions
+2 -1
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`, `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` |
@@ -92,6 +92,7 @@ homelab
homeassistant
asterisk
pstn-trunk
sms-inbound
security-dashboard
sunshine
+87
View File
@@ -199,6 +199,93 @@ In the Security Dashboard's PSTN Trunk tab:
# 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**, set the
destination to **URL**, and paste the string the installer printed. Keep the
`$[message]$` placeholder **last** — that's what makes the unescaped-`&` case
recoverable.
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.
## Bugs hit and fixed along the way (informational — already fixed)
These were all real, confirmed-live bugs in earlier versions of this
+8
View File
@@ -2245,6 +2245,14 @@ extension in \`pstn-permissions.conf\` (the outbound Caller-ID override) —
both kept in sync automatically by the CLI installer and the Security
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 Extensions tab
+621
View File
@@ -0,0 +1,621 @@
#!/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, choose "forward to URL"
and paste this:
\`\`\`
${_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,"
echo " choose \"forward to URL\", and paste exactly:"
echo ""
echo " ${FORWARD_URL}"
echo ""
echo " Keep the message placeholder last — see the README for why."
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 ""
}
+1
View File
@@ -97,6 +97,7 @@ is_installed() {
# 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