You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
rtpengine/t/auto-daemon-tests-rollback-...

555 lines
20 KiB

#!/usr/bin/perl
use strict;
use warnings;
use Bencode;
use JSON;
use NGCP::Rtpengine::AutoTest;
use File::Temp ();
use IO::Select;
use POSIX ();
use Socket qw(AF_INET SOCK_STREAM sockaddr_in inet_aton);
use Test::More;
use Time::HiRes;
my $redis_format = $ENV{RTPE_REDIS_FORMAT} // 'json';
# Fake Redis server, run in a forked child.
#
# The daemon writes to Redis from the poller thread as well as after a signalling
# request, so a test servicing it only between requests eventually leaves a write
# unanswered, which blocks the daemon in redis_consume(). A separate process
# always answers; the parent reads what was stored through the files below.
#
# $state/seq number of SETs the child has serviced
# $state/last value of the most recent SET
# $state/override if present, served in place of the next GET, then removed
my $redis_listener;
socket($redis_listener, AF_INET, SOCK_STREAM, 0) or die;
bind($redis_listener, sockaddr_in(6379, inet_aton('203.0.113.42'))) or die;
listen($redis_listener, 10) or die;
my $state = File::Temp::tempdir("rollback-redis-XXXXXX", TMPDIR => 1, CLEANUP => 1);
write_file("$state/seq", "0");
sub write_file {
my ($path, $content) = @_;
open(my $fh, '>', "$path.tmp") or die "$path: $!";
binmode($fh);
print $fh $content;
close($fh) or die;
rename("$path.tmp", $path) or die "$path: $!";
}
sub read_file {
my ($path) = @_;
open(my $fh, '<', $path) or return;
binmode($fh);
local $/ = undef;
my $content = <$fh>;
close($fh);
return $content;
}
sub server_read_exact {
my ($fd, $len) = @_;
my $buf = '';
while (length($buf) < $len) {
my $part;
defined(recv($fd, $part, $len - length($buf), 0)) or return;
length($part) or return;
$buf .= $part;
}
return $buf;
}
sub server_read_line {
my ($fd) = @_;
my $buf = '';
while ($buf !~ /\r\n\z/) {
my $byte = server_read_exact($fd, 1);
defined($byte) or return;
$buf .= $byte;
}
$buf =~ s/\r\n\z//;
return $buf;
}
sub server_read_command {
my ($fd) = @_;
my $intro = server_read_line($fd);
(defined($intro) && $intro =~ /^\*(\d+)\z/) or return;
my @args;
for (1 .. $1) {
my $bulk = server_read_line($fd);
(defined($bulk) && $bulk =~ /^\$(\d+)\z/) or return;
my $arg = server_read_exact($fd, $1);
defined($arg) or return;
server_read_exact($fd, 2);
push @args, $arg;
}
return \@args;
}
# Answer every command the daemon can send, so it is never left waiting.
sub redis_server {
my %store;
my $sets = 0;
my $select = IO::Select->new($redis_listener);
while (1) {
for my $fh ($select->can_read(1)) {
if ($fh == $redis_listener) {
my $client;
accept($client, $redis_listener) or next;
$select->add($client);
next;
}
my $command = server_read_command($fh);
if (!$command) {
$select->remove($fh);
close($fh);
next;
}
my $verb = uc($command->[0]);
if ($verb eq 'PING') {
send($fh, "+PONG\r\n", 0);
}
elsif ($verb eq 'INFO') {
my $info = "role:master\r\n";
send($fh, '$' . length($info) . "\r\n$info\r\n", 0);
}
elsif ($verb eq 'TYPE') {
send($fh, "+none\r\n", 0);
}
elsif ($verb eq 'KEYS') {
my @keys = keys %store;
my $reply = '*' . scalar(@keys) . "\r\n";
$reply .= '$' . length($_) . "\r\n$_\r\n" for @keys;
send($fh, $reply, 0);
}
elsif ($verb eq 'GET') {
my $value = read_file("$state/override");
if (defined $value) {
unlink("$state/override");
}
else {
$value = $store{$command->[1]};
}
if (defined $value) {
send($fh, '$' . length($value) . "\r\n$value\r\n", 0);
}
else {
send($fh, "\$-1\r\n", 0);
}
}
elsif ($verb eq 'SET') {
$store{$command->[1]} = $command->[2];
write_file("$state/last", $command->[2]);
write_file("$state/seq", ++$sets);
send($fh, "+OK\r\n", 0);
}
elsif ($verb eq 'DEL') {
delete $store{$command->[1]};
send($fh, ":1\r\n", 0);
}
else {
send($fh, "+OK\r\n", 0);
}
}
}
}
my $redis_pid = fork();
defined($redis_pid) or die "cannot fork Redis server";
if (!$redis_pid) {
$SIG{TERM} = sub { POSIX::_exit(0) }; ## no critic (Variables::RequireLocalizedPunctuationVars)
redis_server();
POSIX::_exit(0);
}
# The listener stays open here: under the preload's fake network, closing it in
# the parent removes the socket the child is accepting on.
END { kill('TERM', $redis_pid) if $redis_pid }
sub redis_sets_seen {
return int(read_file("$state/seq") // 0);
}
# The daemon writes to Redis before it answers, so the record is normally there
# already; poll briefly to cover the child not having flushed it yet.
sub redis_record_after {
my ($before) = @_;
for (1 .. 500) {
return read_file("$state/last") if redis_sets_seen() > $before;
Time::HiRes::sleep(0.01);
}
die 'no Redis update seen after the daemon answered';
}
sub serve_next_get {
my ($record) = @_;
write_file("$state/override", $record);
}
my ($expected_pending, $last_record);
sub decode_record {
my ($record) = @_;
return $redis_format eq 'json' ? decode_json($record) : Bencode::bdecode($record, 1);
}
sub encode_record {
my ($record) = @_;
return encode_json($record) if $redis_format eq 'json';
my $as_strings;
$as_strings = sub {
my ($value) = @_;
return { map { $_ => $as_strings->($value->{$_}) } keys %$value }
if ref($value) eq 'HASH';
return [ map { $as_strings->($_) } @$value ] if ref($value) eq 'ARRAY';
my $copy = $value;
return \$copy;
};
return Bencode::bencode($as_strings->($record));
}
# Record values are escaped by the encoder; JSON records carry them percent
# encoded, native (bencode) records do not.
sub decode {
my ($value) = @_;
return if !defined $value;
$value =~ s/%([0-9a-fA-F]{2})/chr(hex($1))/ge if $redis_format eq 'json';
return $value;
}
# A field that is not a number must disable rollback for the call without
# discarding the call itself.
sub checkpoint_with_invalid_field_type {
my ($record) = @_;
my $decoded = decode_record($record);
$decoded->{'checkpoint-0'}{pending} = 'banana';
return encode_record($decoded);
}
sub inspect_checkpoint {
my ($record) = @_;
my $decoded = decode_record($record);
my $checkpoint = $decoded->{'checkpoint-0'};
ok(defined $checkpoint, "$redis_format record contains a checkpoint");
# keyed on the monologue id, so both sides of the dialogue carry one
ok(defined $decoded->{'checkpoint-1'},
"$redis_format both monologues carry a checkpoint");
is(decode($checkpoint->{pending}) ? 1 : 0, $expected_pending,
"$redis_format pending state serialized");
# The snapshot is a nested call record, so it is checked for presence rather
# than shape: what it must contain is asserted by restoring from it.
ok(!$expected_pending || length(decode($checkpoint->{snapshot}) // ''),
"$redis_format pending checkpoint carries a snapshot");
# A snapshot never leaves the daemon, so it is bencode whatever the record is.
ok(!$expected_pending || (decode($checkpoint->{snapshot}) // '') =~ /^d/,
"$redis_format snapshot is bencode");
}
# What rollback restored, judged against the record rather than against `query`.
#
# query exposes only a subset of a media's state, so a rollback can stop restoring
# the rest with every existing assertion still green -- which happened. The record
# carries all of it: the committed state must serialise the same before and after.
sub durable_state {
my ($record) = @_;
return durable_fields(decode_record($record));
}
# A snapshot is a call record too, so it is filtered the same way. It is always
# bencode, whatever the record around it is.
sub checkpoint_snapshot {
my ($record) = @_;
my $checkpoint = decode_record($record)->{'checkpoint-0'} or return;
my $snapshot = decode($checkpoint->{snapshot}) or return;
return durable_fields(Bencode::bdecode($snapshot, 1));
}
sub durable_fields {
my ($decoded) = @_;
my %out;
for my $key (keys %$decoded) {
# Checkpoint entries describe the pending exchange, not the committed
# state, and are expected to differ.
next if $key =~ /^checkpoint/;
# Sockets and endpoint maps are a per-call pool. A rejected offer can add
# to it, and rollback repoints the media rather than freeing what was
# allocated, exactly as the feature has always behaved. What must match is
# the dialogue's own state, not the size of the pool behind it.
next if $key =~ /^(?:sfd|map|map_sfds|maps)-\d+$/;
if ($key eq 'json') {
my %call = %{$decoded->{$key}};
# Wall-clock and bookkeeping that moves on its own.
delete @call{qw(created created_us created_ts last_signal deleted
ml_deleted last_redis_update num_sfds num_maps)};
$out{$key} = \%call;
next;
}
$out{$key} = $decoded->{$key};
}
return \%out;
}
sub redis_rtpe_req {
my ($pending, @request) = @_;
my $before = redis_sets_seen();
my $response = rtpe_req(@request);
$last_record = redis_record_after($before);
$expected_pending = $pending;
inspect_checkpoint($last_record);
return $response;
}
sub assert_record_without_checkpoint {
my ($record) = @_;
my $decoded = decode_record($record);
my @checkpoints = grep { /^checkpoint-/ } keys %$decoded;
is(scalar(@checkpoints), 0,
"$redis_format invalid checkpoint is omitted from the restored call record");
}
sub sdp {
my ($address, $port, $ufrag, $pwd, $key, $direction) = @_;
return "v=0\r\no=- 2 2 IN IP4 $address\r\ns=rollback-redis-secure\r\n"
. "c=IN IP4 $address\r\nt=0 0\r\nm=audio $port RTP/SAVP 0\r\n"
. "a=rtpmap:0 PCMU/8000\r\na=$direction\r\n"
. "a=ice-ufrag:$ufrag\r\na=ice-pwd:$pwd\r\n"
. "a=candidate:1 1 UDP 2130706431 $address $port typ host\r\n"
. "a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:$key\r\n";
}
# Plain RTP, and the same media upgraded to DTLS-SRTP. Used to check that a
# rollback removes state the rejected offer introduced, rather than only
# overwriting state that existed in both.
sub plain_sdp {
my ($address, $port) = @_;
return "v=0\r\no=- 4 4 IN IP4 $address\r\ns=rollback-upgrade\r\n"
. "c=IN IP4 $address\r\nt=0 0\r\nm=audio $port RTP/AVP 0\r\n"
. "a=rtpmap:0 PCMU/8000\r\na=sendrecv\r\n";
}
sub dtls_upgrade_sdp {
my ($address, $port) = @_;
return "v=0\r\no=- 4 5 IN IP4 $address\r\ns=rollback-upgrade\r\n"
. "c=IN IP4 $address\r\nt=0 0\r\nm=audio $port UDP/TLS/RTP/SAVP 0\r\n"
. "a=rtpmap:0 PCMU/8000\r\na=sendrecv\r\na=setup:actpass\r\n"
. "a=fingerprint:sha-256 " . join(':', ('AB') x 32) . "\r\n"
. "a=tls-id:upgradetlsid0123456789abcdef\r\n"
# ICE too, so the rollback has candidates to remove as well as DTLS state.
. "a=ice-ufrag:upgradeUfrag\r\na=ice-pwd:upgradePassword01234567\r\n"
. "a=candidate:1 1 UDP 2130706431 $address $port typ host\r\n";
}
sub secure_parameters {
my ($body) = @_;
my @parameters = $body =~ /^(a=(?:ice-ufrag|ice-pwd):.*|a=crypto:1 .*)$/mg;
return \@parameters;
}
# A second interface so a rejected offer can move the media somewhere else.
my @daemon_args = (qw(--config-file=none -t -1 -i foo/203.0.113.1 -i bar/203.0.113.2
-n 2233 -c 12346 -f -L 7 -E --redis-num-threads=1),
"--redis=203.0.113.42:6379/15", "--redis-format=$redis_format");
$NGCP::Rtpengine::AutoTest::port = 2233;
autotest_start(@daemon_args) or die;
new_call;
my ($call_id, $from_tag, $to_tag) = (cid(), ft(), tt());
my $via_branch = 'rollback-redis-branch';
my $response = redis_rtpe_req(1, 'offer', 'tracked Redis offer', {
'from-tag' => $from_tag, 'via-branch' => $via_branch, flags => ['track-state'],
sdp => sdp('198.51.100.80', 12000, 'oldRedisUfrag',
'oldRedisPassword012345678', 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw', 'sendrecv'),
});
my $secure_parameters = secure_parameters($response->{sdp});
redis_rtpe_req(0, 'answer', 'tracked Redis answer', {
'from-tag' => $from_tag, 'to-tag' => $to_tag, 'via-branch' => $via_branch,
sdp => sdp('198.51.100.81', 12010, 'answerRedisUfrag',
'answerRedisPassword012345', 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2', 'sendrecv'),
});
$response = redis_rtpe_req(1, 'offer', 'offer later rejected by the far end', {
'from-tag' => $from_tag, 'to-tag' => $to_tag, 'via-branch' => $via_branch,
sdp => sdp('198.51.100.82', 12020, 'newRedisUfrag',
'newRedisPassword012345678', 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIz', 'sendonly'),
});
my $pending_record = $last_record;
NGCP::Rtpengine::AutoTest::shut_rtpe();
autotest_start(@daemon_args) or die;
$response = redis_rtpe_req(0, 'rollback', 'rollback after Redis takeover', {
'call-id' => $call_id, 'from-tag' => $from_tag, 'to-tag' => $to_tag,
'via-branch' => $via_branch,
});
is($response->{'rolled-back'}, 1, 'pending checkpoint survives Redis takeover');
my $query = rtpe_req('query', 'query rolled-back Redis call', {'call-id' => $call_id});
ok($query->{tags}{$from_tag}{medias}[0]{streams}[0]{'local port'},
'selected media socket survives takeover rollback');
is($query->{tags}{$from_tag}{medias}[0]{streams}[0]{endpoint}{address}, '198.51.100.80',
'remote media endpoint survives takeover rollback');
$response = redis_rtpe_req(1, 'offer', 'verify restored Redis media state', {
'call-id' => $call_id, 'from-tag' => $from_tag, 'to-tag' => $to_tag,
'via-branch' => $via_branch,
sdp => sdp('198.51.100.80', 12000, 'oldRedisUfrag',
'oldRedisPassword012345678', 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw', 'sendrecv'),
});
is_deeply(secure_parameters($response->{sdp}), $secure_parameters,
'ICE credentials and committed SDES key survive takeover rollback');
redis_rtpe_req(0, 'answer', 'commit exchange after takeover rollback', {
'call-id' => $call_id, 'from-tag' => $from_tag, 'to-tag' => $to_tag,
'via-branch' => $via_branch,
sdp => sdp('198.51.100.81', 12010, 'answerRedisUfrag',
'answerRedisPassword012345', 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2', 'sendrecv'),
});
NGCP::Rtpengine::AutoTest::shut_rtpe();
autotest_start(@daemon_args) or die;
$response = redis_rtpe_req(0, 'rollback', 'rollback after committed state crosses Redis', {
'call-id' => $call_id, 'from-tag' => $from_tag, 'to-tag' => $to_tag,
'via-branch' => $via_branch,
});
is($response->{'rolled-back'}, 0, 'committed checkpoint remains consumed in Redis');
NGCP::Rtpengine::AutoTest::shut_rtpe();
serve_next_get(checkpoint_with_invalid_field_type($pending_record));
autotest_start(@daemon_args) or die;
$query = rtpe_req('query', 'query call restored without invalid checkpoint', {
'call-id' => $call_id,
});
ok($query->{tags}{$from_tag}, 'invalid checkpoint data does not discard the restored call');
my $before_invalid = redis_sets_seen();
$response = rtpe_req('rollback', 'invalid checkpoint degrades to no rollback state', {
'call-id' => $call_id, 'from-tag' => $from_tag, 'to-tag' => $to_tag,
'via-branch' => $via_branch,
});
assert_record_without_checkpoint(redis_record_after($before_invalid));
is($response->{'rolled-back'}, 0, 'type-invalid checkpoint is discarded atomically');
# --- rollback restores the whole committed state, not just what query shows ---
new_call;
my ($cov_call, $cov_from, $cov_to) = (cid(), ft(), tt());
redis_rtpe_req(1, 'offer', 'coverage offer', {
'from-tag' => $cov_from, flags => ['track-state'], direction => [qw(foo foo)],
sdp => sdp('198.51.100.90', 14000, 'covUfrag', 'covPassword0123456789',
'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw', 'sendrecv'),
});
redis_rtpe_req(0, 'answer', 'coverage answer', {
'from-tag' => $cov_from, 'to-tag' => $cov_to,
sdp => sdp('198.51.100.91', 14010, 'covAnsUfrag', 'covAnsPassword012345',
'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2', 'sendrecv'),
});
my $committed_record = $last_record;
# A rejected offer that moves the media to the other interface.
redis_rtpe_req(1, 'offer', 'coverage rejected offer', {
'from-tag' => $cov_from, 'to-tag' => $cov_to, direction => [qw(bar bar)],
sdp => sdp('198.51.100.92', 14020, 'covNewUfrag', 'covNewPassword012345',
'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIz', 'sendonly'),
});
isnt(durable_state($last_record), durable_state($committed_record),
'the rejected offer really changed the stored state');
my $before_roll = redis_sets_seen();
my $cov_rollback = rtpe_req('rollback', 'coverage rollback', {
'call-id' => $cov_call, 'from-tag' => $cov_from, 'to-tag' => $cov_to,
});
is($cov_rollback->{'rolled-back'}, 1, 'coverage rollback applied');
is_deeply(durable_state(redis_record_after($before_roll)),
durable_state($committed_record),
'rollback restores the committed state in full');
# --- a rollback must remove state the rejected offer introduced ---
#
# Fields the encoder writes only when set -- tls_id, the DTLS fingerprint, the
# preferred hash function, the endpoint map -- are absent from a snapshot taken
# before they existed. Restoring has to treat that absence as "clear this", not
# as "leave it alone", or an upgrade to DTLS survives its own rollback.
new_call;
my ($up_call, $up_from, $up_to) = (cid(), ft(), tt());
redis_rtpe_req(1, 'offer', 'upgrade: plain offer', {
'from-tag' => $up_from, flags => ['track-state'],
sdp => plain_sdp('198.51.100.95', 15000),
});
redis_rtpe_req(0, 'answer', 'upgrade: plain answer', {
'from-tag' => $up_from, 'to-tag' => $up_to,
sdp => plain_sdp('198.51.100.96', 15010),
});
my $plain_record = $last_record;
redis_rtpe_req(1, 'offer', 'upgrade: rejected DTLS offer', {
'from-tag' => $up_from, 'to-tag' => $up_to,
sdp => dtls_upgrade_sdp('198.51.100.97', 15020),
});
my $upgraded = decode_record($last_record);
ok(defined decode($upgraded->{'media-0'}{hash_func}),
'the rejected offer really introduced DTLS state');
my $before_up = redis_sets_seen();
my $up_rollback = rtpe_req('rollback', 'upgrade: rollback', {
'call-id' => $up_call, 'from-tag' => $up_from, 'to-tag' => $up_to,
});
is($up_rollback->{'rolled-back'}, 1, 'upgrade rollback applied');
is_deeply(durable_state(redis_record_after($before_up)),
durable_state($plain_record),
'rollback removes DTLS state the rejected offer introduced');
# --- the snapshot-only state has to survive a rollback too ---
#
# ICE credentials and candidates, endpoint learning, offered codecs, tls_id, the
# preferred hash function and the endpoint map are written into snapshots only,
# so a record comparison cannot see them: a rollback could stop restoring any of
# them with every assertion above still green. Comparing the snapshot taken
# before the rejected offer against one taken after the rollback covers all of
# them at once, because a snapshot is taken before its offer is applied and so
# describes the state the rollback was supposed to reproduce.
new_call;
my ($rt_call, $rt_from, $rt_to) = (cid(), ft(), tt());
redis_rtpe_req(1, 'offer', 'round trip: offer', {
'from-tag' => $rt_from, flags => ['track-state'],
sdp => sdp('198.51.100.98', 15030, 'roundTripUfrag', 'roundTripPassword0123456',
'Ai0RVBUpx3FYuJEyv1oOTQVHrfXEIQGRxWLXQBvR', 'sendrecv'),
});
redis_rtpe_req(0, 'answer', 'round trip: answer', {
'from-tag' => $rt_from, 'to-tag' => $rt_to,
sdp => sdp('198.51.100.99', 15040, 'roundTripAnswer', 'roundTripAnswerPwd012345',
'HHf1TXWnpZlfXHBw5Q3xTNTIhFvbEHIYnmSMDGqR', 'sendrecv'),
});
redis_rtpe_req(1, 'offer', 'round trip: rejected offer', {
'from-tag' => $rt_from, 'to-tag' => $rt_to,
sdp => sdp('198.51.100.100', 15050, 'roundTripReject', 'roundTripRejectPwd01234',
'GHi1TXWnpZlfXHBw5Q3xTNTIhFvbEHIYnmSMDGqQ', 'sendrecv'),
});
my $snapshot_before = checkpoint_snapshot($last_record);
ok($snapshot_before, 'round trip: committed snapshot captured');
my $rt_rollback = rtpe_req('rollback', 'round trip: rollback', {
'call-id' => $rt_call, 'from-tag' => $rt_from, 'to-tag' => $rt_to,
});
is($rt_rollback->{'rolled-back'}, 1, 'round trip: rollback applied');
# The snapshot for this offer is taken before it is applied, so it describes the
# state the rollback restored.
redis_rtpe_req(1, 'offer', 'round trip: offer after rollback', {
'from-tag' => $rt_from, 'to-tag' => $rt_to,
sdp => sdp('198.51.100.101', 15060, 'roundTripAfter', 'roundTripAfterPwd012345',
'JKl1TXWnpZlfXHBw5Q3xTNTIhFvbEHIYnmSMDGqP', 'sendrecv'),
});
my $snapshot_after = checkpoint_snapshot($last_record);
ok($snapshot_after, 'round trip: post-rollback snapshot captured');
is_deeply($snapshot_after, $snapshot_before,
'rollback restores the snapshot-only state as well');
NGCP::Rtpengine::AutoTest::shut_rtpe();
done_testing;