The SMS tab has one control — a "Forward to URL" checkbox and field, with SAVE/RETURN buttons where RETURN discards. The instructions described a destination dropdown that isn't there. Also flags that the generated URLs are long (~90 chars relay, ~150+ direct) and worth re-opening the tab to confirm they saved whole. Adds a provider-risk section: the realistic way to lose the number is account action or a lapsed balance rather than the company folding, so keep the balance small, save a recent invoice offline (porting out needs a signed LOA plus the latest bill, which you can't download once an account is closed), and note that Anveo states it does not block port-outs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
625 lines
27 KiB
Bash
625 lines
27 KiB
Bash
#!/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 ""
|
|
}
|