use std::time::Duration;
use sipx_sip::push::Device;
use sipx_sip::{Host, HostName, Uri};
use sipx_transport::{Config as TransportConfig, Target, TransportKind, bind};
use sipx_ua::{Config, Credentials, Flow, InstanceId, RegId, UserAgent};
use crate::Args;
use crate::output::{Exit, Format, Report, fail};
pub(crate) const HELP: &str = "\
sipx register — register with a registrar
USAGE:
sipx register <AOR> [OPTIONS]
ARGS:
<AOR> The address of record, e.g. sip:alice@example.com
OPTIONS:
--password <P> Password. Prefer SIPX_PASSWORD, since argv is world-readable.
--target <ADDR> Where to send, if not derived from the AOR (host:port)
--expires <S> Lease to ask for, in seconds (default 3600)
--local <ADDR> Local address to bind (default 0.0.0.0:0)
--transport <T> Signalling: udp, tcp, tls, ws or wss (default udp)
--tcp Legacy alias for --transport tcp
--tls-server-name <N> Certificate identity to verify (default AOR domain)
--tls-ca <FILE> Add PEM trust roots to the platform store
--tls-cert <FILE> Client certificate chain for mutual TLS (with --tls-key)
--tls-key <FILE> Client private key for mutual TLS (with --tls-cert)
--header <H> Add an application-owned REGISTER field; repeat 'Name: value'
--keep-alive Keep refreshing until interrupted
--outbound Register as one Outbound flow (RFC 5626), and report whether the
registrar accepted it
--instance <URN> With --outbound: this device identity rather than a fresh one
--push-provider <P> Push notification service this device can be woken through (RFC 8599)
--push-prid <T> The identifier that service knows this device by
--push-param <X> Service-specific extra, when the service needs one
--wake Act as though a push arrived: refresh the binding and report the PURR
--capture <FILE> Record signalling to this pcapng file. Credentials are redacted;
TLS is recorded decrypted. Still identifies who called whom
--counters <FILE> Write this run's signalling counters to this file, as JSON.
Implied by --capture, as <capture>.counters.json
--json Report as JSON
";
#[allow(
clippy::too_many_lines,
reason = "the command lifecycle is kept in execution order so validation-before-I/O remains auditable"
)]
pub(crate) async fn run(raw: &[String], format: Format) -> Exit {
let args = match crate::arguments(raw, HELP, format) {
Ok(args) => args,
Err(exit) => return exit,
};
let Some(aor) = args.positional() else {
eprint!("{HELP}");
return fail(format, Exit::Usage, "an address of record is required");
};
let headers = match crate::header::from_args(&args) {
Ok(headers) => headers,
Err(message) => return fail(format, Exit::Usage, &message),
};
let Ok(parsed_aor) = Uri::parse(bytes::Bytes::from(aor.to_owned())) else {
return fail(
format,
Exit::Usage,
&format!("not a SIP address of record: {aor}"),
);
};
let Some((user, domain)) = parse_aor(aor) else {
return fail(
format,
Exit::Usage,
&format!("not a SIP address of record: {aor}"),
);
};
let password = args
.value("password")
.map(str::to_owned)
.or_else(|| std::env::var("SIPX_PASSWORD").ok());
let reach = match reachability(&args) {
Ok(reach) => reach,
Err(message) => return fail(format, Exit::Usage, &message),
};
let transport =
match crate::signalling::Selection::from_args(&args, parsed_aor.scheme().is_secure()) {
Ok(transport) => transport,
Err(message) => return fail(format, Exit::Usage, &message),
};
let unresolved = match resolve_target(args.value("target"), &domain, transport.kind()) {
Ok(target) => target,
Err(message) => return fail(format, Exit::Usage, &message),
};
let target = match transport.target(&args, unresolved.addr, &domain) {
Ok(target) => target,
Err(message) => return fail(format, Exit::Usage, &message),
};
let negotiated_transport = target.transport;
let local = args.value("local").unwrap_or("0.0.0.0:0");
let Ok(local) = local.parse() else {
return fail(format, Exit::Usage, &format!("not an address: {local}"));
};
let mut config = TransportConfig::new(local);
config.sent_by = crate::advertise::reachable_ip(local, target.addr.ip()).to_string();
crate::apply_capture(&args, &mut config);
if let Err(message) = transport.configure_client(&args, &mut config) {
return fail(format, Exit::Usage, &message);
}
let (handle, _incoming) = match bind(config).await {
Ok(bound) => bound,
Err(error) => return fail(format, Exit::Failed, &format!("bind: {error}")),
};
let export = crate::counters::Export::arm(&args, &handle);
let contact = format!("<sip:{user}@{}>", handle.advertised());
let Ok(host) = HostName::new(domain.clone()) else {
return fail(format, Exit::Usage, &format!("not a hostname: {domain}"));
};
let registrar = Uri::sip(Host::Name(host));
let mut ua_config = Config::new(format!("<sip:{user}@{domain}>"), contact, registrar, target);
for header in headers {
ua_config = ua_config.with_header(header);
}
ua_config.expires = Duration::from_secs(args.number("expires").unwrap_or(3600));
if let Some(password) = password {
ua_config = ua_config.with_credentials(Credentials::new(user.clone(), password));
}
let Reachability { flow, device, wake } = reach;
let outbound = flow.is_some();
let provider = device.as_ref().map(|device| device.provider().to_owned());
if let Some(flow) = flow {
ua_config = ua_config.with_outbound(flow);
}
if let Some(device) = device {
ua_config = ua_config.with_push(device);
}
let mut agent = UserAgent::new(handle, ua_config);
let aor = format!("sip:{user}@{domain}");
match agent.register().await {
Ok(lease) => {
let mut report = transport.report(
Report::new()
.text("status", "registered")
.text("aor", aor.clone())
.seconds("expires", lease.granted)
.seconds("refresh_in", lease.refresh_after),
negotiated_transport,
);
if outbound {
report = report.boolean("flow", agent.flow_accepted());
}
if let Some(provider) = &provider {
report = report.boolean("push", agent.push_support().supports(provider));
}
report = match export.into_report(report) {
Ok(report) => report,
Err(message) => return fail(format, Exit::Failed, &message),
};
report.emit(format);
if wake {
if let Err(exit) = report_wake(&mut agent, format, &aor).await {
return exit;
}
}
if args.flag("keep-alive") {
let Err(error) = agent.keep_registered().await;
return report_failure(format, &error);
}
Exit::Success
}
Err(error) => report_failure(format, &error),
}
}
#[derive(Debug)]
struct Reachability {
flow: Option<Flow>,
device: Option<Device>,
wake: bool,
}
fn reachability(args: &Args<'_>) -> Result<Reachability, String> {
let provider = args.value("push-provider");
let prid = args.value("push-prid");
let param = args.value("push-param");
let device = match (provider, prid) {
(Some(provider), Some(prid)) => {
let device = Device::new(provider, prid)
.map_err(|error| format!("--push-provider/--push-prid: {error}"))?;
let device = match param {
Some(param) => device
.with_param(param)
.map_err(|error| format!("--push-param: {error}"))?,
None => device,
};
Some(device)
}
(None, None) => {
if param.is_some() {
return Err(
"--push-param belongs with --push-provider and --push-prid: without them \
there is no push service for it to describe"
.to_owned(),
);
}
None
}
_ => {
return Err(
"--push-provider and --push-prid belong together: RFC 8599 §4.1.2 has a UA name \
the service and the device in one breath, and either alone wakes nothing"
.to_owned(),
);
}
};
let wake = args.flag("wake");
if wake && device.is_none() {
return Err(
"--wake acts as though a push notification arrived, and no push service was named: \
give --push-provider and --push-prid with it"
.to_owned(),
);
}
let instance = args.value("instance");
let flow = if args.flag("outbound") {
let instance = match instance {
Some(urn) => InstanceId::parse(urn).ok_or_else(|| {
format!("--instance must be a URN — RFC 5626 §4.1's grammar is `instance-val = urn`: {urn}")
})?,
None => InstanceId::generate(),
};
let reg_id = RegId::new(1)
.ok_or_else(|| "reg-id 1 is inside §4.2's range by construction".to_owned())?;
Some(Flow { instance, reg_id })
} else {
if instance.is_some() {
return Err(
"--instance names the device an Outbound flow registers; without --outbound \
there is no flow to name it for"
.to_owned(),
);
}
None
};
Ok(Reachability { flow, device, wake })
}
async fn report_wake(agent: &mut UserAgent, format: Format, aor: &str) -> Result<(), Exit> {
match agent.woken().await {
Ok(pending) => {
let mut report = Report::new()
.text("status", "woken")
.text("aor", aor)
.seconds("expires", pending.lease.granted)
.seconds("refresh_in", pending.lease.refresh_after);
if let Some(purr) = &pending.purr {
report = report.text("purr", purr);
}
report.emit(format);
Ok(())
}
Err(error) => Err(report_failure(format, &error)),
}
}
fn report_failure(format: Format, error: &sipx_ua::Error) -> Exit {
let exit = match error {
sipx_ua::Error::Rejected { status, .. } => Exit::for_status(*status),
sipx_ua::Error::PushNotSupported { .. } => Exit::Rejected,
sipx_ua::Error::AuthenticationFailed | sipx_ua::Error::CredentialsRequired => {
Exit::Unauthorized
}
sipx_ua::Error::NoResponse => Exit::Timeout,
_ => Exit::Failed,
};
fail(format, exit, &error.to_string())
}
fn parse_aor(aor: &str) -> Option<(String, String)> {
let rest = aor
.strip_prefix("sip:")
.or_else(|| aor.strip_prefix("sips:"))?;
let (user, domain) = rest.split_once('@')?;
(!user.is_empty() && !domain.is_empty()).then(|| {
(
user.to_owned(),
domain.split(';').next().unwrap_or(domain).to_owned(),
)
})
}
fn resolve_target(
explicit: Option<&str>,
domain: &str,
transport: TransportKind,
) -> Result<Target, String> {
let raw = explicit.unwrap_or(domain);
if let Ok(addr) = raw.parse::<std::net::SocketAddr>() {
return Ok(Target::new(addr, transport));
}
if let Ok(ip) = raw.parse::<std::net::IpAddr>() {
return Ok(Target::new(
std::net::SocketAddr::new(ip, transport.default_port()),
transport,
));
}
Err(format!(
"cannot reach {raw}: give --target host:port, since name resolution is not wired into \
this command yet"
))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn an_address_of_record_splits_into_user_and_domain() {
assert_eq!(
parse_aor("sip:alice@example.com"),
Some(("alice".to_owned(), "example.com".to_owned()))
);
assert_eq!(
parse_aor("sips:bob@secure.example"),
Some(("bob".to_owned(), "secure.example".to_owned()))
);
}
#[tokio::test]
async fn a_sips_aor_is_refused_rather_than_registered_in_the_clear() {
let exit = run(
&["register".to_owned(), "sips:bob@192.0.2.1".to_owned()],
Format::Text,
)
.await;
assert_eq!(
exit.code(),
Exit::Usage.code(),
"registering a sips: AOR must be refused, not sent in the clear"
);
}
#[test]
fn uri_parameters_are_not_part_of_the_domain() {
assert_eq!(
parse_aor("sip:alice@example.com;transport=tcp"),
Some(("alice".to_owned(), "example.com".to_owned()))
);
}
#[test]
fn something_that_is_not_an_aor_is_refused() {
for bad in [
"alice@example.com",
"sip:example.com",
"sip:@example.com",
"sip:alice@",
"",
] {
assert!(parse_aor(bad).is_none(), "{bad} is not an AOR");
}
}
#[test]
fn an_explicit_target_wins_over_the_domain() {
let target = resolve_target(Some("192.0.2.1:5080"), "example.com", TransportKind::Udp)
.expect("an address");
assert_eq!(target.addr.to_string(), "192.0.2.1:5080");
}
#[test]
fn a_bare_address_gets_the_transport_default_port() {
assert_eq!(
resolve_target(Some("192.0.2.1"), "x", TransportKind::Udp)
.expect("an address")
.addr
.port(),
5060
);
assert_eq!(
resolve_target(Some("192.0.2.1"), "x", TransportKind::Tls)
.expect("an address")
.addr
.port(),
5061
);
}
#[test]
fn a_name_with_no_resolver_is_a_named_usage_error() {
let error =
resolve_target(None, "example.com", TransportKind::Udp).expect_err("cannot be reached");
assert!(error.contains("--target"), "{error}");
}
fn parsed(items: &[&str]) -> Result<Reachability, String> {
let raw: Vec<String> = items.iter().map(|item| (*item).to_owned()).collect();
let args = Args::new(&raw).expect("a well formed argument list");
reachability(&args)
}
#[test]
fn the_reachability_flags_build_what_they_select() {
let reach = parsed(&[
"register",
"sip:alice@example.com",
"--outbound",
"--push-provider",
"webpush",
"--push-prid",
"c1a5b3e7d9f2",
"--push-param",
"7f3ad0",
"--wake",
])
.expect("a valid combination");
let flow = reach.flow.expect("a flow was asked for");
assert_eq!(flow.reg_id.value(), 1, "one invocation is one flow");
assert!(flow.instance.urn().starts_with("urn:uuid:"));
let device = reach.device.expect("a push service was named");
assert_eq!(device.provider(), "webpush");
assert_eq!(device.prid(), "c1a5b3e7d9f2");
assert_eq!(device.param(), Some("7f3ad0"));
assert!(reach.wake);
}
#[test]
fn an_adopted_instance_identity_is_presented_verbatim() {
let reach = parsed(&[
"register",
"sip:alice@example.com",
"--outbound",
"--instance",
"urn:uuid:00000000-0000-4000-8000-0000000000ab",
])
.expect("a valid combination");
let flow = reach.flow.expect("a flow");
assert_eq!(
flow.instance.urn(),
"urn:uuid:00000000-0000-4000-8000-0000000000ab"
);
}
#[test]
fn half_a_push_pair_is_refused() {
for items in [
&["register", "sip:a@b.c", "--push-provider", "webpush"][..],
&["register", "sip:a@b.c", "--push-prid", "tok"][..],
] {
let error = parsed(items).expect_err("half a pair is not a push service");
assert!(error.contains("--push-provider"), "{error}");
assert!(error.contains("--push-prid"), "{error}");
}
}
#[test]
fn a_push_param_without_the_pair_is_refused() {
let error = parsed(&["register", "sip:a@b.c", "--push-param", "x"])
.expect_err("a param with no service to describe");
assert!(error.contains("--push-provider"), "{error}");
}
#[test]
fn a_wake_without_a_push_service_is_refused() {
let error =
parsed(&["register", "sip:a@b.c", "--wake"]).expect_err("nothing to be woken through");
assert!(error.contains("--push-provider"), "{error}");
}
#[test]
fn an_instance_without_outbound_is_refused() {
let error = parsed(&["register", "sip:a@b.c", "--instance", "urn:uuid:x"])
.expect_err("no flow to name the device for");
assert!(error.contains("--outbound"), "{error}");
}
#[test]
fn an_instance_that_is_not_a_urn_is_refused() {
let error = parsed(&[
"register",
"sip:a@b.c",
"--outbound",
"--instance",
"device-7",
])
.expect_err("§4.1's grammar is instance-val = urn");
assert!(error.contains("--instance"), "{error}");
}
#[test]
fn a_prid_that_a_uri_parameter_cannot_hold_is_refused() {
let error = parsed(&[
"register",
"sip:a@b.c",
"--push-provider",
"webpush",
"--push-prid",
"tok;en",
])
.expect_err("a ';' would start another parameter at the far end");
assert!(error.contains("--push-prid"), "{error}");
}
#[tokio::test]
async fn a_wake_without_push_is_a_usage_error_from_the_command() {
let exit = run(
&[
"register".to_owned(),
"sip:alice@example.com".to_owned(),
"--wake".to_owned(),
],
Format::Text,
)
.await;
assert_eq!(exit.code(), Exit::Usage.code());
}
}