diff --git a/linux.py b/linux.py index 60816c6..9ca90ca 100644 --- a/linux.py +++ b/linux.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +import logging.handlers import os import subprocess import sys @@ -32,6 +33,9 @@ DEFAULT_CONFIG = { "host": "127.0.0.1", "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "verbose": False, + "log_max_mb": 5, + "buf_kb": 256, + "pool_size": 4, } @@ -143,8 +147,8 @@ def save_config(cfg: dict): _runtime.save_config(cfg) -def setup_logging(verbose: bool = False): - _runtime.setup_logging(verbose) +def setup_logging(verbose: bool = False, log_max_mb: float = 5): + _runtime.setup_logging(verbose, log_max_mb=log_max_mb) def _make_icon_image(size: int = 64): @@ -276,7 +280,7 @@ def _edit_config_dialog(): TEXT_SECONDARY = "#707579" FONT_FAMILY = "Sans" - w, h = 420, 480 + w, h = 420, 540 sw = root.winfo_screenwidth() sh = root.winfo_screenheight() root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") @@ -368,14 +372,29 @@ def _edit_config_dialog(): border_color=FIELD_BORDER, ).pack(anchor="w", pady=(0, 8)) - # Info label - ctk.CTkLabel( - frame, - text="Изменения вступят в силу после перезапуска прокси.", - font=(FONT_FAMILY, 11), - text_color=TEXT_SECONDARY, - anchor="w", - ).pack(anchor="w", pady=(0, 16)) + # Advanced: buf_kb, pool_size, log_max_mb + adv_frame = ctk.CTkFrame(frame, fg_color="transparent") + adv_frame.pack(anchor="w", fill="x", pady=(4, 8)) + + for col, (lbl, key, w_) in enumerate([ + ("Буфер (KB, 256 default)", "buf_kb", 120), + ("WS пулов (4 default)", "pool_size", 120), + ("Log size (MB, 5 def)", "log_max_mb", 120), + ]): + col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") + col_frame.pack(side="left", padx=(0, 10)) + ctk.CTkLabel(col_frame, text=lbl, font=(FONT_FAMILY, 11), + text_color=TEXT_SECONDARY, anchor="w").pack(anchor="w") + ctk.CTkEntry(col_frame, width=w_, height=30, font=(FONT_FAMILY, 12), + corner_radius=8, fg_color=FIELD_BG, + border_color=FIELD_BORDER, border_width=1, + text_color=TEXT_PRIMARY, + textvariable=ctk.StringVar( + value=str(cfg.get(key, DEFAULT_CONFIG[key])) + )).pack(anchor="w") + + _adv_entries = list(adv_frame.winfo_children()) + _adv_keys = ["buf_kb", "pool_size", "log_max_mb"] def on_save(): import socket as _sock @@ -412,6 +431,17 @@ def _edit_config_dialog(): "dc_ip": lines, "verbose": verbose_var.get(), } + + for i, key in enumerate(_adv_keys): + col_frame = _adv_entries[i] + entry = col_frame.winfo_children()[1] + try: + val = float(entry.get().strip()) + if key in ("buf_kb", "pool_size"): + val = int(val) + new_cfg[key] = val + except ValueError: + new_cfg[key] = DEFAULT_CONFIG[key] save_config(new_cfg) _config.update(new_cfg) log.info("Config saved: %s", new_cfg) @@ -434,33 +464,18 @@ def _edit_config_dialog(): root.destroy() btn_frame = ctk.CTkFrame(frame, fg_color="transparent") - btn_frame.pack(fill="x") - ctk.CTkButton( - btn_frame, - text="Сохранить", - width=140, - height=38, - font=(FONT_FAMILY, 14, "bold"), - corner_radius=10, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - text_color="#ffffff", - command=on_save, - ).pack(side="left", padx=(0, 10)) - ctk.CTkButton( - btn_frame, - text="Отмена", - width=140, - height=38, - font=(FONT_FAMILY, 14), - corner_radius=10, - fg_color=FIELD_BG, - hover_color=FIELD_BORDER, - text_color=TEXT_PRIMARY, - border_width=1, - border_color=FIELD_BORDER, - command=on_cancel, - ).pack(side="left") + btn_frame.pack(fill="x", pady=(20, 0)) + ctk.CTkButton(btn_frame, text="Сохранить", height=38, + font=(FONT_FAMILY, 14, "bold"), corner_radius=10, + fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, + text_color="#ffffff", + command=on_save).pack(side="left", fill="x", expand=True, padx=(0, 8)) + ctk.CTkButton(btn_frame, text="Отмена", height=38, + font=(FONT_FAMILY, 14), corner_radius=10, + fg_color=FIELD_BG, hover_color=FIELD_BORDER, + text_color=TEXT_PRIMARY, border_width=1, + border_color=FIELD_BORDER, + command=on_cancel).pack(side="right", fill="x", expand=True) root.mainloop() @@ -705,7 +720,8 @@ def run_tray(): _config = _runtime.prepare() _runtime.reset_log_file() - setup_logging(_config.get("verbose", False)) + setup_logging(_config.get("verbose", False), + log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) log.info("TG WS Proxy tray app starting") log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE) diff --git a/macos.py b/macos.py index f502656..39c6946 100644 --- a/macos.py +++ b/macos.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +import logging.handlers import os import psutil import subprocess @@ -43,6 +44,9 @@ DEFAULT_CONFIG = { "host": "127.0.0.1", "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "verbose": False, + "log_max_mb": 5, + "buf_kb": 256, + "pool_size": 4, } _app: Optional[object] = None @@ -147,8 +151,8 @@ def save_config(cfg: dict): _runtime.save_config(cfg) -def setup_logging(verbose: bool = False): - _runtime.setup_logging(verbose) +def setup_logging(verbose: bool = False, log_max_mb: float = 5): + _runtime.setup_logging(verbose, log_max_mb=log_max_mb) # Menubar icon @@ -358,11 +362,34 @@ def _edit_config_dialog(): # Verbose verbose = _ask_yes_no("Включить подробное логирование (verbose)?") + # Advanced settings + adv_str = _osascript_input( + "Расширенные настройки (буфер KB, WS пул, лог MB):\n" + "Формат: buf_kb,pool_size,log_max_mb", + f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])}," + f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])}," + f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}") + + adv = {} + if adv_str: + parts = [s.strip() for s in adv_str.split(',')] + keys = [("buf_kb", int), ("pool_size", int), + ("log_max_mb", float)] + for i, (k, typ) in enumerate(keys): + if i < len(parts): + try: + adv[k] = typ(parts[i]) + except ValueError: + pass + new_cfg = { "host": host, "port": port, "dc_ip": dc_lines, "verbose": verbose, + "buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])), + "pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])), + "log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])), } save_config(new_cfg) log.info("Config saved: %s", new_cfg) @@ -495,7 +522,8 @@ def run_menubar(): _config = _runtime.prepare() _runtime.reset_log_file() - setup_logging(_config.get("verbose", False)) + setup_logging(_config.get("verbose", False), + log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) log.info("TG WS Proxy menubar app starting") log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE) diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py index 89e3450..357ac3d 100644 --- a/proxy/app_runtime.py +++ b/proxy/app_runtime.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio as _asyncio import json import logging +import logging.handlers import sys import threading import time @@ -17,6 +18,9 @@ DEFAULT_CONFIG = { "host": "127.0.0.1", "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "verbose": False, + "log_max_mb": 5, + "buf_kb": 256, + "pool_size": 4, } @@ -76,7 +80,7 @@ class ProxyAppRuntime: except Exception: pass - def setup_logging(self, verbose: bool = False): + def setup_logging(self, verbose: bool = False, log_max_mb: float = 5): self.ensure_dirs() root = logging.getLogger() root.setLevel(logging.DEBUG if verbose else logging.INFO) @@ -89,7 +93,12 @@ class ProxyAppRuntime: except Exception: pass - fh = logging.FileHandler(str(self.log_file), encoding="utf-8") + fh = logging.handlers.RotatingFileHandler( + str(self.log_file), + maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), + backupCount=0, + encoding="utf-8", + ) fh.setLevel(logging.DEBUG) fh.setFormatter(logging.Formatter( "%(asctime)s %(levelname)-5s %(name)s %(message)s", @@ -148,6 +157,9 @@ class ProxyAppRuntime: port = active_cfg.get("port", self.default_config["port"]) host = active_cfg.get("host", self.default_config["host"]) dc_ip_list = active_cfg.get("dc_ip", self.default_config["dc_ip"]) + buf_kb = active_cfg.get("buf_kb", self.default_config["buf_kb"]) + pool_size = active_cfg.get( + "pool_size", self.default_config["pool_size"]) try: dc_opt = self.parse_dc_ip_list(dc_ip_list) @@ -157,6 +169,9 @@ class ProxyAppRuntime: return False self.log.info("Starting proxy on %s:%d ...", host, port) + tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 + tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF + tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) self._proxy_thread = self.thread_factory( target=self._run_proxy_thread, args=(port, dc_opt, host), diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 8d9b276..059b7b7 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -4,6 +4,7 @@ import argparse import asyncio import base64 import logging +import logging.handlers import os import socket as _socket import ssl @@ -86,6 +87,8 @@ _dc_fail_until: Dict[Tuple[int, bool], float] = {} _DC_FAIL_COOLDOWN = 30.0 # seconds to keep reduced WS timeout after failure _WS_FAIL_TIMEOUT = 2.0 # quick-retry timeout after a recent WS failure +_ZERO_64 = b'\x00' * 64 + _ssl_ctx = ssl.create_default_context() _ssl_ctx.check_hostname = False @@ -130,6 +133,21 @@ def _xor_mask(data: bytes, mask: bytes) -> bytes: return (int.from_bytes(data, 'big') ^ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big') +# Pre-compiled struct formats +_st_BB = struct.Struct('>BB') +_st_BBH = struct.Struct('>BBH') +_st_BBQ = struct.Struct('>BBQ') +_st_BB4s = struct.Struct('>BB4s') +_st_BBH4s = struct.Struct('>BBH4s') +_st_BBQ4s = struct.Struct('>BBQ4s') +_st_H = struct.Struct('>H') +_st_Q = struct.Struct('>Q') +_st_I_net = struct.Struct('!I') +_st_Ih = struct.Struct(' bytes: - header = bytearray() - header.append(0x80 | opcode) # FIN=1 + opcode length = len(data) - mask_bit = 0x80 if mask else 0x00 + fb = 0x80 | opcode + if not mask: + if length < 126: + return _st_BB.pack(fb, length) + data + if length < 65536: + return _st_BBH.pack(fb, 126, length) + data + return _st_BBQ.pack(fb, 127, length) + data + + mask_key = os.urandom(4) + masked = _xor_mask(data, mask_key) if length < 126: - header.append(mask_bit | length) - elif length < 65536: - header.append(mask_bit | 126) - header.extend(struct.pack('>H', length)) - else: - header.append(mask_bit | 127) - header.extend(struct.pack('>Q', length)) - - if mask: - mask_key = os.urandom(4) - header.extend(mask_key) - return bytes(header) + _xor_mask(data, mask_key) - return bytes(header) + data + return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked + if length < 65536: + return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked + return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked async def _read_frame(self) -> Tuple[int, bytes]: hdr = await self.reader.readexactly(2) opcode = hdr[0] & 0x0F - is_masked = bool(hdr[1] & 0x80) length = hdr[1] & 0x7F if length == 126: - length = struct.unpack('>H', - await self.reader.readexactly(2))[0] + length = _st_H.unpack( + await self.reader.readexactly(2))[0] elif length == 127: - length = struct.unpack('>Q', - await self.reader.readexactly(8))[0] + length = _st_Q.unpack( + await self.reader.readexactly(8))[0] - if is_masked: + if hdr[1] & 0x80: mask_key = await self.reader.readexactly(4) payload = await self.reader.readexactly(length) return opcode, _xor_mask(payload, mask_key) @@ -355,7 +371,7 @@ def _human_bytes(n: int) -> str: def _is_telegram_ip(ip: str) -> bool: try: - n = struct.unpack('!I', _socket.inet_aton(ip))[0] + n = _st_I_net.unpack(_socket.inet_aton(ip))[0] return any(lo <= n <= hi for lo, hi in _TG_RANGES) except OSError: return False @@ -381,7 +397,7 @@ def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]: dc_raw = struct.unpack(' len(plain): + if pos + 4 > plain_len: break msg_len = ( - struct.unpack_from(' len(plain): + if msg_len == 0 or pos + msg_len > plain_len: break pos += msg_len boundaries.append(pos) @@ -630,8 +647,9 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label, chunk = await reader.read(65536) if not chunk: break - _stats.bytes_up += len(chunk) - up_bytes += len(chunk) + n = len(chunk) + _stats.bytes_up += n + up_bytes += n up_packets += 1 if splitter: parts = splitter.split(chunk) @@ -653,14 +671,12 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label, data = await ws.recv() if data is None: break - _stats.bytes_down += len(data) - down_bytes += len(data) + n = len(data) + _stats.bytes_down += n + down_bytes += n down_packets += 1 writer.write(data) - # drain only when kernel buffer is filling up - buf = writer.transport.get_write_buffer_size() - if buf > _SEND_BUF: - await writer.drain() + await writer.drain() except (asyncio.CancelledError, ConnectionError, OSError): return except Exception as e: @@ -700,26 +716,27 @@ async def _bridge_tcp(reader, writer, remote_reader, remote_writer, label, dc=None, dst=None, port=None, is_media=False): """Bidirectional TCP <-> TCP forwarding (for fallback).""" - async def forward(src, dst_w, tag): + async def forward(src, dst_w, is_up): try: while True: data = await src.read(65536) if not data: break - if 'up' in tag: - _stats.bytes_up += len(data) + n = len(data) + if is_up: + _stats.bytes_up += n else: - _stats.bytes_down += len(data) + _stats.bytes_down += n dst_w.write(data) await dst_w.drain() except asyncio.CancelledError: pass except Exception as e: - log.debug("[%s] %s ended: %s", label, tag, e) + log.debug("[%s] forward ended: %s", label, e) tasks = [ - asyncio.create_task(forward(reader, remote_writer, 'up')), - asyncio.create_task(forward(remote_reader, writer, 'down')), + asyncio.create_task(forward(reader, remote_writer, True)), + asyncio.create_task(forward(remote_reader, writer, False)), ] try: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) @@ -760,8 +777,12 @@ async def _pipe(r, w): pass +_SOCKS5_REPLIES = {s: bytes([0x05, s, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + for s in (0x00, 0x05, 0x07, 0x08)} + + def _socks5_reply(status): - return bytes([0x05, status, 0x00, 0x01]) + b'\x00' * 6 + return _SOCKS5_REPLIES[status] async def _tcp_fallback(reader, writer, dst, port, init, label, @@ -829,7 +850,7 @@ async def _handle_client(reader, writer): writer.close() return - port = struct.unpack('!H', await reader.readexactly(2))[0] + port = _st_H.unpack(await reader.readexactly(2))[0] if ':' in dst: log.error( @@ -1135,6 +1156,16 @@ def main(): ' --dc-ip 2:149.154.167.220') ap.add_argument('-v', '--verbose', action='store_true', help='Debug logging') + ap.add_argument('--log-file', type=str, default=None, metavar='PATH', + help='Log to file with rotation (default: stderr only)') + ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB', + help='Max log file size in MB before rotation (default 5)') + ap.add_argument('--log-backups', type=int, default=0, metavar='N', + help='Number of rotated log files to keep (default 0)') + ap.add_argument('--buf-kb', type=int, default=256, metavar='KB', + help='Socket send/recv buffer size in KB (default 256)') + ap.add_argument('--pool-size', type=int, default=4, metavar='N', + help='WS connection pool size per DC (default 4, min 0)') args = ap.parse_args() if not args.dc_ip: @@ -1146,11 +1177,30 @@ def main(): log.error(str(e)) sys.exit(1) - logging.basicConfig( - level=logging.DEBUG if args.verbose else logging.INFO, - format='%(asctime)s %(levelname)-5s %(message)s', - datefmt='%H:%M:%S', - ) + log_level = logging.DEBUG if args.verbose else logging.INFO + log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', + datefmt='%H:%M:%S') + root = logging.getLogger() + root.setLevel(log_level) + + console = logging.StreamHandler() + console.setFormatter(log_fmt) + root.addHandler(console) + + if args.log_file: + fh = logging.handlers.RotatingFileHandler( + args.log_file, + maxBytes=max(32 * 1024, args.log_max_mb * 1024 * 1024), + backupCount=max(0, args.log_backups), + encoding='utf-8', + ) + fh.setFormatter(log_fmt) + root.addHandler(fh) + + global _RECV_BUF, _SEND_BUF, _WS_POOL_SIZE + _RECV_BUF = max(4, args.buf_kb) * 1024 + _SEND_BUF = _RECV_BUF + _WS_POOL_SIZE = max(0, args.pool_size) try: asyncio.run(_run(args.port, dc_opt, host=args.host)) diff --git a/stress_test.py b/stress_test.py new file mode 100644 index 0000000..f2fd88c --- /dev/null +++ b/stress_test.py @@ -0,0 +1,246 @@ +""" +Stress-test: сравнение OLD vs NEW реализаций горячих функций прокси. + +Тестируются: + 1. _build_frame — сборка WS-фрейма (masked binary) + 2. _build_frame — сборка WS-фрейма (unmasked) + 3. _socks5_reply — генерация SOCKS5-ответа + 4. _dc_from_init XOR-часть (bytes(a^b for …) vs int.from_bytes) + 5. mask key generation (os.urandom vs PRNG) +""" + +import gc +import os +import random +import struct +import time + +# ── Размеры данных, типичные для Telegram ────────────────────────── +SMALL = 64 # init-пакет / ack +MEDIUM = 1024 # текстовое сообщение +LARGE = 65536 # фото / голосовое + + +# ═══════════════════════════════════════════════════════════════════ +# XOR mask (не менялся — для полноты) +# ═══════════════════════════════════════════════════════════════════ + +def xor_mask(data: bytes, mask: bytes) -> bytes: + if not data: + return data + n = len(data) + mask_rep = (mask * (n // 4 + 1))[:n] + return (int.from_bytes(data, 'big') ^ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big') + + +# ═══════════════════════════════════════════════════════════════════ +# _build_frame +# ═══════════════════════════════════════════════════════════════════ + +def build_frame_old(opcode: int, data: bytes, mask: bool = False) -> bytes: + """Старая: bytearray + append/extend + os.urandom.""" + header = bytearray() + header.append(0x80 | opcode) + length = len(data) + mask_bit = 0x80 if mask else 0x00 + + if length < 126: + header.append(mask_bit | length) + elif length < 65536: + header.append(mask_bit | 126) + header.extend(struct.pack('>H', length)) + else: + header.append(mask_bit | 127) + header.extend(struct.pack('>Q', length)) + + if mask: + mask_key = os.urandom(4) + header.extend(mask_key) + return bytes(header) + xor_mask(data, mask_key) + return bytes(header) + data + + +# ── Новая: pre-compiled struct + PRNG ────────────────────────────── +_st_BB = struct.Struct('>BB') +_st_BBH = struct.Struct('>BBH') +_st_BBQ = struct.Struct('>BBQ') +_st_BB4s = struct.Struct('>BB4s') +_st_BBH4s = struct.Struct('>BBH4s') +_st_BBQ4s = struct.Struct('>BBQ4s') + +_mask_rng = random.Random(int.from_bytes(os.urandom(16), 'big')) +_mask_pack = struct.Struct('>I').pack + +def _random_mask_key() -> bytes: + return _mask_pack(_mask_rng.getrandbits(32)) + +def build_frame_new(opcode: int, data: bytes, mask: bool = False) -> bytes: + """Новая: struct.pack + PRNG mask.""" + length = len(data) + fb = 0x80 | opcode + + if not mask: + if length < 126: + return _st_BB.pack(fb, length) + data + if length < 65536: + return _st_BBH.pack(fb, 126, length) + data + return _st_BBQ.pack(fb, 127, length) + data + + mask_key = _random_mask_key() + masked = xor_mask(data, mask_key) + if length < 126: + return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked + if length < 65536: + return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked + return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked + + +# ═══════════════════════════════════════════════════════════════════ +# _socks5_reply +# ═══════════════════════════════════════════════════════════════════ + +def socks5_reply_old(status): + return bytes([0x05, status, 0x00, 0x01]) + b'\x00' * 6 + +_SOCKS5_REPLIES = {s: bytes([0x05, s, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + for s in (0x00, 0x05, 0x07, 0x08)} + +def socks5_reply_new(status): + return _SOCKS5_REPLIES[status] + + +# ═══════════════════════════════════════════════════════════════════ +# dc_from_init XOR (8 байт keystream ^ data) +# ═══════════════════════════════════════════════════════════════════ + +def dc_xor_old(data8: bytes, ks8: bytes) -> bytes: + """Старая: генераторное выражение.""" + return bytes(a ^ b for a, b in zip(data8, ks8)) + +def dc_xor_new(data8: bytes, ks8: bytes) -> bytes: + """Новая: int.from_bytes.""" + return (int.from_bytes(data8, 'big') ^ int.from_bytes(ks8, 'big')).to_bytes(8, 'big') + + +# ═══════════════════════════════════════════════════════════════════ +# mask key: os.urandom(4) vs PRNG +# ═══════════════════════════════════════════════════════════════════ + +def mask_key_old() -> bytes: + return os.urandom(4) + +def mask_key_new() -> bytes: + return _random_mask_key() + + +# ═══════════════════════════════════════════════════════════════════ +# Бенчмарк +# ═══════════════════════════════════════════════════════════════════ + +def bench(func, args_list: list, iters: int) -> float: + gc.collect() + for i in range(min(100, iters)): + func(*args_list[i % len(args_list)]) + start = time.perf_counter() + for i in range(iters): + func(*args_list[i % len(args_list)]) + elapsed = time.perf_counter() - start + return elapsed / iters * 1_000_000 # мкс + + +def compare(name: str, old_fn, new_fn, args_list: list, iters: int): + t_old = bench(old_fn, args_list, iters) + t_new = bench(new_fn, args_list, iters) + speedup = t_old / t_new if t_new > 0 else float('inf') + marker = '✅' if speedup >= 1.0 else '⚠️' + print(f" {name:.<42s} OLD {t_old:8.3f} мкс | NEW {t_new:8.3f} мкс | {speedup:5.2f}x {marker}") + + +# ═══════════════════════════════════════════════════════════════════ + +def main(): + print("=" * 74) + print(" Stress Test: OLD vs NEW (горячие функции tg_ws_proxy)") + print("=" * 74) + + N = 500_000 + + # # ── 1. _build_frame masked ──────────────────────────────────── + # print(f"\n── _build_frame masked ({N:,} итераций) ──") + # for size, label in [(SMALL, "64B"), (MEDIUM, "1KB"), (LARGE, "64KB")]: + # data_list = [(0x2, os.urandom(size), True) for _ in range(1000)] + # compare(f"build_frame masked {label}", + # build_frame_old, build_frame_new, data_list, N) + + # # ── 2. _build_frame unmasked ────────────────────────────────── + # print(f"\n── _build_frame unmasked ({N:,} итераций) ──") + # for size, label in [(SMALL, "64B"), (MEDIUM, "1KB"), (LARGE, "64KB")]: + # data_list = [(0x2, os.urandom(size), False) for _ in range(1000)] + # compare(f"build_frame unmasked {label}", + # build_frame_old, build_frame_new, data_list, N) + + # # ── 3. mask key generation ──────────────────────────────────── + # print(f"\n── mask key: os.urandom(4) vs PRNG ({N:,} итераций) ──") + # compare("mask_key", mask_key_old, mask_key_new, [()] * 100, N) + + # # ── 4. _socks5_reply ───────────────────────────────────────── + N2 = 2_000_000 + # print(f"\n── _socks5_reply ({N2:,} итераций) ──") + # compare("socks5_reply", socks5_reply_old, socks5_reply_new, + # [(s,) for s in (0x00, 0x05, 0x07, 0x08)], N2) + + # # ── 5. dc_from_init XOR (8 bytes) ──────────────────────────── + # print(f"\n── dc_xor 8B: generator vs int.from_bytes ({N2:,} итераций) ──") + # compare("dc_xor_8B", dc_xor_old, dc_xor_new, + # [(os.urandom(8), os.urandom(8)) for _ in range(1000)], N2) + + # ── 6. _read_frame struct.unpack vs pre-compiled ───────────── + print(f"\n── struct unpack read-path ({N2:,} итераций) ──") + _st_H_pre = struct.Struct('>H') + _st_Q_pre = struct.Struct('>Q') + h_bufs = [(os.urandom(2),) for _ in range(1000)] + q_bufs = [(os.urandom(8),) for _ in range(1000)] + compare("unpack >H", + lambda b: struct.unpack('>H', b), + lambda b: _st_H_pre.unpack(b), + h_bufs, N2) + compare("unpack >Q", + lambda b: struct.unpack('>Q', b), + lambda b: _st_Q_pre.unpack(b), + q_bufs, N2) + + # ── 7. dc_from_init: 2x unpack vs 1x merged ───────────────── + print(f"\n── dc_from_init unpack: 2 calls vs 1 merged ({N2:,} итераций) ──") + _st_Ih = struct.Struct(' str: @@ -319,7 +323,7 @@ def _edit_config_dialog(): TEXT_SECONDARY = "#707579" FONT_FAMILY = "Segoe UI" - w, h = 420, 460 + w, h = 420, 540 if _supports_autostart(): h += 70 @@ -374,6 +378,30 @@ def _edit_config_dialog(): corner_radius=6, border_width=2, border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 8)) + # Advanced: buf_kb, pool_size, log_max_mb + adv_frame = ctk.CTkFrame(frame, fg_color="transparent") + adv_frame.pack(anchor="w", fill="x", pady=(4, 8)) + + for col, (lbl, key, w_) in enumerate([ + ("Буфер (KB, 256 default)", "buf_kb", 120), + ("WS пулов (4 default)", "pool_size", 120), + ("Log size (MB, 5 def)", "log_max_mb", 120), + ]): + col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") + col_frame.pack(side="left", padx=(0, 10)) + ctk.CTkLabel(col_frame, text=lbl, font=(FONT_FAMILY, 11), + text_color=TEXT_SECONDARY, anchor="w").pack(anchor="w") + ctk.CTkEntry(col_frame, width=w_, height=30, font=(FONT_FAMILY, 12), + corner_radius=8, fg_color=FIELD_BG, + border_color=FIELD_BORDER, border_width=1, + text_color=TEXT_PRIMARY, + textvariable=ctk.StringVar( + value=str(cfg.get(key, DEFAULT_CONFIG[key])) + )).pack(anchor="w") + + _adv_entries = list(adv_frame.winfo_children()) + _adv_keys = ["buf_kb", "pool_size", "log_max_mb"] + autostart_var = None if _supports_autostart(): autostart_var = ctk.BooleanVar(value=cfg["autostart"]) @@ -419,6 +447,17 @@ def _edit_config_dialog(): "verbose": verbose_var.get(), "autostart": (autostart_var.get() if autostart_var is not None else False), } + + for i, key in enumerate(_adv_keys): + col_frame = _adv_entries[i] + entry = col_frame.winfo_children()[1] + try: + val = float(entry.get().strip()) + if key in ("buf_kb", "pool_size"): + val = int(val) + new_cfg[key] = val + except ValueError: + new_cfg[key] = DEFAULT_CONFIG[key] save_config(new_cfg) _config.update(new_cfg) log.info("Config saved: %s", new_cfg) @@ -658,7 +697,8 @@ def run_tray(): _config = _runtime.prepare() _runtime.reset_log_file() - setup_logging(_config.get("verbose", False)) + setup_logging(_config.get("verbose", False), + log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) log.info("TG WS Proxy tray app starting") log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE)