Add basic pledge/unveil sandbox on OpenBSD
[tinc] / test / integration / proxy.py
1 #!/usr/bin/env python3
2
3 """Test that tincd works through proxies."""
4
5 import os
6 import re
7 import time
8 import typing as T
9 import multiprocessing.connection as mp
10 import logging
11 import select
12 import socket
13 import struct
14
15 from threading import Thread
16 from socketserver import ThreadingMixIn, TCPServer, StreamRequestHandler
17 from testlib import check, cmd, path, util
18 from testlib.proc import Tinc, Script
19 from testlib.test import Test
20 from testlib.util import random_string
21 from testlib.log import log
22 from testlib.feature import HAVE_SANDBOX
23
24 USERNAME = random_string(8)
25 PASSWORD = random_string(8)
26
27 proxy_stats = {"tx": 0}
28
29 # socks4
30 SOCKS_VERSION_4 = 4
31 CMD_STREAM = 1
32 REQUEST_GRANTED = 0x5A
33
34 # socks5
35 SOCKS_VERSION_5 = 5
36 METHOD_NONE = 0
37 METHOD_USERNAME_PASSWORD = 2
38 NO_METHODS = 0xFF
39 ADDR_TYPE_IPV4 = 1
40 ADDR_TYPE_DOMAIN = 3
41 CMD_CONNECT = 1
42 REP_SUCCESS = 0
43 RESERVED = 0
44 AUTH_OK = 0
45 AUTH_FAILURE = 0xFF
46
47
48 def send_all(sock: socket.socket, data: bytes) -> bool:
49     """Send all data to socket, retrying as necessary."""
50
51     total = 0
52
53     while total < len(data):
54         sent = sock.send(data[total:])
55         if sent <= 0:
56             break
57         total += sent
58
59     return total == len(data)
60
61
62 def proxy_data(client: socket.socket, remote: socket.socket) -> None:
63     """Pipe data between the two sockets."""
64
65     while True:
66         read, _, _ = select.select([client, remote], [], [])
67
68         if client in read:
69             data = client.recv(4096)
70             proxy_stats["tx"] += len(data)
71             log.debug("received from client: '%s'", data)
72             if not data or not send_all(remote, data):
73                 log.info("remote finished")
74                 return
75
76         if remote in read:
77             data = remote.recv(4096)
78             proxy_stats["tx"] += len(data)
79             log.debug("sending to client: '%s'", data)
80             if not data or not send_all(client, data):
81                 log.info("client finished")
82                 return
83
84
85 def error_response(address_type: int, error: int) -> bytes:
86     """Create error response for SOCKS client."""
87     return struct.pack("!BBBBIH", SOCKS_VERSION_5, error, 0, address_type, 0, 0)
88
89
90 def read_ipv4(sock: socket.socket) -> str:
91     """Read IPv4 address from socket and convert it into a string."""
92     ip_addr = sock.recv(4)
93     return socket.inet_ntoa(ip_addr)
94
95
96 def ip_to_int(addr: str) -> int:
97     """Convert address to integer."""
98     return struct.unpack("!I", socket.inet_aton(addr))[0]
99
100
101 def addr_response(address, port: T.Tuple[str, int]) -> bytes:
102     """Create address response. Format:
103     version    rep    rsv    atyp    bind_addr    bind_port
104     """
105     return struct.pack(
106         "!BBBBIH",
107         SOCKS_VERSION_5,
108         REP_SUCCESS,
109         RESERVED,
110         ADDR_TYPE_IPV4,
111         ip_to_int(address),
112         port,
113     )
114
115
116 class ProxyServer(StreamRequestHandler):
117     """Parent class for proxy server implementations."""
118
119     name: T.ClassVar[str] = ""
120
121
122 class ThreadingTCPServer(ThreadingMixIn, TCPServer):
123     """TCPServer which handles each request in a separate thread."""
124
125
126 class HttpProxy(ProxyServer):
127     """HTTP proxy server that handles CONNECT requests."""
128
129     name = "http"
130     _re = re.compile(r"CONNECT ([^:]+):(\d+) HTTP/1\.[01]")
131
132     def handle(self) -> None:
133         try:
134             self._handle_connection()
135         finally:
136             self.server.close_request(self.request)
137
138     def _handle_connection(self) -> None:
139         """Handle a single proxy connection"""
140         data = b""
141         while not data.endswith(b"\r\n\r\n"):
142             data += self.connection.recv(1)
143         log.info("got request: '%s'", data)
144
145         match = self._re.match(data.decode("utf-8"))
146         assert match
147
148         address, port = match.groups()
149         log.info("matched target address %s:%s", address, port)
150
151         with socket.socket() as sock:
152             sock.connect((address, int(port)))
153             log.info("connected to target")
154
155             self.connection.sendall(b"HTTP/1.1 200 OK\r\n\r\n")
156             log.info("sent successful response")
157
158             proxy_data(self.connection, sock)
159
160
161 class Socks4Proxy(ProxyServer):
162     """SOCKS 4 proxy server."""
163
164     name = "socks4"
165     username = USERNAME
166
167     def handle(self) -> None:
168         try:
169             self._handle_connection()
170         finally:
171             self.server.close_request(self.request)
172
173     def _handle_connection(self) -> None:
174         """Handle a single proxy connection."""
175
176         version, command, port = struct.unpack("!BBH", self.connection.recv(4))
177         check.equals(SOCKS_VERSION_4, version)
178         check.equals(command, CMD_STREAM)
179         check.port(port)
180
181         addr = read_ipv4(self.connection)
182         log.info("received address %s:%d", addr, port)
183
184         user = ""
185         while True:
186             byte = self.connection.recv(1)
187             if byte == b"\0":
188                 break
189             user += byte.decode("utf-8")
190
191         log.info("received username %s", user)
192         self._check_username(user)
193
194         with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as remote:
195             remote.connect((addr, port))
196             logging.info("connected to %s:%s", addr, port)
197             self._process_remote(remote)
198
199     def _check_username(self, user: str) -> bool:
200         """Authenticate by comparing socks4 username."""
201         return user == self.username
202
203     def _process_remote(self, sock: socket.socket) -> None:
204         """Process a single proxy connection."""
205
206         addr, port = sock.getsockname()
207         reply = struct.pack("!BBHI", 0, REQUEST_GRANTED, port, ip_to_int(addr))
208         log.info("sending reply %s", reply)
209         self.connection.sendall(reply)
210
211         proxy_data(self.connection, sock)
212
213
214 class AnonymousSocks4Proxy(Socks4Proxy):
215     """socks4 server without any authentication."""
216
217     def _check_username(self, user: str) -> bool:
218         return True
219
220
221 class Socks5Proxy(ProxyServer):
222     """SOCKS 5 proxy server."""
223
224     name = "socks5"
225
226     def handle(self) -> None:
227         """Handle a proxy connection."""
228         try:
229             self._process_connection()
230         finally:
231             self.server.close_request(self.request)
232
233     def _process_connection(self) -> None:
234         """Handle a proxy connection."""
235
236         methods = self._read_header()
237         if not self._authenticate(methods):
238             raise RuntimeError("authentication failed")
239
240         command, address_type = self._read_command()
241         address = self._read_address(address_type)
242         port = struct.unpack("!H", self.connection.recv(2))[0]
243         log.info("got address %s:%d", address, port)
244
245         if command != CMD_CONNECT:
246             raise RuntimeError(f"bad command {command}")
247
248         try:
249             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as remote:
250                 remote.connect((address, port))
251                 bind_address = remote.getsockname()
252                 logging.info("connected to %s:%d", address, port)
253
254                 reply = addr_response(*bind_address)
255                 log.debug("sending address '%s'", reply)
256                 self.connection.sendall(reply)
257
258                 proxy_data(self.connection, remote)
259         except OSError as ex:
260             log.error("socks server failed", exc_info=ex)
261             reply = error_response(address_type, 5)
262             self.connection.sendall(reply)
263             raise ex
264
265     def _read_address(self, address_type: int) -> str:
266         """Read target address."""
267
268         if address_type == ADDR_TYPE_IPV4:
269             return read_ipv4(self.connection)
270
271         if address_type == ADDR_TYPE_DOMAIN:
272             domain_len = self.connection.recv(1)[0]
273             domain = self.connection.recv(domain_len)
274             return socket.gethostbyname(domain.decode())
275
276         raise RuntimeError(f"unknown address type {address_type}")
277
278     def _read_command(self) -> T.Tuple[int, int]:
279         """Check protocol version and get command code and address type."""
280
281         version, command, _, address_type = struct.unpack(
282             "!BBBB", self.connection.recv(4)
283         )
284         check.equals(SOCKS_VERSION_5, version)
285         return command, address_type
286
287     @property
288     def _method(self) -> int:
289         """Supported authentication method."""
290         return METHOD_USERNAME_PASSWORD
291
292     def _authenticate(self, methods: T.List[int]) -> bool:
293         """Perform client authentication."""
294
295         found = self._method in methods
296         choice = self._method if found else NO_METHODS
297         result = struct.pack("!BB", SOCKS_VERSION_5, choice)
298
299         log.debug("sending authentication result '%s'", result)
300         self.connection.sendall(result)
301
302         if not found:
303             log.error("auth method not found in %s", methods)
304             return False
305
306         if not self._read_creds():
307             log.error("could not verify credentials")
308             return False
309
310         return True
311
312     def _read_header(self) -> T.List[int]:
313         """Get the list of methods supported by the client."""
314
315         version, methods = struct.unpack("!BB", self.connection.recv(2))
316         check.equals(SOCKS_VERSION_5, version)
317         check.greater(methods, 0)
318         return [ord(self.connection.recv(1)) for _ in range(methods)]
319
320     def _read_creds(self) -> bool:
321         """Read and verify auth credentials."""
322
323         version = ord(self.connection.recv(1))
324         check.equals(1, version)
325
326         user_len = ord(self.connection.recv(1))
327         user = self.connection.recv(user_len).decode("utf-8")
328
329         passw_len = ord(self.connection.recv(1))
330         passw = self.connection.recv(passw_len).decode("utf-8")
331
332         log.info("got credentials '%s', '%s'", user, passw)
333         log.info("want credentials '%s', '%s'", USERNAME, PASSWORD)
334
335         passed = user == USERNAME and passw == PASSWORD
336         response = struct.pack("!BB", version, AUTH_OK if passed else AUTH_FAILURE)
337         self.connection.sendall(response)
338
339         return passed
340
341
342 class AnonymousSocks5Proxy(Socks5Proxy):
343     """SOCKS 5 server without authentication support."""
344
345     @property
346     def _method(self) -> int:
347         return METHOD_NONE
348
349     def _read_creds(self) -> bool:
350         return True
351
352
353 def init(ctx: Test) -> T.Tuple[Tinc, Tinc]:
354     """Create a new tinc node."""
355
356     foo, bar = ctx.node(), ctx.node()
357     stdin = f"""
358         init {foo}
359         set Address 127.0.0.1
360         set Port 0
361         set DeviceType dummy
362     """
363     foo.cmd(stdin=stdin)
364
365     stdin = f"""
366         init {bar}
367         set Address 127.0.0.1
368         set Port 0
369         set DeviceType dummy
370     """
371     bar.cmd(stdin=stdin)
372
373     return foo, bar
374
375
376 def create_exec_proxy(port: int) -> str:
377     """Create a fake exec proxy program."""
378
379     code = f"""
380 import os
381 import multiprocessing.connection as mp
382
383 with mp.Client(("127.0.0.1", {port}), family="AF_INET") as client:
384     client.send({{ **os.environ }})
385 """
386     return util.temp_file(code)
387
388
389 def test_proxy(ctx: Test, handler: T.Type[ProxyServer], user="", passw="") -> None:
390     """Test socks proxy support."""
391
392     foo, bar = init(ctx)
393
394     if HAVE_SANDBOX:
395         for node in foo, bar:
396             node.cmd("set", "Sandbox", "high")
397
398     bar.add_script(Script.TINC_UP)
399     bar.start()
400
401     cmd.exchange(foo, bar)
402     foo.cmd("set", f"{bar}.Port", str(bar.port))
403
404     with ThreadingTCPServer(("127.0.0.1", 0), handler) as server:
405         _, port = server.server_address
406
407         worker = Thread(target=server.serve_forever)
408         worker.start()
409
410         foo.cmd("set", "Proxy", handler.name, f"127.0.0.1 {port} {user} {passw}")
411
412         foo.add_script(Script.TINC_UP)
413         foo.cmd("start")
414         foo[Script.TINC_UP].wait()
415         time.sleep(1)
416
417         foo.cmd("stop")
418         bar.cmd("stop")
419
420         server.shutdown()
421         worker.join()
422
423
424 def test_proxy_exec(ctx: Test) -> None:
425     """Test that exec proxies work as expected."""
426     foo, bar = init(ctx)
427
428     log.info("exec proxy without arguments fails")
429     foo.cmd("set", "Proxy", "exec")
430     _, stderr = foo.cmd("start", code=1)
431     check.is_in("Argument expected for proxy type", stderr)
432
433     log.info("exec proxy with correct arguments works")
434     bar.cmd("start")
435     cmd.exchange(foo, bar)
436
437     with mp.Listener(("127.0.0.1", 0), family="AF_INET") as listener:
438         port = int(listener.address[1])
439         proxy = create_exec_proxy(port)
440
441         foo.cmd("set", "Proxy", "exec", f"{path.PYTHON_INTERPRETER} {proxy}")
442         foo.cmd("start")
443
444         with listener.accept() as conn:
445             env: T.Dict[str, str] = conn.recv()
446
447             for var in "NAME", "REMOTEADDRESS", "REMOTEPORT":
448                 check.true(env.get(var))
449
450             for var in "NODE", "NETNAME":
451                 if var in env:
452                     check.true(env[var])
453
454         os.remove(proxy)
455
456
457 if os.name != "nt":
458     with Test("exec proxy") as context:
459         test_proxy_exec(context)
460
461 with Test("HTTP CONNECT proxy") as context:
462     proxy_stats["tx"] = 0
463     test_proxy(context, HttpProxy)
464     check.greater(proxy_stats["tx"], 0)
465
466 with Test("socks4 proxy with username") as context:
467     proxy_stats["tx"] = 0
468     test_proxy(context, Socks4Proxy, USERNAME)
469     check.greater(proxy_stats["tx"], 0)
470
471 with Test("anonymous socks4 proxy") as context:
472     proxy_stats["tx"] = 0
473     test_proxy(context, AnonymousSocks4Proxy)
474     check.greater(proxy_stats["tx"], 0)
475
476 with Test("authenticated socks5 proxy") as context:
477     proxy_stats["tx"] = 0
478     test_proxy(context, Socks5Proxy, USERNAME, PASSWORD)
479     check.greater(proxy_stats["tx"], 0)
480
481 with Test("anonymous socks5 proxy") as context:
482     proxy_stats["tx"] = 0
483     test_proxy(context, AnonymousSocks5Proxy)
484     check.greater(proxy_stats["tx"], 0)