Compare commits

...

21 Commits

Author SHA1 Message Date
Flowseal 222cabeee0 Clarify naming 2026-09-19 11:32:10 +03:00
Flowseal c2da669576 Bump metadata version 2026-09-19 11:27:50 +03:00
Flowseal 2c9a0b8a90 Bump version 2026-09-19 11:26:32 +03:00
Flowseal 87977962dc Disable SSL switch 2026-09-19 11:25:12 +03:00
Flowseal 5a61d8f782 Simplify passing None to --dc-ip 2026-09-18 17:30:35 +03:00
Flowseal f200e33fd2 version bump 2026-09-07 16:32:42 +03:00
Flowseal c02398fbc0 censoring domains 2026-09-05 16:23:24 +03:00
Flowseal 5f55245184 fix linux build 2026-09-05 15:39:45 +03:00
Flowseal 312d844eea removed old contribution link 2026-09-05 00:04:15 +03:00
Flowseal be485a3851 version bump 2026-09-04 17:49:40 +03:00
Flowseal f811a10918 don't close update dialog on release page open 2026-09-04 17:36:15 +03:00
Flowseal 55ec6a91df remove topmost 2026-09-04 17:35:41 +03:00
Flowseal b23c8f6122 #1213 fixes 2026-09-04 17:22:30 +03:00
Konukhov Yaroslav d36bb7df71 feat: add manual update triggers to settings and tray menu (#1213) 2026-09-04 17:13:56 +03:00
Flowseal bd4f3d080c todo text correction 2026-09-03 20:20:41 +03:00
Flowseal 492d999248 closes #1232 2026-09-03 20:17:46 +03:00
Flowseal e8ad0d6958 refill pool after rotating expired connections 2026-09-03 17:25:21 +03:00
Flowseal 5eb7e007d3 try ws_pool if connection is timed out 2026-09-03 17:09:18 +03:00
Flowseal 1ae2f3a147 todo #1232 2026-09-03 05:14:03 +03:00
Flowseal d2b6c450eb TODOs 2026-09-03 05:11:03 +03:00
Matt Van Horn 1b8671b3d3 fix: restore Linux Mint 21 binary compatibility (#1238)
Fixes #1144
2026-09-01 18:27:13 +03:00
27 changed files with 402 additions and 136 deletions
-1
View File
@@ -1 +0,0 @@
custom: ['https://nowpayments.io/donation/flowseal']
+3 -1
View File
@@ -382,7 +382,7 @@ jobs:
path: dist/TgWsProxy_macos_universal.dmg path: dist/TgWsProxy_macos_universal.dmg
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
if: ${{ github.event.inputs.build_linux == 'true' }} if: ${{ github.event.inputs.build_linux == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
@@ -395,6 +395,8 @@ jobs:
python3-venv \ python3-venv \
python3-dev \ python3-dev \
python3-gi \ python3-gi \
python3-gi-cairo \
gir1.2-appindicator3-0.1 \
gir1.2-ayatanaappindicator3-0.1 \ gir1.2-ayatanaappindicator3-0.1 \
python3-tk python3-tk
+4 -3
View File
@@ -51,6 +51,7 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--no-cfproxy` | `false` | Disable [Cloudflare proxying](./CfProxy.md) attempts | | `--no-cfproxy` | `false` | Disable [Cloudflare proxying](./CfProxy.md) attempts |
| `--cfproxy-domain` | | Specify your own domain for Cloudflare proxying [Learn more](./CfProxy.md). Can be specified multiple times. | | `--cfproxy-domain` | | Specify your own domain for Cloudflare proxying [Learn more](./CfProxy.md). Can be specified multiple times. |
| `--cfproxy-worker-domain` | | Cloudflare Worker domain [Learn more](./CfWorker.md). Can be specified multiple times. | | `--cfproxy-worker-domain` | | Cloudflare Worker domain [Learn more](./CfWorker.md). Can be specified multiple times. |
| `--no-secure` | `false` | Use 80 port for CF-proxy and CF-worker connections |
| `--fake-tls-domain` | | Enable Fake TLS masquerading (ee-secret) with specified SNI domain | | `--fake-tls-domain` | | Enable Fake TLS masquerading (ee-secret) with specified SNI domain |
| `--proxy-protocol` | disabled | Accept HAProxy PROXY protocol v1 (for use behind nginx/haproxy with `proxy_protocol on`) | | `--proxy-protocol` | disabled | Accept HAProxy PROXY protocol v1 (for use behind nginx/haproxy with `proxy_protocol on`) |
| `--buf-kb` | `256` | Buffer size in KB | | `--buf-kb` | `256` | Buffer size in KB |
@@ -64,13 +65,13 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```bash ```bash
# Standard startup # Standard startup
tg-ws-proxy tg-ws-proxy --secret 00112233445566778899aabbccddeeff
# Different port and additional DCs # Different port and additional DCs
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220 tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# With verbose logging # With verbose logging, without direct connections to DCs
tg-ws-proxy -v tg-ws-proxy -v --dc-ip
# Fake TLS masquerading (ee-secret) # Fake TLS masquerading (ee-secret)
tg-ws-proxy --fake-tls-domain example.com tg-ws-proxy --fake-tls-domain example.com
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Other coins**: https://nowpayments.io/donation/flowseal
The project is completely free for everyone. The project is completely free for everyone.
However, its development and stable operation as the user base grows require investment. However, its development and stable operation as the user base grows require investment.
-1
View File
@@ -20,7 +20,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Other coins**: https://nowpayments.io/donation/flowseal
> [!CAUTION] > [!CAUTION]
> >
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
Проект полностью бесплатен для всех. Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений. Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
-1
View File
@@ -20,7 +20,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
> [!CAUTION] > [!CAUTION]
> >
+4 -3
View File
@@ -51,6 +51,7 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) | | `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. | | `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. | | `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
| `--no-secure` | `false` | Принудительно использует 80 порт для CF-proxy и CF-worker |
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом | | `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) | | `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
| `--buf-kb` | `256` | Размер буфера в КБ | | `--buf-kb` | `256` | Размер буфера в КБ |
@@ -64,13 +65,13 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```bash ```bash
# Стандартный запуск # Стандартный запуск
tg-ws-proxy tg-ws-proxy --secret 00112233445566778899aabbccddeeff
# Другой порт и дополнительные DC # Другой порт и дополнительные DC
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220 tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием # С подробным логированием, без прямого подключения к DC
tg-ws-proxy -v tg-ws-proxy -v --dc-ip
# Fake TLS маскировка (ee-secret) # Fake TLS маскировка (ee-secret)
tg-ws-proxy --fake-tls-domain example.com tg-ws-proxy --fake-tls-domain example.com
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
Проект полностью бесплатен для всех. Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений. Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
+43 -3
View File
@@ -147,8 +147,8 @@ def _edit_config_dialog() -> None:
theme = ctk_theme_for_platform() theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE w, h = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme, ctk, title=t("app.settings_title"), width=w, height=h,
after_create=_apply_window_icon, theme=theme, topmost=False, after_create=_apply_window_icon
) )
fpx, fpy = CONFIG_DIALOG_FRAME_PAD fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@@ -244,7 +244,7 @@ def _show_first_run() -> None:
w, h = FIRST_RUN_SIZE w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title=t("app.name"), width=w, height=h, theme=theme, ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=_apply_window_icon, topmost=False, after_create=_apply_window_icon,
) )
def on_done(open_tg: bool) -> None: def on_done(open_tg: bool) -> None:
@@ -310,7 +310,47 @@ def run_tray() -> None:
log.info("Tray app exited") log.info("Tray app exited")
def _smoke_test() -> None:
"""Exercise the frozen GUI stack without starting the proxy or writing config."""
import gi
gi.require_version('AppIndicator3', '0.1')
gi.require_version('AyatanaAppIndicator3', '0.1')
from gi.repository import AppIndicator3, AyatanaAppIndicator3
# Namespace imports alone do not resolve app_indicator_new: call it too.
indicators = [
namespace.Indicator.new(
'tg-ws-proxy-smoke-test', '', namespace.IndicatorCategory.APPLICATION_STATUS,
)
for namespace in (AppIndicator3, AyatanaAppIndicator3)
]
icon = pystray.Icon('tg-ws-proxy-smoke-test', Image.new('RGB', (16, 16)))
root = ctk.CTk()
root.withdraw()
root.update_idletasks()
root.destroy()
# A successful constructor on the build host can hide missing bundled
# libraries. Check where the dynamic loader actually obtained them.
bundle_dir = os.path.realpath(sys._MEIPASS) + os.sep
prefixes = ('libglib-', 'libgobject-', 'libgio-', 'libgtk-',
'libappindicator', 'libayatana-', 'libdbusmenu-')
with open('/proc/self/maps', encoding='utf-8') as maps:
paths = {line.split(maxsplit=5)[-1].strip() for line in maps}
for path in sorted(paths):
if os.path.basename(path).startswith(prefixes):
if not os.path.realpath(path).startswith(bundle_dir):
raise RuntimeError('GUI library loaded outside the bundle: ' + path)
print(path)
assert icon is not None and all(indicators)
print('Linux bundle smoke test passed')
def main() -> None: def main() -> None:
if sys.argv[1:] == ['--smoke-test']:
_smoke_test()
return
if not acquire_lock(): if not acquire_lock():
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0])) _show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
return return
+2
View File
@@ -348,6 +348,7 @@ def _edit_config_dialog() -> None:
width=width, width=width,
height=height, height=height,
theme=theme, theme=theme,
topmost=False,
after_create=lambda window: _activate_app(), after_create=lambda window: _activate_app(),
) )
_settings_window = root _settings_window = root
@@ -471,6 +472,7 @@ def _show_first_run() -> None:
width=width, width=width,
height=height, height=height,
theme=theme, theme=theme,
topmost=False,
after_create=lambda window: _activate_app(), after_create=lambda window: _activate_app(),
) )
+40 -16
View File
@@ -1,10 +1,9 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import sys
import os import os
import glob import glob
from PyInstaller.utils.hooks import collect_submodules, collect_data_files from PyInstaller.utils.hooks import collect_data_files
block_cipher = None block_cipher = None
@@ -15,21 +14,18 @@ certifi_datas = collect_data_files('certifi')
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n') _i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
# Collect gi (PyGObject) submodules and data so pystray._appindicator works appindicator_binaries = [
gi_hiddenimports = collect_submodules('gi') (path, '.')
gi_datas = collect_data_files('gi') for pattern in ('/usr/lib/*/libappindicator3.so.1',
'/usr/lib/libappindicator3.so.1', '/usr/lib64/libappindicator3.so.1')
# Collect GObject typelib files from the system for path in glob.glob(pattern)
typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0') ]
typelib_datas = []
for d in typelib_dirs:
typelib_datas.append((d, 'gi_typelibs'))
a = Analysis( a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')], [os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[], pathex=[],
binaries=[], binaries=appindicator_binaries,
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas + gi_datas + typelib_datas, datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[ hiddenimports=[
'pystray._appindicator', 'pystray._appindicator',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
@@ -39,15 +35,22 @@ a = Analysis(
'cryptography.hazmat.primitives.ciphers.modes', 'cryptography.hazmat.primitives.ciphers.modes',
'cryptography.hazmat.backends.openssl', 'cryptography.hazmat.backends.openssl',
'gi', 'gi',
'_gi',
'gi.repository.GLib', 'gi.repository.GLib',
'gi.repository.GObject', 'gi.repository.GObject',
'gi.repository.Gtk', 'gi.repository.Gtk',
'gi.repository.Gdk', 'gi.repository.Gdk',
'gi.repository.DBus',
'gi.repository.AppIndicator3',
'gi.repository.AyatanaAppIndicator3', 'gi.repository.AyatanaAppIndicator3',
] + gi_hiddenimports, ],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={
'gi': {
'icons': [],
'themes': [],
'languages': ['en', 'ru'],
},
},
runtime_hooks=[], runtime_hooks=[],
excludes=[ excludes=[
'PIL._avif', 'PIL._avif',
@@ -58,6 +61,27 @@ a = Analysis(
cipher=block_cipher, cipher=block_cipher,
) )
_required_libraries = {
'libglib-2.0.so.0', 'libgobject-2.0.so.0', 'libgio-2.0.so.0',
'libgtk-3.so.0', 'libappindicator3.so.1',
'libayatana-appindicator3.so.1',
}
_required_typelibs = {
'AppIndicator3-0.1.typelib', 'AyatanaAppIndicator3-0.1.typelib', 'DBus-1.0.typelib',
}
_bundled_libraries = {
os.path.basename(name)
for name, _, kind in a.binaries + a.datas
if kind in ('BINARY', 'SYMLINK')
}
_missing = (
_required_libraries - _bundled_libraries
) | (
_required_typelibs - {os.path.basename(name) for name, _, _ in a.datas}
)
if _missing:
raise RuntimeError('Incomplete Linux GI bundle: ' + ', '.join(sorted(_missing)))
_PIL_EXCLUDE_PYDS = { _PIL_EXCLUDE_PYDS = {
'_avif', '_webp', '_imagingtk', '_avif', '_webp', '_imagingtk',
'FpxImagePlugin', 'MicImagePlugin', 'FpxImagePlugin', 'MicImagePlugin',
+4 -4
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, 10, 0, 0), filevers=(1, 10, 3, 0),
prodvers=(1, 10, 0, 0), prodvers=(1, 10, 3, 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.10.0.0'), StringStruct(u'FileVersion', u'1.10.3.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.10.0.0'), StringStruct(u'ProductVersion', u'1.10.3.0'),
] ]
) )
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener from .utils import get_link_host, build_github_opener
__version__ = "1.10.0" __version__ = "1.10.3"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+4 -2
View File
@@ -201,7 +201,8 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
try: try:
ws = await RawWebSocket.connect(worker_domain, worker_domain, ws = await RawWebSocket.connect(worker_domain, worker_domain,
timeout=10.0, path=path) timeout=10.0, path=path,
secure=not proxy_config.disable_secure)
break break
except Exception as exc: except Exception as exc:
cf_worker_pool.report_failure(worker_domain, exc) cf_worker_pool.report_failure(worker_domain, exc)
@@ -234,7 +235,8 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
for base_domain in balancer.get_domains_for_dc(dc): for base_domain in balancer.get_domains_for_dc(dc):
domain = f'kws{dc}.{base_domain}' domain = f'kws{dc}.{base_domain}'
try: try:
ws = await RawWebSocket.connect(domain, domain, timeout=10.0) ws = await RawWebSocket.connect(domain, domain, timeout=10.0,
secure=not proxy_config.disable_secure)
chosen_domain = base_domain chosen_domain = base_domain
break break
except Exception as exc: except Exception as exc:
+1
View File
@@ -70,6 +70,7 @@ class ProxyConfig:
fallback_cfproxy: bool = True fallback_cfproxy: bool = True
cfproxy_user_domains: List[str] = field(default_factory=list) cfproxy_user_domains: List[str] = field(default_factory=list)
cfproxy_worker_domains: List[str] = field(default_factory=list) cfproxy_worker_domains: List[str] = field(default_factory=list)
disable_secure: bool = False
fake_tls_domain: str = '' fake_tls_domain: str = ''
proxy_protocol: bool = False proxy_protocol: bool = False
force_test_dc: bool = False force_test_dc: bool = False
+13 -11
View File
@@ -14,10 +14,12 @@ from .utils import ws_domains, DC_DEFAULT_IPS
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
# TODO: domains handling is broken: wrong is_media flag causes tcp_reset after handshake,
# but initial connection is still established no matter what is is_media flag is set to
class _WsPool: class _WsPool:
WS_POOL_MAX_AGE = 120.0 WS_POOL_MAX_AGE = 120.0
WS_POOL_CHECK_INTERVAL = 5.0 WS_POOL_CHECK_INTERVAL = 5.0
REFILL_BACKOFF_INITIAL = 60.0 REFILL_BACKOFF_INITIAL = 1.0
REFILL_BACKOFF_MAX = 3600.0 REFILL_BACKOFF_MAX = 3600.0
def __init__(self): def __init__(self):
@@ -26,11 +28,10 @@ class _WsPool:
self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {} self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {}
self._refill_failures: Dict[Tuple[int, bool], int] = {} self._refill_failures: Dict[Tuple[int, bool], int] = {}
self._refill_after: Dict[Tuple[int, bool], float] = {} self._refill_after: Dict[Tuple[int, bool], float] = {}
self.try_fronting_first = False self.try_fronting_first = True
async def get(self, dc: int, is_media: bool, async def get(self, dc: int, is_media: bool,
target_ip: str, domains: List[str], target_ip: str, domains: List[str]
*, allow_refill: bool = True
) -> Optional[RawWebSocket]: ) -> Optional[RawWebSocket]:
key = (dc, is_media) key = (dc, is_media)
now = time.monotonic() now = time.monotonic()
@@ -50,13 +51,11 @@ class _WsPool:
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)", log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket)) dc, 'm' if is_media else '', age, len(bucket))
self.report_success(dc, is_media) self.report_success(dc, is_media)
if allow_refill: self._schedule_refill(key, target_ip, domains)
self._schedule_refill(key, target_ip, domains)
return ws return ws
stats.pool_misses += 1 stats.pool_misses += 1
if allow_refill: self._schedule_refill(key, target_ip, domains)
self._schedule_refill(key, target_ip, domains)
return None return None
def _schedule_refill(self, key, target_ip, domains): def _schedule_refill(self, key, target_ip, domains):
@@ -98,7 +97,7 @@ class _WsPool:
self._refill_failures[key] = failures self._refill_failures[key] = failures
delay = min( delay = min(
self.REFILL_BACKOFF_INITIAL self.REFILL_BACKOFF_INITIAL
* (2 ** min(failures - 1, 6)), * (2 ** min(failures - 1, 12)),
self.REFILL_BACKOFF_MAX, self.REFILL_BACKOFF_MAX,
) )
self._refill_after[key] = time.monotonic() + delay self._refill_after[key] = time.monotonic() + delay
@@ -150,6 +149,8 @@ class _WsPool:
log.debug( log.debug(
"WS pool rotated DC%d%s: %d stale, %d ready", "WS pool rotated DC%d%s: %d stale, %d ready",
dc, 'm' if is_media else '', len(expired), len(bucket)) dc, 'm' if is_media else '', len(expired), len(bucket))
if len(bucket) < proxy_config.pool_size:
self._schedule_refill(key, target_ip, domains) self._schedule_refill(key, target_ip, domains)
finally: finally:
if self._rotating.get(key) is asyncio.current_task(): if self._rotating.get(key) is asyncio.current_task():
@@ -166,7 +167,7 @@ class _WsPool:
target_ip, domain, timeout=8) target_ip, domain, timeout=8)
self.try_fronting_first = False self.try_fronting_first = False
return ws return ws
except asyncio.TimeoutError: except (asyncio.TimeoutError, ConnectionResetError):
if self.try_fronting_first: if self.try_fronting_first:
return None return None
return await self._connect_fronted(target_ip, domain) return await self._connect_fronted(target_ip, domain)
@@ -288,7 +289,8 @@ class _CfWorkerPool:
for worker_domain in self.available_domains(worker_domains): for worker_domain in self.available_domains(worker_domains):
try: try:
ws = await RawWebSocket.connect( ws = await RawWebSocket.connect(
worker_domain, worker_domain, timeout=8, path=path) worker_domain, worker_domain, timeout=8, path=path,
secure=not proxy_config.disable_secure)
return ws, worker_domain return ws, worker_domain
except Exception as exc: except Exception as exc:
self.report_failure(worker_domain, exc) self.report_failure(worker_domain, exc)
+17 -7
View File
@@ -22,9 +22,8 @@ _st_H = struct.Struct('>H')
_st_Q = struct.Struct('>Q') _st_Q = struct.Struct('>Q')
_ssl_ctx = ssl.create_default_context() _ssl_ctx = ssl.create_default_context()
_ssl_ctx.check_hostname = False _ssl_ctx_fronting = ssl.create_default_context()
_ssl_ctx.verify_mode = ssl.CERT_NONE _ssl_ctx_fronting.check_hostname = False
class WsHandshakeError(Exception): class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str, def __init__(self, status_code: int, status_line: str,
@@ -87,14 +86,25 @@ class RawWebSocket:
@staticmethod @staticmethod
async def connect(host: str, domain: str, timeout: float = 10.0, async def connect(host: str, domain: str, timeout: float = 10.0,
path: str = '/apiws', *, path: str = '/apiws', *,
sni: Optional[str] = None) -> 'RawWebSocket': sni: Optional[str] = None, secure = True) -> 'RawWebSocket':
ssl = _ssl_ctx_fronting if sni else _ssl_ctx
print(f"Connecting to {host} with secure={secure}, sni={sni}, path={path}")
if sni is None: if sni is None:
sni = domain sni = domain
reader, writer = await asyncio.wait_for( reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, 443, ssl=_ssl_ctx, (
server_hostname=sni), asyncio.open_connection(
timeout=min(timeout, 10)) host, 443,
ssl=ssl,
server_hostname=sni,
)
if secure
else asyncio.open_connection(host, 80)
),
timeout=min(timeout, 10),
)
set_sock_opts(writer.transport, proxy_config.buffer_size) set_sock_opts(writer.transport, proxy_config.buffer_size)
+46 -27
View File
@@ -302,6 +302,8 @@ async def _handle_client(reader, writer, secret: bytes):
ws_path = WS_PATH_TEST if is_test_dc else WS_PATH ws_path = WS_PATH_TEST if is_test_dc else WS_PATH
target = proxy_config.dc_redirects.get(dc) target = proxy_config.dc_redirects.get(dc)
is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains
domains = ws_domains(dc, is_media)
ws = None
# Fallback if DC not in config, if WS blacklisted for this DC/is_media or if connect to ip is timed out # Fallback if DC not in config, if WS blacklisted for this DC/is_media or if connect to ip is timed out
if (dc not in proxy_config.dc_redirects if (dc not in proxy_config.dc_redirects
@@ -315,34 +317,40 @@ async def _handle_client(reader, writer, secret: bytes):
log.info("[%s] DC%d%s WS blacklisted -> fallback", log.info("[%s] DC%d%s WS blacklisted -> fallback",
label, dc, media_tag) label, dc, media_tag)
else: else:
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback", # Try to get WS from pool first, might be accidental timeout
label, dc, media_tag, target) ws = await ws_pool.get(
splitter = None dc, is_media, target, domains
try: ) if not is_test_dc else None
splitter = MsgSplitter(relay_init, proto_int)
except Exception: if not ws:
pass log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
ok = await do_fallback( label, dc, media_tag, target)
clt_reader, clt_writer, relay_init, label, else:
dc, is_test_dc, is_media, media_tag, log.info("[%s] DC%d%s WS connect to %s was timed out, but pool hit -> using WS",
ctx, splitter=splitter) label, dc, media_tag, target)
if not ok:
log.warning("[%s] DC%d%s no fallback available", if not ws:
label, dc, media_tag) splitter = None
return try:
splitter = MsgSplitter(relay_init, proto_int)
except Exception:
pass
ok = await do_fallback(
clt_reader, clt_writer, relay_init, label,
dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter)
if not ok:
log.warning("[%s] DC%d%s no fallback available",
label, dc, media_tag)
return
ws_timeout = WS_FAIL_TIMEOUT if now < dc_fail_until.get(dc_key, 0) else 5.0 ws_timeout = WS_FAIL_TIMEOUT if now < dc_fail_until.get(dc_key, 0) else 5.0
domains = ws_domains(dc, is_media)
ws = None
ws_failed_redirect = False ws_failed_redirect = False
ws_timed_out = False ws_timed_out = False
all_redirects = True all_redirects = True
allow_pool_refill = now >= ip_fail_until.get(target, 0) ws = ws or await ws_pool.get(
ws = await ws_pool.get( dc, is_media, target, domains
dc, is_media, target, domains,
allow_refill=allow_pool_refill,
) if not is_test_dc else None ) if not is_test_dc else None
if ws: if ws:
log.info("[%s] DC%d%s -> pool hit via %s", log.info("[%s] DC%d%s -> pool hit via %s",
@@ -398,7 +406,7 @@ async def _handle_client(reader, writer, secret: bytes):
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
else: else:
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
log.info("[%s] DC%d%s WS cooldown for %ds", log.info("[%s] DC%d%s WS failed for %ds",
label, dc, media_tag, int(DC_FAIL_COOLDOWN)) label, dc, media_tag, int(DC_FAIL_COOLDOWN))
splitter_fb = None splitter_fb = None
@@ -415,7 +423,6 @@ async def _handle_client(reader, writer, secret: bytes):
label, dc, media_tag) label, dc, media_tag)
return return
dc_fail_until.pop(dc_key, None)
ip_fail_until.pop(target, None) ip_fail_until.pop(target, None)
ws_pool.report_success(dc, is_media) ws_pool.report_success(dc, is_media)
stats.connections_ws += 1 stats.connections_ws += 1
@@ -524,6 +531,8 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
if proxy_config.cfproxy_worker_domains: if proxy_config.cfproxy_worker_domains:
log.info(" CF worker: enabled (%s)", log.info(" CF worker: enabled (%s)",
", ".join(proxy_config.cfproxy_worker_domains)) ", ".join(proxy_config.cfproxy_worker_domains))
if proxy_config.disable_secure:
log.info(" No secure: enabled (port 80 for CF proxy/worker)")
log.info("=" * 60) log.info("=" * 60)
log.info(" Connect:") log.info(" Connect:")
if ftls: if ftls:
@@ -640,7 +649,7 @@ def main():
ap.add_argument('--secret', type=str, default=None, ap.add_argument('--secret', type=str, default=None,
help='MTProto proxy secret (32 hex chars). ' help='MTProto proxy secret (32 hex chars). '
'Auto-generated if not provided.') 'Auto-generated if not provided.')
ap.add_argument('--dc-ip', metavar='DC:IP', action='append', ap.add_argument('--dc-ip', metavar='DC:IP', nargs='?', action='append', const=None,
help='Target IP for a DC, e.g. --dc-ip 2:149.154.167.220') help='Target IP for a DC, e.g. --dc-ip 2:149.154.167.220')
ap.add_argument('-v', '--verbose', action='store_true', ap.add_argument('-v', '--verbose', action='store_true',
help='Debug logging') help='Debug logging')
@@ -666,6 +675,8 @@ def main():
'repeatable for multiple domains)') 'repeatable for multiple domains)')
ap.add_argument('--no-cfproxy', action='store_true', ap.add_argument('--no-cfproxy', action='store_true',
help='Disable Cloudflare proxy fallback') help='Disable Cloudflare proxy fallback')
ap.add_argument('--no-secure', action='store_true',
help='Use 80 port for CF-proxy and CF-worker connections')
ap.add_argument('--fake-tls-domain', type=str, default='', ap.add_argument('--fake-tls-domain', type=str, default='',
metavar='DOMAIN', metavar='DOMAIN',
help='Enable Fake TLS (ee-secret) masking with the given ' help='Enable Fake TLS (ee-secret) masking with the given '
@@ -680,8 +691,13 @@ def main():
'(for use behind nginx/haproxy with proxy_protocol on)') '(for use behind nginx/haproxy with proxy_protocol on)')
args = ap.parse_args() args = ap.parse_args()
if not args.dc_ip: if args.dc_ip is None:
args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220'] args.dc_ip = [
'2:149.154.167.220',
'4:149.154.167.220',
]
elif None in args.dc_ip:
args.dc_ip = []
try: try:
dc_redirects = parse_dc_ip_list(args.dc_ip) dc_redirects = parse_dc_ip_list(args.dc_ip)
@@ -712,6 +728,7 @@ def main():
proxy_config.fallback_cfproxy = not args.no_cfproxy proxy_config.fallback_cfproxy = not args.no_cfproxy
proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain) proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain)
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain) proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
proxy_config.disable_secure = args.no_secure
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
proxy_config.force_test_dc = args.force_test_dc proxy_config.force_test_dc = args.force_test_dc
@@ -724,6 +741,7 @@ def main():
console = logging.StreamHandler() console = logging.StreamHandler()
console.setFormatter(log_fmt) console.setFormatter(log_fmt)
console.addFilter(DomainCensorFilter())
root.addHandler(console) root.addHandler(console)
if args.log_file: if args.log_file:
@@ -734,6 +752,7 @@ def main():
backups=args.log_backups, backups=args.log_backups,
) )
fh.setFormatter(log_fmt) fh.setFormatter(log_fmt)
fh.addFilter(DomainCensorFilter())
root.addHandler(fh) root.addHandler(fh)
logging.getLogger('asyncio').setLevel(logging.WARNING) logging.getLogger('asyncio').setLevel(logging.WARNING)
+31 -3
View File
@@ -2,6 +2,8 @@ import socket as _socket
import urllib.request import urllib.request
import http.client import http.client
import ssl import ssl
import logging
import re
import certifi import certifi
@@ -59,9 +61,9 @@ WS_PATH_TEST = WS_PATH + '_test'
def ws_domains(dc: int, is_media) -> List[str]: def ws_domains(dc: int, is_media) -> List[str]:
if dc == 203: if dc == 203:
dc = 2 dc = 2
if is_media is None or is_media: if not is_media:
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'] return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
def human_bytes(n: int) -> str: def human_bytes(n: int) -> str:
@@ -85,6 +87,32 @@ def get_link_host(host: str) -> Optional[str]:
return host return host
class DomainCensorFilter(logging.Filter):
domain_pattern = re.compile(
r'(?<![\w-])(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+'
r'[a-zA-Z]{2,}(?![\w-])'
)
def _censor_match(self, match):
domain = match.group()
normalized = domain.casefold().rstrip('.')
if normalized == 'telegram.org' or normalized.endswith('.telegram.org') or normalized.endswith('.log'):
return domain
parts = domain.split('.')
if len(parts) < 2:
return domain
return '.'.join(
part if i == len(parts) - 1 else
part[:len(part) // 2] + '*' * (len(part) - len(part) // 2)
for i, part in enumerate(parts)
)
def filter(self, record):
record.msg = self.domain_pattern.sub(self._censor_match, record.getMessage())
record.args = ()
return True
class _PinnedHTTPSHandler(urllib.request.HTTPSHandler): class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
def https_open(self, req: Request): def https_open(self, req: Request):
host = req.host.split(":")[0] host = req.host.split(":")[0]
+53
View File
@@ -0,0 +1,53 @@
import time
import unittest
from collections import deque
from types import SimpleNamespace
from unittest import mock
from proxy.config import proxy_config
from proxy.pool import _WsPool
class _StopRotation(Exception):
pass
def _open_ws():
transport = SimpleNamespace(is_closing=lambda: False)
writer = SimpleNamespace(transport=transport)
return SimpleNamespace(_closed=False, writer=writer)
class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
async def test_refills_partially_populated_bucket(self):
pool = _WsPool()
key = (2, False)
pool._idle[key] = deque([
(_open_ws(), time.monotonic()),
(_open_ws(), time.monotonic()),
])
sleep_calls = 0
async def stop_after_one_iteration(_delay):
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls > 1:
raise _StopRotation
with mock.patch.object(proxy_config, 'pool_size', 4):
with mock.patch(
'proxy.pool.asyncio.sleep',
side_effect=stop_after_one_iteration):
with mock.patch.object(
pool, '_schedule_refill') as schedule_refill:
with self.assertRaises(_StopRotation):
await pool._rotate(
key, '149.154.167.220', ['example.com'])
schedule_refill.assert_called_once_with(
key, '149.154.167.220', ['example.com'])
if __name__ == '__main__':
unittest.main()
+62 -28
View File
@@ -44,19 +44,21 @@ _CFWORKER_TEST_DST = {
} }
def _run_connectivity_test(cases: list) -> dict: def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict:
import base64 import base64
from contextlib import nullcontext
import ssl import ssl
import socket as _socket import socket as _socket
ctx = ssl.create_default_context() ctx = ssl.create_default_context() if secure else None
ctx.check_hostname = False port = 443 if secure else 80
ctx.verify_mode = ssl.CERT_NONE
results = {} results = {}
for dc, connect_host, sni_host, req_host, path in cases: for dc, connect_host, sni_host, req_host, path in cases:
try: try:
with _socket.create_connection((connect_host, 443), timeout=5) as raw: with _socket.create_connection((connect_host, port), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname=sni_host) as ssock: connection = (ctx.wrap_socket(raw, server_hostname=sni_host)
if secure else nullcontext(raw))
with connection as ssock:
ws_key = base64.b64encode(os.urandom(16)).decode() ws_key = base64.b64encode(os.urandom(16)).decode()
req = ( req = (
f"GET {path} HTTP/1.1\r\n" f"GET {path} HTTP/1.1\r\n"
@@ -91,36 +93,36 @@ def _run_connectivity_test(cases: list) -> dict:
return results return results
def _run_cfproxy_connectivity_test(domain: str) -> dict: def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = [] cases = []
for dc in _CFPROXY_TEST_DCS: for dc in _CFPROXY_TEST_DCS:
host = f"kws{dc}.{domain}" host = f"kws{dc}.{domain}"
cases.append((dc, host, host, host, "/apiws")) cases.append((dc, host, host, host, "/apiws"))
return _run_connectivity_test(cases) return _run_connectivity_test(cases, secure=secure)
def _run_cfworker_connectivity_test(domain: str) -> dict: def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = [] cases = []
for dc in _CFPROXY_TEST_DCS: for dc in _CFPROXY_TEST_DCS:
dst = _CFWORKER_TEST_DST[dc] dst = _CFWORKER_TEST_DST[dc]
path = f"/apiws?dst={dst}&dc={dc}&media=0" path = f"/apiws?dst={dst}&dc={dc}&media=0"
cases.append((dc, domain, domain, domain, path)) cases.append((dc, domain, domain, domain, path))
return _run_connectivity_test(cases) return _run_connectivity_test(cases, secure=secure)
def _run_cfproxy_multi_test(domains: list) -> dict: def _run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfproxy_connectivity_test(domain) for domain in domains} return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains}
def _run_cfworker_multi_test(domains: list) -> dict: def _run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfworker_connectivity_test(domain) for domain in domains} return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains}
def _run_cfproxy_auto_test(domains: list) -> tuple: def _run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple:
merged: dict = {} merged: dict = {}
best_domain = None best_domain = None
for domain in reversed(domains): for domain in reversed(domains):
res = _run_cfproxy_connectivity_test(domain) res = _run_cfproxy_connectivity_test(domain, secure=secure)
if all(v is True for v in res.values()): if all(v is True for v in res.values()):
return domain, res return domain, res
for dc, v in res.items(): for dc, v in res.items():
@@ -350,6 +352,7 @@ class TrayConfigFormWidgets:
secret_var: Any secret_var: Any
dc_textbox: Any dc_textbox: Any
verbose_var: Any verbose_var: Any
no_secure_var: Any
adv_entries: List[Any] adv_entries: List[Any]
adv_keys: Tuple[str, ...] adv_keys: Tuple[str, ...]
autostart_var: Optional[Any] autostart_var: Optional[Any]
@@ -373,6 +376,7 @@ def install_tray_config_form(
show_autostart: bool = False, show_autostart: bool = False,
autostart_value: bool = False, autostart_value: bool = False,
on_language_change: Optional[Callable[[], None]] = None, on_language_change: Optional[Callable[[], None]] = None,
on_update_click: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets: ) -> TrayConfigFormWidgets:
lang_cfg = cfg.get("language", default_config["language"]) lang_cfg = cfg.get("language", default_config["language"])
set_language(lang_cfg) set_language(lang_cfg)
@@ -535,6 +539,7 @@ def install_tray_config_form(
_cf_test_btn = [None] _cf_test_btn = [None]
def _on_cf_test(): def _on_cf_test():
secure = not no_secure_var.get()
user_domains = ( user_domains = (
coerce_domain_list(cfproxy_user_domain_var.get()) coerce_domain_list(cfproxy_user_domain_var.get())
if cf_custom_cb_var.get() else [] if cf_custom_cb_var.get() else []
@@ -546,7 +551,7 @@ def install_tray_config_form(
if user_domains: if user_domains:
def _worker(): def _worker():
try: try:
per = _run_cfproxy_multi_test(user_domains) per = _run_cfproxy_multi_test(user_domains, secure=secure)
if btn: if btn:
btn.after( btn.after(
0, 0,
@@ -563,7 +568,7 @@ def install_tray_config_form(
else: else:
def _worker_auto(): def _worker_auto():
try: try:
ok_domain, res = _run_cfproxy_auto_test(balancer.domains) ok_domain, res = _run_cfproxy_auto_test(balancer.domains, secure=secure)
if btn: if btn:
btn.after( btn.after(
0, 0,
@@ -671,6 +676,7 @@ def install_tray_config_form(
btn.configure(state="normal" if enabled else "disabled") btn.configure(state="normal" if enabled else "disabled")
def _on_cfworker_test(): def _on_cfworker_test():
secure = not no_secure_var.get()
domains = coerce_domain_list(cfproxy_worker_domain_var.get()) domains = coerce_domain_list(cfproxy_worker_domain_var.get())
btn = _cfworker_test_btn[0] btn = _cfworker_test_btn[0]
if not cfproxy_worker_enabled_var.get() or not domains or btn is None: if not cfproxy_worker_enabled_var.get() or not domains or btn is None:
@@ -680,7 +686,7 @@ def install_tray_config_form(
def _worker(): def _worker():
try: try:
per = _run_cfworker_multi_test(domains) per = _run_cfworker_multi_test(domains, secure=secure)
btn.after( btn.after(
0, 0,
lambda: _show_multi_connectivity_results( lambda: _show_multi_connectivity_results(
@@ -729,6 +735,11 @@ def install_tray_config_form(
verbose_cb.pack(anchor="w", pady=(0, 6)) verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, t("tip.verbose")) attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
no_secure_var = ctk.BooleanVar(value=cfg.get("no_secure", False))
no_secure_cb = _checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var)
no_secure_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(no_secure_cb, t("tip.no_secure"))
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent") adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x") adv_frame.pack(fill="x")
@@ -776,14 +787,35 @@ def install_tray_config_form(
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8)) justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ctk.CTkButton( if st.get("has_update") and on_update_click is not None:
upd_inner, text=t("button.open_release"), height=32, upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
font=(theme.ui_font_family, 13), corner_radius=8, upd_btn_row.pack(fill="x")
fg_color=theme.field_bg, hover_color=theme.field_border, upd_btn_row.grid_columnconfigure(0, weight=1)
text_color=theme.text_primary, border_width=1, upd_btn_row.grid_columnconfigure(1, weight=1)
border_color=theme.field_border, ctk.CTkButton(
command=lambda u=rel_url: webbrowser.open(u), upd_btn_row, text=t("button.open_release"), height=32,
).pack(anchor="w") font=(theme.ui_font_family, 13), corner_radius=8,
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 u=rel_url: webbrowser.open(u),
).grid(row=0, column=0, sticky="ew", padx=(0, 4))
ctk.CTkButton(
upd_btn_row, text=t("button.update"), height=32,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff",
command=on_update_click,
).grid(row=0, column=1, sticky="ew", padx=(4, 0))
else:
ctk.CTkButton(
upd_inner, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
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 u=rel_url: webbrowser.open(u),
).pack(anchor="w")
autostart_var = None autostart_var = None
if show_autostart: if show_autostart:
@@ -801,7 +833,7 @@ def install_tray_config_form(
return TrayConfigFormWidgets( return TrayConfigFormWidgets(
host_var=host_var, port_var=port_var, secret_var=secret_var, host_var=host_var, port_var=port_var, secret_var=secret_var,
dc_textbox=dc_textbox, verbose_var=verbose_var, dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_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_var=cfproxy_var,
@@ -913,6 +945,8 @@ def validate_config_form(
new_cfg["appearance"] = _appearance_to_cfg(widgets.appearance_var.get()) new_cfg["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
if widgets.language_var is not None: if widgets.language_var is not None:
new_cfg["language"] = language_from_label(widgets.language_var.get()).value new_cfg["language"] = language_from_label(widgets.language_var.get()).value
if widgets.no_secure_var is not None:
new_cfg["no_secure"] = bool(widgets.no_secure_var.get())
return new_cfg return new_cfg
+3
View File
@@ -32,6 +32,7 @@
"label.cf_custom_domain": "Custom domain", "label.cf_custom_domain": "Custom domain",
"label.cfworker_domains": "Cloudflare Worker domains (comma-separated)", "label.cfworker_domains": "Cloudflare Worker domains (comma-separated)",
"label.verbose": "Verbose logging", "label.verbose": "Verbose logging",
"label.no_secure": "Disable TLS for CF-proxy and CF-worker",
"label.buf_kb": "Buffer, KB (default 256)", "label.buf_kb": "Buffer, KB (default 256)",
"label.pool_size": "WebSocket session pool (default 4)", "label.pool_size": "WebSocket session pool (default 4)",
"label.log_max_mb": "Max log size, MB (default 5)", "label.log_max_mb": "Max log size, MB (default 5)",
@@ -44,6 +45,7 @@
"tip.secret": "Secret key for client authorization", "tip.secret": "Secret key for client authorization",
"tip.dc": "Mapping of Telegram data center (DC) number to web.telegram.org dc server IP.\nEach line: «number:IP», e.g. 4:149.154.167.220. The proxy routes traffic to Telegram servers using these rules\n\nIf connection fails then fallbacks are used", "tip.dc": "Mapping of Telegram data center (DC) number to web.telegram.org dc server IP.\nEach line: «number:IP», e.g. 4:149.154.167.220. The proxy routes traffic to Telegram servers using these rules\n\nIf connection fails then fallbacks are used",
"tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting", "tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting",
"tip.no_secure": "When enabled, the proxy will use port 80 for CF-proxy and CF-worker connections",
"tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket", "tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket",
"tip.pool": "How many parallel WebSocket sessions per data center can be kept open.\nIncreasing may help under high load", "tip.pool": "How many parallel WebSocket sessions per data center can be kept open.\nIncreasing may help under high load",
"tip.log_mb": "Maximum log file size; the file is overwritten when the limit is reached", "tip.log_mb": "Maximum log file size; the file is overwritten when the limit is reached",
@@ -113,6 +115,7 @@
"tray.restart": "Restart proxy", "tray.restart": "Restart proxy",
"tray.settings": "Settings...", "tray.settings": "Settings...",
"tray.logs": "Open logs", "tray.logs": "Open logs",
"tray.update": "Update ({current} → {new})",
"tray.exit": "Exit", "tray.exit": "Exit",
"dialog.restart_title": "Restart?", "dialog.restart_title": "Restart?",
+3
View File
@@ -32,6 +32,7 @@
"label.cf_custom_domain": "Свой домен", "label.cf_custom_domain": "Свой домен",
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)", "label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
"label.verbose": "Подробное логирование (verbose)", "label.verbose": "Подробное логирование (verbose)",
"label.no_secure": "Выключиь TLS для CF-прокси и CF-worker",
"label.buf_kb": "Буфер, КБ (по умолчанию 256)", "label.buf_kb": "Буфер, КБ (по умолчанию 256)",
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)", "label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)", "label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
@@ -44,6 +45,7 @@
"tip.secret": "Секретный ключ для авторизации клиентов", "tip.secret": "Секретный ключ для авторизации клиентов",
"tip.dc": "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\nКаждая строка: «номер:IP», например 4:149.154.167.220. Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\nЕсли у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220", "tip.dc": "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\nКаждая строка: «номер:IP», например 4:149.154.167.220. Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\nЕсли у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220",
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок", "tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
"tip.no_secure": "Если включено, прокси будет использовать 80 порт для CF-прокси и CF-worker подключений",
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет", "tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
"tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\nУвеличение может помочь при высокой нагрузке", "tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\nУвеличение может помочь при высокой нагрузке",
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается", "tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
@@ -113,6 +115,7 @@
"tray.restart": "Перезапустить прокси", "tray.restart": "Перезапустить прокси",
"tray.settings": "Настройки...", "tray.settings": "Настройки...",
"tray.logs": "Открыть логи", "tray.logs": "Открыть логи",
"tray.update": "Обновить ({current} → {new})",
"tray.exit": "Выход", "tray.exit": "Выход",
"dialog.restart_title": "Перезапустить?", "dialog.restart_title": "Перезапустить?",
+1
View File
@@ -25,6 +25,7 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"cfproxy_worker_enabled": False, "cfproxy_worker_enabled": False,
"cfproxy_worker_domain": [], "cfproxy_worker_domain": [],
"force_test_dc": False, "force_test_dc": False,
"no_secure": False,
} }
+4
View File
@@ -15,6 +15,7 @@ from typing import Any, Callable, Dict, Optional, Tuple
import psutil import psutil
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
from proxy.utils import DomainCensorFilter
from proxy.tg_ws_proxy import _run from proxy.tg_ws_proxy import _run
from utils.default_config import default_tray_config from utils.default_config import default_tray_config
from utils.diagnostics import diagnose_listen_error from utils.diagnostics import diagnose_listen_error
@@ -237,12 +238,14 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1) fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
fh.setLevel(logging.DEBUG) fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S")) fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
fh.addFilter(DomainCensorFilter())
root.addHandler(fh) root.addHandler(fh)
if not IS_FROZEN: if not IS_FROZEN:
ch = logging.StreamHandler(sys.stdout) ch = logging.StreamHandler(sys.stdout)
ch.setLevel(level) ch.setLevel(level)
ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S")) ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
ch.addFilter(DomainCensorFilter())
root.addHandler(ch) root.addHandler(ch)
@@ -371,6 +374,7 @@ def apply_proxy_config(cfg: dict) -> bool:
else [] else []
) )
pc.force_test_dc = cfg.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"]) pc.force_test_dc = cfg.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
pc.disable_secure = cfg.get("no_secure", DEFAULT_CONFIG["no_secure"])
return True return True
+63 -21
View File
@@ -34,7 +34,7 @@ try:
except ImportError: except ImportError:
Image = None Image = None
from proxy import get_link_host from proxy import __version__, get_link_host
from utils.win32_theme import ( from utils.win32_theme import (
is_windows_dark_theme, is_windows_dark_theme,
@@ -47,6 +47,9 @@ from utils.tray_common import (
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 utils.update_check import (
get_status, get_update_asset, run_check, RELEASES_PAGE_URL,
)
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form, install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer, populate_first_run_window, tray_settings_scroll_and_footer,
@@ -62,6 +65,7 @@ _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting = False _exiting = False
_win_mutex_handle = None _win_mutex_handle = None
_update_flow_lock = threading.Lock()
_ERROR_ALREADY_EXISTS = 183 _ERROR_ALREADY_EXISTS = 183
@@ -146,6 +150,7 @@ def update_ctk_form(
width=310 if IS_FROZEN else 210, width=310 if IS_FROZEN else 210,
height=130 if IS_FROZEN else 100, height=130 if IS_FROZEN else 100,
theme=theme, theme=theme,
topmost=False,
after_create=lambda r: r.iconbitmap(ICON_PATH), after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
frame = main_content_frame(ctk, root, theme, padx=16, pady=14) frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
@@ -203,7 +208,7 @@ def update_ctk_form(
btns.append(btn_upd) btns.append(btn_upd)
btn_pg = ctk.CTkButton( btn_pg = ctk.CTkButton(
row, text=t("button.page"), width=88, height=34, row, text=t("button.page"), width=88, height=34,
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"), font=(theme.ui_font_family, 13), command=lambda: webbrowser.open(release_url or RELEASES_PAGE_URL),
) )
btn_pg.pack(side="left", padx=(0, 6)) btn_pg.pack(side="left", padx=(0, 6))
btns.append(btn_pg) btns.append(btn_pg)
@@ -317,6 +322,42 @@ def _perform_update(download_url: str, set_status=None) -> None:
os._exit(0) os._exit(0)
def _trigger_update_flow(icon=None, item=None) -> None:
"""Show the update dialog for an update already known to be available.
Reused by the tray "Update" item and the settings dialog's "Update"
button, so an update can still be started after the initial startup
prompt was skipped/closed.
"""
if not _update_flow_lock.acquire(blocking=False):
return
def _show() -> None:
try:
if _exiting:
return
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
choice = update_ctk_form(
t("update.available", version=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 flow failed: %s", repr(exc))
finally:
_update_flow_lock.release()
threading.Thread(target=_show, daemon=True, name="manual-update").start()
def _maybe_do_update(cfg: dict, is_exiting) -> None: def _maybe_do_update(cfg: dict, is_exiting) -> None:
if not cfg.get("check_updates", True): if not cfg.get("check_updates", True):
return return
@@ -326,23 +367,12 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
if is_exiting(): if is_exiting():
return return
try: try:
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
run_check(__version__) run_check(__version__)
st = get_status() if _tray_icon is not None:
if not st.get("has_update") or is_exiting(): _tray_icon.menu = _build_menu()
if is_exiting():
return return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL _trigger_update_flow()
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
choice = update_ctk_form(
t("update.available", version=ver),
download_url=asset[0] if asset else None,
release_url=url,
)
if choice == "open":
webbrowser.open(url)
except Exception as exc: except Exception as exc:
log.warning("Update check failed: %s", repr(exc)) log.warning("Update check failed: %s", repr(exc))
@@ -481,7 +511,7 @@ def _edit_config_dialog() -> None:
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme, ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH), topmost=False, after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
fpx, fpy = CONFIG_DIALOG_FRAME_PAD fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@@ -498,6 +528,7 @@ def _edit_config_dialog() -> None:
show_autostart=_supports_autostart(), show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False), autostart_value=cfg.get("autostart", False),
on_language_change=_refresh_tray_menu, on_language_change=_refresh_tray_menu,
on_update_click=_trigger_update_flow,
) )
_original_appearance = ctk.get_appearance_mode() _original_appearance = ctk.get_appearance_mode()
@@ -579,7 +610,7 @@ def _show_first_run() -> None:
w, h = FIRST_RUN_SIZE w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title=t("app.name"), width=w, height=h, theme=theme, ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH), topmost=False, after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
def on_done(open_tg: bool) -> None: def on_done(open_tg: bool) -> None:
@@ -602,7 +633,7 @@ def _build_menu():
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host) link_host = get_link_host(host)
return pystray.Menu( items = [
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True), pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem(t("tray.copy_link"), _on_copy_link), pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
@@ -611,7 +642,18 @@ def _build_menu():
pystray.MenuItem(t("tray.logs"), _on_open_logs), pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.exit"), _on_exit), pystray.MenuItem(t("tray.exit"), _on_exit),
) ]
st = get_status()
if st.get("has_update"):
items[-2:-2] = [
pystray.Menu.SEPARATOR,
pystray.MenuItem(
t("tray.update", current=__version__, new=st.get("latest") or "?"),
_trigger_update_flow,
)
]
return pystray.Menu(*items)
# entry point # entry point