New installs now name the container "asterisk", matching every other service's container_name == service name convention, instead of reusing the vendored easy-asterisk CLI tool's own name (which stays /usr/local/bin/easy-asterisk inside the container, unrelated and unchanged). An existing "easy-asterisk" install is never silently renamed: every place that resolves the container name (_asterisk_resolve_layout in asterisk.sh, plus the duplicated copies in security-dashboard.sh, sms-inbound.sh, pstn-trunk.sh, and tools/pstn-test-check.sh's docker ps detection) now reads it from the box's own docker-compose.yml instead of assuming it, falling back to "asterisk" only when there's no existing install to read. Migrating a live box to the new name is a one-time manual action (edit docker-compose.yml's container_name for Asterisk and its coturn sidecar, docker compose down + up -d); every sibling service then picks it up automatically on its next run. The DigitalOcean-droplet layout (asterisk-digital-ocean directory, easy-asterisk-do container) is untouched by this - that naming stays exactly as documented for pre-merge droplet installs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
1004 lines
45 KiB
Bash
1004 lines
45 KiB
Bash
#!/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:<ext>", 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 = "<sip:{}@{}>".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/<user>/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 — "<your-domain>"
|
|
# isn't a resolvable hostname.
|
|
if [[ -z "${SMS_RELAY_DOMAIN:-}" || "${SMS_FORWARD_URL:-}" == *"<your-domain>"* ]]; 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 "<your-domain>" 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 ""
|
|
}
|