#!/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
        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"
# 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"
    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

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.

## 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 ""
                log_success "Settings, Caddy and firewall rules were left untouched."
                echo "  Provider URL: ${SMS_FORWARD_URL}"
                echo ""
                return 0
                ;;
            cancel)
                log_info "Leaving the existing setup as-is — nothing changed."
                return 0
                ;;
            fresh) log_info "Reconfiguring from scratch — every prompt below runs again." ;;
        esac
    fi

    # ── 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

    local FORWARD_URL="https://${RELAY_DOMAIN:-<your-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 ""
    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 ""
}
