Rewrite integration test suite in Python
authorKirill Isakov <bootctl@gmail.com>
Sun, 10 Apr 2022 07:53:25 +0000 (13:53 +0600)
committerKirill Isakov <bootctl@gmail.com>
Sun, 10 Apr 2022 08:42:59 +0000 (14:42 +0600)
While the previous test suite ran fine in practice, it relied on
subtle differences in behavior of many UNIX tools, and thus needed GNU
Coreutils on many operating systems to work properly, and didn't work on
"pure" Windows at all.

A simple example would be how different versions of tail handle SIGPIPE
if you pipe its output into another process: GNU tail exits on SIGPIPE
since about 2017 (too lazy to look up the exact version, but this
changed recently), while most other tails continue reading the file and
piping the output into god knows where.

Since we need Python to run the new build system (meson) anyway, let's
use it for the test suite, and get rid of all other test dependencies.

It (currently) requires only the standard library of Python 3.6 or newer.

Unlike the original test suite, this one assigns node names and port
numbers randomly to support `meson test --repeat` (because meson can run
the same test multiple times in parallel, and this breaks with the old
approach).

Also remove old integration tests based on shell scripts.

56 files changed:
.pylintrc [new file with mode: 0644]
src/sptps_test.c
test/integration/algorithms.py [new file with mode: 0755]
test/integration/algorithms.test [deleted file]
test/integration/basic.py [new file with mode: 0755]
test/integration/basic.test [deleted file]
test/integration/command-fsck.test [deleted file]
test/integration/command_fsck.py [new file with mode: 0755]
test/integration/commandline.py [new file with mode: 0755]
test/integration/commandline.test [deleted file]
test/integration/compression.py [new file with mode: 0755]
test/integration/compression.test [deleted file]
test/integration/executables.py [new file with mode: 0755]
test/integration/executables.test [deleted file]
test/integration/import-export.test [deleted file]
test/integration/import_export.py [new file with mode: 0755]
test/integration/invite-join.test [deleted file]
test/integration/invite-offline.test [deleted file]
test/integration/invite-tinc-up.test [deleted file]
test/integration/invite.py [new file with mode: 0755]
test/integration/invite_tinc_up.py [new file with mode: 0755]
test/integration/legacy-protocol.test [deleted file]
test/integration/legacy_protocol.py [new file with mode: 0755]
test/integration/meson.build
test/integration/ns-ping.test [deleted file]
test/integration/ns_ping.py [new file with mode: 0755]
test/integration/scripts.py [new file with mode: 0755]
test/integration/scripts.test [deleted file]
test/integration/security-legacy.test [deleted file]
test/integration/security-sptps.test [deleted file]
test/integration/security.py [new file with mode: 0755]
test/integration/security.test [deleted file]
test/integration/splice.py [new file with mode: 0755]
test/integration/sptps-basic.test [deleted file]
test/integration/sptps_basic.py [new file with mode: 0755]
test/integration/testlib.sh [deleted file]
test/integration/testlib/__init__.py [new file with mode: 0755]
test/integration/testlib/check.py [new file with mode: 0755]
test/integration/testlib/cmd.py [new file with mode: 0755]
test/integration/testlib/const.py [new file with mode: 0755]
test/integration/testlib/event.py [new file with mode: 0755]
test/integration/testlib/external.py [new file with mode: 0755]
test/integration/testlib/log.py [new file with mode: 0755]
test/integration/testlib/notification.py [new file with mode: 0755]
test/integration/testlib/path.py [new file with mode: 0755]
test/integration/testlib/proc.py [new file with mode: 0755]
test/integration/testlib/script.py [new file with mode: 0755]
test/integration/testlib/template.py [new file with mode: 0755]
test/integration/testlib/template/netns.py.tpl [new file with mode: 0644]
test/integration/testlib/template/script.cmd.tpl [new file with mode: 0644]
test/integration/testlib/template/script.py.tpl [new file with mode: 0644]
test/integration/testlib/test.py [new file with mode: 0755]
test/integration/testlib/util.py [new file with mode: 0755]
test/integration/variables.py [new file with mode: 0755]
test/integration/variables.test [deleted file]
test/meson.build

diff --git a/.pylintrc b/.pylintrc
new file mode 100644 (file)
index 0000000..b02eaa0
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,19 @@
+[MASTER]
+jobs=0
+persistent=yes
+py-version=3.6
+recursive=yes
+ignore=build
+
+[BASIC]
+good-names=foo, bar, baz, f, k, ex
+
+[REPORTS]
+output-format=colorized
+
+[DESIGN]
+min-public-methods=0
+
+[SIMILARITIES]
+min-similarity-lines=10
+
index 7867b72..50057e2 100644 (file)
@@ -301,6 +301,19 @@ server_err:
 
 #endif // HAVE_WINDOWS
 
+static void print_listening_msg(int sock) {
+       sockaddr_t sa = {0};
+       socklen_t salen = sizeof(sa);
+       int port = 0;
+
+       if(!getsockname(sock, &sa.sa, &salen)) {
+               port = ntohs(sa.in.sin_port);
+       }
+
+       fprintf(stderr, "Listening on %d...\n", port);
+       fflush(stderr);
+}
+
 int main(int argc, char *argv[]) {
        program_name = argv[0];
        bool initiator = false;
@@ -481,7 +494,7 @@ int main(int argc, char *argv[]) {
                                return 1;
                        }
 
-                       fprintf(stderr, "Listening...\n");
+                       print_listening_msg(sock);
 
                        sock = accept(sock, NULL, NULL);
 
@@ -490,7 +503,7 @@ int main(int argc, char *argv[]) {
                                return 1;
                        }
                } else {
-                       fprintf(stderr, "Listening...\n");
+                       print_listening_msg(sock);
 
                        char buf[65536];
                        struct sockaddr addr;
diff --git a/test/integration/algorithms.py b/test/integration/algorithms.py
new file mode 100755 (executable)
index 0000000..b056c7d
--- /dev/null
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+
+"""Check that legacy protocol works with different cryptographic algorithms."""
+
+import typing as T
+
+from testlib.test import Test
+from testlib.proc import Tinc
+from testlib.log import log
+from testlib import cmd, check
+
+
+def init(ctx: Test, digest: str, cipher: str) -> T.Tuple[Tinc, Tinc]:
+    """Initialize new test nodes."""
+    foo, bar = ctx.node(), ctx.node()
+
+    stdin = f"""
+        init {foo}
+        set Port 0
+        set DeviceType dummy
+        set Address localhost
+        set ExperimentalProtocol no
+        set Digest {digest}
+        set Cipher {cipher}
+    """
+    foo.cmd(stdin=stdin)
+    foo.start()
+
+    stdin = f"""
+        init {bar}
+        set Port 0
+        set DeviceType dummy
+        set Address localhost
+        set ExperimentalProtocol no
+        set Digest {digest}
+        set Cipher {cipher}
+    """
+    bar.cmd(stdin=stdin)
+
+    foo.add_script(bar.script_up)
+    bar.add_script(foo.script_up)
+
+    cmd.exchange(foo, bar)
+    bar.cmd("add", "ConnectTo", foo.name)
+    bar.cmd("start")
+
+    return foo, bar
+
+
+def test(foo: Tinc, bar: Tinc) -> None:
+    """Run tests on algorithm pair."""
+    log.info("waiting for bar to come up")
+    foo[bar.script_up].wait()
+
+    log.info("waiting for foo to come up")
+    bar[foo.script_up].wait()
+
+    log.info("checking node reachability")
+    stdout, _ = foo.cmd("info", bar.name)
+    check.is_in("reachable", stdout)
+
+
+for alg_digest in "none", "sha256", "sha512":
+    for alg_cipher in "none", "aes-256-cbc":
+        with Test("compression") as context:
+            node0, node1 = init(context, alg_digest, alg_cipher)
+            test(node0, node1)
diff --git a/test/integration/algorithms.test b/test/integration/algorithms.test
deleted file mode 100755 (executable)
index 9dd722c..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initialize two nodes
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Port 30070
-set Address localhost
-set ExperimentalProtocol no
-EOF
-
-tinc bar <<EOF
-init bar
-set DeviceType dummy
-set Port 0
-set ExperimentalProtocol no
-EOF
-
-create_script foo hosts/bar-up
-create_script bar hosts/foo-up
-
-echo [STEP] Exchange configuration
-
-tinc foo export | tinc bar exchange | tinc foo import
-tinc bar add ConnectTo foo
-start_tinc foo
-
-echo [STEP] Test various ciphers and digests
-
-# The full suite results in a large test matrix and it takes a lot of time to run.
-# The list was reduced to the strongest available algorithms (and "none").
-digests="none sha256 sha512"
-ciphers="none aes-256-cbc"
-
-for digest in $digests; do
-  for cipher in $ciphers; do
-    echo "Testing $cipher $digest"
-
-    tinc bar <<EOF
-set Digest $digest
-set Cipher $cipher
-EOF
-
-    start_tinc bar
-    wait_script foo hosts/bar-up
-    wait_script bar hosts/foo-up
-
-    tinc foo info bar | grep reachable
-
-    tinc bar stop
-  done
-done
diff --git a/test/integration/basic.py b/test/integration/basic.py
new file mode 100755 (executable)
index 0000000..aae3ff2
--- /dev/null
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+
+"""Check that basic functionality works (tincd can be started and stopped)."""
+
+from testlib.test import Test
+from testlib.proc import Tinc
+from testlib.log import log
+from testlib.script import Script
+from testlib import check
+
+
+def init(ctx: Test) -> Tinc:
+    """Initialize new test nodes."""
+    node = ctx.node()
+    node.add_script(Script.TINC_UP)
+    stdin = f"""
+        init {node}
+        set Address localhost
+        set Port 0
+        set DeviceType dummy
+    """
+    node.cmd(stdin=stdin)
+    return node
+
+
+def test(ctx: Test, *flags: str) -> None:
+    """Run tests with flags."""
+    log.info("init new node")
+    node = init(ctx)
+
+    log.info('starting tincd with flags "%s"', " ".join(flags))
+    tincd = node.tincd(*flags)
+
+    log.info("waiting for tinc-up script")
+    node[Script.TINC_UP].wait()
+
+    log.info("stopping tincd")
+    node.cmd("stop")
+
+    log.info("checking tincd exit code")
+    check.equals(0, tincd.wait())
+
+
+with Test("foreground mode") as context:
+    test(context, "-D")
+
+with Test("background mode") as context:
+    test(context)
diff --git a/test/integration/basic.test b/test/integration/basic.test
deleted file mode 100755 (executable)
index 31f2755..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initialize and test one node
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Port 0
-EOF
-
-echo [STEP] Test running in the foreground
-
-create_script foo tinc-up '
-  tinc foo stop &
-'
-start_tinc foo -D
-
-echo [STEP] Test running tinc in the background
-
-create_script foo tinc-up '
-  tinc foo stop &
-'
-start_tinc foo
diff --git a/test/integration/command-fsck.test b/test/integration/command-fsck.test
deleted file mode 100755 (executable)
index daf9291..0000000
+++ /dev/null
@@ -1,291 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-foo_dir=$(peer_directory foo)
-foo_host=$foo_dir/hosts/foo
-foo_conf=$foo_dir/tinc.conf
-foo_rsa_priv=$foo_dir/rsa_key.priv
-foo_ec_priv=$foo_dir/ed25519_key.priv
-foo_tinc_up=$foo_dir/tinc-up
-foo_host_up=$foo_dir/host-up
-
-if is_windows; then
-  foo_tinc_up=$foo_tinc_up.cmd
-  foo_host_up=$foo_host_up.cmd
-fi
-
-# Sample RSA key pair (old format). Uses e = 0xFFFF.
-rsa_n=BB82C3A9B906E98ABF2D99FF9B320B229F5C1E58EC784762DA1F4D3509FFF78ECA7FFF19BA170736CDE458EC8E732DDE2C02009632DF731B4A6BD6C504E50B7B875484506AC1E49FD0DF624F6612F564C562BD20F870592A49195023D744963229C35081C8AE48BE2EBB5CC9A0D64924022DC0EB782A3A8F3EABCA04AA42B24B2A6BD2353A6893A73AE01FA54891DD24BF36CA032F19F7E78C01273334BAA2ECF36B6998754CB012BC985C975503D945E4D925F6F719ACC8FBA7B18C810FF850C3CCACD60565D4FCFE02A98FE793E2D45D481A34D1F90584D096561FF3184C462C606535F3F9BB260541DF0D1FEB16938FFDEC2FF96ACCC6BD5BFBC19471F6AB
-rsa_d=8CEC9A4316FE45E07900197D8FBB52D3AF01A51C4F8BD08A1E21A662E3CFCF7792AD7680673817B70AC1888A08B49E8C5835357016D9BF56A0EBDE8B5DF214EC422809BC8D88177F273419116EF2EC7951453F129768DE9BC31D963515CC7481559E4C0E65C549169F2B94AE68DB944171189DD654DC6970F2F5843FB7C8E9D057E2B5716752F1F5686811AC075ED3D3CBD06B5D35AE33D01260D9E0560AF545D0C9D89A31D5EAF96D5422F6567FE8A90E23906B840545805644DFD656E526A686D3B978DD271578CA3DA0F7D23FC1252A702A5D597CAE9D4A5BBF6398A75AF72582C7538A7937FB71A2610DCBC39625B77103FA3B7D0A55177FD98C39CD4A27
-
-# Extracts the PEM key from a config file, leaving the file unchanged.
-# usage: extract_pem_key_from_config path_to_file
-extract_pem_key_from_config() {
-  sed -n '/-----BEGIN /,/-----END /p' "$1"
-}
-
-# Removes the PEM key from a config file.
-# usage: rm_pem_key_from_config path_to_file
-rm_pem_key_from_config() {
-  sed_cmd '/-----BEGIN /,/-----END /d' "$1"
-}
-
-reinit_configs() {
-  if [ -d "$foo_dir" ]; then
-    chmod -f 755 "$foo_dir"
-    rm -rf "$foo_dir"
-  fi
-
-  tinc foo <<EOF
-init foo
-set DeviceType dummy
-EOF
-}
-
-fsck_test() {
-  echo >&2 "[STEP] $*"
-  reinit_configs
-}
-
-run_access_checks() {
-  ! is_root && ! is_windows
-}
-
-test_private_keys() {
-  keyfile=$1
-
-  fsck_test "Must fail on broken $keyfile"
-  printf '' >"$foo_dir/$keyfile"
-  if with_legacy; then
-    expect_msg 'no private key is known' tinc foo fsck
-  else
-    must_fail_with_msg 'no Ed25519 private key found' tinc foo fsck
-  fi
-
-  if run_access_checks; then
-    fsck_test "Must fail on inaccessible $keyfile"
-    chmod 000 "$foo_dir/$keyfile"
-    if with_legacy; then
-      expect_msg 'error reading' tinc foo fsck
-    else
-      must_fail_with_msg 'error reading' tinc foo fsck
-    fi
-  fi
-
-  if ! is_windows; then
-    fsck_test "Must warn about unsafe permissions on $keyfile"
-    chmod 666 "$foo_dir/$keyfile"
-    expect_msg 'unsafe file permissions' tinc foo fsck
-  fi
-
-  if with_legacy; then
-    fsck_test "Must pass on missing $keyfile when the other key is present"
-    rm -f "$foo_dir/$keyfile"
-    tinc foo fsck
-  fi
-}
-
-test_private_key_var() {
-  var=$1
-  keyfile=$2
-
-  fsck_test "Must find private key at $var"
-  mv "$foo_dir/$keyfile" "$foo_dir/renamed_private_key"
-  echo "$var = $(normalize_path "$foo_dir/renamed_private_key")" >>"$foo_conf"
-  fail_on_msg 'key was found but no private key' tinc foo fsck
-}
-
-test_ec_public_key_file_var() {
-  conf=$1
-  fsck_test "EC public key in Ed25519PublicKeyFile in $conf must work"
-  cat >"$foo_dir/ec_pubkey" <<EOF
------BEGIN ED25519 PUBLIC KEY-----
-$(awk '/^Ed25519PublicKey/ { printf $NF }' "$foo_host")
------END ED25519 PUBLIC KEY-----
-EOF
-  sed_cmd '/Ed25519PublicKey/d' "$foo_host"
-  echo "Ed25519PublicKeyFile = $(normalize_path "$foo_dir/ec_pubkey")" >>"$foo_dir/$conf"
-  fail_on_msg 'no (usable) public Ed25519' tinc foo fsck
-}
-
-test_rsa_public_key_file_var() {
-  conf=$1
-  fsck_test "RSA public key in PublicKeyFile in $conf must work"
-  extract_pem_key_from_config "$foo_host" >"$foo_dir/rsa_pubkey"
-  rm_pem_key_from_config "$foo_host"
-  echo "PublicKeyFile = $(normalize_path "$foo_dir/rsa_pubkey")" >>"$foo_dir/$conf"
-  fail_on_msg 'error reading RSA public key' tinc foo fsck
-}
-
-fsck_test 'Newly created configuration should pass'
-tinc foo fsck
-
-fsck_test 'Must fail on missing tinc.conf'
-rm -f "$foo_conf"
-must_fail_with_msg 'no tinc configuration found' tinc foo fsck
-
-if run_access_checks; then
-  fsck_test 'Must fail on inaccessible tinc.conf'
-  chmod 000 "$foo_dir"
-  must_fail_with_msg 'not running tinc as root' tinc foo fsck
-fi
-
-if ! is_windows; then
-  fsck_test 'Non-executable tinc-up MUST be fixed by tinc --force'
-  chmod a-x "$foo_tinc_up"
-  expect_msg 'cannot read and execute' tinc foo --force fsck
-  test -x "$foo_tinc_up"
-
-  fsck_test 'Non-executable tinc-up MUST NOT be fixed by tinc without --force'
-  chmod a-x "$foo_tinc_up"
-  expect_msg 'cannot read and execute' tinc foo fsck
-  must_fail test -x "$foo_tinc_up"
-fi
-
-fsck_test 'Unknown -up script warning'
-touch "$foo_dir/fake-up"
-expect_msg 'unknown script' tinc foo fsck
-
-fsck_test 'Unknown -down script warning'
-touch "$foo_dir/fake-down"
-expect_msg 'unknown script' tinc foo fsck
-
-if ! is_windows; then
-  fsck_test 'Non-executable foo-up MUST be fixed by tinc --force'
-  touch "$foo_host_up"
-  chmod a-x "$foo_host_up"
-  expect_msg 'cannot read and execute' tinc foo --force fsck
-  test -x "$foo_tinc_up"
-
-  fsck_test 'Non-executable bar-up MUST NOT be fixed by tinc'
-  touch "$foo_dir/hosts/bar-up"
-  chmod a-x "$foo_dir/hosts/bar-up"
-  expect_msg 'cannot read and execute' tinc foo fsck
-  must_fail test -x "$foo_dir/bar-up"
-fi
-
-if run_access_checks; then
-  fsck_test 'Inaccessible hosts/foo must fail'
-  chmod 000 "$foo_host"
-  must_fail_with_msg 'cannot open config file' tinc foo fsck
-fi
-
-fsck_test 'Must fail when all private keys are missing'
-rm -f "$foo_ec_priv" "$foo_rsa_priv"
-if with_legacy; then
-  must_fail_with_msg 'neither RSA or Ed25519 private key' tinc foo fsck
-else
-  must_fail_with_msg 'no Ed25519 private key' tinc foo fsck
-fi
-
-if with_legacy; then
-  test_private_keys rsa_key.priv
-
-  if ! is_windows; then
-    fsck_test 'Must warn about unsafe permissions on tinc.conf with PrivateKey'
-    rm -f "$foo_rsa_priv"
-    echo "PrivateKey = $rsa_d" >>"$foo_conf"
-    echo "PublicKey = $rsa_n" >>"$foo_host"
-    chmod 666 "$foo_conf"
-    expect_msg 'unsafe file permissions' tinc foo fsck
-  fi
-
-  fsck_test 'Must warn about missing RSA private key if public key is present'
-  rm -f "$foo_rsa_priv"
-  expect_msg 'public RSA key was found but no private key' tinc foo fsck
-
-  fsck_test 'Must warn about missing RSA public key'
-  rm_pem_key_from_config "$foo_host"
-  expect_msg 'no (usable) public RSA' tinc foo fsck
-  must_fail grep -q 'BEGIN RSA PUBLIC KEY' "$foo_host"
-
-  fsck_test 'Must fix missing RSA public key on --force'
-  rm_pem_key_from_config "$foo_host"
-  expect_msg 'wrote RSA public key' tinc foo --force fsck
-  grep -q 'BEGIN RSA PUBLIC KEY' "$foo_host"
-
-  test_private_key_var PrivateKeyFile rsa_key.priv
-
-  test_rsa_public_key_file_var tinc.conf
-  test_rsa_public_key_file_var hosts/foo
-
-  fsck_test 'RSA PublicKey + PrivateKey must work'
-  rm -f "$foo_rsa_priv"
-  rm_pem_key_from_config "$foo_host"
-  echo "PrivateKey = $rsa_d" >>"$foo_conf"
-  echo "PublicKey = $rsa_n" >>"$foo_host"
-  fail_on_msg 'no (usable) public RSA' tinc foo fsck
-
-  fsck_test 'RSA PrivateKey without PublicKey must warn'
-  rm -f "$foo_rsa_priv"
-  rm_pem_key_from_config "$foo_host"
-  echo "PrivateKey = $rsa_d" >>"$foo_conf"
-  expect_msg 'PrivateKey used but no PublicKey found' tinc foo fsck
-
-  fsck_test 'Must warn about missing EC private key if public key is present'
-  rm -f "$foo_ec_priv"
-  expect_msg 'public Ed25519 key was found but no private key' tinc foo fsck
-
-  fsck_test 'Must fix broken RSA public key with --force'
-  sed_cmd 2d "$foo_host"
-  expect_msg 'old key(s) found and disabled' tinc foo --force fsck
-  tinc foo fsck
-
-  fsck_test 'Must fix missing RSA public key with --force'
-  rm_pem_key_from_config "$foo_host"
-  expect_msg 'no (usable) public RSA key found' tinc foo --force fsck
-  tinc foo fsck
-fi
-
-fsck_test 'Must fix broken Ed25519 public key with --force'
-sed_cmd 's/Ed25519PublicKey.*/Ed25519PublicKey = foobar/' "$foo_host"
-expect_msg 'no (usable) public Ed25519 key' tinc foo --force fsck
-tinc foo fsck
-
-fsck_test 'Must fix missing Ed25519 public key with --force'
-sed_cmd '/Ed25519PublicKey/d' "$foo_host"
-expect_msg 'no (usable) public Ed25519 key' tinc foo --force fsck
-tinc foo fsck
-
-test_private_keys ed25519_key.priv
-test_private_key_var Ed25519PrivateKeyFile ed25519_key.priv
-
-test_ec_public_key_file_var tinc.conf
-test_ec_public_key_file_var hosts/foo
-
-fsck_test 'Must warn about missing EC public key and NOT fix without --force'
-sed_cmd '/Ed25519PublicKey/d' "$foo_host"
-expect_msg 'no (usable) public Ed25519' tinc foo fsck
-must_fail grep -q 'ED25519 PUBLIC KEY' "$foo_host"
-
-fsck_test 'Must fix missing EC public key on --force'
-sed_cmd '/Ed25519PublicKey/d' "$foo_host"
-expect_msg 'wrote Ed25519 public key' tinc foo --force fsck
-grep -q 'ED25519 PUBLIC KEY' "$foo_host"
-
-fsck_test 'Must warn about obsolete variables'
-echo 'GraphDumpFile = /dev/null' >>"$foo_host"
-expect_msg 'obsolete variable GraphDumpFile' tinc foo fsck
-
-fsck_test 'Must warn about missing values'
-echo 'Weight = ' >>"$foo_host"
-must_fail_with_msg 'no value for variable `Weight' tinc foo fsck
-
-fsck_test 'Must warn about duplicate variables'
-echo 'Weight = 0' >>"$foo_host"
-echo 'Weight = 1' >>"$foo_host"
-expect_msg 'multiple instances of variable Weight' tinc foo fsck
-
-fsck_test 'Must warn about server variables in host config'
-echo 'Interface = fake0' >>"$foo_host"
-expect_msg 'server variable Interface found' tinc foo fsck
-
-fsck_test 'Must warn about host variables in server config'
-echo 'Port = 1337' >>"$foo_conf"
-expect_msg 'host variable Port found' tinc foo fsck
-
-fsck_test 'Must warn about missing Name'
-sed_cmd '/^Name =/d' "$foo_conf"
-must_fail_with_msg 'without a valid Name' tinc foo fsck
diff --git a/test/integration/command_fsck.py b/test/integration/command_fsck.py
new file mode 100755 (executable)
index 0000000..4e79ccd
--- /dev/null
@@ -0,0 +1,378 @@
+#!/usr/bin/env python3
+
+"""Test 'tinc fsck' command."""
+
+import os
+import sys
+import typing as T
+
+from testlib import check
+from testlib.log import log
+from testlib.proc import Tinc, Feature
+from testlib.util import read_text, read_lines, write_lines, append_line, write_text
+
+run_legacy_checks = Feature.LEGACY_PROTOCOL in Tinc().features
+run_access_checks = os.name != "nt" and os.geteuid() != 0
+run_executability_checks = os.name != "nt"
+run_permission_checks = run_executability_checks
+
+# Sample RSA key pair (old format). Uses e = 0xFFFF.
+RSA_N = """
+BB82C3A9B906E98ABF2D99FF9B320B229F5C1E58EC784762DA1F4D3509FFF78ECA7FFF19BA17073\
+6CDE458EC8E732DDE2C02009632DF731B4A6BD6C504E50B7B875484506AC1E49FD0DF624F6612F5\
+64C562BD20F870592A49195023D744963229C35081C8AE48BE2EBB5CC9A0D64924022DC0EB782A3\
+A8F3EABCA04AA42B24B2A6BD2353A6893A73AE01FA54891DD24BF36CA032F19F7E78C01273334BA\
+A2ECF36B6998754CB012BC985C975503D945E4D925F6F719ACC8FBA7B18C810FF850C3CCACD6056\
+5D4FCFE02A98FE793E2D45D481A34D1F90584D096561FF3184C462C606535F3F9BB260541DF0D1F\
+EB16938FFDEC2FF96ACCC6BD5BFBC19471F6AB
+""".strip()
+
+RSA_D = """
+8CEC9A4316FE45E07900197D8FBB52D3AF01A51C4F8BD08A1E21A662E3CFCF7792AD7680673817B\
+70AC1888A08B49E8C5835357016D9BF56A0EBDE8B5DF214EC422809BC8D88177F273419116EF2EC\
+7951453F129768DE9BC31D963515CC7481559E4C0E65C549169F2B94AE68DB944171189DD654DC6\
+970F2F5843FB7C8E9D057E2B5716752F1F5686811AC075ED3D3CBD06B5D35AE33D01260D9E0560A\
+F545D0C9D89A31D5EAF96D5422F6567FE8A90E23906B840545805644DFD656E526A686D3B978DD2\
+71578CA3DA0F7D23FC1252A702A5D597CAE9D4A5BBF6398A75AF72582C7538A7937FB71A2610DCB\
+C39625B77103FA3B7D0A55177FD98C39CD4A27
+""".strip()
+
+
+class Context:
+    """Test context. Used to store paths to configuration files."""
+
+    def __init__(self) -> None:
+        node = Tinc()
+        node.cmd("init", node.name)
+
+        self.node = node
+        self.host = node.sub("hosts", node.name)
+        self.conf = node.sub("tinc.conf")
+        self.rsa_priv = node.sub("rsa_key.priv")
+        self.ec_priv = node.sub("ed25519_key.priv")
+        self.tinc_up = node.sub("tinc-up")
+        self.host_up = node.sub("host-up")
+
+        if os.name == "nt":
+            self.tinc_up = f"{self.tinc_up}.cmd"
+            self.host_up = f"{self.host_up}.cmd"
+
+    def expect_msg(
+        self, msg: str, force: bool = False, code: int = 1, present: bool = True
+    ) -> None:
+        """Checks that tinc output contains (or does not contain) the expected message."""
+        args = ["fsck"]
+        if force:
+            args.insert(0, "--force")
+
+        out, err = self.node.cmd(*args, code=code)
+        if present:
+            check.is_in(msg, out, err)
+        else:
+            check.not_in(msg, out, err)
+
+
+def test(msg: str) -> Context:
+    """Create test context."""
+    context = Context()
+    log.info("TEST: %s", msg)
+    return context
+
+
+def remove_pem(config: str) -> T.List[str]:
+    """Remove PEM from a config file, leaving everything else untouched."""
+    key, result = False, []
+    for line in read_lines(config):
+        if line.startswith("-----BEGIN"):
+            key = True
+            continue
+        if line.startswith("-----END"):
+            key = False
+            continue
+        if not key:
+            result.append(line)
+    write_lines(config, result)
+    return result
+
+
+def extract_pem(config: str) -> T.List[str]:
+    """Extract PEM from a config file, ignoring everything else."""
+    key = False
+    result: T.List[str] = []
+    for line in read_lines(config):
+        if line.startswith("-----BEGIN"):
+            key = True
+            continue
+        if line.startswith("-----END"):
+            return result
+        if key:
+            result.append(line)
+    raise Exception("key not found")
+
+
+def replace_line(file_path: str, prefix: str, replace: str = "") -> None:
+    """Replace lines in a file that start with the prefix."""
+    lines = read_lines(file_path)
+    lines = [replace if line.startswith(prefix) else line for line in lines]
+    write_lines(file_path, lines)
+
+
+def test_private_key_var(var: str, file: str) -> None:
+    """Test inline private keys with variable var."""
+    context = test(f"private key variable {var} in file {file}")
+    renamed = os.path.realpath(context.node.sub("renamed_key"))
+    os.rename(src=context.node.sub(file), dst=renamed)
+    append_line(context.host, f"{var} = {renamed}")
+    context.expect_msg("key was found but no private key", present=False, code=0)
+
+
+def test_private_keys(keyfile: str) -> None:
+    """Test private keys in file keyfile."""
+    context = test(f"fail on broken {keyfile}")
+    keyfile_path = context.node.sub(keyfile)
+    os.truncate(keyfile_path, 0)
+
+    if run_legacy_checks:
+        context.expect_msg("no private key is known", code=0)
+    else:
+        context.expect_msg("No Ed25519 private key found")
+
+    if run_access_checks:
+        context = test(f"fail on inaccessible {keyfile}")
+        keyfile_path = context.node.sub(keyfile)
+        os.chmod(keyfile_path, 0)
+        context.expect_msg("Error reading", code=0 if run_legacy_checks else 1)
+
+    if run_permission_checks:
+        context = test(f"warn about unsafe permissions on {keyfile}")
+        keyfile_path = context.node.sub(keyfile)
+        os.chmod(keyfile_path, 0o666)
+        context.expect_msg("unsafe file permissions", code=0)
+
+    if run_legacy_checks:
+        context = test(f"pass on missing {keyfile} when the other key is present")
+        keyfile_path = context.node.sub(keyfile)
+        os.remove(keyfile_path)
+        context.node.cmd("fsck")
+
+
+def test_ec_public_key_file_var(context: Context, *paths: str) -> None:
+    """Test EC public keys in config *paths."""
+    ec_pubkey = os.path.realpath(context.node.sub("ec_pubkey"))
+
+    ec_key = ""
+    for line in read_lines(context.host):
+        if line.startswith("Ed25519PublicKey"):
+            _, _, ec_key = line.split()
+            break
+    assert ec_key
+
+    pem = f"""
+-----BEGIN ED25519 PUBLIC KEY-----
+{ec_key}
+-----END ED25519 PUBLIC KEY-----
+"""
+    write_text(ec_pubkey, pem)
+
+    replace_line(context.host, "Ed25519PublicKey")
+
+    config = context.node.sub(*paths)
+    append_line(config, f"Ed25519PublicKeyFile = {ec_pubkey}")
+
+    context.expect_msg("No (usable) public Ed25519", code=0, present=False)
+
+
+###############################################################################
+# Common tests
+###############################################################################
+
+ctx = test("pass freshly created configuration")
+ctx.node.cmd("fsck")
+
+ctx = test("fail on missing tinc.conf")
+os.remove(ctx.conf)
+ctx.expect_msg("No tinc configuration found")
+
+for suffix in "up", "down":
+    ctx = test(f"unknown -{suffix} script warning")
+    fake_path = ctx.node.sub(f"fake-{suffix}")
+    write_text(fake_path, "")
+    ctx.expect_msg("Unknown script", code=0)
+
+ctx = test("fix broken Ed25519 public key with --force")
+replace_line(ctx.host, "Ed25519PublicKey", "Ed25519PublicKey = foobar")
+ctx.expect_msg("No (usable) public Ed25519 key", force=True, code=0)
+ctx.node.cmd("fsck")
+
+ctx = test("fix missing Ed25519 public key with --force")
+replace_line(ctx.host, "Ed25519PublicKey")
+ctx.expect_msg("No (usable) public Ed25519 key", force=True, code=0)
+ctx.node.cmd("fsck")
+
+ctx = test("fail when all private keys are missing")
+os.remove(ctx.ec_priv)
+if run_legacy_checks:
+    os.remove(ctx.rsa_priv)
+    ctx.expect_msg("Neither RSA or Ed25519 private")
+else:
+    ctx.expect_msg("No Ed25519 private")
+
+ctx = test("warn about missing EC public key and NOT fix without --force")
+replace_line(ctx.host, "Ed25519PublicKey")
+ctx.expect_msg("No (usable) public Ed25519", code=0)
+host = read_text(ctx.host)
+check.not_in("ED25519 PUBLIC KEY", host)
+
+ctx = test("fix missing EC public key on --force")
+replace_line(ctx.host, "Ed25519PublicKey")
+ctx.expect_msg("Wrote Ed25519 public key", force=True, code=0)
+host = read_text(ctx.host)
+check.is_in("ED25519 PUBLIC KEY", host)
+
+ctx = test("warn about obsolete variables")
+append_line(ctx.host, "GraphDumpFile = /dev/null")
+ctx.expect_msg("obsolete variable GraphDumpFile", code=0)
+
+ctx = test("warn about missing values")
+append_line(ctx.host, "Weight = ")
+ctx.expect_msg("No value for variable `Weight")
+
+ctx = test("warn about duplicate variables")
+append_line(ctx.host, f"Weight = 0{os.linesep}Weight = 1")
+ctx.expect_msg("multiple instances of variable Weight", code=0)
+
+ctx = test("warn about server variables in host config")
+append_line(ctx.host, "Interface = fake0")
+ctx.expect_msg("server variable Interface found", code=0)
+
+ctx = test("warn about host variables in server config")
+append_line(ctx.conf, "Port = 1337")
+ctx.expect_msg("host variable Port found", code=0)
+
+ctx = test("warn about missing Name")
+replace_line(ctx.conf, "Name =")
+ctx.expect_msg("without a valid Name")
+
+test_private_keys("ed25519_key.priv")
+test_private_key_var("Ed25519PrivateKeyFile", "ed25519_key.priv")
+
+ctx = test("test EC public key in tinc.conf")
+test_ec_public_key_file_var(ctx, "tinc.conf")
+
+ctx = test("test EC public key in hosts/")
+test_ec_public_key_file_var(ctx, "hosts", ctx.node.name)
+
+if run_access_checks:
+    ctx = test("fail on inaccessible tinc.conf")
+    os.chmod(ctx.conf, 0)
+    ctx.expect_msg("not running tinc as root")
+
+    ctx = test("fail on inaccessible hosts/foo")
+    os.chmod(ctx.host, 0)
+    ctx.expect_msg("Cannot open config file")
+
+if run_executability_checks:
+    ctx = test("non-executable tinc-up MUST be fixed by tinc --force")
+    os.chmod(ctx.tinc_up, 0o644)
+    ctx.expect_msg("cannot read and execute", force=True, code=0)
+    assert os.access(ctx.tinc_up, os.X_OK)
+
+    ctx = test("non-executable tinc-up MUST NOT be fixed by tinc without --force")
+    os.chmod(ctx.tinc_up, 0o644)
+    ctx.expect_msg("cannot read and execute", code=0)
+    assert not os.access(ctx.tinc_up, os.X_OK)
+
+    ctx = test("non-executable foo-up MUST be fixed by tinc --force")
+    write_text(ctx.host_up, "")
+    os.chmod(ctx.host_up, 0o644)
+    ctx.expect_msg("cannot read and execute", force=True, code=0)
+    assert os.access(ctx.tinc_up, os.X_OK)
+
+    ctx = test("non-executable bar-up MUST NOT be fixed by tinc")
+    path = ctx.node.sub("hosts", "bar-up")
+    write_text(path, "")
+    os.chmod(path, 0o644)
+    ctx.expect_msg("cannot read and execute", code=0)
+    assert not os.access(path, os.X_OK)
+
+###############################################################################
+# Legacy protocol
+###############################################################################
+if not run_legacy_checks:
+    log.info("skipping legacy protocol tests")
+    sys.exit(0)
+
+
+def test_rsa_public_key_file_var(context: Context, *paths: str) -> None:
+    """Test RSA public keys in config *paths."""
+    key = extract_pem(context.host)
+    remove_pem(context.host)
+
+    rsa_pub = os.path.realpath(context.node.sub("rsa_pubkey"))
+    write_lines(rsa_pub, key)
+
+    config = context.node.sub(*paths)
+    append_line(config, f"PublicKeyFile = {rsa_pub}")
+
+    context.expect_msg("Error reading RSA public key", code=0, present=False)
+
+
+test_private_keys("rsa_key.priv")
+test_private_key_var("PrivateKeyFile", "rsa_key.priv")
+
+ctx = test("test rsa public key in tinc.conf")
+test_rsa_public_key_file_var(ctx, "tinc.conf")
+
+ctx = test("test rsa public key in hosts/")
+test_rsa_public_key_file_var(ctx, "hosts", ctx.node.name)
+
+ctx = test("warn about missing RSA private key if public key is present")
+os.remove(ctx.rsa_priv)
+ctx.expect_msg("public RSA key was found but no private key", code=0)
+
+ctx = test("warn about missing RSA public key")
+remove_pem(ctx.host)
+ctx.expect_msg("No (usable) public RSA", code=0)
+check.not_in("BEGIN RSA PUBLIC KEY", read_text(ctx.host))
+
+ctx = test("fix missing RSA public key on --force")
+remove_pem(ctx.host)
+ctx.expect_msg("Wrote RSA public key", force=True, code=0)
+check.is_in("BEGIN RSA PUBLIC KEY", read_text(ctx.host))
+
+ctx = test("RSA PublicKey + PrivateKey must work")
+os.remove(ctx.rsa_priv)
+remove_pem(ctx.host)
+append_line(ctx.conf, f"PrivateKey = {RSA_D}")
+append_line(ctx.host, f"PublicKey = {RSA_N}")
+ctx.expect_msg("no (usable) public RSA", code=0, present=False)
+
+ctx = test("RSA PrivateKey without PublicKey must warn")
+os.remove(ctx.rsa_priv)
+remove_pem(ctx.host)
+append_line(ctx.conf, f"PrivateKey = {RSA_D}")
+ctx.expect_msg("PrivateKey used but no PublicKey found", code=0)
+
+ctx = test("warn about missing EC private key if public key is present")
+os.remove(ctx.ec_priv)
+ctx.expect_msg("public Ed25519 key was found but no private key", code=0)
+
+ctx = test("fix broken RSA public key with --force")
+host_lines = read_lines(ctx.host)
+del host_lines[1]
+write_lines(ctx.host, host_lines)
+ctx.expect_msg("old key(s) found and disabled", force=True, code=0)
+ctx.node.cmd("fsck")
+
+ctx = test("fix missing RSA public key with --force")
+remove_pem(ctx.host)
+ctx.expect_msg("No (usable) public RSA key found", force=True, code=0)
+ctx.node.cmd("fsck")
+
+if run_permission_checks:
+    ctx = test("warn about unsafe permissions on tinc.conf with PrivateKey")
+    os.remove(ctx.rsa_priv)
+    append_line(ctx.conf, f"PrivateKey = {RSA_D}")
+    append_line(ctx.host, f"PublicKey = {RSA_N}")
+    os.chmod(ctx.conf, 0o666)
+    ctx.expect_msg("unsafe file permissions", code=0)
diff --git a/test/integration/commandline.py b/test/integration/commandline.py
new file mode 100755 (executable)
index 0000000..531868f
--- /dev/null
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+
+"""Test supported and unsupported commandline flags."""
+
+from testlib import check, util
+from testlib.log import log
+from testlib.proc import Tinc, Script
+from testlib.test import Test
+
+tinc_flags = (
+    (0, ("get", "name")),
+    (0, ("-n", "foo", "get", "name")),
+    (0, ("-nfoo", "get", "name")),
+    (0, ("--net=foo", "get", "name")),
+    (0, ("--net", "foo", "get", "name")),
+    (0, ("-c", "conf", "-c", "conf")),
+    (0, ("-n", "net", "-n", "net")),
+    (0, ("--pidfile=pid", "--pidfile=pid")),
+    (1, ("-n", "foo", "get", "somethingreallyunknown")),
+    (1, ("--net",)),
+    (1, ("--net", "get", "name")),
+    (1, ("foo",)),
+    (1, ("-c", "conf", "-n", "n/e\\t")),
+)
+
+tincd_flags = (
+    (0, ("-D",)),
+    (0, ("--no-detach",)),
+    (0, ("-D", "-d")),
+    (0, ("-D", "-d2")),
+    (0, ("-D", "-d", "2")),
+    (0, ("-D", "-n", "foo")),
+    (0, ("-D", "-nfoo")),
+    (0, ("-D", "--net=foo")),
+    (0, ("-D", "--net", "foo")),
+    (0, ("-D", "-c", ".", "-c", ".")),
+    (0, ("-D", "-n", "net", "-n", "net")),
+    (0, ("-D", "-n", "net", "-o", "FakeOpt=42")),
+    (0, ("-D", "--logfile=log", "--logfile=log")),
+    (0, ("-D", "--pidfile=pid", "--pidfile=pid")),
+    (1, ("foo",)),
+    (1, ("--pidfile",)),
+    (1, ("--foo",)),
+    (1, ("-n", "net", "-o", "Compression=")),
+    (1, ("-c", "fakedir", "-n", "n/e\\t")),
+)
+
+
+def init(ctx: Test) -> Tinc:
+    """Initialize new test nodes."""
+    tinc = ctx.node()
+    stdin = f"""
+        init {tinc}
+        set Port 0
+        set Address localhost
+        set DeviceType dummy
+    """
+    tinc.cmd(stdin=stdin)
+    tinc.add_script(Script.TINC_UP)
+    return tinc
+
+
+with Test("commandline flags") as context:
+    node = init(context)
+
+    for code, flags in tincd_flags:
+        COOKIE = util.random_string(10)
+        server = node.tincd(*flags, env={"COOKIE": COOKIE})
+
+        if not code:
+            log.info("waiting for tincd to come up")
+            env = node[Script.TINC_UP].wait().env
+            check.equals(COOKIE, env["COOKIE"])
+
+        log.info("stopping tinc")
+        node.cmd("stop", code=code)
+
+        log.info("reading tincd output")
+        stdout, stderr = server.communicate()
+
+        log.debug('got code %d, ("%s", "%s")', server.returncode, stdout, stderr)
+        check.equals(code, server.returncode)
+
+    for code, flags in tinc_flags:
+        node.cmd(*flags, code=code)
diff --git a/test/integration/commandline.test b/test/integration/commandline.test
deleted file mode 100755 (executable)
index 800dbfc..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initialize one node
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Port 0
-EOF
-
-create_script foo tinc-up '
-  tinc foo stop &
-'
-
-echo [STEP] Test tincd command line options that should work
-
-tincd foo -D
-tincd foo --no-detach
-tincd foo -D -d
-tincd foo -D -d2
-tincd foo -D -d 2
-tincd foo -D -n foo
-tincd foo -D -nfoo
-tincd foo -D --net=foo
-tincd foo -D --net foo
-
-echo [STEP] Test tincd command line options that should not work
-
-expect_code "$EXIT_FAILURE" tincd foo foo
-expect_code "$EXIT_FAILURE" tincd foo --pidfile
-expect_code "$EXIT_FAILURE" tincd foo --foo
-
-echo [STEP] Test tinc command line options that should work
-
-tinc foo get name
-tinc foo -n foo get name
-tinc foo -nfoo get name
-tinc foo --net=foo get name
-tinc foo --net foo get name
-
-echo [STEP] Test tinc command line options that should not work
-
-expect_code "$EXIT_FAILURE" tinc foo -n foo get somethingreallyunknown
-expect_code "$EXIT_FAILURE" tinc foo --net
-expect_code "$EXIT_FAILURE" tinc foo --net get name
-expect_code "$EXIT_FAILURE" tinc foo foo
-
-# Most of these should fail with ASAN. Some leaks are only detected by Valgrind.
-echo [STEP] Trigger previously known memory leaks
-
-tincd foo -c . -c . --help
-tincd foo -n net -n net --help
-tincd foo -n net -o FakeOpt=42 --help
-tincd foo --logfile=one --logfile=two --help
-tincd foo --pidfile=one --pidfile=two --help
-expect_code "$EXIT_FAILURE" tincd foo -n net -o Compression= --help
-expect_code "$EXIT_FAILURE" tincd foo -c fakedir -n 'n/e\t'
-
-tinc foo -c conf -c conf --help
-tinc foo -n net -n net --help
-tinc foo --pidfile=pid --pidfile=pid --help
-expect_code "$EXIT_FAILURE" tinc foo -c conf -n 'n/e\t'
diff --git a/test/integration/compression.py b/test/integration/compression.py
new file mode 100755 (executable)
index 0000000..cac4d2b
--- /dev/null
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+
+"""Test supported and unsupported compression levels."""
+
+import os
+import signal
+import sys
+import multiprocessing.connection as mpc
+import subprocess as subp
+import time
+import typing as T
+
+from testlib import external as ext, cmd, path, check, util
+from testlib.log import log
+from testlib.proc import Script, Tinc, Feature
+from testlib.test import Test
+from testlib.template import make_netns_config
+
+IP_FOO = "192.168.1.1"
+IP_BAR = "192.168.1.2"
+MASK = 24
+
+CONTENT = "zHgfHEzRsKPU41rWoTzmcxxxUGvjfOtTZ0ZT2S1GezL7QbAcMGiLa8i6JOgn59Dq5BtlfbZj"
+
+
+def run_receiver() -> None:
+    """Run server that receives data it prints it to stdout."""
+    with mpc.Listener((IP_FOO, 0), family="AF_INET") as listener:
+        port = listener.address[1]
+        sys.stdout.write(f"{port}\n")
+        sys.stdout.flush()
+
+        with listener.accept() as conn:
+            data = conn.recv()
+            print(data, sep="", flush=True)
+
+
+def run_sender() -> None:
+    """Start client that reads data from stdin and sends it to server."""
+    port = int(os.environ["PORT"])
+
+    for _ in range(5):
+        try:
+            with mpc.Client((IP_FOO, port)) as client:
+                client.send(CONTENT)
+            return
+        except OSError as ex:
+            log.warning("could not connect to receiver", exc_info=ex)
+            time.sleep(1)
+
+    log.error("failed to send data, terminating")
+    os.kill(0, signal.SIGTERM)
+
+
+def get_levels(features: T.Container[Feature]) -> T.Tuple[T.List[int], T.List[int]]:
+    """Get supported compression levels."""
+    log.info("getting supported compression levels")
+
+    levels: T.List[int] = []
+    bogus: T.List[int] = []
+
+    for comp, lvl_min, lvl_max in (
+        (Feature.COMP_ZLIB, 1, 9),
+        (Feature.COMP_LZO, 10, 11),
+        (Feature.COMP_LZ4, 12, 12),
+    ):
+        lvls = range(lvl_min, lvl_max + 1)
+        if comp in features:
+            levels += lvls
+        else:
+            bogus += lvls
+
+    log.info("supported compression levels: %s", levels)
+    log.info("unsupported compression levels: %s", bogus)
+
+    return levels, bogus
+
+
+def init(ctx: Test) -> T.Tuple[Tinc, Tinc]:
+    """Initialize new test nodes."""
+    foo, bar = ctx.node(addr=IP_FOO), ctx.node(addr=IP_BAR)
+
+    stdin = f"""
+        init {foo}
+        set Port 0
+        set Address {foo.address}
+        set Subnet {foo.address}
+        set Interface {foo}
+        set Address localhost
+    """
+    foo.cmd(stdin=stdin)
+    assert ext.netns_add(foo.name)
+    foo.add_script(Script.TINC_UP, make_netns_config(foo.name, foo.address, MASK))
+
+    stdin = f"""
+        init {bar}
+        set Port 0
+        set Address {bar.address}
+        set Subnet {bar.address}
+        set Interface {bar}
+        set ConnectTo {foo}
+    """
+    bar.cmd(stdin=stdin)
+    assert ext.netns_add(bar.name)
+    bar.add_script(Script.TINC_UP, make_netns_config(bar.name, bar.address, MASK))
+    foo.add_script(Script.SUBNET_UP)
+
+    log.info("start %s and exchange configuration", foo)
+    foo.start()
+    cmd.exchange(foo, bar)
+
+    return foo, bar
+
+
+def test_valid_level(foo: Tinc, bar: Tinc) -> None:
+    """Test that supported compression level works correctly."""
+    while True:
+        env = foo[Script.SUBNET_UP].wait().env
+        if env.get("SUBNET") == bar.address:
+            break
+
+    log.info("start receiver in netns")
+    with subp.Popen(
+        ["ip", "netns", "exec", foo.name, path.PYTHON_PATH, __file__, "--recv"],
+        stdout=subp.PIPE,
+        encoding="utf-8",
+    ) as receiver:
+        assert receiver.stdout
+        port = receiver.stdout.readline().strip()
+
+        log.info("start sender in netns")
+        with subp.Popen(
+            ["ip", "netns", "exec", bar.name, path.PYTHON_PATH, __file__, "--send"],
+            env={**dict(os.environ), "PORT": port},
+        ):
+            recv = receiver.stdout.read()
+            log.info('received %d bytes: "%s"', len(recv), recv)
+
+    check.equals(0, receiver.wait())
+    check.equals(CONTENT, recv.rstrip())
+
+
+def test_bogus_level(node: Tinc) -> None:
+    """Test that unsupported compression level fails to start."""
+    tincd = node.tincd()
+    _, stderr = tincd.communicate()
+    check.equals(1, tincd.returncode)
+    check.is_in("Bogus compression level", stderr)
+
+
+def run_tests() -> None:
+    """Run all tests."""
+    with Test("get supported levels") as ctx:
+        node = ctx.node()
+        levels, bogus = get_levels(node.features)
+
+    with Test("valid levels") as ctx:
+        foo, bar = init(ctx)
+        for level in levels:
+            for node in foo, bar:
+                node.cmd("set", "Compression", str(level))
+            bar.cmd("start")
+            test_valid_level(foo, bar)
+            bar.cmd("stop")
+
+    with Test("test bogus levels") as ctx:
+        node = ctx.node()
+        for level in bogus:
+            node.cmd("set", "Compression", str(level))
+            test_bogus_level(node)
+
+
+last = sys.argv[-1]
+
+if last == "--recv":
+    run_receiver()
+elif last == "--send":
+    run_sender()
+else:
+    util.require_root()
+    util.require_command("ip", "netns", "list")
+    util.require_path("/dev/net/tun")
+    run_tests()
diff --git a/test/integration/compression.test b/test/integration/compression.test
deleted file mode 100755 (executable)
index ac92541..0000000
+++ /dev/null
@@ -1,134 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-require_root "$0" "$@"
-test -e /dev/net/tun || exit "$EXIT_SKIP_TEST"
-ip netns list || exit "$EXIT_SKIP_TEST"
-command -v socat || exit "$EXIT_SKIP_TEST"
-
-ip_foo=192.168.1.1
-ip_bar=192.168.1.2
-port_foo=30100
-recv_port_foo=30101
-mask=24
-
-echo '[STEP] Determining supported compression levels'
-
-features=$(tincd foo --version)
-bogus_levels="-1 13"
-levels=0
-
-add_levels() {
-  algo=$1
-  shift
-
-  if echo "$features" | grep "comp_$algo"; then
-    levels="$levels $*"
-  else
-    bogus_levels="$bogus_levels $*"
-  fi
-}
-
-add_levels zlib 1 2 3 4 5 6 7 8 9
-add_levels lzo 10 11
-add_levels lz4 12
-
-echo "Supported compression levels: $levels"
-echo "Unsupported compression levels: $bogus_levels"
-
-echo [STEP] Create network namespaces
-
-ip netns add foo
-ip netns add bar
-tmp_file=$(mktemp)
-
-cleanup_hook() {
-  ip netns del foo
-  ip netns del bar
-  rm -f "$tmp_file"
-}
-
-echo [STEP] Initialize two nodes
-
-tinc foo <<EOF
-init foo
-set Subnet $ip_foo
-set Interface foo
-set Port $port_foo
-set Address localhost
-EOF
-
-tinc bar <<EOF
-init bar
-set Subnet $ip_bar
-set Interface bar
-set ConnectTo foo
-EOF
-
-# shellcheck disable=SC2016
-create_script foo tinc-up "
-  ip link set dev \$INTERFACE netns foo
-  ip netns exec foo ip addr add $ip_foo/$mask dev \$INTERFACE
-  ip netns exec foo ip link set \$INTERFACE up
-"
-
-# shellcheck disable=SC2016
-create_script bar tinc-up "
-  ip link set dev \$INTERFACE netns bar
-  ip netns exec bar ip addr add $ip_bar/$mask dev \$INTERFACE
-  ip netns exec bar ip link set \$INTERFACE up
-"
-
-echo [STEP] Exchange configuration files
-
-tinc foo export | tinc bar exchange | tinc foo import
-
-echo [STEP] Test supported compression levels
-
-ref_file=$0
-
-create_script foo hosts/bar-up
-create_script bar hosts/foo-up
-
-for level in $levels; do
-  echo "[STEP] Testing compression level $level"
-
-  tinc foo set Compression "$level"
-  tinc bar set Compression "$level"
-
-  start_tinc foo
-  wait_script foo tinc-up
-
-  start_tinc bar
-  wait_script bar tinc-up
-
-  wait_script foo hosts/bar-up
-  wait_script bar hosts/foo-up
-
-  sh <<EOF
-    set -eu
-    ip netns exec foo socat -u TCP4-LISTEN:$recv_port_foo,reuseaddr OPEN:"$tmp_file",creat &
-    ip netns exec bar socat -u OPEN:"$ref_file" TCP4:$ip_foo:$recv_port_foo,retry=30 &
-    wait
-EOF
-
-  diff -w "$ref_file" "$tmp_file"
-
-  tinc foo stop
-  tinc bar stop
-done
-
-echo '[STEP] Invalid compression levels should fail'
-
-for level in $bogus_levels; do
-  echo "[STEP] Testing bogus compression level $level"
-  tinc foo set Compression "$level"
-
-  output=$(expect_code "$EXIT_FAILURE" start_tinc foo 2>&1)
-
-  if ! echo "$output" | grep -q 'Bogus compression level'; then
-    bail 'expected message about the wrong compression level'
-  fi
-done
diff --git a/test/integration/executables.py b/test/integration/executables.py
new file mode 100755 (executable)
index 0000000..121683a
--- /dev/null
@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+
+"""Basic sanity checks on compiled executables."""
+
+from subprocess import run, PIPE
+
+from testlib import path, check
+from testlib.log import log
+
+for exe in (
+    path.TINC_PATH,
+    path.TINCD_PATH,
+    path.SPTPS_TEST_PATH,
+    path.SPTPS_KEYPAIR_PATH,
+):
+    cmd = [exe, "--help"]
+    log.info('testing command "%s"', cmd)
+    res = run(cmd, stdout=PIPE, stderr=PIPE, encoding="utf-8", timeout=10, check=False)
+    check.equals(0, res.returncode)
+    check.is_in("Usage:", res.stdout, res.stderr)
diff --git a/test/integration/executables.test b/test/integration/executables.test
deleted file mode 100755 (executable)
index 390d2c7..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Just test whether the executables work
-
-tinc foo --help
-
-tincd foo --help
-
-if [ -e "$SPTPS_TEST_PATH" ]; then
-  "$SPTPS_TEST_PATH" --help
-fi
diff --git a/test/integration/import-export.test b/test/integration/import-export.test
deleted file mode 100755 (executable)
index 6fe303f..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initialize three nodes
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Port 30000
-set Address localhost
-EOF
-
-tinc bar <<EOF
-init bar
-set DeviceType dummy
-set Port 0
-EOF
-
-tinc baz <<EOF
-init baz
-set DeviceType dummy
-set Port 0
-EOF
-
-echo [STEP] Test import, export and exchange commands
-
-tinc foo export | tinc bar exchange | tinc foo import
-
-echo [STEP] Test export-all and exchange-all
-
-tinc foo export-all | tinc baz exchange | tinc foo import
-tinc foo exchange-all </dev/null | tinc bar import
-
-echo [STEP] Test equivalence of host config files
-
-diff -w "$DIR_FOO/hosts/foo" "$DIR_BAR/hosts/foo"
-diff -w "$DIR_FOO/hosts/foo" "$DIR_BAZ/hosts/foo"
-diff -w "$DIR_FOO/hosts/bar" "$DIR_BAR/hosts/bar"
-diff -w "$DIR_FOO/hosts/bar" "$DIR_BAZ/hosts/bar"
-diff -w "$DIR_FOO/hosts/baz" "$DIR_BAR/hosts/baz"
-diff -w "$DIR_FOO/hosts/baz" "$DIR_BAZ/hosts/baz"
-
-echo [STEP] Check whether the nodes can connect to each other
-
-create_script foo tinc-up '
-  tinc bar add ConnectTo foo
-  tinc baz add ConnectTo foo
-'
-
-create_script foo hosts/bar-up
-create_script foo hosts/baz-up
-
-start_tinc foo
-
-wait_script foo tinc-up
-
-start_tinc bar
-start_tinc baz
-
-wait_script foo hosts/bar-up
-wait_script foo hosts/baz-up
-
-require_nodes foo 3
-require_nodes bar 3
-require_nodes baz 3
diff --git a/test/integration/import_export.py b/test/integration/import_export.py
new file mode 100755 (executable)
index 0000000..dd82781
--- /dev/null
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+
+"""Test peer information import and export."""
+
+import typing as T
+
+from testlib import check, cmd
+from testlib.log import log
+from testlib.proc import Tinc, Script
+from testlib.test import Test
+
+
+def init(ctx: Test) -> T.Tuple[Tinc, Tinc, Tinc]:
+    """Initialize new test nodes."""
+    foo, bar, baz = ctx.node(), ctx.node(), ctx.node()
+
+    log.info("configure %s", foo.name)
+    stdin = f"""
+        init {foo}
+        set Port 0
+        set Address localhost
+        set DeviceType dummy
+    """
+    foo.cmd(stdin=stdin)
+
+    log.info("configure %s", bar.name)
+    stdin = f"""
+        init {bar}
+        set Port 0
+        set Address localhost
+        set DeviceType dummy
+    """
+    bar.cmd(stdin=stdin)
+
+    log.info("configure %s", baz.name)
+    stdin = f"""
+        init {baz}
+        set Port 0
+        set Address localhost
+        set DeviceType dummy
+    """
+    baz.cmd(stdin=stdin)
+
+    return foo, bar, baz
+
+
+def run_tests(ctx: Test) -> None:
+    """Run all tests."""
+    foo, bar, baz = init(ctx)
+
+    tinc_up = f"""
+    bar, baz = Tinc('{bar}'), Tinc('{baz}')
+    bar.cmd('add', 'ConnectTo', this.name)
+    baz.cmd('add', 'ConnectTo', this.name)
+    """
+    foo.add_script(Script.TINC_UP, tinc_up)
+    foo.start()
+
+    log.info("run exchange")
+    cmd.exchange(foo, bar)
+
+    log.info("run exchange with export-all")
+    cmd.exchange(foo, baz, export_all=True)
+
+    log.info("run exchange-all")
+    out, err = foo.cmd("exchange-all", code=1)
+    check.is_in("No host configuration files imported", err)
+
+    log.info("run import")
+    bar.cmd("import", stdin=out)
+
+    for first, second in (
+        (foo.sub("hosts", foo.name), bar.sub("hosts", foo.name)),
+        (foo.sub("hosts", foo.name), baz.sub("hosts", foo.name)),
+        (foo.sub("hosts", bar.name), bar.sub("hosts", bar.name)),
+        (foo.sub("hosts", bar.name), baz.sub("hosts", bar.name)),
+        (foo.sub("hosts", baz.name), bar.sub("hosts", baz.name)),
+        (foo.sub("hosts", baz.name), baz.sub("hosts", baz.name)),
+    ):
+        log.info("comparing configs %s and %s", first, second)
+        check.files_eq(first, second)
+
+    log.info("create %s scripts", foo)
+    foo.add_script(bar.script_up)
+    foo.add_script(baz.script_up)
+
+    log.info("start nodes")
+    bar.cmd("start")
+    baz.cmd("start")
+
+    log.info("wait for up scripts")
+    foo[bar.script_up].wait()
+    foo[baz.script_up].wait()
+
+    for tinc in foo, bar, baz:
+        check.nodes(tinc, 3)
+
+
+with Test("import-export") as context:
+    run_tests(context)
diff --git a/test/integration/invite-join.test b/test/integration/invite-join.test
deleted file mode 100755 (executable)
index be2af2b..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initialize one node
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Mode switch
-set Broadcast no
-set Address localhost
-set Port 30010
-EOF
-
-start_tinc foo
-
-echo [STEP] Generate an invitation and let another node join the VPN
-
-invitation=$(tinc foo invite bar)
-tinc bar join "$invitation"
-
-echo [STEP] Test equivalence of host config files
-
-diff -w "$DIR_FOO/hosts/foo" "$DIR_BAR/hosts/foo"
-test "$(grep ^Ed25519PublicKey "$DIR_FOO/hosts/bar")" = "$(grep ^Ed25519PublicKey "$DIR_BAR/hosts/bar")"
-
-echo [STEP] Test Mode, Broadcast and ConnectTo statements
-
-test "$(tinc bar get Mode)" = switch
-test "$(tinc bar get Broadcast)" = no
-test "$(tinc bar get ConnectTo)" = foo
-
-echo [STEP] Check whether the new node can join the VPN
-
-tinc bar <<EOF
-set DeviceType dummy
-set Port 0
-EOF
-
-create_script foo hosts/bar-up
-create_script bar hosts/foo-up
-
-start_tinc bar
-
-wait_script foo hosts/bar-up
-wait_script bar hosts/foo-up
-
-require_nodes foo 2
-require_nodes bar 2
diff --git a/test/integration/invite-offline.test b/test/integration/invite-offline.test
deleted file mode 100755 (executable)
index 8e53513..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initialize one node
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Mode switch
-set Broadcast no
-set Address localhost
-set Port 30020
-EOF
-
-echo [STEP] Generate an invitation offline and let another node join the VPN
-
-invitation=$(tinc foo invite bar)
-
-start_tinc foo
-tinc bar join "$invitation"
-
-echo [STEP] Test equivalence of host config files
-
-diff -w "$DIR_FOO/hosts/foo" "$DIR_BAR/hosts/foo"
-test "$(grep ^Ed25519PublicKey "$DIR_FOO/hosts/bar")" = "$(grep ^Ed25519PublicKey "$DIR_BAR/hosts/bar")"
-
-echo [STEP] Test Mode, Broadcast and ConnectTo statements
-
-test "$(tinc bar get Mode)" = switch
-test "$(tinc bar get Broadcast)" = no
-test "$(tinc bar get ConnectTo)" = foo
-
-echo [STEP] Check whether the new node can join the VPN
-
-tinc bar <<EOF
-set DeviceType dummy
-set Port 0
-EOF
-
-create_script foo hosts/bar-up
-create_script bar hosts/foo-up
-
-start_tinc bar
-
-wait_script foo hosts/bar-up
-wait_script bar hosts/foo-up
-
-require_nodes foo 2
-require_nodes bar 2
diff --git a/test/integration/invite-tinc-up.test b/test/integration/invite-tinc-up.test
deleted file mode 100755 (executable)
index 14d5a60..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initialize one node
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Address localhost
-set Port 30030
-EOF
-
-create_script foo tinc-up
-start_tinc foo
-wait_script foo tinc-up
-
-echo [STEP] Generate an invitation and let another node join the VPN
-
-# shellcheck disable=SC2016
-create_script foo invitation-created '
-cat >"$INVITATION_FILE" <<INVITE
-Name = $NODE
-Ifconfig = 93.184.216.34/24
-Route = 2606:2800:220:1::/64 2606:2800:220:1:248:1893:25c8:1946
-Route = 1.2.3.4 1234::
-
-$(tinc foo export)
-INVITE
-'
-
-tinc foo invite bar | tail -1 | tinc bar --batch join
-
-echo [STEP] Test equivalence of host config files
-
-diff -w "$DIR_FOO/hosts/foo" "$DIR_BAR/hosts/foo"
-test "$(grep ^Ed25519PublicKey "$DIR_FOO/hosts/bar")" = "$(grep ^Ed25519PublicKey "$DIR_BAR/hosts/bar")"
-
-echo [STEP] Check if the tinc-up.invitation file is created and contains the right commands
-
-bar_tinc_up="$DIR_BAR/tinc-up.invitation"
-test -f "$bar_tinc_up"
-
-grep -F -q "93.184.216.34/24" "$bar_tinc_up"
-grep -F -q "2606:2800:220:1::/64" "$bar_tinc_up"
-grep -F -q "2606:2800:220:1:248:1893:25c8:1946" "$bar_tinc_up"
-must_fail grep -F -q "1234::" "$bar_tinc_up"
-
-echo [STEP] Check that no tinc-up is created and that tinc-up.invitation is not executable
-
-must_fail test -x "$bar_tinc_up"
-must_fail test -f "$DIR_BAR/tinc-up"
diff --git a/test/integration/invite.py b/test/integration/invite.py
new file mode 100755 (executable)
index 0000000..25389f8
--- /dev/null
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+# pylint: disable=import-outside-toplevel
+
+"""Test tinc peer invitations."""
+
+from testlib import check, util
+from testlib.log import log
+from testlib.test import Test
+
+
+def run_invite_test(ctx: Test, start_before_invite: bool) -> None:
+    """Run tests. If start_before_invite is True,
+    tincd is started *before* creating invitation, and vice versa.
+    """
+    foo, bar = ctx.node(), ctx.node()
+
+    stdin = f"""
+        init {foo}
+        set Port 0
+        set Address localhost
+        set DeviceType dummy
+        set Mode switch
+        set Broadcast no
+    """
+    foo.cmd(stdin=stdin)
+
+    if start_before_invite:
+        port = foo.start()
+
+    log.info("create invitation")
+    foo_invite, _ = foo.cmd("invite", bar.name)
+    assert foo_invite
+    foo_invite = foo_invite.strip()
+
+    if not start_before_invite:
+        port = foo.start()
+        foo_invite = foo_invite.replace(":0/", f":{port}/")
+
+    log.info("join second node with %s", foo_invite)
+    bar.cmd("join", foo_invite)
+    bar.cmd("set", "Port", "0")
+
+    if not start_before_invite:
+        log.info("%s thinks %s is using port 0, updating", bar, foo)
+        bar.cmd("set", f"{foo}.Port", str(port))
+
+    log.info("compare configs")
+    check.files_eq(foo.sub("hosts", foo.name), bar.sub("hosts", foo.name))
+
+    log.info("compare keys")
+
+    prefix = "Ed25519PublicKey"
+    foo_key = util.find_line(foo.sub("hosts", bar.name), prefix)
+    bar_key = util.find_line(bar.sub("hosts", bar.name), prefix)
+    check.equals(foo_key, bar_key)
+
+    log.info("checking Mode")
+    bar_mode, _ = bar.cmd("get", "Mode")
+    check.equals("switch", bar_mode.strip())
+
+    log.info("checking Broadcast")
+    bar_bcast, _ = bar.cmd("get", "Broadcast")
+    check.equals("no", bar_bcast.strip())
+
+    log.info("checking ConnectTo")
+    bar_conn, _ = bar.cmd("get", "ConnectTo")
+    check.equals(foo.name, bar_conn.strip())
+
+    log.info("configuring %s", bar.name)
+    bar.cmd("set", "DeviceType", "dummy")
+
+    log.info("adding scripts")
+    foo.add_script(bar.script_up)
+    bar.add_script(foo.script_up)
+
+    log.info("starting %s", bar.name)
+    bar.cmd("start")
+
+    log.info("waiting for nodes to come up")
+    foo[bar.script_up].wait()
+    bar[foo.script_up].wait()
+
+    log.info("checking required nodes")
+    check.nodes(foo, 2)
+    check.nodes(bar, 2)
+
+
+with Test("offline mode") as context:
+    run_invite_test(context, start_before_invite=False)
+
+with Test("online mode") as context:
+    run_invite_test(context, start_before_invite=True)
diff --git a/test/integration/invite_tinc_up.py b/test/integration/invite_tinc_up.py
new file mode 100755 (executable)
index 0000000..72414fb
--- /dev/null
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+
+"""Test inviting tinc nodes through tinc-up script."""
+
+import os
+import typing as T
+
+from testlib import check, util
+from testlib.log import log
+from testlib.proc import Tinc, Script
+from testlib.test import Test
+
+IFCONFIG = "93.184.216.34/24"
+ROUTES_IPV6 = ("2606:2800:220:1::/64", "2606:2800:220:1:248:1893:25c8:1946")
+BAD_IPV4 = "1234::"
+ED_PUBKEY = "Ed25519PublicKey"
+
+
+def make_inv_created(export_output: str) -> str:
+    """Generate script for invitation-created script."""
+    return f'''
+    node, invite = os.environ['NODE'], os.environ['INVITATION_FILE']
+    log.info('writing to invitation file %s, node %s', invite, node)
+
+    script = f"""
+Name = {{node}}
+Ifconfig = {IFCONFIG}
+Route = {' '.join(ROUTES_IPV6)}
+Route = 1.2.3.4 {BAD_IPV4}
+
+{export_output}
+""".strip()
+
+    with open(invite, 'w', encoding='utf-8') as f:
+        f.write(script)
+    '''
+
+
+def init(ctx: Test) -> T.Tuple[Tinc, Tinc]:
+    """Initialize new test nodes."""
+    foo, bar = ctx.node(), ctx.node()
+
+    stdin = f"""
+        init {foo}
+        set Port 0
+        set DeviceType dummy
+        set Address localhost
+    """
+    foo.cmd(stdin=stdin)
+    foo.start()
+
+    return foo, bar
+
+
+def run_tests(ctx: Test) -> None:
+    """Run all tests."""
+    foo, bar = init(ctx)
+
+    log.info("run export")
+    export, _ = foo.cmd("export")
+    assert export
+
+    log.info("adding invitation-created script")
+    code = make_inv_created(export)
+    foo.add_script(Script.INVITATION_CREATED, code)
+
+    log.info("inviting %s", bar)
+    url, _ = foo.cmd("invite", bar.name)
+    url = url.strip()
+    assert url
+
+    log.info('joining %s to %s with "%s"', bar, foo, url)
+    bar.cmd("--batch", "join", url)
+    bar.cmd("set", "Port", "0")
+
+    log.info("comparing host configs")
+    check.files_eq(foo.sub("hosts", foo.name), bar.sub("hosts", foo.name))
+
+    log.info("comparing public keys")
+    foo_key = util.find_line(foo.sub("hosts", bar.name), ED_PUBKEY)
+    bar_key = util.find_line(bar.sub("hosts", bar.name), ED_PUBKEY)
+    check.equals(foo_key, bar_key)
+
+    log.info("bar.tinc-up must not exist")
+    assert not os.path.exists(bar.sub("tinc-up"))
+
+    inv = bar.sub("tinc-up.invitation")
+    log.info("testing %s", inv)
+
+    content = util.read_text(inv)
+    check.is_in(IFCONFIG, content)
+    check.not_in(BAD_IPV4, content)
+
+    for route in ROUTES_IPV6:
+        check.is_in(route, content)
+
+    if os.name != "nt":
+        assert not os.access(inv, os.X_OK)
+
+
+with Test("invite-tinc-up") as context:
+    run_tests(context)
diff --git a/test/integration/legacy-protocol.test b/test/integration/legacy-protocol.test
deleted file mode 100755 (executable)
index 9c44f79..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initialize two nodes
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Port 30060
-set Address localhost
-add Subnet 10.98.98.1
-set PingTimeout 2
-EOF
-
-tinc bar <<EOF
-init bar
-set DeviceType dummy
-set Port 0
-add Subnet 10.98.98.2
-set PingTimeout 2
-set MaxTimeout 2
-EOF
-
-echo [STEP] Exchange host config files
-
-tinc foo export | tinc bar exchange | tinc foo import
-tinc bar add ConnectTo foo
-
-echo [STEP] Foo 1.1, bar 1.0
-
-tinc bar set ExperimentalProtocol no
-tinc foo del bar.Ed25519PublicKey
-tinc bar del foo.Ed25519PublicKey
-
-create_script foo hosts/bar-up
-create_script bar hosts/foo-up
-
-start_tinc foo
-start_tinc bar
-
-wait_script foo hosts/bar-up
-wait_script bar hosts/foo-up
-
-require_nodes foo 2
-require_nodes bar 2
-
-tinc bar stop
-tinc foo stop
-
-test -z "$(tinc foo get bar.Ed25519PublicKey)"
-test -z "$(tinc bar get foo.Ed25519PublicKey)"
-
-echo [STEP] Foo 1.1, bar upgrades to 1.1
-
-tinc bar del ExperimentalProtocol
-
-start_tinc foo
-start_tinc bar
-
-wait_script foo hosts/bar-up
-wait_script bar hosts/foo-up
-
-require_nodes foo 2
-require_nodes bar 2
-
-tinc bar stop
-tinc foo stop
-
-test -n "$(tinc foo get bar.Ed25519PublicKey)"
-test -n "$(tinc bar get foo.Ed25519PublicKey)"
-
-echo [STEP] Bar downgrades, must no longer be allowed to connect
-
-tinc bar set ExperimentalProtocol no
-
-create_script foo subnet-up
-start_tinc foo
-wait_script foo subnet-up
-
-create_script bar subnet-up
-start_tinc bar
-wait_script bar subnet-up
-
-# There is no reliable way to wait for 'not connecting'.
-sleep 10
-
-require_nodes foo 1
-require_nodes bar 1
diff --git a/test/integration/legacy_protocol.py b/test/integration/legacy_protocol.py
new file mode 100755 (executable)
index 0000000..845ac34
--- /dev/null
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+
+"""Test legacy protocol support (tinc 1.0)."""
+
+import typing as T
+
+from testlib import check, cmd
+from testlib.log import log
+from testlib.proc import Tinc, Script
+from testlib.test import Test
+
+TIMEOUT = 2
+
+
+def init(ctx: Test) -> T.Tuple[Tinc, Tinc]:
+    """Initialize new test nodes."""
+    foo, bar = ctx.node(), ctx.node()
+
+    stdin = f"""
+        init {foo}
+        set Port 0
+        set DeviceType dummy
+        set Address localhost
+        add Subnet 10.98.98.1
+        set PingTimeout {TIMEOUT}
+    """
+    foo.cmd(stdin=stdin)
+    foo.start()
+
+    stdin = f"""
+        init {bar}
+        set Port 0
+        set Address localhost
+        set DeviceType dummy
+        add Subnet 10.98.98.2
+        set PingTimeout {TIMEOUT}
+        set MaxTimeout {TIMEOUT}
+    """
+    bar.cmd(stdin=stdin)
+
+    cmd.exchange(foo, bar)
+    bar.cmd("add", "ConnectTo", foo.name)
+
+    foo.add_script(bar.script_up)
+    bar.add_script(foo.script_up)
+
+    return foo, bar
+
+
+def run_keys_test(foo: Tinc, bar: Tinc, empty: bool) -> None:
+    """Check that EC public keys match the expected values."""
+    bar.cmd("start")
+
+    foo[bar.script_up].wait()
+    bar[foo.script_up].wait()
+
+    check.nodes(foo, 2)
+    check.nodes(bar, 2)
+
+    foo_bar, _ = foo.cmd("get", f"{bar.name}.Ed25519PublicKey", code=None)
+    log.info('got key foo/bar "%s"', foo_bar)
+
+    bar_foo, _ = bar.cmd("get", f"{foo.name}.Ed25519PublicKey", code=None)
+    log.info('got key bar/foo "%s"', bar_foo)
+
+    assert not foo_bar == empty
+    assert not bar_foo == empty
+
+
+with Test("foo 1.1, bar 1.1") as context:
+    foo_node, bar_node = init(context)
+    run_keys_test(foo_node, bar_node, empty=False)
+
+with Test("foo 1.1, bar 1.0") as context:
+    foo_node, bar_node = init(context)
+    bar_node.cmd("set", "ExperimentalProtocol", "no")
+    foo_node.cmd("del", f"{bar_node}.Ed25519PublicKey")
+    bar_node.cmd("del", f"{foo_node}.Ed25519PublicKey")
+    run_keys_test(foo_node, bar_node, empty=True)
+
+with Test("bar 1.0 must not be allowed to connect") as context:
+    foo_node, bar_node = init(context)
+    bar_node.cmd("set", "ExperimentalProtocol", "no")
+
+    bar_up = bar_node.add_script(Script.SUBNET_UP)
+    bar_node.cmd("start")
+    bar_up.wait()
+
+    assert not foo_node[bar_node.script_up].wait(TIMEOUT * 2)
+    check.nodes(foo_node, 1)
+    check.nodes(bar_node, 1)
index b32ea82..5dc2430 100644 (file)
@@ -1,32 +1,30 @@
 tests = [
-  'basic.test',
-  'commandline.test',
-  'executables.test',
-  'import-export.test',
-  'invite-join.test',
-  'invite-offline.test',
-  'invite-tinc-up.test',
-  'security.test',
-  'security-legacy.test',
-  'security-sptps.test',
-  'variables.test',
+  'basic.py',
+  'command_fsck.py',
+  'commandline.py',
+  'executables.py',
+  'import_export.py',
+  'invite_tinc_up.py',
+  'invite.py',
+  'scripts.py',
+  'security.py',
+  'splice.py',
+  'sptps_basic.py',
+  'variables.py',
 ]
 
 if opt_crypto != 'nolegacy'
-  tests += 'algorithms.test'
-  tests += 'legacy-protocol.test'
-endif
-
-if os_name != 'windows'
-  tests += 'sptps-basic.test'
+  tests += [
+    'algorithms.py',
+    'legacy_protocol.py',
+  ]
 endif
 
 if os_name == 'linux'
-  tests += 'ns-ping.test'
-endif
-
-if os_name != 'sunos'
-  tests += 'scripts.test'
+  tests += [
+    'ns_ping.py',
+    'compression.py',
+  ]
 endif
 
 exe_splice = executable(
@@ -38,13 +36,14 @@ exe_splice = executable(
   build_by_default: false,
 )
 
-env = environment()
-env.set('TINC_PATH', exe_tinc.full_path())
-env.set('TINCD_PATH', exe_tincd.full_path())
-env.set('SPTPS_TEST_PATH', exe_sptps_test.full_path())
-env.set('SPTPS_KEYPAIR_PATH', exe_sptps_keypair.full_path())
-env.set('SPLICE_PATH', exe_splice.full_path())
-env.set('TESTLIB_PATH', meson.current_source_dir() / 'testlib.sh')
+env_vars = {
+  'TINC_PATH': exe_tinc.full_path(),
+  'TINCD_PATH': exe_tincd.full_path(),
+  'PYTHON_PATH': python_path,
+  'SPLICE_PATH': exe_splice.full_path(),
+  'SPTPS_TEST_PATH': exe_sptps_test.full_path(),
+  'SPTPS_KEYPAIR_PATH': exe_sptps_keypair.full_path(),
+}
 
 deps_test = [
   exe_tinc,
@@ -55,13 +54,24 @@ deps_test = [
 ]
 
 test_wd = meson.current_build_dir()
+test_src = meson.current_source_dir()
 
 foreach test_name : tests
-  target = find_program(test_name, native: true)
+  if meson_version.version_compare('>=0.52')
+    env = environment(env_vars)
+  else
+    env = environment()
+    foreach k, v : env_vars
+      env.set(k, v)
+    endforeach
+  endif
+  env.set('TEST_NAME', test_name)
+
   test(test_name,
-       target,
+       python,
+       args: test_src / test_name,
        suite: 'integration',
-       timeout: 5 * 60,
+       timeout: 60,
        env: env,
        depends: deps_test,
        workdir: test_wd)
diff --git a/test/integration/ns-ping.test b/test/integration/ns-ping.test
deleted file mode 100755 (executable)
index 33e7270..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-require_root "$0" "$@"
-test -e /dev/net/tun || exit "$EXIT_SKIP_TEST"
-ip netns list || exit "$EXIT_SKIP_TEST"
-
-ip_foo=192.168.1.1
-ip_bar=192.168.1.2
-mask=24
-
-echo [STEP] Create network namespaces
-
-ip netns add ping.test1
-ip netns add ping.test2
-
-cleanup_hook() {
-  ip netns del ping.test1
-  ip netns del ping.test2
-}
-
-echo [STEP] Initialize two nodes
-
-tinc foo <<EOF
-init foo
-set Subnet $ip_foo
-set Interface ping.test1
-set Port 30090
-set Address localhost
-set AutoConnect no
-EOF
-
-# shellcheck disable=SC2016
-create_script foo tinc-up "
-  ip link set dev \$INTERFACE netns ping.test1
-  ip netns exec ping.test1 ip addr add $ip_foo/$mask dev \$INTERFACE
-  ip netns exec ping.test1 ip link set \$INTERFACE up
-"
-
-tinc bar <<EOF
-init bar
-set Subnet $ip_bar
-set Interface ping.test2
-set Port 30091
-set AutoConnect no
-EOF
-
-# shellcheck disable=SC2016
-create_script bar tinc-up "
-  ip link set dev \$INTERFACE netns ping.test2
-  ip netns exec ping.test2 ip addr add $ip_bar/$mask dev \$INTERFACE
-  ip netns exec ping.test2 ip link set \$INTERFACE up
-"
-
-echo [STEP] Exchange configuration files
-
-tinc foo export | tinc bar exchange | tinc foo import
-
-echo [STEP] Start tinc
-
-start_tinc foo
-start_tinc bar
-
-wait_script foo tinc-up
-wait_script bar tinc-up
-
-echo [STEP] The nodes should not be able to ping each other if there is no connection
-
-must_fail ip netns exec ping.test1 ping -W1 -c3 $ip_bar
-
-echo [STEP] After connecting they should be
-
-create_script bar hosts/foo-up
-
-tinc bar add ConnectTo foo
-wait_script bar hosts/foo-up
-
-ip netns exec ping.test1 ping -W1 -c3 $ip_bar
diff --git a/test/integration/ns_ping.py b/test/integration/ns_ping.py
new file mode 100755 (executable)
index 0000000..dcb4266
--- /dev/null
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+
+"""Create two network namespaces and run ping between them."""
+
+import subprocess as subp
+import typing as T
+
+from testlib import external as ext, util, template, cmd
+from testlib.log import log
+from testlib.proc import Tinc, Script
+from testlib.test import Test
+
+util.require_root()
+util.require_command("ip", "netns", "list")
+util.require_path("/dev/net/tun")
+
+IP_FOO = "192.168.1.1"
+IP_BAR = "192.168.1.2"
+MASK = 24
+
+
+def init(ctx: Test) -> T.Tuple[Tinc, Tinc]:
+    """Initialize new test nodes."""
+    foo, bar = ctx.node(), ctx.node()
+
+    log.info("create network namespaces")
+    assert ext.netns_add(foo.name)
+    assert ext.netns_add(bar.name)
+
+    log.info("initialize two nodes")
+
+    stdin = f"""
+        init {foo}
+        set Port 0
+        set Subnet {IP_FOO}
+        set Interface {foo}
+        set Address localhost
+        set AutoConnect no
+    """
+    foo.cmd(stdin=stdin)
+    foo.add_script(Script.TINC_UP, template.make_netns_config(foo.name, IP_FOO, MASK))
+    foo.start()
+
+    stdin = f"""
+        init {bar}
+        set Port 0
+        set Subnet {IP_BAR}
+        set Interface {bar}
+        set Address localhost
+        set AutoConnect no
+    """
+    bar.cmd(stdin=stdin)
+    bar.add_script(Script.TINC_UP, template.make_netns_config(bar.name, IP_BAR, MASK))
+
+    cmd.exchange(foo, bar)
+
+    return foo, bar
+
+
+def ping(namespace: str, ip_addr: str) -> int:
+    """Send pings between two network namespaces."""
+    log.info("pinging node from netns %s at %s", namespace, ip_addr)
+    proc = subp.run(
+        ["ip", "netns", "exec", namespace, "ping", "-W1", "-c1", ip_addr], check=False
+    )
+
+    log.info("ping finished with code %d", proc.returncode)
+    return proc.returncode
+
+
+with Test("ns-ping") as context:
+    foo_node, bar_node = init(context)
+    bar_node.cmd("start")
+
+    log.info("waiting for nodes to come up")
+    bar_node[Script.TINC_UP].wait()
+
+    log.info("ping must not work when there is no connection")
+    assert ping(foo_node.name, IP_BAR)
+
+    log.info("add script foo/host-up")
+    bar_node.add_script(foo_node.script_up)
+
+    log.info("add ConnectTo clause")
+    bar_node.cmd("add", "ConnectTo", foo_node.name)
+
+    log.info("bar waits for foo")
+    bar_node[foo_node.script_up].wait()
+
+    log.info("ping must work after connection is up")
+    assert not ping(foo_node.name, IP_BAR)
diff --git a/test/integration/scripts.py b/test/integration/scripts.py
new file mode 100755 (executable)
index 0000000..ef2fc7e
--- /dev/null
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+
+"""Test that all tincd scripts execute in correct order and contain expected env vars."""
+
+import os
+import typing as T
+
+from testlib import check
+from testlib.log import log
+from testlib.proc import Tinc, Script, ScriptType, TincScript
+from testlib.test import Test
+from testlib.util import random_string
+
+SUBNET_SERVER = ("10.0.0.1", "fec0::/64")
+SUBNET_CLIENT = ("10.0.0.2", "fec0::/64#5")
+NETNAMES = {
+    "server": "net_" + random_string(8),
+    "invite": "net_" + random_string(8),
+    "client": "net_" + random_string(8),
+}
+
+# Creation time for the last notification event we've received.
+# Used for checking that scripts are called in the correct order.
+# dict is to avoid angering linters by using `global` to update this value.
+last_time = {"time": -1}
+
+
+def init(ctx: Test) -> T.Tuple[Tinc, Tinc]:
+    """Initialize new test nodes."""
+    server, client = ctx.node(), ctx.node()
+
+    stdin = f"""
+        init {server}
+        set Port 0
+        set DeviceType dummy
+        set Address 127.0.0.1
+        set AddressFamily ipv4
+        add Subnet {SUBNET_SERVER[0]}
+        add Subnet {SUBNET_SERVER[1]}
+    """
+    server.cmd(stdin=stdin)
+
+    for script in (
+        *Script,
+        server.script_up,
+        server.script_down,
+        client.script_up,
+        client.script_down,
+    ):
+        server.add_script(script)
+
+    return server, client
+
+
+def wait_script(script: TincScript) -> T.Dict[str, str]:
+    """Wait for script to finish and check that it was run by tincd *after* the
+    script that was used as the argument in the previous call to this function.
+
+    For example, to check that SUBNET_UP is called after TINC_UP:
+        wait_script(node[Script.TINC_UP])
+        wait_script(node[Script.SUBNET_UP])
+    """
+    msg = script.wait()
+    assert msg.created_at
+
+    log.debug(
+        "%s sent %d, prev %d, diff %d",
+        script,
+        msg.created_at,
+        last_time["time"],
+        msg.created_at - last_time["time"],
+    )
+
+    if msg.created_at <= last_time["time"]:
+        raise ValueError(f"script {script} started in wrong order")
+
+    last_time["time"] = msg.created_at
+    return msg.env
+
+
+def wait_tinc(server: Tinc, script: Script) -> None:
+    """Wait for TINC_UP / TINC_DOWN and check env vars."""
+    log.info("checking tinc: %s %s", server, script)
+
+    env = wait_script(server[script])
+    check.equals(NETNAMES["server"], env["NETNAME"])
+    check.equals(server.name, env["NAME"])
+    check.equals("dummy", env["DEVICE"])
+
+
+def wait_subnet(server: Tinc, script: Script, node: Tinc, subnet: str) -> None:
+    """Wait for SUBNET_UP / SUBNET_DOWN and check env vars."""
+    log.info("checking subnet: %s %s %s %s", server, script, node, subnet)
+
+    env = wait_script(server[script])
+    check.equals(NETNAMES["server"], env["NETNAME"])
+    check.equals(server.name, env["NAME"])
+    check.equals("dummy", env["DEVICE"])
+    check.equals(node.name, env["NODE"])
+
+    if node != server:
+        check.equals("127.0.0.1", env["REMOTEADDRESS"])
+        check.equals(str(node.port), env["REMOTEPORT"])
+
+    if "#" in subnet:
+        addr, weight = subnet.split("#")
+        check.equals(addr, env["SUBNET"])
+        check.equals(weight, env["WEIGHT"])
+    else:
+        check.equals(subnet, env["SUBNET"])
+
+
+def wait_host(server: Tinc, client: Tinc, script: ScriptType) -> None:
+    """Wait for HOST_UP / HOST_DOWN and check env vars."""
+    log.info("checking host: %s %s %s", server, client, script)
+
+    env = wait_script(server[script])
+    check.equals(NETNAMES["server"], env["NETNAME"])
+    check.equals(server.name, env["NAME"])
+    check.equals(client.name, env["NODE"])
+    check.equals("dummy", env["DEVICE"])
+    check.equals("127.0.0.1", env["REMOTEADDRESS"])
+    check.equals(str(client.port), env["REMOTEPORT"])
+
+
+def test_start_server(server: Tinc) -> None:
+    """Start server node and run checks on its scripts."""
+    server.cmd("-n", NETNAMES["server"], "start")
+    wait_tinc(server, Script.TINC_UP)
+
+    port = server.read_port()
+    server.cmd("set", "port", str(port))
+
+    log.info("test server subnet-up")
+    for sub in SUBNET_SERVER:
+        wait_subnet(server, Script.SUBNET_UP, server, sub)
+
+
+def test_invite_client(server: Tinc, client: Tinc) -> str:
+    """Check that client invitation scripts work."""
+    url, _ = server.cmd("-n", NETNAMES["invite"], "invite", client.name)
+    url = url.strip()
+    check.true(url)
+
+    env = wait_script(server[Script.INVITATION_CREATED])
+    check.equals(NETNAMES["invite"], env["NETNAME"])
+    check.equals(server.name, env["NAME"])
+    check.equals(client.name, env["NODE"])
+    check.equals(url, env["INVITATION_URL"])
+    assert os.path.isfile(env["INVITATION_FILE"])
+
+    return url
+
+
+def test_join_client(server: Tinc, client: Tinc, url: str) -> None:
+    """Test that client joining scripts work."""
+    client.cmd("-n", NETNAMES["client"], "join", url)
+
+    env = wait_script(server[Script.INVITATION_ACCEPTED])
+    check.equals(NETNAMES["server"], env["NETNAME"])
+    check.equals(server.name, env["NAME"])
+    check.equals(client.name, env["NODE"])
+    check.equals("dummy", env["DEVICE"])
+    check.equals("127.0.0.1", env["REMOTEADDRESS"])
+
+
+def test_start_client(server: Tinc, client: Tinc) -> None:
+    """Start client and check its script work."""
+    client.randomize_port()
+
+    stdin = f"""
+        set Address {client.address}
+        set ListenAddress {client.address}
+        set Port {client.port}
+        set DeviceType dummy
+        add Subnet {SUBNET_CLIENT[0]}
+        add Subnet {SUBNET_CLIENT[1]}
+        start
+    """
+    client.cmd(stdin=stdin)
+
+    log.info("test client scripts")
+    wait_host(server, client, Script.HOST_UP)
+    wait_host(server, client, client.script_up)
+
+    log.info("test client subnet-up")
+    for sub in SUBNET_CLIENT:
+        wait_subnet(server, Script.SUBNET_UP, client, sub)
+
+
+def test_stop_server(server: Tinc, client: Tinc) -> None:
+    """Stop server and check that its scripts work."""
+    server.cmd("stop")
+    wait_host(server, client, Script.HOST_DOWN)
+    wait_host(server, client, client.script_down)
+
+    log.info("test client subnet-down")
+    for sub in SUBNET_CLIENT:
+        wait_subnet(server, Script.SUBNET_DOWN, client, sub)
+
+    log.info("test server subnet-down")
+    for sub in SUBNET_SERVER:
+        wait_subnet(server, Script.SUBNET_DOWN, server, sub)
+
+    log.info("test tinc-down")
+    wait_tinc(server, Script.TINC_DOWN)
+
+
+def run_tests(ctx: Test) -> None:
+    """Run all tests."""
+    server, client = init(ctx)
+
+    log.info("start server")
+    test_start_server(server)
+
+    log.info("invite client")
+    url = test_invite_client(server, client)
+
+    log.info('join client via url "%s"', url)
+    test_join_client(server, client, url)
+
+    log.info("start client")
+    test_start_client(server, client)
+
+    log.info("stop server")
+    test_stop_server(server, client)
+
+
+with Test("scripts test") as context:
+    run_tests(context)
diff --git a/test/integration/scripts.test b/test/integration/scripts.test
deleted file mode 100755 (executable)
index ff0e565..0000000
+++ /dev/null
@@ -1,133 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initializing server node
-
-port_foo=30040
-port_bar=30041
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Port $port_foo
-set Address 127.0.0.1
-add Subnet 10.0.0.1
-add Subnet fec0::/64
-EOF
-
-echo [STEP] Setting up scripts
-
-OUT=$DIR_FOO/scripts.out
-rm -f "$OUT"
-
-for script in \
-  tinc-up tinc-down \
-  host-up host-down \
-  subnet-up subnet-down \
-  hosts/foo-up hosts/foo-down \
-  hosts/bar-up hosts/bar-down \
-  invitation-created invitation-accepted; do
-
-  commands=$(
-    cat <<EOF
-      if is_windows && [ -n "\$INVITATION_FILE" ]; then
-        INVITATION_FILE=\$(cygpath --unix -- "\$INVITATION_FILE")
-      fi
-      echo >>'$OUT' "$script" "$TINC_SCRIPT_VARS"
-EOF
-  )
-
-  create_script foo "$script" "$commands"
-done
-
-echo [STEP] Starting server node
-
-start_tinc foo -n netname
-wait_script foo subnet-up 2
-echo foo-started >>"$OUT"
-
-echo [STEP] Inviting client node
-
-url=$(tinc foo -n netname2 invite bar)
-file=$(basename "$(find "$DIR_FOO/invitations" -type f ! -name ed25519_key.priv)")
-
-if is_windows; then
-  file=$(cygpath --unix -- "$file")
-fi
-
-wait_script foo invitation-created
-echo bar-invited >>"$OUT"
-
-echo [STEP] Joining client node
-
-tinc bar -n netname3 join "$url"
-wait_script foo invitation-accepted
-echo bar-joined >>"$OUT"
-
-echo [STEP] Starting client node
-
-tinc bar <<EOF
-set DeviceType dummy
-set Port $port_bar
-add Subnet 10.0.0.2
-add Subnet fec0::/64#5
-EOF
-
-start_tinc bar
-wait_script foo subnet-up 2
-echo bar-started-1 >>"$OUT"
-
-tinc foo debug 4
-tinc bar stop
-wait_script foo subnet-down 2
-echo bar-stopped >>"$OUT"
-
-tinc foo debug 5
-start_tinc bar
-wait_script foo subnet-up 2
-echo bar-started-2 >>"$OUT"
-
-echo [STEP] Stop server node
-
-tinc foo stop
-tinc bar stop
-wait_script foo tinc-down
-
-echo [STEP] Check if the script output is what is expected
-
-cat >"$OUT.expected" <<EOF
-tinc-up netname,foo,dummy,,,,,,,,,5
-subnet-up netname,foo,dummy,,foo,,,10.0.0.1,,,,5
-subnet-up netname,foo,dummy,,foo,,,fec0::/64,,,,5
-foo-started
-invitation-created netname2,foo,,,bar,,,,,$DIR_FOO/invitations/$file,$url,
-bar-invited
-invitation-accepted netname,foo,dummy,,bar,127.0.0.1,,,,,,5
-bar-joined
-host-up netname,foo,dummy,,bar,127.0.0.1,$port_bar,,,,,5
-hosts/bar-up netname,foo,dummy,,bar,127.0.0.1,$port_bar,,,,,5
-subnet-up netname,foo,dummy,,bar,127.0.0.1,$port_bar,10.0.0.2,,,,5
-subnet-up netname,foo,dummy,,bar,127.0.0.1,$port_bar,fec0::/64,5,,,5
-bar-started-1
-host-down netname,foo,dummy,,bar,127.0.0.1,$port_bar,,,,,4
-hosts/bar-down netname,foo,dummy,,bar,127.0.0.1,$port_bar,,,,,4
-subnet-down netname,foo,dummy,,bar,127.0.0.1,$port_bar,10.0.0.2,,,,4
-subnet-down netname,foo,dummy,,bar,127.0.0.1,$port_bar,fec0::/64,5,,,4
-bar-stopped
-host-up netname,foo,dummy,,bar,127.0.0.1,$port_bar,,,,,5
-hosts/bar-up netname,foo,dummy,,bar,127.0.0.1,$port_bar,,,,,5
-subnet-up netname,foo,dummy,,bar,127.0.0.1,$port_bar,10.0.0.2,,,,5
-subnet-up netname,foo,dummy,,bar,127.0.0.1,$port_bar,fec0::/64,5,,,5
-bar-started-2
-host-down netname,foo,dummy,,bar,127.0.0.1,$port_bar,,,,,5
-hosts/bar-down netname,foo,dummy,,bar,127.0.0.1,$port_bar,,,,,5
-subnet-down netname,foo,dummy,,bar,127.0.0.1,$port_bar,10.0.0.2,,,,5
-subnet-down netname,foo,dummy,,bar,127.0.0.1,$port_bar,fec0::/64,5,,,5
-subnet-down netname,foo,dummy,,foo,,,10.0.0.1,,,,5
-subnet-down netname,foo,dummy,,foo,,,fec0::/64,,,,5
-tinc-down netname,foo,dummy,,,,,,,,,5
-EOF
-
-diff -w "$OUT" "$OUT.expected"
diff --git a/test/integration/security-legacy.test b/test/integration/security-legacy.test
deleted file mode 100755 (executable)
index 7f036d3..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Skip this test if tools are missing
-
-command -v nc >/dev/null || exit "$EXIT_SKIP_TEST"
-command -v timeout >/dev/null || exit "$EXIT_SKIP_TEST"
-
-foo_port=30110
-bar_port=30111
-
-# usage: splice protocol_version
-splice() {
-  "$SPLICE_PATH" foo localhost $foo_port bar localhost $bar_port "$1" &
-  sleep 10
-}
-
-# usage: send_with_timeout "data to send" "data expected to receive"
-send_with_timeout() {
-  data=$1
-  expected=$3
-
-  result=$(
-    (
-      sleep 6
-      printf "%s\n" "$data"
-    ) | timeout 10 nc localhost $foo_port
-  ) && exit 1
-
-  test $? = "$EXIT_TIMEOUT"
-
-  if [ -z "$expected" ]; then
-    test -z "$result"
-  else
-    echo "$result" | grep -q "^$expected"
-  fi
-}
-
-echo [STEP] Initialize two nodes
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Port $foo_port
-set Address localhost
-set PingTimeout 3
-set ExperimentalProtocol no
-set AutoConnect no
-set Subnet 10.96.96.1
-EOF
-
-tinc bar <<EOF
-init bar
-set DeviceType dummy
-set Port $bar_port
-set PingTimeout 3
-set MaxTimeout 3
-set ExperimentalProtocol no
-set AutoConnect no
-set Subnet 10.96.96.2
-EOF
-
-echo [STEP] Exchange host config files
-
-tinc foo export | tinc bar exchange | tinc foo import
-
-create_script foo subnet-up
-start_tinc foo
-wait_script foo subnet-up
-
-create_script bar subnet-up
-start_tinc bar
-wait_script bar subnet-up
-
-echo [STEP] No splicing allowed, legacy protocol
-
-splice 17.7
-pid=$!
-
-require_nodes foo 1
-require_nodes bar 1
-
-kill $pid
-
-tinc bar stop
-tinc foo stop
diff --git a/test/integration/security-sptps.test b/test/integration/security-sptps.test
deleted file mode 100755 (executable)
index b4b0543..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Skip this test if tools are missing
-
-command -v nc >/dev/null || exit "$EXIT_SKIP_TEST"
-command -v timeout >/dev/null || exit "$EXIT_SKIP_TEST"
-
-foo_port=30120
-bar_port=30121
-
-# usage: splice protocol_version
-splice() {
-  "$SPLICE_PATH" foo localhost $foo_port bar localhost $bar_port "$1" &
-  sleep 10
-}
-
-# usage: send_with_timeout "data to send" "data expected to receive"
-send_with_timeout() {
-  data=$1
-  expected=$3
-
-  result=$(
-    (
-      sleep 6
-      printf "%s\n" "$data"
-    ) | timeout 10 nc localhost $foo_port
-  ) && exit 1
-
-  test $? = "$EXIT_TIMEOUT"
-
-  if [ -z "$expected" ]; then
-    test -z "$result"
-  else
-    echo "$result" | grep -q "^$expected"
-  fi
-}
-
-echo [STEP] Initialize two nodes
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Port $foo_port
-set Address localhost
-set PingTimeout 3
-set AutoConnect no
-set Subnet 10.96.96.1
-EOF
-
-tinc bar <<EOF
-init bar
-set DeviceType dummy
-set Port $bar_port
-set PingTimeout 3
-set MaxTimeout 3
-set AutoConnect no
-set Subnet 10.96.96.2
-EOF
-
-echo [STEP] Exchange host config files
-
-tinc foo export | tinc bar exchange | tinc foo import
-
-create_script foo subnet-up
-start_tinc foo
-wait_script foo subnet-up
-
-create_script bar subnet-up
-start_tinc bar
-wait_script bar subnet-up
-
-echo [STEP] No splicing allowed, SPTPS
-
-splice 17.7
-pid=$!
-
-require_nodes foo 1
-require_nodes bar 1
-
-kill $pid
-
-tinc bar stop
-tinc foo stop
diff --git a/test/integration/security.py b/test/integration/security.py
new file mode 100755 (executable)
index 0000000..11f42f8
--- /dev/null
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+
+"""Test tinc protocol security."""
+
+import asyncio
+import typing as T
+
+from testlib import check
+from testlib.log import log
+from testlib.proc import Tinc, Script
+from testlib.test import Test
+
+TIMEOUT = 2
+
+
+async def recv(read: asyncio.StreamReader, out: T.List[bytes]) -> None:
+    """Receive data until connection is closed."""
+    while not read.at_eof():
+        rec = await read.read(1)
+        out.append(rec)
+
+
+async def send(port: int, buf: str, delay: float = 0) -> bytes:
+    """Send data and receive response."""
+    raw = f"{buf}\n".encode("utf-8")
+    read, write = await asyncio.open_connection(host="localhost", port=port)
+
+    if delay:
+        await asyncio.sleep(delay)
+
+    received: T.List[bytes] = []
+    try:
+        write.write(raw)
+        await asyncio.wait_for(recv(read, received), timeout=1)
+    except asyncio.TimeoutError:
+        log.info('received: "%s"', received)
+        return b"".join(received)
+
+    raise RuntimeError("test should not have reached this line")
+
+
+async def test_id_timeout(foo: Tinc) -> None:
+    """Test that peer does not send its ID before us."""
+    log.info("no ID sent by peer if we don't send ID before the timeout")
+    data = await send(foo.port, "0 bar 17.7", delay=TIMEOUT * 1.5)
+    check.false(data)
+
+
+async def test_tarpitted(foo: Tinc) -> None:
+    """Test that peer sends its ID if we send first and are in tarpit."""
+    log.info("ID sent if initiator sends first, but still tarpitted")
+    data = await send(foo.port, "0 bar 17.7")
+    check.has_prefix(data, f"0 {foo} 17.7".encode("utf-8"))
+
+
+async def test_invalid_id_own(foo: Tinc) -> None:
+    """Test that peer does not accept its own ID."""
+    log.info("own ID not allowed")
+    data = await send(foo.port, f"0 {foo} 17.7")
+    check.false(data)
+
+
+async def test_invalid_id_unknown(foo: Tinc) -> None:
+    """Test that peer does not accept unknown ID."""
+    log.info("no unknown IDs allowed")
+    data = await send(foo.port, "0 baz 17.7")
+    check.false(data)
+
+
+async def test_null_metakey(foo: Tinc) -> None:
+    """Test that NULL metakey is not accepted."""
+    null_metakey = f"""
+0 {foo} 17.0\
+1 0 672 0 0 834188619F4D943FD0F4B1336F428BD4AC06171FEABA66BD2356BC9593F0ECD643F\
+0E4B748C670D7750DFDE75DC9F1D8F65AB1026F5ED2A176466FBA4167CC567A2085ABD070C1545B\
+180BDA86020E275EA9335F509C57786F4ED2378EFFF331869B856DDE1C05C461E4EECAF0E2FB97A\
+F77B7BC2AD1B34C12992E45F5D1254BBF0C3FB224ABB3E8859594A83B6CA393ED81ECAC9221CE6B\
+C71A727BCAD87DD80FC0834B87BADB5CB8FD3F08BEF90115A8DF1923D7CD9529729F27E1B8ABD83\
+C4CF8818AE10257162E0057A658E265610B71F9BA4B365A20C70578FAC65B51B91100392171BA12\
+A440A5E93C4AA62E0C9B6FC9B68F953514AAA7831B4B2C31C4
+""".strip()
+
+    log.info("no NULL METAKEY allowed")
+    data = await send(foo.port, null_metakey)
+    check.false(data)
+
+
+def init(ctx: Test) -> Tinc:
+    """Initialize new test nodes."""
+    foo = ctx.node()
+
+    stdin = f"""
+        init {foo}
+        set Port 0
+        set DeviceType dummy
+        set Address localhost
+        set PingTimeout {TIMEOUT}
+        set AutoConnect no
+        set Subnet 10.96.96.1
+    """
+    foo.cmd(stdin=stdin)
+
+    foo.add_script(Script.SUBNET_UP)
+    foo.start()
+    foo[Script.SUBNET_UP].wait()
+
+    return foo
+
+
+async def run_tests(ctx: Test) -> None:
+    """Run all tests."""
+    foo = init(ctx)
+
+    log.info("getting into tarpit")
+    await test_id_timeout(foo)
+
+    log.info("starting other tests")
+    await asyncio.gather(
+        test_invalid_id_own(foo),
+        test_invalid_id_unknown(foo),
+        test_null_metakey(foo),
+    )
+
+
+loop = asyncio.get_event_loop()
+
+with Test("security") as context:
+    loop.run_until_complete(run_tests(context))
diff --git a/test/integration/security.test b/test/integration/security.test
deleted file mode 100755 (executable)
index 3945079..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Skip this test if tools are missing
-
-command -v nc >/dev/null || exit "$EXIT_SKIP_TEST"
-command -v timeout >/dev/null || exit "$EXIT_SKIP_TEST"
-
-foo_port=30050
-
-# usage: send_with_timeout "data to send" "data expected to receive"
-send_with_timeout() {
-  data=$1
-  expected=$3
-
-  result=$(
-    (
-      sleep 6
-      printf "%s\n" "$data"
-    ) | timeout 10 nc localhost $foo_port
-  ) && exit 1
-
-  test $? = "$EXIT_TIMEOUT"
-
-  if [ -z "$expected" ]; then
-    test -z "$result"
-  else
-    echo "$result" | grep -q "^$expected"
-  fi
-}
-
-echo [STEP] Initialize two nodes
-
-tinc foo <<EOF
-init foo
-set DeviceType dummy
-set Port $foo_port
-set Address localhost
-set PingTimeout 3
-set AutoConnect no
-set Subnet 10.96.96.1
-EOF
-
-create_script foo subnet-up
-start_tinc foo
-wait_script foo subnet-up
-
-echo "[STEP] No ID sent by responding node if we don't send an ID first, before the timeout"
-send_with_timeout "0 bar 17.7" "" &
-job1=$!
-
-echo [STEP] ID sent if initiator sends first, but still tarpitted
-send_with_timeout "0 bar 17.7" "0 foo 17.7" &
-job2=$!
-
-echo [STEP] No invalid IDs allowed
-send_with_timeout "0 foo 17.7" "" &
-job3=$!
-send_with_timeout "0 baz 17.7" "" &
-job4=$!
-
-echo [STEP] No NULL METAKEYs allowed
-data="0 foo 17.0\n1 0 672 0 0 834188619F4D943FD0F4B1336F428BD4AC06171FEABA66BD2356BC9593F0ECD643F0E4B748C670D7750DFDE75DC9F1D8F65AB1026F5ED2A176466FBA4167CC567A2085ABD070C1545B180BDA86020E275EA9335F509C57786F4ED2378EFFF331869B856DDE1C05C461E4EECAF0E2FB97AF77B7BC2AD1B34C12992E45F5D1254BBF0C3FB224ABB3E8859594A83B6CA393ED81ECAC9221CE6BC71A727BCAD87DD80FC0834B87BADB5CB8FD3F08BEF90115A8DF1923D7CD9529729F27E1B8ABD83C4CF8818AE10257162E0057A658E265610B71F9BA4B365A20C70578FAC65B51B91100392171BA12A440A5E93C4AA62E0C9B6FC9B68F953514AAA7831B4B2C31C4\n"
-send_with_timeout "$data" "" & # Not even the ID should be sent when the first packet contains illegal data
-job5=$!
-
-for pid in $job1 $job2 $job3 $job4 $job5; do
-  wait $pid
-done
-
-tinc foo stop
diff --git a/test/integration/splice.py b/test/integration/splice.py
new file mode 100755 (executable)
index 0000000..578845f
--- /dev/null
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+
+"""Test splicing connection between tinc peers."""
+
+import os
+import subprocess as subp
+import typing as T
+
+from testlib import check, cmd, path
+from testlib.log import log
+from testlib.proc import Tinc, Script
+from testlib.test import Test
+
+
+def init(ctx: Test, *options: str) -> T.Tuple[Tinc, Tinc]:
+    """Initialize new test nodes."""
+    custom = os.linesep.join(options)
+    log.info('init two nodes with options "%s"', custom)
+
+    foo, bar = ctx.node(), ctx.node()
+
+    stdin = f"""
+        init {foo}
+        set Port 0
+        set DeviceType dummy
+        set Address localhost
+        set AutoConnect no
+        set Subnet 10.96.96.1
+        {custom}
+    """
+    foo.cmd(stdin=stdin)
+
+    stdin = f"""
+        init {bar}
+        set Port 0
+        set Address localhost
+        set DeviceType dummy
+        set AutoConnect no
+        set Subnet 10.96.96.2
+        {custom}
+    """
+    bar.cmd(stdin=stdin)
+
+    foo.add_script(Script.SUBNET_UP)
+    bar.add_script(Script.SUBNET_UP)
+
+    foo.start()
+    bar.start()
+
+    log.info("exchange host configs")
+    cmd.exchange(foo, bar)
+
+    return foo, bar
+
+
+def splice(foo: Tinc, bar: Tinc, protocol: str) -> subp.Popen:
+    """Start splice between nodes."""
+    args = [
+        path.SPLICE_PATH,
+        foo.name,
+        "localhost",
+        str(foo.port),
+        bar.name,
+        "localhost",
+        str(bar.port),
+        protocol,
+    ]
+    log.info("splice with args %s", args)
+    return subp.Popen(args)
+
+
+def test_splice(ctx: Test, protocol: str, *options: str) -> None:
+    """Splice connection and check that it fails."""
+    log.info("no splicing allowed (%s)", protocol)
+    foo, bar = init(ctx, *options)
+
+    log.info("waiting for subnets to come up")
+    foo[Script.SUBNET_UP].wait()
+    bar[Script.SUBNET_UP].wait()
+
+    splice_proc = splice(foo, bar, protocol)
+    try:
+        check.nodes(foo, 1)
+        check.nodes(bar, 1)
+    finally:
+        splice_proc.kill()
+
+
+with Test("sptps") as context:
+    test_splice(context, "17.7")
+
+with Test("legacy") as context:
+    test_splice(context, "17.0", "set ExperimentalProtocol no")
diff --git a/test/integration/sptps-basic.test b/test/integration/sptps-basic.test
deleted file mode 100755 (executable)
index 0eb285b..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Skip this test if we did not compile sptps_test
-
-test -e "$SPTPS_TEST" -a -e "$SPTPS_KEYPAIR_PATH" || exit "$EXIT_SKIP_TEST"
-
-port=30080
-
-server_priv="$DIR_FOO/server.priv"
-client_priv="$DIR_FOO/client.priv"
-server_pub="$DIR_FOO/server.pub"
-client_pub="$DIR_FOO/client.pub"
-
-echo [STEP] Generate keys
-
-mkdir -p "$DIR_FOO"
-"$SPTPS_KEYPAIR_PATH" "$server_priv" "$server_pub"
-"$SPTPS_KEYPAIR_PATH" "$client_priv" "$client_pub"
-
-echo [STEP] Test transfer of a simple file
-
-reference=testlib.sh
-
-(
-  sleep 3
-  "$SPTPS_TEST_PATH" -4 -q "$client_priv" "$server_pub" localhost $port <"$reference"
-) &
-
-"$SPTPS_TEST_PATH" -4 "$server_priv" "$client_pub" $port >"$DIR_FOO/out1"
-diff -w "$DIR_FOO/out1" "$reference"
-
-"$SPTPS_TEST_PATH" -4 -q "$server_priv" "$client_pub" $port <"$reference" &
-sleep 3
-"$SPTPS_TEST_PATH" -4 "$client_priv" "$server_pub" localhost $port >"$DIR_FOO/out2"
-diff -w "$DIR_FOO/out2" "$reference"
-
-echo [STEP] Datagram mode
-
-"$SPTPS_TEST_PATH" -4 -dq "$server_priv" "$client_pub" $port <"$reference" &
-sleep 3
-sleep 3 | "$SPTPS_TEST_PATH" -4 -dq "$client_priv" "$server_pub" localhost $port >"$DIR_FOO/out3"
-diff -w "$DIR_FOO/out3" "$reference"
diff --git a/test/integration/sptps_basic.py b/test/integration/sptps_basic.py
new file mode 100755 (executable)
index 0000000..c5c42cc
--- /dev/null
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+
+"""Test basic SPTPS features."""
+
+import os
+import subprocess as subp
+import re
+
+from testlib import path, util, check
+from testlib.log import log
+
+port_re = re.compile(r"Listening on (\d+)\.\.\.")
+
+
+class Keypair:
+    """Create public/private keypair using sptps_keypair."""
+
+    private: str
+    public: str
+
+    def __init__(self, name: str) -> None:
+        self.private = os.path.join(path.TEST_WD, f"{name}.priv")
+        self.public = os.path.join(path.TEST_WD, f"{name}.pub")
+        subp.run([path.SPTPS_KEYPAIR_PATH, self.private, self.public], check=True)
+
+
+log.info("generate keys")
+server_key = Keypair("server")
+client_key = Keypair("client")
+
+log.info("transfer random data")
+DATA = util.random_string(256).encode("utf-8")
+
+
+def run_client(port: int, key_priv: str, key_pub: str, *flags: str) -> None:
+    """Start client version of sptps_test."""
+    client_cmd = [
+        path.SPTPS_TEST_PATH,
+        "-4",
+        "-q",
+        *flags,
+        key_priv,
+        key_pub,
+        "localhost",
+        str(port),
+    ]
+    log.info('start client with "%s"', " ".join(client_cmd))
+    subp.run(client_cmd, input=DATA, check=True)
+
+
+def get_port(server: subp.Popen) -> int:
+    """Get port that sptps_test server is listening on."""
+    assert server.stderr
+    while True:
+        line = server.stderr.readline().decode("utf-8")
+        match = port_re.match(line)
+        if match:
+            return int(match[1])
+        log.debug("waiting for server to start accepting connections")
+
+
+def test(key0: Keypair, key1: Keypair, *flags: str) -> None:
+    """Run tests using the supplied keypair."""
+    server_cmd = [path.SPTPS_TEST_PATH, "-4", *flags, key0.private, key1.public, "0"]
+    log.info('start server with "%s"', " ".join(server_cmd))
+
+    with subp.Popen(server_cmd, stdout=subp.PIPE, stderr=subp.PIPE) as server:
+        assert server.stdout
+
+        port = get_port(server)
+        run_client(port, key1.private, key0.public, *flags)
+
+        received = b""
+        while len(received) < len(DATA):
+            received += server.stdout.read()
+
+        if server.returncode is None:
+            server.kill()
+
+    check.equals(DATA, received)
+
+
+def run_keypair_tests(*flags: str) -> None:
+    """Run tests on all generated keypairs."""
+    log.info("running tests with (client, server) keypair and flags %s", flags)
+    test(server_key, client_key)
+
+    log.info("running tests with (server, client) keypair and flags %s", flags)
+    test(client_key, server_key)
+
+
+log.info("running tests in stream mode")
+run_keypair_tests()
+
+log.info("running tests in datagram mode")
+run_keypair_tests("-dq")
diff --git a/test/integration/testlib.sh b/test/integration/testlib.sh
deleted file mode 100644 (file)
index 4c99f3b..0000000
+++ /dev/null
@@ -1,451 +0,0 @@
-#!/bin/sh
-
-set -ex
-
-echo [STEP] Initialize test library
-
-# Paths to compiled executables
-
-# realpath on FreeBSD fails if the path does not exist.
-realdir() {
-  [ -e "$1" ] || mkdir -p "$1"
-  if type realpath >/dev/null; then
-    realpath "$1"
-  else
-    readlink -f "$1"
-  fi
-}
-
-# Exit status list
-# shellcheck disable=SC2034
-EXIT_FAILURE=1
-# shellcheck disable=SC2034
-EXIT_SKIP_TEST=77
-
-# The list of the environment variables that tinc injects into the scripts it calls.
-# shellcheck disable=SC2016
-TINC_SCRIPT_VARS='$NETNAME,$NAME,$DEVICE,$IFACE,$NODE,$REMOTEADDRESS,$REMOTEPORT,$SUBNET,$WEIGHT,$INVITATION_FILE,$INVITATION_URL,$DEBUG'
-
-# Test directories
-
-# Reuse script name if it was passed in an env var (when imported from tinc scripts).
-if [ -z "$SCRIPTNAME" ]; then
-  SCRIPTNAME=$(basename "$0")
-fi
-
-# Network names for tincd daemons.
-net1=$SCRIPTNAME.1
-net2=$SCRIPTNAME.2
-net3=$SCRIPTNAME.3
-
-# Configuration/pidfile directories for tincd daemons.
-DIR_FOO=$(realdir "$PWD/$net1")
-DIR_BAR=$(realdir "$PWD/$net2")
-DIR_BAZ=$(realdir "$PWD/$net3")
-
-# Register helper functions
-
-if [ "$(uname -s)" = SunOS ]; then
-  gnu=/usr/gnu/bin
-  grep="$gnu/grep"
-
-  grep() { $gnu/grep "$@"; }
-  tail() { $gnu/tail "$@"; }
-
-  if ! tail /dev/null || ! echo '' | grep ''; then
-    echo >&2 'Sorry, native Solaris tools are not supported. Please install GNU Coreutils.'
-    exit $EXIT_SKIP_TEST
-  fi
-else
-  grep='grep'
-fi
-
-# Alias gtimeout to timeout if it exists.
-if type gtimeout >/dev/null; then
-  timeout() { gtimeout "$@"; }
-fi
-
-# As usual, BSD tools require special handling, as they do not support -i without a suffix.
-# Note that there must be no space after -i, or it won't work on GNU sed.
-sed_cmd() {
-  sed -i.orig "$@"
-}
-
-# Are the shell tools provided by busybox?
-is_busybox() {
-  timeout --help 2>&1 | grep -q -i busybox
-}
-
-# busybox timeout returns 128 + signal number (which is TERM by default)
-if is_busybox; then
-  # shellcheck disable=SC2034
-  EXIT_TIMEOUT=$((128 + 15))
-else
-  # shellcheck disable=SC2034
-  EXIT_TIMEOUT=124
-fi
-
-# Is this msys2?
-is_windows() {
-  test "$(uname -o)" = Msys
-}
-
-# Are we running on a CI server?
-is_ci() {
-  test "$CI"
-}
-
-# Dump error message and exit with an error.
-bail() {
-  echo >&2 "$@"
-  exit 1
-}
-
-# Remove carriage returns to normalize strings on Windows for easier comparisons.
-rm_cr() {
-  tr -d '\r'
-}
-
-if is_windows; then
-  normalize_path() { cygpath --mixed -- "$@"; }
-else
-  normalize_path() { echo "$@"; }
-fi
-
-# Executes whatever is passed to it, checking that the resulting exit code is non-zero.
-must_fail() {
-  if "$@"; then
-    bail "expected a non-zero exit code"
-  fi
-}
-
-# Executes the passed command and checks two conditions:
-#   1. it must exit successfully (with code 0)
-#   2. its output (stdout + stderr) must include the substring from the first argument (ignoring case)
-# usage: expect_msg 'expected message' command --with --args
-expect_msg() {
-  message=$1
-  shift
-
-  if ! output=$("$@" 2>&1); then
-    bail 'expected 0 exit code'
-  fi
-
-  if ! echo "$output" | grep -q -i "$message"; then
-    bail "expected message '$message'"
-  fi
-}
-
-# The reverse of expect_msg. We cannot simply wrap expect_msg with must_fail
-# because there should be a separate check for tinc exit code.
-fail_on_msg() {
-  message=$1
-  shift
-
-  if ! output=$("$@" 2>&1); then
-    bail 'expected 0 exit code'
-  fi
-
-  if echo "$output" | grep -q -i "$message"; then
-    bail "unexpected message '$message'"
-  fi
-}
-
-# Like expect_msg, but the command must fail with a non-zero exit code.
-# usage: must_fail_with_msg 'expected message' command --with --args
-must_fail_with_msg() {
-  message=$1
-  shift
-
-  if output=$("$@" 2>&1); then
-    bail "expected a non-zero exit code"
-  fi
-
-  if ! echo "$output" | grep -i -q "$message"; then
-    bail "expected message '$message'"
-  fi
-}
-
-# Is the legacy protocol enabled?
-with_legacy() {
-  tincd foo --version | grep -q legacy_protocol
-}
-
-# Are we running with EUID 0?
-is_root() {
-  test "$(id -u)" = 0
-}
-
-# Executes whatever is passed to it, checking that the resulting exit code is equal to the first argument.
-expect_code() {
-  expected=$1
-  shift
-
-  code=0
-  "$@" || code=$?
-
-  if [ $code != "$expected" ]; then
-    bail "wrong exit code $code, expected $expected"
-  fi
-}
-
-# wc -l on mac prints whitespace before the actual number.
-# This is simplest cross-platform alternative without that behavior.
-count_lines() {
-  awk 'END{ print NR }'
-}
-
-# Calls compiled tinc, passing any supplied arguments.
-# Usage: tinc { foo | bar | baz } --arg1 val1 "$args"
-tinc() {
-  peer=$1
-  shift
-
-  case "$peer" in
-  foo) "$TINC_PATH" -n "$net1" --config="$DIR_FOO" --pidfile="$DIR_FOO/pid" "$@" ;;
-  bar) "$TINC_PATH" -n "$net2" --config="$DIR_BAR" --pidfile="$DIR_BAR/pid" "$@" ;;
-  baz) "$TINC_PATH" -n "$net3" --config="$DIR_BAZ" --pidfile="$DIR_BAZ/pid" "$@" ;;
-  *) bail "invalid command [[$peer $*]]" ;;
-  esac
-}
-
-# Calls compiled tincd, passing any supplied arguments.
-# Usage: tincd { foo | bar | baz } --arg1 val1 "$args"
-tincd() {
-  peer=$1
-  shift
-
-  case "$peer" in
-  foo) "$TINCD_PATH" -n "$net1" --config="$DIR_FOO" --pidfile="$DIR_FOO/pid" --logfile="$DIR_FOO/log" -d5 "$@" ;;
-  bar) "$TINCD_PATH" -n "$net2" --config="$DIR_BAR" --pidfile="$DIR_BAR/pid" --logfile="$DIR_BAR/log" -d5 "$@" ;;
-  baz) "$TINCD_PATH" -n "$net3" --config="$DIR_BAZ" --pidfile="$DIR_BAZ/pid" --logfile="$DIR_BAZ/log" -d5 "$@" ;;
-  *) bail "invalid command [[$peer $*]]" ;;
-  esac
-}
-
-# Start the specified tinc daemon.
-# usage: start_tinc { foo | bar | baz }
-start_tinc() {
-  peer=$1
-  shift
-
-  case "$peer" in
-  foo) tinc "$peer" start --logfile="$DIR_FOO/log" -d5 "$@" ;;
-  bar) tinc "$peer" start --logfile="$DIR_BAR/log" -d5 "$@" ;;
-  baz) tinc "$peer" start --logfile="$DIR_BAZ/log" -d5 "$@" ;;
-  *) bail "invalid peer $peer" ;;
-  esac
-}
-
-# Stop all tinc clients.
-stop_all_tincs() {
-  (
-    # In case these pid files are mangled.
-    set +e
-    [ -f "$DIR_FOO/pid" ] && tinc foo stop
-    [ -f "$DIR_BAR/pid" ] && tinc bar stop
-    [ -f "$DIR_BAZ/pid" ] && tinc baz stop
-    true
-  )
-}
-
-# Checks that the number of reachable nodes matches what is expected.
-# usage: require_nodes node_name expected_number
-require_nodes() {
-  echo >&2 "Check that we're able to reach tincd"
-  test "$(tinc "$1" pid | count_lines)" = 1
-
-  echo >&2 "Check the number of reachable nodes for $1 (expecting $2)"
-  actual="$(tinc "$1" dump reachable nodes | count_lines)"
-
-  if [ "$actual" != "$2" ]; then
-    echo >&2 "tinc $1: expected $2 reachable nodes, got $actual"
-    exit 1
-  fi
-}
-
-peer_directory() {
-  peer=$1
-  case "$peer" in
-  foo) echo "$DIR_FOO" ;;
-  bar) echo "$DIR_BAR" ;;
-  baz) echo "$DIR_BAZ" ;;
-  *) bail "invalid peer $peer" ;;
-  esac
-}
-
-# This is an append-only log of all scripts executed by all peers.
-script_runs_log() {
-  echo "$(peer_directory "$1")/script-runs.log"
-}
-
-# Create tincd script. If it fails, it kills the test script with SIGTERM.
-# usage: create_script { foo | bar | baz } { tinc-up | host-down | ... } 'script content'
-create_script() {
-  peer=$1
-  script=$2
-  shift 2
-
-  # This is the line that we should start from when reading the script execution log while waiting
-  # for $script from $peer. It is a poor man's hash map to avoid polluting tinc's home directory with
-  # "last seen" files. There seem to be no good solutions to this that are compatible with all shells.
-  line_var=$(next_line_var "$peer" "$script")
-
-  # We must reassign it here in case the script is recreated.
-  # shellcheck disable=SC2229
-  read -r "$line_var" <<EOF
-1
-EOF
-
-  # Full path to the script.
-  script_path=$(peer_directory "$peer")/$script
-
-  # Full path to the script execution log (one for each peer).
-  script_log=$(script_runs_log "$peer")
-  printf '' >"$script_log"
-
-  # Script output is redirected into /dev/null. Otherwise, it ends up
-  # in tinc's output and breaks things like 'tinc invite'.
-  cat >"$script_path" <<EOF
-#!/bin/sh
-(
-  cd "$PWD" || exit 1
-  SCRIPTNAME="$SCRIPTNAME" . "$TESTLIB_PATH"
-  $@
-  echo "$script,\$$,$TINC_SCRIPT_VARS" >>"$script_log"
-) >/dev/null 2>&1 || kill -TERM $$
-EOF
-
-  chmod u+x "$script_path"
-
-  if is_windows; then
-    echo "@$MINGW_SHELL '$script_path'" >"$script_path.cmd"
-  fi
-}
-
-# Returns the name of the variable that contains the line number
-# we should read next when waiting on $script from $peer.
-# usage: next_line_var foo host-up
-next_line_var() {
-  peer=$1
-  script=$(echo "$2" | sed 's/[^a-zA-Z0-9]/_/g')
-  printf "%s" "next_line_${peer}_${script}"
-}
-
-# Waits for `peer`'s script `script` to finish `count` number of times.
-# usage: wait_script { foo | bar | baz } { tinc-up | host-up | ... } [count=1]
-wait_script() {
-  peer=$1
-  script=$2
-  count=$3
-
-  if [ -z "$count" ] || [ "$count" -lt 1 ]; then
-    count=1
-  fi
-
-  # Find out the location of the log and how many lines we should skip
-  # (because we've already seen them in previous invocations of wait_script
-  # for current $peer and $script).
-  line_var=$(next_line_var "$peer" "$script")
-
-  # eval is the only solution supported by POSIX shells.
-  # https://github.com/koalaman/shellcheck/wiki/SC3053
-  #   1. $line_var expands into 'next_line_foo_hosts_bar_up'
-  #   2. the name is substituted and the command becomes 'echo "$next_line_foo_hosts_bar_up"'
-  #   3. the command is evaluated and the line number is assigned to $line
-  line=$(eval "echo \"\$$line_var\"")
-
-  # This is the file that we monitor for script execution records.
-  script_log=$(script_runs_log "$peer")
-
-  # Starting from $line, read until $count matches are found.
-  # Print the number of the last matching line and exit.
-  # GNU tail 2.82 and newer terminates by itself when the pipe breaks.
-  # To support other tails we do an explicit `kill`.
-  # FIFO is useful here because otherwise it's difficult to determine
-  # which tail process should be killed. We could stick them in a process
-  # group by enabling job control, but this results in weird behavior when
-  # running tests in parallel on some interactive shells
-  # (e.g. when /bin/sh is symlinked to dash).
-  fifo=$(mktemp)
-  rm -f "$fifo"
-  mkfifo "$fifo"
-
-  # This weird thing is required to support old versions of ksh on NetBSD 8.2 and the like.
-  (tail -n +"$line" -f "$script_log" >"$fifo") &
-
-  new_line=$(
-    sh -c "
-      $grep -n -m $count '^$script,' <'$fifo'
-    " | awk -F: 'END { print $1 }'
-  )
-
-  # Try to stop the background tail, ignoring possible failure (some tails
-  # detect EOF, some don't, so it may have already exited), but do wait on
-  # it (which is required at least by old ksh).
-  kill $! || true
-  wait || true
-  rm -f "$fifo"
-
-  # Remember the next line number for future reference. We'll use it if
-  # wait_script is called again with same $peer and $script.
-  read -r "${line_var?}" <<EOF
-$((line + new_line))
-EOF
-}
-
-# Cleanup after running each script.
-cleanup() {
-  (
-    set +ex
-
-    if command -v cleanup_hook 2>/dev/null; then
-      echo >&2 "Cleanup hook found, calling..."
-      cleanup_hook
-    fi
-
-    stop_all_tincs
-  ) || true
-}
-
-# If we're on a CI server, the test requires superuser privileges to run, and we're not
-# currently a superuser, try running the test as one and fail if it doesn't work (the
-# system must be configured to provide passwordless sudo for our user).
-require_root() {
-  if is_root; then
-    return
-  fi
-  if is_ci; then
-    echo "root is required for test $SCRIPTNAME, but we're a regular user; elevating privileges..."
-    if ! command -v sudo 2>/dev/null; then
-      bail "please install sudo and configure passwordless auth for user $USER"
-    fi
-    if ! sudo --preserve-env --non-interactive true; then
-      bail "sudo is not allowed or requires a password for user $USER"
-    fi
-    exec sudo --preserve-env "$@"
-  else
-    # Avoid these kinds of surprises outside CI. Just skip the test.
-    echo "root is required for test $SCRIPTNAME, but we're a regular user; skipping"
-    exit "$EXIT_SKIP_TEST"
-  fi
-}
-
-# Generate path to current shell which can be used from Windows applications.
-if is_windows; then
-  MINGW_SHELL=$(normalize_path "$SHELL")
-fi
-
-# This was called from a tincd script. Skip executing commands with side effects.
-[ -n "$NAME" ] && return
-
-echo [STEP] Check for leftover tinc daemons and test directories
-
-# Cleanup leftovers from previous runs.
-stop_all_tincs
-
-rm -rf "$DIR_FOO" "$DIR_BAR" "$DIR_BAZ"
-
-# Register cleanup function so we don't have to call it everywhere
-# (and failed scripts do not leave stray tincd running).
-trap cleanup EXIT INT TERM
diff --git a/test/integration/testlib/__init__.py b/test/integration/testlib/__init__.py
new file mode 100755 (executable)
index 0000000..3b0564f
--- /dev/null
@@ -0,0 +1,5 @@
+"""Testing library with a few helper classes and functions for use in tinc integration tests."""
+
+import sys
+
+assert sys.version_info >= (3, 6)
diff --git a/test/integration/testlib/check.py b/test/integration/testlib/check.py
new file mode 100755 (executable)
index 0000000..77865b1
--- /dev/null
@@ -0,0 +1,76 @@
+"""Simple assertions which print the expected and received values on failure."""
+
+import typing as T
+
+from .log import log
+
+Val = T.TypeVar("Val")
+Num = T.TypeVar("Num", int, float)
+
+
+def false(value: T.Any) -> None:
+    """Check that value is falsy."""
+    if value:
+        raise ValueError(f'expected "{value}" to be falsy')
+
+
+def true(value: T.Any) -> None:
+    """Check that value is truthy."""
+    if not value:
+        raise ValueError(f'expected "{value}" to be truthy', value)
+
+
+def equals(expected: Val, actual: Val) -> None:
+    """Check that the two values are equal."""
+    if expected != actual:
+        raise ValueError(f'expected "{expected}", got "{actual}"')
+
+
+def has_prefix(text: T.AnyStr, prefix: T.AnyStr) -> None:
+    """Check that text has prefix."""
+    if not text.startswith(prefix):
+        raise ValueError(f"expected {text!r} to start with {prefix!r}")
+
+
+def in_range(value: Num, gte: Num, lte: Num) -> None:
+    """Check that value lies in the range [min, max]."""
+    if not gte >= value >= lte:
+        raise ValueError(f"value {value} must be between {gte} and {lte}")
+
+
+def is_in(needle: Val, *haystacks: T.Container[Val]) -> None:
+    """Check that at least one haystack includes needle."""
+    for haystack in haystacks:
+        if needle in haystack:
+            return
+    raise ValueError(f'expected any of "{haystacks}" to include "{needle}"')
+
+
+def not_in(needle: Val, *haystacks: T.Container[Val]) -> None:
+    """Check that all haystacks do not include needle."""
+    for haystack in haystacks:
+        if needle in haystack:
+            raise ValueError(f'expected all "{haystacks}" NOT to include "{needle}"')
+
+
+def nodes(node, want_nodes: int) -> None:
+    """Check that node can reach exactly N nodes (including itself)."""
+    log.debug("want %d reachable nodes from tinc %s", want_nodes, node)
+    stdout, _ = node.cmd("dump", "reachable", "nodes")
+    equals(want_nodes, len(stdout.splitlines()))
+
+
+def files_eq(path0: str, path1: str) -> None:
+    """Compare file contents, ignoring whitespace at both ends."""
+    log.debug("comparing files %s and %s", path0, path1)
+
+    def read(path: str) -> str:
+        log.debug("reading file %s", path)
+        with open(path, "r", encoding="utf-8") as f:
+            return f.read().strip()
+
+    content0 = read(path0)
+    content1 = read(path1)
+
+    if content0 != content1:
+        raise ValueError(f"expected files {path0} and {path1} to match")
diff --git a/test/integration/testlib/cmd.py b/test/integration/testlib/cmd.py
new file mode 100755 (executable)
index 0000000..9a71f65
--- /dev/null
@@ -0,0 +1,49 @@
+"""Wrappers for more complicated tinc/tincd commands."""
+
+import typing as T
+
+from . import check
+from .log import log
+from .proc import Tinc
+
+ExchangeIO = T.Tuple[
+    T.Tuple[str, str],
+    T.Tuple[str, str],
+    T.Tuple[str, str],
+]
+
+
+def exchange(node0: Tinc, node1: Tinc, export_all: bool = False) -> ExchangeIO:
+    """Run `export(-all) | exchange | import` between the passed nodes.
+    `export-all` is used if export_all is set to True.
+    """
+    export_cmd = "export-all" if export_all else "export"
+    log.debug("%s between %s and %s", export_cmd, node0.name, node1.name)
+
+    exp_out, exp_err = node0.cmd(export_cmd)
+    log.debug(
+        'exchange: %s %s returned ("%s", "%s")', export_cmd, node0, exp_out, exp_err
+    )
+    check.is_in("Name =", exp_out)
+
+    xch_out, xch_err = node1.cmd("exchange", stdin=exp_out)
+    log.debug('exchange: exchange %s returned ("%s", "%s")', node1, xch_out, xch_err)
+    check.is_in("Name =", xch_out)
+    check.is_in("Imported ", xch_err)
+
+    imp_out, imp_err = node0.cmd("import", stdin=xch_out)
+    log.debug('exchange: import %s returned ("%s", "%s")', node0, imp_out, imp_err)
+    check.is_in("Imported ", imp_err)
+
+    return (
+        (exp_out, exp_err),
+        (xch_out, xch_err),
+        (imp_out, imp_err),
+    )
+
+
+def get(tinc: Tinc, var: str) -> str:
+    """Get the value of the variable, stripped of whitespace."""
+    assert var
+    stdout, _ = tinc.cmd("get", var)
+    return stdout.strip()
diff --git a/test/integration/testlib/const.py b/test/integration/testlib/const.py
new file mode 100755 (executable)
index 0000000..36f5f97
--- /dev/null
@@ -0,0 +1,9 @@
+"""Some hardcoded constants."""
+
+import os
+
+# Exit code to skip current test
+EXIT_SKIP = 77
+
+# Family name for multiprocessing Listener/Connection
+MPC_FAMILY = "AF_PIPE" if os.name == "nt" else "AF_UNIX"
diff --git a/test/integration/testlib/event.py b/test/integration/testlib/event.py
new file mode 100755 (executable)
index 0000000..4acd375
--- /dev/null
@@ -0,0 +1,46 @@
+"""Classes for doing data exchange between test and tincd scripts."""
+
+import os
+import sys
+import time
+import platform
+import typing as T
+
+_MONOTONIC_IS_SYSTEMWIDE = not (
+    platform.system() == "Darwin" and sys.version_info < (3, 10)
+)
+
+
+def _time_ns() -> int:
+    if sys.version_info <= (3, 7):
+        return int(time.monotonic() * 1e9)
+    return time.monotonic_ns()
+
+
+class Notification:
+    """Notification about tinc script execution."""
+
+    test: str
+    node: str
+    script: str
+    created_at: T.Optional[int] = None
+    env: T.Dict[str, str]
+    args: T.Dict[str, str]
+    error: T.Optional[Exception]
+
+    def __init__(self) -> None:
+        self.env = dict(os.environ)
+
+        # This field is used to record when the notification was created. On most
+        # operating systems, it uses system-wide monotonic time which is the same
+        # for all processes. Not on macOS, at least not before Python 3.10. So if
+        # we're running such a setup, assign time local to our test process right
+        # when we receive the notification to have a common reference point to
+        # all measurements.
+        if _MONOTONIC_IS_SYSTEMWIDE:
+            self.update_time()
+
+    def update_time(self) -> None:
+        """Update creation time if it was not assigned previously."""
+        if self.created_at is None:
+            self.created_at = _time_ns()
diff --git a/test/integration/testlib/external.py b/test/integration/testlib/external.py
new file mode 100755 (executable)
index 0000000..b90d779
--- /dev/null
@@ -0,0 +1,45 @@
+"""Wrappers for running external commands."""
+
+import subprocess as subp
+import atexit
+import typing as T
+
+from .log import log
+
+_netns_created: T.Set[str] = set()
+
+
+def _netns_cleanup() -> None:
+    for namespace in _netns_created.copy():
+        netns_delete(namespace)
+
+
+atexit.register(_netns_cleanup)
+
+
+def _netns_action(action: str, namespace: str) -> bool:
+    log.debug("%s network namespace %s", action, namespace)
+
+    res = subp.run(["ip", "netns", action, namespace], check=False)
+    if res.returncode:
+        log.error("could not %s netns %s", action, namespace)
+    else:
+        log.debug("OK %s netns %s", action, namespace)
+
+    return not res.returncode
+
+
+def netns_delete(namespace: str) -> bool:
+    """Remove a previously created network namespace."""
+    success = _netns_action("delete", namespace)
+    if success:
+        _netns_created.remove(namespace)
+    return success
+
+
+def netns_add(namespace: str) -> bool:
+    """Add a network namespace (which can be removed manually or automatically at exit)."""
+    success = _netns_action("add", namespace)
+    if success:
+        _netns_created.add(namespace)
+    return success
diff --git a/test/integration/testlib/log.py b/test/integration/testlib/log.py
new file mode 100755 (executable)
index 0000000..0c39475
--- /dev/null
@@ -0,0 +1,50 @@
+"""Global logger for using in test and tincd scripts."""
+
+import logging
+import os
+import sys
+import typing as T
+from types import TracebackType
+
+from .path import TEST_WD, TEST_NAME
+
+logging.basicConfig(level=logging.DEBUG)
+
+_fmt = logging.Formatter(
+    "%(asctime)s %(name)s %(filename)s:%(lineno)d %(levelname)s %(message)s"
+)
+
+# Where to put log files for this test and nodes started by it
+_log_dir = os.path.join(TEST_WD, "logs")
+
+
+def new_logger(name: str) -> logging.Logger:
+    """Create a new named logger with common logging format.
+    Log entries will go into a separate logfile named 'name.log'.
+    """
+    os.makedirs(_log_dir, exist_ok=True)
+
+    logger = logging.getLogger(name)
+    logger.setLevel(logging.DEBUG)
+
+    file = logging.FileHandler(os.path.join(_log_dir, name + ".log"))
+    file.setFormatter(_fmt)
+    logger.addHandler(file)
+
+    return logger
+
+
+# Main logger used by most tests
+log = new_logger(TEST_NAME)
+
+
+def _exc_hook(
+    ex_type: T.Type[BaseException],
+    base: BaseException,
+    tb_type: T.Optional[TracebackType],
+) -> None:
+    """Logging handler for uncaught exceptions."""
+    log.error("Uncaught exception", exc_info=(ex_type, base, tb_type))
+
+
+sys.excepthook = _exc_hook
diff --git a/test/integration/testlib/notification.py b/test/integration/testlib/notification.py
new file mode 100755 (executable)
index 0000000..7813bf0
--- /dev/null
@@ -0,0 +1,105 @@
+"""Support for receiving notifications from tincd scripts."""
+
+import os
+import signal
+import threading
+import queue
+import multiprocessing.connection as mp
+import typing as T
+
+from .log import log
+from .event import Notification
+from .const import MPC_FAMILY
+
+
+def _get_key(name, script) -> str:
+    return f"{name}/{script}"
+
+
+class NotificationServer:
+    """Receive event notifications from tincd scripts."""
+
+    address: T.Union[str, bytes]
+    authkey: bytes  # only to prevent accidental connections to wrong servers
+    _lock: threading.Lock
+    _ready: threading.Event
+    _worker: T.Optional[threading.Thread]
+    _notifications: T.Dict[str, queue.Queue]
+
+    def __init__(self) -> None:
+        self.address = ""
+        self.authkey = os.urandom(8)
+        self._lock = threading.Lock()
+        self._ready = threading.Event()
+        self._worker = threading.Thread(target=self._recv, daemon=True)
+        self._notifications = {}
+
+        log.debug("using authkey %s", self.authkey)
+
+        self._worker.start()
+        log.debug("waiting for notification worker to become ready")
+
+        self._ready.wait()
+        log.debug("notification worker is ready")
+
+    @T.overload
+    def get(self, node: str, script: str) -> Notification:
+        """Receive notification from the specified node and script without a timeout.
+        Doesn't return until a notification arrives.
+        """
+        return self.get(node, script)
+
+    @T.overload
+    def get(self, node: str, script: str, timeout: float) -> T.Optional[Notification]:
+        """Receive notification from the specified node and script with a timeout.
+        If nothing arrives before it expires, None is returned.
+        """
+        return self.get(node, script, timeout)
+
+    def get(
+        self, node: str, script: str, timeout: T.Optional[float] = None
+    ) -> T.Optional[Notification]:
+        """Receive notification from specified node and script. See overloads above."""
+
+        key = _get_key(node, script)
+        with self._lock:
+            que = self._notifications.get(key, queue.Queue())
+            self._notifications[key] = que
+        try:
+            return que.get(timeout=timeout)
+        except queue.Empty:
+            return None
+
+    def _recv(self) -> None:
+        try:
+            self._listen()
+        except (OSError, AssertionError) as ex:
+            log.error("recv notifications failed", exc_info=ex)
+            os.kill(0, signal.SIGTERM)
+
+    def _listen(self) -> None:
+        with mp.Listener(family=MPC_FAMILY, authkey=self.authkey) as listener:
+            assert not isinstance(listener.address, tuple)
+            self.address = listener.address
+            self._ready.set()
+            while True:
+                with listener.accept() as conn:
+                    self._handle_conn(conn)
+
+    def _handle_conn(self, conn: mp.Connection) -> None:
+        log.debug("accepted connection")
+
+        data: Notification = conn.recv()
+        assert isinstance(data, Notification)
+        data.update_time()
+
+        key = _get_key(data.node, data.script)
+        log.debug('from "%s" received data "%s"', key, data)
+
+        with self._lock:
+            que = self._notifications.get(key, queue.Queue())
+            self._notifications[key] = que
+        que.put(data)
+
+
+notifications = NotificationServer()
diff --git a/test/integration/testlib/path.py b/test/integration/testlib/path.py
new file mode 100755 (executable)
index 0000000..a33fba5
--- /dev/null
@@ -0,0 +1,64 @@
+"""Paths to compiled binaries, and a few other important environment variables."""
+
+import os
+import pathlib
+import sys
+
+env = {
+    "TEST_NAME": os.getenv("TEST_NAME"),
+    "TINC_PATH": os.getenv("TINC_PATH"),
+    "TINCD_PATH": os.getenv("TINCD_PATH"),
+    "SPLICE_PATH": os.getenv("SPLICE_PATH"),
+    "PYTHON_PATH": os.getenv("PYTHON_PATH"),
+    "SPTPS_TEST_PATH": os.getenv("SPTPS_TEST_PATH"),
+    "SPTPS_KEYPAIR_PATH": os.getenv("SPTPS_KEYPAIR_PATH"),
+}
+
+# Not strictly necessary, used for better autocompletion and search by reference.
+TEST_NAME = str(env["TEST_NAME"])
+TINC_PATH = str(env["TINC_PATH"])
+TINCD_PATH = str(env["TINCD_PATH"])
+SPLICE_PATH = str(env["SPLICE_PATH"])
+PYTHON_PATH = str(env["PYTHON_PATH"])
+SPTPS_TEST_PATH = str(env["SPTPS_TEST_PATH"])
+SPTPS_KEYPAIR_PATH = str(env["SPTPS_KEYPAIR_PATH"])
+
+
+def _check() -> bool:
+    """Basic sanity checks on passed environment variables."""
+    for key, val in env.items():
+        if not val or (key != "TEST_NAME" and not os.path.isfile(val)):
+            return False
+    return True
+
+
+if not _check():
+    MSG = """
+Please run tests using
+    $ meson test -C build
+or
+    $ ninja -C build test
+"""
+    print(MSG, file=sys.stderr)
+    sys.exit(1)
+
+# Current working directory
+CWD = os.getcwd()
+
+# Path to the testing library
+TESTLIB_ROOT = pathlib.Path(__file__).parent
+
+# Source root for the integration test suite
+TEST_SRC_ROOT = TESTLIB_ROOT.parent.resolve()
+
+_wd = os.path.join(CWD, "wd")
+os.makedirs(_wd, exist_ok=True)
+
+# Useful when running tests manually
+_gitignore = os.path.join(_wd, ".gitignore")
+if not os.path.exists(_gitignore):
+    with open(_gitignore, "w", encoding="utf-8") as f:
+        f.write("*")
+
+# Working directory for this test
+TEST_WD = os.path.join(_wd, TEST_NAME)
diff --git a/test/integration/testlib/proc.py b/test/integration/testlib/proc.py
new file mode 100755 (executable)
index 0000000..8ac65ba
--- /dev/null
@@ -0,0 +1,309 @@
+"""Classes for working with compiled instances of tinc and tincd binaries."""
+
+import os
+import random
+import typing as T
+import subprocess as subp
+from enum import Enum
+from platform import system
+
+from . import check, path
+from .log import log
+from .script import TincScript, Script, ScriptType
+from .template import make_script, make_cmd_wrap
+from .util import random_string, random_port
+
+# Does the OS support all addresses in 127.0.0.0/8 without additional configuration?
+_FULL_LOCALHOST_SUBNET = system() in ("Linux", "Windows")
+
+
+def _make_wd(name: str) -> str:
+    work_dir = os.path.join(path.TEST_WD, "data", name)
+    os.makedirs(work_dir, exist_ok=True)
+    return work_dir
+
+
+def _random_octet() -> int:
+    return random.randint(1, 254)
+
+
+def _rand_localhost() -> str:
+    """Generate random IP in subnet 127.0.0.0/8 for operating systems that support
+    it without additional configuration. For all others, return 127.0.0.1.
+    """
+    if _FULL_LOCALHOST_SUBNET:
+        return f"127.{_random_octet()}.{_random_octet()}.{_random_octet()}"
+    return "127.0.0.1"
+
+
+class Feature(Enum):
+    """Optional features supported by both tinc and tincd."""
+
+    COMP_LZ4 = "comp_lz4"
+    COMP_LZO = "comp_lzo"
+    COMP_ZLIB = "comp_zlib"
+    CURSES = "curses"
+    JUMBOGRAMS = "jumbograms"
+    LEGACY_PROTOCOL = "legacy_protocol"
+    LIBGCRYPT = "libgcrypt"
+    MINIUPNPC = "miniupnpc"
+    OPENSSL = "openssl"
+    READLINE = "readline"
+    TUNEMU = "tunemu"
+    UML = "uml"
+    VDE = "vde"
+
+
+class Tinc:
+    """Thin wrapper around Popen that simplifies running tinc/tincd
+    binaries by passing required arguments, checking exit codes, etc.
+    """
+
+    name: str
+    address: str
+    _work_dir: str
+    _port: T.Optional[int]
+    _scripts: T.Dict[str, TincScript]
+    _procs: T.List[subp.Popen]
+
+    def __init__(self, name: str = "", addr: str = "") -> None:
+        self.name = name if name else random_string(10)
+        self.address = addr if addr else _rand_localhost()
+        self._work_dir = _make_wd(self.name)
+        self._port = None
+        self._scripts = {}
+        self._procs = []
+
+    def randomize_port(self) -> int:
+        """Use a random port for this node."""
+        self._port = random_port()
+        return self._port
+
+    def read_port(self) -> int:
+        """Read port used by tincd from its pidfile and update the _port field."""
+        pidfile = self.sub("pid")
+        log.debug("reading pidfile at %s", pidfile)
+
+        with open(pidfile, "r", encoding="utf-8") as f:
+            content = f.read()
+        log.debug("found data %s", content)
+
+        _, _, _, token, port = content.split()
+        check.equals("port", token)
+
+        self._port = int(port)
+        return self._port
+
+    @property
+    def port(self) -> int:
+        """Port that tincd is listening on."""
+        assert self._port is not None
+        return self._port
+
+    def __str__(self) -> str:
+        return self.name
+
+    def __getitem__(self, script: ScriptType) -> TincScript:
+        if isinstance(script, Script):
+            script = script.name
+        return self._scripts[script]
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.cleanup()
+
+    @property
+    def features(self) -> T.List[Feature]:
+        """List of features supported by tinc and tincd."""
+        tinc, _ = self.cmd("--version")
+        tincd, _ = self.tincd("--version").communicate(timeout=5)
+        prefix, features = "Features: ", []
+
+        for out in tinc, tincd:
+            for line in out.splitlines():
+                if not line.startswith(prefix):
+                    continue
+                tokens = line[len(prefix) :].split()
+                for token in tokens:
+                    features.append(Feature(token))
+                break
+
+        log.info('supported features: "%s"', features)
+        return features
+
+    @property
+    def _common_args(self) -> T.List[str]:
+        return [
+            "--net",
+            self.name,
+            "--config",
+            self._work_dir,
+            "--pidfile",
+            self.sub("pid"),
+        ]
+
+    def sub(self, *paths: str) -> str:
+        """Return path to a subdirectory within the working dir for this node."""
+        return os.path.join(self._work_dir, *paths)
+
+    @property
+    def script_up(self) -> str:
+        """Name of the hosts/XXX-up script for this node."""
+        return f"hosts/{self.name}-up"
+
+    @property
+    def script_down(self) -> str:
+        """Name of the hosts/XXX-down script for this node."""
+        return f"hosts/{self.name}-down"
+
+    def cleanup(self) -> None:
+        """Terminate all tinc and tincd processes started from this instance."""
+        log.info("running node cleanup for %s", self)
+
+        try:
+            self.cmd("stop")
+        except (AssertionError, ValueError):
+            log.info("unsuccessfully tried to stop node %s", self)
+
+        for proc in self._procs:
+            if proc.returncode is not None:
+                log.debug("PID %d exited, skipping", proc.pid)
+            else:
+                log.info("PID %d still running, stopping", proc.pid)
+                try:
+                    proc.kill()
+                except OSError as ex:
+                    log.error("could not kill PID %d", proc.pid, exc_info=ex)
+
+            log.debug("waiting on %d to prevent zombies", proc.pid)
+            try:
+                proc.wait()
+            except OSError as ex:
+                log.error("waiting on %d failed", proc.pid, exc_info=ex)
+
+        self._procs.clear()
+
+    def start(self, *args: str) -> int:
+        """Start the node, wait for it to call tinc-up, and get the port it's
+        listening on from the pid file. Don't use this method unless you need
+        to know the port tincd is running on. Call .cmd("start"), it's faster.
+
+        Reading pidfile and setting the port cannot be done from tinc-up because
+        you can't send tinc commands to yourself there â€” the daemon doesn't
+        respond to them until tinc-up is finished. The port field on this Tinc
+        instance is updated to reflect the correct port. If tinc-up is missing,
+        this command creates a new one, and then disables it.
+        """
+        new_script = Script.TINC_UP.name not in self._scripts
+        if new_script:
+            self.add_script(Script.TINC_UP)
+
+        tinc_up = self[Script.TINC_UP]
+        self.cmd(*args, "start")
+        tinc_up.wait()
+
+        if new_script:
+            tinc_up.disable()
+
+        self._port = self.read_port()
+        self.cmd("set", "Port", str(self._port))
+
+        return self._port
+
+    def cmd(
+        self, *args: str, code: T.Optional[int] = 0, stdin: T.Optional[str] = None
+    ) -> T.Tuple[str, str]:
+        """Run command through tinc, writes `stdin` to it (if the argument is not None),
+        check its return code (if the argument is not None), and return (stdout, stderr).
+        """
+        proc = self.tinc(*args)
+        log.debug('tinc %s: PID %d, in "%s", want code %s', self, proc.pid, stdin, code)
+
+        out, err = proc.communicate(stdin, timeout=60)
+        res = proc.returncode
+        self._procs.remove(proc)
+        log.debug('tinc %s: code %d, out "%s", err "%s"', self, res, out, err)
+
+        if code is not None:
+            check.equals(code, res)
+
+        # Check that port was not used by something else
+        check.not_in("Can't bind to ", err)
+
+        return out if out else "", err if err else ""
+
+    def tinc(self, *args: str) -> subp.Popen:
+        """Start tinc with the specified arguments."""
+        args = tuple(filter(bool, args))
+        cmd = [path.TINC_PATH, *self._common_args, *args]
+        log.debug('starting tinc %s: "%s"', self.name, " ".join(cmd))
+        # pylint: disable=consider-using-with
+        proc = subp.Popen(
+            cmd,
+            cwd=self._work_dir,
+            stdin=subp.PIPE,
+            stdout=subp.PIPE,
+            stderr=subp.PIPE,
+            encoding="utf-8",
+        )
+        self._procs.append(proc)
+        return proc
+
+    def tincd(self, *args: str, env: T.Optional[T.Dict[str, str]] = None) -> subp.Popen:
+        """Start tincd with the specified arguments."""
+        args = tuple(filter(bool, args))
+        cmd = [
+            path.TINCD_PATH,
+            *self._common_args,
+            "--logfile",
+            self.sub("log"),
+            "-d5",
+            *args,
+        ]
+        log.debug('starting tincd %s: "%s"', self.name, " ".join(cmd))
+        if env is not None:
+            env = {**os.environ, **env}
+        # pylint: disable=consider-using-with
+        proc = subp.Popen(
+            cmd,
+            cwd=self._work_dir,
+            stdin=subp.PIPE,
+            stdout=subp.PIPE,
+            stderr=subp.PIPE,
+            encoding="utf-8",
+            env=env,
+        )
+        self._procs.append(proc)
+        return proc
+
+    def add_script(self, script: ScriptType, source: str = "") -> TincScript:
+        """Create a script with the passed Python source code.
+        The source must either be empty, or start indentation with 4 spaces.
+        If the source is empty, the created script can be used to receive notifications.
+        """
+        rel_path = script if isinstance(script, str) else script.value
+        check.not_in(rel_path, self._scripts)
+
+        full_path = os.path.join(self._work_dir, rel_path)
+        tinc_script = TincScript(self.name, rel_path, full_path)
+
+        log.debug("creating script %s at %s", script, full_path)
+        with open(full_path, "w", encoding="utf-8") as f:
+            content = make_script(self.name, rel_path, source)
+            f.write(content)
+
+        if os.name == "nt":
+            log.debug("creating .cmd script wrapper at %s", full_path)
+            win_content = make_cmd_wrap(full_path)
+            with open(f"{full_path}.cmd", "w", encoding="utf-8") as f:
+                f.write(win_content)
+        else:
+            os.chmod(full_path, 0o755)
+
+        if isinstance(script, Script):
+            self._scripts[script.name] = tinc_script
+        self._scripts[rel_path] = tinc_script
+
+        return tinc_script
diff --git a/test/integration/testlib/script.py b/test/integration/testlib/script.py
new file mode 100755 (executable)
index 0000000..9c79855
--- /dev/null
@@ -0,0 +1,86 @@
+"""Classes related to creation and control of tincd scripts."""
+
+import os
+import typing as T
+from enum import Enum
+
+from .log import log
+from .event import Notification
+from .notification import notifications
+
+
+class Script(Enum):
+    """A list of supported tincd scripts.
+    hosts/XXX-{up,down} are missing because we generate node names at runtime.
+    """
+
+    TINC_UP = "tinc-up"
+    TINC_DOWN = "tinc-down"
+    HOST_UP = "host-up"
+    HOST_DOWN = "host-down"
+    SUBNET_UP = "subnet-up"
+    SUBNET_DOWN = "subnet-down"
+    INVITATION_CREATED = "invitation-created"
+    INVITATION_ACCEPTED = "invitation-accepted"
+
+
+# Since we rely on dynamically created node names, we cannot put 'hosts/XXX-up' in an enum.
+# This is the reason we sometimes need strings to type script variables.
+ScriptType = T.Union[Script, str]
+
+
+class TincScript:
+    """Control created tincd scripts and receive notifications from them."""
+
+    _node: str
+    _path: str
+    _script: str
+
+    def __init__(self, node: str, script: str, path: str) -> None:
+        self._node = node
+        self._script = script
+        self._path = path
+
+    def __str__(self):
+        return f"{self._node}/{self._script}"
+
+    @T.overload
+    def wait(self) -> Notification:
+        """Wait for the script to finish, returning the notification sent by the script."""
+        return self.wait()
+
+    @T.overload
+    def wait(self, timeout: float) -> T.Optional[Notification]:
+        """Wait for the script to finish, returning the notification sent by the script.
+        If nothing arrives before timeout expires, None is returned."""
+        return self.wait(timeout)
+
+    def wait(self, timeout: T.Optional[float] = None) -> T.Optional[Notification]:
+        """Wait for the script to finish. See overloads above."""
+        log.debug("waiting for script %s/%s", self._node, self._script)
+        if timeout is None:
+            return notifications.get(self._node, self._script)
+        return notifications.get(self._node, self._script, timeout)
+
+    @property
+    def enabled(self) -> bool:
+        """Check if script is enabled."""
+        if os.name == "nt":
+            return os.path.exists(self._path)
+        return os.access(self._path, os.X_OK)
+
+    def disable(self) -> None:
+        """Disable the script by renaming it."""
+        log.debug("disabling script %s/%s", self._node, self._script)
+        assert self.enabled
+        os.rename(self._path, self._disabled_name)
+
+    def enable(self) -> None:
+        """Enable the script by renaming it back."""
+        log.debug("enabling script %s/%s", self._node, self._script)
+        assert not self.enabled
+        os.rename(self._disabled_name, self._path)
+
+    @property
+    def _disabled_name(self) -> str:
+        return f"{self._path}.disabled"
diff --git a/test/integration/testlib/template.py b/test/integration/testlib/template.py
new file mode 100755 (executable)
index 0000000..b90299c
--- /dev/null
@@ -0,0 +1,54 @@
+"""Various script and configuration file templates."""
+
+import os
+import typing as T
+from string import Template
+
+from . import path
+from .notification import notifications
+
+
+_CMD_VARS = os.linesep.join([f"set {var}={val}" for var, val in path.env.items()])
+_CMD_PY = "runpython" if "meson.exe" in path.PYTHON_PATH.lower() else ""
+
+
+def _read_template(tpl_name: str, maps: T.Dict[str, T.Any]) -> str:
+    tpl_path = path.TESTLIB_ROOT.joinpath("template", tpl_name)
+    tpl = Template(tpl_path.read_text(encoding="utf-8"))
+    return tpl.substitute(maps)
+
+
+def make_script(node: str, script: str, source: str) -> str:
+    """Create a tincd script."""
+    addr = notifications.address
+    if isinstance(addr, str):
+        addr = f'r"{addr}"'  # 'r' is for Windows pipes: \\.\foo\bar
+    maps = {
+        "AUTH_KEY": notifications.authkey,
+        "CWD": path.CWD,
+        "NODE_NAME": node,
+        "NOTIFICATIONS_ADDR": addr,
+        "PYTHON_PATH": path.PYTHON_PATH,
+        "SCRIPT_NAME": script,
+        "SCRIPT_SOURCE": source,
+        "SRC_ROOT": path.TEST_SRC_ROOT,
+        "TEST_NAME": path.TEST_NAME,
+    }
+    return _read_template("script.py.tpl", maps)
+
+
+def make_cmd_wrap(script: str) -> str:
+    """Create a .cmd wrapper for tincd script. Only makes sense on Windows."""
+    maps = {
+        "PYTHON_CMD": _CMD_PY,
+        "PYTHON_PATH": path.PYTHON_PATH,
+        "SCRIPT_PATH": script,
+        "VARIABLES": _CMD_VARS,
+    }
+    return _read_template("script.cmd.tpl", maps)
+
+
+def make_netns_config(namespace: str, ip_addr: str, mask: int) -> str:
+    """Create a tincd script that does network namespace configuration."""
+    maps = {"NAMESPACE": namespace, "ADDRESS": ip_addr, "MASK": mask}
+    return _read_template("netns.py.tpl", maps)
diff --git a/test/integration/testlib/template/netns.py.tpl b/test/integration/testlib/template/netns.py.tpl
new file mode 100644 (file)
index 0000000..6f6457c
--- /dev/null
@@ -0,0 +1,9 @@
+    # Indentation is important! This gets copied inside another Python script.
+    import subprocess as subp
+
+    iface = os.environ['INTERFACE']
+    log.info('using interface %s', iface)
+
+    subp.run(['ip', 'link', 'set', 'dev', iface, 'netns', '$NAMESPACE'], check=True)
+    subp.run(['ip', 'netns', 'exec', '$NAMESPACE', 'ip', 'addr', 'add', '$ADDRESS/$MASK', 'dev', iface], check=True)
+    subp.run(['ip', 'netns', 'exec', '$NAMESPACE', 'ip', 'link', 'set', iface, 'up'], check=True)
diff --git a/test/integration/testlib/template/script.cmd.tpl b/test/integration/testlib/template/script.cmd.tpl
new file mode 100644 (file)
index 0000000..6e8450a
--- /dev/null
@@ -0,0 +1,3 @@
+@echo off
+$VARIABLES
+"$PYTHON_PATH" $PYTHON_CMD "$SCRIPT_PATH"
diff --git a/test/integration/testlib/template/script.py.tpl b/test/integration/testlib/template/script.py.tpl
new file mode 100644 (file)
index 0000000..59e229a
--- /dev/null
@@ -0,0 +1,59 @@
+#!$PYTHON_PATH
+
+import os
+import sys
+import multiprocessing.connection as mpc
+import typing as T
+import time
+import signal
+
+def on_error(*args):
+    try:
+        log.error('Uncaught exception', exc_info=args)
+    except NameError:
+        print('Uncaught exception', args)
+    os.kill(0, signal.SIGTERM)
+
+sys.excepthook = on_error
+
+os.chdir(r'$CWD')
+sys.path.append(r'$SRC_ROOT')
+
+from testlib.proc import Tinc
+from testlib.event import Notification
+from testlib.log import new_logger
+from testlib.const import MPC_FAMILY
+
+this = Tinc('$NODE_NAME')
+log = new_logger(this.name)
+
+def notify_test(args: T.Dict[str, T.Any] = {}, error: T.Optional[Exception] = None):
+    log.debug(f'sending notification to %s', $NOTIFICATIONS_ADDR)
+
+    evt = Notification()
+    evt.test = '$TEST_NAME'
+    evt.node = '$NODE_NAME'
+    evt.script = '$SCRIPT_NAME'
+    evt.args = args
+    evt.error = error
+
+    for retry in range(1, 10):
+        try:
+            with mpc.Client($NOTIFICATIONS_ADDR, family=MPC_FAMILY, authkey=$AUTH_KEY) as conn:
+                conn.send(evt)
+            log.debug(f'sent notification')
+            break
+        except Exception as ex:
+            log.error(f'notification failed', exc_info=ex)
+            time.sleep(0.5)
+
+try:
+    log.debug('running user code')
+$SCRIPT_SOURCE
+    log.debug('user code finished')
+except Exception as ex:
+    log.error('user code failed', exc_info=ex)
+    notify_test(error=ex)
+    sys.exit(1)
+
+notify_test()
diff --git a/test/integration/testlib/test.py b/test/integration/testlib/test.py
new file mode 100755 (executable)
index 0000000..1306abd
--- /dev/null
@@ -0,0 +1,40 @@
+"""Test context that wraps Tinc instances and terminates them on exit."""
+
+import typing as T
+
+from .log import log
+from .proc import Tinc
+
+
+class Test:
+    """Test context. Allows you to obtain Tinc instances which are automatically
+    stopped (and killed if necessary) at __exit__. Should be wrapped in `with`
+    statements (like the built-in `open`). Should be used sparingly (as it usually
+    happens, thanks to Windows: service registration and removal is quite slow,
+    which makes tests take a long time to run, especially on modest CI VMs).
+    """
+
+    name: str
+    _nodes: T.List[Tinc]
+
+    def __init__(self, name: str) -> None:
+        self._nodes = []
+        self.name = name
+
+    def node(self, addr: str = "") -> Tinc:
+        """Create a Tinc instance and remember it for termination on exit."""
+        node = Tinc(addr=addr)
+        self._nodes.append(node)
+        return node
+
+    def __str__(self) -> str:
+        return self.name
+
+    def __enter__(self) -> "Test":
+        log.info("RUNNING TEST: %s", self.name)
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+        for node in self._nodes:
+            node.cleanup()
+        log.info("FINISHED TEST: %s", self.name)
diff --git a/test/integration/testlib/util.py b/test/integration/testlib/util.py
new file mode 100755 (executable)
index 0000000..8102bca
--- /dev/null
@@ -0,0 +1,113 @@
+"""Miscellaneous utility functions."""
+
+import os
+import sys
+import subprocess as subp
+import random
+import string
+import socket
+import typing as T
+
+from . import check
+from .log import log
+from .const import EXIT_SKIP
+
+_ALPHA_NUMERIC = string.ascii_lowercase + string.digits
+
+
+def random_port() -> int:
+    """Return an unused TCP port in the unprivileged range.
+    Note that this function releases the port before returning, and it can be
+    overtaken by something else before you use it.
+    """
+    while True:
+        port = random.randint(1024, 65535)
+        try:
+            with socket.socket() as sock:
+                sock.bind(("0.0.0.0", port))
+                sock.listen()
+            return port
+        except OSError as ex:
+            log.debug("could not bind to random port %d", port, exc_info=ex)
+
+
+def random_string(k: int) -> str:
+    """Generate a random alphanumeric string of length k."""
+    return "".join(random.choices(_ALPHA_NUMERIC, k=k))
+
+
+def find_line(filename: str, prefix: str) -> str:
+    """Find a line with the prefix in a text file.
+    Check that only one line matches.
+    """
+    with open(filename, "r", encoding="utf-8") as f:
+        keylines = [line for line in f.readlines() if line.startswith(prefix)]
+    check.equals(1, len(keylines))
+    return keylines[0].rstrip()
+
+
+def require_root() -> None:
+    """Check that test is running with root privileges.
+    Exit with code 77 otherwise.
+    """
+    euid = os.geteuid()
+    if euid:
+        log.info("this test requires root (but running under UID %d)", euid)
+        sys.exit(EXIT_SKIP)
+
+
+def require_command(*args: str) -> None:
+    """Check that command args runs with exit code 0.
+    Exit with code 77 otherwise.
+    """
+    if subp.run(args, check=False).returncode:
+        log.info('this test requires command "%s" to work', " ".join(args))
+        sys.exit(EXIT_SKIP)
+
+
+def require_path(path: str) -> None:
+    """Check that path exists in your file system.
+    Exit with code 77 otherwise.
+    """
+    if not os.path.exists(path):
+        log.warning("this test requires path %s to be present", path)
+        sys.exit(EXIT_SKIP)
+
+
+# Thin wrappers around `with open(...) as f: f.do_something()`
+# Don't do much, besides saving quite a bit of space because of how frequently they're needed.
+
+
+def read_text(path: str) -> str:
+    """Return the text contents of a file."""
+    with open(path, encoding="utf-8") as f:
+        return f.read()
+
+
+def write_text(path: str, text: str) -> str:
+    """Write text to a file, replacing its content. Return the text added."""
+    with open(path, "w", encoding="utf-8") as f:
+        f.write(text)
+    return text
+
+
+def read_lines(path: str) -> T.List[str]:
+    """Read file as a list of lines."""
+    with open(path, encoding="utf-8") as f:
+        return f.read().splitlines()
+
+
+def write_lines(path: str, lines: T.List[str]) -> T.List[str]:
+    """Write text lines to a file, replacing it content. Return the line added."""
+    with open(path, "w", encoding="utf-8") as f:
+        f.write(os.linesep.join(lines))
+        f.write(os.linesep)
+    return lines
+
+
+def append_line(path: str, line: str) -> str:
+    """Append a line to the end of the file. Return the line added."""
+    line = f"{os.linesep}{line}{os.linesep}"
+    with open(path, "a", encoding="utf-8") as f:
+        f.write(line)
+    return line
diff --git a/test/integration/variables.py b/test/integration/variables.py
new file mode 100755 (executable)
index 0000000..b42fbf5
--- /dev/null
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+
+"""Test tinc and tincd configuration variables."""
+
+import typing as T
+from pathlib import Path
+
+from testlib import check, cmd
+from testlib.log import log
+from testlib.proc import Tinc
+from testlib.test import Test
+
+bad_subnets = (
+    "1.1.1",
+    "1:2:3:4:5:",
+    "1:2:3:4:5:::6",
+    "1:2:3:4:5:6:7:8:9",
+    "256.256.256.256",
+    "1:2:3:4:5:6:7:8.123",
+    "1:2:3:4:5:6:7:1.2.3.4",
+    "a:b:c:d:e:f:g:h",
+    "1.1.1.1/0",
+    "1.1.1.1/-1",
+    "1.1.1.1/33",
+    "1::/0",
+    "1::/-1",
+    "1::/129",
+    ":" * 1024,
+)
+
+
+def init(ctx: Test) -> T.Tuple[Tinc, Tinc]:
+    """Initialize new test nodes."""
+    node0, node1 = ctx.node(), ctx.node()
+
+    log.info("initialize node %s", node0)
+    stdin = f"""
+        init {node0}
+        set Port 0
+        set Address localhost
+        get Name
+    """
+    out, _ = node0.cmd(stdin=stdin)
+    check.equals(node0.name, out.strip())
+
+    return node0, node1
+
+
+with Test("test case sensitivity") as context:
+    foo, bar = init(context)
+
+    foo.cmd("set", "Mode", "switch")
+    check.equals("switch", cmd.get(foo, "Mode"))
+    check.equals("switch", cmd.get(foo, "mOdE"))
+
+    foo.cmd("set", "Mode", "router")
+    check.equals("router", cmd.get(foo, "MoDE"))
+    check.equals("router", cmd.get(foo, "mode"))
+
+    foo.cmd("set", "Mode", "Switch")
+    check.equals("Switch", cmd.get(foo, "mode"))
+
+    foo.cmd("del", "Mode", "hub", code=1)
+    foo.cmd("del", "Mode", "switch")
+    mode, _ = foo.cmd("get", "Mode", code=1)
+    check.false(mode)
+
+with Test("single Mode variable is permitted") as context:
+    foo, bar = init(context)
+    foo.cmd("add", "Mode", "switch")
+    foo.cmd("add", "Mode", "hub")
+    check.equals("hub", cmd.get(foo, "Mode"))
+
+with Test("test addition/deletion of multivalued variables") as context:
+    foo, bar = init(context)
+    for i in range(1, 4):
+        sub = f"{i}.{i}.{i}.{i}"
+        foo.cmd("add", "Subnet", sub)
+        foo.cmd("add", "Subnet", sub)
+    check.equals(["1.1.1.1", "2.2.2.2", "3.3.3.3"], cmd.get(foo, "Subnet").splitlines())
+
+    log.info("delete one subnet")
+    foo.cmd("del", "Subnet", "2.2.2.2")
+    check.equals(["1.1.1.1", "3.3.3.3"], cmd.get(foo, "Subnet").splitlines())
+
+    log.info("delete all subnets")
+    foo.cmd("del", "Subnet")
+    subnet, _ = foo.cmd("get", "Subnet", code=1)
+    check.false(subnet)
+
+with Test("cannot get/set server variables using node.variable syntax") as context:
+    foo, bar = init(context)
+    name, _ = foo.cmd("get", f"{foo.name}.Name", code=1)
+    check.false(name)
+    foo.cmd("set", f"{foo.name}.Name", "fake", code=1)
+
+with Test("get/set host variables for other nodes") as context:
+    foo, bar = init(context)
+    foo_bar = foo.sub("hosts", bar.name)
+    Path(foo_bar).touch(0o644, exist_ok=True)
+
+    bar_pmtu = f"{bar.name}.PMTU"
+    foo.cmd("add", bar_pmtu, "1")
+    foo.cmd("add", bar_pmtu, "2")
+    check.equals("2", cmd.get(foo, bar_pmtu))
+
+    bar_subnet = f"{bar.name}.Subnet"
+    for i in range(1, 4):
+        sub = f"{i}.{i}.{i}.{i}"
+        foo.cmd("add", bar_subnet, sub)
+        foo.cmd("add", bar_subnet, sub)
+
+    check.equals(
+        ["1.1.1.1", "2.2.2.2", "3.3.3.3"], cmd.get(foo, bar_subnet).splitlines()
+    )
+
+    foo.cmd("del", bar_subnet, "2.2.2.2")
+    check.equals(["1.1.1.1", "3.3.3.3"], cmd.get(foo, bar_subnet).splitlines())
+
+    foo.cmd("del", bar_subnet)
+    subnet, _ = foo.cmd("get", bar_subnet, code=1)
+    check.false(subnet)
+
+with Test("cannot get/set variables for nodes with invalid names") as context:
+    foo, bar = init(context)
+    Path(foo.sub("hosts", "fake-node")).touch(0o644, exist_ok=True)
+    foo.cmd("set", "fake-node.Subnet", "1.1.1.1", code=1)
+
+    log.info("cannot set obsolete variables unless forced")
+    foo.cmd("set", "PrivateKey", "12345", code=1)
+    foo.cmd("--force", "set", "PrivateKey", "67890")
+    check.equals("67890", cmd.get(foo, "PrivateKey"))
+
+    foo.cmd("del", "PrivateKey")
+    key, _ = foo.cmd("get", "PrivateKey", code=1)
+    check.false(key)
+
+    log.info("cannot set/add malformed Subnets")
+    for subnet in bad_subnets:
+        log.info("testing subnet %s", subnet)
+        foo.cmd("add", "Subnet", subnet, code=1)
+
+    subnet, _ = foo.cmd("get", "Subnet", code=1)
+    check.false(subnet)
diff --git a/test/integration/variables.test b/test/integration/variables.test
deleted file mode 100755 (executable)
index fffbd8c..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-#!/bin/sh
-
-# shellcheck disable=SC1090
-. "$TESTLIB_PATH"
-
-echo [STEP] Initialize one node
-
-tinc foo init foo
-test "$(tinc foo get Name)" = "foo"
-
-echo [STEP] Test case sensitivity
-
-tinc foo set Mode switch
-test "$(tinc foo get Mode)" = "switch"
-test "$(tinc foo get mode)" = "switch"
-
-tinc foo set mode router
-test "$(tinc foo get Mode)" = "router"
-test "$(tinc foo get mode)" = "router"
-
-tinc foo set Mode Switch
-test "$(tinc foo get Mode)" = "Switch"
-
-echo [STEP] Test deletion
-
-expect_code "$EXIT_FAILURE" tinc foo del Mode hub
-tinc foo del Mode switch
-test -z "$(tinc foo get Mode)"
-
-echo [STEP] There can only be one Mode variable
-
-tinc foo add Mode switch
-tinc foo add Mode hub
-test "$(tinc foo get Mode)" = "hub"
-
-echo [STEP] Test addition/deletion of multivalued variables
-
-tinc foo add Subnet 1.1.1.1
-tinc foo add Subnet 2.2.2.2
-tinc foo add Subnet 2.2.2.2
-tinc foo add Subnet 3.3.3.3
-test "$(tinc foo get Subnet | rm_cr)" = "1.1.1.1
-2.2.2.2
-3.3.3.3"
-
-tinc foo del Subnet 2.2.2.2
-test "$(tinc foo get Subnet | rm_cr)" = "1.1.1.1
-3.3.3.3"
-
-tinc foo del Subnet
-test -z "$(tinc foo get Subnet)"
-
-echo [STEP] We should not be able to get/set server variables using node.variable syntax
-
-test -z "$(tinc foo get foo.Name)"
-expect_code "$EXIT_FAILURE" tinc foo set foo.Name bar
-
-echo [STEP] Test getting/setting host variables for other nodes
-
-touch "$DIR_FOO/hosts/bar"
-
-tinc foo add bar.PMTU 1
-tinc foo add bar.PMTU 2
-test "$(tinc foo get bar.PMTU)" = "2"
-
-tinc foo add bar.Subnet 1.1.1.1
-tinc foo add bar.Subnet 2.2.2.2
-tinc foo add bar.Subnet 2.2.2.2
-tinc foo add bar.Subnet 3.3.3.3
-test "$(tinc foo get bar.Subnet | rm_cr)" = "1.1.1.1
-2.2.2.2
-3.3.3.3"
-
-tinc foo del bar.Subnet 2.2.2.2
-test "$(tinc foo get bar.Subnet | rm_cr)" = "1.1.1.1
-3.3.3.3"
-
-tinc foo del bar.Subnet
-test -z "$(tinc foo get bar.Subnet)"
-
-echo [STEP] We should not be able to get/set for nodes with invalid names
-
-touch "$DIR_FOO/hosts/qu-ux"
-expect_code "$EXIT_FAILURE" tinc foo set qu-ux.Subnet 1.1.1.1
-
-echo [STEP] We should not be able to set obsolete variables unless forced
-
-expect_code "$EXIT_FAILURE" tinc foo set PrivateKey 12345
-tinc foo --force set PrivateKey 12345
-test "$(tinc foo get PrivateKey)" = "12345"
-
-tinc foo del PrivateKey
-test -z "$(tinc foo get PrivateKey)"
-
-echo [STEP] We should not be able to set/add malformed Subnets
-
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1.1.1
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1:2:3:4:5:
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1:2:3:4:5:::6
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1:2:3:4:5:6:7:8:9
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 256.256.256.256
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1:2:3:4:5:6:7:8.123
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1:2:3:4:5:6:7:1.2.3.4
-expect_code "$EXIT_FAILURE" tinc foo add Subnet a:b:c:d:e:f:g:h
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1.1.1.1/0
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1.1.1.1/-1
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1.1.1.1/33
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1::/0
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1::/-1
-expect_code "$EXIT_FAILURE" tinc foo add Subnet 1::/129
-expect_code "$EXIT_FAILURE" tinc foo add Subnet ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
-test -z "$(tinc foo get Subnet)"
index 36d9753..48e6689 100644 (file)
@@ -1,6 +1,3 @@
-if cc_name != 'msvc'
-  subdir('integration')
-endif
-
+subdir('integration')
 subdir('unit')