diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index c4d9b3e..ca9fcbf 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -16,6 +16,7 @@ - [Exposing your first service](./exposing_your_first_service.md) - [Local forwarding](./local_forwarding.md) - [Custom domains](./custom_domains.md) +- [UDP-over-TCP](./udp_over_tcp.md) - [Advanced options](./advanced_options.md) # Reference diff --git a/book/src/exposing_your_first_service.md b/book/src/exposing_your_first_service.md index 5067e65..f7e6d46 100644 --- a/book/src/exposing_your_first_service.md +++ b/book/src/exposing_your_first_service.md @@ -32,7 +32,7 @@ ssh -i /your/private/key -p 2222 -R test:80:localhost:3000 sandhole.com.br ssh -i /your/private/key -p 2222 -R test.sandhole.com.br:80:localhost:3000 sandhole.com.br ``` -And if we'd like to bind to a specific port, say 4321: +And if we'd like to bind to a specific TCP port, say 4321: ```bash ssh -i /your/private/key -p 2222 -R 4321:localhost:3000 sandhole.com.br diff --git a/book/src/faq.md b/book/src/faq.md index 9f5327a..0a08120 100644 --- a/book/src/faq.md +++ b/book/src/faq.md @@ -10,7 +10,7 @@ ssh -p 2222 -R example.com:80:localhost:3000 -R www.example.com:80:localhost:300 Use `ssh -p 2222 -J sandhole.com.br:2222 mysshserver.com` (replace the ports with Sandhole's SSH port if not using the default `2222`). -If you'd like to avoid typing out the proxy jump command every time, make sure to edit your SSH config file (usually `~/.ssh/config`) and add the following entry (changing the port where appropriate): +If you'd like to avoid typing out the proxy jump command every time, edit your SSH config file (usually `~/.ssh/config`) and add the following entry (changing the port where appropriate): ```ssh-config Host mysshserver.com @@ -24,7 +24,7 @@ Websockets are always enabled for HTTP services. ## Can I expose UDP services (like HTTP/3)? -No. SSH remote forwarding only supports TCP. +See [UDP-over-TCP](./udp_over_tcp.md). ## How do I retrieve proxy information for my HTTP service? diff --git a/book/src/udp_over_tcp.md b/book/src/udp_over_tcp.md new file mode 100644 index 0000000..0afb5af --- /dev/null +++ b/book/src/udp_over_tcp.md @@ -0,0 +1,30 @@ +# UDP-over-TCP + +Sandhole has experimental support for UDP over SSH, with a thin TCP-based protocol. + +Provided that the Sandhole instance that you wish to connect to has UDP enabled, the quickest way to get UDP running is with the [`udp_over_tcp.py` client provided in the Sandhole repository](https://github.com/EpicEric/sandhole/blob/main/udp_over_tcp.py): + +```bash +wget https://raw.githubusercontent.com/EpicEric/sandhole/refs/heads/main/udp_over_tcp.py +python3 udp_over_tcp.py --udp-port 12345 --tcp-port 6789 +``` + +This will create a TCP server listening on port 6789 which proxies UDP-over-TCP data to port 12345. + +In order to create an UDP socket on port 9999 of Sandhole, use the reserved `udp.sandhole` remote host: + +```bash +ssh -p 2222 -R udp.sandhole:9999:localhost:6789 sandhole.com.br +``` + +Make sure that you're pointing to the local TCP port created from teh script above. + +## Limitations + +Common issues associated with UDP-over-TCP (increased latency and jitter, TCP Meltdown) apply to Sandhole as well. + +## Technical details + +Since UDP is a protocol based on datagrams, the only extra information added by the translation layer is the number of bytes in the datagram, to ensure that it's reassembled correctly on both ends even if TCP splits or merges data. + +State is handled by associating each UDP socket with an SSH forwarding channel. As such, a compatible client can translate TCP listeners to UDP socket connections one-to-one. diff --git a/udp_over_tcp.py b/udp_over_tcp.py new file mode 100644 index 0000000..1e67765 --- /dev/null +++ b/udp_over_tcp.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +import argparse +import asyncio + + +class UdpClientProtocol(asyncio.Protocol): + def __init__(self, tcp_transport): + self.tcp_transport = tcp_transport + + def datagram_received(self, data, addr): + # Add datagram size to start of data + data_len = bytes((len(data) >> 8, len(data) & 0xFF)) + # Send to the connected TCP socket + self.tcp_transport.writelines((data_len, data)) + + +class TcpProxyProtocol(asyncio.Protocol): + def __init__(self, udp_address, udp_port): + self.udp_address = udp_address + self.udp_port = udp_port + self.task = None + self.tcp_transport = None + self.udp_transport = None + self.data_len = (0, 0) + self.data = b"" + self.buffered_data = [] + self.on_connection_lost = asyncio.get_running_loop().create_future() + + def connection_made(self, transport): + self.tcp_transport = transport + loop = asyncio.get_running_loop() + + # Create an UDP socket that's connected to the TCP proxy + async def connect_to_udp(self): + udp_transport, _ = await loop.create_datagram_endpoint( + lambda: UdpClientProtocol(self.tcp_transport), + remote_addr=(self.udp_address, self.udp_port), + ) + self.udp_transport = udp_transport + + for buffered in self.buffered_data: + self.udp_transport.sendto(buffered) + self.buffered_data = [] + + try: + await self.on_connection_lost + finally: + udp_transport.close() + + self.task = loop.create_task(connect_to_udp(self)) + + def data_received(self, data): + # Re-assemble datagrams from TCP data + while data: + # Compute datagram length + if self.data_len[0] < 2: + self.data_len = ( + self.data_len[0] + 1, + (self.data_len[1] << 8) + data[0], + ) + data = data[1:] + continue + + # Consume data to fill the datagram + data_to_take = min(self.data_len[1] - len(self.data), len(data)) + self.data += data[:data_to_take] + data = data[data_to_take:] + + # Check whether we have a full datagram + if len(self.data) == self.data_len[1]: + if self.udp_transport: + self.udp_transport.sendto(self.data) + else: + # UDP is not connected yet; save to buffer + self.buffered_data.append(self.data) + self.data_len = (0, 0) + self.data = b"" + + def connection_lost(self, exc): + self.on_connection_lost.set_result(True) + + +async def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--udp-address", default="127.0.0.1", help="UDP address to proxy" + ) + parser.add_argument("--udp-port", type=int, help="UDP port to proxy") + parser.add_argument("--tcp-address", default="::1", help="TCP address to bind to") + parser.add_argument("--tcp-port", type=int, help="TCP port to bind to") + args = parser.parse_args() + + # Start TCP server + server = await asyncio.get_running_loop().create_server( + lambda: TcpProxyProtocol(args.udp_address, args.udp_port), + args.tcp_address, + args.tcp_port, + ) + async with server: + await server.serve_forever() + + +if __name__ == "__main__": + asyncio.run(main())