22 Commits

Author SHA1 Message Date
Flowseal
df98baf961 and another one 2026-04-08 00:36:12 +03:00
Flowseal
34dde32033 and another one 2026-04-08 00:25:41 +03:00
Flowseal
b8bd062663 git actions compile test 2026-04-08 00:18:38 +03:00
Flowseal
8e1e3fcc45 bootloader recompile test 2026-04-08 00:11:07 +03:00
Flowseal
097bb9d0b7 version bump 2026-04-08 00:00:17 +03:00
Flowseal
19fbf7494a pyinstaller version update 2026-04-08 00:00:02 +03:00
Flowseal
4b0bc2f4d2 dc203 override hardcode 2026-04-07 23:53:58 +03:00
Flowseal
7850e1f5b4 pool reset on restart 2026-04-07 23:52:55 +03:00
Flowseal
63d5bafd3e docs upd 2026-04-07 18:11:26 +03:00
Flowseal
7eaba0b29c docs upd 2026-04-07 18:06:49 +03:00
Flowseal
6c94d3a39d tip block 2026-04-07 17:51:59 +03:00
Flowseal
746cd66b35 build fixes 2026-04-07 17:15:30 +03:00
Flowseal
e5d8ff7769 version changing & readme update 2026-04-07 17:12:29 +03:00
Flowseal
3ee82e5114 typos 2026-04-07 17:07:41 +03:00
Qirashi
db1308e3f5 Tray dark theme (#591) 2026-04-07 17:06:21 +03:00
Flowseal
6231499c39 lock fixes 2026-04-07 17:04:01 +03:00
Flowseal
826554abfb CfProxy UI setup 2026-04-07 17:04:01 +03:00
Flowseal
7f44c524c8 lists clear on restart 2026-04-07 17:04:01 +03:00
Flowseal
6310fcd6eb docs 2026-04-07 17:03:01 +03:00
Flowseal
081b150b3d Removed dc overriding 2026-04-07 17:02:13 +03:00
Flowseal
15001980dc cloudflare proxy; closes #576 2026-04-07 17:02:13 +03:00
gogamlg3
da4b521aba Изменение README для AUR (#485) 2026-03-30 09:55:44 +03:00
15 changed files with 558 additions and 64 deletions

View File

@@ -29,15 +29,43 @@ jobs:
python-version: "3.12" python-version: "3.12"
cache: "pip" cache: "pip"
- name: Setup MSVC 14.40 toolset
uses: ilammy/msvc-dev-cmd@v1
with:
toolset: 14.40
- name: Install dependencies - name: Install dependencies
run: pip install . run: pip install .
- name: Install pyinstaller - name: Build PyInstaller bootloader from source
run: pip install "pyinstaller==6.13.0" run: |
pip install "pyinstaller==6.16.0" --no-binary pyinstaller
env:
PYINSTALLER_COMPILE_BOOTLOADER: 1
- name: Build EXE with PyInstaller - name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm run: pyinstaller packaging/windows.spec --noconfirm
- name: Strip Rich PE header
shell: bash
run: |
python -c "
import struct, pathlib
exe = pathlib.Path('dist/TgWsProxy.exe')
data = bytearray(exe.read_bytes())
rich = data.find(b'Rich')
if rich == -1:
raise SystemExit('Rich header not found')
ck = struct.unpack_from('<I', data, rich + 4)[0]
dans = struct.pack('<I', 0x536E6144 ^ ck)
ds = data.find(dans)
if ds == -1:
raise SystemExit('DanS marker not found')
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
exe.write_bytes(data)
print(f'Stripped Rich header: offset {ds}..{rich+8}')
"
- name: Rename artifact - name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
@@ -71,6 +99,26 @@ jobs:
- name: Build EXE with PyInstaller - name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm run: pyinstaller packaging/windows.spec --noconfirm
- name: Strip Rich PE header
shell: bash
run: |
python -c "
import struct, pathlib
exe = pathlib.Path('dist/TgWsProxy.exe')
data = bytearray(exe.read_bytes())
rich = data.find(b'Rich')
if rich == -1:
raise SystemExit('Rich header not found')
ck = struct.unpack_from('<I', data, rich + 4)[0]
dans = struct.pack('<I', 0x536E6144 ^ ck)
ds = data.find(dans)
if ds == -1:
raise SystemExit('DanS marker not found')
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
exe.write_bytes(data)
print(f'Stripped Rich header: offset {ds}..{rich+8}')
"
- name: Rename artifact - name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
@@ -145,7 +193,7 @@ jobs:
python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl
python3.12 -m pip install . python3.12 -m pip install .
python3.12 -m pip install pyinstaller==6.13.0 python3.12 -m pip install pyinstaller==6.16.0
- name: Create macOS icon from ICO - name: Create macOS icon from ICO
run: | run: |
@@ -247,7 +295,7 @@ jobs:
run: | run: |
.venv/bin/pip install --upgrade pip .venv/bin/pip install --upgrade pip
.venv/bin/pip install . .venv/bin/pip install .
.venv/bin/pip install "pyinstaller==6.13.0" .venv/bin/pip install "pyinstaller==6.16.0"
- name: Build binary with PyInstaller - name: Build binary with PyInstaller
run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm

View File

@@ -35,7 +35,7 @@ RUN apt-get update \
WORKDIR /app WORKDIR /app
COPY --from=builder /opt/venv /opt/venv COPY --from=builder /opt/venv /opt/venv
COPY proxy ./proxy COPY proxy ./proxy
COPY README.md LICENSE ./ COPY docs/README.md LICENSE ./
USER app USER app

29
docs/CfProxy.md Normal file
View File

@@ -0,0 +1,29 @@
# Cloudflare Proxy
Для недоступных датацентров можно использовать альтернативный бесплатный метод подключения - проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и лучше) заменить на свой.
Прокси возвращает доступ к тому, что до этого не грузило (реакциям, некоторым стикерам). Если у вас до этого не грузило видео/фото на аккаунте без премиума, то уберите `2:149.154.167.220` из `DC->IP` блока в настройках. Если CF-прокси у вас работает - медиа сновая начнёт грузиться.
## Зачем мне настраивать свой домен?
Cloudflare имеет лимиты на одновременное количество подключений WS. Домен по умолчанию может перестать работать в любой момент.
## Настройка своего домена
1. Добавьте свой домен в Cloudflare (либо купив у них напрямую, либо поменяв NS сервера: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/)
2. В `SSL/TLS` -> `Overview` выставьте режим **Flexible**
3. В `DNS` -> `Records` добавьте следующие `A` записи через `+ Add Record`:
- Name=`kws1` IPv4=`149.154.175.50`
- Name=`kws2` IPv4=`149.154.167.51`
- Name=`kws3` IPv4=`149.154.175.100`
- Name=`kws4` IPv4=`149.154.167.91`
- Name=`kws5` IPv4=`149.154.171.5`
- Name=`kws203` IPv4=`149.154.175.50`
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или другой софт для обхода блокировок, так как подсеть Cloudflare забанена (по крайней мере, если вы из России)**
5. В настройках TgWsProxy поменяйте домен на свой
## Mentions
Idea - https://github.com/Nekogram/WSProxy
Thanks to [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) for the information

View File

@@ -1,3 +1,11 @@
> [!TIP]
>
> ### 🎉 Поддержать меня
>
> USDT (TRC20): `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> BTC: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> ETH: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> [!CAUTION] > [!CAUTION]
> >
> ### Реакция антивирусов > ### Реакция антивирусов
@@ -26,7 +34,7 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
2. Перехватывает подключения к IP-адресам Telegram 2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета 3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram 4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение 5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
## 🚀 Быстрый старт ## 🚀 Быстрый старт
@@ -69,8 +77,9 @@ makepkg -si
# При помощи AUR-helper # При помощи AUR-helper
paru -S tg-ws-proxy-bin paru -S tg-ws-proxy-bin
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта прокси: # Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта,
sudo systemctl start tg-ws-proxy-cli@8888 # разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16
sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f
``` ```
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64). Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
@@ -128,11 +137,14 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--host` | `127.0.0.1` | Хост прокси | | `--host` | `127.0.0.1` | Хост прокси |
| `--secret` | `random` | 32 hex chars secret для авторизации клиентов | | `--secret` | `random` | 32 hex chars secret для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) | | `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
| `--buf-kb` | `256` | Размер буфера в КБ | `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare]((https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md)) |
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC | `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudfalre. [Подробнее тут](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md) |
| `--log-file` | выкл. | Путь до файла, в который сохранять логи | `--cfproxy-priority` | `true` | Пробовать проксировать через Cloudflare перед прямым TCP подключением |
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись) | `--buf-kb` | `256` | Размер буфера в КБ |
| `--log-backups` | `0` | Количество сохранений логов после перезаписи | `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
| `--log-file` | выкл. | Путь до файла, в который сохранять логи |
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись) |
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) | | `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:** **Примеры:**

View File

@@ -392,6 +392,27 @@ def _edit_config_dialog() -> None:
except ValueError: except ValueError:
pass pass
cfproxy = _ask_yes_no_close("Включить Cloudflare Proxy (CfProxy)?")
if cfproxy is None:
return
cfproxy_priority = True
if cfproxy:
cfproxy_priority_result = _ask_yes_no_close("Приоритет CfProxy (пробовать раньше прямого TCP)?")
if cfproxy_priority_result is None:
return
cfproxy_priority = cfproxy_priority_result
cfproxy_domain = _osascript_input(
"Домен CF-прокси:\n"
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.\n"
"pclead.co.uk готовый настроенный домен. Подробнее про настройку читайте в репозитории - docs/CfProxy.md",
cfg.get("cfproxy_domain", DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk")),
)
if cfproxy_domain is None:
return
cfproxy_domain = cfproxy_domain.strip()
new_cfg = { new_cfg = {
"host": host, "host": host,
"port": port, "port": port,
@@ -402,6 +423,9 @@ def _edit_config_dialog() -> None:
"pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])), "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"])), "log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
"check_updates": cfg.get("check_updates", True), "check_updates": cfg.get("check_updates", True),
"cfproxy": cfproxy,
"cfproxy_priority": cfproxy_priority,
"cfproxy_domain": cfproxy_domain or DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk"),
} }
save_config(new_cfg) save_config(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)

View File

@@ -0,0 +1,36 @@
# UTF-8
#
# For more details about fixed file info 'ffi' see:
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 0, 0, 0),
prodvers=(1, 0, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo(
[
StringTable(
u'040904B0',
[
StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.0.0.0'),
StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.0.0.0'),
]
)
]
),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
)

View File

@@ -34,6 +34,7 @@ a = Analysis(
) )
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico') icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
version_path = os.path.join(os.path.dirname(SPEC), 'version_info.txt')
if os.path.exists(icon_path): if os.path.exists(icon_path):
a.datas += [('icon.ico', icon_path, 'DATA')] a.datas += [('icon.ico', icon_path, 'DATA')]
@@ -50,7 +51,7 @@ exe = EXE(
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=True, upx=False,
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=False, console=False,
@@ -60,4 +61,5 @@ exe = EXE(
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
icon=icon_path if os.path.exists(icon_path) else None, icon=icon_path if os.path.exists(icon_path) else None,
version=version_path if os.path.exists(version_path) else None,
) )

View File

@@ -1 +1 @@
__version__ = "1.4.0" __version__ = "1.5.1"

View File

@@ -26,9 +26,11 @@ class ProxyConfig:
host: str = '127.0.0.1' host: str = '127.0.0.1'
secret: str = field(default_factory=lambda: os.urandom(16).hex()) secret: str = field(default_factory=lambda: os.urandom(16).hex())
dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'}) dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'})
dc_overrides: Dict[int, int] = field(default_factory=lambda: {203: 2})
buffer_size: int = 256 * 1024 buffer_size: int = 256 * 1024
pool_size: int = 4 pool_size: int = 4
fallback_cfproxy: bool = True
fallback_cfproxy_priority: bool = True
fallback_cfproxy_domain: str = 'pclead.co.uk'
proxy_config = ProxyConfig() proxy_config = ProxyConfig()
@@ -40,7 +42,7 @@ DC_DEFAULT_IPS: Dict[int, str] = {
3: '149.154.175.100', 3: '149.154.175.100',
4: '149.154.167.91', 4: '149.154.167.91',
5: '149.154.171.5', 5: '149.154.171.5',
203: '91.105.192.100' 203: '149.154.175.50'
} }
HANDSHAKE_LEN = 64 HANDSHAKE_LEN = 64
@@ -475,7 +477,8 @@ class _MsgSplitter:
def _ws_domains(dc: int, is_media) -> List[str]: def _ws_domains(dc: int, is_media) -> List[str]:
dc = proxy_config.dc_overrides.get(dc, dc) if dc == 203:
dc = 2
if is_media is None or is_media: if is_media is None or is_media:
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org'] return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org'] return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
@@ -487,6 +490,7 @@ class Stats:
self.connections_active = 0 self.connections_active = 0
self.connections_ws = 0 self.connections_ws = 0
self.connections_tcp_fallback = 0 self.connections_tcp_fallback = 0
self.connections_cfproxy = 0
self.connections_bad = 0 self.connections_bad = 0
self.ws_errors = 0 self.ws_errors = 0
self.bytes_up = 0 self.bytes_up = 0
@@ -502,6 +506,7 @@ class Stats:
f"active={self.connections_active} " f"active={self.connections_active} "
f"ws={self.connections_ws} " f"ws={self.connections_ws} "
f"tcp_fb={self.connections_tcp_fallback} " f"tcp_fb={self.connections_tcp_fallback} "
f"cf={self.connections_cfproxy} "
f"bad={self.connections_bad} " f"bad={self.connections_bad} "
f"err={self.ws_errors} " f"err={self.ws_errors} "
f"pool={pool_s} " f"pool={pool_s} "
@@ -603,6 +608,10 @@ class _WsPool:
self._schedule_refill((dc, is_media), target_ip, domains) self._schedule_refill((dc, is_media), target_ip, domains)
log.info("WS pool warmup started for %d DC(s)", len(dc_redirects)) log.info("WS pool warmup started for %d DC(s)", len(dc_redirects))
def reset(self):
self._idle.clear()
self._refilling.clear()
_ws_pool = _WsPool() _ws_pool = _WsPool()
@@ -784,6 +793,88 @@ def _fallback_ip(dc: int) -> Optional[str]:
return DC_DEFAULT_IPS.get(dc) return DC_DEFAULT_IPS.get(dc)
def _cfproxy_domains(dc: int) -> List[str]:
base = proxy_config.fallback_cfproxy_domain
return [f'kws{dc}.{base}']
async def _cfproxy_fallback(reader, writer, relay_init, label,
dc=None, is_media=False,
clt_decryptor=None, clt_encryptor=None,
tg_encryptor=None, tg_decryptor=None,
splitter=None):
domains = _cfproxy_domains(dc)
media_tag = ' media' if is_media else ''
ws = None
for domain in domains:
log.info("[%s] DC%d%s -> CF proxy wss://%s/apiws",
label, dc, media_tag, domain)
try:
ws = await RawWebSocket.connect(domain, domain,
timeout=10.0)
break
except Exception as exc:
log.warning("[%s] DC%d%s CF proxy %s failed: %s",
label, dc, media_tag, domain, exc)
if ws is None:
return False
_stats.connections_cfproxy += 1
await ws.send(relay_init)
await _bridge_ws_reencrypt(reader, writer, ws, label,
dc=dc, is_media=is_media,
clt_decryptor=clt_decryptor,
clt_encryptor=clt_encryptor,
tg_encryptor=tg_encryptor,
tg_decryptor=tg_decryptor,
splitter=splitter)
return True
async def _do_fallback(reader, writer, relay_init, label,
dc, is_media, media_tag,
clt_decryptor, clt_encryptor,
tg_encryptor, tg_decryptor,
splitter=None):
"""Try CF proxy and/or TCP fallback based on config priority."""
fallback_dst = _fallback_ip(dc)
use_cf = proxy_config.fallback_cfproxy
cf_first = proxy_config.fallback_cfproxy_priority
methods: List[str] = []
if use_cf and cf_first:
methods = ['cf', 'tcp']
elif use_cf:
methods = ['tcp', 'cf']
else:
methods = ['tcp']
for method in methods:
if method == 'cf':
ok = await _cfproxy_fallback(
reader, writer, relay_init, label,
dc=dc, is_media=is_media,
clt_decryptor=clt_decryptor,
clt_encryptor=clt_encryptor,
tg_encryptor=tg_encryptor,
tg_decryptor=tg_decryptor,
splitter=splitter)
if ok:
return True
elif method == 'tcp' and fallback_dst:
log.info("[%s] DC%d%s -> TCP fallback to %s:443",
label, dc, media_tag, fallback_dst)
ok = await _tcp_fallback(
reader, writer, fallback_dst, 443,
relay_init, label, dc=dc, is_media=is_media,
clt_decryptor=clt_decryptor,
clt_encryptor=clt_encryptor,
tg_encryptor=tg_encryptor,
tg_decryptor=tg_decryptor)
if ok:
return True
return False
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
@@ -867,27 +958,29 @@ async def _handle_client(reader, writer, secret: bytes):
tg_encryptor.update(ZERO_64) tg_encryptor.update(ZERO_64)
dc_key = (dc, is_media) dc_key = f'{dc}{"m" if is_media else ""}'
media_tag = " media" if is_media else "" media_tag = " media" if is_media else ""
# Fallback if DC not in config or WS blacklisted for this DC/is_media # Fallback if DC not in config or WS blacklisted for this DC/is_media
if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist: if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist:
fallback_dst = _fallback_ip(dc) if dc not in proxy_config.dc_redirects:
if fallback_dst: log.info("[%s] DC%d not in config -> fallback",
if dc not in proxy_config.dc_redirects: label, dc)
log.info("[%s] DC%d not in config -> TCP fallback %s:443",
label, dc, fallback_dst)
else:
log.info("[%s] DC%d%s WS blacklisted -> TCP fallback %s:443",
label, dc, media_tag, fallback_dst)
await _tcp_fallback(reader, writer, fallback_dst, 443,
relay_init, label, dc=dc,
is_media=is_media,
clt_decryptor=clt_decryptor,
clt_encryptor=clt_encryptor,
tg_encryptor=tg_encryptor,
tg_decryptor=tg_decryptor)
else: else:
log.info("[%s] DC%d%s WS blacklisted -> fallback",
label, dc, media_tag)
splitter = None
try:
splitter = _MsgSplitter(relay_init, proto_int)
except Exception:
pass
ok = await _do_fallback(
reader, writer, relay_init, label,
dc, is_media, media_tag,
clt_decryptor, clt_encryptor,
tg_encryptor, tg_decryptor,
splitter=splitter)
if not ok:
log.warning("[%s] DC%d%s no fallback available", log.warning("[%s] DC%d%s no fallback available",
label, dc, media_tag) label, dc, media_tag)
return return
@@ -948,18 +1041,19 @@ async def _handle_client(reader, writer, secret: bytes):
log.info("[%s] DC%d%s WS cooldown for %ds", log.info("[%s] DC%d%s WS cooldown for %ds",
label, dc, media_tag, int(DC_FAIL_COOLDOWN)) label, dc, media_tag, int(DC_FAIL_COOLDOWN))
fallback_dst = _fallback_ip(dc) or target splitter_fb = None
log.info("[%s] DC%d%s -> TCP fallback to %s:443", try:
label, dc, media_tag, fallback_dst) splitter_fb = _MsgSplitter(relay_init, proto_int)
ok = await _tcp_fallback(reader, writer, fallback_dst, 443, except Exception:
relay_init, label, dc=dc, pass
is_media=is_media, ok = await _do_fallback(
clt_decryptor=clt_decryptor, reader, writer, relay_init, label,
clt_encryptor=clt_encryptor, dc, is_media, media_tag,
tg_encryptor=tg_encryptor, clt_decryptor, clt_encryptor,
tg_decryptor=tg_decryptor) tg_encryptor, tg_decryptor,
splitter=splitter_fb)
if ok: if ok:
log.info("[%s] DC%d%s TCP fallback closed", log.info("[%s] DC%d%s fallback closed",
label, dc, media_tag) label, dc, media_tag)
return return
@@ -1015,6 +1109,10 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
global _server_instance, _server_stop_event global _server_instance, _server_stop_event
_server_stop_event = stop_event _server_stop_event = stop_event
_ws_pool.reset()
ws_blacklist.clear()
dc_fail_until.clear()
secret_bytes = bytes.fromhex(proxy_config.secret) secret_bytes = bytes.fromhex(proxy_config.secret)
def client_cb(r, w): def client_cb(r, w):
@@ -1040,6 +1138,10 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
for dc in sorted(proxy_config.dc_redirects.keys()): for dc in sorted(proxy_config.dc_redirects.keys()):
ip = proxy_config.dc_redirects.get(dc) ip = proxy_config.dc_redirects.get(dc)
log.info(" DC%d: %s", dc, ip) log.info(" DC%d: %s", dc, ip)
if proxy_config.fallback_cfproxy:
prio = 'CF first' if proxy_config.fallback_cfproxy_priority else 'TCP first'
log.info(" CF proxy: %s (%s)",
proxy_config.fallback_cfproxy_domain, prio)
log.info("=" * 60) log.info("=" * 60)
log.info(" Connect link:") log.info(" Connect link:")
log.info(" %s", tg_link) log.info(" %s", tg_link)
@@ -1119,7 +1221,7 @@ def main():
ap = argparse.ArgumentParser( ap = argparse.ArgumentParser(
description='Telegram MTProto WebSocket Bridge Proxy') description='Telegram MTProto WebSocket Bridge Proxy')
ap.add_argument('--port', type=int, default=1443, ap.add_argument('--port', type=int, default=1443,
help=f'Listen port (default 1443)') help='Listen port (default 1443)')
ap.add_argument('--host', type=str, default='127.0.0.1', ap.add_argument('--host', type=str, default='127.0.0.1',
help='Listen host (default 127.0.0.1)') help='Listen host (default 127.0.0.1)')
ap.add_argument('--secret', type=str, default=None, ap.add_argument('--secret', type=str, default=None,
@@ -1139,6 +1241,14 @@ def main():
help='Socket send/recv buffer size in KB (default 256)') help='Socket send/recv buffer size in KB (default 256)')
ap.add_argument('--pool-size', type=int, default=4, metavar='N', ap.add_argument('--pool-size', type=int, default=4, metavar='N',
help='WS connection pool size per DC (default 4, min 0)') help='WS connection pool size per DC (default 4, min 0)')
ap.add_argument('--cfproxy-domain', type=str, default='pclead.co.uk',
metavar='DOMAIN',
help='Cloudflare-proxied domain for WS fallback '
'(default: pclead.co.uk)')
ap.add_argument('--no-cfproxy', action='store_true',
help='Disable Cloudflare proxy fallback')
ap.add_argument('--cfproxy-priority', type=bool, default=True,
help='Try cfproxy before tcp fallback (default: true)')
args = ap.parse_args() args = ap.parse_args()
if not args.dc_ip: if not args.dc_ip:
@@ -1171,7 +1281,10 @@ def main():
secret=secret_hex, secret=secret_hex,
dc_redirects=dc_redirects, dc_redirects=dc_redirects,
buffer_size=max(4, args.buf_kb) * 1024, buffer_size=max(4, args.buf_kb) * 1024,
pool_size=max(0, args.pool_size) pool_size=max(0, args.pool_size),
fallback_cfproxy=not args.no_cfproxy,
fallback_cfproxy_priority=args.cfproxy_priority,
fallback_cfproxy_domain=args.cfproxy_domain,
) )
log_level = logging.DEBUG if args.verbose else logging.INFO log_level = logging.DEBUG if args.verbose else logging.INFO

View File

@@ -7,7 +7,7 @@ name = "tg-ws-proxy"
dynamic=["version"] dynamic=["version"]
description = "Telegram Desktop WebSocket Bridge Proxy" description = "Telegram Desktop WebSocket Bridge Proxy"
readme = "README.md" readme = "docs/README.md"
requires-python = ">=3.8" requires-python = ">=3.8"
license = { name = "MIT", file = "LICENSE" } license = { name = "MIT", file = "LICENSE" }

View File

@@ -27,8 +27,9 @@ _TIP_PORT = (
_TIP_SECRET = "Секретный ключ для авторизации клиентов" _TIP_SECRET = "Секретный ключ для авторизации клиентов"
_TIP_DC = ( _TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n" "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 2:149.154.167.220. " "Каждая строка: «номер:IP», например 4:149.154.167.220. "
"Прокси по этим правилам направляет трафик к нужным серверам Telegram" "Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\n"
"Если у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220"
) )
_TIP_VERBOSE = ( _TIP_VERBOSE = (
"Если включено, в файл логов пишется больше подробностей — " "Если включено, в файл логов пишется больше подробностей — "
@@ -50,9 +51,100 @@ _TIP_AUTOSTART = (
"Если вы переместите программу в другую папку, автозапуск сбросится" "Если вы переместите программу в другую папку, автозапуск сбросится"
) )
_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений" _TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
_TIP_CFPROXY = (
"Использовать Cloudflare прокси для недоступных датацентров"
)
_TIP_CFPROXY_PRIORITY = (
"Пробовать CF-прокси раньше прямого TCP-подключения"
)
_TIP_CFPROXY_DOMAIN = (
"Домен, проксируемый через Cloudflare, для WS-подключения"
)
_TIP_SAVE = "Сохранить настройки" _TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений" _TIP_CANCEL = "Закрыть окно без сохранения изменений"
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
def _run_cfproxy_connectivity_test(domain: str) -> dict:
import base64
import ssl
import socket as _socket
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
results = {}
for dc in _CFPROXY_TEST_DCS:
host = f"kws{dc}.{domain}"
try:
with _socket.create_connection((host, 443), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname=host) as ssock:
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f"GET /apiws HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"Sec-WebSocket-Protocol: binary\r\n"
f"\r\n"
).encode()
ssock.sendall(req)
ssock.settimeout(5)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = ssock.recv(512)
if not chunk:
break
buf += chunk
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
if "101" in first:
results[dc] = True
else:
results[dc] = first or "нет ответа"
ssock.close()
raw.close()
except _socket.timeout:
results[dc] = "таймаут"
except OSError as exc:
msg = str(exc)
results[dc] = msg[:60] if len(msg) > 60 else msg
return results
def _cfproxy_show_test_results(domain: str, results: dict) -> None:
import tkinter as _tk
from tkinter import messagebox as _mb
ok = [dc for dc, v in results.items() if v is True]
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == len(_CFPROXY_TEST_DCS):
title = "CF-прокси: всё работает"
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}."
elif not ok:
title = "CF-прокси: недоступен"
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
msg += "\n".join(f" kws{dc}: {v}" for dc, v in fail)
else:
title = "CF-прокси: частично работает"
msg = (
f"Домен: {domain}\n\n"
f"\u2713 Работают: {', '.join(f'kws{dc}' for dc in ok)}\n\n"
f"\u2717 Недоступны:\n"
+ "\n".join(f" kws{dc}: {v}" for dc, v in fail)
)
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
_mb.showinfo(title, msg, parent=root)
root.destroy()
_INNER_W = 396 _INNER_W = 396
@@ -155,6 +247,9 @@ class TrayConfigFormWidgets:
adv_keys: Tuple[str, ...] adv_keys: Tuple[str, ...]
autostart_var: Optional[Any] autostart_var: Optional[Any]
check_updates_var: Optional[Any] check_updates_var: Optional[Any]
cfproxy_var: Optional[Any] = None
cfproxy_priority_var: Optional[Any] = None
cfproxy_domain_var: Optional[Any] = None
def install_tray_config_form( def install_tray_config_form(
@@ -233,6 +328,76 @@ def install_tray_config_form(
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"]))) dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC) attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC)
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy")
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_row.pack(fill="x", pady=(0, 6))
cfproxy_var = ctk.BooleanVar(
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
)
cf_cb = _checkbox(ctk, cf_row, theme, "Включить CF-прокси", cfproxy_var)
cf_cb.pack(side="left", padx=(0, 16))
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY)
cfproxy_priority_var = ctk.BooleanVar(
value=cfg.get("cfproxy_priority", default_config.get("cfproxy_priority", True))
)
cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет CF-прокси", cfproxy_priority_var)
cf_prio_cb.pack(side="left")
attach_ctk_tooltip(cf_prio_cb, _TIP_CFPROXY_PRIORITY)
cf_domain_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_domain_row.pack(fill="x")
cf_domain_col, cfproxy_domain_var = _labeled_entry(
ctk, cf_domain_row, theme, "Домен",
cfg.get("cfproxy_domain", default_config.get("cfproxy_domain", "pclead.co.uk")),
tip=_TIP_CFPROXY_DOMAIN, width=160, pack_fill=True,
)
cf_domain_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
_cf_test_btn = [None]
def _on_cf_test():
domain = cfproxy_domain_var.get().strip()
if not domain:
return
btn = _cf_test_btn[0]
if btn:
btn.configure(text="...", state="disabled")
import threading as _threading
def _worker():
res = _run_cfproxy_connectivity_test(domain)
if btn:
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
btn.after(0, lambda: _cfproxy_show_test_results(domain, res))
_threading.Thread(target=_worker, daemon=True).start()
cf_test_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent")
cf_test_col.pack(side="left", anchor="s", padx=(0, 6))
ctk.CTkLabel(cf_test_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
_cf_test_widget = ctk.CTkButton(
cf_test_col, text="Тест", width=56, height=36,
font=(theme.ui_font_family, 13), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cf_test,
)
_cf_test_widget.pack()
_cf_test_btn[0] = _cf_test_widget
cf_help_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent")
cf_help_col.pack(side="left", anchor="s")
ctk.CTkLabel(cf_help_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
ctk.CTkButton(
cf_help_col, text="?", width=36, height=36,
font=(theme.ui_font_family, 18), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
).pack()
log_inner = _config_section(ctk, frame, theme, "Логи и производительность") log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
@@ -321,6 +486,9 @@ def install_tray_config_form(
dc_textbox=dc_textbox, verbose_var=verbose_var, dc_textbox=dc_textbox, verbose_var=verbose_var,
adv_entries=adv_entries, adv_keys=adv_keys, adv_entries=adv_entries, adv_keys=adv_keys,
autostart_var=autostart_var, check_updates_var=check_updates_var, autostart_var=autostart_var, check_updates_var=check_updates_var,
cfproxy_var=cfproxy_var,
cfproxy_priority_var=cfproxy_priority_var,
cfproxy_domain_var=cfproxy_domain_var,
) )
@@ -363,9 +531,9 @@ def validate_config_form(
return "Порт должен быть числом 1-65535" return "Порт должен быть числом 1-65535"
lines = [ lines = [
l.strip() line.strip()
for l in widgets.dc_textbox.get("1.0", "end").strip().splitlines() for line in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
if l.strip() if line.strip()
] ]
try: try:
tg_ws_proxy.parse_dc_ip_list(lines) tg_ws_proxy.parse_dc_ip_list(lines)
@@ -397,6 +565,14 @@ def validate_config_form(
merge_adv_from_form(widgets, new_cfg, default_config) merge_adv_from_form(widgets, new_cfg, default_config)
if widgets.check_updates_var is not None: if widgets.check_updates_var is not None:
new_cfg["check_updates"] = bool(widgets.check_updates_var.get()) new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
if widgets.cfproxy_var is not None:
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
if widgets.cfproxy_priority_var is not None:
new_cfg["cfproxy_priority"] = bool(widgets.cfproxy_priority_var.get())
if widgets.cfproxy_domain_var is not None:
domain = widgets.cfproxy_domain_var.get().strip()
if domain:
new_cfg["cfproxy_domain"] = domain
return new_cfg return new_cfg

View File

@@ -17,6 +17,9 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"log_max_mb": 5, "log_max_mb": 5,
"buf_kb": 256, "buf_kb": 256,
"pool_size": 4, "pool_size": 4,
"cfproxy": True,
"cfproxy_priority": True,
"cfproxy_domain": "pclead.co.uk",
} }

View File

@@ -60,12 +60,6 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
return False return False
if IS_FROZEN: if IS_FROZEN:
return APP_NAME.lower() in proc.name().lower() return APP_NAME.lower() in proc.name().lower()
try:
for arg in proc.cmdline():
if script_hint in arg:
return True
except Exception:
pass
return False return False
@@ -76,7 +70,10 @@ def acquire_lock(script_hint: str = "") -> bool:
try: try:
pid = int(f.stem) pid = int(f.stem)
except Exception: except Exception:
f.unlink(missing_ok=True) try:
f.unlink(missing_ok=True)
except OSError:
pass
continue continue
meta: dict = {} meta: dict = {}
try: try:
@@ -85,12 +82,17 @@ def acquire_lock(script_hint: str = "") -> bool:
meta = json.loads(raw) meta = json.loads(raw)
except Exception: except Exception:
pass pass
is_running = False
try: try:
if _same_process(meta, psutil.Process(pid), script_hint): is_running = _same_process(meta, psutil.Process(pid), script_hint)
return False
except Exception: except Exception:
pass pass
f.unlink(missing_ok=True) if is_running:
return False
try:
f.unlink(missing_ok=True)
except OSError:
pass
lock_file = APP_DIR / f"{os.getpid()}.lock" lock_file = APP_DIR / f"{os.getpid()}.lock"
try: try:
@@ -100,7 +102,10 @@ def acquire_lock(script_hint: str = "") -> bool:
encoding="utf-8", encoding="utf-8",
) )
except Exception: except Exception:
lock_file.touch() try:
lock_file.touch()
except Exception:
pass
_lock_file_path = lock_file _lock_file_path = lock_file
return True return True
@@ -264,6 +269,9 @@ def apply_proxy_config(cfg: dict) -> bool:
pc.dc_redirects = dc_redirects pc.dc_redirects = dc_redirects
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024 pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])) pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
pc.fallback_cfproxy_priority = cfg.get("cfproxy_priority", DEFAULT_CONFIG["cfproxy_priority"])
pc.fallback_cfproxy_domain = cfg.get("cfproxy_domain", DEFAULT_CONFIG["cfproxy_domain"])
return True return True

35
utils/win32_theme.py Normal file
View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import sys
def is_windows_dark_theme() -> bool:
if sys.platform != "win32":
return False
try:
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize")
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
return value == 0
except Exception:
return False
def apply_windows_dark_theme() -> None:
try:
import ctypes
uxtheme = ctypes.windll.uxtheme
try:
set_preferred = uxtheme[135]
result = set_preferred(2)
if result == 0:
flush = uxtheme[136]
flush()
except Exception:
try:
allow_dark = uxtheme[135]
allow_dark(True)
except Exception:
pass
except Exception:
pass

View File

@@ -32,6 +32,10 @@ except ImportError:
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from utils.win32_theme import (
is_windows_dark_theme,
apply_windows_dark_theme,
)
from utils.tray_common import ( 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,
@@ -316,6 +320,10 @@ def run_tray() -> None:
global _tray_icon, _config global _tray_icon, _config
_config = load_config() _config = load_config()
if is_windows_dark_theme:
apply_windows_dark_theme()
bootstrap(_config) bootstrap(_config)
if pystray is None or Image is None or ctk is None: if pystray is None or Image is None or ctk is None: