#!/usr/bin/perl #%# Copyright (C) 2014-2024 Christoph Biedl #%# License: GPL-2.0+ =head1 NAME ngircd-test-tls-link - test certificate validation in ngircd's TLS based s2s links =head1 VERSION Version YYYY.MM.DD =cut our $VERSION = 'YYYY.MM.DD'; =head1 SYNOPSIS ngircd-test-tls-link --x509-dir /path/to/dir --x509-populate ngircd-test-tls-link --x509-dir /path/to/dir [ ...] =cut use 5.010; use strict; use warnings; use Test::More; use File::Basename; use File::Copy; use File::Slurp; use File::Temp qw; use Getopt::Long; use Pod::Usage; use Proc::Simple; use Socket; use Time::HiRes qw; my $ngircd_exe_default = '/usr/sbin/ngircd'; my @ngircd_exe = ( $ngircd_exe_default ); my @tls_mode; my $x509_dir; my $x509_populate; my $verbose = 0; =head1 OPTIONS =over =cut { my $help; my $man; my %GetOptions = ( 'help|?' => \$help, 'man' => \$man, ); =item B<--x509-dir> F A directory to hold the x509 credentials needed. Default: Use a temporary directory which is deleted upon exit. This delays every execution of this program by a few seconds. =cut $GetOptions{'x509-dir=s'} = \$x509_dir; =item B<--x509-populate> Populate a given C<--x509-dir> with the files needed, then exit. =cut $GetOptions{'x509-populate'} = \$x509_populate; =item B<--tls-mode> C One of C or C. The type of TLS configuration to use. Must match ngircd's compilation option. Use only if auto-detection failed, and report a bug then. For TLS interoperability tests, you can provide two strings, separated by C<:> (colon). Default: Detect from the binary. =cut $GetOptions{'tls-mode=s'} = sub { @tls_mode = split (/:/, $_[1], 2); }; =item B<--ngircd> F The ngircd binary. For TLS interoperability tests, you can provide two programs, separated by C<:> (colon). Default: F =cut $GetOptions{'ngircd=s'} = sub { @ngircd_exe = split (/:/, $_[1], 2); }; =item B<--verbose> Add verbosity. =cut $GetOptions{'verbose+'} = \$verbose++; =item B<--Version> Show the version number and exit. =cut $GetOptions{'Version'} = sub { printf "%s version %s\n", (split (/\//, $0))[-1], $VERSION; exit 1; }; =item [ ... ] Select specific tests to run only. Default: Run all tests. =back =cut GetOptions (%GetOptions) or pod2usage (2); $help and pod2usage (1); $man and pod2usage (-exitstatus => 0, -verbose => 2); if (@tls_mode) { foreach my $m (@tls_mode) { ($m =~ /^(openssl|gnutls)$/) or die ("Invalid '--tls-mode' value '$m'"); } } else { for (my $i = 0; $i < scalar (@ngircd_exe); $i++) { my $x = $ngircd_exe[$i]; my $tls_mode; (-x $x) or die ("Not an executable: '$x'"); my $pipe; open ($pipe, '-|', 'ldd', $x) or die ("Cannot run ldd on '$x': $!"); while (defined (my $line = <$pipe>)) { if ($line =~ /^\tlibssl\.so\./) { $tls_mode = 'openssl'; last; } elsif ($line =~ /^\tlibgnutls/) { $tls_mode = 'gnutls'; last; } } close ($pipe); if ($tls_mode) { note ("Auto-detected TLS mode for '$x' is '$tls_mode'"); $tls_mode[$i] = $tls_mode; } else { die ("BUG: Failed to determine TLS mode for '$x'"); } } } $ngircd_exe[1] //= $ngircd_exe[0]; $tls_mode[1] //= $tls_mode[0]; } my $temp_dir = tempdir ( "ngircd-test-tls-link.$$.XXXXX", 'TMPDIR' => 1, 'CLEANUP' => 1, ); my $exit_after_x509_populate; if ($x509_dir) { (-d $x509_dir || $x509_populate) or die ("Not a directory: '$x509_dir' - perhaps you forgot --x509-populate?"); $exit_after_x509_populate = $x509_populate; } else { # not provided, use a temporary directoryy $x509_dir = "$temp_dir/CA"; # always populate it, and continue $x509_populate and note ('Ignoring --x509-populate option as no --x509-dir was provided'); $x509_populate = 1; $exit_after_x509_populate = undef; } my $conf_dir = "$temp_dir/conf"; my $hosts_file ="$temp_dir/hosts"; write_file ( $hosts_file, <<__EOS__, 127.0.0.1 server1 127.0.0.1 server2 127.0.0.1 server1.example.com 127.0.0.1 server2.example.com __EOS__ ); foreach my $dir (($x509_dir, $conf_dir)) { ( -d $dir || mkdir ($dir) ) or die ("Cannot create directory '$dir'"); } my $src_dir = dirname ($0); my $one_month_future = $^T + 30*86400; my %tests = ( 'regular' => { 'expect' => 'pass', 'fail' => qr/Connection [0-9]+ with "server2:6692" closed/, ':order' => __LINE__, }, 'different-CAs' => { 'expect' => 'pass', 'server1-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA2/root-ca.crt", }, }, 'server2-extra' => { 'SSL' => { 'CertFile' => "$x509_dir/CA2/server2.example.com.crt", 'KeyFile' => "$x509_dir/CA2/server2.example.com.key", }, }, ':order' => __LINE__, }, 'CN-mismatch' => { # using certificate with wrong CN, must fail 'expect' => 'fail', 'server2-extra' => { 'SSL' => { 'CertFile' => "$x509_dir/CA1/server3.example.com.crt", 'KeyFile' => "$x509_dir/CA1/server3.example.com.key", }, }, 'pass' => qr/Peer certificate check failed for/, ':order' => __LINE__, }, 'using-peer-cert-as-CA' => { # using peer cert as CA a.k.a. self-signed 'unsupported' => 1, # fails on openssl 'expect' => 'pass', 'server1-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA1/server2.example.com.crt", }, }, ':order' => __LINE__, }, 'peer-cert-signed-by-unknown-CA' => { # peer cert signed by unknown CA, must fail 'expect' => 'fail', 'server2-extra' => { 'SSL' => { 'CertFile' => "$x509_dir/CA2/server2.example.com.crt", 'KeyFile' => "$x509_dir/CA2/server2.example.com.key", }, }, ':order' => __LINE__, }, 'peer-cert-signed-by-unknown-CA-but-verify-disabled' => { 'expect' => 'pass', 'server1-extra' => { 'Server' => { 'SSLVerify' => 'no', } }, 'server2-extra' => { 'SSL' => { 'CertFile' => "$x509_dir/CA2/server2.example.com.crt", 'KeyFile' => "$x509_dir/CA2/server2.example.com.key", }, }, 'pass' => qr/Synchronization with "ngircd\.test\.server2" done/, 'noop' => qr/Certificate validation failed/, ':order' => __LINE__, }, 'peer-cert-revoked' => { # certificate is revoked, must fail 'expect' => 'fail', 'server1-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA3/root-ca.crt", 'CertFile' => "$x509_dir/CA3/server1.example.com.crt", 'KeyFile' => "$x509_dir/CA3/server1.example.com.key", 'CRLFile' => "$x509_dir/CA3/crl.pem", }, }, 'server2-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA3/root-ca.crt", 'CertFile' => "$x509_dir/CA3/server2.example.com.crt", 'KeyFile' => "$x509_dir/CA3/server2.example.com.key", }, }, ':order' => __LINE__, }, 'peer-cert-revoked-but-verify-disabled' => { 'expect' => 'pass', 'server1-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA3/root-ca.crt", 'CertFile' => "$x509_dir/CA3/server1.example.com.crt", 'KeyFile' => "$x509_dir/CA3/server1.example.com.key", 'CRLFile' => "$x509_dir/CA3/crl.pem", }, 'Server' => { 'SSLVerify' => 'no', } }, 'server2-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA3/root-ca.crt", 'CertFile' => "$x509_dir/CA3/server2.example.com.crt", 'KeyFile' => "$x509_dir/CA3/server2.example.com.key", }, }, 'pass' => qr/Synchronization with "ngircd\.test\.server2" done/, 'noop' => qr/Certificate validation failed/, ':order' => __LINE__, }, 'revocation-by-wrong-CA' => { # CRL signed by a different CA 'expect' => 'fail', 'server1-extra' => { 'SSL' => { 'CRLFile' => "$x509_dir/CA2/crl.pem", }, }, ':order' => __LINE__, }, 'wildcard-cert' => { # server uses a wildcard certificate 'expect' => 'pass', 'server1-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA4/root-ca.crt", 'CertFile' => "$x509_dir/CA4/server1.example.com.crt", 'KeyFile' => "$x509_dir/CA4/server1.example.com.key", }, }, 'server2-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA4/root-ca.crt", 'CertFile' => "$x509_dir/CA4/*.example.com.crt", 'KeyFile' => "$x509_dir/CA4/*.example.com.key", }, }, ':order' => __LINE__, }, 'mixed-case' => { # uppercase letters in certificate name 'expect' => 'pass', 'server1-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA5/root-ca.crt", 'CertFile' => "$x509_dir/CA5/server1.example.com.crt", 'KeyFile' => "$x509_dir/CA5/server1.example.com.key", }, }, 'server2-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA5/root-ca.crt", 'CertFile' => "$x509_dir/CA5/Server2.example.com.crt", 'KeyFile' => "$x509_dir/CA5/Server2.example.com.key", }, }, ':order' => __LINE__, }, 'subject-alternate-name' => { 'expect' => 'pass', 'server1-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA6/root-ca.crt", 'CertFile' => "$x509_dir/CA6/server1.example.com.crt", 'KeyFile' => "$x509_dir/CA6/server1.example.com.key", }, }, 'server2-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA6/root-ca.crt", 'CertFile' => "$x509_dir/CA6/server2.example.com.crt", 'KeyFile' => "$x509_dir/CA6/server2.example.com.key", }, }, ':order' => __LINE__, }, 'subject-alternate-name-mismatch' => { 'expect' => 'fail', 'pass' => qr/Failed to verify the hostname, expected/, 'server1-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA6/root-ca.crt", 'CertFile' => "$x509_dir/CA6/server1.example.com.crt", 'KeyFile' => "$x509_dir/CA6/server1.example.com.key", }, }, 'server2-extra' => { 'SSL' => { 'CAFile' => "$x509_dir/CA6/root-ca.crt", 'CertFile' => "$x509_dir/CA6/server3.example.com.crt", 'KeyFile' => "$x509_dir/CA6/server3.example.com.key", }, }, ':order' => __LINE__, }, 'cert-expired' => { 'expect' => 'fail', 'pass' => qr/SSL error: A TLS fatal alert has been received/, 'prefix' => [ 'faketime', "\@$one_month_future" ], ':order' => __LINE__, }, ); sub write_config { my ($file, $number, $tls_mode, @extras) = @_; my $cipher_list = ($tls_mode =~ /^o/ ? 'HIGH:!aNULL:@STRENGTH:!SSLv3' : 'SECURE128:-VERS-SSL3.0' ); my $peer_number = 3 - $number; # write server configurations my %config = ( 'Global' => { 'Name' => "ngircd.test.server$number", 'Info' => "ngIRCd Test-Server $number", 'Listen' => '127.0.0.1', 'Ports' => "678$number", 'AdminEMail' => "admin\@server$number.example", 'ServerUID' => $<, 'ServerGID' => $(, 'MotdFile' => '/dev/null', }, 'Options' => { 'OperCanUseMode' => 'yes', 'Ident' => 'no', 'IncludeDir' => '', 'PAM' => 'no', }, 'Operator' => { 'Name' => 'TestOp', 'Password' => '123', }, 'Server' => { 'Name' => "ngircd.test.server$peer_number", 'Host' => "server$peer_number.example.com", 'Port' => "669$peer_number", 'MyPassword' => "pwd$number", 'PeerPassword' => "pwd$peer_number", 'SSLConnect' => 'yes', 'SSLVerify' => 'yes', }, 'SSL' => { 'CAFile' => "$x509_dir/CA1/root-ca.crt", 'CertFile' => "$x509_dir/CA1/server$number.example.com.crt", 'CipherList' => $cipher_list, 'DHFile' => "$x509_dir/dhparams.pem", 'KeyFile' => "$x509_dir/CA1/server$number.example.com.key", 'Ports' => "669$number", }, ); foreach my $extra (@extras) { foreach my $section (keys %$extra) { foreach my $key (keys %{$extra->{$section}}) { if ( exists ($config{$section}{$key}) && defined ($config{$section}{$key}) && exists ($extra->{$section}{$key}) && defined ($extra->{$section}{$key}) && $config{$section}{$key} eq $extra->{$section}{$key} ) { note ("W: Identical re-definition of server/section/key: $number/$section/$key"); } $config{$section}{$key} = $extra->{$section}{$key}; } } } my $return = ''; my $fh; open ($fh, '>', \$return); foreach my $section (sort keys %config) { print $fh "[$section]\n"; foreach my $key (sort keys %{$config{$section}}) { my $value = $config{$section}{$key}; defined ($value) and printf $fh " %s = %s\n", $key, $value; } } close ($fh); write_file ($file, $return); } sub write_configs { my ( $server1_config_file, $server2_config_file, $server1_config_extra, $server2_config_extra, ) = @_; write_config ( $server1_config_file, '1', $tls_mode[0], { 'SSL' => { 'Ports' => undef, }, }, $server1_config_extra, ); write_config ( $server2_config_file, '2', $tls_mode[1], { 'Server' => { 'Passive' => 'yes', }, }, $server2_config_extra, ); } sub test1 { my ($name, $test) = @_; { my $l = length ($name); note ('+-' . ('-' x $l) . '-+'); note ("| $name | "); note ('+-' . ('-' x $l) . '-+'); } my $server1_config = "$conf_dir/ngircd-test1.conf"; my $server2_config = "$conf_dir/ngircd-test2.conf"; write_configs ( $server1_config, $server2_config, $test->{'server1-extra'}, $test->{'server2-extra'}, ), my $prefix = $test->{'prefix'} // []; # start the receiving server2 first, it might need a # little extra time my $server2_log = "$temp_dir/server2.log"; write_file ($server2_log, ''); # so open below won't fail my $server2 = Proc::Simple->new; $server2->redirect_output ($server2_log, $server2_log); $server2->start (( @$prefix, $ngircd_exe[1], '--config', $server2_config, '--nodaemon', )); $server2->kill_on_destroy (1); sleep (0.5); # start connecting server1 my $server1_log = "$temp_dir/server1.log"; write_file ($server1_log, ''); # so open below won't fail my $server1 = Proc::Simple->new; $server1->redirect_output ($server1_log, $server1_log); $server1->start (( @$prefix, $ngircd_exe[0], '--config', $server1_config, '--nodaemon', )); $server1->kill_on_destroy (1); sleep (0.5); my $fh; open ($fh, '<', $server1_log) or die ("Cannot read '$server1_log': $!"); # read output from server1, find 'pass' or 'fail' line my $timeout = time + 10; my $t0 = time; my $firstline; my $got_verdict; TAIL: while (1) { if (time > $timeout) { fail (sprintf ('Timeout (%u sec)', time - $t0)); last TAIL; } my $curpos; my $line; for ($curpos = tell ($fh); $line = <$fh>; $curpos = tell ($fh)) { chomp ($line); $firstline //= $line; $verbose and note (sprintf ('%.2f %s', time-$t0, $line)); # generic pass/fail pattern my $passed; if ($line =~ /(Can't bind socket to address 127\.0\.0\.1.*$)/) { # previous instance running fail ($1); $got_verdict = 1; last TAIL; } elsif ( # configured pass/fail pattern $test->{'pass'} && $line =~ /$test->{'pass'}/ ) { $passed = 1; } elsif ($test->{'fail'} && $line =~ /$test->{'fail'}/) { $passed = 0; } elsif ($test->{'noop'} && $line =~ /$test->{'noop'}/) { next; } elsif ($line =~ /Synchronization with "ngircd\.test\.server2" done/) { $passed = $test->{'expect'}; } elsif ($line =~ /Certificate validation failed/) { $passed = 1 - $test->{'expect'}; } elsif ($line =~ /Fatail: /) { # some fatal error fail ($1); } if (defined ($passed)) { if ($passed) { pass ("Got expected line: '$line'"); } else { fail ("Got line that should not be there: '$line'"); } $got_verdict = 1; last TAIL; } } if (!$server1->poll) { note ('server1 has left the building'); $got_verdict = 1; last TAIL; } sleep (0.1); seek ($fh, $curpos, 0); } close ($fh); $server1->kill; $server2->kill; wait; ok ($got_verdict, 'have a verdict'); if ($firstline && $firstline =~ /^\[[0-9]+:[0-9] +[0-9]+\./) { # have absolute timestamps my @log = read_file ($server1_log); my $first_server2_line = scalar (@log); push @log, read_file ($server2_log); my $RED = "\e[1;31m"; my $GREEN = "\e[1;32m"; my $NORMAL = "\e[0m"; my @log_sorter; for (my $i = 0; $i < scalar (@log); $i++) { my $line = $log[$i]; chomp ($line); my $srt; my $server = $i < $first_server2_line ? 1 : 2; if ($line =~ /^ \[ (?[0-9]+): (?[0-9])\s+ (?