source: ntrip/trunk/BNC/tests/tools/net_check.py

Last change on this file was 11131, checked in by stuerze, 3 days ago

test suite extended regardiing security aspects and new ssr qc check

  • Property svn:executable set to *
File size: 10.3 KB
Line 
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
22import argparse
23import glob
24import os
25import re
26import shutil
27import socket
28import subprocess
29import sys
30import threading
31import time
32
33TOOLS = os.path.dirname(os.path.abspath(__file__))
34TESTS = os.path.dirname(TOOLS)
35ROOT = os.path.dirname(TESTS)
36WORK = os.path.join(TESTS, "work", "net_check")
37UPLOAD_RAW = os.path.join(TESTS, "data", "upload_rtnet_5min.raw")
38SERVER, SPOOFER, UN_SENDER, UN_OTHER = "127.0.0.3", "127.0.0.4", "127.0.0.5", "127.0.0.6"
39DURATION = 8.0 # seconds of data
40RTP_HEADER = bytes([128, 96]) + bytes(10)
41
42
43def udp_socket(addr):
44 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
45 s.bind((addr, 0))
46 return s
47
48
49def 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
58def 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
71def 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
105def 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
135def 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
161def 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
256if __name__ == "__main__":
257 sys.exit(main())
Note: See TracBrowser for help on using the repository browser.