Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions tests/cp5-origination-signaling-scope/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# CP5 — external signalling scope on origination-relevant ancillary paths

Folded from the M6 routing review (MINOR-1): several **ancillary** signalling
paths still interpolate the raw internal bind (`local_addr`) into Via/From/Contact
instead of the NAT-scoped `advertised_signaling_hostport()`, so an external peer
could see the internal address. This checkpoint answers the M7-scoped question:
**which of those ancillary paths does a Chime origination actually exercise?**

## Finding: none of the ancillary paths is on the M7 Chime origination path

| Ancillary path | Emits raw bind? | On the Chime origination path? |
|---|---|---|
| `outbound_registration.rs` (REGISTER + its keepalive OPTIONS) | yes (`local_addr` in Via/Contact) | **No.** AWS Chime Voice Connector **termination is IP-authenticated** — rustisk does **not** REGISTER to the carrier (the incumbent `call.sh` documents `NOREG`; no outbound registration is started for origination). CP1's carrier auth is a **digest challenge answered on the INVITE**, not a REGISTER. |
| `refer.rs` (REFER / transfer) | yes | **No.** The M7 origination path implements no call transfer, so REFER is never sent. |
| `notify.rs` / `notify_service.rs` (NOTIFY) | yes (`127.0.0.1:5060` fallback / `local_addr`) | **No.** No subscription/event dialog is created on the origination path. |
| `messaging.rs` (MESSAGE) | yes | **No.** SIP MESSAGE is unrelated to origination. |
| `update.rs` (outbound UPDATE / session-timer refresh) | template-only today | **No.** rustisk selects `refresher=uac` and is the session-timer **responder**, not the refresher (`event_handler.rs`: "UAS-side refresh SCHEDULING is deferred"). It answers a peer's UPDATE/re-INVITE but does not originate refreshes, so it never puts an outbound UPDATE on the wire. |

The **core** outbound INVITE (M6 CP2) **and** the in-dialog ACK / BYE / CANCEL
added in M7 CP1 already route through `signaling_hostport()` →
`advertised_signaling_hostport()`, so the entire origination signalling path is
NAT-scoped. There is therefore nothing origination-relevant left to scope in
this checkpoint.

## What this checkpoint delivers instead

1. **This analysis + deferral.** Scoping the genuinely ancillary paths
(REFER/NOTIFY/MESSAGE/REGISTER/UPDATE) remains a real latent leak but is
**off the M7 origination path** — deferred as an M6-MINOR-1 follow-up, to be
done when/if transfer, event subscriptions, MESSAGE, or an authenticated
outbound REGISTER are put on a carrier path.
2. **A receiver-side regression** (`run.sh`) that **locks in** the origination
path's scoping so a future change cannot silently reintroduce a bind leak on
INVITE/ACK/BYE. With `external_signaling_address = signaling.example.net` and
the carrier outside `local_net`, the offline carrier asserts every origination
request advertises the external host and **none** carries the raw `0.0.0.0`
bind.

## Running

```
tests/cp5-origination-signaling-scope/run.sh # GREEN: fully scoped
CP5_MODE=red-noext tests/cp5-origination-signaling-scope/run.sh # RED: drop the
# external address -> every request leaks 0.0.0.0 (assertion RED)
```

Isolated Docker only — never the live voice stack, carrier, or real PIN.
65 changes: 65 additions & 0 deletions tests/cp5-origination-signaling-scope/ami_originate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Trigger one rustisk outbound Dial via a single authenticated AMI Originate.

ami_originate.py HOST PORT ENDPOINT ACTION_ID

Sends `Originate Channel: PJSIP/<ENDPOINT>` asynchronously. rustisk resolves the
endpoint's contact (live registrar binding preferred over static config) and
sends the INVITE to it. Prints the AMI response; exits nonzero if the action was
not queued.
"""

import socket
import sys


def main():
if len(sys.argv) != 5:
raise SystemExit("usage: ami_originate.py HOST PORT ENDPOINT ACTION_ID")
host, port, endpoint, action_id = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]

login = (
"Action: Login\r\n"
"Username: cp5\r\n"
"Secret: cp5-local-only\r\n"
"\r\n"
)
originate = (
"Action: Originate\r\n"
"ActionID: %s\r\n"
"Channel: PJSIP/%s\r\n"
"Context: default\r\n"
"Exten: s\r\n"
"Priority: 1\r\n"
"Timeout: 4000\r\n"
"Async: true\r\n"
"\r\n"
) % (action_id, endpoint)
logoff = "Action: Logoff\r\n\r\n"

payload = (login + originate + logoff).encode("utf-8")
response = bytearray()
with socket.create_connection((host, port), timeout=4) as mgr:
mgr.settimeout(4)
mgr.sendall(payload)
try:
while b"Response: Goodbye\r\n" not in response:
chunk = mgr.recv(65536)
if not chunk:
break
response.extend(chunk)
except socket.timeout:
pass

text = response.decode("utf-8", "replace")
sys.stdout.write(text)
# Require the Originate-specific queued message. A bare "Success" is NOT
# sufficient: the Login reply also carries "Success", so matching it would
# green-light a session whose Originate actually failed.
if "successfully queued" not in text:
sys.stderr.write("Originate not queued\n")
sys.exit(2)


if __name__ == "__main__":
main()
133 changes: 133 additions & 0 deletions tests/cp5-origination-signaling-scope/carrier_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Offline carrier that captures the advertised signalling host on EVERY request
of a full origination (INVITE, ACK, BYE) for the CP5 signalling-scope harness.

With `external_signaling_address` configured and the carrier outside `local_net`,
a scope-correct rustisk advertises the EXTERNAL address (not the raw `0.0.0.0`
bind) in Via/From/Contact on the core INVITE AND the in-dialog ACK/BYE. This
carrier records the host of those headers receiver-side so the harness can assert
no internal-bind leak on the origination path.
"""

import argparse
import re
import socket
import sys


SIP_PORT = 5060


def log(m):
sys.stderr.write("[carrier_scope] " + m + "\n"); sys.stderr.flush()


def own_ip_toward(peer_ip):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((peer_ip, 15060)); return s.getsockname()[0]
finally:
s.close()


def get_headers(text, name):
out = []
for line in text.split("\r\n"):
if line == "":
break
if ":" in line:
hn, hv = line.split(":", 1)
if hn.strip().lower() == name.lower():
out.append(hv.strip())
return out


def get_header(text, name):
v = get_headers(text, name)
return v[0] if v else None


def via_host(text):
via = get_header(text, "Via") or ""
m = re.search(r'SIP/2\.0/UDP\s+([^;\s]+)', via)
return m.group(1) if m else "?"


def uri_host(header_value):
if not header_value:
return "?"
m = re.search(r'<[a-z]+:[^@>]*@([^;>]+)', header_value)
if m:
return m.group(1)
m = re.search(r'[a-z]+:[^@>]*@([^;>]+)', header_value)
return m.group(1) if m else "?"


def cap(path, line):
with open(path, "a") as f:
f.write(line + "\n"); f.flush()
log(line)


def build_response(req, code, reason, own, to_tag=None, sdp=None):
vias = get_headers(req, "Via")
frm = get_header(req, "From") or ""
to = get_header(req, "To") or ""
cid = get_header(req, "Call-ID") or ""
cseq = get_header(req, "CSeq") or ""
if to_tag and "tag=" not in to:
to = to + ";tag=%s" % to_tag
lines = ["SIP/2.0 %d %s" % (code, reason)]
for v in vias:
lines.append("Via: %s" % v)
lines += ["From: %s" % frm, "To: %s" % to, "Call-ID: %s" % cid, "CSeq: %s" % cseq]
lines.append("Contact: <sip:carrier@%s:%d>" % (own, SIP_PORT))
body = sdp or ""
if body:
lines.append("Content-Type: application/sdp")
lines.append("Content-Length: %d" % len(body))
lines += ["", body]
return ("\r\n".join(lines)).encode("utf-8")


def carrier_sdp(own):
return ("v=0\r\no=carrier 0 0 IN IP4 %s\r\ns=-\r\nc=IN IP4 %s\r\n"
"t=0 0\r\nm=audio 40000 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n") % (own, own)


def main():
ap = argparse.ArgumentParser()
ap.add_argument("--caller", required=True)
ap.add_argument("--capture", required=True)
args = ap.parse_args()
own = own_ip_toward(args.caller)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", SIP_PORT))
cap(args.capture, "READY own=%s" % own)
while True:
try:
s.settimeout(0.5)
data, src = s.recvfrom(8192)
except socket.timeout:
continue
except OSError:
break
text = data.decode("utf-8", "replace")
first = text.split("\r\n", 1)[0]
method = first.split(" ", 1)[0]
if method in ("INVITE", "ACK", "BYE"):
cap(args.capture, "%s via_host=%s from_host=%s contact_host=%s src=%s:%d" % (
method, via_host(text),
uri_host(get_header(text, "From")),
uri_host(get_header(text, "Contact")) if get_header(text, "Contact") else "-",
src[0], src[1]))
if method == "INVITE":
s.sendto(build_response(text, 100, "Trying", own), src)
s.sendto(build_response(text, 200, "OK", own, to_tag="cp5scope200", sdp=carrier_sdp(own)), src)
elif method == "BYE":
s.sendto(build_response(text, 200, "OK", own), src)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[directories]
astetcdir = @CONFIG_DIR@
astrundir = @RUN_DIR@
astincludedir = @RUN_DIR@/include
4 changes: 4 additions & 0 deletions tests/cp5-origination-signaling-scope/config/extensions.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[default]
exten => s,1,Answer()
same => n,Wait(1)
same => n,Hangup()
9 changes: 9 additions & 0 deletions tests/cp5-origination-signaling-scope/config/manager.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[general]
enabled = yes
bindaddr = 0.0.0.0
port = 15038

[cp5]
secret = cp5-local-only
read = all
write = system
20 changes: 20 additions & 0 deletions tests/cp5-origination-signaling-scope/config/pjsip.conf.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:15060
local_net = 127.0.0.0/8
external_signaling_address = signaling.example.net

[carrier]
type = endpoint
context = default
disallow = all
allow = ulaw
direct_media = no
rtp_symmetric = yes
dtmf_mode = rfc4733
aors = carrier_aor

[carrier_aor]
type = aor
contact = sip:carrier@@CORE_S@:5060
3 changes: 3 additions & 0 deletions tests/cp5-origination-signaling-scope/config/rtp.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[general]
rtpstart = 31000
rtpend = 31040
Loading
Loading