mod advertise;
mod answer;
mod counters;
mod device;
mod dial;
mod header;
mod load;
mod media;
mod output;
mod peers;
mod register;
mod scenario;
mod signalling;
use std::process::ExitCode;
use output::{Exit, Format};
const USAGE: &str = "\
sipx — a command line SIP softphone
USAGE:
sipx <COMMAND> [OPTIONS]
COMMANDS:
register Register with a registrar
dial Place a call
answer Wait for and answer a call
devices List stable audio device identifiers
load Place a finite, reproducible call load
peers List what can be called
scenario Drive a call through correlated NDJSON commands
help Show this message
version Show the version
GLOBAL OPTIONS:
--json Report results as JSON on stdout
-v Log a call's progress to stderr: what was dialled, what answered,
who called, how it ended (INFO)
-vv Add the protocol detail behind it: signalling, transactions, media
(DEBUG, and the most there is — further v's change nothing)
-h, --help Show help for a command
Logging never reaches stdout, which carries results. Repeated v's count by
letter, so -vv and -v -v are the same request.
EXIT CODES:
0 success 3 rejected 5 timeout
1 failed 4 unauthorized 6 busy
2 usage
";
#[tokio::main]
async fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let format = if args.iter().any(|a| a == "--json") {
Format::Json
} else {
Format::Text
};
init_logging(verbosity(&args));
let exit = match args.first().map(String::as_str) {
Some("register") => register::run(&args, format).await,
Some("dial") => dial::run(&args, format).await,
Some("answer") => answer::run(&args, format).await,
Some("devices") => device::list(&args, format),
Some("load") => load::run(&args, format).await,
Some("peers") => peers::run(&args, format),
Some("scenario") => scenario::run(&args).await,
Some("version" | "--version" | "-V") => {
println!("sipx {}", env!("CARGO_PKG_VERSION"));
Exit::Success
}
Some("help" | "--help" | "-h") | None => {
print!("{USAGE}");
Exit::Success
}
Some(unknown) => {
eprint!("{USAGE}");
output::fail(format, Exit::Usage, &format!("unknown command: {unknown}"))
}
};
ExitCode::from(u8::try_from(exit.code()).unwrap_or(1))
}
fn verbosity(args: &[String]) -> usize {
args.iter()
.filter_map(|arg| arg.strip_prefix('-'))
.filter(|cluster| !cluster.is_empty() && cluster.bytes().all(|letter| letter == b'v'))
.map(str::len)
.sum()
}
fn level(verbosity: usize) -> tracing::Level {
match verbosity {
0 => tracing::Level::WARN,
1 => tracing::Level::INFO,
_ => tracing::Level::DEBUG,
}
}
fn init_logging(verbosity: usize) {
let _ = tracing_subscriber::fmt()
.with_max_level(level(verbosity))
.with_writer(std::io::stderr)
.try_init();
}
#[must_use]
pub(crate) fn wants_help(raw: &[String]) -> bool {
raw.iter().any(|arg| arg == "--help" || arg == "-h")
}
pub(crate) fn arguments<'a>(
raw: &'a [String],
help: &str,
format: Format,
) -> Result<Args<'a>, Exit> {
if wants_help(raw) {
print!("{help}");
return Err(Exit::Success);
}
Args::new(raw).map_err(|message| output::fail(format, Exit::Usage, &message))
}
fn apply_capture(args: &Args<'_>, config: &mut sipx_transport::Config) {
if let Some(path) = args.value("capture") {
config.capture = Some(sipx_transport::CaptureConfig::new(path));
}
}
async fn record(
call: &sipx_call::Call,
within: std::time::Duration,
idle: std::time::Duration,
) -> Vec<i16> {
record_media(call.media(), within, idle).await
}
async fn record_media(
media: &sipx_media::MediaSession,
within: std::time::Duration,
idle: std::time::Duration,
) -> Vec<i16> {
let deadline = tokio::time::Instant::now() + within;
let mut recorded = Vec::new();
match tokio::time::timeout_at(deadline, media.recv()).await {
Ok(Some(frame)) => recorded.extend_from_slice(&frame),
Ok(None) | Err(_) => return recorded,
}
loop {
let next = tokio::time::Instant::now() + idle;
match tokio::time::timeout_at(next.min(deadline), media.recv()).await {
Ok(Some(frame)) => recorded.extend_from_slice(&frame),
Ok(None) | Err(_) => return recorded,
}
}
}
const RECORD_IDLE: std::time::Duration = std::time::Duration::from_millis(500);
const DIGIT_GAP: std::time::Duration = std::time::Duration::from_millis(800);
#[derive(Debug)]
pub(crate) struct Args<'a> {
raw: &'a [String],
}
impl<'a> Args<'a> {
pub(crate) fn new(raw: &'a [String]) -> Result<Self, String> {
for (index, arg) in raw.iter().enumerate() {
let Some(body) = arg.strip_prefix("--") else {
continue;
};
let (name, given) = match body.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (body, raw.get(index + 1).map(String::as_str)),
};
let flag = format!("--{name}");
if !VALUED_FLAGS.contains(&flag.as_str()) {
continue;
}
match given {
None => {
return Err(format!(
"{flag} takes a value and nothing followed it. A flag in final position is \
not an absent one — reading it as absent would run this command on a \
default that was not asked for"
));
}
Some("") => {
return Err(format!(
"{flag} takes a value and was given an empty one. No flag here has a \
meaningful empty value, and an unset shell variable expands to exactly \
this, so it is refused rather than read as absent"
));
}
Some(_) => {}
}
if NUMERIC_FLAGS.contains(&flag.as_str()) {
let raw_value = given.unwrap_or_default();
let value = raw_value.parse::<u64>().map_err(|_| {
format!(
"{flag} must be a whole number of seconds from 0 through {}, not \
{raw_value:?}",
u32::MAX
)
})?;
if value > u64::from(u32::MAX) {
return Err(format!(
"{flag} must be a whole number of seconds from 0 through {}, not \
{raw_value:?}",
u32::MAX
));
}
}
}
Ok(Self { raw })
}
#[must_use]
pub(crate) fn value(&self, name: &str) -> Option<&'a str> {
let flag = format!("--{name}");
let mut iter = self.raw.iter();
while let Some(arg) = iter.next() {
if arg == &flag {
return iter.next().map(String::as_str);
}
if let Some(rest) = arg.strip_prefix(&format!("{flag}=")) {
return Some(rest);
}
}
None
}
pub(crate) fn values(&self, name: &str) -> impl Iterator<Item = &'a str> + '_ {
let flag = format!("--{name}");
self.raw.iter().enumerate().filter_map(move |(index, arg)| {
if arg == &flag {
return self.raw.get(index + 1).map(String::as_str);
}
arg.strip_prefix(&format!("{flag}="))
})
}
#[must_use]
pub(crate) fn flag(&self, name: &str) -> bool {
let flag = format!("--{name}");
self.raw.iter().any(|arg| arg == &flag)
}
#[must_use]
pub(crate) fn positional(&self) -> Option<&'a str> {
let mut skip_next = false;
for (index, arg) in self.raw.iter().enumerate() {
if index == 0 {
continue; }
if skip_next {
skip_next = false;
continue;
}
if arg.starts_with('-') {
skip_next = !arg.contains('=') && VALUED_FLAGS.iter().any(|f| arg == f);
continue;
}
return Some(arg);
}
None
}
#[must_use]
pub(crate) fn number(&self, name: &str) -> Option<u64> {
self.value(name)?.parse().ok()
}
}
const VALUED_FLAGS: &[&str] = &[
"--password",
"--play",
"--record",
"--duration",
"--timeout",
"--wait",
"--dtmf",
"--from",
"--expires",
"--local",
"--target",
"--book",
"--instance",
"--push-provider",
"--push-prid",
"--push-param",
"--capture",
"--counters",
"--transport",
"--tls-server-name",
"--tls-ca",
"--tls-cert",
"--tls-key",
"--codec",
"--media-security",
"--ice",
"--stun-server",
"--audio-input",
"--audio-output",
"--header",
"--rate",
"--concurrency",
"--calls",
"--seed",
"--call-duration",
];
const NUMERIC_FLAGS: &[&str] = &[
"--duration",
"--timeout",
"--wait",
"--expires",
"--call-duration",
];
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn args(items: &[&str]) -> Vec<String> {
items.iter().map(|s| (*s).to_owned()).collect()
}
fn parsed(raw: &[String]) -> Args<'_> {
Args::new(raw).expect("a well formed argument list")
}
#[test]
fn a_repeated_v_is_counted_by_letter_so_the_spellings_agree() {
assert_eq!(verbosity(&args(&["dial", "sip:a@b"])), 0);
assert_eq!(verbosity(&args(&["dial", "-v"])), 1);
assert_eq!(verbosity(&args(&["dial", "-vv"])), 2);
assert_eq!(verbosity(&args(&["dial", "-v", "-v"])), 2);
assert_eq!(verbosity(&args(&["dial", "-v", "-vv"])), 3);
assert_eq!(
verbosity(&args(&["dial", "-vvv"])),
verbosity(&args(&["dial", "-v", "-v", "-v"])),
"the two spellings of three must not disagree"
);
}
#[test]
fn only_a_cluster_of_vs_is_verbosity() {
assert_eq!(verbosity(&args(&["version", "-V"])), 0);
assert_eq!(verbosity(&args(&["dial", "--verbose"])), 0);
assert_eq!(verbosity(&args(&["dial", "-vx"])), 0);
assert_eq!(verbosity(&args(&["dial", "-"])), 0);
}
#[test]
fn the_ladder_climbs_one_level_per_v_and_stops_at_debug() {
assert_eq!(level(0), tracing::Level::WARN);
assert_eq!(level(1), tracing::Level::INFO);
assert_eq!(level(2), tracing::Level::DEBUG);
assert_eq!(level(3), tracing::Level::DEBUG);
assert_eq!(level(9), tracing::Level::DEBUG);
}
#[test]
fn a_flag_value_is_read_in_either_form() {
let raw = args(&["dial", "--password", "secret", "sip:a@b"]);
assert_eq!(parsed(&raw).value("password"), Some("secret"));
let raw = args(&["dial", "--password=secret", "sip:a@b"]);
assert_eq!(parsed(&raw).value("password"), Some("secret"));
}
#[test]
fn a_missing_flag_reads_as_absent() {
let raw = args(&["dial", "sip:a@b"]);
assert_eq!(parsed(&raw).value("password"), None);
assert!(!parsed(&raw).flag("json"));
}
#[test]
fn a_flag_value_is_not_mistaken_for_the_positional() {
let raw = args(&["dial", "--password", "secret", "sip:bob@example.com"]);
assert_eq!(parsed(&raw).positional(), Some("sip:bob@example.com"));
let raw = args(&["dial", "sip:bob@example.com", "--password", "secret"]);
assert_eq!(parsed(&raw).positional(), Some("sip:bob@example.com"));
let raw = args(&["dial", "--json", "sip:bob@example.com"]);
assert_eq!(parsed(&raw).positional(), Some("sip:bob@example.com"));
}
#[test]
fn every_valued_flag_in_the_help_text_is_registered() {
let help = format!(
"{}{}{}{}{}{}{}",
USAGE,
crate::register::HELP,
crate::dial::HELP,
crate::answer::HELP,
crate::peers::HELP,
crate::load::HELP,
crate::scenario::HELP
);
let mut documented = Vec::new();
for line in help.lines() {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix("--") else {
continue;
};
let Some((flag, tail)) = rest.split_once(char::is_whitespace) else {
continue;
};
if tail.trim_start().starts_with('<') {
documented.push(format!("--{flag}"));
}
}
assert!(
!documented.is_empty(),
"the help text lists no valued flags"
);
for flag in documented {
assert!(
VALUED_FLAGS.contains(&flag.as_str()),
"{flag} takes a value but is missing from VALUED_FLAGS, so its value would be \
read as the positional argument"
);
}
}
#[test]
fn every_valued_flag_is_refused_when_it_is_given_no_value() {
for flag in VALUED_FLAGS.iter().copied() {
let raw = args(&["register", flag]);
let error =
Args::new(&raw).expect_err("a valued flag in final position was given no value");
assert!(
error.contains(flag),
"the refusal must name {flag}: {error}"
);
let joined = format!("{flag}=");
for items in [
&["register", joined.as_str(), "sip:a@b.c"][..],
&["register", flag, "", "sip:a@b.c"][..],
] {
let raw = args(items);
let error = Args::new(&raw).expect_err("an empty value is not a value");
assert!(
error.contains(flag),
"the refusal must name {flag}: {error}"
);
}
}
}
#[test]
fn a_valueless_flag_is_untouched_by_the_rule() {
let raw = args(&["register", "sip:a@b.c", "--outbound", "--tcp"]);
assert!(parsed(&raw).flag("tcp"));
let raw = args(&["dial", "sip:a@b.c", "--json"]);
assert!(parsed(&raw).flag("json"));
let raw = args(&["dial", "sip:a@b.c", "--json="]);
assert!(Args::new(&raw).is_ok());
}
#[test]
fn an_equals_in_the_positional_is_not_a_flag() {
let raw = args(&["register", "sip:alice@example.com;transport=tcp"]);
assert_eq!(
parsed(&raw).positional(),
Some("sip:alice@example.com;transport=tcp")
);
}
#[test]
fn a_timeout_before_the_uri_does_not_become_the_uri() {
let raw = args(&["dial", "--timeout", "30", "sip:bob@192.0.2.1:5060"]);
assert_eq!(parsed(&raw).positional(), Some("sip:bob@192.0.2.1:5060"));
assert_eq!(parsed(&raw).value("timeout"), Some("30"));
let raw = args(&["answer", "--wait", "20", "--json"]);
assert_eq!(
parsed(&raw).positional(),
None,
"answer takes no positional"
);
}
#[test]
fn a_numeric_option_is_validated_before_it_can_be_read() {
let raw = args(&["dial", "--duration", "30"]);
assert_eq!(parsed(&raw).number("duration"), Some(30));
let raw = args(&["dial", "--duration", "thirty"]);
let error = Args::new(&raw).expect_err("a non-number is not an absent flag");
assert!(error.contains("--duration"), "{error}");
assert!(error.contains("whole number"), "{error}");
}
#[test]
fn every_seconds_flag_is_registered_as_numeric() {
let help = format!(
"{}{}{}{}{}{}{}",
USAGE,
crate::register::HELP,
crate::dial::HELP,
crate::answer::HELP,
crate::peers::HELP,
crate::load::HELP,
crate::scenario::HELP
);
let documented: Vec<String> = help
.lines()
.filter_map(|line| {
let rest = line.trim_start().strip_prefix("--")?;
let (flag, tail) = rest.split_once(char::is_whitespace)?;
tail.trim_start()
.starts_with("<S>")
.then(|| format!("--{flag}"))
})
.collect();
assert!(
!documented.is_empty(),
"the help documents no seconds flags"
);
for flag in &documented {
assert!(
NUMERIC_FLAGS.contains(&flag.as_str()),
"{flag} is documented as seconds but is not validated as numeric"
);
}
for flag in NUMERIC_FLAGS {
assert!(
documented.iter().any(|item| item == flag),
"{flag} is validated as numeric but not documented with <S>"
);
}
}
#[test]
fn numeric_boundaries_are_the_declared_ones_for_every_seconds_flag() {
for flag in NUMERIC_FLAGS {
for accepted in ["0", "4294967295"] {
let raw = args(&["answer", flag, accepted]);
assert!(Args::new(&raw).is_ok(), "{flag} must accept {accepted}");
}
for refused in ["-1", "4294967296", "18446744073709551616"] {
let raw = args(&["answer", flag, refused]);
let error = Args::new(&raw).expect_err("outside the declared numeric domain");
assert!(error.contains(flag), "{error}");
}
}
}
}