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