mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-09-20 17:28:27 +03:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70b982da2c | |||
| 5226a409d4 | |||
| e519c4df4c | |||
| a0605c7447 | |||
| 222cabeee0 | |||
| c2da669576 | |||
| 2c9a0b8a90 | |||
| 87977962dc | |||
| 5a61d8f782 | |||
| f200e33fd2 | |||
| c02398fbc0 | |||
| 5f55245184 | |||
| 312d844eea | |||
| be485a3851 | |||
| f811a10918 | |||
| 55ec6a91df | |||
| b23c8f6122 | |||
| d36bb7df71 | |||
| bd4f3d080c | |||
| 492d999248 | |||
| e8ad0d6958 | |||
| 5eb7e007d3 | |||
| 1ae2f3a147 | |||
| d2b6c450eb | |||
| 1b8671b3d3 |
@@ -1 +0,0 @@
|
|||||||
custom: ['https://nowpayments.io/donation/flowseal']
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||||
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
|
||||||
|
|
||||||
Проект полностью бесплатен для всех.
|
Проект полностью бесплатен для всех.
|
||||||
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
||||||
|
|||||||
@@ -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]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||||
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
|
||||||
|
|
||||||
Проект полностью бесплатен для всех.
|
Проект полностью бесплатен для всех.
|
||||||
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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, 4, 0),
|
||||||
prodvers=(1, 10, 0, 0),
|
prodvers=(1, 10, 4, 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.4.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.4.0'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -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.4"
|
||||||
|
|
||||||
__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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+18
-8
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
|
import certifi
|
||||||
import logging
|
import logging
|
||||||
import base64
|
import base64
|
||||||
import struct
|
import struct
|
||||||
@@ -21,10 +22,9 @@ _st_BBQ4s = struct.Struct('>BBQ4s')
|
|||||||
_st_H = struct.Struct('>H')
|
_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(cafile=certifi.where())
|
||||||
_ssl_ctx.check_hostname = False
|
_ssl_ctx_fronting = ssl.create_default_context(cafile=certifi.where())
|
||||||
_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 +87,24 @@ 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
|
||||||
|
|
||||||
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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
+63
-28
@@ -44,19 +44,22 @@ _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 certifi
|
||||||
import socket as _socket
|
import socket as _socket
|
||||||
|
|
||||||
ctx = ssl.create_default_context()
|
ctx = ssl.create_default_context(cafile=certifi.where()) 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 +94,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 +353,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 +377,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 +540,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 +552,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 +569,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 +677,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 +687,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 +736,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 +788,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 +834,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 +946,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -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,11 +115,12 @@
|
|||||||
"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?",
|
||||||
"dialog.restart_body": "Settings saved.\n\nRestart the proxy now?",
|
"dialog.restart_body": "Settings saved.\n\nRestart the proxy now?",
|
||||||
"dialog.already_running": "Application is already running.",
|
"dialog.already_running": "Application is already running, check the tray",
|
||||||
"dialog.log_not_found": "Log file has not been created yet.",
|
"dialog.log_not_found": "Log file has not been created yet.",
|
||||||
"dialog.ctk_missing": "customtkinter is not installed.",
|
"dialog.ctk_missing": "customtkinter is not installed.",
|
||||||
"dialog.copy_ok": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
|
"dialog.copy_ok": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
|
||||||
|
|||||||
+4
-1
@@ -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,11 +115,12 @@
|
|||||||
"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": "Перезапустить?",
|
||||||
"dialog.restart_body": "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
"dialog.restart_body": "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
||||||
"dialog.already_running": "Приложение уже запущено.",
|
"dialog.already_running": "Приложение уже запущено, проверьте трей.",
|
||||||
"dialog.log_not_found": "Файл логов ещё не создан.",
|
"dialog.log_not_found": "Файл логов ещё не создан.",
|
||||||
"dialog.ctk_missing": "customtkinter не установлен.",
|
"dialog.ctk_missing": "customtkinter не установлен.",
|
||||||
"dialog.copy_ok": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
|
"dialog.copy_ok": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user