Confirm inbound SMS→AMI working live; rule out outbound over SIP

Inbound: a real text through the full path (Anveo webhook -> relay ->
AMI MessageSend -> Sipnetic) landed with a SIP 200 OK, confirmed live.
Removes the last "UNVERIFIED"/"believed correct" hedges from
sms-inbound.sh now that the AMI permission class and the
Destination-not-To fix are both proven, not just plausible.

Outbound over SIP: tested directly by sending a MESSAGE toward Anveo's
trunk (the mirror image of the inbound webhook). Anveo's SBC responded
501 Not Implemented -- a real, unambiguous rejection of the method
itself. Closes off this avenue for good, symmetric with inbound
SMS-over-SIP already being confirmed unavailable on this DID: neither
direction is offered on this account via SIP. Sending still has no
working path here until Anveo activates HTTP SMS-API access.

Also updates docs/anveo-direct-setup-guide.md's SMS section, which still
described the ntfy-based mechanism this session fully replaced with
AMI/Sipnetic delivery, and corrects its stale "not available on Direct"
sending claim with what's actually been confirmed: SIP MESSAGE outbound
is a dead end (501), the HTTP API is real but pending Anveo activation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
This commit is contained in:
Claude
2026-07-27 13:41:31 +00:00
parent 0135b2ed27
commit a74da52760
2 changed files with 55 additions and 46 deletions
+34 -26
View File
@@ -199,7 +199,7 @@ In the Security Dashboard's PSTN Trunk tab:
# container is named easy-asterisk-do instead # container is named easy-asterisk-do instead
``` ```
## 8. SMS — receiving verification codes ## 8. SMS — receiving texts in Sipnetic
Voice and SMS are separate features on an Anveo DID and are configured in Voice and SMS are separate features on an Anveo DID and are configured in
different places. This section covers **receiving** only; see "What about different places. This section covers **receiving** only; see "What about
@@ -242,41 +242,49 @@ Run the installer and follow what it prints:
sudo ./setup.sh sms-inbound sudo ./setup.sh sms-inbound
``` ```
It generates a long random ntfy topic, then offers two ways for Anveo to A small systemd service on the droplet receives Anveo's webhook request,
reach it: looks up which extension (or Ring Group) currently owns the destination DID
— the exact same `pstn-personal-dids.conf`/`pstn-groups.conf` data
inbound-voice ring logic already reads — and delivers the text into
Asterisk over its Manager Interface as a SIP `MESSAGE`, landing in Sipnetic
the same way this project's internal extension-to-extension texting already
does. Not a push notification: a real message in the softphone's own thread.
- **Relay (recommended)** — a small systemd service on the droplet receives ("SMS over SIP" — Anveo delivering the text as a MESSAGE/INVITE directly to
Anveo's request and republishes to ntfy properly. Two concrete wins: a this box's SIP trunk, skipping the HTTP webhook entirely — was tried first
message body containing `&` survives intact (Anveo interpolates the text and confirmed **not offered** on this DID: the SMS tab only ever showed the
into the query string unescaped, so an unencoded `&` otherwise truncates plain "Forward to URL" option, no SIP-based alternative.)
the message), and your ntfy credentials never get stored in Anveo's portal.
- **Direct** — Anveo calls ntfy itself; nothing runs on the droplet. Simpler,
but the URL you paste into Anveo carries your ntfy token, and the `&` case
loses the tail of the message.
Then in the Anveo portal: **Phone Numbers → the DID → SMS tab**. The tab has Then in the Anveo portal: **Phone Numbers → the DID → SMS tab**. The tab has
exactly one control — a **Forward to URL** checkbox and a text field. Tick the exactly one control — a **Forward to URL** checkbox and a text field. Tick the
box, paste the string the installer printed into the field, and press **SAVE** box, paste the string the installer printed into the field, and press **SAVE**
(not RETURN, which discards). Keep the `$[message]$` placeholder **last** in (not RETURN, which discards). Keep the `$[message]$` placeholder **last** in
that URL — that's what makes the unescaped-`&` case recoverable. that URL — Anveo interpolates the message text into the query string
unescaped, so a body containing `&` otherwise truncates; with the placeholder
last, everything after it is recoverable verbatim.
The field has no visible length limit, but the generated URLs are long Send a text to the number from another phone; it should land in Sipnetic
(~90 characters in relay mode, ~150+ in direct mode, since that one carries within seconds. `journalctl -u sms-inbound -f` shows sender, recipient,
the ntfy auth parameter). After saving, reopen the tab and confirm the whole message length, and the full AMI exchange — never the message body itself,
string came back intact rather than truncated — if it didn't, relay mode is since the journal has a wider audience than the message thread does.
the shorter of the two.
Send a text to the number from another phone; the notification should arrive
within seconds. `journalctl -u sms-inbound -f` shows sender, recipient and
message length (never the body — these are one-time passcodes and the journal
has a wider audience than the notification does).
### What about sending? ### What about sending?
Not covered, on purpose. Outbound SMS isn't available on Anveo Direct — Anveo Two things confirmed this way, neither of them a working path yet:
support directs users to an Anveo **Retail** account for it, which is a
second account to fund and manage. Any of the free texting apps covers - **Outbound over SIP** — sending a `MESSAGE` toward Anveo's trunk (the
sending without involving this box. mirror image of the inbound webhook) was tried directly: Asterisk placed
the request fine, but Anveo's SBC came back `501 Not Implemented`. A real,
unambiguous rejection of the method itself — not a config or auth problem,
and not something any amount of retrying the request format will get past.
- **Outbound via Anveo's HTTP API** (`api/v1.asp?action=sms`) — real and
documented, but gated behind Anveo support activating SMS-API access for
this account; still pending as of this writing (limited support tickets on
this account, so this has been a slow back-and-forth rather than a quick
toggle).
Until that activation lands, there's no outbound path from this box. Any of
the free texting apps covers sending in the meantime without involving it.
### MMS and group texts ### MMS and group texts
+21 -20
View File
@@ -60,11 +60,8 @@ _sms_detect_container_name() {
# ── AMI: a scoped manager.conf user, MessageSend only ─────────────────────── # ── AMI: a scoped manager.conf user, MessageSend only ───────────────────────
# Localhost-only (bindaddr + permit below) since the relay runs on this same # 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" # 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 # as the AMI permission class is confirmed live: MessageSend succeeds with
# against a live MessageSend call yet (see the relay's own comment on # just read/write = message, no wider class needed.
# 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 # Idempotent: creates manager.conf fresh if absent, otherwise ensures
# enabled=yes and the [smsrelay] section exist without disturbing anything # enabled=yes and the [smsrelay] section exist without disturbing anything
@@ -333,21 +330,25 @@ def _ami_read_response(sock_file):
def ami_deliver(from_number, to_exts, body): def ami_deliver(from_number, to_exts, body):
"""Logs into AMI once and sends one MessageSend action per recipient. """Logs into AMI once and sends one MessageSend action per recipient.
"message" as the AMI permission class is confirmed live (login succeeds). Confirmed working end-to-end live (2026-07-27): a real inbound text
Destination (not To) is what actually resolves an outgoing message's delivered through this exact path landed in Sipnetic with a SIP 200 OK.
endpoint/technology -- confirmed against this box's own Two things worth keeping straight if this ever needs touching again:
`manager show command MessageSend`: To alone is documented as a
backward-compatible fallback for the destination, but live testing - "message" as the AMI permission class is enough on its own (login +
(bare "pjsip:212", then "pjsip:212@domain", both for To with no MessageSend both succeed with just read/write = message).
Destination) produced zero SIP wire traffic in either case -- `pjsip - Use Destination, not To, to resolve the endpoint. `manager show
set logger on` during a real attempt showed Asterisk never even tried command MessageSend` documents To-alone as a backward-compatible
reaching the target's registered contact, so that fallback path isn't fallback for the destination when Destination is omitted, but live
actually wired up on this Asterisk version regardless of what the docs testing that fallback (bare "pjsip:212", then "pjsip:212@domain",
promise. Destination's documented "endpoint" form -- bare "pjsip:<ext>", both as To with no Destination) produced zero SIP wire traffic
no domain -- resolves via the endpoint's own default aor/contact, which either way -- Asterisk never even attempted to reach the target's
is exactly the live, registered contact `pjsip show contacts` already registered contact. Destination's documented "endpoint" form (bare
confirmed exists. Every AMI response is still logged in full so the "pjsip:<ext>", no domain) is what actually resolves via the
next attempt is self-diagnosing if this isn't the whole fix either. 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).""" Returns (delivered_count, total_count)."""
if not AMI_SECRET: if not AMI_SECRET: