| 1 | #!/usr/bin/env python3
|
|---|
| 2 | """Network checks of BNC on the loopback interface (no root needed):
|
|---|
| 3 |
|
|---|
| 4 | UDP sender filter: a fake server sends datagrams marked GOOD, a second
|
|---|
| 5 | sender on another loopback address datagrams marked SPOOFED to the same
|
|---|
| 6 | BNC port. BNC's raw output (rawOutFile) must contain only GOOD data:
|
|---|
| 7 | 'U' Ntrip 2 UDP mode (server 127.0.0.3)
|
|---|
| 8 | 'R' RTSP/RTP mode (RTSP server 127.0.0.3)
|
|---|
| 9 | 'UN' UDP without Ntrip, stream URL with sender 127.0.0.5
|
|---|
| 10 | and with the URL host 127.0.0.1 ('UN' as written by the 'Add Stream -
|
|---|
| 11 | Coming from UDP Port' dialog) data of any sender (both GOOD and SPOOFED).
|
|---|
| 12 |
|
|---|
| 13 | Ntrip 1 response header: an endless header is cut off ("Caster response
|
|---|
| 14 | header too long"), a normal Ntrip 1 stream still works.
|
|---|
| 15 |
|
|---|
| 16 | Upload status: a caster answering 'HTTP/1.1 401 Unauthorized' with a
|
|---|
| 17 | header that contains '200' must not be taken as success.
|
|---|
| 18 |
|
|---|
| 19 | tests/tools/net_check.py [--bnc PATH] exit code 1 if a check fails
|
|---|
| 20 | """
|
|---|
| 21 |
|
|---|
| 22 | import argparse
|
|---|
| 23 | import glob
|
|---|
| 24 | import os
|
|---|
| 25 | import re
|
|---|
| 26 | import shutil
|
|---|
| 27 | import socket
|
|---|
| 28 | import subprocess
|
|---|
| 29 | import sys
|
|---|
| 30 | import threading
|
|---|
| 31 | import time
|
|---|
| 32 |
|
|---|
| 33 | TOOLS = os.path.dirname(os.path.abspath(__file__))
|
|---|
| 34 | TESTS = os.path.dirname(TOOLS)
|
|---|
| 35 | ROOT = os.path.dirname(TESTS)
|
|---|
| 36 | WORK = os.path.join(TESTS, "work", "net_check")
|
|---|
| 37 | UPLOAD_RAW = os.path.join(TESTS, "data", "upload_rtnet_5min.raw")
|
|---|
| 38 | SERVER, SPOOFER, UN_SENDER, UN_OTHER = "127.0.0.3", "127.0.0.4", "127.0.0.5", "127.0.0.6"
|
|---|
| 39 | DURATION = 8.0 # seconds of data
|
|---|
| 40 | RTP_HEADER = bytes([128, 96]) + bytes(10)
|
|---|
| 41 |
|
|---|
| 42 |
|
|---|
| 43 | def udp_socket(addr):
|
|---|
| 44 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|---|
| 45 | s.bind((addr, 0))
|
|---|
| 46 | return s
|
|---|
| 47 |
|
|---|
| 48 |
|
|---|
| 49 | def send_both(good, spoof, dest, header=b""):
|
|---|
| 50 | """GOOD from 'good', SPOOFED from 'spoof' to dest, alternating."""
|
|---|
| 51 | t0 = time.time()
|
|---|
| 52 | while time.time() - t0 < DURATION:
|
|---|
| 53 | good.sendto(header + b"GOOD" * 8, dest)
|
|---|
| 54 | spoof.sendto(header + b"SPOOFED!" * 4, dest)
|
|---|
| 55 | time.sleep(0.2)
|
|---|
| 56 |
|
|---|
| 57 |
|
|---|
| 58 | def fake_udp_server(port):
|
|---|
| 59 | """Ntrip 2 UDP mode: request datagram -> reply with session -> data."""
|
|---|
| 60 | srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|---|
| 61 | srv.bind((SERVER, port))
|
|---|
| 62 | srv.settimeout(20)
|
|---|
| 63 | try:
|
|---|
| 64 | req, client = srv.recvfrom(4096)
|
|---|
| 65 | except socket.timeout:
|
|---|
| 66 | return
|
|---|
| 67 | srv.sendto(RTP_HEADER + b"HTTP/1.1 200 OK\r\nSession: 4711\r\n\r\n", client)
|
|---|
| 68 | send_both(srv, udp_socket(SPOOFER), client, RTP_HEADER)
|
|---|
| 69 |
|
|---|
| 70 |
|
|---|
| 71 | def fake_rtsp_server(port):
|
|---|
| 72 | """RTSP: SETUP -> PLAY on TCP, then RTP data to the client port."""
|
|---|
| 73 | srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|---|
| 74 | srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|---|
| 75 | srv.bind((SERVER, port))
|
|---|
| 76 | srv.listen(1)
|
|---|
| 77 | srv.settimeout(20)
|
|---|
| 78 | try:
|
|---|
| 79 | conn, peer = srv.accept()
|
|---|
| 80 | except socket.timeout:
|
|---|
| 81 | return
|
|---|
| 82 | conn.settimeout(10)
|
|---|
| 83 | rtp = udp_socket(SERVER)
|
|---|
| 84 |
|
|---|
| 85 | def request():
|
|---|
| 86 | buf = b""
|
|---|
| 87 | while b"\r\n\r\n" not in buf:
|
|---|
| 88 | d = conn.recv(4096)
|
|---|
| 89 | if not d:
|
|---|
| 90 | return None
|
|---|
| 91 | buf += d
|
|---|
| 92 | return buf.decode("latin-1")
|
|---|
| 93 |
|
|---|
| 94 | setup = request()
|
|---|
| 95 | cport = int(re.search(r"client_port=(\d+)", setup).group(1))
|
|---|
| 96 | conn.sendall(("RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: 4711\r\n"
|
|---|
| 97 | "Transport: RTP/GNSS;unicast;client_port=%d;server_port=%d\r\n\r\n"
|
|---|
| 98 | % (cport, rtp.getsockname()[1])).encode())
|
|---|
| 99 | request() # PLAY
|
|---|
| 100 | conn.sendall(b"RTSP/1.0 200 OK\r\nCSeq: 2\r\nSession: 4711\r\n\r\n")
|
|---|
| 101 | send_both(rtp, udp_socket(SPOOFER), (peer[0], cport), RTP_HEADER)
|
|---|
| 102 | conn.close()
|
|---|
| 103 |
|
|---|
| 104 |
|
|---|
| 105 | def fake_ntrip1_caster(port, endless):
|
|---|
| 106 | """Ntrip 1: endless response header, or ICY 200 OK followed by data."""
|
|---|
| 107 | srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|---|
| 108 | srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|---|
| 109 | srv.bind((SERVER, port))
|
|---|
| 110 | srv.listen(5)
|
|---|
| 111 | srv.settimeout(1)
|
|---|
| 112 | t0 = time.time()
|
|---|
| 113 | while time.time() - t0 < DURATION:
|
|---|
| 114 | try:
|
|---|
| 115 | conn, _ = srv.accept()
|
|---|
| 116 | except socket.timeout:
|
|---|
| 117 | continue
|
|---|
| 118 | conn.settimeout(5)
|
|---|
| 119 | try:
|
|---|
| 120 | conn.recv(4096)
|
|---|
| 121 | if endless:
|
|---|
| 122 | t1 = time.time()
|
|---|
| 123 | while time.time() - t1 < 3:
|
|---|
| 124 | conn.sendall(b"X-Padding: " + b"x" * 1000 + b"\r\n")
|
|---|
| 125 | else:
|
|---|
| 126 | conn.sendall(b"ICY 200 OK\r\n\r\n")
|
|---|
| 127 | while time.time() - t0 < DURATION:
|
|---|
| 128 | conn.sendall(b"GOOD" * 8)
|
|---|
| 129 | time.sleep(0.2)
|
|---|
| 130 | except OSError:
|
|---|
| 131 | pass
|
|---|
| 132 | conn.close()
|
|---|
| 133 |
|
|---|
| 134 |
|
|---|
| 135 | def bnc_live(bnc, name, mountpoint, server=None):
|
|---|
| 136 | """Run BNC with one stream for DURATION + a few seconds; returns its raw
|
|---|
| 137 | output and log."""
|
|---|
| 138 | d = os.path.join(WORK, name)
|
|---|
| 139 | os.makedirs(os.path.join(d, "Output"))
|
|---|
| 140 | env = dict(os.environ, QT_QPA_PLATFORM="offscreen", LC_ALL="C", TZ="UTC")
|
|---|
| 141 | cmd = [bnc, "--nw", "--platform", "offscreen",
|
|---|
| 142 | "--conf", os.path.join(ROOT, "Example_Configs", "22_Empty.bnc"),
|
|---|
| 143 | "-key", "mountPoints", mountpoint,
|
|---|
| 144 | "-key", "rawOutFile", "Output/raw",
|
|---|
| 145 | "-key", "logFile", "Output/bnc.log"]
|
|---|
| 146 | with open(os.path.join(d, "bnc.out"), "wb") as out:
|
|---|
| 147 | p = subprocess.Popen(cmd, cwd=d, stdout=out, stderr=subprocess.STDOUT, env=env)
|
|---|
| 148 | if server:
|
|---|
| 149 | server()
|
|---|
| 150 | else:
|
|---|
| 151 | time.sleep(DURATION + 2)
|
|---|
| 152 | time.sleep(1)
|
|---|
| 153 | p.send_signal(2)
|
|---|
| 154 | p.wait(30)
|
|---|
| 155 | raw = b"".join(open(f, "rb").read() for f in glob.glob(os.path.join(d, "Output", "raw*")))
|
|---|
| 156 | log = "".join(open(f, errors="replace").read()
|
|---|
| 157 | for f in glob.glob(os.path.join(d, "Output", "bnc.log*")))
|
|---|
| 158 | return raw, log
|
|---|
| 159 |
|
|---|
| 160 |
|
|---|
| 161 | def main():
|
|---|
| 162 | ap = argparse.ArgumentParser(description=__doc__,
|
|---|
| 163 | formatter_class=argparse.RawDescriptionHelpFormatter)
|
|---|
| 164 | ap.add_argument("--bnc", default=os.path.join(ROOT, "bnc"))
|
|---|
| 165 | args = ap.parse_args()
|
|---|
| 166 | bnc = os.path.abspath(args.bnc)
|
|---|
| 167 | shutil.rmtree(WORK, ignore_errors=True)
|
|---|
| 168 | failed = 0
|
|---|
| 169 |
|
|---|
| 170 | def check(name, ok, detail):
|
|---|
| 171 | nonlocal failed
|
|---|
| 172 | print("%-6s %-44s %s" % ("PASS" if ok else "FAIL", name, detail), flush=True)
|
|---|
| 173 | failed += not ok
|
|---|
| 174 |
|
|---|
| 175 | def counts(raw):
|
|---|
| 176 | return raw.count(b"GOOD" * 8), raw.count(b"SPOOFED!" * 4)
|
|---|
| 177 |
|
|---|
| 178 | def detail(raw, log):
|
|---|
| 179 | good, spoof = counts(raw)
|
|---|
| 180 | reconnects = log.count("reconnecting")
|
|---|
| 181 | return "%d GOOD, %d SPOOFED datagrams, %d reconnect(s)" % (good, spoof, reconnects)
|
|---|
| 182 |
|
|---|
| 183 | # 'U': Ntrip 2 in UDP mode
|
|---|
| 184 | port = 21211
|
|---|
| 185 | raw, log = bnc_live(bnc, "udp_ntrip", "//user:pass@%s:%d/TEST RTCM_3 DEU 50 8 no U" % (SERVER, port),
|
|---|
| 186 | lambda: fake_udp_server(port))
|
|---|
| 187 | good, spoof = counts(raw)
|
|---|
| 188 | check("U: only datagrams of the caster", good > 0 and spoof == 0 and "reconnecting" not in log,
|
|---|
| 189 | detail(raw, log))
|
|---|
| 190 |
|
|---|
| 191 | # 'R': RTSP/RTP
|
|---|
| 192 | port = 21212
|
|---|
| 193 | raw, log = bnc_live(bnc, "rtsp", "//user:pass@%s:%d/TEST RTCM_3 DEU 50 8 no R" % (SERVER, port),
|
|---|
| 194 | lambda: fake_rtsp_server(port))
|
|---|
| 195 | good, spoof = counts(raw)
|
|---|
| 196 | check("R: only RTP datagrams of the RTSP server", good > 0 and spoof == 0 and "reconnecting" not in log,
|
|---|
| 197 | detail(raw, log))
|
|---|
| 198 |
|
|---|
| 199 | # 'UN' with sender address: only that sender
|
|---|
| 200 | port = 21213
|
|---|
| 201 | sender = lambda: (time.sleep(2), send_both(udp_socket(UN_SENDER), udp_socket(UN_OTHER),
|
|---|
| 202 | ("127.0.0.1", port)))
|
|---|
| 203 | raw, log = bnc_live(bnc, "udp_raw_sender", "//%s:%d/TEST RTCM_3 DEU 50 8 no UN" % (UN_SENDER, port),
|
|---|
| 204 | sender)
|
|---|
| 205 | good, spoof = counts(raw)
|
|---|
| 206 | check("UN with sender address: only that sender", good > 0 and spoof == 0, detail(raw, log))
|
|---|
| 207 |
|
|---|
| 208 | # 'UN' as written by the dialog (127.0.0.1): any sender, as before
|
|---|
| 209 | port = 21214
|
|---|
| 210 | sender = lambda: (time.sleep(2), send_both(udp_socket(UN_SENDER), udp_socket(UN_OTHER),
|
|---|
| 211 | ("127.0.0.1", port)))
|
|---|
| 212 | raw, log = bnc_live(bnc, "udp_raw_any", "//127.0.0.1:%d/TEST RTCM_3 DEU 50 8 no UN" % port, sender)
|
|---|
| 213 | good, spoof = counts(raw)
|
|---|
| 214 | check("UN with 127.0.0.1: any sender", good > 0 and spoof > 0, detail(raw, log))
|
|---|
| 215 |
|
|---|
| 216 | # Ntrip 1: endless response header / normal stream
|
|---|
| 217 | port = 21216
|
|---|
| 218 | raw, log = bnc_live(bnc, "ntrip1_endless", "//user:pass@%s:%d/TEST RTCM_3 DEU 50 8 no 1" % (SERVER, port),
|
|---|
| 219 | lambda: fake_ntrip1_caster(port, True))
|
|---|
| 220 | check("Ntrip 1: endless response header cut off", "response header too long" in log and not raw,
|
|---|
| 221 | "%d bytes stored" % len(raw))
|
|---|
| 222 | port = 21217
|
|---|
| 223 | raw, log = bnc_live(bnc, "ntrip1_stream", "//user:pass@%s:%d/TEST RTCM_3 DEU 50 8 no 1" % (SERVER, port),
|
|---|
| 224 | lambda: fake_ntrip1_caster(port, False))
|
|---|
| 225 | good, spoof = counts(raw)
|
|---|
| 226 | check("Ntrip 1: normal stream", good > 0, "%d GOOD blocks" % good)
|
|---|
| 227 |
|
|---|
| 228 | # Upload: rejection whose header contains "200"
|
|---|
| 229 | d = os.path.join(WORK, "upload_401")
|
|---|
| 230 | os.makedirs(os.path.join(d, "Output"))
|
|---|
| 231 | os.symlink(os.path.join(ROOT, "Example_Configs", "Input"), os.path.join(d, "Input"))
|
|---|
| 232 | fc = subprocess.Popen(
|
|---|
| 233 | [sys.executable, "-I", os.path.join(TOOLS, "fakecaster.py"), "--port", "21215",
|
|---|
| 234 | "--out", os.path.join(d, "Output", "upload.raw"), "--time", "2026-10-01T09:33:00",
|
|---|
| 235 | "--timeout", "8",
|
|---|
| 236 | "--reply", "HTTP/1.1 401 Unauthorized\\r\\nContent-Length: 200\\r\\n\\r\\n"],
|
|---|
| 237 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|---|
| 238 | fc.stdout.readline()
|
|---|
| 239 | env = dict(os.environ, QT_QPA_PLATFORM="offscreen", LC_ALL="C", TZ="UTC")
|
|---|
| 240 | with open(os.path.join(d, "bnc.out"), "wb") as out:
|
|---|
| 241 | subprocess.run([bnc, "--nw", "--platform", "offscreen",
|
|---|
| 242 | "--conf", os.path.join(ROOT, "Example_Configs", "16_Upload.bnc"),
|
|---|
| 243 | "--file", UPLOAD_RAW, "-key", "uploadMountpointsOut",
|
|---|
| 244 | "127.0.0.1,21215,CLOCK,2,user,pass,IGS20,IGS-SSR,0,,,,,,,0 byte(s),"],
|
|---|
| 245 | cwd=d, stdout=out, stderr=subprocess.STDOUT, env=env, timeout=300)
|
|---|
| 246 | caster = fc.communicate(timeout=120)[0]
|
|---|
| 247 | log = "".join(open(f, errors="replace").read() for f in glob.glob(os.path.join(d, "Output", "*.log*")))
|
|---|
| 248 | opened, broken = log.count("Connection opened"), log.count("Connection broken")
|
|---|
| 249 | check("upload: 401 with '200' in header rejected", opened == 0 and broken > 0,
|
|---|
| 250 | "%d 'opened', %d 'broken' (%s)" % (opened, broken, caster.strip().splitlines()[-1]))
|
|---|
| 251 |
|
|---|
| 252 | print("%d check(s) failed, details in %s" % (failed, os.path.relpath(WORK, ROOT)))
|
|---|
| 253 | return 1 if failed else 0
|
|---|
| 254 |
|
|---|
| 255 |
|
|---|
| 256 | if __name__ == "__main__":
|
|---|
| 257 | sys.exit(main())
|
|---|