diff --git a/services/sms-inbound.sh b/services/sms-inbound.sh index b0e45e2..e0f89a4 100644 --- a/services/sms-inbound.sh +++ b/services/sms-inbound.sh @@ -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 = "".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 '' read-only - docker exec -it ntfy ntfy access '*' '' 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:-}/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:-}/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 "" }