aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Tom van der Lee <tom@vanderlee.io>2026-07-01 21:41:38 +0200
committerGravatar Tom van der Lee <tom@vanderlee.io>2026-07-01 21:41:38 +0200
commitd8968acab83c7a91b01eee4b35828a2b05c8dd6b (patch)
tree461fc01fcbeb8add7562c8fd48f13212a70577d5
parent12f2e24e2154113a6329d74aa556ae23506c34e1 (diff)
downloadserver-d8968acab83c7a91b01eee4b35828a2b05c8dd6b.tar.gz
server-d8968acab83c7a91b01eee4b35828a2b05c8dd6b.tar.bz2
server-d8968acab83c7a91b01eee4b35828a2b05c8dd6b.zip
Split each part into its own app
-rw-r--r--proxy/__init__.py13
-rw-r--r--proxy/endpoints.py63
-rw-r--r--proxy/utils.py7
-rw-r--r--proxy/websockets.py112
-rw-r--r--ttun_server/__init__.py23
-rw-r--r--ttun_server/endpoints.py57
-rw-r--r--ttun_server/types.py1
-rw-r--r--ttun_server/websockets.py217
8 files changed, 268 insertions, 225 deletions
diff --git a/proxy/__init__.py b/proxy/__init__.py
new file mode 100644
index 0000000..fcdb23b
--- /dev/null
+++ b/proxy/__init__.py
@@ -0,0 +1,13 @@
1from starlette.applications import Starlette
2from starlette.routing import Route, WebSocketRoute
3
4from proxy.endpoints import Proxy
5from proxy.websockets import WebsocketProxy
6
7app = Starlette(
8 debug=True,
9 routes=[
10 Route('/{path:path}', Proxy),
11 WebSocketRoute('/{path:path}', WebsocketProxy),
12 ]
13)
diff --git a/proxy/endpoints.py b/proxy/endpoints.py
new file mode 100644
index 0000000..c287a88
--- /dev/null
+++ b/proxy/endpoints.py
@@ -0,0 +1,63 @@
1import logging
2from base64 import b64encode, b64decode
3from uuid import uuid4
4
5from starlette.endpoints import HTTPEndpoint
6from starlette.requests import Request
7from starlette.responses import Response
8
9from proxy.utils import get_path_with_query_string
10from ttun_server.proxy_queue import ProxyQueue
11from ttun_server.types import HttpMessage, HttpMessageType, HttpRequestData
12
13logger = logging.getLogger(__name__)
14
15
16class HeaderMapping:
17 def __init__(self, headers: list[tuple[str, str]]):
18 self._headers = headers
19
20 def items(self):
21 for header in self._headers:
22 yield header
23
24
25class Proxy(HTTPEndpoint):
26 async def dispatch(self) -> None:
27 request = Request(self.scope, self.receive)
28
29 subdomain = request.path_params['subdomain']
30 response = Response(content='Not Found', status_code=404)
31
32 identifier = str(uuid4())
33 response_queue = await ProxyQueue.create_for_identifier(identifier)
34
35 try:
36 request_queue = await ProxyQueue.get_for_identifier(subdomain)
37
38 logger.debug('PROXY %s%s ', subdomain, request.url)
39 await request_queue.enqueue(
40 HttpMessage(
41 type=HttpMessageType.request.value,
42 identifier=identifier,
43 payload=HttpRequestData(
44 method=request.method,
45 path=get_path_with_query_string(request),
46 headers=list(request.headers.items()),
47 body=b64encode(await request.body()).decode()
48 )
49 )
50 )
51
52 _response = await response_queue.dequeue()
53 payload = _response['payload']
54 response = Response(
55 status_code=payload['status'],
56 headers=HeaderMapping(payload['headers']),
57 content=b64decode(payload['body'].encode())
58 )
59 except AssertionError:
60 pass
61 finally:
62 await response(self.scope, self.receive, self.send)
63 await response_queue.delete()
diff --git a/proxy/utils.py b/proxy/utils.py
new file mode 100644
index 0000000..2b80f43
--- /dev/null
+++ b/proxy/utils.py
@@ -0,0 +1,7 @@
1from starlette.requests import HTTPConnection
2
3
4def get_path_with_query_string(connection: HTTPConnection) -> str:
5 path = connection.url.path
6 query_string = '?' + connection.scope['query_string'].decode() if connection.scope['query_string'] else ''
7 return f"{path}{query_string}"
diff --git a/proxy/websockets.py b/proxy/websockets.py
new file mode 100644
index 0000000..ef80ec2
--- /dev/null
+++ b/proxy/websockets.py
@@ -0,0 +1,112 @@
1import asyncio
2import json
3import logging
4import typing
5from base64 import b64decode, b64encode
6from contextlib import asynccontextmanager
7from uuid import uuid4
8
9from starlette.endpoints import WebSocketEndpoint
10from starlette.websockets import WebSocket
11
12from proxy.utils import get_path_with_query_string
13from ttun_server.proxy_queue import ProxyQueue
14from ttun_server.types import WebsocketMessage, WebsocketMessageType, WebsocketConnectData, WebsocketMessageData, \
15 WebsocketDisconnectData
16
17logger = logging.getLogger(__name__)
18logger.setLevel('DEBUG')
19
20
21class WebsocketProxy(WebSocketEndpoint):
22 encoding = 'json'
23 websocket_listen_task = None
24
25 def __init__(self, *args, **kwargs):
26 super().__init__(*args, **kwargs)
27 self.id = str(uuid4())
28
29 @asynccontextmanager
30 async def proxy(self, websocket: WebSocket, message: WebsocketMessage):
31 [subdomain, *_] = websocket.url.hostname.split('.')
32
33 expect_ack = WebsocketMessageType(message['type']) == WebsocketMessageType.connect
34
35 try:
36 request_queue = await ProxyQueue.get_for_identifier(subdomain)
37 await request_queue.enqueue(message)
38
39 if expect_ack:
40 response_queue = await ProxyQueue.create_for_identifier(message["identifier"])
41 yield await response_queue.dequeue()
42 await response_queue.delete()
43 else:
44 yield
45 except AssertionError:
46 yield None
47
48 async def listen_for_messages(self, websocket: WebSocket):
49 response_queue = await ProxyQueue.create_for_identifier(self.id)
50
51 while True:
52 message: WebsocketMessage = await response_queue.dequeue()
53 logger.debug(message)
54 await websocket.send_text(b64decode(message['payload']['body'].encode()).decode())
55
56 async def on_connect(self, websocket: WebSocket) -> None:
57 message = WebsocketMessage(
58 type=WebsocketMessageType.connect.value,
59 identifier=self.id,
60 payload=WebsocketConnectData(
61 path=get_path_with_query_string(websocket),
62 headers=[
63 (k.decode(), v.decode())
64 for k, v
65 in websocket.scope['headers']
66 ],
67 )
68 )
69
70 async with self.proxy(websocket, message) as m:
71 if m is not None and WebsocketMessageType(m['type']) == WebsocketMessageType.ack:
72 await super().on_connect(websocket)
73
74 self.websocket_listen_task = asyncio.create_task(self.listen_for_messages(websocket))
75
76 def callback(*args, **kwargs):
77 self.websocket_listen_task = None
78
79 self.websocket_listen_task.add_done_callback(callback)
80
81 async def on_receive(self, websocket: WebSocket, data: typing.Any) -> None:
82 match data:
83 case dict():
84 data_bytes = json.dumps(data).encode()
85 case bytes():
86 data_bytes = data
87 case _:
88 data_bytes = data.encode()
89
90 message = WebsocketMessage(
91 type=WebsocketMessageType.message.value,
92 identifier=self.id,
93 payload=WebsocketMessageData(
94 body=b64encode(data_bytes).decode(),
95 )
96 )
97
98 async with self.proxy(websocket, message):
99 pass
100
101 async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None:
102 message = WebsocketMessage(
103 type=WebsocketMessageType.disconnect.value,
104 identifier=self.id,
105 payload=WebsocketDisconnectData(
106 close_code=close_code,
107 )
108 )
109
110 async with self.proxy(websocket, message):
111 if self.websocket_listen_task is not None:
112 self.websocket_listen_task.cancel()
diff --git a/ttun_server/__init__.py b/ttun_server/__init__.py
index 6c77858..d54227a 100644
--- a/ttun_server/__init__.py
+++ b/ttun_server/__init__.py
@@ -2,30 +2,23 @@ import logging
2import os 2import os
3 3
4from fastapi import FastAPI 4from fastapi import FastAPI
5from starlette.routing import Host, Route, Router, WebSocketRoute 5from starlette.routing import Host, Route, WebSocketRoute
6 6
7from ttun_server.endpoints import health, proxy 7from proxy import app as proxy_app
8from .websockets import WebsocketProxy, Tunnel 8from ttun_server.endpoints import health, base_endpoints
9from ttun_server.websockets import tunnel
9 10
10logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO'))) 11logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO')))
11 12
12base_router = Router(routes=[ 13app = FastAPI(
13 Route('/health/', health),
14 WebSocketRoute('/tunnel/', Tunnel)
15])
16
17server = FastAPI(
18 debug=True, 14 debug=True,
19 routes=[ 15 routes=[
20 Host(os.environ['TUNNEL_DOMAIN'], base_router, 'base'), 16 WebSocketRoute('/tunnel/', endpoint=tunnel),
21 Route('/{path:path}', proxy), 17 Route('/health/', endpoint=health),
22 WebSocketRoute('/{path:path}', WebsocketProxy) 18 Host(f'{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}', app=proxy_app)
23 ] 19 ]
24) 20)
25 21
26server.post()
27
28
29try: 22try:
30 from ._version import version 23 from ._version import version
31 __version__ = version 24 __version__ = version
diff --git a/ttun_server/endpoints.py b/ttun_server/endpoints.py
index 22dcb6d..51c17aa 100644
--- a/ttun_server/endpoints.py
+++ b/ttun_server/endpoints.py
@@ -1,54 +1,7 @@
1import logging 1from fastapi import FastAPI
2from base64 import b64decode, b64encode
3from uuid import uuid4
4 2
5from starlette.background import BackgroundTask 3base_endpoints = FastAPI()
6from starlette.requests import Request
7from starlette.responses import Response
8 4
9from ttun_server.proxy_queue import ProxyQueue 5@base_endpoints.get('/health/')
10from ttun_server.types import HttpRequestData, HttpMessageType, HttpMessage 6async def health():
11 7 return 'OK'
12logger = logging.getLogger(__name__)
13
14
15async def proxy(request: Request) -> Response:
16 [subdomain, *_] = request.headers['host'].split('.')
17 identifier = str(uuid4())
18 response_queue = await ProxyQueue.create_for_identifier(identifier)
19
20 try:
21 request_queue = await ProxyQueue.get_for_identifier(subdomain)
22
23 logger.debug('PROXY %s%s ', subdomain, request.url)
24 await request_queue.enqueue(
25 HttpMessage(
26 type=HttpMessageType.request.value,
27 identifier=identifier,
28 payload=HttpRequestData(
29 method=request.method,
30 path=str(request.url).replace(str(request.base_url), '/'),
31 headers=list(request.headers.items()),
32 body=b64encode(await request.body()).decode()
33 )
34 )
35 )
36
37 _response = await response_queue.dequeue()
38 payload = _response['payload']
39 return Response(
40 status_code=payload['status'],
41 headers=dict(payload['headers']),
42 content=b64decode(payload['body'].encode()),
43 background=BackgroundTask(response_queue.delete)
44 )
45 except AssertionError:
46 return Response(
47 content='Not Found',
48 status_code=404,
49 background=BackgroundTask(response_queue.delete)
50 )
51
52
53async def health(_: Request) -> Response:
54 return Response(content='OK', status_code=200)
diff --git a/ttun_server/types.py b/ttun_server/types.py
index 8591e7d..a0c22a3 100644
--- a/ttun_server/types.py
+++ b/ttun_server/types.py
@@ -9,6 +9,7 @@ class HttpMessageType(Enum):
9 9
10 10
11class Config(TypedDict): 11class Config(TypedDict):
12 version: str
12 subdomain: str 13 subdomain: str
13 client_version: str 14 client_version: str
14 15
diff --git a/ttun_server/websockets.py b/ttun_server/websockets.py
index e828b8d..ea9ae94 100644
--- a/ttun_server/websockets.py
+++ b/ttun_server/websockets.py
@@ -1,189 +1,90 @@
1import asyncio 1import asyncio
2import json
3import logging 2import logging
4import os 3import os
5import typing
6from asyncio import create_task
7from base64 import b64encode, b64decode
8from contextlib import asynccontextmanager
9from typing import Optional
10from uuid import uuid4 4from uuid import uuid4
11 5
12from starlette.endpoints import WebSocketEndpoint 6from fastapi import WebSocket, WebSocketDisconnect
13from starlette.types import Scope, Receive, Send
14from starlette.websockets import WebSocket
15 7
16import ttun_server 8import ttun_server
17from ttun_server.proxy_queue import ProxyQueue 9from ttun_server.proxy_queue import ProxyQueue
18from ttun_server.types import Config, Message, WebsocketMessageType, \ 10from ttun_server.types import (
19 WebsocketConnectData, WebsocketMessage, WebsocketMessageData, WebsocketDisconnectData, MessageType 11 Config,
12 Message,
13 MessageType,
14)
20 15
21logger = logging.getLogger(__name__) 16logger = logging.getLogger(__name__)
22logger.setLevel('DEBUG') 17logger.setLevel('DEBUG')
23 18
24class WebsocketProxy(WebSocketEndpoint): 19async def assert_compatible_version(websocket: WebSocket, config: Config) -> None:
25 encoding = 'json' 20 client_version = config.get('version', '1.0.0')
26 websocket_listen_task = None 21 logger.debug('client_version %s', client_version)
27 22
28 def __init__(self, *args, **kwargs): 23 if 'git' not in client_version and ttun_server.__version__ != 'development':
29 super().__init__(*args, **kwargs) 24 [client_major, *_] = [int(i) for i in client_version.split('.')[:3]]
30 self.id = str(uuid4()) 25 [server_major, *_] = [int(i) for i in ttun_server.__version__.split('.')]
31 26
32 @asynccontextmanager 27 if client_major < server_major:
33 async def proxy(self, websocket: WebSocket, message: WebsocketMessage): 28 await websocket.close(4000, 'Your client is too old')
34 [subdomain, *_] = websocket.url.hostname.split('.')
35 29
36 expect_ack = WebsocketMessageType(message['type']) == WebsocketMessageType.connect 30 if client_major > server_major:
31 await websocket.close(4001, 'Your client is too new')
37 32
38 try:
39 request_queue = await ProxyQueue.get_for_identifier(subdomain)
40 await request_queue.enqueue(message)
41 33
42 if expect_ack: 34async def tunnel(websocket: WebSocket) -> None:
43 response_queue = await ProxyQueue.create_for_identifier(message["identifier"]) 35 request_tasks: dict[str, asyncio.Task] = {}
44 yield await response_queue.dequeue() 36 proxy_queues: dict[str, ProxyQueue] = {}
45 await response_queue.delete()
46 else:
47 yield
48 except AssertionError:
49 yield None
50 37
51 async def listen_for_messages(self, websocket: WebSocket): 38 await websocket.accept()
52 response_queue = await ProxyQueue.create_for_identifier(self.id) 39 config: Config = await websocket.receive_json()
53 40
54 while True: 41 await assert_compatible_version(websocket, config)
55 message: WebsocketMessage = await response_queue.dequeue()
56 logger.debug(message)
57 await websocket.send_text(b64decode(message['payload']['body'].encode()).decode())
58
59 async def on_connect(self, websocket: WebSocket) -> None:
60 message = WebsocketMessage(
61 type=WebsocketMessageType.connect.value,
62 identifier=self.id,
63 payload=WebsocketConnectData(
64 path=websocket.path_params['path'],
65 headers=[
66 (k.decode(), v.decode())
67 for k, v
68 in websocket.scope['headers']
69 ],
70 )
71 )
72 42
73 async with self.proxy(websocket, message) as m: 43 if 'subdomains' not in config:
74 if m is not None and WebsocketMessageType(m['type']) == WebsocketMessageType.ack: 44 config['subdomains'] = [config['subdomain']]
75 await super().on_connect(websocket) 45 elif config['subdomains'] is None:
46 config['subdomains'] = [None]
76 47
77 self.websocket_listen_task = asyncio.create_task(self.listen_for_messages(websocket)) 48 for i, subdomain in enumerate(config['subdomains']):
49 if subdomain is None or await ProxyQueue.has_connection(subdomain):
50 config['subdomains'][i] = uuid4().hex
78 51
79 def callback(*args, **kwargs): 52 for subdomain in config['subdomains']:
80 self.websocket_listen_task = None 53 proxy_queues[subdomain] = await ProxyQueue.create_for_identifier(subdomain)
81 54
82 self.websocket_listen_task.add_done_callback(callback) 55 hostname = os.environ.get('TUNNEL_DOMAIN')
56 protocol = 'https' if os.environ.get('SECURE', False) else 'http'
83 57
84 async def on_receive(self, websocket: WebSocket, data: typing.Any) -> None: 58 urls = [
85 match data: 59 f'{protocol}://{subdomain}.{hostname}'
86 case dict(): 60 for subdomain in config['subdomains']
87 data_bytes = json.dumps(data).encode() 61 ]
88 case bytes():
89 data_bytes = data
90 case _:
91 data_bytes = data.encode()
92 62
93 message = WebsocketMessage( 63 await websocket.send_json({
94 type=WebsocketMessageType.message.value, 64 'url': urls[0],
95 identifier=self.id, 65 'urls': urls,
96 payload=WebsocketMessageData( 66 })
97 body=b64encode(data_bytes).decode(),
98 )
99 )
100 67
101 async with self.proxy(websocket, message): 68 async def handle_requests(subdomain: str) -> None:
102 pass 69 while request := await proxy_queues[subdomain].dequeue():
70 asyncio.create_task(websocket.send_json(request), name=request['identifier'])
103 71
104 async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None: 72 for subdomain in config['subdomains']:
105 message = WebsocketMessage( 73 request_tasks[subdomain] = asyncio.create_task(
106 type=WebsocketMessageType.disconnect.value, 74 handle_requests(subdomain), name=subdomain
107 identifier=self.id,
108 payload=WebsocketDisconnectData(
109 close_code=close_code,
110 )
111 ) 75 )
112 76
113 async with self.proxy(websocket, message): 77 try:
114 if self.websocket_listen_task is not None: 78 while True:
115 self.websocket_listen_task.cancel() 79 data: Message = await websocket.receive_json()
116 80 try:
117class Tunnel(WebSocketEndpoint): 81 data['type'] = MessageType(data['type']).value
118 encoding = 'json' 82 response_queue = await ProxyQueue.get_for_identifier(data['identifier'])
119 83 await response_queue.enqueue(data)
120 def __init__(self, scope: Scope, receive: Receive, send: Send): 84 except AssertionError:
121 super().__init__(scope, receive, send) 85 pass
122 self.request_tasks: dict[str, asyncio.Task] = {} 86 except WebSocketDisconnect:
123 self.config: Optional[Config] = None 87 for proxy_queue in proxy_queues.values():
124 self.proxy_queues: dict[str, ProxyQueue] = {}
125
126 async def handle_requests(self, websocket: WebSocket, subdomain: str):
127 while request := await self.proxy_queues[subdomain].dequeue():
128 task = asyncio.create_task(websocket.send_json(request), name=request['identifier'])
129
130
131 async def on_connect(self, websocket: WebSocket) -> None:
132 await websocket.accept()
133 self.config = await websocket.receive_json()
134
135 client_version = self.config.get('version', '1.0.0')
136 logger.debug('client_version %s', client_version)
137
138 if 'git' not in client_version and ttun_server.__version__ != 'development':
139 [client_major, *_] = [int(i) for i in client_version.split('.')[:3]]
140 [server_major, *_] = [int(i) for i in ttun_server.__version__.split('.')]
141
142 if client_major < server_major:
143 await websocket.close(4000, 'Your client is too old')
144
145 if client_major > server_major:
146 await websocket.close(4001, 'Your client is too new')
147
148 if 'subdomains' not in self.config:
149 self.config['subdomains'] = [self.config['subdomain']]
150 elif self.config['subdomains'] is None:
151 self.config['subdomains'] = [None]
152
153 for i, subdomain in enumerate(self.config['subdomains']):
154 if subdomain is None or await ProxyQueue.has_connection(subdomain):
155 self.config['subdomains'][i] = uuid4().hex
156
157 for subdomain in self.config['subdomains']:
158 self.proxy_queues[subdomain] = await ProxyQueue.create_for_identifier(subdomain)
159
160 hostname = os.environ.get("TUNNEL_DOMAIN")
161 protocol = "https" if os.environ.get("SECURE", False) else "http"
162
163 urls = [
164 f'{protocol}://{subdomain}.{hostname}'
165 for subdomain in self.config['subdomains']
166 ]
167
168 await websocket.send_json({
169 'url': urls[0],
170 'urls': urls,
171 })
172
173 for subdomain in self.config['subdomains']:
174 self.request_tasks[subdomain] = asyncio.create_task(self.handle_requests(websocket, subdomain), name=subdomain)
175
176 async def on_receive(self, websocket: WebSocket, data: Message):
177 try:
178 data['type'] = MessageType(data['type']).value
179 response_queue = await ProxyQueue.get_for_identifier(data['identifier'])
180 await response_queue.enqueue(data)
181 except AssertionError:
182 pass
183
184 async def on_disconnect(self, websocket: WebSocket, close_code: int):
185 for proxy_queue in self.proxy_queues.values():
186 await proxy_queue.delete() 88 await proxy_queue.delete()
187 89 for request_task in request_tasks.values():
188 for request_task in self.request_tasks.values():
189 request_task.cancel() 90 request_task.cancel()