use std::io::Write;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use crate::counters::Meters;
use crate::target::TransportKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HepConfig {
pub collector: SocketAddr,
pub capture_id: u32,
}
impl HepConfig {
#[must_use]
pub const fn new(collector: SocketAddr, capture_id: u32) -> Self {
Self {
collector,
capture_id,
}
}
}
#[derive(Debug, Clone)]
pub struct CaptureConfig {
pub path: PathBuf,
pub redact: bool,
pub queue: usize,
pub hep: Option<HepConfig>,
}
impl CaptureConfig {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
redact: true,
queue: 1024,
hep: None,
}
}
#[must_use]
pub fn with_hep(mut self, hep: HepConfig) -> Self {
self.hep = Some(hep);
self
}
#[must_use]
pub fn without_redaction(mut self) -> Self {
self.redact = false;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
In,
Out,
}
impl Direction {
const fn as_str(self) -> &'static str {
match self {
Self::In => "in",
Self::Out => "out",
}
}
}
#[derive(Debug)]
struct Record {
seq: u64,
at: SystemTime,
local: SocketAddr,
peer: SocketAddr,
transport: TransportKind,
direction: Direction,
bytes: Bytes,
redacted: bool,
}
#[derive(Debug)]
pub(crate) struct Capture {
records: std::sync::mpsc::SyncSender<Record>,
seq: u64,
redact: bool,
failed: Arc<AtomicBool>,
}
impl Capture {
pub(crate) fn start(config: &CaptureConfig, meters: Arc<Meters>) -> std::io::Result<Self> {
let file = std::fs::File::create(&config.path)?;
let mut writer = std::io::BufWriter::new(file);
write_section_header(&mut writer)?;
writer.flush()?;
let (records, incoming) = std::sync::mpsc::sync_channel::<Record>(config.queue.max(1));
let failed = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&failed);
let path = config.path.clone();
let hep = config.hep;
std::thread::Builder::new()
.name("sipx-capture".to_owned())
.spawn(move || {
write_loop(&mut writer, &incoming, &meters, &flag, &path, hep);
})?;
Ok(Self {
records,
seq: 0,
redact: config.redact,
failed,
})
}
fn is_failed(&self) -> bool {
self.failed.load(Ordering::Relaxed)
}
}
fn write_loop(
writer: &mut std::io::BufWriter<std::fs::File>,
incoming: &std::sync::mpsc::Receiver<Record>,
meters: &Meters,
failed: &AtomicBool,
path: &Path,
hep: Option<HepConfig>,
) {
let mut hep = hep.map(HepExporter::new);
while let Ok(record) = incoming.recv() {
if let Err(error) = write_packet(writer, &record).and_then(|()| writer.flush()) {
meters.capture_error();
failed.store(true, Ordering::Relaxed);
tracing::error!(
%error,
path = %path.display(),
"capture write failed; the capture is now off"
);
return;
}
if let Some(exporter) = hep.as_mut() {
exporter.export(&record, meters);
}
}
if let Err(error) = writer.flush() {
meters.capture_error();
tracing::warn!(%error, path = %path.display(), "capture could not be flushed at shutdown");
}
}
struct HepExporter {
socket: Option<std::net::UdpSocket>,
config: HepConfig,
warned: bool,
}
impl HepExporter {
fn new(config: HepConfig) -> Self {
let bind = if config.collector.is_ipv4() {
"0.0.0.0:0"
} else {
"[::]:0"
};
let socket = std::net::UdpSocket::bind(bind)
.and_then(|socket| {
socket.connect(config.collector)?;
socket.set_nonblocking(true)?;
Ok(socket)
})
.map_err(|error| {
tracing::warn!(
%error,
collector = %config.collector,
"HEP collector is unavailable; signalling capture will continue locally"
);
})
.ok();
let warned = socket.is_none();
Self {
socket,
config,
warned,
}
}
fn export(&mut self, record: &Record, meters: &Meters) {
let sent = encode_hep(record, self.config.capture_id).and_then(|datagram| {
let socket = self.socket.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotConnected,
"HEP collector socket is unavailable",
)
})?;
let written = socket.send(&datagram)?;
if written == datagram.len() {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::WriteZero,
"HEP datagram was not sent in full",
))
}
});
match sent {
Ok(()) => meters.capture_hep_record(),
Err(error) => {
meters.capture_hep_drop();
if self.warned {
tracing::debug!(
%error,
collector = %self.config.collector,
"dropping HEP signalling export"
);
} else {
self.warned = true;
tracing::warn!(
%error,
collector = %self.config.collector,
"dropping HEP signalling export; calls and local capture continue"
);
}
}
}
}
}
fn hep_chunk(out: &mut Vec<u8>, kind: u16, value: &[u8]) -> std::io::Result<()> {
let length = u16::try_from(6usize.saturating_add(value.len()))
.map_err(|_| std::io::Error::other("HEP chunk is too large"))?;
out.extend_from_slice(&0u16.to_be_bytes());
out.extend_from_slice(&kind.to_be_bytes());
out.extend_from_slice(&length.to_be_bytes());
out.extend_from_slice(value);
Ok(())
}
fn encode_hep(record: &Record, capture_id: u32) -> std::io::Result<Vec<u8>> {
let (source, destination) = match record.direction {
Direction::In => (record.peer, record.local),
Direction::Out => (record.local, record.peer),
};
let protocol = match record.transport {
TransportKind::Udp | TransportKind::Quic => 17,
TransportKind::Tcp | TransportKind::Tls | TransportKind::Ws | TransportKind::Wss => 6,
};
let mut ordered = Vec::with_capacity(record.bytes.len().saturating_add(128));
match (source.ip(), destination.ip()) {
(IpAddr::V4(from), IpAddr::V4(to)) => {
hep_chunk(&mut ordered, 0x0001, &[2])?;
hep_chunk(&mut ordered, 0x0002, &[protocol])?;
hep_chunk(&mut ordered, 0x0003, &from.octets())?;
hep_chunk(&mut ordered, 0x0004, &to.octets())?;
}
(IpAddr::V6(from), IpAddr::V6(to)) => {
hep_chunk(&mut ordered, 0x0001, &[10])?;
hep_chunk(&mut ordered, 0x0002, &[protocol])?;
hep_chunk(&mut ordered, 0x0005, &from.octets())?;
hep_chunk(&mut ordered, 0x0006, &to.octets())?;
}
_ => {
return Err(std::io::Error::other(
"HEP endpoints use different IP families",
));
}
}
hep_chunk(&mut ordered, 0x0007, &source.port().to_be_bytes())?;
hep_chunk(&mut ordered, 0x0008, &destination.port().to_be_bytes())?;
let since = record.at.duration_since(UNIX_EPOCH).unwrap_or_default();
let seconds = u32::try_from(since.as_secs() & u64::from(u32::MAX)).unwrap_or(0);
hep_chunk(&mut ordered, 0x0009, &seconds.to_be_bytes())?;
hep_chunk(&mut ordered, 0x000a, &since.subsec_micros().to_be_bytes())?;
hep_chunk(&mut ordered, 0x000b, &[1])?;
hep_chunk(&mut ordered, 0x000c, &capture_id.to_be_bytes())?;
hep_chunk(&mut ordered, 0x000f, &record.bytes)?;
let total = u16::try_from(6usize.saturating_add(ordered.len()))
.map_err(|_| std::io::Error::other("HEP datagram is too large"))?;
let mut datagram = Vec::with_capacity(usize::from(total));
datagram.extend_from_slice(b"HEP3");
datagram.extend_from_slice(&total.to_be_bytes());
datagram.extend_from_slice(&ordered);
Ok(datagram)
}
const BLOCK_SECTION_HEADER: u32 = 0x0A0D_0D0A;
const BLOCK_INTERFACE: u32 = 0x0000_0001;
const BLOCK_PACKET: u32 = 0x0000_0006;
const BYTE_ORDER_MAGIC: u32 = 0x1A2B_3C4D;
const LINKTYPE_RAW: u16 = 101;
const OPT_COMMENT: u16 = 1;
const OPT_TSRESOL: u16 = 9;
const OPT_END: u16 = 0;
const TSRESOL_NANOS: u8 = 9;
const IP_PROTO_UDP: u8 = 17;
const fn padded(len: usize) -> usize {
len.next_multiple_of(4)
}
fn padding(len: usize) -> &'static [u8] {
const ZEROS: [u8; 3] = [0; 3];
ZEROS.get(..padded(len).saturating_sub(len)).unwrap_or(&[])
}
fn write_block(out: &mut impl Write, kind: u32, body: &[u8]) -> std::io::Result<()> {
let total = u32::try_from(12usize.saturating_add(padded(body.len())))
.map_err(|_| std::io::Error::other("capture block too large"))?;
out.write_all(&kind.to_ne_bytes())?;
out.write_all(&total.to_ne_bytes())?;
out.write_all(body)?;
out.write_all(padding(body.len()))?;
out.write_all(&total.to_ne_bytes())
}
fn push_option(body: &mut Vec<u8>, code: u16, value: &[u8]) {
body.extend_from_slice(&code.to_ne_bytes());
let len = u16::try_from(value.len()).unwrap_or(u16::MAX);
body.extend_from_slice(&len.to_ne_bytes());
let value = value.get(..usize::from(len)).unwrap_or(value);
body.extend_from_slice(value);
body.extend_from_slice(padding(value.len()));
}
fn write_section_header(out: &mut impl Write) -> std::io::Result<()> {
let mut shb = Vec::new();
shb.extend_from_slice(&BYTE_ORDER_MAGIC.to_ne_bytes());
shb.extend_from_slice(&1u16.to_ne_bytes()); shb.extend_from_slice(&0u16.to_ne_bytes()); shb.extend_from_slice(&(-1i64).to_ne_bytes()); push_option(&mut shb, OPT_COMMENT, b"sipx signalling capture");
push_option(&mut shb, OPT_END, &[]);
write_block(out, BLOCK_SECTION_HEADER, &shb)?;
let mut idb = Vec::new();
idb.extend_from_slice(&LINKTYPE_RAW.to_ne_bytes());
idb.extend_from_slice(&0u16.to_ne_bytes()); idb.extend_from_slice(&0u32.to_ne_bytes()); push_option(&mut idb, OPT_TSRESOL, &[TSRESOL_NANOS]);
push_option(&mut idb, OPT_END, &[]);
write_block(out, BLOCK_INTERFACE, &idb)
}
fn write_packet(out: &mut impl Write, record: &Record) -> std::io::Result<()> {
let packet = synthesise(record);
let nanos = record
.at
.duration_since(UNIX_EPOCH)
.map_or(0u128, |since| since.as_nanos());
let timestamp = u64::try_from(nanos).unwrap_or(u64::MAX);
let len = u32::try_from(packet.len())
.map_err(|_| std::io::Error::other("captured message too large"))?;
let mut body = Vec::with_capacity(packet.len() + 64);
body.extend_from_slice(&0u32.to_ne_bytes()); body.extend_from_slice(&((timestamp >> 32) as u32).to_ne_bytes());
#[allow(
clippy::cast_possible_truncation,
reason = "the low half of the timestamp is exactly what this field is"
)]
body.extend_from_slice(&(timestamp as u32).to_ne_bytes());
body.extend_from_slice(&len.to_ne_bytes()); body.extend_from_slice(&len.to_ne_bytes()); body.extend_from_slice(&packet);
body.extend_from_slice(padding(packet.len()));
push_option(&mut body, OPT_COMMENT, comment(record).as_bytes());
push_option(&mut body, OPT_END, &[]);
write_block(out, BLOCK_PACKET, &body)
}
fn comment(record: &Record) -> String {
let mut comment = format!(
"seq={} dir={} transport={} local={} peer={}",
record.seq,
record.direction.as_str(),
record.transport.as_str(),
record.local,
record.peer,
);
if record.transport.is_secure() {
comment.push_str(" decrypted-in-process=yes");
}
if record.redacted {
comment.push_str(" redacted=yes");
}
comment
}
fn synthesise(record: &Record) -> Vec<u8> {
let (source, destination) = match record.direction {
Direction::In => (record.peer, record.local),
Direction::Out => (record.local, record.peer),
};
let payload = &record.bytes;
let udp_len = u16::try_from(8usize.saturating_add(payload.len())).unwrap_or(u16::MAX);
let mut packet = Vec::with_capacity(40 + 8 + payload.len());
match (source.ip(), destination.ip()) {
(IpAddr::V4(from), IpAddr::V4(to)) => {
let total =
u16::try_from(20usize.saturating_add(usize::from(udp_len))).unwrap_or(u16::MAX);
let mut header = Vec::with_capacity(20);
header.push(0x45); header.push(0); header.extend_from_slice(&total.to_be_bytes());
header.extend_from_slice(&0u16.to_be_bytes()); header.extend_from_slice(&0u16.to_be_bytes()); header.push(64); header.push(IP_PROTO_UDP);
header.extend_from_slice(&0u16.to_be_bytes()); header.extend_from_slice(&from.octets());
header.extend_from_slice(&to.octets());
let checksum = ones_complement(&header);
if let Some(slot) = header.get_mut(10..12) {
slot.copy_from_slice(&checksum.to_be_bytes());
}
packet.extend_from_slice(&header);
}
(IpAddr::V6(from), IpAddr::V6(to)) => {
packet.extend_from_slice(&0x6000_0000u32.to_be_bytes()); packet.extend_from_slice(&udp_len.to_be_bytes()); packet.push(IP_PROTO_UDP);
packet.push(64); packet.extend_from_slice(&from.octets());
packet.extend_from_slice(&to.octets());
}
_ => {}
}
packet.extend_from_slice(&source.port().to_be_bytes());
packet.extend_from_slice(&destination.port().to_be_bytes());
packet.extend_from_slice(&udp_len.to_be_bytes());
packet.extend_from_slice(&0u16.to_be_bytes());
packet.extend_from_slice(payload);
packet
}
fn ones_complement(header: &[u8]) -> u16 {
let mut sum: u32 = 0;
for pair in header.chunks(2) {
let high = u32::from(pair.first().copied().unwrap_or(0));
let low = u32::from(pair.get(1).copied().unwrap_or(0));
sum = sum.wrapping_add((high << 8) | low);
}
while sum >> 16 != 0 {
sum = (sum & 0xFFFF).wrapping_add(sum >> 16);
}
#[allow(
clippy::cast_possible_truncation,
reason = "the fold above leaves at most sixteen significant bits"
)]
let folded = sum as u16;
!folded
}
const REDACTED_PARAMS: &[&[u8]] = &[
b"response",
b"nextnonce",
b"rspauth",
b"pn-prid",
b"pn-param",
b"+sip.instance",
];
const AUTH_HEADERS: &[&[u8]] = &[
b"authorization",
b"proxy-authorization",
b"authentication-info",
b"proxy-authenticate",
b"www-authenticate",
];
const CONTACT_HEADERS: &[&[u8]] = &[b"contact", b"m"];
const OPAQUE_SCHEMES: &[&[u8]] = &[b"bearer", b"basic"];
const REDACTION: &[u8] = b"REDACTED";
struct Line<'a> {
text: &'a [u8],
terminator: &'a [u8],
}
fn lines(message: &[u8]) -> Vec<Line<'_>> {
let mut out = Vec::new();
let mut start = 0usize;
let mut at = 0usize;
while at < message.len() {
let width = match message.get(at) {
Some(b'\r') if message.get(at.saturating_add(1)) == Some(&b'\n') => 2,
Some(b'\r' | b'\n') => 1,
_ => 0,
};
if width == 0 {
at = at.saturating_add(1);
continue;
}
let end = at.saturating_add(width);
out.push(Line {
text: message.get(start..at).unwrap_or(&[]),
terminator: message.get(at..end).unwrap_or(&[]),
});
at = end;
start = end;
}
if start < message.len() {
out.push(Line {
text: message.get(start..).unwrap_or(&[]),
terminator: &[],
});
}
out
}
fn is_wsp(byte: u8) -> bool {
byte == b' ' || byte == b'\t'
}
fn is_continuation(line: &[u8]) -> bool {
line.first().copied().is_some_and(is_wsp)
}
fn unfold(physical: &[Line<'_>], from: usize, to: usize, separator: &[u8]) -> Vec<u8> {
let mut logical = Vec::new();
for index in from..to {
let Some(line) = physical.get(index) else {
continue;
};
if index == from {
logical.extend_from_slice(line.text);
} else {
logical.extend_from_slice(separator);
logical.extend_from_slice(line.text.trim_ascii_start());
}
}
logical
}
fn header_name(line: &[u8]) -> Option<Vec<u8>> {
let at = line.iter().position(|byte| *byte == b':')?;
Some(line.get(..at)?.trim_ascii_end().to_ascii_lowercase())
}
pub(crate) fn redact(message: &[u8]) -> Option<Bytes> {
let physical = lines(message);
let mut out: Vec<u8> = Vec::with_capacity(message.len().saturating_add(16));
let mut changed = false;
let mut in_body = false;
let mut index = 0usize;
while index < physical.len() {
let Some(line) = physical.get(index) else {
break;
};
if in_body {
match redact_body_line(line.text) {
Some(redacted) => {
changed = true;
out.extend_from_slice(&redacted);
}
None => out.extend_from_slice(line.text),
}
out.extend_from_slice(line.terminator);
index = index.saturating_add(1);
continue;
}
if line.text.is_empty() {
in_body = true;
out.extend_from_slice(line.terminator);
index = index.saturating_add(1);
continue;
}
let mut end = index.saturating_add(1);
while physical
.get(end)
.is_some_and(|next| is_continuation(next.text))
{
end = end.saturating_add(1);
}
let redacted = redact_header(&unfold(&physical, index, end, b" ")).or_else(|| {
(end.saturating_sub(index) > 1)
.then(|| redact_header(&unfold(&physical, index, end, b"")))
.flatten()
});
match redacted {
Some(redacted) => {
changed = true;
out.extend_from_slice(&redacted);
out.extend_from_slice(b"\r\n");
}
None => {
for at in index..end {
if let Some(original) = physical.get(at) {
out.extend_from_slice(original.text);
out.extend_from_slice(original.terminator);
}
}
}
}
index = end;
}
changed.then(|| Bytes::from(out))
}
fn redact_header(line: &[u8]) -> Option<Vec<u8>> {
match header_name(line) {
Some(name) if AUTH_HEADERS.contains(&name.as_slice()) => redact_auth_header(line),
Some(name) if CONTACT_HEADERS.contains(&name.as_slice()) => redact_params(line, false),
Some(_) => None,
None => redact_params(line, false),
}
}
fn redact_auth_header(line: &[u8]) -> Option<Vec<u8>> {
let colon = line.iter().position(|byte| *byte == b':')?;
let after_colon = colon.saturating_add(1);
let value = line.get(after_colon..).unwrap_or(&[]);
let lead = value
.iter()
.position(|byte| !is_wsp(*byte))
.unwrap_or(value.len());
let token = value.get(lead..).unwrap_or(&[]);
let width = token
.iter()
.position(|byte| is_wsp(*byte))
.unwrap_or(token.len());
let scheme = token.get(..width).unwrap_or(&[]).to_ascii_lowercase();
let rest_at = after_colon.saturating_add(lead).saturating_add(width);
let rest = line.get(rest_at..).unwrap_or(&[]);
let opaque = OPAQUE_SCHEMES.contains(&scheme.as_slice())
|| (!scheme.is_empty() && !rest.trim_ascii().is_empty() && !rest.contains(&b'='));
if opaque {
let mut redacted = Vec::with_capacity(line.len());
redacted.extend_from_slice(line.get(..rest_at).unwrap_or(&[]));
redacted.push(b' ');
redacted.extend_from_slice(REDACTION);
return Some(redacted);
}
redact_params(line, false)
}
fn redact_params(line: &[u8], preserve_len: bool) -> Option<Vec<u8>> {
let mut out = line.to_vec();
let mut changed = false;
for name in REDACTED_PARAMS {
while let Some(replaced) = redact_param(&out, name, preserve_len) {
out = replaced;
changed = true;
}
}
changed.then_some(out)
}
fn replacement(width: usize, preserve_len: bool) -> Vec<u8> {
if !preserve_len {
return REDACTION.to_vec();
}
let mut padded = Vec::with_capacity(width);
padded.extend_from_slice(REDACTION.get(..width.min(REDACTION.len())).unwrap_or(&[]));
while padded.len() < width {
padded.push(b'X');
}
padded
}
fn redact_param(line: &[u8], name: &[u8], preserve_len: bool) -> Option<Vec<u8>> {
let mut from = 0usize;
loop {
let at = find_ci(line, name, from)?;
let before_ok = at == 0
|| line
.get(at.wrapping_sub(1))
.is_some_and(|byte| matches!(byte, b',' | b';' | b' ' | b'\t' | b'"' | b'='));
let mut cursor = at.saturating_add(name.len());
while line.get(cursor).is_some_and(u8::is_ascii_whitespace) {
cursor = cursor.saturating_add(1);
}
if !before_ok || line.get(cursor) != Some(&b'=') {
from = at.saturating_add(1);
continue;
}
cursor = cursor.saturating_add(1);
while line.get(cursor).is_some_and(u8::is_ascii_whitespace) {
cursor = cursor.saturating_add(1);
}
let quoted = line.get(cursor) == Some(&b'"');
let value_start = if quoted {
cursor.saturating_add(1)
} else {
cursor
};
let mut end = value_start;
while let Some(&byte) = line.get(end) {
if quoted {
if byte == b'\\' && line.get(end.saturating_add(1)).is_some() {
end = end.saturating_add(2);
continue;
}
if byte == b'"' {
break;
}
} else if matches!(byte, b',' | b';' | b' ' | b'\t') {
break;
}
end = end.saturating_add(1);
}
let value = line.get(value_start..end).unwrap_or(&[]);
if value == replacement(value.len(), preserve_len).as_slice() || value.is_empty() {
from = end.max(at.saturating_add(1));
continue;
}
let mut out = Vec::with_capacity(line.len());
out.extend_from_slice(line.get(..value_start).unwrap_or(&[]));
out.extend_from_slice(&replacement(value.len(), preserve_len));
out.extend_from_slice(line.get(end..).unwrap_or(&[]));
return Some(out);
}
}
fn redact_body_line(line: &[u8]) -> Option<Vec<u8>> {
if starts_with_ci(line, b"a=crypto:") {
return redact_inline_keys(line);
}
if starts_with_ci(line, b"k=") {
return redact_sdp_key(line);
}
match header_name(line) {
Some(name)
if AUTH_HEADERS.contains(&name.as_slice())
|| CONTACT_HEADERS.contains(&name.as_slice()) =>
{
redact_params(line, true)
}
_ => None,
}
}
fn redact_inline_keys(line: &[u8]) -> Option<Vec<u8>> {
const INLINE: &[u8] = b"inline:";
let mut out: Vec<u8> = Vec::with_capacity(line.len());
let mut cursor = 0usize;
let mut changed = false;
while let Some(found) = find_from(line, INLINE, cursor) {
let value_start = found.saturating_add(INLINE.len());
let mut end = value_start;
while let Some(&byte) = line.get(end) {
if matches!(byte, b'|' | b' ' | b'\t' | b';') {
break;
}
end = end.saturating_add(1);
}
let width = end.saturating_sub(value_start);
out.extend_from_slice(line.get(cursor..value_start).unwrap_or(&[]));
if width > 0 {
out.extend_from_slice(&replacement(width, true));
changed = true;
}
cursor = end;
}
out.extend_from_slice(line.get(cursor..).unwrap_or(&[]));
changed.then_some(out)
}
fn redact_sdp_key(line: &[u8]) -> Option<Vec<u8>> {
let colon = line.iter().position(|byte| *byte == b':')?;
let value_start = colon.saturating_add(1);
let width = line.len().saturating_sub(value_start);
if width == 0 {
return None;
}
let mut out = Vec::with_capacity(line.len());
out.extend_from_slice(line.get(..value_start).unwrap_or(&[]));
out.extend_from_slice(&replacement(width, true));
Some(out)
}
fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
(from..=haystack.len().saturating_sub(needle.len()))
.find(|&at| haystack.get(at..at.saturating_add(needle.len())) == Some(needle))
}
fn find_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
(from..=haystack.len().saturating_sub(needle.len())).find(|&at| {
haystack
.get(at..at.saturating_add(needle.len()))
.is_some_and(|window| window.eq_ignore_ascii_case(needle))
})
}
fn starts_with_ci(haystack: &[u8], prefix: &[u8]) -> bool {
haystack
.get(..prefix.len())
.is_some_and(|window| window.eq_ignore_ascii_case(prefix))
}
impl Capture {
pub(crate) fn observe_if_capturing(
capture: Option<&mut Self>,
meters: &Meters,
local: SocketAddr,
peer: SocketAddr,
transport: TransportKind,
direction: Direction,
bytes: impl FnOnce() -> Bytes,
) {
let Some(capture) = capture else {
return;
};
if capture.is_failed() {
return;
}
capture.observe(meters, &bytes(), local, peer, transport, direction);
}
fn observe(
&mut self,
meters: &Meters,
bytes: &Bytes,
local: SocketAddr,
peer: SocketAddr,
transport: TransportKind,
direction: Direction,
) {
let (bytes, redacted) = if self.redact {
match redact(bytes) {
Some(clean) => (clean, true),
None => (bytes.clone(), false),
}
} else {
(bytes.clone(), false)
};
self.seq = self.seq.saturating_add(1);
let record = Record {
seq: self.seq,
at: SystemTime::now(),
local,
peer,
transport,
direction,
bytes,
redacted,
};
match self.records.try_send(record) {
Ok(()) => meters.capture_record(),
Err(_) => meters.capture_drop(),
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[test]
fn a_digest_response_is_removed_and_the_challenge_is_kept() {
let message = b"REGISTER sip:example.net SIP/2.0\r\n\
Authorization: Digest username=\"alice\", realm=\"example.net\", \
nonce=\"abc123\", uri=\"sip:example.net\", response=\"deadbeefcafe0001\", qop=auth\r\n\
Content-Length: 0\r\n\r\n";
let redacted = redact(message).expect("a digest response must be redacted");
let out = text(&redacted);
assert!(
!out.contains("deadbeefcafe0001"),
"the digest response is still in the capture: {out}"
);
assert!(out.contains("response=\"REDACTED\""), "{out}");
assert!(out.contains("realm=\"example.net\""), "{out}");
assert!(out.contains("nonce=\"abc123\""), "{out}");
assert!(out.contains("username=\"alice\""), "{out}");
assert!(out.contains("qop=auth"), "{out}");
assert!(
out.starts_with("REGISTER sip:example.net SIP/2.0\r\n"),
"{out}"
);
}
#[test]
fn an_srtp_key_is_removed_without_changing_the_body_length() {
let message = b"INVITE sip:bob@example.net SIP/2.0\r\n\
Content-Type: application/sdp\r\n\
Content-Length: 68\r\n\r\n\
v=0\r\n\
a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:d0RmdmcmVCspeEc3QGZiNWpVLFJhQX1cfHAwJSoj|2^20|1:32\r\n";
let redacted = redact(message).expect("an SRTP key must be redacted");
let out = text(&redacted);
assert!(
!out.contains("d0RmdmcmVCspeEc3QGZiNWpVLFJhQX1cfHAwJSoj"),
"the SRTP master key is still in the capture: {out}"
);
assert!(
out.contains("a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:"),
"{out}"
);
assert!(out.contains("AES_CM_128_HMAC_SHA1_80"), "{out}");
assert!(
out.contains("|2^20|1:32"),
"the lifetime and MKI are not secret: {out}"
);
assert_eq!(
redacted.len(),
message.len(),
"body redaction must preserve length or Content-Length becomes a lie"
);
}
#[test]
fn a_push_token_and_an_instance_urn_are_removed() {
let message = b"REGISTER sip:example.net SIP/2.0\r\n\
Contact: <sip:alice@192.0.2.4>;+sip.instance=\"<urn:uuid:00000000-0000-0000-0000-00000000042>\";\
pn-provider=apns;pn-prid=SECRETTOKEN;pn-param=SECRETPARAM\r\n\
Content-Length: 0\r\n\r\n";
let out = text(&redact(message).expect("push credentials must be redacted"));
assert!(!out.contains("SECRETTOKEN"), "{out}");
assert!(!out.contains("SECRETPARAM"), "{out}");
assert!(!out.contains("urn:uuid:00000000"), "{out}");
assert!(out.contains("pn-provider=apns"), "{out}");
assert!(out.contains("sip:alice@192.0.2.4"), "{out}");
}
#[test]
fn redaction_keeps_what_makes_a_message_diagnosable() {
let message = b"INVITE sip:bob@example.net SIP/2.0\r\n\
Via: SIP/2.0/UDP 192.0.2.4:5060;branch=z9hG4bKtrace\r\n\
From: \"Alice Example\" <sip:alice@example.net>;tag=abcd\r\n\
To: <sip:bob@example.net>\r\n\
Call-ID: trace@sipx\r\n\
CSeq: 1 INVITE\r\n\
Authorization: Digest response=\"secret\"\r\n\
Content-Length: 0\r\n\r\n";
let out = text(&redact(message).expect("the digest response is redacted"));
for kept in [
"z9hG4bKtrace",
"Alice Example",
"sip:alice@example.net",
"sip:bob@example.net",
"trace@sipx",
"CSeq: 1 INVITE",
] {
assert!(
out.contains(kept),
"redaction removed {kept}, which it needs: {out}"
);
}
assert!(!out.contains("\"secret\""), "{out}");
}
#[test]
fn a_message_with_no_credential_is_left_untouched() {
let message = b"OPTIONS sip:example.net SIP/2.0\r\n\
To: <sip:example.net>\r\n\
Content-Length: 0\r\n\r\n";
assert!(
redact(message).is_none(),
"nothing to redact must mean no copy"
);
}
#[test]
fn a_display_name_that_looks_like_a_parameter_survives() {
let message = b"INVITE sip:bob@example.net SIP/2.0\r\n\
From: \"response=me\" <sip:alice@example.net>\r\n\
Content-Length: 0\r\n\r\n";
assert!(
redact(message).is_none(),
"a From display name is not a credential-bearing header"
);
}
#[test]
fn a_truncated_message_is_redacted_without_panicking() {
assert!(redact(b"").is_none());
assert!(redact(b"\r\n").is_none());
assert!(redact(b"Authorization: Digest response=").is_none());
let partial = redact(b"Authorization: Digest response=\"abc")
.expect("an unterminated quoted value is still a credential");
assert!(!text(&partial).contains("abc"));
let no_crlf = redact(b"Authorization: Digest response=\"xyz\"").expect("redacted");
assert!(!text(&no_crlf).contains("xyz"));
}
fn message(lines: &[&str]) -> Vec<u8> {
joined(lines, "\r\n")
}
fn joined(lines: &[&str], terminator: &str) -> Vec<u8> {
let mut out = String::new();
for line in lines {
out.push_str(line);
out.push_str(terminator);
}
out.push_str(terminator);
out.into_bytes()
}
#[test]
fn every_legal_spelling_of_a_credential_header_is_redacted() {
const SECRET: &str = "SPELLINGSECRET0001";
let folded = message(&[
"REGISTER sip:example.net SIP/2.0",
"Authorization: Digest username=\"alice\",",
"\tresponse=\"SPELLINGSECRET0001\"",
"Content-Length: 0",
]);
let folded_mid_name = message(&[
"REGISTER sip:example.net SIP/2.0",
"Authorization: Digest respo",
" nse=\"SPELLINGSECRET0001\"",
]);
let space_before_colon = message(&[
"REGISTER sip:example.net SIP/2.0",
"Authorization : Digest response=\"SPELLINGSECRET0001\"",
]);
let tab_before_colon = message(&[
"REGISTER sip:example.net SIP/2.0",
"Authorization\t: Digest response=\"SPELLINGSECRET0001\"",
]);
let bare_lf = joined(
&[
"REGISTER sip:example.net SIP/2.0",
"Authorization: Digest response=\"SPELLINGSECRET0001\"",
],
"\n",
);
let bare_cr = joined(
&[
"REGISTER sip:example.net SIP/2.0",
"Authorization: Digest response=\"SPELLINGSECRET0001\"",
],
"\r",
);
let unterminated =
b"REGISTER sip:example.net SIP/2.0\r\nAuthorization: Digest response=\"SPELLINGSECRET0001\"".to_vec();
let cases: [(&str, &[u8]); 7] = [
("folded onto a continuation line (RFC 3261 §7.3.1)", &folded),
(
"folded in the middle of the parameter name",
&folded_mid_name,
),
(
"whitespace before the colon, which HCOLON permits (§25.1)",
&space_before_colon,
),
("a tab before the colon", &tab_before_colon),
("bare LF, which made the whole datagram one line", &bare_lf),
("bare CR", &bare_cr),
("no trailing terminator at all", &unterminated),
];
for (spelling, raw) in cases {
let redacted =
redact(raw).unwrap_or_else(|| panic!("{spelling}: nothing was redacted at all"));
let out = text(&redacted);
assert!(
!out.contains(SECRET),
"{spelling}: the credential survived redaction: {out}"
);
}
}
#[test]
fn a_line_with_no_header_name_is_redacted_conservatively() {
let raw = message(&[
"REGISTER sip:example.net SIP/2.0",
"GARBAGE WITHOUT A COLON response=\"CONSERVATIVE0004\"",
]);
let out = text(&redact(&raw).expect("a nameless line is still scanned"));
assert!(!out.contains("CONSERVATIVE0004"), "{out}");
}
#[test]
fn every_inline_key_on_a_crypto_line_is_redacted() {
let raw = message(&[
"INVITE sip:bob@example.net SIP/2.0",
"Content-Length: 0",
"",
"v=0",
"a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:FIRSTKEY0005aaaaaaaaaaaaaaaaaaaa|2^20|1;inline:SECONDKEY0006bbbbbbbbbbbbbbbbbbb|2^20|2",
]);
let redacted = redact(&raw).expect("both keys are redacted");
let out = text(&redacted);
assert!(
!out.contains("FIRSTKEY0005"),
"the first key survived: {out}"
);
assert!(
!out.contains("SECONDKEY0006"),
"the second key survived: {out}"
);
assert!(out.contains("|2^20|1"), "the lifetime is not secret: {out}");
assert!(out.contains("|2^20|2"), "{out}");
assert_eq!(
redacted.len(),
raw.len(),
"body redaction must preserve length"
);
}
#[test]
fn an_opaque_scheme_has_its_whole_credential_removed() {
for (scheme, secret) in [
("Bearer", "BEARERTOKEN0007"),
("bearer", "BEARERTOKEN0007"),
("Basic", "BASICSECRET0008"),
("Weird", "WEIRDTOKEN0009"),
] {
let raw = message(&[
"REGISTER sip:example.net SIP/2.0",
&format!("Authorization: {scheme} {secret}"),
]);
let out =
text(&redact(&raw).unwrap_or_else(|| panic!("{scheme} was not redacted at all")));
assert!(!out.contains(secret), "{scheme}: {out}");
assert!(out.contains(scheme), "{scheme} should survive: {out}");
}
}
#[test]
fn a_digest_header_is_not_mistaken_for_an_opaque_credential() {
let raw = message(&[
"REGISTER sip:example.net SIP/2.0",
"Authorization: Digest realm=\"example.net\", nonce=\"n\", response=\"DIGEST0010\"",
]);
let out = text(&redact(&raw).expect("redacted"));
assert!(!out.contains("DIGEST0010"), "{out}");
assert!(out.contains("realm=\"example.net\""), "{out}");
assert!(out.contains("nonce=\"n\""), "{out}");
}
#[test]
fn an_sdp_key_field_is_redacted_but_prompt_is_not() {
let raw = message(&[
"INVITE sip:bob@example.net SIP/2.0",
"",
"v=0",
"k=base64:SDPKEY0011aaaa",
]);
let redacted = redact(&raw).expect("a k= key is redacted");
let out = text(&redacted);
assert!(!out.contains("SDPKEY0011"), "{out}");
assert!(out.contains("k=base64:"), "the method is kept: {out}");
assert_eq!(redacted.len(), raw.len(), "length preserved");
let prompt = message(&["INVITE sip:bob@example.net SIP/2.0", "", "v=0", "k=prompt"]);
assert!(
redact(&prompt).is_none(),
"k=prompt carries no key, so there is nothing to rewrite"
);
}
#[test]
fn a_credential_nested_in_a_body_is_redacted() {
let raw = message(&[
"INVITE sip:bob@example.net SIP/2.0",
"Content-Type: message/sipfrag",
"",
"REGISTER sip:inner SIP/2.0",
"Authorization: Digest response=\"NESTEDSECRET0012\"",
]);
let redacted = redact(&raw).expect("a nested credential is redacted");
let out = text(&redacted);
assert!(!out.contains("NESTEDSECRET0012"), "{out}");
assert_eq!(
redacted.len(),
raw.len(),
"a body stays the length Content-Length claims"
);
}
#[test]
fn an_escaped_quote_inside_a_value_does_not_end_it() {
let raw = message(&[
"REGISTER sip:example.net SIP/2.0",
"Authorization: Digest response=\"aaa\\\"TAIL0013\"",
]);
let out = text(&redact(&raw).expect("redacted"));
assert!(
!out.contains("TAIL0013"),
"the escaped tail survived: {out}"
);
}
#[test]
fn a_message_with_no_credential_is_never_rewritten() {
let folded = message(&[
"INVITE sip:bob@example.net SIP/2.0",
"From: \"Alice\"",
" <sip:alice@example.net>;tag=abcd",
"Subject: a response= that is not a parameter",
]);
assert!(
redact(&folded).is_none(),
"nothing to redact must mean no copy, so a fold survives untouched"
);
}
#[test]
fn no_capture_means_the_bytes_are_never_produced() {
let meters = Meters::default();
let produced = std::cell::Cell::new(false);
Capture::observe_if_capturing(
None,
&meters,
"127.0.0.1:5060".parse().expect("valid"),
"127.0.0.1:5061".parse().expect("valid"),
TransportKind::Tcp,
Direction::In,
|| {
produced.set(true);
Bytes::from_static(b"OPTIONS sip:x SIP/2.0\r\n\r\n")
},
);
assert!(
!produced.get(),
"with no capture configured the message must not even be serialised"
);
assert_eq!(
meters.snapshot().capture,
crate::counters::CaptureCounts::default(),
"and nothing is counted"
);
}
#[test]
fn a_header_name_is_matched_without_regard_to_case() {
let out = text(
&redact(b"AUTHORIZATION: Digest RESPONSE=\"secret\"\r\n\r\n")
.expect("header and parameter names are case-insensitive"),
);
assert!(!out.contains("secret"), "{out}");
}
#[test]
fn the_synthesised_ipv4_header_carries_a_valid_checksum() {
let record = Record {
seq: 1,
at: UNIX_EPOCH,
local: "192.0.2.1:5060".parse().unwrap(),
peer: "192.0.2.9:5061".parse().unwrap(),
transport: TransportKind::Udp,
direction: Direction::Out,
bytes: Bytes::from_static(b"OPTIONS sip:x SIP/2.0\r\n\r\n"),
redacted: false,
};
let packet = synthesise(&record);
assert_eq!(packet[0], 0x45, "IPv4 with a twenty-byte header");
assert_eq!(packet[9], IP_PROTO_UDP);
assert_eq!(
ones_complement(&packet[..20]),
0,
"a header with a correct checksum sums to zero"
);
assert_eq!(&packet[20..22], &5060u16.to_be_bytes());
assert_eq!(&packet[22..24], &5061u16.to_be_bytes());
assert_eq!(&packet[28..], record.bytes.as_ref());
}
#[test]
fn a_tls_record_says_it_was_decrypted_in_process() {
let record = Record {
seq: 7,
at: UNIX_EPOCH,
local: "192.0.2.1:5061".parse().unwrap(),
peer: "192.0.2.9:5061".parse().unwrap(),
transport: TransportKind::Tls,
direction: Direction::In,
bytes: Bytes::from_static(b"SIP/2.0 200 OK\r\n\r\n"),
redacted: true,
};
let comment = comment(&record);
assert!(comment.contains("seq=7"), "{comment}");
assert!(comment.contains("dir=in"), "{comment}");
assert!(comment.contains("transport=TLS"), "{comment}");
assert!(comment.contains("decrypted-in-process=yes"), "{comment}");
assert!(comment.contains("redacted=yes"), "{comment}");
}
#[test]
fn ipv6_is_synthesised_as_ipv6() {
let record = Record {
seq: 1,
at: UNIX_EPOCH,
local: "[2001:db8::1]:5060".parse().unwrap(),
peer: "[2001:db8::2]:5060".parse().unwrap(),
transport: TransportKind::Udp,
direction: Direction::In,
bytes: Bytes::from_static(b"x"),
redacted: false,
};
let packet = synthesise(&record);
assert_eq!(packet[0] >> 4, 6, "version 6");
assert_eq!(packet[6], IP_PROTO_UDP, "next header");
assert_eq!(packet.len(), 40 + 8 + 1);
}
#[test]
fn hep_ipv4_udp_vector_is_byte_exact() {
let record = Record {
seq: 1,
at: UNIX_EPOCH + std::time::Duration::new(1, 2_000),
local: "192.0.2.10:5060".parse().unwrap(),
peer: "198.51.100.20:5080".parse().unwrap(),
transport: TransportKind::Udp,
direction: Direction::Out,
bytes: Bytes::from_static(b"SIP"),
redacted: true,
};
let encoded = encode_hep(&record, 0x0102_0304).expect("encodes");
let expected = [
b'H', b'E', b'P', b'3', 0x00, 0x66, 0, 0, 0, 1, 0, 7, 2, 0, 0, 0, 2, 0, 7, 17, 0, 0, 0, 3, 0, 10, 192, 0, 2, 10, 0, 0, 0, 4, 0, 10, 198, 51, 100, 20, 0, 0, 0, 7, 0, 8, 0x13, 0xc4, 0, 0, 0, 8, 0, 8, 0x13, 0xd8, 0, 0, 0, 9, 0, 10, 0, 0, 0, 1, 0, 0, 0, 10, 0, 10, 0, 0, 0, 2, 0, 0, 0, 11, 0, 7, 1, 0, 0, 0, 12, 0, 10, 1, 2, 3, 4, 0, 0, 0, 15, 0, 9, b'S', b'I', b'P', ];
assert_eq!(encoded, expected);
}
#[test]
fn an_unavailable_hep_sink_drops_without_disabling_local_capture() {
let record = Record {
seq: 1,
at: UNIX_EPOCH,
local: "192.0.2.10:5060".parse().unwrap(),
peer: "198.51.100.20:5080".parse().unwrap(),
transport: TransportKind::Udp,
direction: Direction::Out,
bytes: Bytes::from_static(b"OPTIONS sip:x SIP/2.0\r\n\r\n"),
redacted: true,
};
let meters = Meters::default();
let mut exporter = HepExporter {
socket: None,
config: HepConfig::new("127.0.0.1:9060".parse().unwrap(), 7),
warned: true,
};
exporter.export(&record, &meters);
let capture = meters.snapshot().capture;
assert_eq!(capture.hep_records, 0);
assert_eq!(capture.hep_dropped, 1);
assert_eq!(capture.errors, 0, "HEP failure does not disable pcapng");
let mut pcapng = Vec::new();
write_packet(&mut pcapng, &record).expect("local capture remains writable");
assert!(!pcapng.is_empty());
}
}