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
`manager show command MessageSend` (this box's own Asterisk, requested
live) documents Destination as the field that actually resolves an
outgoing message's endpoint/technology; To is documented as a
backward-compatible fallback for the destination when Destination is
omitted, and separately as just the outgoing SIP MESSAGE's To: header
content when Destination IS provided. Two live attempts using only To
(bare "pjsip:212", then domain-qualified "pjsip:212@domain") both
produced zero SIP wire traffic -- confirmed via `pjsip set logger on`
during a real delivery attempt against an actively-registered contact --
meaning that documented fallback path isn't actually wired up on this
Asterisk version regardless of what the docs promise.
Switched to Destination using the docs' own "endpoint" form: bare
"pjsip:<ext>", no domain, which resolves via the endpoint's default
aor/contact -- the same live, registered contact `pjsip show contacts`
already confirmed exists for this extension.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
Live test: AMI MessageSend to a bare "pjsip:212" produced zero SIP wire
traffic (confirmed via `pjsip set logger on` during a real delivery
attempt with the target extension actively registered) -- Asterisk
never even tried reaching the registered contact, meaning the failure
was in URI resolution before anything got sent, not a rejection from
the softphone. From already carried a domain (SMS_DOMAIN); To didn't.
Testing whether that asymmetry was the actual cause.
Explicitly a live experiment, not a confirmed fix -- next test will
show whether this produces real SIP MESSAGE traffic in the logger.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
Chased this as an ACL-ordering problem for the last several commits, and
those fixes were real and worth keeping, but none of them could ever
have fixed this: ProtectHome=true in the systemd unit doesn't just
restrict permissions, it mounts an empty, invisible filesystem over
/home, /root, and /run/user for the whole unit. ASTERISK_CONFIG_DIR
lives under /root/docker/... (or /home/<user>/docker/... on a non-root
install), so the relay process could never see it regardless of any ACL
grant on the real filesystem underneath -- from inside the sandboxed
unit it genuinely doesn't exist, while a plain unsandboxed shell
(confirmed live: `sudo -u smsrelay cat pstn-personal-dids.conf` outside
systemd) reads the exact same path fine.
Fix: ProtectHome=read-only instead of true. Still stops this service
from writing into /home or /root -- all it should ever need is read --
it just stops hiding them outright.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
Reproduced live: pstn-personal-dids.conf was confirmed readable by
smsrelay right after a manual ACL grant, then unreadable again
("does not exist" in the relay's log -- os.path.isfile() swallows the
PermissionError and just returns False) immediately after the very next
fresh install. The only thing that ran in between was this same
install's own Asterisk container restart (needed to pick up the new AMI
secret).
The ACL grant was sequenced BEFORE that restart. CLAUDE.md documents the
container's entrypoint re-chowning its mounted config directory on every
restart and says chown alone can't touch ACL entries -- true, but
apparently this image's entrypoint also chmods, and chmod recomputes a
directory's ACL mask entry, which can silently weaken a named-user grant
made before it even though the grant's ACL entry itself is untouched.
Fix: do the grant last, after the restart-or-not branch, so nothing left
in this install run can undo it. ensure_docker_dir_ownership() (chown
only, confirmed in lib/common.sh, no chmod) stays where it was --
chow doesn't need this ordering fix, only the ACL grant does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
A token mismatch returned a bare 404 with no journal line at all, so
"nothing is happening" was indistinguishable from "no request ever
arrived" -- exactly what a stale provider URL looks like after a
RELAY_TOKEN rotation (every full reinstall generates a new one, which
invalidates whatever's still pasted into the DID's SMS tab until it's
updated). Now logs the request's source IP and path length -- never the
attempted path itself, since that's unauthenticated input from whoever
hit the port, no reason to trust or echo it into the journal.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
The port-taken message on the last run ("Port 8093 was taken — the relay
will use 8094") was this service colliding with itself, not a real
conflict. Fresh-install never stopped the previous run before scanning
for a free port, so it always found its own earlier process still bound
to 8093, silently moved to 8094, and then `systemctl enable --now` was a
no-op against an already-active unit -- meaning the OLD process (holding
the OLD AMI secret and OLD relay token, from before this session's
settings.env fix) kept serving traffic while the freshly-written config
and port sat unused underneath it.
Fix: `systemctl stop sms-inbound` right before the port scan. Now the
scan only reports a real conflict from something else, and the box
should settle back on 8093 (or whatever's actually free) with the
process that's really running matching what was just configured.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
Root cause of the "line 9: from: unbound variable" crash: SMS_FORWARD_URL
stores Anveo's own template placeholders literally ($[from]$, $[to]$,
$[message]$ -- text Anveo substitutes on its end, not ours). Written into
settings.env double-quoted, that landed in the file as
SMS_FORWARD_URL="...?from=$[from]$&...". Harmless to write, but the
update path `source`s this same file on every re-run, and bash reads
$[from] as legacy arithmetic expansion ($[...] == $((...))) even inside
double quotes -- a bare name in it means "look up variable from", which
is unset, and setup.sh runs under `set -uo pipefail`, so nounset kills
the whole installer before it gets anywhere near the AMI-diagnostics
code from the last two commits.
Fix: single-quote every value in the written settings.env. A source'd
single-quoted assignment never re-expands its contents, so this is safe
regardless of what SMS_FORWARD_URL (or anything else in that file) holds.
This only fixes future writes -- the box that hit this already has a
broken settings.env on disk from before this fix existed, and "update"
mode sources that file before it gets a chance to rewrite it, so it will
crash the same way one more time even after pulling this. Choosing
"full install" instead on the next run skips the source entirely and
regenerates the file correctly quoted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
First live test dropped an inbound text with only "no owner found for
this DID, dropped" in the journal -- not enough to tell a config-dir
path problem from an ACL/permission problem from a DID-normalization
mismatch from an actually-unassigned DID, without re-triggering a real
text each time. resolve_recipients() now prints which of those it hit:
config dir unset, file missing (which also silently covers "smsrelay
can't traverse/read config/asterisk" -- os.path.isfile() swallows
PermissionError and just returns False), normalized DID has no section,
section has no owner=, or a group owner has no current members.
Nothing added is more sensitive than what's already visible on the
dashboard itself (config dir path, a DID's own digits, the set of DIDs
that exist) -- never the message body.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
SMS-over-SIP is confirmed unavailable on this DID (the account's SMS tab
only offers "Forward to URL", no MESSAGE/INVITE option) so the diagnostic
dialplan from the previous commit is dead weight for delivery purposes,
though pstn-trunk.sh's message_context wiring stays since it's harmless
and costs nothing to leave in place.
Pivots the whole service: same proven HTTP-webhook front end (rate limit,
constant-time token check, message/from/to extraction), but the delivery
target changes from a push notification to a real SIP MESSAGE landing in
the extension's own softphone (Sipnetic), via Asterisk's Manager
Interface. "Direct mode" and all ntfy options are removed entirely per
instruction to fully replace ntfy, not keep both paths.
Recipient resolution reuses the exact on-disk formats
services/security-dashboard.sh already reads/writes: DID -> owner from
pstn-personal-dids.conf, and for a Ring-Group owner ("@Name"), that
group's current members from pstn-groups.conf -- so delivery always
reflects live DID ownership and live group membership, not a snapshot
from assignment time.
AMI access is scoped tightly: a dedicated "smsrelay" manager.conf user
with only the "message" permission class, bound to 127.0.0.1, secret
generated via generate_password. The service account also gets a POSIX
ACL grant (not chmod/group, which the container's entrypoint reverts on
every restart) for read-only access to the two config files above.
Flagged as UNVERIFIED in code comments and will need a live test: the
"message" AMI permission class name and MessageSend's To/From/Body
parameter names are believed correct from documentation but unconfirmed
against a real Asterisk instance. ami_deliver() logs every AMI response
in full specifically so the first real delivery attempt is
self-diagnosing if something here is wrong.
Verified before commit: bash -n on the full file, and py_compile on the
extracted relay.py (configparser/socket/hmac/http.server, all stdlib).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
The SMS tab has one control — a "Forward to URL" checkbox and field, with
SAVE/RETURN buttons where RETURN discards. The instructions described a
destination dropdown that isn't there. Also flags that the generated URLs are
long (~90 chars relay, ~150+ direct) and worth re-opening the tab to confirm
they saved whole.
Adds a provider-risk section: the realistic way to lose the number is account
action or a lapsed balance rather than the company folding, so keep the
balance small, save a recent invoice offline (porting out needs a signed LOA
plus the latest bill, which you can't download once an account is closed),
and note that Anveo states it does not block port-outs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
New service for one narrow job — getting SMS verification codes sent to a
VoIP number onto a phone with no SIM. Deliberately not a texting app: no
outbound path (Anveo Direct has none; that needs an Anveo Retail account, and
a free texting app covers sending), and messages arrive as push notifications
rather than being routed into Asterisk as SIP MESSAGE, since a code you read
and type is better served by a notification than a softphone chat thread.
Two modes, both driven entirely from the provider's "forward SMS to URL" box:
- direct — the provider calls ntfy itself; nothing installed here. ntfy
accepts GET publishing at /{topic}/(publish|send|trigger) with message and
title as query params, and auth via ?auth= holding base64url (unpadded) of
the literal "Bearer <token>" — confirmed against ntfy's server.go and
server_auth.go rather than its docs.
- relay — a stdlib systemd service, Caddy-fronted on its own domain with no
Authelia (the provider can't log in; a random 32-char token in the path is
the secret). Buys two things direct mode can't have: an unescaped "&" in a
message body survives intact, because the relay takes everything after the
last message= verbatim instead of parse_qs — which is why the generated URL
always puts the message placeholder last — and no ntfy credentials sit in a
third party's web portal.
Verification codes are bearer credentials, so: a 24-char random topic name
(the repo's ntfy defaults to auth-default-access: read-write, making the topic
name the read credential), constant-time token compare, a 60/min rate limit,
and the relay logs sender/recipient/length but never the message body.
The Anveo guide gains a section covering the two things that actually decide
whether codes arrive: short-code support (Anveo has it, unusually — VoIP.ms
does not except for Google) and Anveo's carrier-sourced *mobile* DIDs, which
are classified as mobile in the lookups that reject VoIP numbers at signup.
Also documents MMS and group texts being out of reach, and why the native
Messages app never sees any of this.
Verified against a stub ntfy: plain OTP, encoded "&", unencoded "&", "+" as
space, wrong token (404), missing message (400) and the rate limit (57x204
then 429) all behave; both installer modes were run end to end in a sandbox
and their generated URLs, settings files and READMEs checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh