Public feed #723490

SIPRTPRecorder

public text v1 · immutable
Raw Edit
·published 2007-10-02 20:38 UTC
rendered paste body
#!/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<Record> header which triggers the program to capture an I<RTP> stream.

=cut