Browse Source

Add autopkgtest for server2server TLS connections

Christoph Biedl 2 years ago
parent
commit
743fdd748c

+ 8 - 0
debian/tests/control

@@ -4,3 +4,11 @@ Depends: @,
     openssl,
     telnet,
 Restrictions: allow-stderr
+
+Tests: test-s2s-tls
+Depends: @,
+    faketime,
+    libfile-slurp-perl,
+    libnss-wrapper,
+    libproc-simple-perl,
+    openssl,

+ 8 - 0
debian/tests/data/dhparams.pem

@@ -0,0 +1,8 @@
+-----BEGIN DH PARAMETERS-----
+MIIBCAKCAQEA8WzJP514X2J0v7iOIuBR7rMaekjUfvq389RJxaUut4Ei7M7MO1gg
+TsQ0lCXOu5RGrt6IRbqYX96pah9NurODJhUbDaglDj7kSZFefGO/b8WTeVBLpYdu
+y1irAgIQOkmxDhChF0eZQpgArQcatNcFz/phLGul/L3M4b7okNJ589KyXlZdd99f
+yyLV1EuSoybpoTq3xx6LUaaOBOjXuuLbxNXNR+wDM88ZtT7dlbrjLDpj3Ma9QeSL
+f+W2pKQsa9TIH1Ne2GiJK+96H+y0I81yQCFqUlrWBZBf9pYC1LjmLS86OxTaxsnt
+OmwbrhcY2t9OHTSzp942QC4I0gfPdM2A6wIBAg==
+-----END DH PARAMETERS-----

+ 247 - 0
debian/tests/data/openssl.cnf

@@ -0,0 +1,247 @@
+#
+# OpenSSL example configuration file.
+# This is mostly being used for generation of certificate requests.
+#
+
+# This definition stops the following lines choking if HOME isn't
+# defined.
+HOME			= .
+RANDFILE		= $ENV::RNDFILE
+
+# Extra OBJECT IDENTIFIER info:
+#oid_file		= $ENV::HOME/.oid
+oid_section		= new_oids
+
+# To use this configuration file with the "-extfile" option of the
+# "openssl x509" utility, name here the section containing the
+# X.509v3 extensions to use:
+# extensions		=
+# (Alternatively, use a configuration file that has only
+# X.509v3 extensions in its main [= default] section.)
+
+[ new_oids ]
+
+# We can add new OIDs in here for use by 'ca' and 'req'.
+# Add a simple OID like this:
+# testoid1=1.2.3.4
+# Or use config file substitution like this:
+# testoid2=${testoid1}.5.6
+
+####################################################################
+[ ca ]
+default_ca	= CA_default		# The default ca section
+
+####################################################################
+[ CA_default ]
+
+dir		= $ENV::CA_DIR		# Where everything is kept
+certs		= $dir			# Where the issued certs are kept
+crl_dir		= $dir			# Where the issued crl are kept
+database	= $dir/index.txt	# database index file.
+new_certs_dir	= $dir			# default place for new certs.
+
+certificate	= $dir/root-ca.crt	 	# The CA certificate
+serial		= $dir/serial 		# The current serial number
+crl		= $dir/crl.pem 		# The current CRL
+private_key	= $dir/root-ca.key	 	# The private key
+RANDFILE	= $dir/.rand		# private random number file
+
+x509_extensions	= usr_cert		# The extentions to add to the cert
+
+# Extensions to add to a CRL. Note: Netscape communicator chokes on V2 CRLs
+# so this is commented out by default to leave a V1 CRL.
+# crl_extensions	= crl_ext
+
+default_days	= 3650			# how long to certify for
+default_crl_days= 30			# how long before next CRL
+default_md	= sha256		# which md to use.
+preserve	= no			# keep passed DN ordering
+
+# A few difference way of specifying how similar the request should look
+# For type CA, the listed attributes must be the same, and the optional
+# and supplied fields are just that :-)
+policy		= policy_match
+
+# For the CA policy
+[ policy_match ]
+countryName		= match
+stateOrProvinceName	= match
+organizationName	= match
+organizationalUnitName	= optional
+commonName		= supplied
+emailAddress		= optional
+
+# For the 'anything' policy
+# At this point in time, you must list all acceptable 'object'
+# types.
+[ policy_anything ]
+countryName		= optional
+stateOrProvinceName	= optional
+localityName		= optional
+organizationName	= optional
+organizationalUnitName	= optional
+commonName		= supplied
+emailAddress		= optional
+
+####################################################################
+[ req ]
+default_bits		= $ENV::KEY_SIZE
+default_keyfile 	= privkey.pem
+distinguished_name	= req_distinguished_name
+attributes		= req_attributes
+x509_extensions	= v3_ca	# The extentions to add to the self signed cert
+
+# Passwords for private keys if not present they will be prompted for
+# input_password = secret
+# output_password = secret
+
+# This sets a mask for permitted string types. There are several options.
+# default: PrintableString, T61String, BMPString.
+# pkix	 : PrintableString, BMPString.
+# utf8only: only UTF8Strings.
+# nombstr : PrintableString, T61String (no BMPStrings or UTF8Strings).
+# MASK:XXXX a literal mask value.
+# WARNING: current versions of Netscape crash on BMPStrings or UTF8Strings
+# so use this option with caution!
+string_mask = nombstr
+
+# req_extensions = v3_req # The extensions to add to a certificate request
+
+[ req_distinguished_name ]
+countryName			= Country Name (2 letter code)
+countryName_default		= $ENV::KEY_COUNTRY
+countryName_min			= 2
+countryName_max			= 2
+
+stateOrProvinceName		= State or Province Name (full name)
+stateOrProvinceName_default	= $ENV::KEY_PROVINCE
+
+localityName			= Locality Name (eg, city)
+localityName_default		= $ENV::KEY_CITY
+
+0.organizationName		= Organization Name (eg, company)
+0.organizationName_default	= $ENV::KEY_ORG
+
+# we can do this but it is not needed normally :-)
+#1.organizationName		= Second Organization Name (eg, company)
+#1.organizationName_default	= World Wide Web Pty Ltd
+
+organizationalUnitName		= Organizational Unit Name (eg, section)
+#organizationalUnitName_default	=
+
+commonName			= Common Name (eg, your name or your server\'s hostname)
+commonName_default = $ENV::KEY_COMMON
+commonName_max			= 64
+
+emailAddress			= Email Address
+emailAddress_default		= $ENV::KEY_EMAIL
+emailAddress_max		= 40
+
+# SET-ex3			= SET extension number 3
+
+[ req_attributes ]
+challengePassword		= A challenge password
+challengePassword_min		= 4
+challengePassword_max		= 20
+
+unstructuredName		= An optional company name
+
+[ usr_cert ]
+
+# These extensions are added when 'ca' signs a request.
+
+# This goes against PKIX guidelines but some CAs do it and some software
+# requires this to avoid interpreting an end user certificate as a CA.
+
+basicConstraints=CA:FALSE
+
+# Here are some examples of the usage of nsCertType. If it is omitted
+# the certificate can be used for anything *except* object signing.
+
+# This is OK for an SSL server.
+# nsCertType			= server
+
+# For an object signing certificate this would be used.
+# nsCertType = objsign
+
+# For normal client use this is typical
+# nsCertType = client, email
+
+# and for everything including object signing:
+# nsCertType = client, email, objsign
+
+# This is typical in keyUsage for a client certificate.
+# keyUsage = nonRepudiation, digitalSignature, keyEncipherment
+
+# This will be displayed in Netscape's comment listbox.
+nsComment			= "OpenSSL Generated Certificate"
+
+# PKIX recommendations harmless if included in all certificates.
+subjectKeyIdentifier=hash
+authorityKeyIdentifier=keyid,issuer:always
+
+# This stuff is for subjectAltName and issuerAltname.
+# Import the email address.
+# subjectAltName=email:copy
+
+# Copy subject details
+# issuerAltName=issuer:copy
+
+#nsCaRevocationUrl		= http://www.domain.dom/ca-crl.pem
+#nsBaseUrl
+#nsRevocationUrl
+#nsRenewalUrl
+#nsCaPolicyUrl
+#nsSslServerName
+
+[ v3_req ]
+
+# Extensions to add to a certificate request
+
+basicConstraints = CA:FALSE
+keyUsage = nonRepudiation, digitalSignature, keyEncipherment
+
+[ v3_ca ]
+
+
+# Extensions for a typical CA
+
+
+# PKIX recommendation.
+
+subjectKeyIdentifier=hash
+
+authorityKeyIdentifier=keyid:always,issuer:always
+
+# This is what PKIX recommends but some broken software chokes on critical
+# extensions.
+#basicConstraints = critical,CA:true
+# So we do this instead.
+basicConstraints = CA:true
+
+# Key usage: this is typical for a CA certificate. However since it will
+# prevent it being used as an test self-signed certificate it is best
+# left out by default.
+# keyUsage = cRLSign, keyCertSign
+
+# Some might want this also
+# nsCertType = sslCA, emailCA
+
+# Include email address in subject alt name: another PKIX recommendation
+# subjectAltName=email:copy
+# Copy issuer details
+# issuerAltName=issuer:copy
+
+# DER hex encoding of an extension: beware experts only!
+# obj=DER:02:03
+# Where 'obj' is a standard or added object
+# You can even override a supported extension:
+# basicConstraints= critical, DER:30:03:01:01:FF
+
+[ crl_ext ]
+
+# CRL extensions.
+# Only issuerAltName and authorityKeyIdentifier make any sense in a CRL.
+
+# issuerAltName=issuer:copy
+authorityKeyIdentifier=keyid:always,issuer:always

+ 251 - 0
debian/tests/data/openssl.san.cnf

@@ -0,0 +1,251 @@
+#
+# OpenSSL example configuration file.
+# This is mostly being used for generation of certificate requests.
+#
+
+# This definition stops the following lines choking if HOME isn't
+# defined.
+HOME			= .
+RANDFILE		= $ENV::RNDFILE
+
+# Extra OBJECT IDENTIFIER info:
+#oid_file		= $ENV::HOME/.oid
+oid_section		= new_oids
+
+# To use this configuration file with the "-extfile" option of the
+# "openssl x509" utility, name here the section containing the
+# X.509v3 extensions to use:
+# extensions		=
+# (Alternatively, use a configuration file that has only
+# X.509v3 extensions in its main [= default] section.)
+
+[ new_oids ]
+
+# We can add new OIDs in here for use by 'ca' and 'req'.
+# Add a simple OID like this:
+# testoid1=1.2.3.4
+# Or use config file substitution like this:
+# testoid2=${testoid1}.5.6
+
+####################################################################
+[ ca ]
+default_ca	= CA_default		# The default ca section
+
+####################################################################
+[ CA_default ]
+
+dir		= $ENV::CA_DIR		# Where everything is kept
+certs		= $dir			# Where the issued certs are kept
+crl_dir		= $dir			# Where the issued crl are kept
+database	= $dir/index.txt	# database index file.
+new_certs_dir	= $dir			# default place for new certs.
+
+certificate	= $dir/root-ca.crt	 	# The CA certificate
+serial		= $dir/serial 		# The current serial number
+crl		= $dir/crl.pem 		# The current CRL
+private_key	= $dir/root-ca.key	 	# The private key
+RANDFILE	= $dir/.rand		# private random number file
+
+x509_extensions	= usr_cert		# The extentions to add to the cert
+
+# Extensions to add to a CRL. Note: Netscape communicator chokes on V2 CRLs
+# so this is commented out by default to leave a V1 CRL.
+# crl_extensions	= crl_ext
+
+default_days	= 3650			# how long to certify for
+default_crl_days= 30			# how long before next CRL
+default_md	= sha256		# which md to use.
+preserve	= no			# keep passed DN ordering
+
+# A few difference way of specifying how similar the request should look
+# For type CA, the listed attributes must be the same, and the optional
+# and supplied fields are just that :-)
+policy		= policy_match
+
+# For the CA policy
+[ policy_match ]
+countryName		= match
+stateOrProvinceName	= match
+organizationName	= match
+organizationalUnitName	= optional
+commonName		= supplied
+emailAddress		= optional
+
+# For the 'anything' policy
+# At this point in time, you must list all acceptable 'object'
+# types.
+[ policy_anything ]
+countryName		= optional
+stateOrProvinceName	= optional
+localityName		= optional
+organizationName	= optional
+organizationalUnitName	= optional
+commonName		= supplied
+emailAddress		= optional
+
+####################################################################
+[ req ]
+default_bits		= $ENV::KEY_SIZE
+default_keyfile 	= privkey.pem
+distinguished_name	= req_distinguished_name
+attributes		= req_attributes
+x509_extensions	= v3_ca	# The extentions to add to the self signed cert
+
+# Passwords for private keys if not present they will be prompted for
+# input_password = secret
+# output_password = secret
+
+# This sets a mask for permitted string types. There are several options.
+# default: PrintableString, T61String, BMPString.
+# pkix	 : PrintableString, BMPString.
+# utf8only: only UTF8Strings.
+# nombstr : PrintableString, T61String (no BMPStrings or UTF8Strings).
+# MASK:XXXX a literal mask value.
+# WARNING: current versions of Netscape crash on BMPStrings or UTF8Strings
+# so use this option with caution!
+string_mask = nombstr
+
+# req_extensions = v3_req # The extensions to add to a certificate request
+
+[ req_distinguished_name ]
+countryName			= Country Name (2 letter code)
+countryName_default		= $ENV::KEY_COUNTRY
+countryName_min			= 2
+countryName_max			= 2
+
+stateOrProvinceName		= State or Province Name (full name)
+stateOrProvinceName_default	= $ENV::KEY_PROVINCE
+
+localityName			= Locality Name (eg, city)
+localityName_default		= $ENV::KEY_CITY
+
+0.organizationName		= Organization Name (eg, company)
+0.organizationName_default	= $ENV::KEY_ORG
+
+# we can do this but it is not needed normally :-)
+#1.organizationName		= Second Organization Name (eg, company)
+#1.organizationName_default	= World Wide Web Pty Ltd
+
+organizationalUnitName		= Organizational Unit Name (eg, section)
+#organizationalUnitName_default	=
+
+commonName			= Common Name (eg, your name or your server\'s hostname)
+commonName_default = $ENV::KEY_COMMON
+commonName_max			= 64
+
+emailAddress			= Email Address
+emailAddress_default		= $ENV::KEY_EMAIL
+emailAddress_max		= 40
+
+# SET-ex3			= SET extension number 3
+
+[ req_attributes ]
+challengePassword		= A challenge password
+challengePassword_min		= 4
+challengePassword_max		= 20
+
+unstructuredName		= An optional company name
+
+[ usr_cert ]
+
+# These extensions are added when 'ca' signs a request.
+
+# This goes against PKIX guidelines but some CAs do it and some software
+# requires this to avoid interpreting an end user certificate as a CA.
+
+basicConstraints=CA:FALSE
+
+# Here are some examples of the usage of nsCertType. If it is omitted
+# the certificate can be used for anything *except* object signing.
+
+# This is OK for an SSL server.
+# nsCertType			= server
+
+# For an object signing certificate this would be used.
+# nsCertType = objsign
+
+# For normal client use this is typical
+# nsCertType = client, email
+
+# and for everything including object signing:
+# nsCertType = client, email, objsign
+
+# This is typical in keyUsage for a client certificate.
+# keyUsage = nonRepudiation, digitalSignature, keyEncipherment
+
+# This will be displayed in Netscape's comment listbox.
+nsComment			= "OpenSSL Generated Certificate"
+
+# PKIX recommendations harmless if included in all certificates.
+subjectKeyIdentifier=hash
+authorityKeyIdentifier=keyid,issuer:always
+
+# This stuff is for subjectAltName and issuerAltname.
+# Import the email address.
+# subjectAltName=alt_names
+
+# Copy subject details
+# issuerAltName=issuer:copy
+
+#nsCaRevocationUrl		= http://www.domain.dom/ca-crl.pem
+#nsBaseUrl
+#nsRevocationUrl
+#nsRenewalUrl
+#nsCaPolicyUrl
+#nsSslServerName
+
+[ v3_req ]
+
+# Extensions to add to a certificate request
+
+basicConstraints = CA:FALSE
+keyUsage = nonRepudiation, digitalSignature, keyEncipherment
+
+[ v3_ca ]
+
+
+# Extensions for a typical CA
+
+
+# PKIX recommendation.
+
+subjectKeyIdentifier=hash
+
+authorityKeyIdentifier=keyid:always,issuer:always
+
+# This is what PKIX recommends but some broken software chokes on critical
+# extensions.
+#basicConstraints = critical,CA:true
+# So we do this instead.
+basicConstraints = CA:true
+
+# Key usage: this is typical for a CA certificate. However since it will
+# prevent it being used as an test self-signed certificate it is best
+# left out by default.
+# keyUsage = cRLSign, keyCertSign
+
+# Some might want this also
+# nsCertType = sslCA, emailCA
+
+# Include email address in subject alt name: another PKIX recommendation
+subjectAltName=@alt_names
+# Copy issuer details
+# issuerAltName=issuer:copy
+
+# DER hex encoding of an extension: beware experts only!
+# obj=DER:02:03
+# Where 'obj' is a standard or added object
+# You can even override a supported extension:
+# basicConstraints= critical, DER:30:03:01:01:FF
+
+[ crl_ext ]
+
+# CRL extensions.
+# Only issuerAltName and authorityKeyIdentifier make any sense in a CRL.
+
+# issuerAltName=issuer:copy
+authorityKeyIdentifier=keyid:always,issuer:always
+
+
+[alt_names]
+DNS.1 = $ENV::SUBJ_ALT_NAME

+ 142 - 0
debian/tests/gen-x509-stuff

@@ -0,0 +1,142 @@
+#!/bin/sh
+
+set -eu
+
+revoke=
+san=
+
+base_dir="$1"
+
+export OPENSSL_CONFIG="$base_dir/openssl.cnf"
+export OPENSSL_SAN_CONFIG="$base_dir/openssl.cnf"
+
+while [ "${2##--}" != "$2" ]; do
+    case "$2" in
+        --revoke)
+            revoke=1
+            ;;
+        --san)
+            san=1
+            # need an extra configuration file for that
+            export OPENSSL_SAN_CONFIG="$base_dir/openssl.san.cnf"
+            ;;
+        *)
+            echo "E: Unknown option '$2'"
+            exit 1
+            ;;
+    esac
+    shift
+done
+
+ca_name="$2"
+server_name="$3"
+
+if [ "${server_name##--}" != "$server_name" ]; then
+    echo "F: Parameter mismatch"
+    exit 1
+fi
+if [ -z "$server_name" ]; then
+    echo "Usage: $(dirname "$0") <base-directory> <CA name> <server name>"
+    exit 1
+fi
+
+dh_params="$base_dir/dhparams.pem"
+
+if [ ! -f "$dh_params" ]; then
+    echo 'I: Generating dh parameter file. This may take some time.'
+    openssl dhparam -2 -out "$dh_params" 2048
+fi
+
+export KEY_SIZE=4096
+
+export KEY_COUNTRY=DE
+export KEY_PROVINCE=Badenia
+export KEY_CITY=Littenweiler
+export KEY_ORG='A ngIRCd test site'
+
+export CA_DIR="$base_dir/$ca_name"
+
+export SERVER_DIR="$base_dir/$server_name"
+
+CRL_FILE="$CA_DIR/crl.pem"
+
+if [ ! -d "$CA_DIR" ]; then
+    echo 'I: Setting up CA first'
+
+    mkdir -p "$CA_DIR"
+
+    touch "$CA_DIR/index.txt"
+    echo '01' >"$CA_DIR/serial"
+
+    export KEY_COMMON='CA'
+    export KEY_EMAIL='ca@invalid.invalid'
+
+    openssl req \
+        -batch \
+        -days 10 \
+        -nodes \
+        -new \
+        -x509 \
+        -sha1 \
+        -newkey rsa:4096 \
+        -keyout "$CA_DIR/root-ca.key" \
+        -out "$CA_DIR/root-ca.crt" \
+        -config "$OPENSSL_CONFIG"
+    chmod 400 "$CA_DIR/root-ca.key"
+
+    openssl ca \
+        -gencrl \
+        -out "$CRL_FILE" \
+        -config "$OPENSSL_CONFIG"
+fi
+
+if [ "$san" ]; then
+    export KEY_COMMON="$server_name.bogus"
+    export SUBJ_ALT_NAME="$server_name"
+else
+    export KEY_COMMON="$server_name"
+fi
+export KEY_EMAIL='ca@invalid.invalid'
+
+server_csr="$CA_DIR/$server_name.csr"
+server_crt="$CA_DIR/$server_name.crt"
+server_key="$CA_DIR/$server_name.key"
+
+if [ -f "$server_crt" ]; then
+    echo "I: Certificate for '$server_name' already exists"
+    exit 1
+fi
+
+echo 'I: Creating CSR'
+openssl req \
+    -batch \
+    -days 10 \
+    -nodes \
+    -new \
+    -sha1 \
+    -newkey rsa:4096 \
+    -keyout "$server_key" \
+    -out "$server_csr" \
+    -config "$OPENSSL_SAN_CONFIG"
+
+echo 'I: Signing certificate'
+openssl ca \
+    -extensions v3_ca \
+    -batch \
+    -days 10 \
+    -out "$server_crt" \
+    -in "$server_csr" \
+    -config "$OPENSSL_SAN_CONFIG"
+
+if [ "$revoke" ]; then
+    echo 'I: Revoking certificate'
+    openssl ca \
+        -revoke "$server_crt" \
+        -config "$OPENSSL_CONFIG"
+    openssl ca \
+        -gencrl \
+        -out "$CRL_FILE" \
+        -config "$OPENSSL_CONFIG"
+fi
+
+exit 0

+ 997 - 0
debian/tests/test-s2s-tls

@@ -0,0 +1,997 @@
+#!/usr/bin/perl
+
+#%# Copyright (C) 2014-2024 Christoph Biedl <debian.axhn@manchmal.in-ulm.de>
+#%# 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 [<test-name> ...]
+
+=cut
+
+use 5.010;
+use strict;
+use warnings;
+
+use Test::More;
+
+use File::Basename;
+use File::Copy;
+use File::Slurp;
+use File::Temp qw<tempdir>;
+use Getopt::Long;
+use Pod::Usage;
+use Proc::Simple;
+use Socket;
+use Time::HiRes qw<sleep time>;
+
+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<directory>
+
+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<mode>
+
+One of C<openssl> or C<gnutls>.
+
+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<program>
+
+The ngircd binary. For TLS interoperability tests, you can provide two
+programs, separated by C<:> (colon).
+
+Default: F</usr/sbin/ngircd>
+
+=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 [ <test> ... ]
+
+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 =~ /^
+                \[
+                (?<pid>[0-9]+):
+                (?<prio>[0-9])\s+
+                (?<time>[0-9]+\.[0-9]+)
+                \]\s(?<msg>.+)
+            $/x) {
+                $srt = [
+                    $+{'time'},
+                    $server,
+                    $+{'prio'},
+                    $+{'msg'},
+                ];
+            } else {
+                fail ("Cannot parse log line '$line'");
+                $srt = [ 0, $server, 0, $line ];
+            }
+            $log_sorter[$i] = $srt;
+        }
+
+        # sort the log file by time, process, line
+        my @idx = sort {
+            $log_sorter[$a][0] <=> $log_sorter[$b][0] ||
+            $log_sorter[$a][1] <=> $log_sorter[$b][1] ||
+            $log_sorter[$a][3] cmp $log_sorter[$b][3] ||
+            $a cmp $b
+        } 0..$#log_sorter;
+
+        note ('combined log ([server:prio ms])');
+        my $t1 = $log_sorter[$idx[0]][0];
+        foreach my $idx (@idx) {
+            my $data = $log_sorter[$idx];
+            note (sprintf (
+                '| %s[%s:%d %.6f] %s%s',
+                ($data->[1] == 1 ? $GREEN : $RED),
+                $data->[1],
+                $data->[2],
+                $data->[0] - $t1,
+                $data->[3],
+                $NORMAL,
+            ));
+        }
+    } else {
+        # Just combine
+        my @log;
+        @log = read_file ($server1_log);
+        note ('server 1 log:');
+        foreach my $line (@log) {
+            note ("1: $line");
+        }
+        @log = read_file ($server2_log);
+        note ('server 2 log:');
+        foreach my $line (@log) {
+            note ("2: $line");
+        }
+    }
+}
+
+
+# start the show
+
+if ($x509_populate) {
+    note ('setting up x509 stuff');
+
+    my $fail_x509;
+
+    $ENV{'RNDFILE'} = "$temp_dir/.rnd";
+
+    for my $f (qw<dhparams.pem openssl.cnf openssl.san.cnf>) {
+        copy ("$src_dir/data/$f", "$x509_dir/$f") or
+            die ("Failed to copy $f: $!");
+    }
+
+    foreach my $command ((
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA1 server1.example.com> ],
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA1 server2.example.com> ],
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA1 server3.example.com> ],
+
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA2 server2.example.com> ],
+
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA3 server1.example.com> ],
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<--revoke CA3 server2.example.com> ],
+
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA4 server1.example.com> ],
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA4 *.example.com> ],
+
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA5 server1.example.com> ],
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA5 Server2.example.com> ],
+
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<CA6 server1.example.com> ],
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<--san CA6 server2.example.com> ],
+        [ "$src_dir/gen-x509-stuff", $x509_dir, qw<--san CA6 server3.example.com> ],
+    )) {
+        my $run = Proc::Simple->new;
+        my $log = "$temp_dir/gen-stuff.log";
+        write_file ($log, '');
+        $run->redirect_output ($log, $log);
+        $run->start (@$command);
+        my $exit = $run->wait;
+        my $fail = !is (
+            $exit,
+            0,
+            'gen-x509-stuff ' . join (' ', @$command[2..$#$command]),
+        );
+        $fail and $fail_x509++;
+        if ($fail || $verbose) {
+            my $out = read_file ($log);
+            $log and note ("Output:\n$out");
+        }
+    }
+    $fail_x509 and exit 1;
+    if ($exit_after_x509_populate) {
+        pass ('here we go');
+        done_testing;
+        exit 0;
+    }
+}
+
+$ENV{'LD_PRELOAD'} = 'libnss_wrapper.so';
+$ENV{'NSS_WRAPPER_HOSTS'} = $hosts_file;
+
+note ('checking mocked resolver');
+{
+    my $fail;
+
+    foreach my $hostname (qw<server1 server2>) {
+        my $output = `getent hosts $hostname`;
+        is ($?, 0, "resolve $hostname") or $fail++;
+        is ($?, 0, "resolve $hostname.example.com") or $fail++;
+    }
+}
+
+my @tests;
+# check test description integrity
+{
+    my $fail;
+    my @required = qw<expect :order>;  # NB: pass and fail may be missing
+    foreach my $test (sort keys %tests) {
+        foreach my $r (@required) {
+            exists ($tests{$test}{$r}) and next;
+            fail ("No '$r' field in test '$test'");
+            $fail++;
+        }
+    }
+    $fail and die ('Cannot continue');
+    @tests =
+        sort { $tests{$a}{':order'} <=> $tests{$b}{':order'} }
+        keys %tests;
+
+    foreach my $test (@tests) {
+        my $got = $tests{$test}{'expect'};
+        if ($got =~ /^(pass|fail)$/) {
+            $tests{$test}{'expect'} = $got eq 'pass' ? 1 : 0;
+            next;
+        }
+        fail ("The 'expect' in test '$test' is '$got', not 'pass' or 'fail'");
+        $fail++;
+    }
+    $fail and die ('Cannot continue');
+    # drop those who are expected to fail
+    @tests = grep { !$tests{$_}{'unsupported'} } @tests;
+}
+
+if (@ARGV) {
+    my $warned;
+    foreach my $t (@ARGV) {
+        if (exists ($tests{$t})) {
+            $tests{$t}{'unsupported'} and
+                note ("Warn: Test '$t' is marked unsupported. Expect breakage");
+            test1 ($t, $tests{$t});
+            next;
+        }
+        fail ("Don't know how to test '$t'");
+        if (!$warned) {
+            note ("Available tests:\n" . join ("\n", map { "    $_" } @tests));
+            $warned = 1;
+        }
+    }
+} else {
+    # run all
+    foreach my $test (@tests) {
+        test1 ($test, $tests{$test});
+    }
+}
+
+done_testing;
+
+exit 0;
+
+=head1 DESCRIPTION
+
+Build ngircd for both TLS linkages, possibly using the following script:
+
+    #!/bin/sh
+    set -e
+    case "$1" in
+        openssl | gnutls)
+            [ -f ./configure ] || ./autogen.sh
+            ./configure \
+                --prefix=/usr \
+                --mandir="\${prefix}/share/man" \
+                --infodir="\${prefix}/share/info" \
+                --sysconfdir=/etc/ngircd \
+                "--with-$1"
+            make 2>&1 | tee "../build.$1.log"
+            cp src/ngircd/ngircd "../ngircd-$1"
+            ;;
+        *)
+            echo "Usage: $0 <gnutls|openssl>"
+            exit 1
+            ;;
+    esac
+
+One time only: Create the certificates
+
+    perl ngircd-test-tls-link \
+        --x509-populate \
+        --x509-dir ../x509-data/ \
+        --ngircd <path to any ngircd binary>
+
+Then run this program
+
+For GnuTLS:
+
+    ngircd-test-tls-link \
+        --x509-dir ../x509-data/ \
+        --ngircd ../ngircd-gnutls
+
+Likewise for OpenSSL:
+
+    (...)
+        --ngircd ../ngircd-openssl
+
+For interoperability tests:
+
+    ngircd-test-tls-link \
+        --x509-dir ../x509-data/ \
+        --ngircd ../ngircd-gnutls:../ngircd-openssl
+
+... also with the two binaries swapped.
+
+=head1 DEPENDENCIES
+
+The following programs, libraries and Perl modules must be installed:
+
+    faketime
+    openssl
+    libnss-wrapper
+    File::Slurp
+    Proc::Simple
+
+=head1 BUGS
+
+Hack.
+
+=head1 SEE ALSO
+
+ngircd(8)
+
+=head1 AUTHOR
+
+Christoph Biedl C<< <debian.axhn@manchmal.in-ulm.de> >>
+
+=head1 ACKNOWLEDGEMENTS
+
+Alex Barton for ngircd.
+
+=head1 COPYRIGHT & LICENSE
+
+    Copyright (C) 2014-2024 Christoph Biedl <debian.axhn@manchmal.in-ulm.de>
+
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.
+
+    This package is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program. If not, see <http://www.gnu.org/licenses/>
+
+    On Debian systems, the complete text of the GNU General Public
+    License version 2 can be found in
+    "/usr/share/common-licenses/GPL-2".
+
+=cut