11 Commits

Author SHA1 Message Date
Flowseal
cc00c6d040 Version bump 2026-04-26 16:58:48 +03:00
Flowseal
b3ed5c09db Windows auto update 2026-04-26 16:58:17 +03:00
Flowseal
b8556dc702 fix #775 2026-04-26 16:26:50 +03:00
Flowseal
28be00ea9e docs update 2026-04-19 17:32:54 +03:00
Flowseal
5795de00b1 Version bump 2026-04-18 18:59:46 +03:00
Flowseal
c5fa5b7f3e fix: cfproxy user domain not set via CLI #741 2026-04-18 18:59:16 +03:00
Flowseal
a70e50b9f3 refactor 2026-04-18 16:58:49 +03:00
Flowseal
059ca8760f moved some dubug logs to warning level 2026-04-18 15:49:42 +03:00
Flowseal
0c8d0f160a better exception logging 2026-04-18 15:45:15 +03:00
Flowseal
791708cc3d ws_blacklsit annotation fix 2026-04-18 15:25:11 +03:00
Flowseal
1abcbf86fe gitignore clear 2026-04-18 15:23:56 +03:00
15 changed files with 478 additions and 188 deletions

View File

@@ -5,3 +5,4 @@ zaewayzmplad.com
twdmbzcm.com twdmbzcm.com
awzwsldi.com awzwsldi.com
clngqrflngqin.com clngqrflngqin.com
tjacxbqtj.com

5
.gitignore vendored
View File

@@ -24,9 +24,4 @@ Thumbs.db
Desktop.ini Desktop.ini
.DS_Store .DS_Store
# Project-specific (not for the repo)
scan_ips.py
scan.txt
AyuGramDesktop-dev/
tweb-master/
/icon.icns /icon.icns

View File

@@ -309,7 +309,7 @@ def _maybe_notify_update_async() -> None:
): ):
webbrowser.open(url) webbrowser.open(url)
except Exception as exc: except Exception as exc:
log.debug("Update check failed: %s", exc) log.warning("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start() threading.Thread(target=_work, daemon=True, name="update-check").start()

View File

@@ -4,8 +4,8 @@
# http://msdn.microsoft.com/en-us/library/ms646997.aspx # http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo( VSVersionInfo(
ffi=FixedFileInfo( ffi=FixedFileInfo(
filevers=(1, 6, 2, 0), filevers=(1, 6, 5, 0),
prodvers=(1, 6, 2, 0), prodvers=(1, 6, 5, 0),
mask=0x3f, mask=0x3f,
flags=0x0, flags=0x0,
OS=0x40004, OS=0x40004,
@@ -21,12 +21,12 @@ VSVersionInfo(
[ [
StringStruct(u'CompanyName', u'Flowseal'), StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'), StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.6.2.0'), StringStruct(u'FileVersion', u'1.6.5.0'),
StringStruct(u'InternalName', u'TgWsProxy'), StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'), StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'), StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'), StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.6.2.0'), StringStruct(u'ProductVersion', u'1.6.5.0'),
] ]
) )
] ]

View File

@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config from .config import parse_dc_ip_list, proxy_config
from .utils import get_link_host from .utils import get_link_host
__version__ = "1.6.3" __version__ = "1.6.5"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"]

View File

@@ -29,7 +29,8 @@ class _Balancer:
def get_domains_for_dc(self, dc_id: int) -> Iterator[str]: def get_domains_for_dc(self, dc_id: int) -> Iterator[str]:
current_domain = self._dc_to_domain.get(dc_id) current_domain = self._dc_to_domain.get(dc_id)
yield current_domain if current_domain is not None:
yield current_domain
shuffled_domains = self.domains[:] shuffled_domains = self.domains[:]
random.shuffle(shuffled_domains) random.shuffle(shuffled_domains)

View File

@@ -127,7 +127,7 @@ class MsgSplitter:
async def do_fallback(reader, writer, relay_init, label, async def do_fallback(reader, writer, relay_init, label,
dc, is_media, media_tag, dc: int, is_media: bool, media_tag: str,
ctx: CryptoCtx, splitter=None): ctx: CryptoCtx, splitter=None):
fallback_dst = DC_DEFAULT_IPS.get(dc) fallback_dst = DC_DEFAULT_IPS.get(dc)
use_cf = proxy_config.fallback_cfproxy use_cf = proxy_config.fallback_cfproxy
@@ -141,9 +141,9 @@ async def do_fallback(reader, writer, relay_init, label,
for method in methods: for method in methods:
if method == 'cf': if method == 'cf':
ok = await _cfproxy_fallback( ok = await _cfproxy_fallback(
reader, writer, relay_init, label, reader, writer, relay_init, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
ctx=ctx, splitter=splitter) splitter=splitter)
if ok: if ok:
return True return True
elif method == 'tcp' and fallback_dst: elif method == 'tcp' and fallback_dst:
@@ -151,15 +151,16 @@ async def do_fallback(reader, writer, relay_init, label,
label, dc, media_tag, fallback_dst) label, dc, media_tag, fallback_dst)
ok = await _tcp_fallback( ok = await _tcp_fallback(
reader, writer, fallback_dst, 443, reader, writer, fallback_dst, 443,
relay_init, label, dc=dc, is_media=is_media, ctx=ctx) relay_init, label, ctx)
if ok: if ok:
return True return True
return False return False
async def _cfproxy_fallback(reader, writer, relay_init, label, async def _cfproxy_fallback(reader, writer, relay_init, label,
dc=None, is_media=False, ctx: CryptoCtx,
ctx: CryptoCtx = None, splitter=None): dc: int, is_media: bool,
splitter=None):
media_tag = ' media' if is_media else '' media_tag = ' media' if is_media else ''
ws = None ws = None
chosen_domain = None chosen_domain = None
@@ -175,7 +176,7 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
break break
except Exception as exc: except Exception as exc:
log.warning("[%s] DC%d%s CF proxy failed: %s", log.warning("[%s] DC%d%s CF proxy failed: %s",
label, dc, media_tag, exc) label, dc, media_tag, repr(exc))
if ws is None: if ws is None:
return False return False
@@ -185,34 +186,32 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
stats.connections_cfproxy += 1 stats.connections_cfproxy += 1
await ws.send(relay_init) await ws.send(relay_init)
await bridge_ws_reencrypt(reader, writer, ws, label, await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
ctx=ctx, splitter=splitter) splitter=splitter)
return True return True
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
dc=None, is_media=False, ctx: CryptoCtx = None):
try: try:
rr, rw = await asyncio.wait_for( rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10) asyncio.open_connection(dst, port), timeout=10)
except Exception as exc: except Exception as exc:
log.warning("[%s] TCP fallback to %s:%d failed: %s", log.warning("[%s] TCP fallback to %s:%d failed: %s",
label, dst, port, exc) label, dst, port, repr(exc))
return False return False
stats.connections_tcp_fallback += 1 stats.connections_tcp_fallback += 1
rw.write(relay_init) rw.write(relay_init)
await rw.drain() await rw.drain()
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
dc=dc, is_media=is_media, ctx=ctx)
return True return True
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label, async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
ctx: CryptoCtx,
dc=None, is_media=False, dc=None, is_media=False,
ctx: CryptoCtx = None, splitter: Optional[MsgSplitter] = None):
splitter: MsgSplitter = None):
""" """
Bidirectional TCP(client) <-> WS(telegram) with re-encryption. Bidirectional TCP(client) <-> WS(telegram) with re-encryption.
client ciphertext decrypt(clt_key) encrypt(tg_key) WS client ciphertext decrypt(clt_key) encrypt(tg_key) WS
@@ -309,8 +308,7 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer, async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
label, dc=None, is_media=False, label, ctx: CryptoCtx):
ctx: CryptoCtx = None):
"""Bidirectional TCP <-> TCP with re-encryption.""" """Bidirectional TCP <-> TCP with re-encryption."""
async def forward(src, dst_w, is_up): async def forward(src, dst_w, is_up):

View File

@@ -66,7 +66,7 @@ def _fetch_cfproxy_domain_list() -> List[str]:
] ]
return [_dd(d) for d in encoded] return [_dd(d) for d in encoded]
except Exception as exc: except Exception as exc:
log.warning("Failed to fetch CF proxy domain list: %s", exc) log.warning("Failed to fetch CF proxy domain list: %s", repr(exc))
return [] return []

View File

@@ -213,8 +213,8 @@ async def proxy_to_masking_domain(reader, writer, initial_data: bytes,
up_reader, up_writer = await asyncio.wait_for( up_reader, up_writer = await asyncio.wait_for(
asyncio.open_connection(domain, 443), timeout=10) asyncio.open_connection(domain, 443), timeout=10)
except Exception as exc: except Exception as exc:
log.debug("[%s] masking: cannot connect to %s:443: %s", log.warning("[%s] masking: cannot connect to %s:443: %s",
label, domain, exc) label, domain, repr(exc))
return return
log.debug("[%s] masking -> %s:443", label, domain) log.debug("[%s] masking -> %s:443", label, domain)

View File

@@ -25,7 +25,7 @@ _ssl_ctx.verify_mode = ssl.CERT_NONE
class WsHandshakeError(Exception): class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str, def __init__(self, status_code: int, status_line: str,
headers: dict = None, location: str = None): headers: Optional[dict] = None, location: Optional[str] = None):
self.status_code = status_code self.status_code = status_code
self.status_line = status_line self.status_line = status_line
self.headers = headers or {} self.headers = headers or {}
@@ -96,9 +96,6 @@ class RawWebSocket:
f'Sec-WebSocket-Key: {ws_key}\r\n' f'Sec-WebSocket-Key: {ws_key}\r\n'
f'Sec-WebSocket-Version: 13\r\n' f'Sec-WebSocket-Version: 13\r\n'
f'Sec-WebSocket-Protocol: binary\r\n' f'Sec-WebSocket-Protocol: binary\r\n'
f'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
f'AppleWebKit/537.36 (KHTML, like Gecko) '
f'Chrome/131.0.0.0 Safari/537.36\r\n'
f'\r\n' f'\r\n'
) )
writer.write(req.encode()) writer.write(req.encode())

View File

@@ -35,8 +35,8 @@ log = logging.getLogger('tg-mtproto-proxy')
DC_FAIL_COOLDOWN = 30.0 DC_FAIL_COOLDOWN = 30.0
WS_FAIL_TIMEOUT = 2.0 WS_FAIL_TIMEOUT = 2.0
ws_blacklist: Set[Tuple[int, bool]] = set() ws_blacklist: Set[str] = set()
dc_fail_until: Dict[Tuple[int, bool], float] = {} dc_fail_until: Dict[str, float] = {}
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]: def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
@@ -191,7 +191,7 @@ class _WsPool:
except Exception: except Exception:
pass pass
async def warmup(self, dc_redirects: Dict[int, Optional[str]]): async def warmup(self, dc_redirects: Dict[int, str]):
for dc, target_ip in dc_redirects.items(): for dc, target_ip in dc_redirects.items():
if target_ip is None: if target_ip is None:
continue continue
@@ -207,6 +207,146 @@ class _WsPool:
_ws_pool = _WsPool() _ws_pool = _WsPool()
async def _read_client_init(reader, writer, secret, label, masking):
if proxy_config.proxy_protocol:
try:
pp_line = await asyncio.wait_for(
reader.readline(), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] disconnected during PROXY header", label)
return None
pp_text = pp_line.decode('ascii', errors='replace').strip()
if pp_text.startswith('PROXY '):
parts = pp_text.split()
if len(parts) >= 6:
label = f"{parts[2]}:{parts[4]}"
log.debug("[%s] PROXY protocol: %s", label, pp_text)
else:
log.debug("[%s] expected PROXY header, got: %r", label,
pp_text[:60])
try:
first_byte = await asyncio.wait_for(
reader.readexactly(1), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return None
if first_byte[0] == TLS_RECORD_HANDSHAKE and masking:
try:
hdr_rest = await asyncio.wait_for(
reader.readexactly(4), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete TLS record header", label)
return None
tls_header = first_byte + hdr_rest
record_len = struct.unpack('>H', tls_header[3:5])[0]
try:
record_body = await asyncio.wait_for(
reader.readexactly(record_len), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete TLS record body", label)
return None
client_hello = tls_header + record_body
tls_result = verify_client_hello(client_hello, secret)
if tls_result is None:
log.debug("[%s] Fake TLS verify failed (size=%d rec=%d) "
"-> masking",
label, len(client_hello), record_len)
await proxy_to_masking_domain(
reader, writer, client_hello, masking, label)
return None
client_random, session_id, ts = tls_result
log.debug("[%s] Fake TLS handshake ok (ts=%d)", label, ts)
server_hello = build_server_hello(secret, client_random, session_id)
writer.write(server_hello)
await writer.drain()
tls_stream = FakeTlsStream(reader, writer)
try:
handshake = await asyncio.wait_for(
tls_stream.readexactly(HANDSHAKE_LEN), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete obfs2 init inside TLS", label)
return None
return handshake, tls_stream, tls_stream, label
elif masking:
log.debug("[%s] non-TLS byte 0x%02X -> HTTP redirect", label,
first_byte[0])
redirect = (
f"HTTP/1.1 301 Moved Permanently\r\n"
f"Location: https://{masking}/\r\n"
f"Content-Length: 0\r\n"
f"Connection: close\r\n\r\n"
).encode()
writer.write(redirect)
await writer.drain()
return None
else:
try:
rest = await asyncio.wait_for(
reader.readexactly(HANDSHAKE_LEN - 1), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return None
return first_byte + rest, reader, writer, label
def _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init):
# key = SHA256(prekey + secret), iv from handshake
# "dec" = decrypt data from client; "enc" = encrypt data to client
clt_dec_prekey = client_dec_prekey_iv[:PREKEY_LEN]
clt_dec_iv = client_dec_prekey_iv[PREKEY_LEN:]
clt_dec_key = hashlib.sha256(clt_dec_prekey + secret).digest()
clt_enc_prekey_iv = client_dec_prekey_iv[::-1]
clt_enc_key = hashlib.sha256(
clt_enc_prekey_iv[:PREKEY_LEN] + secret).digest()
clt_enc_iv = clt_enc_prekey_iv[PREKEY_LEN:]
clt_decryptor = Cipher(
algorithms.AES(clt_dec_key), modes.CTR(clt_dec_iv)
).encryptor()
clt_encryptor = Cipher(
algorithms.AES(clt_enc_key), modes.CTR(clt_enc_iv)
).encryptor()
# fast-forward client decryptor past the 64-byte init
clt_decryptor.update(ZERO_64)
# relay side: standard obfuscation (no secret hash, raw key)
relay_enc_key = relay_init[SKIP_LEN:SKIP_LEN + PREKEY_LEN]
relay_enc_iv = relay_init[SKIP_LEN + PREKEY_LEN:
SKIP_LEN + PREKEY_LEN + IV_LEN]
relay_dec_prekey_iv = relay_init[SKIP_LEN:
SKIP_LEN + PREKEY_LEN + IV_LEN][::-1]
relay_dec_key = relay_dec_prekey_iv[:KEY_LEN]
relay_dec_iv = relay_dec_prekey_iv[KEY_LEN:]
tg_encryptor = Cipher(
algorithms.AES(relay_enc_key), modes.CTR(relay_enc_iv)
).encryptor()
tg_decryptor = Cipher(
algorithms.AES(relay_dec_key), modes.CTR(relay_dec_iv)
).encryptor()
tg_encryptor.update(ZERO_64)
return CryptoCtx(clt_decryptor, clt_encryptor, tg_encryptor, tg_decryptor)
async def _handle_client(reader, writer, secret: bytes): async def _handle_client(reader, writer, secret: bytes):
stats.connections_total += 1 stats.connections_total += 1
stats.connections_active += 1 stats.connections_active += 1
@@ -215,115 +355,25 @@ async def _handle_client(reader, writer, secret: bytes):
set_sock_opts(writer.transport, proxy_config.buffer_size) set_sock_opts(writer.transport, proxy_config.buffer_size)
tls_stream = None
masking = proxy_config.fake_tls_domain
try: try:
if proxy_config.proxy_protocol: init = await _read_client_init(
try: reader, writer, secret, label, proxy_config.fake_tls_domain)
pp_line = await asyncio.wait_for( if init is None:
reader.readline(), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] disconnected during PROXY header", label)
return
pp_text = pp_line.decode('ascii', errors='replace').strip()
if pp_text.startswith('PROXY '):
parts = pp_text.split()
if len(parts) >= 6:
label = f"{parts[2]}:{parts[4]}"
log.debug("[%s] PROXY protocol: %s", label, pp_text)
else:
log.debug("[%s] expected PROXY header, got: %r", label,
pp_text[:60])
try:
first_byte = await asyncio.wait_for(
reader.readexactly(1), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return return
if first_byte[0] == TLS_RECORD_HANDSHAKE and masking: handshake, clt_reader, clt_writer, label = init
try:
hdr_rest = await asyncio.wait_for(
reader.readexactly(4), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete TLS record header", label)
return
tls_header = first_byte + hdr_rest
record_len = struct.unpack('>H', tls_header[3:5])[0]
try:
record_body = await asyncio.wait_for(
reader.readexactly(record_len), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete TLS record body", label)
return
client_hello = tls_header + record_body
tls_result = verify_client_hello(client_hello, secret)
if tls_result is None:
log.debug("[%s] Fake TLS verify failed (size=%d rec=%d) "
"-> masking",
label, len(client_hello), record_len)
await proxy_to_masking_domain(
reader, writer, client_hello, masking, label)
return
client_random, session_id, ts = tls_result
log.debug("[%s] Fake TLS handshake ok (ts=%d)", label, ts)
server_hello = build_server_hello(secret, client_random, session_id)
writer.write(server_hello)
await writer.drain()
tls_stream = FakeTlsStream(reader, writer)
try:
handshake = await asyncio.wait_for(
tls_stream.readexactly(HANDSHAKE_LEN), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete obfs2 init inside TLS", label)
return
elif masking:
log.debug("[%s] non-TLS byte 0x%02X -> HTTP redirect", label,
first_byte[0])
redirect = (
f"HTTP/1.1 301 Moved Permanently\r\n"
f"Location: https://{masking}/\r\n"
f"Content-Length: 0\r\n"
f"Connection: close\r\n\r\n"
).encode()
writer.write(redirect)
await writer.drain()
return
else:
try:
rest = await asyncio.wait_for(
reader.readexactly(HANDSHAKE_LEN - 1), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return
handshake = first_byte + rest
result = _try_handshake(handshake, secret) result = _try_handshake(handshake, secret)
if result is None: if result is None:
stats.connections_bad += 1 stats.connections_bad += 1
log.debug("[%s] bad handshake (wrong secret or proto)", label) log.warning("[%s] bad handshake (wrong secret or proto)", label)
try: try:
drain_src = tls_stream or reader while await clt_reader.read(4096):
while await drain_src.read(4096):
pass pass
except Exception: except Exception:
pass pass
return return
clt_reader = tls_stream or reader
clt_writer = tls_stream or writer
dc, is_media, proto_tag, client_dec_prekey_iv = result dc, is_media, proto_tag, client_dec_prekey_iv = result
if proto_tag == PROTO_TAG_ABRIDGED: if proto_tag == PROTO_TAG_ABRIDGED:
@@ -339,48 +389,7 @@ async def _handle_client(reader, writer, secret: bytes):
label, dc, ' media' if is_media else '', proto_int) label, dc, ' media' if is_media else '', proto_int)
relay_init = _generate_relay_init(proto_tag, dc_idx) relay_init = _generate_relay_init(proto_tag, dc_idx)
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init)
# key = SHA256(prekey + secret), iv from handshake
# "dec" = decrypt data from client; "enc" = encrypt data to client
clt_dec_prekey = client_dec_prekey_iv[:PREKEY_LEN]
clt_dec_iv = client_dec_prekey_iv[PREKEY_LEN:]
clt_dec_key = hashlib.sha256(clt_dec_prekey + secret).digest()
clt_enc_prekey_iv = client_dec_prekey_iv[::-1]
clt_enc_key = hashlib.sha256(
clt_enc_prekey_iv[:PREKEY_LEN] + secret).digest()
clt_enc_iv = clt_enc_prekey_iv[PREKEY_LEN:]
clt_decryptor = Cipher(
algorithms.AES(clt_dec_key), modes.CTR(clt_dec_iv)
).encryptor()
clt_encryptor = Cipher(
algorithms.AES(clt_enc_key), modes.CTR(clt_enc_iv)
).encryptor()
# fast-forward client decryptor past the 64-byte init
clt_decryptor.update(ZERO_64)
# relay side: standard obfuscation (no secret hash, raw key)
relay_enc_key = relay_init[SKIP_LEN:SKIP_LEN + PREKEY_LEN]
relay_enc_iv = relay_init[SKIP_LEN + PREKEY_LEN:
SKIP_LEN + PREKEY_LEN + IV_LEN]
relay_dec_prekey_iv = relay_init[SKIP_LEN:
SKIP_LEN + PREKEY_LEN + IV_LEN][::-1]
relay_dec_key = relay_dec_prekey_iv[:KEY_LEN]
relay_dec_iv = relay_dec_prekey_iv[KEY_LEN:]
tg_encryptor = Cipher(
algorithms.AES(relay_enc_key), modes.CTR(relay_enc_iv)
).encryptor()
tg_decryptor = Cipher(
algorithms.AES(relay_dec_key), modes.CTR(relay_dec_iv)
).encryptor()
tg_encryptor.update(ZERO_64)
ctx = CryptoCtx(clt_decryptor, clt_encryptor, tg_encryptor, tg_decryptor)
dc_key = f'{dc}{"m" if is_media else ""}' dc_key = f'{dc}{"m" if is_media else ""}'
media_tag = " media" if is_media else "" media_tag = " media" if is_media else ""
@@ -448,7 +457,7 @@ async def _handle_client(reader, writer, secret: bytes):
stats.ws_errors += 1 stats.ws_errors += 1
all_redirects = False all_redirects = False
log.warning("[%s] DC%d%s WS connect failed: %s", log.warning("[%s] DC%d%s WS connect failed: %s",
label, dc, media_tag, exc) label, dc, media_tag, repr(exc))
# WS failed -> fallback # WS failed -> fallback
if ws is None: if ws is None:
@@ -490,9 +499,9 @@ async def _handle_client(reader, writer, secret: bytes):
await ws.send(relay_init) await ws.send(relay_init)
await bridge_ws_reencrypt(clt_reader, clt_writer, ws, label, await bridge_ws_reencrypt(clt_reader, clt_writer, ws, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
ctx=ctx, splitter=splitter) splitter=splitter)
except asyncio.TimeoutError: except asyncio.TimeoutError:
log.warning("[%s] timeout during handshake", label) log.warning("[%s] timeout during handshake", label)
@@ -506,7 +515,7 @@ async def _handle_client(reader, writer, secret: bytes):
if getattr(exc, 'winerror', None) == 1236: if getattr(exc, 'winerror', None) == 1236:
log.debug("[%s] connection aborted by local system", label) log.debug("[%s] connection aborted by local system", label)
else: else:
log.error("[%s] unexpected OS error: %s", label, exc) log.error("[%s] unexpected OS error: %s", label, repr(exc))
except Exception as exc: except Exception as exc:
log.error("[%s] unexpected: %s", label, exc, exc_info=True) log.error("[%s] unexpected: %s", label, exc, exc_info=True)
finally: finally:
@@ -712,6 +721,7 @@ def main():
proxy_config.pool_size = max(0, args.pool_size) proxy_config.pool_size = max(0, args.pool_size)
proxy_config.fallback_cfproxy = not args.no_cfproxy proxy_config.fallback_cfproxy = not args.no_cfproxy
proxy_config.fallback_cfproxy_priority = args.cfproxy_priority proxy_config.fallback_cfproxy_priority = args.cfproxy_priority
proxy_config.cfproxy_user_domain = args.cfproxy_domain.strip()
proxy_config.fake_tls_domain = args.fake_tls_domain.strip() proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
proxy_config.proxy_protocol = args.proxy_protocol proxy_config.proxy_protocol = args.proxy_protocol

View File

@@ -31,7 +31,7 @@ def human_bytes(n: int) -> str:
for unit in ('B', 'KB', 'MB', 'GB'): for unit in ('B', 'KB', 'MB', 'GB'):
if abs(n) < 1024: if abs(n) < 1024:
return f"{n:.1f}{unit}" return f"{n:.1f}{unit}"
n /= 1024 n /= 1024 # type: ignore
return f"{n:.1f}TB" return f"{n:.1f}TB"

View File

@@ -132,7 +132,7 @@ def load_config() -> dict:
data.setdefault(k, v) data.setdefault(k, v)
return data return data
except Exception as exc: except Exception as exc:
log.warning("Failed to load config: %s", exc) log.warning("Failed to load config: %s", repr(exc))
return dict(DEFAULT_CONFIG) return dict(DEFAULT_CONFIG)
@@ -242,7 +242,7 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
try: try:
loop.run_until_complete(_run(stop_event=stop_ev)) loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc: except Exception as exc:
log.error("Proxy thread crashed: %s", exc) log.error("Proxy thread crashed: %s", repr(exc))
if "Address already in use" in str(exc) or "10048" in str(exc): if "Address already in use" in str(exc) or "10048" in str(exc):
on_port_busy( on_port_busy(
"Не удалось запустить прокси:\n" "Не удалось запустить прокси:\n"
@@ -391,7 +391,7 @@ def maybe_notify_update(
): ):
webbrowser.open(url) webbrowser.open(url)
except Exception as exc: except Exception as exc:
log.debug("Update check failed: %s", exc) log.warning("Update check failed: %s", repr(exc))
threading.Thread(target=_work, daemon=True, name="update-check").start() threading.Thread(target=_work, daemon=True, name="update-check").start()

View File

@@ -30,6 +30,7 @@ _state: Dict[str, Any] = {
"latest": None, "latest": None,
"html_url": None, "html_url": None,
"error": None, "error": None,
"assets": [],
} }
@@ -162,6 +163,7 @@ def run_check(current_version: str) -> None:
tag = (cache.get("tag_name") or "").strip() tag = (cache.get("tag_name") or "").strip()
if tag: if tag:
_apply_release_tag(tag, cache.get("html_url") or "", current_version) _apply_release_tag(tag, cache.get("html_url") or "", current_version)
_state["assets"] = cache.get("assets") or []
return return
err = cache.get("last_error") err = cache.get("last_error")
_state["error"] = ( _state["error"] = (
@@ -181,6 +183,7 @@ def run_check(current_version: str) -> None:
tag = (cache.get("tag_name") or "").strip() tag = (cache.get("tag_name") or "").strip()
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
_apply_release_tag(tag, url, current_version) _apply_release_tag(tag, url, current_version)
_state["assets"] = cache.get("assets") or []
if new_etag: if new_etag:
cache["etag"] = new_etag cache["etag"] = new_etag
_save_cache(cache_path, cache) _save_cache(cache_path, cache)
@@ -200,6 +203,13 @@ def run_check(current_version: str) -> None:
cache["etag"] = new_etag cache["etag"] = new_etag
cache["tag_name"] = tag cache["tag_name"] = tag
cache["html_url"] = html_url cache["html_url"] = html_url
assets = [
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
for a in (data.get("assets") or [])
if a.get("name") and a.get("browser_download_url")
]
_state["assets"] = assets
cache["assets"] = assets
cache.pop("last_error", None) cache.pop("last_error", None)
_save_cache(cache_path, cache) _save_cache(cache_path, cache)
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e: except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
@@ -221,3 +231,45 @@ def run_check(current_version: str) -> None:
def get_status() -> Dict[str, Any]: def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках).""" """Снимок состояния после run_check (для подписей в настройках)."""
return dict(_state) return dict(_state)
def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
assets = _state.get("assets") or []
if not assets:
return None
# Try SHA256 match against release asset digests
try:
import hashlib
h = hashlib.sha256()
with open(exe_path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
h.update(chunk)
exe_sha = h.hexdigest().lower()
for a in assets:
d = (a.get("digest") or "").lower()
if d.startswith("sha256:") and d[7:] == exe_sha:
return a["url"], a["name"]
except Exception:
pass
# Fallback
import struct
is_64 = struct.calcsize("P") * 8 == 64
try:
is_modern = sys.getwindowsversion().major >= 10
except Exception:
is_modern = True
if is_modern:
name = "TgWsProxy_windows.exe"
elif is_64:
name = "TgWsProxy_windows_7_64bit.exe"
else:
name = "TgWsProxy_windows_7_32bit.exe"
for a in assets:
if a.get("name") == name:
return a["url"], a["name"]
return None

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import ctypes import ctypes
import os import os
import subprocess
import sys import sys
import threading import threading
import time import time
@@ -40,7 +41,7 @@ from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
maybe_notify_update, quit_ctk, release_lock, restart_proxy, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url, save_config, start_proxy, stop_proxy, tg_proxy_url,
) )
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
@@ -101,7 +102,9 @@ _u32.MessageBoxW.restype = ctypes.c_int
_MB_OK_ERR = 0x10 _MB_OK_ERR = 0x10
_MB_OK_INFO = 0x40 _MB_OK_INFO = 0x40
_MB_YESNO_Q = 0x24 _MB_YESNO_Q = 0x24
_MB_YESNOCANCEL_Q = 0x23
_IDYES = 6 _IDYES = 6
_IDNO = 7
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
@@ -116,6 +119,227 @@ def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES
def update_ctk_form(
text: str, title: str = "TG WS Proxy", download_url: Optional[str] = None,
release_url: Optional[str] = None,
) -> str:
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
if result == _IDYES:
return "update"
if result == _IDNO:
return "open"
return "close"
result = {"value": "close"}
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
root = create_ctk_toplevel(
ctk,
title=title,
width=310 if IS_FROZEN else 210,
height=130 if IS_FROZEN else 100,
theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
ctk.CTkLabel(
frame,
text=text,
justify="left",
anchor="w",
wraplength=270,
font=(theme.ui_font_family, 12),
text_color=theme.text_primary,
).pack(fill="x", pady=(0, 10))
row = ctk.CTkFrame(frame, fg_color="transparent")
row.pack(fill="x")
status_label = ctk.CTkLabel(
frame, text="", justify="left", anchor="w", wraplength=270,
font=(theme.ui_font_family, 11), text_color=theme.text_secondary,
)
status_label.pack(fill="x", pady=(6, 0))
btns: list = []
def _set_status(msg: str) -> None:
root.after(0, lambda: status_label.configure(text=msg))
def _close_with(value: str) -> None:
result["value"] = value
root.destroy()
done.set()
def _on_update() -> None:
if not download_url:
if release_url:
webbrowser.open(release_url)
_close_with("open")
return
for b in btns:
b.configure(state="disabled")
root.protocol("WM_DELETE_WINDOW", lambda: None)
def _run():
_perform_update(download_url, set_status=_set_status)
root.after(0, lambda: [b.configure(state="normal") for b in btns])
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
threading.Thread(target=_run, daemon=True).start()
if IS_FROZEN:
btn_upd = ctk.CTkButton(
row, text="Обновить", width=88, height=34,
font=(theme.ui_font_family, 13), command=_on_update,
)
btn_upd.pack(side="left", padx=(0, 6))
btns.append(btn_upd)
btn_pg = ctk.CTkButton(
row, text="Страница", width=88, height=34,
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
)
btn_pg.pack(side="left", padx=(0, 6))
btns.append(btn_pg)
btn_cl = ctk.CTkButton(
row, text="Закрыть", width=88, height=34,
font=(theme.ui_font_family, 13),
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1, border_color=theme.field_border,
command=lambda: _close_with("close"),
)
btn_cl.pack(side="left")
btns.append(btn_cl)
root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close"))
ctk_run_dialog(_build)
return result["value"]
def _perform_update(download_url: str, set_status=None) -> None:
import tempfile
import urllib.request
def _step(msg: str) -> None:
log.info("Update: %s", msg)
if set_status:
set_status(msg)
time.sleep(0.8)
def _err(msg: str) -> None:
log.error("Update error: %s", msg)
if set_status:
set_status(f"Ошибка: {msg}")
else:
_show_error(msg)
_step("Скачивание...")
cur_exe = Path(sys.executable)
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
tmp_path = None
try:
fd, tmp_name = tempfile.mkstemp(dir=cur_exe.parent, suffix=".tmp")
os.close(fd)
tmp_path = Path(tmp_name)
log.info("Downloading update from %s", download_url)
urllib.request.urlretrieve(download_url, str(tmp_path))
except Exception as exc:
_err(f"Не удалось скачать:\n{exc}")
if tmp_path:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
_step("Замена файла...")
try:
if old_exe.exists():
old_exe.unlink()
cur_exe.rename(old_exe)
except Exception as exc:
_err(f"Не удалось переименовать файл:\n{exc}")
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
try:
tmp_path.rename(cur_exe)
except Exception as exc:
_err(f"Не удалось переместить файл:\n{exc}")
try:
old_exe.rename(cur_exe)
except OSError:
pass
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
_step("Перезапуск...")
_release_win_mutex()
stop_proxy()
# Don't reuse existing _MEI* dir
env = os.environ.copy()
for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]:
del env[_k]
if hasattr(sys, "_MEIPASS"):
_mei = os.path.normcase(sys._MEIPASS.rstrip("\\/"))
env["PATH"] = os.pathsep.join(
p for p in env.get("PATH", "").split(os.pathsep)
if os.path.normcase(p.rstrip("\\/")) != _mei
)
try:
subprocess.Popen(
[str(cur_exe)],
env=env,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
except Exception as exc:
log.error("Failed to launch updated exe: %s", exc)
time.sleep(0.5)
os._exit(0)
def _maybe_do_update(cfg: dict, is_exiting) -> None:
if not cfg.get("check_updates", True):
return
def _work():
time.sleep(1.5)
if is_exiting():
return
try:
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update") or is_exiting():
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable)) if IS_FROZEN else None
choice = update_ctk_form(
f"Доступна новая версия: {ver}",
download_url=asset[0] if asset else None,
release_url=url,
)
if choice == "open":
webbrowser.open(url)
except Exception as exc:
log.warning("Update check failed: %s", repr(exc))
threading.Thread(target=_work, daemon=True, name="update-check").start()
# autostart (registry) # autostart (registry)
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
@@ -370,7 +594,7 @@ def run_tray() -> None:
return return
start_proxy(_config, _show_error) start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) _maybe_do_update(_config, lambda: _exiting)
_show_first_run() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
@@ -387,6 +611,18 @@ def main() -> None:
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
if IS_FROZEN:
def _cleanup_old_exes():
exe_dir = Path(sys.executable).parent
time.sleep(3)
for _f in exe_dir.glob("*_oldtgws.exe"):
try:
_f.unlink()
log.info("Deleted leftover: %s", _f)
except OSError:
pass
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()
try: try:
run_tray() run_tray()
finally: finally: