| 1 | #!/usr/bin/env python3
|
|---|
| 2 | """TLS checks of BNC's Ntrip 2s client and upload (option sslIgnoreErrors,
|
|---|
| 3 | CA certificate path, client certificate) against tools/fakecaster.py.
|
|---|
| 4 |
|
|---|
| 5 | BNC uploads with Ntrip 2s always to port 443, so the checks run in an
|
|---|
| 6 | unprivileged user and network namespace (unshare -rn), where binding port 443
|
|---|
| 7 | is allowed and nothing leaves the machine. Certificates are created with
|
|---|
| 8 | openssl in tests/work/ssl_check/.
|
|---|
| 9 |
|
|---|
| 10 | tests/tools/ssl_check.py [--bnc PATH] exit code 1 if a check fails
|
|---|
| 11 |
|
|---|
| 12 | Checks (host name localhost, self-signed test CA):
|
|---|
| 13 | upload : server certificate of an unknown CA is rejected / accepted with
|
|---|
| 14 | the CA in 'sslCaCertPath' / accepted with sslIgnoreErrors, the
|
|---|
| 15 | ignored errors being logged; certificate for another host name
|
|---|
| 16 | rejected; client certificate presented also with sslIgnoreErrors
|
|---|
| 17 | download: unknown CA rejected / accepted with the CA / with sslIgnoreErrors
|
|---|
| 18 | """
|
|---|
| 19 |
|
|---|
| 20 | import argparse
|
|---|
| 21 | import glob
|
|---|
| 22 | import os
|
|---|
| 23 | import shutil
|
|---|
| 24 | import subprocess
|
|---|
| 25 | import sys
|
|---|
| 26 |
|
|---|
| 27 | TOOLS = os.path.dirname(os.path.abspath(__file__))
|
|---|
| 28 | TESTS = os.path.dirname(TOOLS)
|
|---|
| 29 | ROOT = os.path.dirname(TESTS)
|
|---|
| 30 | WORK = os.path.join(TESTS, "work", "ssl_check")
|
|---|
| 31 | UPLOAD_RAW = os.path.join(TESTS, "data", "upload_rtnet_5min.raw")
|
|---|
| 32 | SERVE_RAW = os.path.join(TESTS, "data", "crafted_limits.raw")
|
|---|
| 33 |
|
|---|
| 34 |
|
|---|
| 35 | def sh(*cmd):
|
|---|
| 36 | subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|---|
| 37 |
|
|---|
| 38 |
|
|---|
| 39 | def make_certs(d):
|
|---|
| 40 | os.makedirs(d)
|
|---|
| 41 | sh("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "30",
|
|---|
| 42 | "-subj", "/CN=BNC Test CA", "-keyout", d + "/ca.key", "-out", d + "/ca.crt")
|
|---|
| 43 |
|
|---|
| 44 | def signed(name, cn, san, ext="serverAuth"):
|
|---|
| 45 | with open(d + "/" + name + ".ext", "w") as f:
|
|---|
| 46 | f.write("subjectAltName=%s\nextendedKeyUsage=%s\n" % (san, ext))
|
|---|
| 47 | sh("openssl", "genrsa", "-traditional", "-out", d + "/" + name + ".key", "2048")
|
|---|
| 48 | sh("openssl", "req", "-new", "-key", d + "/" + name + ".key", "-subj", "/CN=" + cn,
|
|---|
| 49 | "-out", d + "/" + name + ".csr")
|
|---|
| 50 | sh("openssl", "x509", "-req", "-in", d + "/" + name + ".csr", "-CA", d + "/ca.crt",
|
|---|
| 51 | "-CAkey", d + "/ca.key", "-CAcreateserial", "-days", "30",
|
|---|
| 52 | "-extfile", d + "/" + name + ".ext", "-out", d + "/" + name + ".crt")
|
|---|
| 53 |
|
|---|
| 54 | signed("server", "localhost", "DNS:localhost,IP:127.0.0.1")
|
|---|
| 55 | signed("other", "other.example", "DNS:other.example")
|
|---|
| 56 | signed("client", "bnc-client", "DNS:bnc-client", "clientAuth")
|
|---|
| 57 | os.makedirs(d + "/cadir")
|
|---|
| 58 | shutil.copy(d + "/ca.crt", d + "/cadir/ca.crt")
|
|---|
| 59 | os.makedirs(d + "/emptydir")
|
|---|
| 60 | # client certificate naming convention of BNC: <host>.<port>.crt/.key
|
|---|
| 61 | os.makedirs(d + "/clientdir")
|
|---|
| 62 | shutil.copy(d + "/client.crt", d + "/clientdir/localhost.443.crt")
|
|---|
| 63 | shutil.copy(d + "/client.key", d + "/clientdir/localhost.443.key")
|
|---|
| 64 |
|
|---|
| 65 |
|
|---|
| 66 | def run(bnc, name, certs, server, caster_args, bnc_args, live):
|
|---|
| 67 | """Start the fake caster on 443, run BNC; returns (caster output, BNC log)."""
|
|---|
| 68 | d = os.path.join(WORK, name)
|
|---|
| 69 | os.makedirs(os.path.join(d, "Output"))
|
|---|
| 70 | os.symlink(os.path.join(ROOT, "Example_Configs", "Input"), os.path.join(d, "Input"))
|
|---|
| 71 | fc = subprocess.Popen(
|
|---|
| 72 | [sys.executable, "-I", os.path.join(TOOLS, "fakecaster.py"), "--port", "443",
|
|---|
| 73 | "--out", os.path.join(d, "Output", "upload.raw"), "--sta", "SSL",
|
|---|
| 74 | "--time", "2026-10-01T09:33:00", "--timeout", "8",
|
|---|
| 75 | "--tls-cert", certs + "/" + server + ".crt", "--tls-key", certs + "/" + server + ".key"]
|
|---|
| 76 | + caster_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|---|
| 77 | fc.stdout.readline() # "listening"
|
|---|
| 78 | env = dict(os.environ, QT_QPA_PLATFORM="offscreen", LC_ALL="C", TZ="UTC")
|
|---|
| 79 | cmd = [bnc, "--nw", "--platform", "offscreen"] + bnc_args
|
|---|
| 80 | with open(os.path.join(d, "bnc.out"), "wb") as out:
|
|---|
| 81 | if live: # Ntrip client: runs until stopped
|
|---|
| 82 | p = subprocess.Popen(cmd, cwd=d, stdout=out, stderr=subprocess.STDOUT, env=env)
|
|---|
| 83 | try:
|
|---|
| 84 | p.wait(12)
|
|---|
| 85 | except subprocess.TimeoutExpired:
|
|---|
| 86 | p.send_signal(2)
|
|---|
| 87 | p.wait(30)
|
|---|
| 88 | else:
|
|---|
| 89 | subprocess.run(cmd, cwd=d, stdout=out, stderr=subprocess.STDOUT, env=env, timeout=300)
|
|---|
| 90 | caster = fc.communicate(timeout=120)[0]
|
|---|
| 91 | log = ""
|
|---|
| 92 | for f in sorted(glob.glob(os.path.join(d, "Output", "*.log*"))):
|
|---|
| 93 | with open(f, errors="replace") as inp:
|
|---|
| 94 | log += inp.read()
|
|---|
| 95 | with open(os.path.join(d, "caster.out"), "w") as f:
|
|---|
| 96 | f.write(caster)
|
|---|
| 97 | return caster, log
|
|---|
| 98 |
|
|---|
| 99 |
|
|---|
| 100 | def upload(bnc, name, certs, server="server", ignore=0, cadir="emptydir", clientdir="emptydir",
|
|---|
| 101 | caster_args=()):
|
|---|
| 102 | mp = ("localhost,443,CLOCK,2s,user,pass,IGS20,IGS-SSR,0,,,,,,,0 byte(s),")
|
|---|
| 103 | return run(bnc, name, certs, server, list(caster_args),
|
|---|
| 104 | ["--conf", os.path.join(ROOT, "Example_Configs", "16_Upload.bnc"),
|
|---|
| 105 | "--file", UPLOAD_RAW,
|
|---|
| 106 | "-key", "uploadMountpointsOut", mp,
|
|---|
| 107 | "-key", "sslIgnoreErrors", str(ignore),
|
|---|
| 108 | "-key", "sslCaCertPath", certs + "/" + cadir,
|
|---|
| 109 | "-key", "sslClientCertPath", certs + "/" + clientdir + "/"], live=False)
|
|---|
| 110 |
|
|---|
| 111 |
|
|---|
| 112 | def download(bnc, name, certs, ignore=0, cadir="emptydir"):
|
|---|
| 113 | return run(bnc, name, certs, "server", ["--serve", SERVE_RAW],
|
|---|
| 114 | ["--conf", os.path.join(ROOT, "Example_Configs", "22_Empty.bnc"),
|
|---|
| 115 | "-key", "mountPoints", "//user:pass@localhost:443/TEST RTCM_3 DEU 50.00 8.00 no 2s",
|
|---|
| 116 | "-key", "logFile", "Output/bnc.log",
|
|---|
| 117 | "-key", "sslIgnoreErrors", str(ignore),
|
|---|
| 118 | "-key", "sslCaCertPath", certs + "/" + cadir], live=True)
|
|---|
| 119 |
|
|---|
| 120 |
|
|---|
| 121 | def main():
|
|---|
| 122 | ap = argparse.ArgumentParser(description=__doc__,
|
|---|
| 123 | formatter_class=argparse.RawDescriptionHelpFormatter)
|
|---|
| 124 | ap.add_argument("--bnc", default=os.path.join(ROOT, "bnc"))
|
|---|
| 125 | args = ap.parse_args()
|
|---|
| 126 | bnc = os.path.abspath(args.bnc)
|
|---|
| 127 |
|
|---|
| 128 | if os.environ.get("BNC_SSL_CHECK_NS") != "1":
|
|---|
| 129 | # re-run inside a new user + network namespace
|
|---|
| 130 | env = dict(os.environ, BNC_SSL_CHECK_NS="1")
|
|---|
| 131 | return subprocess.run(["unshare", "-rn", "sh", "-c",
|
|---|
| 132 | 'ip link set lo up && exec "$@"', "sh",
|
|---|
| 133 | sys.executable, "-I", os.path.abspath(__file__), "--bnc", bnc],
|
|---|
| 134 | env=env).returncode
|
|---|
| 135 |
|
|---|
| 136 | shutil.rmtree(WORK, ignore_errors=True)
|
|---|
| 137 | certs = os.path.join(WORK, "certs")
|
|---|
| 138 | make_certs(certs)
|
|---|
| 139 |
|
|---|
| 140 | failed = 0
|
|---|
| 141 |
|
|---|
| 142 | def check(name, ok, detail):
|
|---|
| 143 | nonlocal failed
|
|---|
| 144 | print("%-6s %-40s %s" % ("PASS" if ok else "FAIL", name, detail), flush=True)
|
|---|
| 145 | failed += not ok
|
|---|
| 146 |
|
|---|
| 147 | def received(caster):
|
|---|
| 148 | for line in caster.splitlines():
|
|---|
| 149 | if line.startswith("received "):
|
|---|
| 150 | return int(line.split()[1])
|
|---|
| 151 | return 0
|
|---|
| 152 |
|
|---|
| 153 | def served(caster):
|
|---|
| 154 | for line in caster.splitlines():
|
|---|
| 155 | if line.startswith("served "):
|
|---|
| 156 | return int(line.split()[1])
|
|---|
| 157 | return 0
|
|---|
| 158 |
|
|---|
| 159 | c, log = upload(bnc, "up_unknown_ca", certs)
|
|---|
| 160 | check("upload: unknown CA rejected", received(c) == 0 and "SSL Error" in log
|
|---|
| 161 | and "ignored" not in log, "%d bytes received" % received(c))
|
|---|
| 162 |
|
|---|
| 163 | c, log = upload(bnc, "up_ca", certs, cadir="cadir")
|
|---|
| 164 | check("upload: CA in sslCaCertPath accepted", received(c) > 0 and "SSL Error" not in log,
|
|---|
| 165 | "%d bytes received" % received(c))
|
|---|
| 166 |
|
|---|
| 167 | c, log = upload(bnc, "up_ignore", certs, ignore=2)
|
|---|
| 168 | check("upload: sslIgnoreErrors, errors logged", received(c) > 0 and "(ignored as configured)" in log,
|
|---|
| 169 | "%d bytes received" % received(c))
|
|---|
| 170 |
|
|---|
| 171 | c, log = upload(bnc, "up_other_host", certs, server="other", cadir="cadir")
|
|---|
| 172 | check("upload: other host name rejected", received(c) == 0 and "SSL Error" in log,
|
|---|
| 173 | "%d bytes received" % received(c))
|
|---|
| 174 |
|
|---|
| 175 | c, log = upload(bnc, "up_client_cert", certs, cadir="cadir", clientdir="clientdir",
|
|---|
| 176 | caster_args=["--tls-client-ca", certs + "/ca.crt"])
|
|---|
| 177 | check("upload: client certificate", received(c) > 0, "%d bytes received" % received(c))
|
|---|
| 178 |
|
|---|
| 179 | c, log = upload(bnc, "up_client_cert_ignore", certs, ignore=2, clientdir="clientdir",
|
|---|
| 180 | caster_args=["--tls-client-ca", certs + "/ca.crt"])
|
|---|
| 181 | check("upload: client certificate, sslIgnoreErrors", received(c) > 0,
|
|---|
| 182 | "%d bytes received" % received(c))
|
|---|
| 183 |
|
|---|
| 184 | c, log = download(bnc, "down_unknown_ca", certs)
|
|---|
| 185 | check("download: unknown CA rejected", served(c) == 0 and "SSL Error" in log
|
|---|
| 186 | and "ignored" not in log, "%d bytes served" % served(c))
|
|---|
| 187 |
|
|---|
| 188 | c, log = download(bnc, "down_ca", certs, cadir="cadir")
|
|---|
| 189 | check("download: CA in sslCaCertPath accepted", served(c) > 0 and "Get data" in log,
|
|---|
| 190 | "%d bytes served" % served(c))
|
|---|
| 191 |
|
|---|
| 192 | c, log = download(bnc, "down_ignore", certs, ignore=2)
|
|---|
| 193 | check("download: sslIgnoreErrors, errors logged", served(c) > 0 and "Get data" in log
|
|---|
| 194 | and "(ignored as configured)" in log, "%d bytes served" % served(c))
|
|---|
| 195 |
|
|---|
| 196 | print("%d check(s) failed, details in %s" % (failed, os.path.relpath(WORK, ROOT)))
|
|---|
| 197 | return 1 if failed else 0
|
|---|
| 198 |
|
|---|
| 199 |
|
|---|
| 200 | if __name__ == "__main__":
|
|---|
| 201 | sys.exit(main())
|
|---|