aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--ttun/__main__.py11
-rw-r--r--ttun/client.py214
-rw-r--r--ttun/inspect_server.py5
-rw-r--r--ttun/pubsub.py1
-rw-r--r--ttun/types.py60
5 files changed, 233 insertions, 58 deletions
diff --git a/ttun/__main__.py b/ttun/__main__.py
index 4e693fb..dd83e53 100644
--- a/ttun/__main__.py
+++ b/ttun/__main__.py
@@ -1,21 +1,23 @@
1import asyncio 1import asyncio
2import logging
3import os
2import re 4import re
3import time
4from argparse import ArgumentDefaultsHelpFormatter 5from argparse import ArgumentDefaultsHelpFormatter
5from argparse import ArgumentParser 6from argparse import ArgumentParser
6from asyncio import FIRST_EXCEPTION 7from asyncio import FIRST_EXCEPTION
7from asyncio.exceptions import CancelledError 8from asyncio.exceptions import CancelledError
8from asyncio.exceptions import TimeoutError 9from asyncio.exceptions import TimeoutError
9from typing import Dict
10from typing import Tuple 10from typing import Tuple
11 11
12from websockets.exceptions import ConnectionClosedError
13
14from ttun.client import Client 12from ttun.client import Client
15from ttun.inspect_server import Server 13from ttun.inspect_server import Server
16from ttun.settings import SERVER_HOSTNAME 14from ttun.settings import SERVER_HOSTNAME
17from ttun.settings import SERVER_USING_SSL 15from ttun.settings import SERVER_USING_SSL
18 16
17logging.basicConfig(encoding="utf-8")
18logging.getLogger("asyncio").setLevel(os.environ.get("LOGGING_LEVEL", "NOTSET"))
19logging.getLogger("websockets").setLevel(os.environ.get("LOGGING_LEVEL", "NOTSET"))
20
19inspect_queue = asyncio.Queue() 21inspect_queue = asyncio.Queue()
20 22
21 23
@@ -73,7 +75,6 @@ def main():
73 headers=args.header, 75 headers=args.header,
74 ) 76 )
75 77
76
77 try: 78 try:
78 loop = asyncio.get_running_loop() 79 loop = asyncio.get_running_loop()
79 except RuntimeError: 80 except RuntimeError:
diff --git a/ttun/client.py b/ttun/client.py
index a75c882..b19bb47 100644
--- a/ttun/client.py
+++ b/ttun/client.py
@@ -1,5 +1,8 @@
1import asyncio 1import asyncio
2import json 2import json
3import logging
4import os
5import sys
3from asyncio import get_running_loop 6from asyncio import get_running_loop
4from base64 import b64decode 7from base64 import b64decode
5from base64 import b64encode 8from base64 import b64encode
@@ -24,10 +27,18 @@ from websockets.exceptions import ConnectionClosed
24from ttun import __version__ 27from ttun import __version__
25from ttun.pubsub import PubSub 28from ttun.pubsub import PubSub
26from ttun.types import Config 29from ttun.types import Config
30from ttun.types import HttpMessage
31from ttun.types import HttpMessageType
32from ttun.types import HttpRequestData
33from ttun.types import HttpResponseData
27from ttun.types import Message 34from ttun.types import Message
28from ttun.types import MessageType 35from ttun.types import MessageType
29from ttun.types import RequestData 36from ttun.types import WebsocketMessage
30from ttun.types import ResponseData 37from ttun.types import WebsocketMessageData
38from ttun.types import WebsocketMessageType
39
40logger = logging.getLogger(__name__)
41logger.setLevel(os.environ.get("LOGGING_LEVEL", "NOTSET"))
31 42
32 43
33class Client: 44class Client:
@@ -48,9 +59,12 @@ class Client:
48 self.connection: WebSocketClientProtocol = None 59 self.connection: WebSocketClientProtocol = None
49 60
50 self.proxy_origin = f'{"https" if https else "http"}://{to}:{port}' 61 self.proxy_origin = f'{"https" if https else "http"}://{to}:{port}'
62 self.ws_proxy_origin = f'{"wss" if https else "ws"}://{to}:{port}'
51 63
52 self.headers = [] if headers is None else headers 64 self.headers = [] if headers is None else headers
53 65
66 self.websocket_connections = {}
67
54 async def send(self, data: dict): 68 async def send(self, data: dict):
55 await self.connection.send(json.dumps(data)) 69 await self.connection.send(json.dumps(data))
56 70
@@ -86,63 +100,181 @@ class Client:
86 def session(self): 100 def session(self):
87 return ClientSession(base_url=self.proxy_origin, cookie_jar=DummyCookieJar()) 101 return ClientSession(base_url=self.proxy_origin, cookie_jar=DummyCookieJar())
88 102
103 async def handle_request(self, message: HttpMessage, session: ClientSession = None):
104 if session is None:
105 session = self.session()
106
107 request: HttpRequestData = message["payload"]
108
109 request["headers"] = [
110 *request["headers"],
111 *self.headers,
112 ]
113
114 async def response_handler(
115 response: HttpResponseData, identifier=message["identifier"]
116 ):
117 await self.send(
118 HttpMessage(
119 type=HttpMessageType.response.value,
120 identifier=identifier,
121 payload=response,
122 )
123 )
124
125 await self.proxy_request(
126 session=session,
127 request=request,
128 on_response=response_handler,
129 )
130
131 async def receive_websocket_message(self, message: str, idenitfier: str):
132 message_data = WebsocketMessage(
133 identifier=idenitfier,
134 type=WebsocketMessageType.message.value,
135 payload=WebsocketMessageData(body=b64encode(message.encode()).decode()),
136 )
137 await self.send(message_data)
138
139 await PubSub.publish(
140 {
141 "type": "websocket_outbound",
142 "payload": {
143 "id": message_data["identifier"],
144 "timestamp": datetime.now().isoformat(),
145 **message_data["payload"],
146 },
147 }
148 )
149
150 async def connect_websocket(self, message: WebsocketMessage):
151 assert not message["identifier"] in self.websocket_connections
152
153 start = perf_counter()
154 await PubSub.publish(
155 {
156 "type": "websocket_connect",
157 "payload": {
158 "id": message["identifier"],
159 "timestamp": datetime.now().isoformat(),
160 **message["payload"],
161 },
162 }
163 )
164
165 async with websockets.connect(
166 f'{self.ws_proxy_origin}/{message["payload"]["path"]}'
167 ) as connection:
168 end = perf_counter()
169 self.websocket_connections[message["identifier"]] = connection
170
171 await self.send(
172 WebsocketMessage(
173 identifier=message["identifier"],
174 type=WebsocketMessageType.ack.value,
175 payload=None,
176 )
177 )
178
179 await PubSub.publish(
180 {
181 "type": "websocket_connected",
182 "payload": {
183 "id": message["identifier"],
184 "timing": end - start,
185 },
186 }
187 )
188
189 async for m in connection:
190 await self.receive_websocket_message(m, message["identifier"])
191
192 async def send_websocket_message(self, message: WebsocketMessage):
193 assert message["identifier"] in self.websocket_connections
194 await self.websocket_connections[message["identifier"]].send(
195 b64decode(message["payload"]["body"]).decode()
196 )
197
198 await PubSub.publish(
199 {
200 "type": "websocket_inbound",
201 "payload": {
202 "id": message["identifier"],
203 "timestamp": datetime.now().isoformat(),
204 **message["payload"],
205 },
206 }
207 )
208
209 async def disconnect_websocket(self, message: WebsocketMessage):
210 assert message["identifier"] in self.websocket_connections
211
212 await self.websocket_connections[message["identifier"]].close()
213
214 self.websocket_connections[message["identifier"]] = None
215 await PubSub.publish(
216 {
217 "type": "websocket_disconnect",
218 "payload": {
219 "id": message["identifier"],
220 "timestamp": datetime.now().isoformat(),
221 **message["payload"],
222 },
223 }
224 )
225
89 async def handle_messages(self): 226 async def handle_messages(self):
90 loop = get_running_loop() 227 loop = get_running_loop()
228 tasks = set()
229
91 async with self.session() as session: 230 async with self.session() as session:
92 while True: 231 while True:
93 try: 232 try:
94 message: Message = await self.receive() 233 message: Message = await self.receive()
234 logger.debug(message)
95 235
96 try: 236 match MessageType(message["type"]):
97 if MessageType(message["type"]) != MessageType.request: 237 case MessageType.request:
98 continue 238 task = loop.create_task(
99 except ValueError: 239 self.handle_request(message, session)
100 continue
101
102 request: RequestData = message["payload"]
103
104 request["headers"] = [
105 *request["headers"],
106 *self.headers,
107 ]
108
109 async def response_handler(
110 response: ResponseData, identifier=message["identifier"]
111 ):
112 await self.send(
113 Message(
114 type=MessageType.response.value,
115 identifier=identifier,
116 payload=response,
117 ) 240 )
118 ) 241 case MessageType.ws_connect:
242 task = loop.create_task(self.connect_websocket(message))
243 case MessageType.ws_message:
244 task = loop.create_task(
245 self.send_websocket_message(message)
246 )
247 case MessageType.ws_disconnect:
248 task = loop.create_task(self.disconnect_websocket(message))
249 case _:
250 logger.debug(message)
119 251
120 await loop.create_task( 252 tasks.add(task)
121 self.proxy_request( 253 task.add_done_callback(tasks.discard)
122 session=session, 254 except ValueError:
123 request=request, 255 continue
124 on_response=response_handler, 256 except ConnectionClosed as e:
125 ) 257 raise e
126 ) 258
127 except ConnectionClosed: 259 for task in tasks:
128 break 260 task.cancel()
129 261
130 async def resend(self, data: RequestData): 262 async def resend(self, data: HttpRequestData):
131 async with self.session() as session: 263 async with self.session() as session:
132 await self.proxy_request(session, data) 264 await self.proxy_request(session, data)
133 265
134 async def proxy_request( 266 async def proxy_request(
135 self, 267 self,
136 session: ClientSession, 268 session: ClientSession,
137 request: RequestData, 269 request: HttpRequestData,
138 on_response: Callable[[ResponseData], Awaitable] = None, 270 on_response: Callable[[HttpResponseData], Awaitable] = None,
139 ): 271 ):
140 request_id = uuid4() 272 request_id = str(uuid4())
141 await PubSub.publish( 273 await PubSub.publish(
142 { 274 {
143 "type": "request", 275 "type": "request",
144 "payload": { 276 "payload": {
145 "id": request_id.hex, 277 "id": request_id,
146 "timestamp": datetime.now().isoformat(), 278 "timestamp": datetime.now().isoformat(),
147 **request, 279 **request,
148 }, 280 },
@@ -160,7 +292,7 @@ class Client:
160 ) 292 )
161 end = perf_counter() 293 end = perf_counter()
162 294
163 response_data = ResponseData( 295 response_data = HttpResponseData(
164 status=response.status, 296 status=response.status,
165 headers=[ 297 headers=[
166 (key, value) 298 (key, value)
@@ -173,7 +305,7 @@ class Client:
173 except ClientError as e: 305 except ClientError as e:
174 end = perf_counter() 306 end = perf_counter()
175 307
176 response_data = ResponseData( 308 response_data = HttpResponseData(
177 status=(504 if isinstance(e, ClientConnectionError) else 502), 309 status=(504 if isinstance(e, ClientConnectionError) else 502),
178 headers=[("content-type", "text/plain")], 310 headers=[("content-type", "text/plain")],
179 body=b64encode(str(e).encode()).decode(), 311 body=b64encode(str(e).encode()).decode(),
@@ -186,7 +318,7 @@ class Client:
186 { 318 {
187 "type": "response", 319 "type": "response",
188 "payload": { 320 "payload": {
189 "id": request_id.hex, 321 "id": request_id,
190 "timing": end - start, 322 "timing": end - start,
191 **response_data, 323 **response_data,
192 }, 324 },
diff --git a/ttun/inspect_server.py b/ttun/inspect_server.py
index 1cd8809..df274a2 100644
--- a/ttun/inspect_server.py
+++ b/ttun/inspect_server.py
@@ -2,13 +2,12 @@ from importlib import resources
2from pathlib import Path 2from pathlib import Path
3from typing import Awaitable 3from typing import Awaitable
4from typing import Callable 4from typing import Callable
5from typing import Optional
6 5
7from aiohttp import web 6from aiohttp import web
8 7
9from ttun.pubsub import PubSub 8from ttun.pubsub import PubSub
10from ttun.types import Config 9from ttun.types import Config
11from ttun.types import RequestData 10from ttun.types import HttpRequestData
12 11
13BASE_DIR = Path(__file__).resolve().parent 12BASE_DIR = Path(__file__).resolve().parent
14 13
@@ -17,7 +16,7 @@ class Server:
17 def __init__( 16 def __init__(
18 self, 17 self,
19 config: Config, 18 config: Config,
20 on_resend: Callable[[RequestData], Awaitable], 19 on_resend: Callable[[HttpRequestData], Awaitable],
21 on_started: Callable[["Server"], None], 20 on_started: Callable[["Server"], None],
22 ): 21 ):
23 self.port = 4040 22 self.port = 4040
diff --git a/ttun/pubsub.py b/ttun/pubsub.py
index 0ba964e..85a1988 100644
--- a/ttun/pubsub.py
+++ b/ttun/pubsub.py
@@ -1,7 +1,6 @@
1import asyncio 1import asyncio
2from contextlib import contextmanager 2from contextlib import contextmanager
3from typing import Any 3from typing import Any
4from typing import Generator
5from typing import Iterator 4from typing import Iterator
6 5
7 6
diff --git a/ttun/types.py b/ttun/types.py
index 59bfa79..cdda635 100644
--- a/ttun/types.py
+++ b/ttun/types.py
@@ -1,28 +1,72 @@
1from enum import Enum 1from enum import Enum
2from itertools import chain
2from typing import Optional 3from typing import Optional
3from typing import TypedDict 4from typing import TypedDict
4 5
5class MessageType(Enum):
6 request = 'request'
7 response = 'response'
8 6
9class RequestData(TypedDict): 7class HttpMessageType(Enum):
8 request = "request"
9 response = "response"
10
11
12class HttpRequestData(TypedDict):
10 method: str 13 method: str
11 path: str 14 path: str
12 headers: list[tuple[str, str]] 15 headers: list[tuple[str, str]]
13 body: Optional[str] 16 body: Optional[str]
14 17
15 18
16class ResponseData(TypedDict): 19class HttpResponseData(TypedDict):
17 status: int 20 status: int
18 headers: list[tuple[str, str]] 21 headers: list[tuple[str, str]]
19 body: Optional[str] 22 body: Optional[str]
20 23
21 24
22class Message(TypedDict): 25class HttpMessage(TypedDict):
23 type: MessageType 26 type: HttpMessageType
24 identifier: str 27 identifier: str
25 payload: RequestData | ResponseData 28 payload: HttpRequestData | HttpResponseData
29
30
31class WebsocketMessageType(Enum):
32 connect = "connect"
33 disconnect = "disconnect"
34 message = "message"
35 ack = "ack"
36
37
38class WebsocketConnectData(TypedDict):
39 path: str
40 headers: list[tuple[str, str]]
41
42
43class WebsocketDisconnectData(TypedDict):
44 pass
45
46
47class WebsocketMessageData(TypedDict):
48 body: Optional[str]
49
50
51class WebsocketMessage(TypedDict):
52 type: WebsocketMessageType
53 identifier: str
54 payload: Optional[
55 WebsocketConnectData | WebsocketDisconnectData | WebsocketMessageData
56 ]
57
58
59class MessageType(Enum):
60 request = "request"
61 response = "response"
62
63 ws_connect = "connect"
64 ws_disconnect = "disconnect"
65 ws_message = "message"
66 ws_ack = "ack"
67
68
69Message = HttpMessage | WebsocketMessage
26 70
27 71
28class Config(TypedDict): 72class Config(TypedDict):