mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-05-22 23:41:44 +03:00
fake-tls cli
This commit is contained in:
@@ -47,6 +47,8 @@ class ProxyConfig:
|
||||
cfproxy_user_domain: str = ''
|
||||
cfproxy_domains: List[str] = field(default_factory=lambda: list(CFPROXY_DEFAULT_DOMAINS))
|
||||
active_cfproxy_domain: str = field(default_factory=lambda: random.choice(CFPROXY_DEFAULT_DOMAINS))
|
||||
fake_tls_domain: str = ''
|
||||
proxy_protocol: bool = False
|
||||
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
|
||||
256
proxy/fake_tls.py
Normal file
256
proxy/fake_tls.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import hashlib
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from .stats import stats
|
||||
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
TLS_RECORD_HANDSHAKE = 0x16
|
||||
TLS_RECORD_CCS = 0x14
|
||||
TLS_RECORD_APPDATA = 0x17
|
||||
|
||||
TLS_VERSION_10 = b'\x03\x01'
|
||||
TLS_VERSION_12 = b'\x03\x03'
|
||||
TLS_VERSION_13 = b'\x03\x04'
|
||||
|
||||
CLIENT_RANDOM_OFFSET = 11
|
||||
CLIENT_RANDOM_LEN = 32
|
||||
SESSION_ID_OFFSET = 44
|
||||
SESSION_ID_LEN = 32
|
||||
|
||||
TIMESTAMP_TOLERANCE = 120
|
||||
|
||||
TLS_APPDATA_MAX = 16384
|
||||
|
||||
|
||||
_CCS_FRAME = b'\x14\x03\x03\x00\x01\x01'
|
||||
|
||||
_SERVER_HELLO_TEMPLATE = bytearray(
|
||||
b'\x16\x03\x03\x00\x7a'
|
||||
b'\x02\x00\x00\x76'
|
||||
b'\x03\x03'
|
||||
+ b'\x00' * 32
|
||||
+ b'\x20'
|
||||
+ b'\x00' * 32
|
||||
+ b'\x13\x01\x00'
|
||||
+ b'\x00\x2e'
|
||||
+ b'\x00\x33\x00\x24\x00\x1d\x00\x20'
|
||||
+ b'\x00' * 32
|
||||
+ b'\x00\x2b\x00\x02\x03\x04'
|
||||
)
|
||||
|
||||
_SH_RANDOM_OFF = 11
|
||||
_SH_SESSID_OFF = 44
|
||||
_SH_PUBKEY_OFF = 89
|
||||
|
||||
|
||||
def verify_client_hello(data: bytes, secret: bytes) -> Optional[Tuple[bytes, bytes, int]]:
|
||||
n = len(data)
|
||||
# 5 (record hdr) + 6 (hs type+len+version) + 32 (random) = 43
|
||||
if n < 43:
|
||||
return None
|
||||
if data[0] != TLS_RECORD_HANDSHAKE:
|
||||
return None
|
||||
if data[5] != 0x01:
|
||||
return None
|
||||
|
||||
client_random = bytes(data[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN])
|
||||
|
||||
zeroed = bytearray(data)
|
||||
zeroed[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = b'\x00' * CLIENT_RANDOM_LEN
|
||||
|
||||
expected = hmac.new(secret, bytes(zeroed), hashlib.sha256).digest()
|
||||
|
||||
if not hmac.compare_digest(expected[:28], client_random[:28]):
|
||||
return None
|
||||
|
||||
ts_xor = bytes(client_random[28 + i] ^ expected[28 + i] for i in range(4))
|
||||
timestamp = struct.unpack('<I', ts_xor)[0]
|
||||
|
||||
now = int(time.time())
|
||||
if abs(now - timestamp) > TIMESTAMP_TOLERANCE:
|
||||
return None
|
||||
|
||||
session_id = b'\x00' * SESSION_ID_LEN
|
||||
if n >= SESSION_ID_OFFSET + SESSION_ID_LEN and data[43] == 0x20:
|
||||
session_id = bytes(data[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN])
|
||||
|
||||
return client_random, session_id, timestamp
|
||||
|
||||
|
||||
def build_server_hello(secret: bytes, client_random: bytes, session_id: bytes) -> bytes:
|
||||
sh = bytearray(_SERVER_HELLO_TEMPLATE)
|
||||
sh[_SH_SESSID_OFF:_SH_SESSID_OFF + 32] = session_id
|
||||
sh[_SH_PUBKEY_OFF:_SH_PUBKEY_OFF + 32] = os.urandom(32)
|
||||
|
||||
ccs = _CCS_FRAME
|
||||
encrypted_size = random.randint(1900, 2100)
|
||||
encrypted_data = os.urandom(encrypted_size)
|
||||
app_record = b'\x17\x03\x03' + struct.pack('>H', encrypted_size) + encrypted_data
|
||||
|
||||
response = bytes(sh) + ccs + app_record
|
||||
|
||||
hmac_input = client_random + response
|
||||
server_random = hmac.new(secret, hmac_input, hashlib.sha256).digest()
|
||||
|
||||
final = bytearray(response)
|
||||
final[_SH_RANDOM_OFF:_SH_RANDOM_OFF + 32] = server_random
|
||||
|
||||
return bytes(final)
|
||||
|
||||
|
||||
def wrap_tls_record(data: bytes) -> bytes:
|
||||
parts = []
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
chunk = data[offset:offset + TLS_APPDATA_MAX]
|
||||
parts.append(
|
||||
b'\x17\x03\x03'
|
||||
+ struct.pack('>H', len(chunk))
|
||||
+ chunk
|
||||
)
|
||||
offset += len(chunk)
|
||||
return b''.join(parts)
|
||||
|
||||
|
||||
class FakeTlsStream:
|
||||
__slots__ = ('_reader', '_writer', '_read_buf', '_read_left')
|
||||
|
||||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
self._reader = reader
|
||||
self._writer = writer
|
||||
self._read_buf = bytearray()
|
||||
self._read_left = 0
|
||||
|
||||
async def readexactly(self, n: int) -> bytes:
|
||||
while len(self._read_buf) < n:
|
||||
payload = await self._read_tls_payload()
|
||||
if not payload:
|
||||
raise asyncio.IncompleteReadError(bytes(self._read_buf), n)
|
||||
self._read_buf.extend(payload)
|
||||
result = bytes(self._read_buf[:n])
|
||||
del self._read_buf[:n]
|
||||
return result
|
||||
|
||||
async def read(self, n: int) -> bytes:
|
||||
if self._read_buf:
|
||||
chunk = bytes(self._read_buf[:n])
|
||||
del self._read_buf[:n]
|
||||
return chunk
|
||||
payload = await self._read_tls_payload()
|
||||
if not payload:
|
||||
return b''
|
||||
if len(payload) > n:
|
||||
self._read_buf.extend(payload[n:])
|
||||
return payload[:n]
|
||||
return payload
|
||||
|
||||
async def _read_tls_payload(self) -> bytes:
|
||||
if self._read_left > 0:
|
||||
data = await self._reader.read(self._read_left)
|
||||
if not data:
|
||||
return b''
|
||||
self._read_left -= len(data)
|
||||
return data
|
||||
|
||||
while True:
|
||||
hdr = await self._reader.readexactly(5)
|
||||
rtype = hdr[0]
|
||||
rec_len = struct.unpack('>H', hdr[3:5])[0]
|
||||
|
||||
if rtype == TLS_RECORD_CCS:
|
||||
if rec_len > 0:
|
||||
await self._reader.readexactly(rec_len)
|
||||
continue
|
||||
|
||||
if rtype != TLS_RECORD_APPDATA:
|
||||
return b''
|
||||
|
||||
data = await self._reader.read(min(rec_len, 65536))
|
||||
if not data:
|
||||
return b''
|
||||
remaining = rec_len - len(data)
|
||||
if remaining > 0:
|
||||
self._read_left = remaining
|
||||
return data
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self._writer.write(wrap_tls_record(data))
|
||||
|
||||
async def drain(self) -> None:
|
||||
await self._writer.drain()
|
||||
|
||||
def close(self) -> None:
|
||||
self._writer.close()
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
await self._writer.wait_closed()
|
||||
|
||||
def get_extra_info(self, name, default=None):
|
||||
return self._writer.get_extra_info(name, default)
|
||||
|
||||
@property
|
||||
def transport(self):
|
||||
return self._writer.transport
|
||||
|
||||
def is_closing(self):
|
||||
return self._writer.is_closing()
|
||||
|
||||
|
||||
async def proxy_to_masking_domain(reader, writer, initial_data: bytes,
|
||||
domain: str, label: str) -> None:
|
||||
try:
|
||||
up_reader, up_writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(domain, 443), timeout=10)
|
||||
except Exception as exc:
|
||||
log.debug("[%s] masking: cannot connect to %s:443: %s",
|
||||
label, domain, exc)
|
||||
return
|
||||
|
||||
log.debug("[%s] masking -> %s:443", label, domain)
|
||||
stats.connections_masked += 1
|
||||
|
||||
try:
|
||||
if initial_data:
|
||||
up_writer.write(initial_data)
|
||||
await up_writer.drain()
|
||||
|
||||
async def _relay(src, dst):
|
||||
try:
|
||||
while True:
|
||||
chunk = await src.read(16384)
|
||||
if not chunk:
|
||||
break
|
||||
dst.write(chunk)
|
||||
await dst.drain()
|
||||
except (ConnectionResetError, BrokenPipeError, OSError,
|
||||
asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
dst.close()
|
||||
await dst.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(
|
||||
_relay(reader, up_writer),
|
||||
_relay(up_reader, writer),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
up_writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -8,6 +8,7 @@ class _Stats:
|
||||
self.connections_tcp_fallback = 0
|
||||
self.connections_cfproxy = 0
|
||||
self.connections_bad = 0
|
||||
self.connections_masked = 0
|
||||
self.ws_errors = 0
|
||||
self.bytes_up = 0
|
||||
self.bytes_down = 0
|
||||
@@ -24,6 +25,7 @@ class _Stats:
|
||||
f"tcp_fb={self.connections_tcp_fallback} "
|
||||
f"cf={self.connections_cfproxy} "
|
||||
f"bad={self.connections_bad} "
|
||||
f"masked={self.connections_masked} "
|
||||
f"err={self.ws_errors} "
|
||||
f"pool={pool_s} "
|
||||
f"up={human_bytes(self.bytes_up)} "
|
||||
|
||||
@@ -28,6 +28,7 @@ from .stats import stats
|
||||
from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh, CFPROXY_DEFAULT_DOMAINS
|
||||
from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt
|
||||
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts
|
||||
from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE
|
||||
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
@@ -214,25 +215,115 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
|
||||
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
||||
|
||||
tls_stream = None
|
||||
masking = proxy_config.fake_tls_domain
|
||||
|
||||
try:
|
||||
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
|
||||
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:
|
||||
handshake = await asyncio.wait_for(
|
||||
reader.readexactly(HANDSHAKE_LEN), timeout=10)
|
||||
first_byte = await asyncio.wait_for(
|
||||
reader.readexactly(1), timeout=10)
|
||||
except asyncio.IncompleteReadError:
|
||||
log.debug("[%s] client disconnected before handshake", label)
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
if result is None:
|
||||
stats.connections_bad += 1
|
||||
log.debug("[%s] bad handshake (wrong secret or proto)", label)
|
||||
try:
|
||||
while await reader.read(4096):
|
||||
drain_src = tls_stream or reader
|
||||
while await drain_src.read(4096):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
clt_reader = tls_stream or reader
|
||||
clt_writer = tls_stream or writer
|
||||
|
||||
dc, is_media, proto_tag, client_dec_prekey_iv = result
|
||||
|
||||
if proto_tag == PROTO_TAG_ABRIDGED:
|
||||
@@ -308,7 +399,7 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
except Exception:
|
||||
pass
|
||||
ok = await do_fallback(
|
||||
reader, writer, relay_init, label,
|
||||
clt_reader, clt_writer, relay_init, label,
|
||||
dc, is_media, media_tag,
|
||||
ctx, splitter=splitter)
|
||||
if not ok:
|
||||
@@ -378,7 +469,7 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
except Exception:
|
||||
pass
|
||||
ok = await do_fallback(
|
||||
reader, writer, relay_init, label,
|
||||
clt_reader, clt_writer, relay_init, label,
|
||||
dc, is_media, media_tag,
|
||||
ctx, splitter=splitter_fb)
|
||||
if ok:
|
||||
@@ -399,7 +490,7 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
|
||||
await ws.send(relay_init)
|
||||
|
||||
await bridge_ws_reencrypt(reader, writer, ws, label,
|
||||
await bridge_ws_reencrypt(clt_reader, clt_writer, ws, label,
|
||||
dc=dc, is_media=is_media,
|
||||
ctx=ctx, splitter=splitter)
|
||||
|
||||
@@ -422,6 +513,7 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
stats.connections_active -= 1
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
@@ -467,12 +559,23 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
||||
pass
|
||||
|
||||
link_host = get_link_host(proxy_config.host)
|
||||
tg_link = f"tg://proxy?server={link_host}&port={proxy_config.port}&secret=dd{proxy_config.secret}"
|
||||
ftls = proxy_config.fake_tls_domain
|
||||
dd_link = (f"tg://proxy?server={link_host}"
|
||||
f"&port={proxy_config.port}"
|
||||
f"&secret=dd{proxy_config.secret}")
|
||||
ee_link = ""
|
||||
if ftls:
|
||||
domain_hex = ftls.encode('ascii').hex()
|
||||
ee_link = (f"tg://proxy?server={link_host}"
|
||||
f"&port={proxy_config.port}"
|
||||
f"&secret=ee{proxy_config.secret}{domain_hex}")
|
||||
|
||||
log.info("=" * 60)
|
||||
log.info(" Telegram MTProto WS Bridge Proxy")
|
||||
log.info(" Listening on %s:%d", proxy_config.host, proxy_config.port)
|
||||
log.info(" Secret: %s", proxy_config.secret)
|
||||
if ftls:
|
||||
log.info(" Fake TLS: %s", ftls)
|
||||
log.info(" Target DC IPs:")
|
||||
for dc in sorted(proxy_config.dc_redirects.keys()):
|
||||
ip = proxy_config.dc_redirects.get(dc)
|
||||
@@ -482,8 +585,12 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
||||
user_domain = "user" if proxy_config.cfproxy_user_domain else "auto"
|
||||
log.info(" CF proxy: enabled (%s | %s)", prio, user_domain)
|
||||
log.info("=" * 60)
|
||||
log.info(" Connect link:")
|
||||
log.info(" %s", tg_link)
|
||||
log.info(" Connect links:")
|
||||
if ftls:
|
||||
log.info(" ee (Fake TLS): %s", ee_link)
|
||||
else:
|
||||
log.info(" (standard): %s", proxy_config.secret)
|
||||
log.info(" dd (random padding): %s", dd_link)
|
||||
log.info("=" * 60)
|
||||
|
||||
async def log_stats():
|
||||
@@ -569,6 +676,13 @@ def main():
|
||||
help='Disable Cloudflare proxy fallback')
|
||||
ap.add_argument('--cfproxy-priority', type=bool, default=True,
|
||||
help='Try cfproxy before tcp fallback (default: true)')
|
||||
ap.add_argument('--fake-tls-domain', type=str, default='',
|
||||
metavar='DOMAIN',
|
||||
help='Enable Fake TLS (ee-secret) masking with the given '
|
||||
'SNI domain, e.g. example.com')
|
||||
ap.add_argument('--proxy-protocol', action='store_true',
|
||||
help='Accept PROXY protocol v1 header '
|
||||
'(for use behind nginx/haproxy with proxy_protocol on)')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.dc_ip:
|
||||
@@ -603,6 +717,8 @@ def main():
|
||||
proxy_config.fallback_cfproxy = not args.no_cfproxy
|
||||
proxy_config.fallback_cfproxy_priority = args.cfproxy_priority
|
||||
proxy_config.cfproxy_user_domain = args.cfproxy_domain
|
||||
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
||||
proxy_config.proxy_protocol = args.proxy_protocol
|
||||
|
||||
log_level = logging.DEBUG if args.verbose else logging.INFO
|
||||
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
|
||||
@@ -624,6 +740,8 @@ def main():
|
||||
fh.setFormatter(log_fmt)
|
||||
root.addHandler(fh)
|
||||
|
||||
logging.getLogger('asyncio').setLevel(logging.WARNING)
|
||||
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
except KeyboardInterrupt:
|
||||
|
||||
Reference in New Issue
Block a user