#!/usr/bin/perl $main::VERSION = sprintf "%d.%03d",q$Revision: 1.3 $ =~ /(\d+)\.(\d+)/; use strict; use warnings; use sigtrap qw(handler signal_handler normal-signals); use threads; use threads::shared; use Thread::Queue; use Pod::Usage; use Getopt::Long qw(:config posix_default no_ignore_case); use File::Temp qw(tempfile tempdir); use File::Path qw(rmtree); use Net::Pcap; use NetPacket::Ethernet; use NetPacket::IP qw(IP_PROTO_TCP IP_PROTO_UDP); use NetPacket::TCP; use NetPacket::UDP; use Net::RTP::Packet; use Audio::SndFile; use HTTP::Date; use LWP::UserAgent; my $interface = 'any'; my $dump = ''; my $snaplen = 2048; my $url = "http://btnwfsqa02.btn.tech.xo.com:8080/FileUploadServlet"; my $base = "/record"; my $verbose = 0; my $debug = 0; my $help = 0; my $manual = 0; my $version = 0; GetOptions('interface|i=s' => \$interface, 'dump=s' => \$dump, 'snaplen=i' => \$snaplen, 'url|u=s' => \$url, 'base|b=s' => \$base, 'verbose|v!' => \$verbose, 'debug|d+' => \$debug, 'help|?!' => \$help, 'manual|m!' => \$manual, 'version|V!' => \$version) or pod2usage(-exitval => 1, -verbose => 1); pod2usage(-exitval => 0, -verbose => 2, -noperldoc => 1) if $help; pod2usage(-exitval => 0, -verbose => 2) if $manual; pod2usage(-exitval => 0, -verbose => 0, -message => "Version $main::VERSION") if $version; pod2usage(-exitval => 1, -verbose => 1, -message => "Missing --interface argument") unless $interface; my $layer2_unpack; if ($interface eq 'any') { # Linux cooked capture $layer2_unpack = "x[nnnH16]na*"; } else { # Ethernet $layer2_unpack = "x[NnNn]na*"; } my $temp_dir = tempdir(CLEANUP => 1); chmod 0755, $temp_dir; my $ua = LWP::UserAgent->new; $ua->agent("SIPRTPRecorder/" . $main::VERSION . " (" . $ua->agent . ")"); my %active_calls : shared = (); my %timeout_calls : shared = (); my %active_rtp : shared = (); my $pcap_error; my $capture = Net::Pcap::open_live($interface, $snaplen, 0, 0, \$pcap_error); unless (defined $capture) { die 'Unable to open device ', $interface, ': ', $pcap_error; } my $dumper; if ($dump) { $dumper = Net::Pcap::dump_open($capture, $dump); unless (defined $dumper) { die 'Unable to create dump file ', $dump, ': ', Net::Pcap::geterr($capture); } } my $filter_str = 'port 5060'; my $filter; if (Net::Pcap::compile($capture, \$filter, $filter_str, 1, 0)) { die 'Unable to compile filter "', $filter_str, '": ', Net::Pcap::geterr($capture); } if (Net::Pcap::setfilter($capture, $filter)) { die 'Unable to set packet capture filter: ', Net::Pcap::geterr($capture); } # Recording Queue handles initiation of Indbound and Outbound, and Cleanup of # RTP streams my $recording_queue = Thread::Queue->new; sub recording_queue_reader { while (my $recording_element = $recording_queue->dequeue) { my ($type, $call_id) = @$recording_element; if ($type eq 'Exit') { return; } elsif ($type eq 'Cleanup') { my $inbound_address = $active_calls{$call_id}{inbound_address}; my $inbound_port = $active_calls{$call_id}{inbound_port}; my $outbound_address = $active_calls{$call_id}{outbound_address}; my $outbound_port = $active_calls{$call_id}{outbound_port}; print "* Cleaning Call-Id $call_id, ", "Record-Id: ", $active_calls{$call_id}{record_id}, ", ", "Inbound-RTP: $inbound_address:$inbound_port, ", "Outbound-RTP $outbound_address:$outbound_port\n" if $debug; delete $active_rtp{"$inbound_address:$inbound_port"}; delete $active_rtp{"$outbound_address:$outbound_port"}; my $expression = " or (host $inbound_address and dst port $inbound_port)"; if ($filter_str =~ /(.*)\Q$expression\E(.*)?/) { $filter_str = "$1$2"; } $expression = " or (host $outbound_address and dst port $outbound_port)"; if ($filter_str =~ /(.*)\Q$expression\E(.*)?/) { $filter_str = "$1$2"; } if (Net::Pcap::compile($capture, \$filter, $filter_str, 1, 0)) { warn 'Unable to compile filter "', $filter_str, '": ', Net::Pcap::geterr($capture); kill 6, $$; return; } if (Net::Pcap::setfilter($capture, $filter)) { warn 'Unable to set packet capture filter: ', Net::Pcap::geterr($capture); kill 6, $$; return; } print "* Filter '$filter_str'\n" if $debug > 1; $active_calls{$call_id}{cleaned} = 1; } else { my ($address, $port); if ($type eq 'Inbound') { $address = $active_calls{$call_id}{inbound_address}; $port = $active_calls{$call_id}{inbound_port}; } elsif ($type eq 'Outbound') { $address = $active_calls{$call_id}{outbound_address}; $port = $active_calls{$call_id}{outbound_port}; } print "* Recording $type Call-Id: $call_id, ", "Record-Id: ", $active_calls{$call_id}{record_id}, ", ", "RTP: $address:$port\n", if $verbose; $filter_str .= " or (host $address and dst port $port)"; my $previous_filter = $filter; if (Net::Pcap::compile($capture, \$filter, $filter_str, 1, 0)) { die 'Unable to compile filter "', $filter_str, '": ', Net::Pcap::geterr($capture); } if (Net::Pcap::setfilter($capture, $filter)) { die 'Unable to set packet capture filter: ', Net::Pcap::geterr($capture); } if (Net::Pcap::freecode($previous_filter)) { die 'Unable to free previous filter: ', Net::Pcap::geterr($capture); } print "* Filter '$filter_str'\n" if $debug > 1; } } } my $recording_thread = threads->create(\&recording_queue_reader); # Writer Queue handles file operations for RTP streams my $writer_queue = Thread::Queue->new; my %writer_files = (); sub writer_queue_reader { while (my $writer_element = $writer_queue->dequeue) { my ($type, $call_id, $direction, $data) = @$writer_element; if ($type eq 'Exit') { return; } elsif ($type eq 'Close') { my %upload_parts = (); foreach my $file_type (qw(inbound outbound dump)) { if (exists $active_calls{$call_id}{$file_type . "_file"}) { my $file = $active_calls{$call_id}{$file_type . "_file"}; if ($file_type eq "dump") { my $dump_handle = $active_calls{$call_id}{dump_handle}; if (Net::Pcap::dump_close($dump_handle)) { warn 'Unable to close dump file ', $file, ': ', Net::Pcap::geterr($capture); } delete $active_calls{$call_id}{dump_handle}; print "* Closed dump ", $file, "\n" if ($debug); $upload_parts{$file_type} = [ $file => $base . "/" . $active_calls{$call_id}{record_id} . ".pcap" ]; } else { if (exists $writer_files{$file}) { my $sndfile = $writer_files{$file}{sndfile}; $sndfile->close; my $fh = $writer_files{$file}{fh}; close $fh; print "* Closed file ", $file, "\n" if ($debug); delete $writer_files{$file}; $upload_parts{$file_type} = [ $file => $base . "/" . $active_calls{$call_id}{record_id} . "_" . $file_type . ".wav" ]; } } } } my $upload_response = $ua->post($url, \%upload_parts, Content_Type => "form-data"); if ($upload_response->is_success) { print "* Uploaded files to ", $base, "/", $active_calls{$call_id}{record_id}, " at ", $url, "\n" if $debug; } else { warn "Failed to upload files to $url: ", $upload_response->status_line, "\n"; } foreach my $file_type (qw(inbound_file outbound_file dump_file)) { if (exists $active_calls{$call_id}{$file_type}) { my $file = $active_calls{$call_id}{$file_type}; unlink $file; } } $active_calls{$call_id}{closed} = 1; } elsif ($type eq 'Write') { my $file; if (exists $active_calls{$call_id}{$direction . "_file"}) { $file = $active_calls{$call_id}{$direction . "_file"}; } my $sndfile; if (defined $file && exists $writer_files{$file}) { $sndfile = $writer_files{$file}{sndfile}; } else { my $fh; ($fh, $file) = File::Temp::tempfile(DIR => $temp_dir, SUFFIX => ".wav"); chmod 0644, $file; $sndfile = Audio::SndFile->open(">", $fh, type => "wav", subtype => "ulaw", endianness => "file", channels => 1, samplerate => 8000); $sndfile->set_title($active_calls{$call_id}{record_id}); $active_calls{$call_id}{$direction . "_file"} = $file; $writer_files{$file}{sndfile} = $sndfile; $writer_files{$file}{fh} = $fh; print "* Created file ", $file, " for ", $direction, " RTP stream ", $active_calls{$call_id}{$direction . "_address"}, ":", $active_calls{$call_id}{$direction . "_port"}, "\n" if ($debug); } $sndfile->write_raw($data); } } } my $writer_thread = threads->create(\&writer_queue_reader); # Timeout calls, usually this simply deletes old %active_calls keys, and # occasionally closes resources for calls which have not had a SIP BYE message my $timeout_handler_active : shared = 1; sub timeout_handler { while ($timeout_handler_active) { sleep 1; my $time = time; foreach my $call_id (keys %timeout_calls) { my $timeout = $timeout_calls{$call_id}; if ($time > $timeout) { my $close = 0; my $cleanup = 0; if (exists $active_calls{$call_id}) { if (!exists $active_calls{$call_id}{closed} || !$active_calls{$call_id}{closed}) { $close = 1; } if (!exists $active_calls{$call_id}{cleaned} || !$active_calls{$call_id}{cleaned}) { $cleanup = 1; } } if ($close || $cleanup) { print "* Timeout reached for $call_id\n" if ($verbose || $debug); if ($close) { my @writer_element : shared = ('Close', $call_id, '', ''); $writer_queue->enqueue(\@writer_element); } if ($cleanup) { my @recording_element : shared = ('Cleanup', $call_id); $recording_queue->enqueue(\@recording_element); } } else { delete $active_calls{$call_id}; delete $timeout_calls{$call_id}; } } } } } my $timeout_thread = threads->create(\&timeout_handler); # Packet processing callback sub capture_packets($$$) { my ($user_data, $header, $packet) = @_; if (defined $dumper) { if (Net::Pcap::dump($dumper, $header, $packet)) { warn 'Unable to write dump file ', $dump, ': ', Net::Pcap::geterr($capture); } } my ($layer2_type, $layer2_data) = unpack($layer2_unpack, $packet); if ($layer2_type != 0x0800) { print "Unrecognized Layer 2 type ", $layer2_type, ", expected IPv4 (0x800)" if ($verbose || $debug); return; } my $ip = NetPacket::IP->decode($layer2_data); my $proto; if ($ip->{proto} == IP_PROTO_UDP) { $proto = NetPacket::UDP->decode($ip->{data}); } elsif ($ip->{proto} == IP_PROTO_TCP) { $proto = NetPacket::TCP->decode($ip->{data}); } else { print "Unexpected IP protocol ", $ip->{proto}, ", expected TCP (", IP_PROTO_TCP, ") or UDP (", IP_PROTO_UDP, ")\n" if $verbose || $debug; return; } print $ip->{src_ip}, ":", $proto->{src_port}, " -> ", $ip->{dest_ip}, ":", $proto->{dest_port}, "\n", ($proto->{src_port} == 5060 || $proto->{dest_port} == 5060 || $debug > 2 ? $proto->{data} . "\n" :"") if $debug > 1; # Process SIP requests if ($proto->{src_port} == 5060 || $proto->{dest_port} == 5060) { if ($proto->{data} =~ /\bCall-ID: (.*)\b/i) { my $call_id = $1; print "Examining $call_id\n" if $debug; if (exists $active_calls{$call_id}) { if (exists $active_calls{$call_id}{dump_file} && exists $active_calls{$call_id}{dump_handle}) { my $dump_file = $active_calls{$call_id}{dump_file}; my $dump_handle = $active_calls{$call_id}{dump_handle}; if (Net::Pcap::dump($dump_handle, $header, $packet)) { warn 'Unable to write dump file ', $dump_file, ': ', Net::Pcap::geterr($capture); } } if (exists $active_calls{$call_id}{outbound_address}) { if ($proto->{data} =~ /\bBYE /i) { print " Found BYE\n" if $debug; my @writer_element : shared = ('Close', $call_id, '', ''); $writer_queue->enqueue(\@writer_element); my @recording_element : shared = ('Cleanup', $call_id); $recording_queue->enqueue(\@recording_element); } else { print " Active\n" if $debug; } } else { print " Checking for outbound RTP\n" if $debug; if ($proto->{data} =~ /\bc=IN IP4 (.*)\b/i) { my $address = $1; print " Address $address\n" if $debug; if ($proto->{data} =~ /\bm=audio (\d+) RTP/i) { my $port = $1; print " Port $port\n" if $debug; $active_calls{$call_id}{outbound_address} = $address; $active_calls{$call_id}{outbound_port} = $port; $active_rtp{"$address:$port"} = $call_id; my @recording_element : shared = ('Outbound', $call_id); $recording_queue->enqueue(\@recording_element); } } } } else { if ($proto->{data} =~ /\bRecord-Call: (.*)\b/i) { my $record_id = $1; my $timeout = time + 300; if ($proto->{data} =~ /\bRecord-Until: (.*)\b/i) { $timeout = str2time($1); } print " Found Record header in $call_id\n" if $debug; print " Timeout ", time2str($timeout), "\n" if $debug; if ($proto->{data} =~ /\bc=IN IP4 (.*)\b/i) { my $address = $1; print " Address $address\n" if $debug; if ($proto->{data} =~ /\bm=audio (\d+) RTP/i) { my $port = $1; print " Port $port\n" if $debug; $active_calls{$call_id} = &share({}); $active_calls{$call_id}{record_id} = $record_id; $active_calls{$call_id}{inbound_address} = $address; $active_calls{$call_id}{inbound_port} = $port; $timeout_calls{$call_id} = $timeout; $active_rtp{"$address:$port"} = $call_id; my ($dump_fh, $dump_file) = File::Temp::tempfile(DIR => $temp_dir, SUFFIX => ".pcap"); close $dump_fh; chmod 0644, $dump_file; my $dump_handle = Net::Pcap::dump_open($capture, $dump_file); unless (defined $dump_handle) { warn 'Unable to create dump file ', $dump_file, ': ', Net::Pcap::geterr($capture); } if (defined $dump_handle) { if (Net::Pcap::dump($dump_handle, $header, $packet)) { warn 'Unable to write dump file ', $dump_file, ': ', Net::Pcap::geterr($capture); } else { $active_calls{$call_id}{dump_file} = $dump_file; $active_calls{$call_id}{dump_handle} = &share($dump_handle); } } my @recording_element : shared = ('Inbound', $call_id); $recording_queue->enqueue(\@recording_element); } } } } } } # Process RTP streams else { my $call_id; my ($address, $port); if (exists $active_rtp{$ip->{src_ip} . ":" . $proto->{src_port}}) { $address = $ip->{src_ip}; $port = $proto->{src_port}; $call_id = $active_rtp{"$address:$port"}; } elsif (exists $active_rtp{$ip->{dest_ip} . ":" . $proto->{dest_port}}) { $address = $ip->{dest_ip}; $port = $proto->{dest_port}; $call_id = $active_rtp{"$address:$port"}; } else { print "Unexpected RTP stream from ", $ip->{src_ip}, ":", $proto->{src_port}, " to ", $ip->{dest_ip}, ":", $proto->{dest_port}, " not found in active RTP streams\n", if ($verbose || $debug); return; } if (exists $active_calls{$call_id}{dump_file} && exists $active_calls{$call_id}{dump_handle}) { my $dump_file = $active_calls{$call_id}{dump_file}; my $dump_handle = $active_calls{$call_id}{dump_handle}; if (Net::Pcap::dump($dump_handle, $header, $packet)) { warn 'Unable to write dump file ', $dump_file, ': ', Net::Pcap::geterr($capture); } } my $direction; if ($address eq $active_calls{$call_id}{inbound_address} && $port == $active_calls{$call_id}{inbound_port}) { $direction = "inbound"; } elsif ($address eq $active_calls{$call_id}{outbound_address} && $port == $active_calls{$call_id}{outbound_port}) { $direction = "outbound"; } else { print "Unexpected RTP stream from ", $ip->{src_ip}, ":", $proto->{src_port}, " to ", $ip->{dest_ip}, ":", $proto->{dest_port}, " does not match active calls\n", if ($verbose || $debug); return; } my $rtp = new Net::RTP::Packet($proto->{data}); if ($rtp->{payload_type} != 0) { print "Unrecognized RTP payload type ", $rtp->{payload_type}, " expected PCMU (0)\n" if ($verbose || $debug); return; } my @writer_element : shared = ('Write', $call_id, $direction, $rtp->payload); $writer_queue->enqueue(\@writer_element); } } unless (Net::Pcap::loop($capture, -1, \&capture_packets, '')) { die 'Unable to perform packet capture', Net::Pcap::geterr($capture); } # Signal handler for cleaning up temporary files sub signal_handler { print "Caught signal ", @_, "\n" if ($verbose || $debug); $timeout_handler_active = 0; if (defined $writer_queue) { my @writer_element : shared = ('Exit', '', '', ''); $writer_queue->enqueue(\@writer_element); } if (defined $recording_queue) { my @recording_element : shared = ('Exit', ''); $recording_queue->enqueue(\@recording_element); } if (defined $timeout_thread) { $timeout_thread->join; } if (defined $writer_thread) { $writer_thread->join; } if (defined $recording_thread) { $recording_thread->join; } if (defined $temp_dir && -d $temp_dir) { print "Removing directory $temp_dir\n" if $debug; rmtree($temp_dir, ($debug > 1 ? 1 : 0), 0); } if (defined $capture) { Net::Pcap::close($capture); } if (defined $dumper) { if (Net::Pcap::dump_close($dumper)) { warn 'Unable to close dump file ', $dump, ': ', Net::Pcap::geterr($capture); } } exit; } __END__ =head1 NAME siprtprecorder - Capture RTP streams triggered by SIP requests. =head1 SYNOPSIS siprtprecorder [options] =head1 OPTIONS =over 8 =item B<--interface, -i> Interface to capture SIP requests and RTP streams, may be specified multiple times. Default value is "any". =item B<--dump> Optional file which all captured packets will be written to for debugging purposes. =item B<--url, -u> URL of FileUploadServlet where audio files are uploaded. Default value is "http://btnwfsqa02.btn.tech.xo.com:8080/FileUploadServlet". =item B<--base, -b> Base path of audio files uploaded to FileUploadServet. Default is "/record". =item B<--verbose, v> Print verbose output. =item B<--debug, -d> Print debugging output, may specify multiple times for increased debugging. =item B<--help, -?> Print a help message and exit. =item B<--manual, -m> Display the complete documentation. =item B<--version, -V> Print the version and exit. =back =head1 DESCRIPTION This program monitors a network interface for SIP requests containing a I header which triggers the program to capture an I stream. =cut