diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 000000000..b02eaa07e --- /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 + diff --git a/src/sptps_test.c b/src/sptps_test.c index 7867b722d..50057e29e 100644 --- a/src/sptps_test.c +++ b/src/sptps_test.c @@ -301,6 +301,19 @@ static int start_input_reader(void) { #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 index 000000000..b056c7d54 --- /dev/null +++ b/test/integration/algorithms.py @@ -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 index 9dd722c9a..000000000 --- a/test/integration/algorithms.test +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/sh - -# shellcheck disable=SC1090 -. "$TESTLIB_PATH" - -echo [STEP] Initialize two nodes - -tinc foo < 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 index 31f275524..000000000 --- a/test/integration/basic.test +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh - -# shellcheck disable=SC1090 -. "$TESTLIB_PATH" - -echo [STEP] Initialize and test one node - -tinc foo <&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" <>"$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 index 000000000..4e79ccd51 --- /dev/null +++ b/test/integration/command_fsck.py @@ -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 index 000000000..531868f63 --- /dev/null +++ b/test/integration/commandline.py @@ -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 index 800dbfcfa..000000000 --- a/test/integration/commandline.test +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/sh - -# shellcheck disable=SC1090 -. "$TESTLIB_PATH" - -echo [STEP] Initialize one node - -tinc foo < 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 index ac925417e..000000000 --- a/test/integration/compression.test +++ /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 <&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 index 000000000..121683aa8 --- /dev/null +++ b/test/integration/executables.py @@ -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 index 390d2c71a..000000000 --- a/test/integration/executables.test +++ /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 index 6fe303fd8..000000000 --- a/test/integration/import-export.test +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/sh - -# shellcheck disable=SC1090 -. "$TESTLIB_PATH" - -echo [STEP] Initialize three nodes - -tinc foo < 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 index be2af2be6..000000000 --- a/test/integration/invite-join.test +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh - -# shellcheck disable=SC1090 -. "$TESTLIB_PATH" - -echo [STEP] Initialize one node - -tinc foo <"$INVITATION_FILE" < 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 index 000000000..72414fb64 --- /dev/null +++ b/test/integration/invite_tinc_up.py @@ -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 index 9c44f7901..000000000 --- a/test/integration/legacy-protocol.test +++ /dev/null @@ -1,90 +0,0 @@ -#!/bin/sh - -# shellcheck disable=SC1090 -. "$TESTLIB_PATH" - -echo [STEP] Initialize two nodes - -tinc foo < 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) diff --git a/test/integration/meson.build b/test/integration/meson.build index b32ea82ef..5dc2430ae 100644 --- a/test/integration/meson.build +++ b/test/integration/meson.build @@ -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 index 33e72708d..000000000 --- a/test/integration/ns-ping.test +++ /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 < 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 index 000000000..ef2fc7e6c --- /dev/null +++ b/test/integration/scripts.py @@ -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 index ff0e565fd..000000000 --- a/test/integration/scripts.test +++ /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 <>'$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 <>"$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" </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 </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 < 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 index 3945079f6..000000000 --- a/test/integration/security.test +++ /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 < 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 index 0eb285b13..000000000 --- a/test/integration/sptps-basic.test +++ /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 index 000000000..c5c42cc3d --- /dev/null +++ b/test/integration/sptps_basic.py @@ -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 index 4c99f3be6..000000000 --- a/test/integration/testlib.sh +++ /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" <"$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" <>"$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?}" </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 index 000000000..3b0564f63 --- /dev/null +++ b/test/integration/testlib/__init__.py @@ -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 index 000000000..77865b1a3 --- /dev/null +++ b/test/integration/testlib/check.py @@ -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 index 000000000..9a71f65c6 --- /dev/null +++ b/test/integration/testlib/cmd.py @@ -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 index 000000000..36f5f9777 --- /dev/null +++ b/test/integration/testlib/const.py @@ -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 index 000000000..4acd375b3 --- /dev/null +++ b/test/integration/testlib/event.py @@ -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 index 000000000..b90d779f5 --- /dev/null +++ b/test/integration/testlib/external.py @@ -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 index 000000000..0c39475af --- /dev/null +++ b/test/integration/testlib/log.py @@ -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 index 000000000..7813bf01d --- /dev/null +++ b/test/integration/testlib/notification.py @@ -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 index 000000000..a33fba551 --- /dev/null +++ b/test/integration/testlib/path.py @@ -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 index 000000000..8ac65baf0 --- /dev/null +++ b/test/integration/testlib/proc.py @@ -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 index 000000000..9c7985560 --- /dev/null +++ b/test/integration/testlib/script.py @@ -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 index 000000000..b90299c02 --- /dev/null +++ b/test/integration/testlib/template.py @@ -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 index 000000000..6f6457c2c --- /dev/null +++ b/test/integration/testlib/template/netns.py.tpl @@ -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 index 000000000..6e8450a47 --- /dev/null +++ b/test/integration/testlib/template/script.cmd.tpl @@ -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 index 000000000..59e229a40 --- /dev/null +++ b/test/integration/testlib/template/script.py.tpl @@ -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 index 000000000..1306abd65 --- /dev/null +++ b/test/integration/testlib/test.py @@ -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 index 000000000..8102bca27 --- /dev/null +++ b/test/integration/testlib/util.py @@ -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 index 000000000..b42fbf5e3 --- /dev/null +++ b/test/integration/variables.py @@ -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 index fffbd8ced..000000000 --- a/test/integration/variables.test +++ /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)" diff --git a/test/meson.build b/test/meson.build index 36d9753f7..48e668988 100644 --- a/test/meson.build +++ b/test/meson.build @@ -1,6 +1,3 @@ -if cc_name != 'msvc' - subdir('integration') -endif - +subdir('integration') subdir('unit')