wormhole-proxy 3.2.0__tar.gz → 3.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/PKG-INFO +1 -1
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/pyproject.toml +1 -1
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/auth_manager.py +1 -1
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/handler.py +113 -49
- wormhole_proxy-3.2.2/wormhole/logger.py +136 -0
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/proxy.py +48 -10
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/safeguards.py +6 -5
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/server.py +35 -12
- wormhole_proxy-3.2.0/wormhole/logger.py +0 -58
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/LICENSE +0 -0
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/README.md +0 -0
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/__init__.py +0 -0
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/__main__.py +0 -0
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/ad_blocker.py +0 -0
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/authentication.py +0 -0
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/tools.py +0 -0
- {wormhole_proxy-3.2.0 → wormhole_proxy-3.2.2}/wormhole/version.py +0 -0
|
@@ -28,7 +28,7 @@ def _secure_create_file(path: Path) -> bool:
|
|
|
28
28
|
if sys.platform == "win32":
|
|
29
29
|
# On Windows, we rely on default user permissions and warn if the filesystem is not NTFS.
|
|
30
30
|
try:
|
|
31
|
-
import win32api
|
|
31
|
+
import win32api # noqa
|
|
32
32
|
|
|
33
33
|
fs_type = win32api.GetVolumeInformation(
|
|
34
34
|
str(path.resolve().drive) + "\\"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from .logger import logger
|
|
1
|
+
from .logger import logger, format_log_message as flm
|
|
2
2
|
from .safeguards import has_public_ipv6, is_ad_domain, is_private_ip
|
|
3
3
|
from .tools import get_content_length, get_host_and_port
|
|
4
4
|
import asyncio
|
|
@@ -19,6 +19,7 @@ async def relay_stream(
|
|
|
19
19
|
writer: asyncio.StreamWriter,
|
|
20
20
|
ident: dict[str, str],
|
|
21
21
|
return_first_line: bool = False,
|
|
22
|
+
verbose: int = 0,
|
|
22
23
|
) -> bytes | None:
|
|
23
24
|
"""
|
|
24
25
|
Relays data between a reader and a writer stream until EOF.
|
|
@@ -46,13 +47,13 @@ async def relay_stream(
|
|
|
46
47
|
writer.write(data)
|
|
47
48
|
await writer.drain()
|
|
48
49
|
except (ConnectionResetError, BrokenPipeError) as e:
|
|
49
|
-
logger.debug(
|
|
50
|
-
f"[{ident['id']}][{ident['client']}]: Relay network error: {e}"
|
|
51
|
-
)
|
|
50
|
+
logger.debug(flm(f"Relay network error: {e}", ident, verbose))
|
|
52
51
|
except Exception as e:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
msg = flm(f"Unexpected relay error: {e}", ident, verbose)
|
|
53
|
+
if verbose > 2: # Show full traceback only for -vv
|
|
54
|
+
logger.exception(msg)
|
|
55
|
+
else:
|
|
56
|
+
logger.error(msg)
|
|
56
57
|
finally:
|
|
57
58
|
if not writer.is_closing():
|
|
58
59
|
writer.close()
|
|
@@ -61,7 +62,7 @@ async def relay_stream(
|
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
async def _resolve_and_validate_host(
|
|
64
|
-
host: str, allow_private: bool
|
|
65
|
+
host: str, ident: dict[str, str], allow_private: bool, verbose: int = 0
|
|
65
66
|
) -> list[str]:
|
|
66
67
|
"""
|
|
67
68
|
Resolves a hostname to a list of IPs, validates them, and caches the list.
|
|
@@ -72,14 +73,18 @@ async def _resolve_and_validate_host(
|
|
|
72
73
|
"""
|
|
73
74
|
# Ad-block check
|
|
74
75
|
if is_ad_domain(host):
|
|
75
|
-
raise PermissionError(f"Blocked ad domain
|
|
76
|
+
raise PermissionError(f"Blocked ad domain")
|
|
76
77
|
|
|
77
78
|
# Check cache first
|
|
78
79
|
if host in DNS_CACHE:
|
|
79
80
|
ip_list, timestamp = DNS_CACHE[host]
|
|
80
81
|
if time.time() - timestamp < DNS_CACHE_TTL:
|
|
81
82
|
logger.debug(
|
|
82
|
-
|
|
83
|
+
flm(
|
|
84
|
+
f"DNS cache hit for '{host}'. ({len(DNS_CACHE)} hosts cached)",
|
|
85
|
+
ident,
|
|
86
|
+
verbose,
|
|
87
|
+
)
|
|
83
88
|
)
|
|
84
89
|
return ip_list
|
|
85
90
|
|
|
@@ -109,7 +114,11 @@ async def _resolve_and_validate_host(
|
|
|
109
114
|
final_ip_list = []
|
|
110
115
|
if has_public_ipv6() and valid_ipv6s:
|
|
111
116
|
logger.debug(
|
|
112
|
-
|
|
117
|
+
flm(
|
|
118
|
+
f"Host has public IPv6. Prioritizing {len(valid_ipv6s)} IPv6 addresses.",
|
|
119
|
+
ident,
|
|
120
|
+
verbose,
|
|
121
|
+
)
|
|
113
122
|
)
|
|
114
123
|
random.shuffle(valid_ipv6s)
|
|
115
124
|
final_ip_list.extend(valid_ipv6s)
|
|
@@ -131,19 +140,30 @@ async def _resolve_and_validate_host(
|
|
|
131
140
|
# Update cache
|
|
132
141
|
DNS_CACHE[host] = (final_ip_list, time.time())
|
|
133
142
|
logger.debug(
|
|
134
|
-
|
|
143
|
+
flm(
|
|
144
|
+
(
|
|
145
|
+
f"DNS cache miss for '{host}'. "
|
|
146
|
+
f"Resolved to {final_ip_list}. Caching. "
|
|
147
|
+
f"({len(DNS_CACHE)} hosts cached)"
|
|
148
|
+
),
|
|
149
|
+
ident,
|
|
150
|
+
verbose,
|
|
151
|
+
)
|
|
135
152
|
)
|
|
136
153
|
return final_ip_list
|
|
137
154
|
|
|
138
155
|
|
|
139
156
|
async def _create_connection_with_retries(
|
|
140
|
-
ip_list: list[str],
|
|
157
|
+
ip_list: list[str],
|
|
158
|
+
port: int,
|
|
159
|
+
ident: dict[str, str],
|
|
160
|
+
max_attempts: int = 3,
|
|
161
|
+
timeout: int = 5,
|
|
162
|
+
verbose: int = 0,
|
|
141
163
|
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
142
164
|
"""
|
|
143
165
|
Tries to connect to a list of IPs with a fast timeout and retry mechanism.
|
|
144
166
|
"""
|
|
145
|
-
max_attempts = 3
|
|
146
|
-
timeout = 5 # seconds
|
|
147
167
|
last_error = None
|
|
148
168
|
|
|
149
169
|
# Create a list of connection targets to try, ensuring we don't exceed max_attempts
|
|
@@ -154,7 +174,11 @@ async def _create_connection_with_retries(
|
|
|
154
174
|
for i, ip in enumerate(targets_to_try):
|
|
155
175
|
attempt = i + 1
|
|
156
176
|
logger.debug(
|
|
157
|
-
|
|
177
|
+
flm(
|
|
178
|
+
f"Connection attempt {attempt}/{max_attempts} to {ip}:{port}",
|
|
179
|
+
ident,
|
|
180
|
+
verbose,
|
|
181
|
+
)
|
|
158
182
|
)
|
|
159
183
|
try:
|
|
160
184
|
# Use a short timeout for each connection attempt
|
|
@@ -162,13 +186,21 @@ async def _create_connection_with_retries(
|
|
|
162
186
|
asyncio.open_connection(ip, port), timeout=timeout
|
|
163
187
|
)
|
|
164
188
|
logger.debug(
|
|
165
|
-
|
|
189
|
+
flm(
|
|
190
|
+
f"Successfully connected to {ip}:{port} on attempt {attempt}",
|
|
191
|
+
ident,
|
|
192
|
+
verbose,
|
|
193
|
+
)
|
|
166
194
|
)
|
|
167
195
|
return reader, writer
|
|
168
196
|
except (OSError, asyncio.TimeoutError) as e:
|
|
169
197
|
last_error = e
|
|
170
198
|
logger.warning(
|
|
171
|
-
|
|
199
|
+
flm(
|
|
200
|
+
f"Connection to {ip}:{port} failed on attempt {attempt}: {e}",
|
|
201
|
+
ident,
|
|
202
|
+
verbose,
|
|
203
|
+
)
|
|
172
204
|
)
|
|
173
205
|
|
|
174
206
|
raise OSError(
|
|
@@ -186,6 +218,7 @@ async def process_https_tunnel(
|
|
|
186
218
|
uri: str,
|
|
187
219
|
ident: dict[str, str],
|
|
188
220
|
allow_private: bool,
|
|
221
|
+
verbose: int = 0,
|
|
189
222
|
) -> None:
|
|
190
223
|
"""Establishes an HTTPS tunnel and relays data between client and server."""
|
|
191
224
|
host, port = get_host_and_port(uri)
|
|
@@ -194,11 +227,13 @@ async def process_https_tunnel(
|
|
|
194
227
|
|
|
195
228
|
try:
|
|
196
229
|
# Resolve and validate the host to get a list of potential IPs.
|
|
197
|
-
ip_list = await _resolve_and_validate_host(
|
|
230
|
+
ip_list = await _resolve_and_validate_host(
|
|
231
|
+
host, ident, allow_private, verbose
|
|
232
|
+
)
|
|
198
233
|
|
|
199
234
|
# Attempt to connect to one of the IPs with retry logic.
|
|
200
235
|
server_reader, server_writer = await _create_connection_with_retries(
|
|
201
|
-
ip_list, port, ident
|
|
236
|
+
ip_list, port, ident, verbose=verbose
|
|
202
237
|
)
|
|
203
238
|
|
|
204
239
|
# Signal the client that the tunnel is established.
|
|
@@ -207,21 +242,30 @@ async def process_https_tunnel(
|
|
|
207
242
|
|
|
208
243
|
# Use a TaskGroup for structured concurrency to relay data in both directions.
|
|
209
244
|
async with asyncio.TaskGroup() as tg:
|
|
210
|
-
tg.create_task(
|
|
211
|
-
|
|
245
|
+
tg.create_task(
|
|
246
|
+
relay_stream(
|
|
247
|
+
client_reader, server_writer, ident, verbose=verbose
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
tg.create_task(
|
|
251
|
+
relay_stream(
|
|
252
|
+
server_reader, client_writer, ident, verbose=verbose
|
|
253
|
+
)
|
|
254
|
+
)
|
|
212
255
|
|
|
213
|
-
logger.info(f"
|
|
256
|
+
logger.info(flm(f"{method} 200 {uri}", ident, verbose))
|
|
214
257
|
|
|
215
258
|
except PermissionError as e:
|
|
216
|
-
logger.warning(
|
|
217
|
-
f"[{ident['id']}][{ident['client']}]: {method} 403 {uri} ({e})"
|
|
218
|
-
)
|
|
259
|
+
logger.warning(flm(f"{method} 403 {uri} ({e})", ident, verbose))
|
|
219
260
|
client_writer.write(b"HTTP/1.1 403 Forbidden\r\n\r\n")
|
|
220
261
|
await client_writer.drain()
|
|
221
262
|
except Exception as e:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
263
|
+
msg = flm(f"{method} 502 {uri} ({e})", ident, verbose)
|
|
264
|
+
if verbose > 2: # Show full traceback only for -vv
|
|
265
|
+
logger.exception(msg)
|
|
266
|
+
else:
|
|
267
|
+
logger.error(msg)
|
|
268
|
+
|
|
225
269
|
finally:
|
|
226
270
|
# Ensure server streams are closed if they were opened.
|
|
227
271
|
if server_writer and not server_writer.is_closing():
|
|
@@ -238,6 +282,8 @@ async def _send_http_request(
|
|
|
238
282
|
headers: list[str],
|
|
239
283
|
payload: bytes,
|
|
240
284
|
ident: dict[str, str],
|
|
285
|
+
max_attempts: int = 3,
|
|
286
|
+
verbose: int = 0,
|
|
241
287
|
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
242
288
|
"""Helper function to connect and send an HTTP request."""
|
|
243
289
|
request_line = f"{method} {path or '/'} {version}".encode()
|
|
@@ -245,7 +291,7 @@ async def _send_http_request(
|
|
|
245
291
|
|
|
246
292
|
# Attempt to connect to one of the IPs with retry logic.
|
|
247
293
|
server_reader, server_writer = await _create_connection_with_retries(
|
|
248
|
-
ip_list, port, ident
|
|
294
|
+
ip_list, port, ident, max_attempts, verbose=verbose
|
|
249
295
|
)
|
|
250
296
|
|
|
251
297
|
server_writer.write(request_line + b"\r\n" + headers_bytes + b"\r\n\r\n")
|
|
@@ -265,6 +311,8 @@ async def process_http_request(
|
|
|
265
311
|
payload: bytes,
|
|
266
312
|
ident: dict[str, str],
|
|
267
313
|
allow_private: bool,
|
|
314
|
+
max_attempts: int = 3,
|
|
315
|
+
verbose: int = 0,
|
|
268
316
|
) -> None:
|
|
269
317
|
"""Processes a standard HTTP request by forwarding it to the target server."""
|
|
270
318
|
server_reader = None
|
|
@@ -300,12 +348,18 @@ async def process_http_request(
|
|
|
300
348
|
return
|
|
301
349
|
|
|
302
350
|
# Resolve and validate the host to get a list of potential IPs.
|
|
303
|
-
ip_list = await _resolve_and_validate_host(
|
|
351
|
+
ip_list = await _resolve_and_validate_host(
|
|
352
|
+
host, ident, allow_private, verbose
|
|
353
|
+
)
|
|
304
354
|
|
|
305
355
|
# --- Attempt to upgrade to HTTP/1.1 if needed ---
|
|
306
356
|
if version == "HTTP/1.0":
|
|
307
357
|
logger.debug(
|
|
308
|
-
|
|
358
|
+
flm(
|
|
359
|
+
f"Attempting to upgrade HTTP/1.0 request for {host_header} to HTTP/1.1",
|
|
360
|
+
ident,
|
|
361
|
+
verbose,
|
|
362
|
+
)
|
|
309
363
|
)
|
|
310
364
|
|
|
311
365
|
# Prepare headers for HTTP/1.1
|
|
@@ -332,10 +386,16 @@ async def process_http_request(
|
|
|
332
386
|
headers_v1_1,
|
|
333
387
|
payload,
|
|
334
388
|
ident,
|
|
389
|
+
max_attempts,
|
|
390
|
+
verbose,
|
|
335
391
|
)
|
|
336
392
|
except Exception as e:
|
|
337
393
|
logger.warning(
|
|
338
|
-
|
|
394
|
+
flm(
|
|
395
|
+
f"HTTP/1.1 upgrade failed ({e}). Falling back to HTTP/1.0.",
|
|
396
|
+
ident,
|
|
397
|
+
verbose,
|
|
398
|
+
)
|
|
339
399
|
)
|
|
340
400
|
if server_writer and not server_writer.is_closing():
|
|
341
401
|
server_writer.close()
|
|
@@ -361,6 +421,8 @@ async def process_http_request(
|
|
|
361
421
|
original_headers,
|
|
362
422
|
payload,
|
|
363
423
|
ident,
|
|
424
|
+
max_attempts,
|
|
425
|
+
verbose,
|
|
364
426
|
)
|
|
365
427
|
else:
|
|
366
428
|
# Original request was already HTTP/1.1 or newer
|
|
@@ -385,11 +447,17 @@ async def process_http_request(
|
|
|
385
447
|
final_headers,
|
|
386
448
|
payload,
|
|
387
449
|
ident,
|
|
450
|
+
max_attempts,
|
|
451
|
+
verbose,
|
|
388
452
|
)
|
|
389
453
|
|
|
390
454
|
# Relay the server's response back to the client.
|
|
391
455
|
response_status_line = await relay_stream(
|
|
392
|
-
server_reader,
|
|
456
|
+
server_reader,
|
|
457
|
+
client_writer,
|
|
458
|
+
ident,
|
|
459
|
+
return_first_line=True,
|
|
460
|
+
verbose=verbose,
|
|
393
461
|
)
|
|
394
462
|
|
|
395
463
|
# Log the outcome.
|
|
@@ -398,26 +466,24 @@ async def process_http_request(
|
|
|
398
466
|
if response_status_line
|
|
399
467
|
else 502
|
|
400
468
|
)
|
|
401
|
-
logger.info(
|
|
402
|
-
f"[{ident['id']}][{ident['client']}]: {method} {response_code} {uri}"
|
|
403
|
-
)
|
|
469
|
+
logger.info(flm(f"{method} {response_code} {uri}", ident, verbose))
|
|
404
470
|
|
|
405
471
|
except PermissionError as e:
|
|
406
|
-
logger.warning(
|
|
407
|
-
f"[{ident['id']}][{ident['client']}]: {method} 403 {uri} ({e})"
|
|
408
|
-
)
|
|
472
|
+
logger.warning(flm(f"{method} 403 {uri} ({e})", ident, verbose))
|
|
409
473
|
client_writer.write(b"HTTP/1.1 403 Forbidden\r\n\r\n")
|
|
410
474
|
await client_writer.drain()
|
|
411
475
|
except Exception as e:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
476
|
+
msg = flm(f"{method} 502 {uri} ({e})", ident, verbose)
|
|
477
|
+
if verbose > 2: # Show full traceback only for -vv
|
|
478
|
+
logger.exception(msg)
|
|
479
|
+
else:
|
|
480
|
+
logger.error(msg)
|
|
415
481
|
if not client_writer.is_closing():
|
|
416
482
|
try:
|
|
417
483
|
client_writer.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
|
418
484
|
await client_writer.drain()
|
|
419
485
|
except ConnectionError:
|
|
420
|
-
pass
|
|
486
|
+
pass # Ignore if client is already closed
|
|
421
487
|
finally:
|
|
422
488
|
if server_writer and not server_writer.is_closing():
|
|
423
489
|
server_writer.close()
|
|
@@ -425,7 +491,7 @@ async def process_http_request(
|
|
|
425
491
|
|
|
426
492
|
|
|
427
493
|
async def parse_request(
|
|
428
|
-
client_reader: asyncio.StreamReader,
|
|
494
|
+
client_reader: asyncio.StreamReader, ident: dict[str, str], verbose: int = 0
|
|
429
495
|
) -> tuple[str, list[str], bytes] | tuple[None, None, None]:
|
|
430
496
|
"""
|
|
431
497
|
Parses the initial request from the client.
|
|
@@ -443,7 +509,7 @@ async def parse_request(
|
|
|
443
509
|
)
|
|
444
510
|
except (asyncio.IncompleteReadError, asyncio.TimeoutError) as e:
|
|
445
511
|
logger.debug(
|
|
446
|
-
f"
|
|
512
|
+
flm(f"Failed to read initial request: {e}", ident, verbose)
|
|
447
513
|
)
|
|
448
514
|
return None, None, None
|
|
449
515
|
|
|
@@ -459,9 +525,7 @@ async def parse_request(
|
|
|
459
525
|
try:
|
|
460
526
|
payload = await client_reader.readexactly(content_length)
|
|
461
527
|
except asyncio.IncompleteReadError:
|
|
462
|
-
logger.debug(
|
|
463
|
-
f"[{ident['id']}][{ident['client']}]: Incomplete payload read."
|
|
464
|
-
)
|
|
528
|
+
logger.debug(flm(f"Incomplete payload read.", ident, verbose))
|
|
465
529
|
return None, None, None
|
|
466
530
|
|
|
467
531
|
return request_line, headers, payload
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from loguru import logger
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging.handlers
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LogThrottler:
|
|
9
|
+
"""A class to throttle and summarize repeated log messages."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, logger, level: str, delay: float = 5.0):
|
|
12
|
+
"""Initializes the log throttler with a level name and delay."""
|
|
13
|
+
self.logger = logger
|
|
14
|
+
self.level = level.upper() # Store the level name, e.g., "ERROR"
|
|
15
|
+
self.delay = delay
|
|
16
|
+
self.last_message: str | None = None
|
|
17
|
+
self.repeat_count: int = 0
|
|
18
|
+
self.timer: asyncio.TimerHandle | None = None
|
|
19
|
+
|
|
20
|
+
def _flush_summary(self, **kwargs):
|
|
21
|
+
"""Prints the summary of how many times the last message was repeated."""
|
|
22
|
+
if self.repeat_count > 2:
|
|
23
|
+
self.logger.opt(depth=2).log(
|
|
24
|
+
self.level,
|
|
25
|
+
f"{self.last_message} (and {self.repeat_count -1} more in the last {self.delay} seconds.)",
|
|
26
|
+
**kwargs,
|
|
27
|
+
)
|
|
28
|
+
elif self.repeat_count == 2:
|
|
29
|
+
# If the message was repeated only once, we log it directly.
|
|
30
|
+
self.logger.opt(depth=2).log(
|
|
31
|
+
self.level, self.last_message, **kwargs
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Reset the state
|
|
35
|
+
self.timer = None
|
|
36
|
+
self.last_message = None
|
|
37
|
+
self.repeat_count = 0
|
|
38
|
+
|
|
39
|
+
def process(self, message: str, **kwargs):
|
|
40
|
+
"""Processes a log message, either logging it or incrementing a repeat counter."""
|
|
41
|
+
if self.last_message and message != self.last_message:
|
|
42
|
+
if self.timer:
|
|
43
|
+
self.timer.cancel()
|
|
44
|
+
self._flush_summary()
|
|
45
|
+
|
|
46
|
+
if message == self.last_message:
|
|
47
|
+
self.repeat_count += 1
|
|
48
|
+
else:
|
|
49
|
+
# It's a new message. Log it immediately, but look 1 frame up the stack.
|
|
50
|
+
# This ensures the log record shows the original caller (e.g., wormhole.handler).
|
|
51
|
+
self.logger.opt(depth=1).log(self.level, message, **kwargs)
|
|
52
|
+
self.last_message = message
|
|
53
|
+
self.repeat_count = 1
|
|
54
|
+
|
|
55
|
+
if self.timer:
|
|
56
|
+
self.timer.cancel()
|
|
57
|
+
|
|
58
|
+
loop = asyncio.get_running_loop()
|
|
59
|
+
self.timer = loop.call_later(self.delay, self._flush_summary)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# In loguru, the logger is imported and ready to be configured.
|
|
63
|
+
# We just need to ensure other modules import this configured instance.
|
|
64
|
+
def setup_logger(
|
|
65
|
+
syslog_host: str | None = None, syslog_port: int = 514, verbose: int = 0
|
|
66
|
+
) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Configures the global loguru logger instance. This should only be called once.
|
|
69
|
+
"""
|
|
70
|
+
# Remove the default handler to have full control over sinks.
|
|
71
|
+
logger.remove()
|
|
72
|
+
|
|
73
|
+
# Set logging level based on verbosity.
|
|
74
|
+
if verbose >= 2:
|
|
75
|
+
level = "DEBUG"
|
|
76
|
+
elif verbose >= 1:
|
|
77
|
+
level = "DEBUG"
|
|
78
|
+
else:
|
|
79
|
+
level = "INFO"
|
|
80
|
+
|
|
81
|
+
# --- Console Sink ---
|
|
82
|
+
# Loguru automatically adds contextual data. The format is simpler.
|
|
83
|
+
console_format = (
|
|
84
|
+
"<green>{time:MMM D HH:mm:ss}</green> "
|
|
85
|
+
"<cyan>{name}</cyan>[<cyan>{process}</cyan>]: "
|
|
86
|
+
"<level>{message}</level>"
|
|
87
|
+
)
|
|
88
|
+
logger.add(sys.stderr, level=level, format=console_format)
|
|
89
|
+
|
|
90
|
+
# --- Syslog Sink ---
|
|
91
|
+
if syslog_host and syslog_host != "DISABLED":
|
|
92
|
+
# Create a standard library syslog handler instance.
|
|
93
|
+
# Loguru can sink to handler objects directly.
|
|
94
|
+
if syslog_host.startswith("/") and os.path.exists(syslog_host):
|
|
95
|
+
handler = logging.handlers.SysLogHandler(address=syslog_host)
|
|
96
|
+
syslog_format = "{time:MMM D HH:mm:ss} {name}[{process}]: {message}"
|
|
97
|
+
else:
|
|
98
|
+
handler = logging.handlers.SysLogHandler(
|
|
99
|
+
address=(syslog_host, syslog_port)
|
|
100
|
+
)
|
|
101
|
+
# For network syslog, the hostname is typically added by the syslog server,
|
|
102
|
+
# but we can include it if needed.
|
|
103
|
+
syslog_format = "{time:MMM D HH:mm:ss} {extra[hostname]} {name}[{process}]: {message}"
|
|
104
|
+
# Add hostname to all log records.
|
|
105
|
+
logger.configure(extra={"hostname": os.uname().nodename})
|
|
106
|
+
|
|
107
|
+
logger.add(handler, level="INFO", format=syslog_format)
|
|
108
|
+
|
|
109
|
+
# Suppress overly verbose asyncio logger messages unless in high verbosity.
|
|
110
|
+
logging.getLogger("asyncio").setLevel(
|
|
111
|
+
logging.DEBUG if verbose >= 2 else logging.CRITICAL
|
|
112
|
+
)
|
|
113
|
+
if verbose < 2:
|
|
114
|
+
logger.info = LogThrottler(logger, "info").process
|
|
115
|
+
logger.warning = LogThrottler(logger, "warning").process
|
|
116
|
+
logger.error = LogThrottler(logger, "error").process
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def format_log_message(
|
|
120
|
+
message: str, ident: dict[str, str], verbose: int
|
|
121
|
+
) -> str:
|
|
122
|
+
"""
|
|
123
|
+
Formats a log message with the given identifier.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
message: The log message to format.
|
|
127
|
+
ident: A dictionary containing identifiers like 'id' and 'client'.
|
|
128
|
+
verbose: The verbosity level of the logger.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
A formatted log message string.
|
|
132
|
+
"""
|
|
133
|
+
if verbose > 1:
|
|
134
|
+
return f"[{ident['id']}][{ident['client']}]: {message}"
|
|
135
|
+
else:
|
|
136
|
+
return f"[{ident['client']}]: {message}"
|
|
@@ -9,7 +9,7 @@ if sys.version_info < (3, 11):
|
|
|
9
9
|
|
|
10
10
|
from .ad_blocker import update_database
|
|
11
11
|
from .auth_manager import add_user, modify_user, delete_user
|
|
12
|
-
from .logger import logger, setup_logger
|
|
12
|
+
from .logger import logger, setup_logger, format_log_message as flm
|
|
13
13
|
from .safeguards import load_ad_block_db, load_allowlist
|
|
14
14
|
from .server import start_wormhole_server
|
|
15
15
|
from .version import VERSION
|
|
@@ -32,22 +32,42 @@ except ImportError:
|
|
|
32
32
|
async def main_async(args) -> None:
|
|
33
33
|
"""The main asynchronous function to run the server."""
|
|
34
34
|
if uvloop:
|
|
35
|
-
logger.info(
|
|
35
|
+
logger.info(
|
|
36
|
+
flm(
|
|
37
|
+
f"Using high-performance event loop: {uvloop.__name__}",
|
|
38
|
+
ident={"id": "000000", "client": args.host},
|
|
39
|
+
verbose=args.verbose,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
36
42
|
else:
|
|
37
|
-
logger.info(
|
|
43
|
+
logger.info(
|
|
44
|
+
flm(
|
|
45
|
+
"Using standard asyncio event loop.",
|
|
46
|
+
ident={"id": "000000", "client": args.host},
|
|
47
|
+
verbose=args.verbose,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
38
50
|
|
|
39
51
|
if args.allowlist:
|
|
40
|
-
num_allowed = load_allowlist(args.allowlist)
|
|
52
|
+
num_allowed = load_allowlist(args.allowlist, args.host)
|
|
41
53
|
if num_allowed > 0:
|
|
42
54
|
logger.info(
|
|
43
|
-
|
|
55
|
+
flm(
|
|
56
|
+
f"Loaded custom allowlist. Total allowlist size: {num_allowed} domains.",
|
|
57
|
+
ident={"id": "000000", "client": args.host},
|
|
58
|
+
verbose=args.verbose,
|
|
59
|
+
)
|
|
44
60
|
)
|
|
45
61
|
|
|
46
62
|
if args.ad_block_db:
|
|
47
|
-
num_blocked = await load_ad_block_db(args.ad_block_db)
|
|
63
|
+
num_blocked = await load_ad_block_db(args.ad_block_db, args.host)
|
|
48
64
|
if num_blocked > 0:
|
|
49
65
|
logger.info(
|
|
50
|
-
|
|
66
|
+
flm(
|
|
67
|
+
f"Ad-blocker enabled with {num_blocked} domains from database.",
|
|
68
|
+
ident={"id": "000000", "client": args.host},
|
|
69
|
+
verbose=args.verbose,
|
|
70
|
+
)
|
|
51
71
|
)
|
|
52
72
|
|
|
53
73
|
shutdown_event = asyncio.Event()
|
|
@@ -65,16 +85,34 @@ async def main_async(args) -> None:
|
|
|
65
85
|
args.allow_private,
|
|
66
86
|
)
|
|
67
87
|
|
|
68
|
-
logger.info(
|
|
88
|
+
logger.info(
|
|
89
|
+
flm(
|
|
90
|
+
"Server startup complete. Waiting for connections...",
|
|
91
|
+
ident={"id": "000000", "client": args.host},
|
|
92
|
+
verbose=args.verbose,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
69
95
|
|
|
70
96
|
# Wait for the shutdown signal.
|
|
71
97
|
await shutdown_event.wait()
|
|
72
98
|
|
|
73
99
|
# Gracefully shut down the server.
|
|
74
|
-
logger.info(
|
|
100
|
+
logger.info(
|
|
101
|
+
flm(
|
|
102
|
+
f"Shutdown signal received, closing server...",
|
|
103
|
+
ident={"id": "000000", "client": args.host},
|
|
104
|
+
verbose=args.verbose,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
75
107
|
server.close()
|
|
76
108
|
await server.wait_closed()
|
|
77
|
-
logger.info(
|
|
109
|
+
logger.info(
|
|
110
|
+
flm(
|
|
111
|
+
f"Server has been shut down gracefully.",
|
|
112
|
+
ident={"id": "000000", "client": args.host},
|
|
113
|
+
verbose=args.verbose,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
78
116
|
|
|
79
117
|
|
|
80
118
|
def main() -> int:
|
|
@@ -83,13 +83,14 @@ def is_private_ip(ip_str: str) -> bool:
|
|
|
83
83
|
return True
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
async def load_ad_block_db(path: str) -> int:
|
|
86
|
+
async def load_ad_block_db(path: str, host: str) -> int:
|
|
87
87
|
"""
|
|
88
88
|
Loads a list of domains to block from a SQLite database into a global set
|
|
89
89
|
for fast in-memory access.
|
|
90
90
|
|
|
91
91
|
Args:
|
|
92
92
|
path: The path to the SQLite database file.
|
|
93
|
+
host: The host IP of the server, used for logging.
|
|
93
94
|
|
|
94
95
|
Returns:
|
|
95
96
|
The number of unique domains loaded into the blocklist.
|
|
@@ -103,7 +104,7 @@ async def load_ad_block_db(path: str) -> int:
|
|
|
103
104
|
AD_BLOCK_SET.add(row[0])
|
|
104
105
|
except Exception as e:
|
|
105
106
|
logger.error(
|
|
106
|
-
f"Could not load ad-block database from '{path}': {e}",
|
|
107
|
+
f"[000000][{host}]: Could not load ad-block database from '{path}': {e}",
|
|
107
108
|
)
|
|
108
109
|
|
|
109
110
|
if AD_BLOCK_SET:
|
|
@@ -112,13 +113,13 @@ async def load_ad_block_db(path: str) -> int:
|
|
|
112
113
|
content_size = sum(sys.getsizeof(s) for s in AD_BLOCK_SET)
|
|
113
114
|
total_size_mb = (set_size + content_size) / (1024 * 1024)
|
|
114
115
|
logger.debug(
|
|
115
|
-
f"Ad-block set memory usage: ~{total_size_mb:.2f} MB for {len(AD_BLOCK_SET)} domains"
|
|
116
|
+
f"[000000][{host}]: Ad-block set memory usage: ~{total_size_mb:.2f} MB for {len(AD_BLOCK_SET)} domains"
|
|
116
117
|
)
|
|
117
118
|
|
|
118
119
|
return len(AD_BLOCK_SET)
|
|
119
120
|
|
|
120
121
|
|
|
121
|
-
def load_allowlist(path: str) -> int:
|
|
122
|
+
def load_allowlist(path: str, host: str) -> int:
|
|
122
123
|
"""
|
|
123
124
|
Loads domains from a user-provided file and adds them to the global allowlist set.
|
|
124
125
|
"""
|
|
@@ -128,7 +129,7 @@ def load_allowlist(path: str) -> int:
|
|
|
128
129
|
if line.strip() and not line.startswith("#"):
|
|
129
130
|
ALLOW_LIST_SET.add(line.strip().lower())
|
|
130
131
|
except FileNotFoundError:
|
|
131
|
-
logger.error(f"Allowlist file not found at '{path}'")
|
|
132
|
+
logger.error(f"[000000][{host}]: Allowlist file not found at '{path}'")
|
|
132
133
|
return len(ALLOW_LIST_SET)
|
|
133
134
|
|
|
134
135
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from .authentication import get_ident, verify_credentials
|
|
2
2
|
from .handler import process_http_request, process_https_tunnel, parse_request
|
|
3
|
-
from .logger import logger
|
|
3
|
+
from .logger import logger, format_log_message as flm
|
|
4
4
|
from time import time
|
|
5
5
|
import asyncio
|
|
6
6
|
import functools
|
|
@@ -45,22 +45,22 @@ async def handle_connection(
|
|
|
45
45
|
CURRENT_TASKS += 1
|
|
46
46
|
if verbose > 0:
|
|
47
47
|
logger.debug(
|
|
48
|
-
f"
|
|
48
|
+
flm(f"{CURRENT_TASKS}/{MAX_TASKS} Tasks active", ident, verbose)
|
|
49
49
|
)
|
|
50
50
|
else:
|
|
51
|
-
logger.debug(
|
|
51
|
+
logger.debug(flm("Connection started.", ident, verbose))
|
|
52
52
|
|
|
53
53
|
try:
|
|
54
54
|
# Parse the initial request from the client.
|
|
55
55
|
request_line, headers, payload = await parse_request(
|
|
56
|
-
client_reader,
|
|
56
|
+
client_reader, ident, verbose
|
|
57
57
|
)
|
|
58
58
|
# If parse_request fails, it returns (None, None, None). We check all three
|
|
59
59
|
# to explicitly narrow the types for mypy, which can't infer that if one
|
|
60
60
|
# is None, they all are.
|
|
61
61
|
if not request_line or headers is None or payload is None:
|
|
62
62
|
logger.debug(
|
|
63
|
-
|
|
63
|
+
flm("Empty request, closing connection.", ident, verbose)
|
|
64
64
|
)
|
|
65
65
|
return
|
|
66
66
|
|
|
@@ -69,7 +69,11 @@ async def handle_connection(
|
|
|
69
69
|
method, uri, version = request_line.split(" ", 2)
|
|
70
70
|
except ValueError:
|
|
71
71
|
logger.debug(
|
|
72
|
-
|
|
72
|
+
flm(
|
|
73
|
+
f"Malformed request line '{request_line}', closing.",
|
|
74
|
+
ident,
|
|
75
|
+
verbose,
|
|
76
|
+
)
|
|
73
77
|
)
|
|
74
78
|
return
|
|
75
79
|
|
|
@@ -86,14 +90,24 @@ async def handle_connection(
|
|
|
86
90
|
)
|
|
87
91
|
if user_ident is None:
|
|
88
92
|
logger.info(
|
|
89
|
-
|
|
93
|
+
flm(
|
|
94
|
+
f"{method} 407 {uri} (Authentication Failed)",
|
|
95
|
+
ident,
|
|
96
|
+
verbose,
|
|
97
|
+
)
|
|
90
98
|
)
|
|
91
99
|
return
|
|
92
100
|
ident = user_ident # Update ident with authenticated user info.
|
|
93
101
|
# --- Request Dispatching ---
|
|
94
102
|
if method.upper() == "CONNECT":
|
|
95
103
|
await process_https_tunnel(
|
|
96
|
-
client_reader,
|
|
104
|
+
client_reader,
|
|
105
|
+
client_writer,
|
|
106
|
+
method,
|
|
107
|
+
uri,
|
|
108
|
+
ident,
|
|
109
|
+
allow_private,
|
|
110
|
+
verbose,
|
|
97
111
|
)
|
|
98
112
|
else:
|
|
99
113
|
# The check above ensures `headers` is `list[str]` and `payload` is `bytes`.
|
|
@@ -106,11 +120,12 @@ async def handle_connection(
|
|
|
106
120
|
payload,
|
|
107
121
|
ident,
|
|
108
122
|
allow_private,
|
|
123
|
+
verbose,
|
|
109
124
|
)
|
|
110
125
|
|
|
111
126
|
except Exception as e:
|
|
112
127
|
logger.error(
|
|
113
|
-
f"
|
|
128
|
+
flm(f"Unhandled error in connection handler: {e}", ident, verbose),
|
|
114
129
|
exc_info=True,
|
|
115
130
|
)
|
|
116
131
|
finally:
|
|
@@ -120,7 +135,7 @@ async def handle_connection(
|
|
|
120
135
|
await client_writer.wait_closed()
|
|
121
136
|
duration = time() - start_time
|
|
122
137
|
logger.debug(
|
|
123
|
-
f"
|
|
138
|
+
flm(f"Connection closed ({duration:.5f} seconds).", ident, verbose)
|
|
124
139
|
)
|
|
125
140
|
|
|
126
141
|
|
|
@@ -154,13 +169,21 @@ async def start_wormhole_server(
|
|
|
154
169
|
for s in server.sockets:
|
|
155
170
|
addr = s.getsockname()
|
|
156
171
|
logger.info(
|
|
157
|
-
|
|
172
|
+
flm(
|
|
173
|
+
f"Wormhole proxy bound and listening at {addr[0]}:{addr[1]}",
|
|
174
|
+
ident={"id": "000000", "client": host},
|
|
175
|
+
verbose=verbose,
|
|
176
|
+
)
|
|
158
177
|
)
|
|
159
178
|
|
|
160
179
|
return server
|
|
161
180
|
|
|
162
181
|
except OSError as e:
|
|
163
182
|
logger.critical(
|
|
164
|
-
|
|
183
|
+
flm(
|
|
184
|
+
f"Failed to bind server at {host}:{port}: {e}",
|
|
185
|
+
ident={"id": "000000", "client": host},
|
|
186
|
+
verbose=verbose,
|
|
187
|
+
)
|
|
165
188
|
)
|
|
166
189
|
raise
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
from loguru import logger
|
|
2
|
-
import logging.handlers
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
|
-
# In loguru, the logger is imported and ready to be configured.
|
|
7
|
-
# We just need to ensure other modules import this configured instance.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def setup_logger(
|
|
11
|
-
syslog_host: str | None = None, syslog_port: int = 514, verbose: int = 0
|
|
12
|
-
) -> None:
|
|
13
|
-
"""
|
|
14
|
-
Configures the global loguru logger instance. This should only be called once.
|
|
15
|
-
"""
|
|
16
|
-
# Remove the default handler to have full control over sinks.
|
|
17
|
-
logger.remove()
|
|
18
|
-
|
|
19
|
-
# Set logging level based on verbosity.
|
|
20
|
-
if verbose >= 2:
|
|
21
|
-
level = "DEBUG"
|
|
22
|
-
elif verbose >= 1:
|
|
23
|
-
level = "DEBUG"
|
|
24
|
-
else:
|
|
25
|
-
level = "INFO"
|
|
26
|
-
|
|
27
|
-
# --- Console Sink ---
|
|
28
|
-
# Loguru automatically adds contextual data. The format is simpler.
|
|
29
|
-
console_format = (
|
|
30
|
-
"<green>{time:MMM D HH:mm:ss}</green> "
|
|
31
|
-
"<cyan>{name}</cyan>[<cyan>{process}</cyan>]: "
|
|
32
|
-
"<level>{message}</level>"
|
|
33
|
-
)
|
|
34
|
-
logger.add(sys.stderr, level=level, format=console_format)
|
|
35
|
-
|
|
36
|
-
# --- Syslog Sink ---
|
|
37
|
-
if syslog_host and syslog_host != "DISABLED":
|
|
38
|
-
# Create a standard library syslog handler instance.
|
|
39
|
-
# Loguru can sink to handler objects directly.
|
|
40
|
-
if syslog_host.startswith("/") and os.path.exists(syslog_host):
|
|
41
|
-
handler = logging.handlers.SysLogHandler(address=syslog_host)
|
|
42
|
-
syslog_format = "{time:MMM D HH:mm:ss} {name}[{process}]: {message}"
|
|
43
|
-
else:
|
|
44
|
-
handler = logging.handlers.SysLogHandler(
|
|
45
|
-
address=(syslog_host, syslog_port)
|
|
46
|
-
)
|
|
47
|
-
# For network syslog, the hostname is typically added by the syslog server,
|
|
48
|
-
# but we can include it if needed.
|
|
49
|
-
syslog_format = "{time:MMM D HH:mm:ss} {extra[hostname]} {name}[{process}]: {message}"
|
|
50
|
-
# Add hostname to all log records.
|
|
51
|
-
logger.configure(extra={"hostname": os.uname().nodename})
|
|
52
|
-
|
|
53
|
-
logger.add(handler, level="INFO", format=syslog_format)
|
|
54
|
-
|
|
55
|
-
# Suppress overly verbose asyncio logger messages unless in high verbosity.
|
|
56
|
-
logging.getLogger("asyncio").setLevel(
|
|
57
|
-
logging.DEBUG if verbose >= 2 else logging.CRITICAL
|
|
58
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|