#!/bin/bash # 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). # # 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. # # 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 (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 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" # ── 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 # Read the box's own container_name instead of assuming — new # installs use plain "asterisk" now, but an existing "easy-asterisk" # install (this repo's container name before that rename) keeps # working unchanged until someone deliberately migrates it. See # services/asterisk.sh's _asterisk_resolve_layout for the reasoning. local _name="" [[ -f "$_ea_dir/docker-compose.yml" ]] && \ _name="$(grep -m1 '^[[:space:]]*container_name:' "$_ea_dir/docker-compose.yml" | awk '{print $2}')" echo "${_name:-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" # as the AMI permission class is confirmed live: MessageSend succeeds with # just read/write = message, no wider class needed. # # 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 # 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 -> 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), 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, 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 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", "") 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 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() 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 _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. Every early-return here also prints exactly why, since "no owner found" alone gives no way to tell a path problem from a normalization problem from an actually-unassigned DID without re-triggering a real text. Nothing printed below is more sensitive than what's already visible on the dashboard (config dir path, a DID's own digits, the set of DIDs that exist) -- never the message body.""" if not ASTERISK_CONFIG_DIR: print("resolve_recipients: SMS_ASTERISK_CONFIG_DIR is not set", flush=True) return [] did = _normalize_did(to_raw) if not did: print("resolve_recipients: couldn't normalize to={!r} to a 10-digit DID".format(to_raw), flush=True) return [] dids_path = os.path.join(ASTERISK_CONFIG_DIR, "pstn-personal-dids.conf") if not os.path.isfile(dids_path): print("resolve_recipients: {} does not exist".format(dids_path), flush=True) return [] dids_cp = _read_conf(dids_path) if not dids_cp.has_section(did): print("resolve_recipients: normalized did={} has no section in {} (known DIDs: {})".format( did, dids_path, dids_cp.sections()), flush=True) return [] owner = dids_cp.get(did, "owner", fallback="").strip() if not owner: print("resolve_recipients: did={} section has no owner= set".format(did), flush=True) 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="") members = [m.strip() for m in members_raw.split(",") if m.strip()] if not members: print("resolve_recipients: did={} owner=@{} but group has no members in pstn-groups.conf".format( did, group_name), flush=True) return members 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. Confirmed working end-to-end live (2026-07-27): a real inbound text delivered through this exact path landed in Sipnetic with a SIP 200 OK. Two things worth keeping straight if this ever needs touching again: - "message" as the AMI permission class is enough on its own (login + MessageSend both succeed with just read/write = message). - Use Destination, not To, to resolve the endpoint. `manager show command MessageSend` documents To-alone as a backward-compatible fallback for the destination when Destination is omitted, but live testing that fallback (bare "pjsip:212", then "pjsip:212@domain", both as To with no Destination) produced zero SIP wire traffic either way -- Asterisk never even attempted to reach the target's registered contact. Destination's documented "endpoint" form (bare "pjsip:", no domain) is what actually resolves via the endpoint's default aor/contact and is what's used below. Every AMI response is still logged in full, since a wrong recipient, an unregistered contact, etc. all still need to be diagnosable from the journal alone. 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 = "".format(from_number or "unknown", SMS_DOMAIN or "localhost") for ext in to_exts: resp = send_action([ ("Action", "MessageSend"), ("Destination", "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): 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): # A silent 404 here used to mean this line never printed at # all -- "the box is up but nothing is happening" was # indistinguishable from "no request ever arrived". Every # RELAY_TOKEN rotation (a full reinstall generates a new one) # invalidates whatever URL is still pasted into the provider, # and that's exactly what this looks like: log something, # never the attempted path itself (arbitrary attacker/scanner # input, no reason to trust or echo it into the journal). print("sms webhook: request with unrecognized path/token (len={}) from {} -- " "does the URL pasted into the provider match this box's current " "SMS_FORWARD_URL in /opt/sms-inbound/settings.env?".format( len(path), self.client_address[0]), flush=True) 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 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: delivered, total = ami_deliver(sender, exts, message) except Exception as exc: # noqa: BLE001 print("ami_deliver failed: {}".format(exc), flush=True) self._respond(502) return 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() 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 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 # 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" _asterisk_dir="$3" _ami_secret="$4" _domain="$5" cat > /etc/systemd/system/sms-inbound.service << SMSSVC [Unit] Description=Inbound SMS webhook, delivered into Asterisk via AMI 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_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 NoNewPrivileges=true ProtectSystem=strict # NOT "true" -- that doesn't just restrict permissions, it mounts an # empty, invisible filesystem over /home, /root and /run/user for this # unit, full stop. ASTERISK_CONFIG_DIR lives under /root/docker/... (or # /home//docker/... on a non-root install), so with ProtectHome=true # the relay process can never see it, no ACL or ownership on the real # filesystem can fix that, and every read looks exactly like "the file # doesn't exist" from inside the unit while a plain, unsandboxed shell # (e.g. `sudo -u smsrelay cat ...`) reads it fine. Confirmed live: this # was the actual cause of every "does not exist" seen while chasing what # looked like an ACL-ordering problem -- read-only still protects /home # and /root from being written to by this service (all it should ever # need is read), it just stops hiding them outright. ProtectHome=read-only 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 } # Remote-Caddy counterpart to _sms_configure_caddy — same site block, but a # remote Caddy box can't resolve host.docker.internal (that hostname only # works via the extra_hosts entry a LOCAL Caddy container gets) and isn't on # this host's Docker bridge at all, so it has to reach the relay over this # host's own IP and published port instead. Mirrors the snippet-file pattern # configure_caddy_for_service uses for every other service (lib/common.sh). _sms_write_caddy_snippet() { local _domain="$1" _port="$2" local _this_ip="${CADDY_REMOTE_HOST:-}" [ -z "$_this_ip" ] && _this_ip="$(hostname -I 2>/dev/null | awk '{print $1}')" [ -z "$_this_ip" ] && _this_ip="$(hostname -f 2>/dev/null || echo "127.0.0.1")" local _snippet_dir="$DOCKER_DIR/caddy-snippets" local _snippet_file="$_snippet_dir/sms-inbound.caddy" mkdir -p "$_snippet_dir" cat > "$_snippet_file" << 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 ${_this_ip}:${_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 chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true log_success "Snippet saved: $_snippet_file" log_info "Copy to your Caddy machine and append to its Caddyfile:" log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" log_info " cat ~/caddy-snippets/sms-inbound.caddy >> /path/to/Caddyfile" log_info " docker restart caddy # reload API is disabled by default" } _sms_write_readme() { local _url="$1" _relay_domain="$2" local _url_section if [ -n "$_url" ]; then _url_section="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 trigger a message delivery into your Asterisk." else _url_section="**Not set up yet — no public domain was entered.** Re-run \`sudo ./setup.sh sms-inbound\` and choose \"Full reinstall\" once DNS for the webhook's domain points at this box; nothing here works until then." fi write_readme "$SMS_APP_DIR" << MD # Inbound SMS → Sipnetic (via AMI) 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 ${_url_section} ## 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 — 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. ## Security - **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 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 \`\`\` $( [[ -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 (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+message/')" \`\`\` **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 → Sipnetic (via Asterisk's Manager Interface)..." if [ "$DRY_RUN" = true ]; then 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" 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 "" 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" _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 "" # A missing/placeholder domain here means an earlier run was # left with no real webhook URL (RELAY_DOMAIN entered blank, # or DNS wasn't ready yet) — "update" mode never re-prompts # for the domain (by design, same as every other service's # non-destructive update path), so silently repeating that # broken URL forever, looking like nothing is wrong, is worse # than saying so plainly. Confirmed live: this is exactly # what a DID provider like Anveo rejects — "" # isn't a resolvable hostname. if [[ -z "${SMS_RELAY_DOMAIN:-}" || "${SMS_FORWARD_URL:-}" == *""* ]]; then log_warning "No real webhook domain was ever set for this install — the stored" log_warning "provider URL is a placeholder, not something a DID provider can use." log_warning "Re-run 'sudo ./setup.sh sms-inbound' and choose \"2) Full reinstall\"" log_warning "to be asked for the domain again (needs DNS pointed at this box first)." else log_success "Settings, Caddy and firewall rules were left untouched." echo " Provider URL: ${SMS_FORWARD_URL}" fi 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 # ── AMI + relay system user ───────────────────────────────────────────── id -u "$SMS_SVC_USER" &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin "$SMS_SVC_USER" 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" if [[ "$MGR_STATE" != "unchanged" ]]; then echo "" 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 "Not restarted — the relay will run, but AMI logins will fail until" log_warning "you restart: docker restart $CONTAINER_NAME" fi fi # Grant smsrelay's ACL access AFTER any Asterisk container restart above, # not before. The container's entrypoint re-chowns (and, apparently on # this image, re-chmods) its mounted config directory on every restart — # chown alone leaves ACL entries alone (that's the whole reason this uses # ACLs over chmod/group membership in the first place, see CLAUDE.md), # but a chmod recomputes the ACL mask entry and can silently weaken a # grant made before it. Confirmed live: pstn-personal-dids.conf was # readable by smsrelay immediately after a manual grant+test, then # unreadable again ("does not exist" — os.path.isfile() swallows the # PermissionError) right after the very next fresh install, which # restarts Asterisk for the new AMI secret. Granting last means nothing # runs afterward in this same install to undo it. _sms_grant_asterisk_read_access "$SMS_SVC_USER" "$ASTERISK_DIR" # ── Relay service ───────────────────────────────────────────────────────── mkdir -p "$SMS_APP_DIR" # Stop any instance of this exact service BEFORE scanning for a free # port. Otherwise a fresh install run while the service is still up # from a previous run always finds its own old process squatting on # 8093, "frees" itself onto 8094 instead, and `enable --now` below is # then a no-op against an already-active unit — leaving the OLD # process (old token, old AMI secret) as the one actually serving # traffic while the newly-written config sits unused. Confirmed live: # exactly this sequence on a re-run right after a fresh install. systemctl stop sms-inbound 2>/dev/null || true 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 by something else — 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 # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): # explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy, # then the legacy CADDY_REMOTE_HOST var. local _CADDY_MODE="${CADDY_MODE:-none}" [ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local" [ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote" 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 [ "$_CADDY_MODE" = "local" ]; then _sms_configure_caddy "$RELAY_DOMAIN" "$RELAY_PORT" elif [ "$_CADDY_MODE" = "remote" ]; then _sms_write_caddy_snippet "$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 [ "$_CADDY_MODE" = "local" ]; 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 # No local Caddy to hide behind — a remote Caddy box needs to # reach this port over the network, and with no Caddy at all the # provider needs to reach it directly. Either way it stays open. ufw allow "${RELAY_PORT}/tcp" fi ensure_ufw_enabled fi # Empty (not a "" placeholder) when no domain was entered — # a placeholder here used to get persisted to settings.env and silently # re-served as-is on every later "update" run (which never re-prompts # for the domain, by design), looking like a valid webhook URL right up # until a DID provider like Anveo rejected it as an unresolvable host. # Confirmed live. local FORWARD_URL="" [ -n "$RELAY_DOMAIN" ] && FORWARD_URL="https://${RELAY_DOMAIN}/sms/${RELAY_TOKEN}?from=\$[from]\$&to=\$[to]\$&message=\$[message]\$" # ── Persist settings ────────────────────────────────────────────────────── # Single-quoted values: this file gets `source`d again on the next # "update" run, and SMS_FORWARD_URL's value contains Anveo's own # template placeholders ($[from]$, $[to]$, $[message]$ - literal text # Anveo substitutes on its end). Double-quoting would leave those # sitting unescaped in the sourced file, and bash reads "$[from]" as # legacy arithmetic expansion ($[...] == $((...))) - a bare variable # name inside it under `set -u` (see setup.sh) is "from: unbound # variable", killing the whole installer. Single quotes make the # sourced value a literal string, no re-expansion, regardless of what # it contains. Confirmed live: this crashed "update" mode outright on # the very next run after a fresh install wrote this file. cat > "$SMS_SETTINGS" << ENV # Written by services/sms-inbound.sh — re-run that to change any of this. 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 trigger a delivery into your Asterisk. SMS_FORWARD_URL='${FORWARD_URL}' ENV chmod 600 "$SMS_SETTINGS" _sms_write_readme "$FORWARD_URL" "$RELAY_DOMAIN" # ── Summary ─────────────────────────────────────────────────────────────── echo "" if [ -z "$FORWARD_URL" ]; then log_warning "Inbound SMS relay is running, but nothing can reach it yet — no domain was entered." log_warning "Point an A record at this box, then re-run 'sudo ./setup.sh sms-inbound' and" log_warning "choose \"2) Full reinstall\" to be asked for the domain again and get a real" log_warning "\"Forward to URL\" to paste into your DID provider." echo "" return 0 fi log_success "Inbound SMS → Sipnetic configured." echo "" 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 "" 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 " 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 "" 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 "" }