Merge pull request #245 from outis1one/claude/asterisk-caller-id-number-y1b3ls

Rebuild sms-inbound to deliver texts into Asterisk via AMI, not ntfy
This commit is contained in:
Outis
2026-07-26 22:07:06 -04:00
committed by GitHub
+481 -295
View File
@@ -1,85 +1,201 @@
#!/bin/bash
# services/sms-inbound.sh — Inbound SMS from a VoIP DID → ntfy push notification.
# services/sms-inbound.sh — Inbound SMS from a VoIP DID, delivered into
# Asterisk as a SIP MESSAGE so it lands in Sipnetic (or any softphone with
# the internal texting feature) like a real text thread.
# 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.
# Was originally "SMS -> ntfy push", built for one-off verification codes.
# Confirmed live this session that the DID's own "SMS over SIP" delivery
# (Anveo's MESSAGE/INVITE-over-SIP option) simply isn't offered on this
# account/DID — the SMS tab only ever showed the HTTP "Forward to URL"
# option. So this reuses that exact same HTTP-webhook mechanism (proven
# working already) but changes what happens on receipt: instead of a push
# notification, the relay looks up which extension(s) own the destination
# DID (services/pstn-trunk.sh's personal-DID/Ring-Group ownership data —
# the SAME mapping inbound voice calls already use) and delivers the text
# to them via Asterisk's Manager Interface (AMI), the same way
# services/asterisk.sh's internal SIP MESSAGE texting already works
# end-to-end. ntfy is gone from this path entirely — deliberate, not an
# oversight, since the point now is a real two-way texting experience, not
# a one-way notification.
#
# 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.
# Sending (SMS out to a real number) is NOT handled here — that's a
# separate, still-pending piece (see docs/anveo-direct-setup-guide.md's SMS
# section) blocked on the provider activating API-based sending, unrelated
# to this file. This file is inbound-only, the same as it always was.
#
# 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.
# is an add-on for a box the repo already set up (specifically: needs
# services/asterisk.sh AND services/pstn-trunk.sh already installed, since
# it reads pstn-trunk's DID-ownership files and needs a live Asterisk to
# talk to over AMI), 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
register_service sms-inbound homelab "Inbound SMS from a VoIP DID, delivered into Asterisk (Sipnetic) via AMI" 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"
# ── Asterisk detection — same layout probe as services/pstn-trunk.sh and
# services/security-dashboard.sh, duplicated here rather than shared since
# there's no cross-service helper for it yet. Prefers the droplet layout if
# both happen to exist, for the same reason those two files do.
_sms_detect_ea_dir() {
if [[ -d "$DOCKER_DIR/asterisk-digital-ocean" ]]; then
echo "$DOCKER_DIR/asterisk-digital-ocean"
elif [[ -d "$DOCKER_DIR/asterisk" ]]; then
echo "$DOCKER_DIR/asterisk"
fi
}
_sms_detect_container_name() {
local _ea_dir="$1"
if [[ "$_ea_dir" == *asterisk-digital-ocean ]]; then
echo "easy-asterisk-do"
else
echo "easy-asterisk"
fi
}
# ── AMI: a scoped manager.conf user, MessageSend only ───────────────────────
# Localhost-only (bindaddr + permit below) since the relay runs on this same
# host, not over the network — no firewall port to open for this. "message"
# is the AMI permission class MessageSend needs; this hasn't been confirmed
# against a live MessageSend call yet (see the relay's own comment on
# ami_deliver) — if the very first real delivery attempt gets an AMI
# permission error in the journal, widen read/write here first before
# looking anywhere else.
#
# Idempotent: creates manager.conf fresh if absent, otherwise ensures
# enabled=yes and the [smsrelay] section exist without disturbing anything
# else already in the file (this box's manager.conf isn't vendor-managed/
# regenerated the way pjsip.conf and rooms.conf are, so there's no
# "overwrite unconditionally" contract to honor or fight here).
_sms_write_manager_conf() {
local _asterisk_dir="$1" _secret="$2"
local _mgr="$_asterisk_dir/manager.conf"
if [[ ! -f "$_mgr" ]]; then
cat > "$_mgr" << MGR
; Written by services/sms-inbound.sh — the [smsrelay] section below is
; managed there (re-run that installer to rotate the secret). Anything
; else you add here by hand is left alone on future runs.
[general]
enabled = yes
port = 5038
bindaddr = 127.0.0.1
displayconnects = no
[smsrelay]
secret = ${_secret}
read = message
write = message
deny = 0.0.0.0/0
permit = 127.0.0.1/255.255.255.255
MGR
echo "fresh"
return 0
fi
local _changed=false
if ! grep -q '^enabled[[:space:]]*=[[:space:]]*yes' "$_mgr"; then
if grep -q '^\[general\]' "$_mgr"; then
sed -i '/^\[general\]/a enabled = yes' "$_mgr"
else
printf '[general]\nenabled = yes\nport = 5038\nbindaddr = 127.0.0.1\n\n%s' "$(cat "$_mgr")" > "$_mgr.tmp" \
&& mv "$_mgr.tmp" "$_mgr"
fi
_changed=true
fi
if grep -q '^\[smsrelay\]' "$_mgr"; then
sed -i "/^\[smsrelay\]/,/^\[/{s/^secret[[:space:]]*=.*/secret = ${_secret}/}" "$_mgr"
else
cat >> "$_mgr" << MGR
[smsrelay]
secret = ${_secret}
read = message
write = message
deny = 0.0.0.0/0
permit = 127.0.0.1/255.255.255.255
MGR
_changed=true
fi
[[ "$_changed" == true ]] && echo "changed" || echo "unchanged"
}
# ── ACL grant: read-only access to pstn-trunk's DID-ownership files ────────
# Same technique services/security-dashboard.sh's _secdash_grant_asterisk_
# access uses, and the same reason: the Asterisk container's own entrypoint
# re-chowns /etc/asterisk to asterisk:asterisk on every restart, which plain
# chmod/group-membership grants don't survive but POSIX ACLs do (chown
# doesn't touch ACL entries). Read + traversal only — smsrelay never writes
# anything in this directory.
_sms_grant_asterisk_read_access() {
local _svc_user="$1" _asterisk_dir="$2"
command -v setfacl >/dev/null 2>&1 || run_cmd apt-get install -y acl >/dev/null 2>&1
if ! command -v setfacl >/dev/null 2>&1; then
log_warning "Package 'acl' unavailable — falling back to a one-time chmod, which can"
log_warning "silently break again the next time the Asterisk container restarts and"
log_warning "re-chowns its config directory. Install 'acl' and re-run to fix properly."
chmod 755 "$_asterisk_dir" 2>/dev/null || true
return 0
fi
local _dir
_dir="$(dirname "$_asterisk_dir")"
while [[ "$_dir" != "/" && -n "$_dir" ]]; do
sudo -u "$_svc_user" test -x "$_dir" 2>/dev/null && break
setfacl -m "u:${_svc_user}:x" "$_dir" 2>/dev/null || true
_dir="$(dirname "$_dir")"
done
setfacl -R -m "u:${_svc_user}:rX" "$_asterisk_dir" 2>/dev/null || true
setfacl -R -d -m "u:${_svc_user}:rX" "$_asterisk_dir" 2>/dev/null || true
}
# ── 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
# 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.
"""Inbound SMS webhook -> delivered into Asterisk as a SIP MESSAGE via AMI.
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.
issues a plain GET with the message interpolated into the query string),
looks up which extension(s) currently own the destination DID (same
pstn-personal-dids.conf / pstn-groups.conf data services/pstn-trunk.sh's
own inbound-voice ring logic reads), and asks Asterisk over its Manager
Interface to deliver the text to each of them.
Deliberately minimal: one path, one secret, no state, no database.
Deliberately minimal: one path, one secret, no state, no database, no
non-stdlib dependencies (same reasoning as services/security-dashboard.sh).
"""
import configparser
import hmac
import os
import re
import socket
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")
ASTERISK_CONFIG_DIR = os.environ.get("SMS_ASTERISK_CONFIG_DIR", "")
AMI_HOST = os.environ.get("SMS_AMI_HOST", "127.0.0.1")
AMI_PORT = int(os.environ.get("SMS_AMI_PORT", "5038"))
AMI_USER = os.environ.get("SMS_AMI_USER", "smsrelay")
AMI_SECRET = os.environ.get("SMS_AMI_SECRET", "")
SMS_DOMAIN = os.environ.get("SMS_DOMAIN", "")
# 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.
# The token is the only thing standing between the public internet and this
# box's Asterisk. Well above any real SMS volume; low enough that a leaked
# URL can't be used to spam extensions or hammer the AMI connection.
RATE_LIMIT = 60 # requests
RATE_WINDOW = 60 # seconds
_hits = deque()
@@ -124,25 +240,126 @@ def extract_param(query, 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
def _normalize_did(raw):
"""10 or 11(leading-1) digit US number -> bare 10-digit, matching
pstn-personal-dids.conf's section-name convention (see
services/security-dashboard.sh's _normalize_personal_did_input)."""
digits = re.sub(r"\D", "", raw or "")
if len(digits) == 11 and digits.startswith("1"):
digits = digits[1:]
return digits if len(digits) == 10 else None
def _read_conf(path):
cp = configparser.ConfigParser(delimiters=("=",))
if path and os.path.isfile(path):
try:
cp.read(path)
except configparser.Error:
pass
return cp
def resolve_recipients(to_raw):
"""Destination DID -> list of extensions that should receive this text.
Single-extension ownership -> that one extension. Ring-Group ownership
("@GroupName") -> every CURRENT member, read live from
pstn-groups.conf — same mirror services/security-dashboard.sh's
sync_room_group_mirror() keeps in step with actual Ring Group
membership, so this always reflects who's in the group right now, not
who was in it when the DID was assigned. Empty list = nobody currently
owns this DID; the caller logs that and gives up rather than guessing
where to deliver it."""
if not ASTERISK_CONFIG_DIR:
return []
did = _normalize_did(to_raw)
if not did:
return []
dids_cp = _read_conf(os.path.join(ASTERISK_CONFIG_DIR, "pstn-personal-dids.conf"))
if not dids_cp.has_section(did):
return []
owner = dids_cp.get(did, "owner", fallback="").strip()
if not owner:
return []
if owner.startswith("@"):
groups_cp = _read_conf(os.path.join(ASTERISK_CONFIG_DIR, "pstn-groups.conf"))
group_name = owner[1:]
members_raw = groups_cp.get(group_name, "members", fallback="")
return [m.strip() for m in members_raw.split(",") if m.strip()]
return [owner]
class AMIError(Exception):
pass
def _ami_read_response(sock_file):
"""One AMI message = lines up to a blank line. Returns {Key: Value}."""
resp = {}
while True:
line = sock_file.readline()
if not line:
raise AMIError("connection closed while reading AMI response")
line = line.decode("utf-8", errors="replace").rstrip("\r\n")
if line == "":
break
if ":" in line:
k, v = line.split(":", 1)
resp[k.strip()] = v.strip()
return resp
def ami_deliver(from_number, to_exts, body):
"""Logs into AMI once and sends one MessageSend action per recipient.
UNVERIFIED against a real Asterisk instance as of this being written —
"message" as the AMI permission class, and To/From/Body as MessageSend's
exact parameter names, are both believed correct but haven't been
confirmed live. Every AMI response is logged in full specifically so the
first real delivery attempt shows exactly what Asterisk said if
something here is wrong, rather than failing silently.
Returns (delivered_count, total_count)."""
if not AMI_SECRET:
print("ami_deliver: SMS_AMI_SECRET not set, cannot deliver", flush=True)
return 0, len(to_exts)
delivered = 0
with socket.create_connection((AMI_HOST, AMI_PORT), timeout=10) as sock:
sock_file = sock.makefile("rb")
banner = sock_file.readline() # AMI sends a version banner first
print("AMI banner: {}".format(banner.decode("utf-8", errors="replace").strip()), flush=True)
def send_action(fields):
payload = "".join("{}: {}\r\n".format(k, v) for k, v in fields) + "\r\n"
sock.sendall(payload.encode("utf-8"))
return _ami_read_response(sock_file)
login_resp = send_action([
("Action", "Login"),
("Username", AMI_USER),
("Secret", AMI_SECRET),
])
print("AMI login response: {}".format(login_resp), flush=True)
if login_resp.get("Response") != "Success":
raise AMIError("AMI login failed: {}".format(login_resp))
from_uri = "<sip:{}@{}>".format(from_number or "unknown", SMS_DOMAIN or "localhost")
for ext in to_exts:
resp = send_action([
("Action", "MessageSend"),
("To", "pjsip:{}".format(ext)),
("From", from_uri),
("Body", body),
])
print("AMI MessageSend to {}: {}".format(ext, resp), flush=True)
if resp.get("Response") == "Success":
delivered += 1
send_action([("Action", "Logoff")])
return delivered, len(to_exts)
class Handler(BaseHTTPRequestHandler):
@@ -169,18 +386,26 @@ class Handler(BaseHTTPRequestHandler):
self._respond(400)
return
exts = resolve_recipients(recipient)
if not exts:
# Never log the body: could be a verification code or anything
# else sensitive, and the journal is more widely readable than
# this delivery path is supposed to be.
print("sms from={} to={} chars={} -- no owner found for this DID, dropped".format(
sender or "?", recipient or "?", len(message)), flush=True)
self._respond(204) # acknowledge to the provider either way — an unowned DID isn't its problem
return
try:
ok = publish(sender, recipient, message)
delivered, total = ami_deliver(sender, exts, message)
except Exception as exc: # noqa: BLE001
print("publish failed: {}".format(exc), flush=True)
print("ami_deliver 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)
print("sms from={} to={} chars={} delivered={}/{} recipients={}".format(
sender or "?", recipient or "?", len(message), delivered, total, exts), flush=True)
self._respond(204 if delivered else 502)
def do_GET(self):
self._handle()
@@ -196,8 +421,8 @@ class Handler(BaseHTTPRequestHandler):
def main():
if not NTFY_URL or not TOKEN:
raise SystemExit("SMS_NTFY_URL and SMS_RELAY_TOKEN must both be set")
if not TOKEN or not AMI_SECRET:
raise SystemExit("SMS_RELAY_TOKEN and SMS_AMI_SECRET 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
@@ -215,10 +440,10 @@ PYRELAY
}
_sms_write_systemd_unit() {
local _port="$1" _token="$2" _ntfy_url="$3" _ntfy_token="$4"
local _port="$1" _token="$2" _asterisk_dir="$3" _ami_secret="$4" _domain="$5"
cat > /etc/systemd/system/sms-inbound.service << SMSSVC
[Unit]
Description=Inbound SMS webhook to ntfy relay
Description=Inbound SMS webhook, delivered into Asterisk via AMI
After=network.target
[Service]
@@ -227,8 +452,12 @@ 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
Environment=SMS_ASTERISK_CONFIG_DIR=$_asterisk_dir
Environment=SMS_AMI_HOST=127.0.0.1
Environment=SMS_AMI_PORT=5038
Environment=SMS_AMI_USER=smsrelay
Environment=SMS_AMI_SECRET=$_ami_secret
Environment=SMS_DOMAIN=$_domain
ExecStart=/usr/bin/python3 $SMS_APP_DIR/relay.py
Restart=on-failure
RestartSec=3
@@ -298,20 +527,19 @@ CBLOCK
}
_sms_write_readme() {
local _mode="$1" _url="$2" _ntfy_url="$3" _relay_domain="$4"
local _url="$1" _relay_domain="$2"
write_readme "$SMS_APP_DIR" << MD
# Inbound SMS → ntfy
# Inbound SMS → Sipnetic (via AMI)
Gets SMS sent to a VoIP DID onto a phone as a push notification. Built for
**verification codes**, not for conversations.
Mode: **${_mode}**
Gets SMS sent to one of your PSTN DIDs delivered into Asterisk as a SIP
MESSAGE, landing in Sipnetic the same way internal texting already does —
not a push notification, a real message in the softphone.
## 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):
"Forward to URL" field (on Anveo: Phone Numbers → the DID → SMS tab, tick
the checkbox, paste, press SAVE — RETURN discards):
\`\`\`
${_url}
@@ -322,116 +550,107 @@ 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.
Treat this URL like a password — anyone holding it can trigger a message
delivery into your Asterisk.
## How delivery is decided
The relay looks up who currently owns the destination DID, using the exact
same data the inbound-voice ring logic reads (\`pstn-personal-dids.conf\` /
\`pstn-groups.conf\`, managed from the Security Dashboard's Extensions tab —
see the Personal Numbers card and Ring Groups' own DID field there):
- **Owned by a single extension** — that extension gets the text.
- **Owned by a Ring Group** — every CURRENT member gets it, checked fresh on
every delivery (not baked in at assignment time), same as inbound voice
ring-group calls already work.
- **Not owned by anyone** — logged and dropped. Assign the DID to an
extension or Ring Group first.
## 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.
- **Sending.** There's no outbound path here — that's a separate piece,
still pending on Anveo activating API-based SMS sending for this account
(see \`docs/anveo-direct-setup-guide.md\`).
- **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}\`
- **The relay token is a secret.** This installer generated a long random
one; anyone holding the full URL can trigger a delivery into your
Asterisk (though only to whichever DID they claim as \`to=\`, and only
reaching whoever currently owns that DID).
- **AMI access is scoped and localhost-only.** The \`smsrelay\` manager.conf
user can only run \`MessageSend\`, and can only connect from 127.0.0.1 —
it has no path to originate calls, read call data, or touch configuration.
## 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
systemctl status sms-inbound
journalctl -u sms-inbound -f # from/to, recipient count, and full AMI
# responses — never the message body
sudo ./setup.sh sms-inbound # re-run to change settings or rotate secrets
\`\`\`
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:
yourself — no need to wait for a text (use a DID that's actually assigned to
an extension or Ring Group, or it'll just log "no owner found" and drop it):
\`\`\`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/')"
"$(printf '%s' "$_url" | sed 's/\$\[from\]\$/15555550123/; s/\$\[to\]\$/15555550199/; s/\$\[message\]\$/test+message/')"
\`\`\`
$( 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 )
**204** means it was delivered (or, if nothing owns that DID, acknowledged
and dropped — check \`journalctl -u sms-inbound\` to tell which). **404**
means the token in the path is wrong. **429** means the rate limit tripped
(60 requests/minute). **502** means AMI delivery itself failed — check the
journal for the full AMI response before assuming which end is wrong.
MD
}
install_sms-inbound() {
log_info "Setting up inbound SMS → ntfy..."
log_info "Setting up inbound SMS → Sipnetic (via Asterisk's Manager Interface)..."
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 detect the Asterisk install and its DID-ownership config"
echo "[DRY-RUN] (pstn-personal-dids.conf / pstn-groups.conf) — requires"
echo "[DRY-RUN] services/asterisk.sh and services/pstn-trunk.sh already installed"
echo "[DRY-RUN] Would write a scoped AMI user (MessageSend only, localhost-only) into"
echo "[DRY-RUN] manager.conf, and grant the relay's system user read-only ACL access"
echo "[DRY-RUN] to Asterisk's config directory"
echo "[DRY-RUN] Would 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] 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"
echo "[DRY-RUN] Would write $SMS_APP_DIR/README.md"
return 0
fi
local EA_DIR="" CONTAINER_NAME=""
EA_DIR="$(_sms_detect_ea_dir)"
if [[ -z "$EA_DIR" ]]; then
log_error "No Asterisk install detected (services/asterisk.sh) — this service delivers"
log_error "into a running Asterisk over its Manager Interface, so there's nothing to"
log_error "wire up without one. Run 'sudo ./setup.sh asterisk' first."
return 1
fi
CONTAINER_NAME="$(_sms_detect_container_name "$EA_DIR")"
local ASTERISK_DIR="$EA_DIR/config/asterisk"
if [[ ! -f "$ASTERISK_DIR/pstn-personal-dids.conf" ]]; then
log_warning "No pstn-personal-dids.conf found yet (services/pstn-trunk.sh) — this relay"
log_warning "will run, but every inbound text will be logged as 'no owner found' and"
log_warning "dropped until at least one DID is assigned to an extension or Ring Group"
log_warning "from the Security Dashboard's Extensions tab."
fi
# ── Existing install? ─────────────────────────────────────────────────────
if [[ -f "$SMS_SETTINGS" ]]; then
echo ""
@@ -442,16 +661,13 @@ install_sms-inbound() {
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
_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}" "$ASTERISK_DIR" "${SMS_AMI_SECRET}" "${SMS_DOMAIN:-}"
_sms_grant_asterisk_read_access "$SMS_SVC_USER" "$ASTERISK_DIR"
systemctl restart sms-inbound \
&& log_success "Relay refreshed and restarted." \
|| log_warning "Restart failed — check: journalctl -u sms-inbound -n 50"
echo ""
log_success "Settings, Caddy and firewall rules were left untouched."
echo " Provider URL: ${SMS_FORWARD_URL}"
@@ -466,144 +682,111 @@ install_sms-inbound() {
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%/}"
# ── AMI + relay system user ─────────────────────────────────────────────
id -u "$SMS_SVC_USER" &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin "$SMS_SVC_USER"
# 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 AMI_SECRET
AMI_SECRET="$(generate_password 32)"
local MGR_STATE
MGR_STATE="$(_sms_write_manager_conf "$ASTERISK_DIR" "$AMI_SECRET")"
ensure_docker_dir_ownership "$ASTERISK_DIR"
_sms_grant_asterisk_read_access "$SMS_SVC_USER" "$ASTERISK_DIR"
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.
if [[ "$MGR_STATE" != "unchanged" ]]; then
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"
log_warning "AMI was just enabled/changed on this box — Asterisk needs a restart to pick"
log_warning "that up (confirmed elsewhere this session: a plain reload isn't reliable for"
log_warning "config that's new to the running process). This drops any calls in progress"
log_warning "right now."
local _restart_ami=""
prompt_yn "Restart Asterisk now to enable AMI? (y/n):" "y" _restart_ami
if [[ "$_restart_ami" =~ ^[Yy]$ ]]; then
docker restart "$CONTAINER_NAME" &>/dev/null \
&& log_success "Restarted." \
|| log_warning "Restart failed — check: docker logs $CONTAINER_NAME"
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."
log_warning "Not restarted — the relay will run, but AMI logins will fail until"
log_warning "you restart: docker restart $CONTAINER_NAME"
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
# ── Relay service ─────────────────────────────────────────────────────────
mkdir -p "$SMS_APP_DIR"
local 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}."
local RELAY_TOKEN
RELAY_TOKEN="$(generate_password 32)"
local SMS_DOMAIN=""
[[ -f "$EA_DIR/.env" ]] && SMS_DOMAIN="$(grep -E '^DOMAIN_NAME=' "$EA_DIR/.env" | cut -d= -f2-)"
_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" "$ASTERISK_DIR" "$AMI_SECRET" "$SMS_DOMAIN"
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 RELAY_DOMAIN=""
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
local FORWARD_URL="https://${RELAY_DOMAIN:-<your-domain>}/sms/${RELAY_TOKEN}?from=\$[from]\$&to=\$[to]\$&message=\$[message]\$"
# ── 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}"
SMS_AMI_SECRET="${AMI_SECRET}"
SMS_DOMAIN="${SMS_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.
# Secret: anyone holding it can trigger a delivery into your Asterisk.
SMS_FORWARD_URL="${FORWARD_URL}"
ENV
chmod 600 "$SMS_SETTINGS"
_sms_write_readme "$SMS_MODE" "$FORWARD_URL" "$NTFY_TOPIC_URL" "$RELAY_DOMAIN"
_sms_write_readme "$FORWARD_URL" "$RELAY_DOMAIN"
# ── Summary ───────────────────────────────────────────────────────────────
echo ""
log_success "Inbound SMS → ntfy configured (${SMS_MODE} mode)."
log_success "Inbound SMS → Sipnetic configured."
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 " 1. 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 ""
@@ -611,14 +794,17 @@ ENV
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 " 2. Make sure that DID is assigned to an extension or Ring Group on the"
echo " Security Dashboard's Extensions tab — an unassigned DID just gets"
echo " logged and dropped."
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 " 3. Text the number from another phone, then check:"
echo " journalctl -u sms-inbound -f"
echo " for delivery status and the full AMI response — this is the first"
echo " real test of the AMI plumbing, so check here if it doesn't land in"
echo " Sipnetic even though the journal shows it as delivered."
echo ""
log_warning "That URL is a secret — anyone with it can trigger a delivery into your Asterisk."
echo " Details, caveats and testing: $SMS_APP_DIR/README.md"
echo ""
}