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
11 changes: 11 additions & 0 deletions crates/asterisk-sip/src/channel_driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,15 @@ impl ChannelDriver for SipChannelDriver {
))
});

// Apply the endpoint's outbound caller identity (`from_user`/
// `from_domain`) to the origination From (CP2). A carrier that authorizes
// by caller ID (Chime rejects a From that is not a DID we own) needs a
// specific identity here rather than the internal `asterisk@<bind>`.
if let Some(ep) = endpoint_config.as_ref().and_then(|config| config.find_endpoint(dest)) {
sip_session.from_user = ep.from_user.clone();
sip_session.from_domain = ep.from_domain.clone();
}

// Create SDP offer with a concrete, routable connection address
// (external_media_address / routed interface — never 0.0.0.0,
// issue #56). Fail closed (CP3): if a configured external_media_address
Expand Down Expand Up @@ -730,6 +739,8 @@ impl ChannelDriver for SipChannelDriver {
early_media_config: session.early_media_config.clone(),
outbound_auth: session.outbound_auth.clone(),
auth_attempts: session.auth_attempts,
from_user: session.from_user.clone(),
from_domain: session.from_domain.clone(),
};
handler.register_outbound_session(
&session.call_id,
Expand Down
104 changes: 102 additions & 2 deletions crates/asterisk-sip/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ pub struct SipSession {
/// the challenge/response loop so a carrier that keeps challenging cannot
/// drive an unbounded resend (M-f "bounded retries").
pub auth_attempts: u32,
/// Outbound From user-part (`from_user`). A carrier that authorizes calls by
/// caller identity (e.g. Chime rejects a From that is not a DID we own)
/// requires a specific user here. `None` falls back to `asterisk`.
pub from_user: Option<String>,
/// Outbound From host-part (`from_domain`). `None` falls back to the
/// signalling host:port advertised toward the peer.
pub from_domain: Option<String>,
}

impl SipSession {
Expand Down Expand Up @@ -176,6 +183,8 @@ impl SipSession {
early_media_config: EarlyMediaConfig::default(),
outbound_auth: None,
auth_attempts: 0,
from_user: None,
from_domain: None,
}
}

Expand Down Expand Up @@ -215,6 +224,8 @@ impl SipSession {
early_media_config: EarlyMediaConfig::default(),
outbound_auth: None,
auth_attempts: 0,
from_user: None,
from_domain: None,
})
}

Expand All @@ -234,12 +245,25 @@ impl SipSession {
self.build_invite_with_uri(to_uri, to_uri)
}

/// The outbound From URI (`sip:user@domain`) this session presents. Honors
/// the endpoint's `from_user`/`from_domain` (a carrier expects a specific
/// caller identity — e.g. a DID it owns), falling back to `asterisk` @ the
/// signalling host:port. Used for the INVITE and every in-dialog request so
/// the From identity stays stable across the dialog.
pub fn from_uri(&self) -> String {
let user = self.from_user.as_deref().unwrap_or("asterisk");
match self.from_domain.as_deref() {
Some(domain) => format!("sip:{user}@{domain}"),
None => format!("sip:{user}@{}", self.signaling_hostport()),
}
}

/// Build an INVITE with separate Request-URI and To header value.
/// The request_uri is used as the actual SIP Request-URI (typically the
/// contact address), while to_uri is used in the To header.
pub fn build_invite_with_uri(&mut self, request_uri: &str, to_uri: &str) -> SipMessage {
let sig = self.signaling_hostport();
let from_uri = format!("sip:asterisk@{sig}");
let from_uri = self.from_uri();
let contact_uri = format!("sip:asterisk@{sig}");
let branch = format!("z9hG4bK{}", &Uuid::new_v4().to_string().replace('-', "")[..16]);

Expand Down Expand Up @@ -568,6 +592,9 @@ impl SipSession {

/// Build a BYE request.
pub fn build_bye(&mut self) -> Option<SipMessage> {
// From identity must match the INVITE's (same user@domain + local tag)
// for the dialog's lifetime — carriers key in-dialog requests on it.
let from_base = self.from_uri();
let sig = self.signaling_hostport();
// Loose/strict routing per the established route set (Contact + Route),
// so an in-dialog BYE follows the same proxy path as the ACK instead of
Expand All @@ -591,7 +618,7 @@ impl SipSession {

let branch = format!("z9hG4bK{}", &Uuid::new_v4().to_string().replace('-', "")[..16]);

let from_value = format!("<sip:asterisk@{}>;tag={}", sig, dialog.local_tag);
let from_value = format!("<{}>;tag={}", from_base, dialog.local_tag);

let to_value = format!("<{}>;tag={}", dialog.remote_uri, dialog.remote_tag);

Expand Down Expand Up @@ -955,3 +982,76 @@ mod cp1_tests {
assert_eq!(s.auth_attempts, 0);
}
}

#[cfg(test)]
mod cp2_tests {
use super::*;

fn addr(s: &str) -> SocketAddr {
s.parse().unwrap()
}

#[test]
fn from_user_domain_applied_to_invite_from() {
let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060"));
s.from_user = Some("+19995551234".to_string());
s.from_domain = Some("carrier.example.net".to_string());
let invite = s.build_invite_with_uri("sip:+15550001111@10.0.0.2:5060", "sip:+15550001111@10.0.0.2");
let from = invite.from_header().unwrap().to_string();
assert!(
from.contains("sip:+19995551234@carrier.example.net"),
"From must carry the configured user@domain, got: {from}"
);
// The internal bind identity must be gone (the CP2 defect).
assert!(!from.contains("asterisk@"), "From still hardcodes asterisk: {from}");
}

#[test]
fn from_user_only_keeps_signalling_domain() {
let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060"));
s.from_user = Some("+19995551234".to_string());
let invite = s.build_invite_with_uri("sip:x@10.0.0.2:5060", "sip:x@10.0.0.2");
let from = invite.from_header().unwrap().to_string();
assert!(from.contains("sip:+19995551234@"), "user not applied: {from}");
// Domain falls back to the signalling host:port (the bind), not "asterisk".
assert!(from.contains("@10.0.0.1:5060"), "domain fallback wrong: {from}");
}

#[test]
fn from_defaults_to_asterisk_when_unset() {
let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060"));
let invite = s.build_invite_with_uri("sip:x@10.0.0.2:5060", "sip:x@10.0.0.2");
assert!(invite.from_header().unwrap().contains("sip:asterisk@10.0.0.1:5060"));
}

#[test]
fn bye_from_matches_configured_identity() {
// The in-dialog BYE From must carry the SAME user@domain as the INVITE.
let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060"));
s.from_user = Some("+19995551234".to_string());
s.from_domain = Some("carrier.example.net".to_string());
let invite = s.build_invite_with_uri("sip:+15550001111@10.0.0.2:5060", "sip:+15550001111@10.0.0.2");
let ok = SipMessage::parse(
format!(
"SIP/2.0 200 OK\r\n\
Via: {via}\r\n\
From: {from}\r\n\
To: <sip:+15550001111@10.0.0.2>;tag=c200\r\n\
Call-ID: {cid}\r\n\
CSeq: 1 INVITE\r\n\
Contact: <sip:carrier@10.0.0.2:5060>\r\n\
Content-Length: 0\r\n\r\n",
via = invite.get_header(header_names::VIA).unwrap(),
from = invite.from_header().unwrap(),
cid = s.call_id,
)
.as_bytes(),
)
.unwrap();
s.on_response(&ok);
let bye = s.build_bye().unwrap();
let from = bye.from_header().unwrap().to_string();
assert!(from.contains("sip:+19995551234@carrier.example.net"), "BYE From wrong: {from}");
assert!(!from.contains("asterisk@"), "BYE From still hardcodes asterisk: {from}");
}
}
65 changes: 65 additions & 0 deletions tests/cp2-from-identity/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: cp2\r\n"
"Secret: cp2-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()
126 changes: 126 additions & 0 deletions tests/cp2-from-identity/carrier_from.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Minimal offline carrier for the CP2 from-identity harness (stdlib only).

Captures the outbound INVITE's From header RECEIVER-SIDE and answers the call
(100 -> 200 with SDP -> consumes ACK/BYE) so the leg completes cleanly. The
harness asserts the captured From carries the endpoint's configured
from_user@from_domain — never a rustisk TX log.
"""

import argparse
import re
import socket
import sys


SIP_PORT = 5060


def log(msg):
sys.stderr.write("[carrier_from] " + msg + "\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 append_capture(path, line):
with open(path, "a") as f:
f.write(line + "\n")
f.flush()
log(line)


def build_response(req_text, code, reason, own, to_tag=None, sdp=None):
vias = get_headers(req_text, "Via")
frm = get_header(req_text, "From") or ""
to = get_header(req_text, "To") or ""
call_id = get_header(req_text, "Call-ID") or ""
cseq = get_header(req_text, "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.append("From: %s" % frm)
lines.append("To: %s" % to)
lines.append("Call-ID: %s" % call_id)
lines.append("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.append("")
lines.append(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)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", SIP_PORT))
append_capture(args.capture, "READY own=%s" % own)

while True:
try:
sock.settimeout(0.5)
data, src = sock.recvfrom(8192)
except socket.timeout:
continue
except OSError:
break
text = data.decode("utf-8", "replace")
first = text.split("\r\n", 1)[0]
if first.startswith("INVITE "):
frm = get_header(text, "From") or "?"
# Extract the bare URI (sip:user@domain) from the From value.
m = re.search(r'<([^>]+)>', frm)
uri = m.group(1) if m else frm
append_capture(args.capture, "INVITE-FROM own=%s from_uri=%s raw_from=%s" % (own, uri, frm))
sock.sendto(build_response(text, 100, "Trying", own), src)
sock.sendto(build_response(text, 200, "OK", own, to_tag="cp2from200", sdp=carrier_sdp(own)), src)
elif first.startswith("BYE "):
append_capture(args.capture, "BYE own=%s" % own)
sock.sendto(build_response(text, 200, "OK", own), src)
# ACK: nothing to answer.


if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions tests/cp2-from-identity/config/asterisk.conf.tmpl
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
3 changes: 3 additions & 0 deletions tests/cp2-from-identity/config/extensions.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[default]
exten => s,1,Wait(1)
same => n,Hangup()
9 changes: 9 additions & 0 deletions tests/cp2-from-identity/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

[cp2]
secret = cp2-local-only
read = all
write = system
Loading
Loading