Compare commits

..
263 Commits
Author SHA1 Message Date
Flowseal dc2c4c03f6 Restore win7 compatability 2026-10-30 20:38:49 +03:00
Flowseal 73aa394d9a Version bump 2026-10-30 18:52:39 +03:00
Flowseal a70fc8c5c1 Removed X25519MLKEM768 from optional supported groups 2026-10-30 18:44:15 +03:00
Flowseal 44c5ee2a57 some ai powered fixes for #1473 2026-10-30 18:34:32 +03:00
Denis 7fb01a7d05 Fake TLS: mirror client key share and profile the masking site (#1473) 2026-10-30 18:25:21 +03:00
partoftheworlD 08abb69df5 Add --no-secure support for Docker (#1493) 2026-10-30 18:20:46 +03:00
Flowseal 307a384ab7 Optional build signing 2026-10-11 19:44:57 +03:00
Flowseal 241d76d7b9 Bump version 2026-10-11 19:23:07 +03:00
Flowseal 7460091c19 SignPath CI integration 2026-10-11 19:20:53 +03:00
Flowseal 9deed3707a Update adjusts 2026-10-11 18:57:56 +03:00
Flowseal c52714f264 Manual update check 2026-10-11 18:01:21 +03:00
Flowseal 03be9baad1 Updates refactoring 2026-10-11 15:55:00 +03:00
holdingq0 c317406b52 Пул CF worker: закрытие при остановке, OPTI, сборка, доки (#1411) 2026-10-10 22:36:55 +03:00
Flowseal bf07b42f88 #1223 fixes, timeout for downloads 2026-10-10 22:08:57 +03:00
Nikita Kolesnikov a6177fec87 feat: автообновление на Linux без ручной установки пакета (#1223) 2026-10-10 22:01:46 +03:00
Fallout 5f8820d41f Перевод secret и donate на русский язык (#1419) 2026-10-10 21:44:18 +03:00
Flowseal 3cda0f5446 handle timeouted h2 connections, refill reply budget 2026-10-10 21:36:28 +03:00
Flowseal 59c3a6e3b3 fix h2 plain padding 2026-10-10 20:47:50 +03:00
Flowseal 9109570d32 fix commands on different keyboard layouts 2026-10-10 20:36:30 +03:00
Flowseal 00d72a4c7f fixes #1398 2026-10-10 19:00:34 +03:00
Flowseal a0a92f5e7e fix socketpair EOF 2026-10-10 18:54:09 +03:00
Flowseal a232472cbe fixes #1422 2026-10-09 18:54:03 +03:00
Flowseal 3fa281625f Optimizations 2026-10-06 23:21:03 +03:00
Flowseal 3c54c54d36 Optimizations 2026-10-06 19:15:27 +03:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ 03421197c8 Correct donate link (#1399) 2026-10-06 17:25:39 +03:00
Flowseal 18175fb4fe Version bump 2026-10-06 00:56:50 +03:00
Flowseal f742a8957d Correct handle of the 404 mtproto error 2026-10-06 00:56:20 +03:00
Flowseal 3686c36462 fixes #1392 2026-10-05 12:47:53 +03:00
Flowseal 4bdd69ade9 version bump 2026-10-05 02:31:01 +03:00
Flowseal 50c980e9ea WsPool refactoring, TCP fallback backoff, removed direct connections outside of a pool 2026-10-04 21:53:26 +03:00
Flowseal 48954ff9ab multiplexing media through cfproxy 2026-10-04 15:57:33 +03:00
Flowseal fe1580cd83 remove old docs path support 2026-10-04 15:12:26 +03:00
Flowseal ad7f3cae9e UI refactoring 2026-09-25 16:30:14 +03:00
Flowseal caa949bee0 fixes #1357 fixes #1354 2026-09-22 21:26:53 +03:00
Flowseal 3d59c813aa typo 2026-09-22 20:57:11 +03:00
Flowseal 70b982da2c Bump version 2026-09-19 22:51:47 +03:00
Flowseal 5226a409d4 clarify dialog message 2026-09-19 22:49:57 +03:00
Flowseal e519c4df4c use certifi for tls connections, fixes #1348 2026-09-19 22:48:25 +03:00
Flowseal a0605c7447 removed debug print closes #1350 2026-09-19 22:41:37 +03:00
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
Flowseal b2a8074c59 version bump 2026-08-13 14:48:53 +03:00
delewer 0d459995f3 chore: generic fixes (#1187) 2026-08-13 14:44:09 +03:00
Flowseal b8a5634008 fix contributing path 2026-08-07 22:40:35 +03:00
Flowseal de4179305c docs duplicate for comp 2026-08-07 22:33:36 +03:00
Fallout e3958984c4 Добавить двуязычную документацию (русский и английский) (#1101) 2026-08-07 21:14:52 +03:00
Flowseal 3e7e266176 fix (macOS): crash fix on settings open 2026-08-07 20:29:26 +03:00
Flowseal fba86856db unpin certifi version 2026-08-07 14:04:13 +03:00
partoftheworlD 8b05ba79ae Fix 9f9e1c2 (#1183) 2026-08-07 14:02:54 +03:00
Flowseal 9f9e1c2482 MacOS moved to tkinter and pystray; cetifi implementation to fix SSL issues 2026-08-07 00:41:20 +03:00
Flowseal 2496004c93 move locales path to be handled by pyinstaller 2026-08-04 15:36:50 +03:00
Flowseal b7ff77f550 tooltips moved to customtkinter; closes #1176 2026-08-04 15:24:09 +03:00
Flowseal 2995ae7436 Save user domains instead of erasing; closes #1177 2026-08-04 15:02:18 +03:00
Flowseal 02e52da3a7 removed unused config var 2026-08-04 13:44:56 +03:00
Flowseal 2a699bb91b Version bump 2026-07-31 16:53:31 +03:00
Flowseal a5b2b73dfd Updated icon 2026-07-31 16:52:17 +03:00
Flowseal aee473c9f9 CF Worker pool refactoring 2026-07-31 16:38:42 +03:00
Flowseal 41e97c6a05 hard limit workers pool 2026-07-31 14:20:58 +03:00
Flowseal ecf3d6f3a1 Fixes #1161 fixes #1155 2026-07-31 13:41:23 +03:00
Flowseal 21aaeb3aba macos build fix 2026-07-28 11:43:28 +03:00
Flowseal e0230ebda7 Version bump 2026-07-28 11:33:42 +03:00
Flowseal 47b8db18d9 Added pool refill backoff; don't refill pool on .get if ip is blocked 2026-07-28 11:08:24 +03:00
Flowseal a0545cca64 don't retry fronting connect 2026-07-28 10:55:36 +03:00
Flowseal 129a760996 fixes #1079 #1083 2026-07-28 10:41:39 +03:00
Flowseal 8ac52f69b3 auto wspool rotation 2026-07-20 02:38:57 +03:00
Flowseal 34cfd8cf22 pool fronting 2026-07-19 16:02:49 +03:00
Flowseal 88b7b55c58 fronting refactoring: implemented into ws_pool 2026-07-19 15:28:57 +03:00
Flowseal 57b538389c docs update 2026-07-09 23:23:15 +03:00
Flowseal eb00122821 github bot 2026-07-08 14:15:41 +03:00
Flowseal e81f0a973d fixes #1113 2026-07-08 00:43:13 +03:00
Flowseal 3143eb7147 docs upd 2026-07-07 21:57:06 +03:00
Danil c3309ed11b Поддержка тестового окружения Telegram (тестовые DC) (#1087) 2026-07-07 21:56:24 +03:00
Flowseal e9b74d7d7d optional art builds 2026-06-30 19:15:53 +03:00
Flowseal 050dcbce44 fixes #1072 #1060 2026-06-30 19:11:01 +03:00
Flowseal 4f9edf3072 Version bump 2026-06-27 01:22:19 +03:00
Flowseal 9ff95d1222 ip_timeout implementation 2026-06-27 01:19:27 +03:00
Flowseal 4c19a6cce4 release footer separator 2026-06-26 19:35:25 +03:00
Flowseal bb900e0c9e todo 2026-06-26 19:33:48 +03:00
Flowseal 1cdbca8893 Version bump 2026-06-26 19:03:19 +03:00
Flowseal 0df9174ce2 dc tip correction for en locale 2026-06-26 19:01:14 +03:00
Flowseal 7fc24fea95 todos 2026-06-26 18:59:37 +03:00
Flowseal 5c40fa2574 faster scroll 2026-06-26 18:51:55 +03:00
Flowseal c8f6f8caf4 Fronting fallback 2026-06-26 18:37:30 +03:00
Flowseal d3d30799a1 watchdog adjustments 2026-06-26 16:54:02 +03:00
Flowseal a0806d5a22 i18n fixes 2026-06-26 16:52:56 +03:00
Flowseal eb22fadb7a watchdog as possible fix winerror 64 2026-06-26 16:44:13 +03:00
Flowseal 43bca3a71b may be fixes #1017 2026-06-23 18:45:20 +03:00
Flowseal 6b5fd72612 i18n fixes 2026-06-23 18:24:26 +03:00
Kirill 85b5e7f22a feature: i18n (#1025) 2026-06-23 15:41:49 +03:00
Flowseal fed772049b session close reason 2026-06-23 14:55:25 +03:00
Flowseal 91d39a5ebe Revert "fix: add WebSocket keepalive pings to prevent idle disconnects (#646) (#925)" (#1036)
This reverts commit 96e5b4b639.
2026-06-23 14:47:12 +03:00
Flowseal 5cbac657dc CfWorker pool age tune 2026-06-23 13:46:54 +03:00
delewer ee6c34e065 ci: add better view on macos installation (#1019) 2026-06-23 13:36:20 +03:00
Flowseal ce6a456bd1 docs update 2026-06-23 13:24:49 +03:00
Flowseal 5bc5001c4d SHA256 compare fix 2026-06-18 15:22:30 +03:00
Flowseal 2afd80825b docs update 2026-06-17 11:33:20 +03:00
Flowseal 12fafbc8f4 Version bump 2026-06-17 11:05:17 +03:00
Flowseal 5839ca2564 Portable mode 2026-06-17 11:02:04 +03:00
Flowseal e40c571009 #646 fixes 2026-06-17 10:25:45 +03:00
Konukhov Yaroslav 96e5b4b639 fix: add WebSocket keepalive pings to prevent idle disconnects (#646) (#925) 2026-06-17 10:13:55 +03:00
Flowseal 13d2b1db6d #943 fixes 2026-06-17 09:54:53 +03:00
Yan a29a1a8610 Добавлена сборка Windows ARM64 в Build & Release workflow (#943) 2026-06-17 09:50:32 +03:00
partoftheworlD 94010f1481 Added support for cf worker for docker container (#996) 2026-06-17 09:45:25 +03:00
Kenyka Kenykovich 42172235c7 Исправлен fallback список CF proxy доменов (добавлена запятая) (#958) 2026-06-17 09:44:00 +03:00
Flowseal b0010af130 #924 improvements 2026-06-17 09:43:06 +03:00
Konukhov Yaroslav 784a7f659b fix: diagnose permission and bad-address bind failures on startup (#924) 2026-06-17 09:24:44 +03:00
Konukhov Yaroslav 21fe672963 fix: rotate log files instead of growing without bound (#885) (#932) 2026-06-17 09:13:32 +03:00
Flowseal ed46ecce5a version bump 2026-06-03 17:14:12 +03:00
Flowseal 9562b11101 docs 2026-06-03 17:13:47 +03:00
Flowseal dfdb993da5 shuffle cfworker domains 2026-06-03 17:09:16 +03:00
Flowseal d4f8b51326 version bump 2026-05-30 20:34:26 +03:00
Flowseal ca431633d7 Version bump 2026-05-30 20:32:11 +03:00
Flowseal ea4e8e790a Possibility to pass few cfproxy and worker domains 2026-05-30 20:30:47 +03:00
Flowseal 05d6de269b import path fixes 2026-05-30 19:39:58 +03:00
Flowseal 1c4b103df2 Pool for cloudflare worker 2026-05-30 19:34:47 +03:00
Erik 23f0e4d426 Fall back to system libcrypto when cryptography is unavailable (#894) 2026-05-30 19:31:47 +03:00
Konukhov Yaroslav 49e62ca142 perf(bridge): split MTProto packets in O(N) instead of O(N^2) (#913) 2026-05-30 19:25:56 +03:00
delewer 5915a0e1f3 docs: update images (#858) 2026-05-17 01:04:37 +03:00
Flowseal 7bc9e133c8 Update README.md 2026-05-16 20:01:26 +03:00
Flowseal 12d3d5e478 Update README.md 2026-05-16 20:00:34 +03:00
Flowseal b7cca232ea Update CfWorker.md 2026-05-16 11:47:56 +03:00
Flowseal 0eebdff69e Version bump 2026-05-16 11:32:48 +03:00
Flowseal ab3bec967c Update CfWorker.md 2026-05-16 11:32:12 +03:00
Flowseal a16f7dfc0b Update CfWorker.md 2026-05-16 11:31:21 +03:00
Flowseal 6f02fc1c46 remove cf priority flag, cf worker ui setup 2026-05-16 11:17:42 +03:00
Flowseal 884fffcc2f cf worker mention in readme 2026-05-16 11:17:42 +03:00
Flowseal 09ce00b2e0 worker's code cleanup 2026-05-16 11:17:21 +03:00
Flowseal 362c5a4893 cloudflare worker implementation 2026-05-16 11:17:21 +03:00
Kira bff67b3ecf Docs/readme docker (#843) 2026-05-13 09:20:10 +03:00
Flowseal d5abfbf9c2 github connection fallback 2026-05-09 16:47:56 +03:00
Flowseal 8269ebe3bb download ways mention on build's page 2026-05-08 20:42:31 +03:00
Flowseal 3770569789 revert version 2026-05-08 14:49:19 +03:00
Flowseal e72a44d74b github downloader fix 2026-05-08 14:36:54 +03:00
deexsed 33d3147c0b fix: автоответы только для label "bug" (#826) 2026-05-08 12:19:20 +03:00
Flowseal e46cf138ac new domains 2026-05-08 09:35:17 +03:00
Flowseal 145b0c431a version bump 2026-05-08 09:32:07 +03:00
Flowseal b991948a77 remove labels 2026-05-08 09:31:50 +03:00
Flowseal 42df9cfdc4 triage update 2026-05-08 09:31:26 +03:00
Flowseal eabc0c9a15 docs update 2026-05-08 09:22:56 +03:00
Flowseal ea88adc2dc rpm build fixes 2026-05-08 09:19:23 +03:00
Sylvester Alderson f554f730c2 linux rpm package, github actions (#726) 2026-05-08 09:16:54 +03:00
deexsed f85c4fe854 fix(config): добавлена валидация и надежный fallback для CF domain list (#799) 2026-05-08 08:59:03 +03:00
Proger b070647996 support TG_WS_PROXY_SECRET (#769) 2026-05-08 08:54:45 +03:00
Konukhov Yaroslav e3d2229f7f Мелкие фиксы настроек и пара багов (#805) 2026-05-08 08:54:30 +03:00
deexsed 1cbc7dee7d Улучшена "архитектура" документации и настроен triage для issues (#801) 2026-05-08 08:51:40 +03:00
delewer 1728fabfc7 docs(README): update preview image source with better visual (#817) 2026-05-06 17:31:49 +03:00
deexsed bf08bad11e Fix --cfproxy-priority CLI parsing (#797) 2026-04-30 13:29:43 +03:00
Kleshzz a787b4fd6b Update README.md (#796) 2026-04-30 06:12:54 +03:00
Sanfix 2c577c1166 Update README.md (change service name in cli version) (#783) 2026-04-28 17:38:37 +03:00
delewer 05b22fe3ba docs(README): fix little typo bug (#778) 2026-04-26 18:54:02 +03:00
Flowseal cc00c6d040 Version bump 2026-04-26 16:58:48 +03:00
Flowseal b3ed5c09db Windows auto update 2026-04-26 16:58:17 +03:00
Flowseal b8556dc702 fix #775 2026-04-26 16:26:50 +03:00
Flowseal 28be00ea9e docs update 2026-04-19 17:32:54 +03:00
Flowseal 5795de00b1 Version bump 2026-04-18 18:59:46 +03:00
Flowseal c5fa5b7f3e fix: cfproxy user domain not set via CLI #741 2026-04-18 18:59:16 +03:00
Flowseal a70e50b9f3 refactor 2026-04-18 16:58:49 +03:00
Flowseal 059ca8760f moved some dubug logs to warning level 2026-04-18 15:49:42 +03:00
Flowseal 0c8d0f160a better exception logging 2026-04-18 15:45:15 +03:00
Flowseal 791708cc3d ws_blacklsit annotation fix 2026-04-18 15:25:11 +03:00
Flowseal 1abcbf86fe gitignore clear 2026-04-18 15:23:56 +03:00
Flowseal d84b9eadc4 version fix 2026-04-16 18:20:47 +03:00
Flowseal c1b4cb0204 docs update 2026-04-16 18:01:48 +03:00
Flowseal 5d08e16e5d removed repeated annotation 2026-04-16 17:56:48 +03:00
Flowseal a844a88f38 docs update 2026-04-16 17:52:58 +03:00
Flowseal e5f1d02737 docs links update 2026-04-16 17:51:41 +03:00
Flowseal 3a6e82c2a8 docs update 2026-04-16 17:50:32 +03:00
Flowseal e56ada1a34 CF domains balancer 2026-04-16 17:08:03 +03:00
Flowseal b44d79a933 docs update 2026-04-16 17:08:03 +03:00
Aksarin Mikhail 77723d875f Update README.md (#711)
Fix relative links
2026-04-16 00:29:58 +03:00
Flowseal 548ec05fc5 docs update 2026-04-14 21:56:14 +03:00
Flowseal 03c7719c39 mutex check simplify 2026-04-14 16:58:54 +03:00
Flowseal db4cebe0b2 build test 2026-04-14 16:51:26 +03:00
Flowseal ca81d037f7 docs update 2026-04-14 03:11:13 +03:00
Flowseal 07615af49c bootloader build fix 2026-04-14 02:44:15 +03:00
Flowseal f8ee37370d Version bump 2026-04-14 00:27:27 +03:00
Flowseal 4cbb9e555c windows mutex-lock 2026-04-14 00:27:27 +03:00
Flowseal 25ae4b0a24 build version changes 2026-04-14 00:27:27 +03:00
Kleshzz 8af1bc8c89 Add .gitattributes & Update .gitignore (#690) 2026-04-13 19:30:57 +03:00
Flowseal b48ac67b9f donate web link 2026-04-11 21:27:21 +03:00
Flowseal 937acdb461 Version bump 2026-04-11 21:09:46 +03:00
Flowseal 6f3da84e48 Refresh domains schedule 2026-04-11 21:09:08 +03:00
Flowseal 3c3e9eb34b fix domains testing 2026-04-11 21:03:53 +03:00
Flowseal ba89cad8b8 fake-tls cli 2026-04-11 20:52:24 +03:00
Flowseal bf905ec54f docs update 2026-04-11 19:11:47 +03:00
Flowseal ace0a5e968 docs update 2026-04-11 18:54:32 +03:00
Flowseal e47eef4709 docs update 2026-04-11 15:28:37 +03:00
Flowseal abe1d1f01e docs update 2026-04-11 15:28:37 +03:00
Flowseal cc31c02c9d donate button ctk 2026-04-11 15:28:37 +03:00
Flowseal f39bb15ff6 docs update 2026-04-11 15:28:31 +03:00
kreker06 5a62cd82b2 Update Dockerfile (#586) 2026-04-11 14:54:32 +03:00
Flowseal fe4e0e8234 docs update 2026-04-10 19:28:36 +03:00
Flowseal 172dc67093 docs update 2026-04-10 02:57:25 +03:00
Flowseal c5c2907fa8 docs update 2026-04-10 02:23:18 +03:00
Flowseal 26b95ffa0f Version bump 2026-04-10 01:48:07 +03:00
Flowseal 3dfcc27932 remove caching for domains check 2026-04-10 00:59:43 +03:00
Flowseal 6e0e567790 new domain 2026-04-10 00:56:57 +03:00
Flowseal bc79a5e4c1 possible #626 ref 2026-04-10 00:37:27 +03:00
Flowseal ce83b78bac small fixes 2026-04-10 00:22:45 +03:00
Flowseal a6235f3594 prettify 2026-04-09 23:51:18 +03:00
Flowseal c0d9b5f8e1 refactoring 2026-04-09 23:43:06 +03:00
Flowseal 4041fd9f05 unpack bug fix 2026-04-09 23:20:32 +03:00
Flowseal dd09f24449 multiple domains handling 2026-04-09 23:12:17 +03:00
Flowseal dd666489e3 theme combobox 2026-04-09 20:10:48 +03:00
Flowseal 3af0cd75a2 update imports after refactor 2026-04-09 19:55:12 +03:00
Flowseal 535d4126ed refactoring 2026-04-09 19:54:38 +03:00
Flowseal 44e754ded0 exclude not needed modules 2026-04-09 15:45:11 +03:00
Flowseal 71be4461d3 cfproxy typo 2026-04-08 02:17:21 +03:00
Flowseal 9279399f00 readme typo 2026-04-08 02:16:35 +03:00
Flowseal 557c92b9a3 docs upd 2026-04-08 02:15:39 +03:00
Flowseal c883674ad0 dc203 ip change 2026-04-08 02:09:03 +03:00
Flowseal df98baf961 and another one 2026-04-08 00:36:12 +03:00
Flowseal 34dde32033 and another one 2026-04-08 00:25:41 +03:00
Flowseal b8bd062663 git actions compile test 2026-04-08 00:18:38 +03:00
Flowseal 8e1e3fcc45 bootloader recompile test 2026-04-08 00:11:07 +03:00
Flowseal 097bb9d0b7 version bump 2026-04-08 00:00:17 +03:00
Flowseal 19fbf7494a pyinstaller version update 2026-04-08 00:00:02 +03:00
Flowseal 4b0bc2f4d2 dc203 override hardcode 2026-04-07 23:53:58 +03:00
Flowseal 7850e1f5b4 pool reset on restart 2026-04-07 23:52:55 +03:00
Flowseal 63d5bafd3e docs upd 2026-04-07 18:11:26 +03:00
Flowseal 7eaba0b29c docs upd 2026-04-07 18:06:49 +03:00
Flowseal 6c94d3a39d tip block 2026-04-07 17:51:59 +03:00
Flowseal 746cd66b35 build fixes 2026-04-07 17:15:30 +03:00
Flowseal e5d8ff7769 version changing & readme update 2026-04-07 17:12:29 +03:00
Flowseal 3ee82e5114 typos 2026-04-07 17:07:41 +03:00
Qirashi db1308e3f5 Tray dark theme (#591) 2026-04-07 17:06:21 +03:00
Flowseal 6231499c39 lock fixes 2026-04-07 17:04:01 +03:00
Flowseal 826554abfb CfProxy UI setup 2026-04-07 17:04:01 +03:00
Flowseal 7f44c524c8 lists clear on restart 2026-04-07 17:04:01 +03:00
Flowseal 6310fcd6eb docs 2026-04-07 17:03:01 +03:00
Flowseal 081b150b3d Removed dc overriding 2026-04-07 17:02:13 +03:00
Flowseal 15001980dc cloudflare proxy; closes #576 2026-04-07 17:02:13 +03:00
gogamlg3 da4b521aba Изменение README для AUR (#485) 2026-03-30 09:55:44 +03:00
Flowseal 07facfe18c Version bump 2026-03-29 20:00:31 +03:00
Qirashi 7a886dff26 Update ctk_theme.py (#480) 2026-03-29 19:56:43 +03:00
Flowseal 17e37f9ca0 host detect in first-run window 2026-03-29 19:55:39 +03:00
Flowseal 968827445f copy link, mtproto new first run notify 2026-03-29 17:57:55 +03:00
Flowseal be8d178e5c secret validation 2026-03-29 17:30:39 +03:00
Flowseal 46426c45b0 ctk refactoring 2026-03-29 15:21:56 +03:00
Flowseal c4a044542c fixes 2026-03-29 15:21:45 +03:00
Flowseal af74009b11 icon fix 2026-03-28 16:48:59 +03:00
Flowseal 6766db9812 mtproto recode 2026-03-28 15:45:08 +03:00
Flowseal 95f99be26b old socks removed 2026-03-28 13:07:51 +03:00
deexsed 0d11062c92 fix: остановка прокси из трея, пул WS и проверка обновлений (#443) 2026-03-27 10:54:33 +03:00
106 changed files with 15793 additions and 3899 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
* text=auto eol=lf
*.py text diff=python
*.spec text linguist-language=Python
*.toml text
*.txt text
*.ico binary
+11
View File
@@ -0,0 +1,11 @@
# Default owners
* @Flowseal
# Automation and repository settings
.github/** @Flowseal
# Documentation
docs/** @Flowseal
# Core proxy implementation
proxy/** @Flowseal
View File
Whitespace-only changes.
+12 -9
View File
@@ -1,20 +1,23 @@
name: 🐛 Проблема
title: '[Проблема] '
description: Сообщить о проблеме
labels: ['type: проблема', 'status: нуждается в сортировке']
labels: ['bug']
body:
- type: textarea
id: description
- type: input
id: app_version
attributes:
label: Опишите вашу проблему
description: Чётко опишите проблему с которой вы столкнулись
placeholder: Описание проблемы
label: Версия TG WS Proxy
description: Укажите версию приложения (например, v1.2.3)
placeholder: vX.Y.Z
validations:
required: true
- type: textarea
id: additions
id: description
attributes:
label: Дополнительные детали
description: Если у вас проблемы с работой прокси, то приложите файл логов в момент возникновения проблемы.
label: Опишите вашу проблему
description: Чётко опишите проблему, с которой вы столкнулись
placeholder: Описание проблемы
validations:
required: true
+6
View File
@@ -0,0 +1,6 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Документация
url: https://github.com/Flowseal/tg-ws-proxy/tree/main/docs
about: Ознакомьтесь с документацией перед созданием issue
@@ -0,0 +1,37 @@
name: 🚀 Предложение
title: '[Предложение] '
description: Предложить улучшение или новую функциональность
labels: ['enhancement']
body:
- type: textarea
id: solution
attributes:
label: Предлагаемое решение
description: Опишите, как именно вы предлагаете улучшить проект
placeholder: |
Предлагаю добавить ...
Это позволит ...
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Для какой платформы актуально?
description: Выберите платформу, если предложение связано с конкретной ОС
options:
- Все платформы
- Windows
- macOS
- Linux
- Другое
validations:
required: true
- type: textarea
id: context
attributes:
label: Дополнительный контекст
description: Добавьте примеры, ссылки, скриншоты или другие детали
placeholder: Любые дополнительные материалы по предложению
+20
View File
@@ -0,0 +1,20 @@
virkgj.com
vmmzovy.com
mkuosckvso.com
zaewayzmplad.com
twdmbzcm.com
awzwsldi.com
clngqrflngqin.com
tjacxbqtj.com
bxaxtxmrw.com
dmohrsgmohcrwb.com
vwbmtmoi.com
khgrre.com
ulihssf.com
tmhqsdqmfpmk.com
xwuwoqbm.com
orgcnunpj.com
zhkuldz.com
zypoljnslxa.com
efabnxaowuzs.com
zaftuzsftqdq.com
+2
View File
@@ -0,0 +1,2 @@
trusted_users:
- Flowseal
+382 -51
View File
@@ -12,13 +12,51 @@ on:
description: "Release version tag (e.g. v1.0.0)"
required: false
default: "v1.0.0"
sign_windows:
description: 'Sign?'
type: boolean
required: false
default: true
build_windows_x64:
description: 'Build Windows x64?'
type: boolean
required: false
default: true
build_windows_arm64:
description: 'Build Windows ARM64?'
type: boolean
required: false
default: true
build_win7:
description: 'Build Windows 7 (x64 + x86)?'
type: boolean
required: false
default: true
build_macos:
description: 'Build macOS universal?'
type: boolean
required: false
default: true
build_linux:
description: 'Build Linux (amd64/.deb/.rpm)?'
type: boolean
required: false
default: true
permissions:
actions: read
contents: write
env:
SIGNPATH_ORGANIZATION_ID: d0164909-65ed-4ca8-aa9f-ec148a9f7950
SIGNPATH_PROJECT_SLUG: tg-ws-proxy
SIGNPATH_ARTIFACT_CONFIGURATION_SLUG: initial
SIGNPATH_SIGNING_POLICY: ${{ github.event.inputs.make_release == 'true' && 'release-signing' || 'test-signing' }}
jobs:
build-windows:
build-windows-x64:
runs-on: windows-latest
if: ${{ github.event.inputs.build_windows_x64 == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -26,29 +64,186 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
python-version: "3.11"
cache: "pip"
- name: Setup MSVC 14.40 toolset
uses: ilammy/msvc-dev-cmd@v1
with:
toolset: 14.40
- name: Install dependencies
run: pip install .
- name: Install pyinstaller
run: pip install "pyinstaller==6.13.0"
- name: Build PyInstaller bootloader from source
env:
PYINSTALLER_COMPILE_BOOTLOADER: "1"
run: |
pip download --no-binary pyinstaller --no-deps --no-cache-dir -d pyinstaller_src "pyinstaller==6.10.0"
pip install (Get-ChildItem pyinstaller_src\*.tar.gz).FullName
- name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm
- name: Strip Rich PE header
shell: bash
run: |
python -c "
import struct, pathlib
exe = pathlib.Path('dist/TgWsProxy.exe')
data = bytearray(exe.read_bytes())
rich = data.find(b'Rich')
if rich == -1:
raise SystemExit('Rich header not found')
ck = struct.unpack_from('<I', data, rich + 4)[0]
dans = struct.pack('<I', 0x536E6144 ^ ck)
ds = data.find(dans)
if ds == -1:
raise SystemExit('DanS marker not found')
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
exe.write_bytes(data)
print(f'Stripped Rich header: offset {ds}..{rich+8}')
"
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
- name: Upload artifact
- name: Upload unsigned artifact
if: ${{ inputs.sign_windows }}
id: upload-unsigned
uses: actions/upload-artifact@v7
with:
name: TgWsProxy
name: unsigned-windows-x64
path: dist/TgWsProxy_windows.exe
archive: true
if-no-files-found: error
- name: Sign EXE with SignPath
if: ${{ inputs.sign_windows }}
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy-windows-x64
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows.exe
archive: true
if-no-files-found: error
build-windows-arm64:
runs-on: windows-11-arm
if: ${{ github.event.inputs.build_windows_arm64 == 'true' }}
env:
CRYPTOGRAPHY_VERSION: "46.0.5"
ARM64_WHEELHOUSE: wheelhouse-arm64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
architecture: arm64
cache: "pip"
- name: Restore ARM64 cryptography wheel
id: cryptography-wheel-cache
uses: actions/cache@v4
with:
path: ${{ env.ARM64_WHEELHOUSE }}
key: windows-arm64-py311-cryptography-${{ env.CRYPTOGRAPHY_VERSION }}-${{ hashFiles('pyproject.toml', '.github/workflows/build.yml') }}
- name: Install ARM64 OpenSSL
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
vcpkg install openssl:arm64-windows-static
$opensslDir = "$env:VCPKG_INSTALLATION_ROOT\installed\arm64-windows-static"
"OPENSSL_DIR=$opensslDir" >> $env:GITHUB_ENV
"OPENSSL_STATIC=1" >> $env:GITHUB_ENV
"VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" >> $env:GITHUB_ENV
- name: Build ARM64 cryptography wheel
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
run: |
mkdir $env:ARM64_WHEELHOUSE
pip wheel --no-deps --wheel-dir $env:ARM64_WHEELHOUSE "cryptography==$env:CRYPTOGRAPHY_VERSION"
- name: Install dependencies & pyinstaller
run: pip install --find-links $env:ARM64_WHEELHOUSE . "pyinstaller==6.13.0"
- name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm
- name: Strip Rich PE header
shell: bash
run: |
python -c "
import struct, pathlib
exe = pathlib.Path('dist/TgWsProxy.exe')
data = bytearray(exe.read_bytes())
rich = data.find(b'Rich')
if rich == -1:
print('Rich header not found, skipping')
raise SystemExit(0)
ck = struct.unpack_from('<I', data, rich + 4)[0]
dans = struct.pack('<I', 0x536E6144 ^ ck)
ds = data.find(dans)
if ds == -1:
print('DanS marker not found, skipping')
raise SystemExit(0)
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
exe.write_bytes(data)
print(f'Stripped Rich header: offset {ds}..{rich+8}')
"
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_arm64.exe
- name: Upload unsigned artifact
if: ${{ inputs.sign_windows }}
id: upload-unsigned
uses: actions/upload-artifact@v7
with:
name: unsigned-windows-arm64
path: dist/TgWsProxy_windows_arm64.exe
archive: true
if-no-files-found: error
- name: Sign EXE with SignPath
if: ${{ inputs.sign_windows }}
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy-windows-arm64
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows_arm64.exe
archive: true
if-no-files-found: error
build-win7:
runs-on: windows-latest
if: ${{ github.event.inputs.build_win7 == 'true' }}
strategy:
matrix:
include:
@@ -71,17 +266,65 @@ jobs:
- name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm
- name: Strip Rich PE header
shell: bash
run: |
python -c "
import struct, pathlib
exe = pathlib.Path('dist/TgWsProxy.exe')
data = bytearray(exe.read_bytes())
rich = data.find(b'Rich')
if rich == -1:
raise SystemExit('Rich header not found')
ck = struct.unpack_from('<I', data, rich + 4)[0]
dans = struct.pack('<I', 0x536E6144 ^ ck)
ds = data.find(dans)
if ds == -1:
raise SystemExit('DanS marker not found')
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
exe.write_bytes(data)
print(f'Stripped Rich header: offset {ds}..{rich+8}')
"
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
- name: Upload artifact
- name: Upload unsigned artifact
if: ${{ inputs.sign_windows }}
id: upload-unsigned
uses: actions/upload-artifact@v7
with:
name: unsigned-win7-${{ matrix.suffix }}
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
archive: true
if-no-files-found: error
- name: Sign EXE with SignPath
if: ${{ inputs.sign_windows }}
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy-win7-${{ matrix.suffix }}
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
archive: true
if-no-files-found: error
build-macos:
runs-on: macos-latest
if: ${{ github.event.inputs.build_macos == 'true' }}
env:
CFFI_VERSION: "2.0.0"
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -107,7 +350,7 @@ jobs:
--python-version 3.12 \
--implementation cp \
-d wheelhouse/arm64 \
'cffi>=2.0.0' \
"cffi==$CFFI_VERSION" \
Pillow==12.1.0 \
psutil==7.0.0
@@ -117,7 +360,7 @@ jobs:
--python-version 3.12 \
--implementation cp \
-d wheelhouse/x86_64 \
'cffi>=2.0.0' \
"cffi==$CFFI_VERSION" \
Pillow==12.1.0
python3.12 -m pip download \
@@ -147,30 +390,16 @@ jobs:
python3.12 -m pip install .
python3.12 -m pip install pyinstaller==6.13.0
- name: Create macOS icon from ICO
- name: Validate macOS GUI dependencies
run: |
python3.12 -m pip check
python3.12 -m py_compile macos.py
python3.12 -c "import AppKit, customtkinter, macos, pystray, tkinter"
- name: Create macOS icon
run: |
set -euo pipefail
python3.12 - <<'PY'
from PIL import Image
image = Image.open('icon.ico')
image = image.resize((1024, 1024), Image.LANCZOS)
image.save('icon_1024.png', 'PNG')
PY
mkdir -p icon.iconset
sips -z 16 16 icon_1024.png --out icon.iconset/icon_16x16.png
sips -z 32 32 icon_1024.png --out icon.iconset/icon_16x16@2x.png
sips -z 32 32 icon_1024.png --out icon.iconset/icon_32x32.png
sips -z 64 64 icon_1024.png --out icon.iconset/icon_32x32@2x.png
sips -z 128 128 icon_1024.png --out icon.iconset/icon_128x128.png
sips -z 256 256 icon_1024.png --out icon.iconset/icon_128x128@2x.png
sips -z 256 256 icon_1024.png --out icon.iconset/icon_256x256.png
sips -z 512 512 icon_1024.png --out icon.iconset/icon_256x256@2x.png
sips -z 512 512 icon_1024.png --out icon.iconset/icon_512x512.png
sips -z 1024 1024 icon_1024.png --out icon.iconset/icon_512x512@2x.png
iconutil -c icns icon.iconset -o icon.icns
rm -rf icon.iconset icon_1024.png
python3.12 macos.py --render-app-icon icon.icns
- name: Build app with PyInstaller
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
@@ -178,6 +407,12 @@ jobs:
- name: Validate universal2 app bundle
run: |
set -euo pipefail
ICON_FILE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' \
'dist/TG WS Proxy.app/Contents/Info.plist')"
test -n "$ICON_FILE"
test -f "dist/TG WS Proxy.app/Contents/Resources/$ICON_FILE"
test -d "dist/TG WS Proxy.app/Contents/Resources/customtkinter"
found=0
while IFS= read -r -d '' file; do
if file "$file" | grep -q "Mach-O"; then
@@ -201,22 +436,31 @@ jobs:
- name: Create DMG
run: |
set -euo pipefail
APP_NAME="TG WS Proxy"
DMG_TEMP="dist/dmg_temp"
rm -rf "$DMG_TEMP"
mkdir -p "$DMG_TEMP"
cp -R "dist/${APP_NAME}.app" "$DMG_TEMP/"
ln -s /Applications "$DMG_TEMP/Applications"
hdiutil create \
-volname "$APP_NAME" \
-srcfolder "$DMG_TEMP" \
-ov \
-format UDZO \
packaging/dmg/build_dmg.sh \
"dist/TG WS Proxy.app" \
"TG WS Proxy" \
"dist/TgWsProxy_macos_universal.dmg"
rm -rf "$DMG_TEMP"
- name: Validate DMG
run: |
set -euo pipefail
for DMG in "dist/TgWsProxy_macos_universal.dmg"; do
MOUNT_DIR="$(mktemp -d)"
DEVICE="$(hdiutil attach \
-readonly \
-nobrowse \
-mountpoint "$MOUNT_DIR" \
"$DMG" \
| awk '/^\/dev\// { print $1; exit }')"
test -d "$MOUNT_DIR/TG WS Proxy.app"
test -L "$MOUNT_DIR/Applications"
test "$(readlink "$MOUNT_DIR/Applications")" = "/Applications"
test -f "$MOUNT_DIR/.background/background.tiff"
test -f "$MOUNT_DIR/.DS_Store"
hdiutil detach "$DEVICE"
rmdir "$MOUNT_DIR"
done
- name: Upload artifact
uses: actions/upload-artifact@v7
@@ -225,7 +469,8 @@ jobs:
path: dist/TgWsProxy_macos_universal.dmg
build-linux:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
if: ${{ github.event.inputs.build_linux == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -237,6 +482,8 @@ jobs:
python3-venv \
python3-dev \
python3-gi \
python3-gi-cairo \
gir1.2-appindicator3-0.1 \
gir1.2-ayatanaappindicator3-0.1 \
python3-tk
@@ -303,12 +550,82 @@ jobs:
Maintainer: Flowseal
Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk
Description: Telegram Desktop WebSocket Bridge Proxy
SOCKS5/WebSocket bridge proxy for Telegram Desktop with tray UI.
MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI.
EOF
dpkg-deb --build --root-owner-group \
"$PKG_ROOT" \
"dist/TgWsProxy_linux_amd64.deb"
- name: Create .rpm package with fpm
run: |
set -euo pipefail
VERSION="${{ github.event.inputs.version }}"
VERSION="${VERSION#v}"
sudo gem install fpm -v 1.17.0
mkdir -p rpm_package/usr/bin
mkdir -p rpm_package/usr/share/applications
mkdir -p rpm_package/usr/share/icons/hicolor/256x256/apps
cp dist/TgWsProxy_linux_amd64 rpm_package/usr/bin/tg-ws-proxy
chmod 755 rpm_package/usr/bin/tg-ws-proxy
.venv/bin/python - <<PY
from PIL import Image
Image.open("icon.ico").save(
"rpm_package/usr/share/icons/hicolor/256x256/apps/tg-ws-proxy.png",
"PNG",
)
PY
cat > rpm_package/usr/share/applications/tg-ws-proxy.desktop <<EOF
[Desktop Entry]
Type=Application
Name=TG WS Proxy
GenericName=Telegram Proxy
Comment=Telegram Desktop WebSocket Bridge Proxy
Exec=tg-ws-proxy
Icon=tg-ws-proxy
Terminal=false
Categories=Network;
StartupNotify=true
Keywords=telegram;proxy;websocket;
EOF
cat > post_install.sh <<EOF
#!/bin/bash
if [ -x /usr/bin/update-desktop-database ]; then
/usr/bin/update-desktop-database &> /dev/null || :
fi
if [ -x /usr/bin/gtk-update-icon-cache ]; then
/usr/bin/gtk-update-icon-cache -q /usr/share/icons/hicolor &> /dev/null || :
fi
EOF
chmod +x post_install.sh
fpm -s dir \
-t rpm \
-n tg-ws-proxy \
-v ${VERSION} \
--iteration 1 \
--architecture x86_64 \
--license "MIT" \
--vendor "Flowseal" \
--maintainer "Flowseal" \
--url "https://github.com/Flowseal/tg-ws-proxy" \
--description "MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI." \
--depends "libgtk-3.so.0()(64bit)" \
--depends "libayatana-appindicator3.so.1()(64bit)" \
--depends "python3-tkinter" \
--after-install post_install.sh \
--after-remove post_install.sh \
-C rpm_package \
.
mv tg-ws-proxy-${VERSION}-1.x86_64.rpm dist/TgWsProxy_linux_amd64.rpm
- name: Upload artifact
uses: actions/upload-artifact@v7
@@ -317,11 +634,18 @@ jobs:
path: |
dist/TgWsProxy_linux_amd64
dist/TgWsProxy_linux_amd64.deb
dist/TgWsProxy_linux_amd64.rpm
release:
needs: [build-windows, build-win7, build-macos, build-linux]
needs: [build-windows-x64, build-windows-arm64, build-win7, build-macos, build-linux]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.make_release == 'true' }}
if: >-
${{ github.event.inputs.make_release == 'true'
&& github.event.inputs.build_windows_x64 == 'true'
&& github.event.inputs.build_windows_arm64 == 'true'
&& github.event.inputs.build_win7 == 'true'
&& github.event.inputs.build_macos == 'true'
&& github.event.inputs.build_linux == 'true' }}
steps:
- uses: actions/download-artifact@v8
with:
@@ -335,14 +659,21 @@ jobs:
tag_name: ${{ github.event.inputs.version }}
name: "TG WS Proxy ${{ github.event.inputs.version }}"
body: |
## TG WS Proxy ${{ github.event.inputs.version }}
---
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/RU/Funding.md)
> [!TIP]
> Не можете скачать?
> Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/
files: |
dist/TgWsProxy_windows.exe
dist/TgWsProxy_windows_arm64.exe
dist/TgWsProxy_windows_7_64bit.exe
dist/TgWsProxy_windows_7_32bit.exe
dist/TgWsProxy_macos_universal.dmg
dist/TgWsProxy_linux_amd64
dist/TgWsProxy_linux_amd64.deb
dist/TgWsProxy_linux_amd64.rpm
draft: false
prerelease: false
env:
+42
View File
@@ -0,0 +1,42 @@
name: Auto comment on new issues
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
comment:
if: contains(github.event.issue.labels.*.name, 'bug')
runs-on: ubuntu-latest
steps:
- name: Comment on new issue
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.issue.number }}
body: |
### Проверьте две вещи:
- вы на последней версии: [Releases](https://github.com/Flowseal/tg-ws-proxy/releases)
- запускали по инструкции для своей ОС: [Быстрый старт](https://github.com/Flowseal/tg-ws-proxy#навигация)
## Решение частых проблем:
**Q**: Не запускается, падает с ошибкой, не работает как раньше после обновления?
**A**:
1. Удалите всё в папке Temp (или хотя бы всё, что начинается с _MEI)
2. Запускайте от имени админа
3. Попробуйте Win7 версию (если вы пользователь Windows)
4. Попробуйте отключить антивирус (если помогло, то добавьте exe в исключения). Не забудьте включить антивирус обратно.
###
**Q**: Не грузит медиа? (фото/видео/стикеры)
**A**: Удалите в настройках прокси в поле **DC → IP** всё, кроме `4:149.154.167.220`. Если это не помогло, полностью очистите это поле.
#### Если проблема решена, то закройте Issue
### Если проблема осталась, пожалуйста, приложите по возможности логи.
Сделать это можно через иконку в трее -> Пкм -> Открыть логи. Сохраните логи в файл и приложите его сюда.
+2 -5
View File
@@ -6,6 +6,8 @@ __pycache__/
dist/
build/
*.spec.bak
venv/
.venv/
# PyInstaller
*.manifest
@@ -22,9 +24,4 @@ Thumbs.db
Desktop.ini
.DS_Store
# Project-specific (not for the repo)
scan_ips.py
scan.txt
AyuGramDesktop-dev/
tweb-master/
/icon.icns
+11 -7
View File
@@ -15,7 +15,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 certifi "httpx[http2]==0.28.1"
FROM python:3.12-slim AS runtime
@@ -23,8 +23,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH=/opt/venv/bin:$PATH \
TG_WS_PROXY_HOST=0.0.0.0 \
TG_WS_PROXY_PORT=1080 \
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220"
TG_WS_PROXY_PORT=1443 \
TG_WS_PROXY_SECRET="" \
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" \
TG_WS_PROXY_CF_WORKER="" \
TG_WS_PROXY_NO_SECURE=False
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini ca-certificates \
@@ -35,11 +38,12 @@ RUN apt-get update \
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY proxy ./proxy
COPY README.md LICENSE ./
COPY utils ./utils
COPY docs/README.md LICENSE ./
USER app
EXPOSE 1080/tcp
EXPOSE 1443/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
CMD []
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; if [ -n \"${TG_WS_PROXY_CF_WORKER}\" ]; then args=\"$args --cfproxy-worker-domain ${TG_WS_PROXY_CF_WORKER}\"; fi; if [ \"${TG_WS_PROXY_NO_SECURE}\" = \"True\" ]; then args=\"$args --no-secure \"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
CMD []
-215
View File
@@ -1,215 +0,0 @@
> [!CAUTION]
>
> ### Реакция антивирусов
>
> Windows Defender часто ошибочно помечает приложение как **Wacatac**.
> Если вы не можете скачать из-за блокировки, то:
>
> 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала)
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
>
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal**
# TG WS Proxy
**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" />
## Как это работает
```
Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegram DC
```
1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080`
2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
## 🚀 Быстрый старт
### Windows
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_windows.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
**Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках).
### macOS
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel.
1. Открыть образ
2. Перенести **TG WS Proxy.app** в папку **Applications**
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
### Linux
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет **`TgWsProxy_linux_amd64.deb`**.
Для Arch и Arch-Based дистрибутивов подготовлены пакеты в AUR: [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin), [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git), [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
```shell
# Установка без AUR-helper
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
cd tg-ws-proxy-bin
makepkg -si
# При помощи AUR-helper
paru -S tg-ws-proxy-bin
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта прокси:
sudo systemctl start tg-ws-proxy-cli@8888
```
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
```bash
chmod +x TgWsProxy_linux_amd64
./TgWsProxy_linux_amd64
```
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
## Установка из исходников
### Консольный proxy
Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки:
```bash
pip install -e .
tg-ws-proxy
```
### Windows 7/10+
```bash
pip install -e .
tg-ws-proxy-tray-win
```
### macOS
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
### Linux
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
### Консольный режим из исходников
```bash
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```
**Аргументы:**
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--port` | `1080` | Порт SOCKS5-прокси |
| `--host` | `127.0.0.1` | Хост SOCKS5-прокси |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:**
```bash
# Стандартный запуск
tg-ws-proxy
# Другой порт и дополнительные DC
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием
tg-ws-proxy -v
```
## CLI-скрипты (pyproject.toml)
CLI команды объявляются в `pyproject.toml` в секции `[project.scripts]` и должны указывать на `module:function`.
Пример:
```toml
[project.scripts]
tg-ws-proxy = "proxy.tg_ws_proxy:main"
tg-ws-proxy-tray-win = "windows:main"
tg-ws-proxy-tray-macos = "macos:main"
tg-ws-proxy-tray-linux = "linux:main"
```
## Настройка Telegram Desktop
### Автоматически
ПКМ по иконке в трее → **«Открыть в Telegram»**
### Вручную
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
2. Добавить прокси:
- **Тип:** SOCKS5
- **Сервер:** `127.0.0.1`
- **Порт:** `1080`
- **Логин/Пароль:** оставить пустыми
## Конфигурация
Tray-приложение хранит данные в:
- **Windows:** `%APPDATA%/TgWsProxy`
- **macOS:** `~/Library/Application Support/TgWsProxy`
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
```json
{
"host": "127.0.0.1",
"port": 1080,
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5.0,
"check_updates": true
}
```
Ключ **`check_updates`** — при `true` при запросе к GitHub сравнивается версия с последним релизом (только уведомление и ссылка на страницу загрузки). На Windows в конфиге может быть **`autostart`** (автозапуск при входе в систему).
## Автоматическая сборка
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
- Windows 10+ для `TgWsProxy_windows.exe`
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
- Linux x86_64 (требуется AppIndicator для системного трея)
## Лицензия
[MIT License](LICENSE)
+62
View File
@@ -0,0 +1,62 @@
# CONTRIBUTING
Спасибо за желание помочь проекту `tg-ws-proxy`.
## Перед созданием issue
1. Проверьте документацию в `docs/README.md`.
2. Убедитесь, что похожий issue еще не открыт.
3. Для корректной работы triage используйте стандартные лейблы из `.github/labels.md`.
## Как сообщать о проблемах
- Используйте шаблон `Проблема`.
- По возможности укажите:
- версию приложения,
- ОС,
- шаги воспроизведения,
- ожидаемое и фактическое поведение,
- лог-файл или текст ошибки.
Чем точнее описание, тем быстрее можно помочь.
## Локальный запуск из исходников
Требуется Python `>=3.8`.
```bash
pip install -e .
```
Запуск:
- консольный режим: `tg-ws-proxy`
- Windows tray: `tg-ws-proxy-tray-win`
- macOS tray: `tg-ws-proxy-tray-macos`
- Linux tray: `tg-ws-proxy-tray-linux`
Подробности: `docs/BuildFromSource.md`.
## Проверки
Тесты используют только стандартную библиотеку, дополнительные зависимости не нужны:
```bash
python -m unittest discover -s tests -t .
```
Линтер (`ruff` настроен в `pyproject.toml`):
```bash
ruff check .
```
## Pull Request
Перед открытием PR:
1. Убедитесь, что изменение решает конкретную проблему.
2. Проверьте, что не сломаны существующие сценарии: запустите тесты и линтер.
3. Обновите документацию, если меняется поведение или настройка.
Небольшие и сфокусированные PR проверяются и принимаются быстрее.
+80
View File
@@ -0,0 +1,80 @@
# Building from Source
## Console Proxy
To run only the proxy without the system tray interface, basic installation is sufficient:
```bash
pip install -e .
tg-ws-proxy
```
## Tray Application by OS
### Windows 7/10+
```bash
pip install -e .
tg-ws-proxy-tray-win
```
### macOS
Requires a Python build with Tk support. You can verify it with the command `python3 -m tkinter`.
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
### Linux
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
## Console Mode from Source
```bash
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```
**Arguments:**
| Argument | Default | Description |
|---|---|---|
| `--port` | `1443` | Proxy port |
| `--host` | `127.0.0.1` | Proxy host |
| `--secret` | `random` | 32-character hex key for client authorization |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Target IP for DC (can be specified multiple times) |
| `--no-cfproxy` | `false` | Disable [Cloudflare proxying](./CfProxy.md) attempts |
| `--no-h2` | `false` | Disable HTTP/2 media multiplexing through CF proxy |
| `--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. |
| `--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 |
| `--proxy-protocol` | disabled | Accept HAProxy PROXY protocol v1 (for use behind nginx/haproxy with `proxy_protocol on`) |
| `--force-test-dc` | `false` | Route all traffic to Telegram test DCs [Learn more](./TestDc.md) |
| `--buf-kb` | `256` | Buffer size in KB |
| `--pool-size` | `4` | Number of ready WS connections per DC. `0` disables the direct DC→IP WS route |
| `--log-file` | disabled | Path to file for saving logs |
| `--log-max-mb` | `5` | Maximum log file size in MB (afterwards overwrites) |
| `--log-backups` | `1` | Number of rotated log files to keep (minimum 1) |
| `-v`, `--verbose` | disabled | Verbose logging (DEBUG) |
**Examples:**
```bash
# Standard startup
tg-ws-proxy --secret 00112233445566778899aabbccddeeff
# Different port and additional DCs
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# With verbose logging, without direct connections to DCs
tg-ws-proxy -v --dc-ip
# Fake TLS masquerading (ee-secret)
tg-ws-proxy --fake-tls-domain example.com
```
+62
View File
@@ -0,0 +1,62 @@
# CONTRIBUTING
Thank you for wanting to help the `tg-ws-proxy` project.
## Before Creating an Issue
1. Check the documentation in `docs/README.md`.
2. Make sure a similar issue hasn't already been opened.
3. Use standard labels from `.github/labels.md` for correct triage.
## How to Report Problems
- Use the `Problem` template.
- If possible, provide:
- Application version,
- Operating system,
- Steps to reproduce,
- Expected and actual behavior,
- Log file or error text.
The more precise your description, the faster we can help.
## Local Development from Source
Python `>=3.8` is required.
```bash
pip install -e .
```
Running:
- console mode: `tg-ws-proxy`
- Windows tray: `tg-ws-proxy-tray-win`
- macOS tray: `tg-ws-proxy-tray-macos`
- Linux tray: `tg-ws-proxy-tray-linux`
Details: `docs/BuildFromSource.md`.
## Checks
Tests use the standard library only, no extra dependencies:
```bash
python -m unittest discover -s tests -t .
```
Linting (`ruff` is configured in `pyproject.toml`):
```bash
ruff check .
```
## Pull Request
Before opening a PR:
1. Make sure your change solves a specific problem.
2. Check that existing scenarios aren't broken; run the tests and the linter.
3. Update documentation if behavior or configuration changes.
Smaller and focused PRs are reviewed and accepted faster.
+37
View File
@@ -0,0 +1,37 @@
# Cloudflare Proxy
An alternative, free connection method is proxying through Cloudflare, which can be used for unreachable data centers. **All you need to get it working is a domain**. The application includes a default domain, but it can (and ideally should) be replaced with your own.
The proxy restores access to content that previously wouldn't load (reactions, certain stickers). If you are using a non-Premium account and photos/videos still fail to load, leave only `4:149.154.167.220` in the `DC → IP` block. If the CF proxy works, media will start loading again.
## Why should I set up my own domain?
Cloudflare limits the number of simultaneous WebSocket (WS) connections. The default domain could stop working at any moment.
## Setting up your own domain
1. Add your domain to Cloudflare (either by purchasing it directly from Cloudflare or by changing the NS servers: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Domains cost around $1.50–$2.00 per year, and any domain extension will work.
2. In `SSL/TLS` → `Overview`, set the mode to **Flexible**.
3. In `DNS` → `Records`, add the following `A` records via `+ Add Record`:
- Name=`kws1` IPv4=`149.154.175.50`
- Name=`kws2` IPv4=`149.154.167.51`
- Name=`kws3` IPv4=`149.154.175.100`
- Name=`kws4` IPv4=`149.154.167.91`
- Name=`kws5` IPv4=`149.154.171.5`
- Name=`kws203` IPv4=`91.105.192.100`
4. **Add your domain to [zapret](https://github.com/Flowseal/zapret-discord-youtube/) or any other DPI bypass software, as the Cloudflare subnet may be blocked (e.g., in Russia).**
5. In the `TgWsProxy` settings, replace the default domain with your own.
## HTTP/2 media multiplexing
Multiplexing is enabled by default. Media connections share one TCP/TLS connection to Cloudflare per DC and domain, using `/api` instead of `/apiws`. Main connections and test DCs continue using WS. H2 requires CF proxy and TLS enabled.
Can be disabled with `--no-h2` or by unchecking the **Media multiplexing (HTTP/2)** in the **Cloudflare Proxy** settings.
## Credits / Acknowledgments
- Original Idea: https://github.com/Nekogram/WSProxy
- Special thanks to [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) for providing the information.
+129
View File
@@ -0,0 +1,129 @@
# Cloudflare Worker
An alternative (completely free, no domain purchase required unlike [CfProxy](./CfProxy.md)) method for proxying.
The proxy restores access to content that previously wouldn't load (reactions, certain stickers). If you are using a non-Premium account with this method and photos/videos still fail to load, leave only `4:149.154.167.220` in the `DC → IP` block.
##
1. **Add the following domains to [zapret](https://github.com/Flowseal/zapret-discord-youtube/) or any other DPI bypass software:**
```
cloudflare.com
cloudflare.dev
workers.dev
```
2. Create an account on [Cloudflare](https://dash.cloudflare.com/) (or log into an existing one)
* **After creating your account, verify your email using the link sent to your inbox**
3. Select `Compute` → `Workers & Pages` from the left panel
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
4. Click the **`Create application`** button in the top right → `Start with Hello World!` → `Deploy`
<img width="1406" height="193" alt="image" src="https://github.com/user-attachments/assets/7ac65944-8761-42a6-ab6d-ba5f9080c883" />
<img width="586" height="379" alt="image" src="https://github.com/user-attachments/assets/ff901439-c2a1-4867-95de-e11b82a37044" />
<img width="624" height="694" alt="image" src="https://github.com/user-attachments/assets/bb68d49a-166d-42a0-8fe2-bd2b16c0d066" />
5. Click the **`Edit code`** button in the top right, then replace the code on the left with the one [found at the bottom of this page](#worker-code)
* If the code section fails to load, it means you missed the first step
<img width="911" height="117" alt="image" src="https://github.com/user-attachments/assets/6bcdf839-d776-47e9-9d18-ba0efdf53244" />
<img width="1027" height="512" alt="image" src="https://github.com/user-attachments/assets/daf131ed-82d5-40f0-a7eb-daeb598bea40" />
6. Click the **`Deploy`** button in the top right
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
7. Copy the domain from the field on the right and specify it in your **Cloudflare Worker** settings (or via the `--cfproxy-worker-domain` argument)
* Example domain: `random-symbols-1234.username.workers.dev`
* **You can specify multiple domains separated by commas (or by repeating the `--cfproxy-worker-domain` argument)**
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
### Worker Code
```javascript
import { connect } from "cloudflare:sockets";
function toBytes(data) {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (typeof data === "string") {
return new TextEncoder().encode(data);
}
if (data && typeof data.arrayBuffer === "function") {
return data.arrayBuffer().then((ab) => new Uint8Array(ab));
}
return new Uint8Array();
}
export default {
async fetch(request) {
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const url = new URL(request.url);
if (url.pathname !== "/apiws") {
return new Response("Not found", { status: 404 });
}
const dst = url.searchParams.get("dst");
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
server.accept();
const socket = connect({ hostname: dst, port: 443 });
const tcpReader = socket.readable.getReader();
const tcpWriter = socket.writable.getWriter();
server.addEventListener("message", async (event) => {
try {
await tcpWriter.write(await toBytes(event.data));
} catch {
try {
server.close(1011, "tcp write failed");
} catch {}
}
});
server.addEventListener("close", async () => {
try {
await tcpWriter.close();
} catch {}
try {
socket.close();
} catch {}
});
(async () => {
try {
while (true) {
const { value, done } = await tcpReader.read();
if (done) {
break;
}
if (value) {
server.send(value);
}
}
} catch {
} finally {
try {
server.close();
} catch {}
try {
tcpReader.releaseLock();
} catch {}
try {
socket.close();
} catch {}
}
})();
return new Response(null, { status: 101, webSocket: client });
},
};
```
+52
View File
@@ -0,0 +1,52 @@
# Fake TLS + Upstream in Nginx
The domain in the `--fake-tls-domain` parameter should point to the same IP where the proxy is running.
## Example `nginx.conf` for Stream Module
```nginx
upstream mtproto {
server 127.0.0.1:8446;
}
map $ssl_preread_server_name $sni_name {
hostnames;
example.com mtproto;
# if you have xray with selfsni running:
# sub.example.com www;
# default xray;
}
# upstream xray {
# server 127.0.0.1:8443;
# }
#
# upstream www {
# server 127.0.0.1:7443;
# }
server {
proxy_protocol on;
set_real_ip_from unix:;
listen 443;
proxy_pass $sni_name;
ssl_preread on;
}
```
## Running Proxy Behind Nginx
```bash
python3 proxy/tg_ws_proxy.py \
--port 8446 \
--host 127.0.0.1 \
--fake-tls-domain example.com \
--proxy-protocol \
--secret <32-hex-chars>
```
The connection link will be in `ee`-secret format:
```text
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
```
+11
View File
@@ -0,0 +1,11 @@
> [!TIP]
>
> ### 🎉 Support Me
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
The project is completely free for everyone.
However, its development and stable operation as the user base grows require investment.
I would appreciate any form of support! Thank you ❤️
+71
View File
@@ -0,0 +1,71 @@
# TG WS Proxy for Docker
## Installation from Source
Enter the commands sequentially, one by one:
```bash
# Clone the repository
git clone https://github.com/Flowseal/tg-ws-proxy.git
# Navigate to the project folder
cd tg-ws-proxy
# Build the image
docker build -t tg-ws-proxy .
# Run the container
docker run -d \
--name tg-ws-proxy \
--restart=always \
-p 1443:1443 \
tg-ws-proxy:latest
# Get the connection link
docker logs tg-ws-proxy 2>&1 | grep 'tg://proxy'
```
After running the last command, you will see a link like:
```text
tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
```
## Configuring Parameters
All settings are configured using environment variables when running the container:
| Variable | Description | Default |
| ----------------------- | -------------------------------------------------- | ------------------------------------- |
| `TG_WS_PROXY_HOST` | Address for incoming connections | `0.0.0.0` |
| `TG_WS_PROXY_PORT` | Port inside the container | `1443` |
| `TG_WS_PROXY_SECRET` | Secret key | `random` |
| `TG_WS_PROXY_DC_IPS` | DC number:IP pairs separated by space | `2:149.154.167.220 4:149.154.167.220` |
| `TG_WS_PROXY_CF_WORKER` | Cloudflare Worker domain | `None` |
| `TG_WS_PROXY_NO_SECURE` | Use 80 port for CF-proxy and CF-worker connections | `False` |
Example with manually specified secret:
```bash
docker run -d \
--name tg-ws-proxy \
--restart=always \
-p 1443:1443 \
-e TG_WS_PROXY_SECRET="your_secret" \
tg-ws-proxy:latest
```
To generate a secret, you can use:
```bash
openssl rand -hex 16
```
## Configuring Telegram Desktop
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
+62
View File
@@ -0,0 +1,62 @@
# TG WS Proxy for Linux
## Prebuilt Packages
For Debian/Ubuntu, download the `TgWsProxy_linux_amd64.deb` package from the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases).
For Arch and Arch-based distributions, packages are available in AUR:
- [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin)
- [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git)
- [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
```shell
# Installation without AUR helper
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
cd tg-ws-proxy-bin
makepkg -si
# Using AUR helper
paru -S tg-ws-proxy-bin
# For -cli package, run via systemd (8888 — port number; secret can be generated with openssl rand -hex 16)
sudo systemctl start tg-ws-proxy@8888:3075abe65830f0325116bb0416cadf9f
```
For other distributions, you can use `TgWsProxy_linux_amd64` (binary for x86_64).
```bash
chmod +x TgWsProxy_linux_amd64
./TgWsProxy_linux_amd64
```
On first launch, a window will open with instructions. The application runs in the system tray (AppIndicator required).
## Updating
When update checks are enabled in settings, the app offers to update itself on startup and installs the new version:
- installed from `.deb`/`.rpm` — the new package is downloaded and installed by the package manager (via `pkexec`, password confirmation required);
- standalone binary — replaced in place if its directory is writable, otherwise also via `pkexec`.
The downloaded file is checked against the response length and the SHA-256 digest from the release; a corrupted download is never installed. The app restarts automatically after the update.
For AUR packages, `pip` installs, and non-x86_64 architectures the previous behaviour applies — the release page is opened.
## Configuring Telegram Desktop
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## Building from Source
Detailed instructions: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
+32
View File
@@ -0,0 +1,32 @@
# TG WS Proxy for macOS
Go to the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases) and download `TgWsProxy_macos_universal.dmg` (universal build for Apple Silicon and Intel).
1. Open the image
2. Drag `TG WS Proxy.app` to the `Applications` folder
3. On first launch, macOS may ask for confirmation: **System Settings → Privacy & Security → Open Anyway**
Minimum supported versions:
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
## Configuring Telegram Desktop
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## Building from Source
Detailed instructions: [BuildFromSource.md](./BuildFromSource.md)
The interface requires Tk, CustomTkinter, and access to Cocoa via PyObjC. They are installed automatically, except for Tk, which must be included in your Python build.
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
+144
View File
@@ -0,0 +1,144 @@
<div align="center">
**[🇷🇺 Русский](../README.md) • 🇬🇧 English**
</div>
<div align="center">
<br />
<p>
<img width="1729" height="910" alt="tgwsproxy" src="../images/workflow.png" />
</p>
</div>
##
> [!TIP]
>
> ### [🎉 Support Me](../EN/Funding.md)
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> [!CAUTION]
>
> ### Antivirus Detection
>
> Antivirus software sometimes incorrectly marks the application as a virus due to the packer.
> If you cannot download due to antivirus blocking, then:
>
> 1) **Try downloading the Windows 7 version (functionally identical)**
> 2) Temporarily disable antivirus during download, add the file to exclusions, then re-enable
>
> Always verify what you download from the internet, especially from untrusted sources. It's best to check detections from well-known antivirus vendors on VirusTotal.
# TG WS Proxy
**Local MTProto proxy** for Telegram Desktop that **speeds up Telegram**, redirecting traffic through WebSocket connections. Data is transmitted in the same encrypted form, and no external servers are needed.
<picture>
<source srcset="../images/preview-dark.png" media="(prefers-color-scheme: dark)">
<img src="../images/preview-white.png">
</picture>
## Navigation
- **🚀 Quick Start**
- **[Windows](./README.windows.md)**
- **[macOS](./README.macos.md)**
- **[Linux](./README.linux.md)**
- **[Docker](./README.docker.md)**
- [Cloudflare Worker Setup (free alternative to CF proxy)](./CfWorker.md)
- [Cloudflare Domain Setup (CF proxy)](./CfProxy.md)
- [Telegram Test Environment (Test DCs)](./TestDc.md)
- [Fake TLS + upstream in Nginx](./FakeTlsNginx.md)
- [Tray Application Configuration Files](./TrayConfig.md)
- [Building from Source](./BuildFromSource.md)
- [Contributor Guide](./CONTRIBUTING.md)
## Windows: Quick Start
Go to the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases) and download:
- `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
On first launch, a window will open with instructions for connecting Telegram Desktop. **The application minimizes to system tray.**
### Tray Menu
- **Open in Telegram** — automatically configure proxy via `tg://proxy` link
- **Copy Link** — copy the proxy connection link
- **Restart Proxy** — restart without exiting the application
- **Settings...** — GUI configuration editor (app version, optional GitHub update checks)
- **Open Logs** — open log file
- **Exit** — stop proxy and close application
### Configuring Telegram Desktop
**Automatic Setup**
Right-click the tray icon and select **"Open in Telegram"**.
If it doesn't work (Telegram doesn't open with proxy), follow these steps:
1. Right-click the tray icon and select **"Copy Link"**
2. Send the link to "Saved Messages" in Telegram and click it
3. Connect
**Manual Setup**
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## How It Works
```
Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
```
1. Application starts MTProto proxy on `127.0.0.1:1443`
2. Intercepts connections to Telegram IP addresses
3. Extracts DC ID from MTProto obfuscation init packet
4. Establishes WebSocket connection (TLS) to corresponding DC via Telegram domains
5. If WS unavailable (302 redirect) — automatically switches to CfProxy / direct TCP connection
> [!IMPORTANT]
> ### Photos/Videos Not Loading?
> **In proxy settings, leave only `4:149.154.167.220` in DC → IP**
> **If that doesn't work, clear the field completely**
> This issue occurs on non-Premium accounts
> If still not working, set up your own domain following: [CfProxy.md](./CfProxy.md)
## Automatic Build
The project contains PyInstaller specs ([`packaging/windows.spec`](../../packaging/windows.spec), [`packaging/macos.spec`](../../packaging/macos.spec), [`packaging/linux.spec`](../../packaging/linux.spec)) and GitHub Actions workflow ([`.github/workflows/build.yml`](../../.github/workflows/build.yml)) for automated builds.
Minimum supported OS versions for current binary builds:
- Windows 10+ x64 for `TgWsProxy_windows.exe`
- Windows 10+ ARM64 for `TgWsProxy_windows_arm64.exe`
- Windows 7 (x64) for `TgWsProxy_windows_7_64bit.exe`
- Windows 7 (x32) for `TgWsProxy_windows_7_32bit.exe`
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
- Linux x86_64 (AppIndicator required for system tray)
## Contributors
Thanks to everyone who helps develop this project ❤️
<a href="https://github.com/Flowseal/tg-ws-proxy/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Flowseal/tg-ws-proxy" />
</a>
## License
[MIT License](../../LICENSE)
+58
View File
@@ -0,0 +1,58 @@
# TG WS Proxy for Windows
Go to the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases) and download:
- `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
Builds are published automatically via [GitHub Actions](https://github.com/Flowseal/tg-ws-proxy/actions) from open source code.
On first launch, a window will open with instructions for connecting Telegram Desktop. **The application minimizes to system tray.**
## Tray Menu
- **Open in Telegram** — automatically configure proxy via `tg://proxy` link
- **Copy Link** — copy the proxy connection link
- **Restart Proxy** — restart without exiting the application
- **Settings...** — GUI configuration editor (app version, optional GitHub update checks)
- **Open Logs** — open log file
- **Exit** — stop proxy and close application
On first launch after startup, you may be prompted to open the release page if a new version is available on GitHub (this check can be disabled in settings).
## Configuring Telegram Desktop
### Automatic Setup
Right-click the tray icon and select **"Open in Telegram"**.
If it doesn't work (Telegram doesn't open with proxy), follow these steps:
1. Right-click the tray icon and select **"Copy Link"**
2. Send the link to "Saved Messages" in Telegram and click it
3. Connect
### Manual Setup
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## Portable Mode
Portable mode is automatically enabled if a folder named `TgWsProxy_data` exists next to the executable.
You can also force portable mode by running the executable with the `--portable` parameter (it will create the folder).
## Building from Source
Detailed instructions: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-win
```
+23
View File
@@ -0,0 +1,23 @@
# Telegram Test Environment (Test DCs)
Traffic routing to Telegram test data centers (test environment).
Useful for developing/testing bots and clients within the Telegram test environment.
## How to Enable
**Automatically.** Telegram Desktop marks test DCs with a +10000
offset (10001–10003). The proxy automatically detects this offset — no configuration needed, allowing
you to use production and test accounts simultaneously in a single client.
**Forced.** For clients that report test DCs as standard 1-3
(Telethon, TDLib) — they cannot be detected automatically. In this case, all traffic
is forcibly routed to test DCs (production accounts will stop working through this proxy).
To force this behavior, use the `--force-test-dc` flag in CLI:
```bash
tg-ws-proxy --force-test-dc # + your --secret / --port
```
## Limitations
Only works for **direct DC → IP** and **Cloudflare Worker** routes (see [Setting up a Cloudflare Worker](./CfWorker.md)).
+37
View File
@@ -0,0 +1,37 @@
# Tray Application Configuration Files
The tray application stores data in:
- **Windows:** `%APPDATA%/TgWsProxy`
- **macOS:** `~/Library/Application Support/TgWsProxy`
- **Linux:** `~/.config/TgWsProxy` (or `$XDG_CONFIG_HOME/TgWsProxy`)
```json
{
"host": "127.0.0.1",
"port": 1443,
"secret": "...",
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5,
"check_updates": true,
"cfproxy": true,
"h2": true,
"cfproxy_user_domain_enabled": false,
"cfproxy_user_domain": [],
"cfproxy_worker_enabled": false,
"cfproxy_worker_domain": [],
"force_test_dc": false,
"no_secure": false,
"appearance": "auto",
"language": "en"
}
```
The `check_updates` key: when `true`, performs a request to GitHub and compares the current version with the latest release (notification and link to download page only).
On Windows, the config may contain `autostart` (auto-start on system login).
+144
View File
@@ -0,0 +1,144 @@
<div align="center">
**🇷🇺 Русский • [🇬🇧 English](./EN/README.md)**
</div>
<div align="center">
<br />
<p>
<img width="1729" height="910" alt="tgwsproxy" src="./images/workflow.png" />
</p>
</div>
##
> [!TIP]
>
> ### [🎉 Поддержать меня](./RU/Funding.md)
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> [!CAUTION]
>
> ### Реакция антивирусов
>
> Антивирусы часто ошибочно помечают приложение как вирус из-за упаковщика.
> Если вы не можете скачать из-за блокировки антивирусом, то:
>
> 1) **Попробуйте скачать версию для Windows 7 (по функциональности она не отличается)**
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
>
> Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal
# TG WS Proxy
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
<picture>
<source srcset="./images/preview-dark.png" media="(prefers-color-scheme: dark)">
<img src="./images/preview-white.png">
</picture>
## Навигация
- **🚀 Быстрый старт**
- **[Windows](./RU/README.windows.md)**
- **[macOS](./RU/README.macos.md)**
- **[Linux](./RU/README.linux.md)**
- **[Docker](./RU/README.docker.md)**
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./RU/CfWorker.md)
- [Настройка Cloudflare-домена (CF-прокси)](./RU/CfProxy.md)
- [Тестовое окружение Telegram (тестовые DC)](./RU/TestDc.md)
- [Fake TLS + upstream в Nginx](./RU/FakeTlsNginx.md)
- [Файлы конфигурации Tray-приложения](./RU/TrayConfig.md)
- [Установка из исходников](./RU/BuildFromSource.md)
- [Руководство для контрибьюторов](./CONTRIBUTING.md)
## Windows: быстрый вход
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
- `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. **Приложение сворачивается в системный трей.**
### Меню трея
- **Открыть в Telegram** — автоматически настроить прокси через ссылку `tg://proxy`
- **Скопировать ссылку** — скопировать ссылку для подключения
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации (версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение
### Настройка Telegram Desktop
**Автоматическая настройка**
Щелкните правой кнопкой мыши по значку в трее и выберите **«Открыть в Telegram»**.
Если не сработало (Telegram не открылся с подключением), выполните шаги ниже:
1. Щелкните правой кнопкой мыши по значку в трее и выберите **«Скопировать ссылку»**
2. Отправьте ссылку в «Избранное» в Telegram и нажмите по ней левой кнопкой мыши
3. Подключитесь
**Ручная настройка**
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
2. Добавьте прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
## Как это работает
```
Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
```
1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket-соединение (TLS) к соответствующему DC через домены Telegram
5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
> [!IMPORTANT]
> ### Не грузит фото/видео?
> **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`**
> **Если это не помогло, полностью очистите это поле**
> Подобная проблема встречается на аккаунтах без Premium
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./RU/CfProxy.md)
## Автоматическая сборка
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](../packaging/windows.spec), [`packaging/macos.spec`](../packaging/macos.spec), [`packaging/linux.spec`](../packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](../.github/workflows/build.yml)) для автоматической сборки.
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
- Windows 10+ x64 для `TgWsProxy_windows.exe`
- Windows 10+ ARM64 для `TgWsProxy_windows_arm64.exe`
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
- Linux x86_64 (требуется AppIndicator для системного трея)
## Контрибьюторы
Спасибо всем, кто помогает развивать проект ❤️
<a href="https://github.com/Flowseal/tg-ws-proxy/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Flowseal/tg-ws-proxy" />
</a>
## Лицензия
[MIT License](../LICENSE)
+80
View File
@@ -0,0 +1,80 @@
# Установка из исходников
## Консольный прокси
Для запуска только прокси без интерфейса системного трея достаточно базовой установки:
```bash
pip install -e .
tg-ws-proxy
```
## Tray-приложение по ОС
### Windows 7/10+
```bash
pip install -e .
tg-ws-proxy-tray-win
```
### macOS
Требуется сборка Python с поддержкой Tk. Проверить её можно командой `python3 -m tkinter`.
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
### Linux
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
## Консольный режим из исходников
```bash
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```
**Аргументы:**
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--port` | `1443` | Порт прокси |
| `--host` | `127.0.0.1` | Хост прокси |
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
| `--no-h2` | `false` | Отключить мультиплексирование медиа через HTTP/2 в CF-прокси |
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
| `--no-secure` | `false` | Принудительно использует 80 порт для CF-proxy и CF-worker |
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
| `--force-test-dc` | `false` | Направлять весь трафик на тестовые DC Telegram [Подробнее](./TestDc.md) |
| `--buf-kb` | `256` | Размер буфера в КБ |
| `--pool-size` | `4` | Количество заготовленных WS-соединений на каждый DC. `0` отключает прямой WS-маршрут DC→IP |
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
| `--log-backups` | `1` | Сколько старых файлов логов хранить при ротации (минимум 1) |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:**
```bash
# Стандартный запуск
tg-ws-proxy --secret 00112233445566778899aabbccddeeff
# Другой порт и дополнительные DC
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием, без прямого подключения к DC
tg-ws-proxy -v --dc-ip
# Fake TLS маскировка (ee-secret)
tg-ws-proxy --fake-tls-domain example.com
```
+38
View File
@@ -0,0 +1,38 @@
# Cloudflare-прокси
Для недоступных дата-центров можно использовать альтернативный бесплатный способ подключения — проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и желательно) заменить на свой.
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`. Если CF-прокси работает, медиа снова начнет загружаться.
## Зачем мне настраивать свой домен?
Cloudflare имеет лимиты на одновременное количество WS-подключений. Домен по умолчанию может перестать работать в любой момент.
## Настройка своего домена
1. Добавьте свой домен в Cloudflare (либо купив его напрямую у Cloudflare, либо изменив NS-серверы: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят примерно 150 рублей в год, подойдёт любой.
2. В `SSL/TLS` → `Overview` выставьте режим **Flexible**.
3. В `DNS` → `Records` добавьте следующие `A`-записи через `+ Add Record`:
- Name=`kws1` IPv4=`149.154.175.50`
- Name=`kws2` IPv4=`149.154.167.51`
- Name=`kws3` IPv4=`149.154.175.100`
- Name=`kws4` IPv4=`149.154.167.91`
- Name=`kws5` IPv4=`149.154.171.5`
- Name=`kws203` IPv4=`91.105.192.100`
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО, так как подсеть Cloudflare может быть заблокирована (например, в России).**
5. В настройках `TgWsProxy` замените домен на свой.
## Мультиплексирование медиа через HTTP/2
Мультиплексирование включено по умолчанию. Медиа-соединения используют `/api` вместо `/apiws` через одно общее TCP/TLS-соединение до Cloudflare для каждого DC и домена. Основные соединения и тестовые DC продолжают использовать WS.
Чтобы отключить мультиплексирование, добавьте `--no-h2` или снимите галочку
**Мультиплексирование медиа (HTTP/2)** в настройках **Cloudflare Proxy**.
## Благодарности
- Идея: https://github.com/Nekogram/WSProxy
- Спасибо [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) за информацию.
+125
View File
@@ -0,0 +1,125 @@
# Cloudflare Worker
Альтернативный (полностью бесплатный, не нужно покупать домен в отличии от [CfProxy](./CfProxy.md)) способ проксирования.
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium с данным способом все еще не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`
##
1. **Добавьте в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО следующие домены:**
```
cloudflare.com
cloudflare.dev
workers.dev
```
2. Создайте аккаунт в [Cloudflare](https://dash.cloudflare.com/) (или войдите в существующий)
* **После создания аккаунта подтвердите почту с помощью письма, который вам пришел на email**
3. Слева в панели выберите `Compute` → `Workers & Pages`
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
4. Нажмите сверху справа кнопку **`Create application`** → `Start with Hello World!` → `Deploy`
<img width="1406" height="193" alt="image" src="https://github.com/user-attachments/assets/7ac65944-8761-42a6-ab6d-ba5f9080c883" />
<img width="586" height="379" alt="image" src="https://github.com/user-attachments/assets/ff901439-c2a1-4867-95de-e11b82a37044" />
<img width="624" height="694" alt="image" src="https://github.com/user-attachments/assets/bb68d49a-166d-42a0-8fe2-bd2b16c0d066" />
5. Сверху справа нажмите кнопку **`Edit code`**, замените код слева на тот, [что находится внизу этой страницы](./CfWorker.md#код-workerа)
* Если у вас не загружается код, то вы не выполнили первый пункт
<img width="911" height="117" alt="image" src="https://github.com/user-attachments/assets/6bcdf839-d776-47e9-9d18-ba0efdf53244" />
<img width="1027" height="512" alt="image" src="https://github.com/user-attachments/assets/daf131ed-82d5-40f0-a7eb-daeb598bea40" />
6. Нажмите сверху справа кнопку **`Deploy`**
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
* Пример домена: `random-symbols-1234.username.workers.dev`
* **Можно указывать несколько доменов через запятую (или повторением аргумента `--cfproxy-worker-domain`)**
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
### Код Worker'а
```javascript
import { connect } from "cloudflare:sockets";
function toBytes(data) {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (typeof data === "string") {
return new TextEncoder().encode(data);
}
if (data && typeof data.arrayBuffer === "function") {
return data.arrayBuffer().then((ab) => new Uint8Array(ab));
}
return new Uint8Array();
}
export default {
async fetch(request) {
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const url = new URL(request.url);
if (url.pathname !== "/apiws") {
return new Response("Not found", { status: 404 });
}
const dst = url.searchParams.get("dst");
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
server.accept();
const socket = connect({ hostname: dst, port: 443 });
const tcpReader = socket.readable.getReader();
const tcpWriter = socket.writable.getWriter();
server.addEventListener("message", async (event) => {
try {
await tcpWriter.write(await toBytes(event.data));
} catch {
try {
server.close(1011, "tcp write failed");
} catch {}
}
});
server.addEventListener("close", async () => {
try {
await tcpWriter.close();
} catch {}
try {
socket.close();
} catch {}
});
(async () => {
try {
while (true) {
const { value, done } = await tcpReader.read();
if (done) {
break;
}
if (value) {
server.send(value);
}
}
} catch {
} finally {
try {
server.close();
} catch {}
try {
tcpReader.releaseLock();
} catch {}
try {
socket.close();
} catch {}
}
})();
return new Response(null, { status: 101, webSocket: client });
},
};
```
+52
View File
@@ -0,0 +1,52 @@
# Fake TLS + upstream в nginx
Домен в параметре `--fake-tls-domain` должен указывать на тот же IP, на котором запущен прокси.
## Пример `nginx.conf` для stream-модуля
```nginx
upstream mtproto {
server 127.0.0.1:8446;
}
map $ssl_preread_server_name $sni_name {
hostnames;
example.com mtproto;
# if you have xray with selfsni running:
# sub.example.com www;
# default xray;
}
# upstream xray {
# server 127.0.0.1:8443;
# }
#
# upstream www {
# server 127.0.0.1:7443;
# }
server {
proxy_protocol on;
set_real_ip_from unix:;
listen 443;
proxy_pass $sni_name;
ssl_preread on;
}
```
## Запуск прокси за Nginx
```bash
python3 proxy/tg_ws_proxy.py \
--port 8446 \
--host 127.0.0.1 \
--fake-tls-domain example.com \
--proxy-protocol \
--secret <32-hex-chars>
```
Ссылка для подключения будет в формате `ee`-секрета:
```text
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
```
+11
View File
@@ -0,0 +1,11 @@
> [!TIP]
>
> ### 🎉 Поддержать меня
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
Буду благодарен за любую форму поддержки! Спасибо ❤️
+71
View File
@@ -0,0 +1,71 @@
# TG WS Proxy для Docker
## Установка из исходников
Вводите команды последовательно, одну за другой:
```bash
# Скачиваем репозиторий
git clone https://github.com/Flowseal/tg-ws-proxy.git
# Переходим в папку с проектом
cd tg-ws-proxy
# Собираем образ
docker build -t tg-ws-proxy .
# Запускаем контейнер
docker run -d \
--name tg-ws-proxy \
--restart=always \
-p 1443:1443 \
tg-ws-proxy:latest
# Получаем ссылку для подключения
docker logs tg-ws-proxy 2>&1 | grep 'tg://proxy'
```
После выполнения последней команды вы увидите ссылку вида:
```text
tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
```
## Настройка параметров
Все настройки задаются переменными окружения при запуске контейнера:
| Переменная | Описание | По умолчанию |
| ----------------------- | ------------------------------------------------------------- | ------------------------------------- |
| `TG_WS_PROXY_HOST` | `Адрес для приёма подключений` | `0.0.0.0` |
| `TG_WS_PROXY_PORT` | `Порт внутри контейнера` | `1443` |
| `TG_WS_PROXY_SECRET` | `Секретный ключ` | `random` |
| `TG_WS_PROXY_DC_IPS` | `Пары «номер DC:IP» через пробел` | `2:149.154.167.220 4:149.154.167.220` |
| `TG_WS_PROXY_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
| `TG_WS_PROXY_NO_SECURE` | `Использовать 80 порт для подключения к CF-proxy и CF-Worker` | `False` |
Пример с ручным указанием секрета:
```bash
docker run -d \
--name tg-ws-proxy \
--restart=always \
-p 1443:1443 \
-e TG_WS_PROXY_SECRET="ваш_секрет" \
tg-ws-proxy:latest
```
Для генерации секрета можно использовать:
```bash
openssl rand -hex 16
```
## Настройка Telegram Desktop
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
2. Добавьте прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
+62
View File
@@ -0,0 +1,62 @@
# TG WS Proxy для Linux
## Готовые сборки
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет `TgWsProxy_linux_amd64.deb`.
Для Arch и основанных на Arch дистрибутивов подготовлены пакеты в AUR:
- [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin)
- [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git)
- [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
```shell
# Установка без AUR-helper
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
cd tg-ws-proxy-bin
makepkg -si
# При помощи AUR-helper
paru -S tg-ws-proxy-bin
# Для пакета -cli запуск через systemd (8888 — номер порта; secret можно сгенерировать командой openssl rand -hex 16)
sudo systemctl start tg-ws-proxy@8888:3075abe65830f0325116bb0416cadf9f
```
Для остальных дистрибутивов можно использовать `TgWsProxy_linux_amd64` (бинарный файл для x86_64).
```bash
chmod +x TgWsProxy_linux_amd64
./TgWsProxy_linux_amd64
```
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
## Обновление
Если в настройках включена проверка обновлений, при запуске приложение само предложит обновиться и установит новую версию:
- установка из `.deb`/`.rpm` — скачивается новый пакет и ставится пакетным менеджером (через `pkexec`, потребуется подтверждение пароля);
- отдельный бинарный файл — заменяется на месте, если каталог доступен на запись, иначе тоже через `pkexec`.
Загруженный файл проверяется по длине ответа и контрольной сумме SHA-256 из релиза, повреждённая загрузка не устанавливается. После установки приложение перезапускается автоматически.
Для пакетов из AUR, установки через `pip` и архитектур, отличных от x86_64, обновление выполняется прежним способом — открывается страница релиза.
## Настройка Telegram Desktop
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
2. Добавьте прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
## Установка из исходников
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
+32
View File
@@ -0,0 +1,32 @@
# TG WS Proxy для macOS
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте `TgWsProxy_macos_universal.dmg` (универсальная сборка для Apple Silicon и Intel).
1. Откройте образ
2. Перенесите `TG WS Proxy.app` в папку `Applications`
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
Минимально поддерживаемые версии:
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
## Настройка Telegram Desktop
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
2. Добавьте прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
## Установка из исходников
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
Для интерфейса требуются Tk, CustomTkinter и доступ к Cocoa через PyObjC. Они устанавливаются автоматически, кроме Tk, который должен входить в используемую сборку Python.
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
+57
View File
@@ -0,0 +1,57 @@
# TG WS Proxy для Windows
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
- `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
Сборки публикуются автоматически через [GitHub Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. **Приложение сворачивается в системный трей.**
## Меню трея
- **Открыть в Telegram** — автоматически настроить прокси через ссылку `tg://proxy`
- **Скопировать ссылку** — скопировать ссылку для подключения
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации (версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (эту проверку можно отключить в настройках).
## Настройка Telegram Desktop
### Автоматическая настройка
Щелкните правой кнопкой мыши по значку в трее и выберите **«Открыть в Telegram»**.
Если не сработало (Telegram не открылся с подключением), выполните шаги ниже:
1. Щелкните правой кнопкой мыши по значку в трее и выберите **«Скопировать ссылку»**
2. Отправьте ссылку в «Избранное» в Telegram и нажмите по ней левой кнопкой мыши
3. Подключитесь
### Ручная настройка
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
2. Добавьте прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
## Портативный режим
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
## Установка из исходников
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-win
```
+23
View File
@@ -0,0 +1,23 @@
# Тестовое окружение Telegram (тестовые DC)
Маршрутизация трафика к тестовым дата-центрам Telegram (test environment).
Нужна для разработки/тестирования ботов и клиентов в тестовой среде Telegram.
## Как включается
**Автоматически.** Telegram Desktop помечает тестовые DC сдвигом +10000
(10001–10003). Прокси распознаёт сдвиг сам — включать ничего не нужно, можно
одновременно пользоваться продовым и тестовым аккаунтом в одном клиенте.
**Принудительно.** Для клиентов, которые сообщают тестовые DC как обычные 1-3
(Telethon, TDLib) — распознать их автоматически нельзя. Тогда весь трафик
принудительно направляется на тестовые DC (продовые аккаунты через этот прокси
работать перестанут). Для принудительной работы используйте флаг `--force-test-dc` в CLI:
```bash
tg-ws-proxy --force-test-dc # + ваши --secret / --port
```
## Ограничения
Работает только для маршрутов **прямой DC → IP** и **Cloudflare Worker** (см. [Настройка Cloudflare Worker'а](./CfWorker.md)).
+37
View File
@@ -0,0 +1,37 @@
# Файлы конфигурации Tray-приложения
Tray-приложение хранит данные в:
- **Windows:** `%APPDATA%/TgWsProxy`
- **macOS:** `~/Library/Application Support/TgWsProxy`
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
```json
{
"host": "127.0.0.1",
"port": 1443,
"secret": "...",
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5,
"check_updates": true,
"cfproxy": true,
"h2": true,
"cfproxy_user_domain_enabled": false,
"cfproxy_user_domain": [],
"cfproxy_worker_enabled": false,
"cfproxy_worker_domain": [],
"force_test_dc": false,
"no_secure": false,
"appearance": "auto",
"language": "ru"
}
```
Ключ `check_updates`: при `true` выполняется запрос к GitHub и сравнение текущей версии с последним релизом (только уведомление и ссылка на страницу загрузки).
На Windows в конфиге может быть `autostart` (автозапуск при входе в систему).
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+284 -572
View File
@@ -1,350 +1,48 @@
from __future__ import annotations
import asyncio as _asyncio
import json
import logging
import logging.handlers
import os
import subprocess
import sys
import threading
import webbrowser
import time
from pathlib import Path
from typing import Dict, Optional
import webbrowser
from typing import Optional
import customtkinter as ctk
import psutil
import pyperclip
import pystray
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageTk
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
from proxy import __version__, get_link_host
from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, check_update_async,
ctk_run_dialog, ensure_ctk_thread, ensure_dirs, load_config, load_icon,
log, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from ui.ctk_tray_ui import (
install_tray_config_buttons,
install_tray_config_form,
populate_first_run_window,
tray_settings_scroll_and_footer,
validate_config_form,
install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer,
)
from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD,
CONFIG_DIALOG_SIZE,
FIRST_RUN_SIZE,
create_ctk_root,
ctk_theme_for_platform,
main_content_frame,
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
)
from ui.i18n import t
from ui.settings_dialog import SettingsDialog
from utils.update_check import get_status, relaunch_env
APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None
_config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
_exiting = False
_update_flow_lock = threading.Lock()
log = logging.getLogger("tg-ws-tray")
# dialogs (tkinter messagebox)
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
try:
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
try:
cmdline = proc.cmdline()
for arg in cmdline:
if "linux.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return APP_NAME.lower() in proc.name().lower()
return False
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> dict:
_ensure_dirs()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict):
_ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
backupCount=0,
encoding='utf-8',
)
fh.setLevel(logging.DEBUG)
fh.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(fh)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s", datefmt="%H:%M:%S"
)
)
root.addHandler(ch)
def _make_icon_image(size: int = 64):
if Image is None:
raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = 2
draw.ellipse(
[margin, margin, size - margin, size - margin], fill=(0, 136, 204, 255)
)
try:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
size=int(size * 0.55),
)
except Exception:
try:
font = ImageFont.truetype(
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", size=int(size * 0.55)
)
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img
def _load_icon():
icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image:
try:
return Image.open(str(icon_path))
except Exception:
pass
return _make_icon_image()
def _apply_linux_ctk_window_icon(root) -> None:
"""PhotoImage храним на root — иначе GC может убрать картинку до закрытия окна."""
icon_img = _load_icon()
if icon_img:
from PIL import ImageTk
root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, root._ctk_icon_photo)
def _run_proxy_thread(
port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1"
):
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)
)
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc):
_show_error(
"Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
_async_stop = None
def start_proxy():
global _proxy_thread, _config
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
return
log.info("Starting proxy on %s:%d ...", host, port)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True,
name="proxy",
)
_proxy_thread.start()
def stop_proxy():
global _proxy_thread, _async_stop
if _async_stop:
loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread:
_proxy_thread.join(timeout=2)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy():
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showerror(title, text, parent=root)
root.destroy()
def _show_info(text: str, title: str = "TG WS Proxy"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showinfo(title, text, parent=root)
root.destroy()
def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
def _msgbox(kind: str, text: str, title: str, **kw):
import tkinter as _tk
from tkinter import messagebox as _mb
@@ -354,303 +52,282 @@ def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
root.attributes("-topmost", True)
except Exception:
pass
r = _mb.askyesno(title, text, parent=root)
result = getattr(_mb, kind)(title, text, parent=root, **kw)
root.destroy()
return bool(r)
return result
def _maybe_notify_update_async():
def _work():
time.sleep(1.5)
if _exiting:
return
if not _config.get("check_updates", True):
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
run_check(__version__)
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 "?"
text = (
f"Доступна новая версия: {ver}\n\n"
f"Открыть страницу релиза в браузере?"
)
if _ask_yes_no_dialog(text, "TG WS Proxy — обновление"):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()
def _show_error(text: str, title: Optional[str] = None) -> None:
_msgbox("showerror", text, title or t("app.error_title"))
def _on_open_in_telegram(icon=None, item=None):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server={host}&port={port}"
def _show_info(text: str, title: Optional[str] = None) -> None:
_msgbox("showinfo", text, title or t("app.name"))
def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
return bool(_msgbox("askyesno", text, title or t("app.name")))
def _apply_window_icon(root) -> None:
icon_img = load_icon()
if icon_img:
root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, root._ctk_icon_photo)
# tray callbacks
def _on_open_in_telegram(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
log.info("Copying %s", url)
try:
pyperclip.copy(url)
_show_info(
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
"TG WS Proxy",
)
_show_info(t("dialog.copy_ok", url=url))
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
_show_error(t("dialog.copy_fail", error=exc))
def _on_restart(icon=None, item=None):
threading.Thread(target=restart_proxy, daemon=True).start()
def _on_copy_link(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
log.info("Copying link: %s", url)
try:
pyperclip.copy(url)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(t("dialog.copy_fail", error=exc))
def _on_edit_config(icon=None, item=None):
def _on_restart(icon=None, item=None) -> None:
threading.Thread(
target=lambda: restart_proxy(_config, _show_error), daemon=True
).start()
def _on_edit_config(icon=None, item=None) -> None:
threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _edit_config_dialog():
if ctk is None:
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
root = create_ctk_root(
ctk,
title="TG WS Proxy — Настройки",
width=w,
height=h,
theme=theme,
after_create=_apply_linux_ctk_window_icon,
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=False,
)
def on_save():
merged = validate_config_form(
widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str):
_show_error(merged)
return
new_cfg = merged
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
_tray_icon.menu = _build_menu()
from tkinter import messagebox
if messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
):
root.destroy()
restart_proxy()
else:
root.destroy()
def on_cancel():
root.destroy()
install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _on_open_logs(icon=None, item=None):
def _on_open_logs(icon=None, item=None) -> None:
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME")}
subprocess.Popen(
["xdg-open", str(LOG_FILE)],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
["xdg-open", str(LOG_FILE)], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, start_new_session=True,
)
else:
_show_info("Файл логов ещё не создан.", "TG WS Proxy")
_show_info(t("dialog.log_not_found"))
def _on_exit(icon=None, item=None):
def _on_exit(icon=None, item=None) -> None:
global _exiting
if _exiting:
os._exit(0)
return
_exiting = True
log.info("User requested exit")
def _force_exit():
time.sleep(3)
os._exit(0)
threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
quit_ctk()
threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start()
if icon:
icon.stop()
def _show_first_run():
_ensure_dirs()
# settings dialog
def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
_show_error(t("dialog.ctk_missing"))
return
cfg = dict(_config)
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel(
ctk, title=t("app.settings_title"), width=w, height=h,
theme=theme, topmost=False, after_create=_apply_window_icon
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
def _refresh_tray_menu() -> None:
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=False,
on_update_click=_trigger_update_flow,
)
def _finish() -> None:
root.destroy()
done.set()
dialog = SettingsDialog(
ctk=ctk, root=root, widgets=widgets, config=_config,
defaults=DEFAULT_CONFIG, persist=save_config,
refresh_menu=_refresh_tray_menu, finish=_finish,
restart=lambda config: restart_proxy(config, _show_error),
)
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel)
ctk_run_dialog(_build)
# first run
def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
FIRST_RUN_MARKER.touch()
return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
if ctk is None:
FIRST_RUN_MARKER.touch()
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk, title=t("app.name"), width=w, height=h, theme=theme,
topmost=False, after_create=_apply_window_icon,
)
def on_done(open_tg: bool) -> None:
FIRST_RUN_MARKER.touch()
root.destroy()
done.set()
if open_tg:
_on_open_in_telegram()
populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done)
ctk_run_dialog(_build)
# update
def _restart_after_update() -> None:
from utils.linux_update import exe_path
stop_proxy()
release_lock()
exe = str(exe_path())
try:
subprocess.Popen(
[exe, *sys.argv[1:]], env=relaunch_env(),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, start_new_session=True,
)
except OSError as exc:
log.error("Failed to launch updated binary: %s", exc)
if acquire_lock():
start_proxy(_config, _show_error)
_show_error(t("update.error", msg=exc))
return
os._exit(0)
def _trigger_update_flow(icon=None, item=None) -> None:
if not _update_flow_lock.acquire(blocking=False):
return
theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE
def _work() -> None:
from utils.linux_update import asset_name, detect_install_kind
from utils.update_check import find_asset, RELEASES_PAGE_URL
root = create_ctk_root(
ctk,
title="TG WS Proxy",
width=w,
height=h,
theme=theme,
after_create=_apply_linux_ctk_window_icon,
)
def on_done(open_tg: bool):
FIRST_RUN_MARKER.touch()
root.destroy()
if open_tg:
_on_open_in_telegram()
populate_first_run_window(
ctk, root, theme, host=host, port=port, on_done=on_done)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
st = get_status()
if _exiting or not st["has_update"]:
return
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
kind = detect_install_kind() if IS_FROZEN else None
asset = find_asset(asset_name(kind)) if kind else None
page_url = st["html_url"] or RELEASES_PAGE_URL
if not asset:
if _ask_yes_no(t("update.ask_open", version=st["latest"]), t("app.update_title")):
webbrowser.open(page_url)
return
if _ask_yes_no(t("update.ask_install", version=st["latest"]), t("app.update_title")):
_install_update(kind, asset, page_url)
except Exception:
log.exception("Update flow failed")
finally:
_update_flow_lock.release()
threading.Thread(target=_work, daemon=True, name="manual-update").start()
def _has_ipv6_enabled() -> bool:
import socket as _sock
def _install_update(kind: str, asset: dict, page_url: str) -> None:
from utils.linux_update import apply_update
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
if not _has_ipv6_enabled():
return
IPV6_WARN_MARKER.touch()
threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
def _show_ipv6_dialog():
_show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз.",
"TG WS Proxy",
err = apply_update(
kind, asset["url"], digest=asset.get("digest", ""), log=log,
)
if err is None:
log.info("Update installed, restarting")
_restart_after_update()
return
log.error("Update failed: %s", err)
if _ask_yes_no(t("update.install_fail", error=err), t("app.update_title")):
webbrowser.open(page_url)
# tray menu
def _build_menu():
if pystray is None:
return None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
return pystray.Menu(
pystray.MenuItem(
f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, default=True
),
link_host = get_link_host(host)
items = [
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.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config),
pystray.MenuItem("Открыть логи", _on_open_logs),
pystray.MenuItem(t("tray.restart"), _on_restart),
pystray.MenuItem(t("tray.settings"), _on_edit_config),
pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", _on_exit),
)
pystray.MenuItem(t("tray.exit"), _on_exit),
]
st = get_status()
if st["has_update"]:
items.insert(-2, pystray.MenuItem(
t("tray.update", current=__version__, new=st["latest"]), _trigger_update_flow,
))
return pystray.Menu(*items)
def run_tray():
# entry point
def run_tray() -> None:
global _tray_icon, _config
_config = load_config()
save_config(_config)
if LOG_FILE.exists():
try:
LOG_FILE.unlink()
except Exception:
pass
setup_logging(_config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
log.info("TG WS Proxy версия %s, tray app starting", __version__)
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
bootstrap(_config)
if pystray is None or Image is None:
log.error("pystray or Pillow not installed; running in console mode")
start_proxy()
start_proxy(_config, _show_error)
try:
while True:
time.sleep(1)
@@ -658,16 +335,12 @@ def run_tray():
stop_proxy()
return
start_proxy()
_maybe_notify_update_async()
start_proxy(_config, _show_error)
check_update_async(_config, lambda: _exiting, lambda version, url: _trigger_update_flow())
_show_first_run()
_check_ipv6_warning()
icon_image = _load_icon()
_tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu())
check_ipv6_warning(_show_info)
_tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
log.info("Tray icon running")
_tray_icon.run()
@@ -675,15 +348,54 @@ def run_tray():
log.info("Tray app exited")
def main():
if not _acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return
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:
if sys.argv[1:] == ['--smoke-test']:
_smoke_test()
return
if not acquire_lock():
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
return
try:
run_tray()
finally:
_release_lock()
release_lock()
if __name__ == "__main__":
+476 -686
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
APP_PATH="${1:?Usage: build_dmg.sh <App.app> <Volume Name> <output.dmg> [assets_dir]}"
VOL_NAME="${2:?missing volume name}"
OUT_DMG="${3:?missing output dmg path}"
ASSETS_DIR="${4:-$(cd "$(dirname "${BASH_SOURCE[0]}")/assets" && pwd)}"
WIN_W=660
WIN_H=440
ICON_SIZE=128
APP_X=145
APPS_X=515
ICON_Y=220
APP_NAME="$(basename "$APP_PATH")"
WORK="$(mktemp -d)"
STAGE="$WORK/stage"
RW_DMG="$WORK/rw.dmg"
MOUNT="/Volumes/$VOL_NAME"
DEVICE=""
cleanup() {
if [ -n "$DEVICE" ]; then
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 || true
fi
rm -rf "$WORK"
}
trap cleanup EXIT
mkdir -p "$STAGE/.background"
cp -R "$APP_PATH" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
tiffutil -cathidpicheck \
"$ASSETS_DIR/background-light.png" \
"$ASSETS_DIR/background-light@2x.png" \
-out "$STAGE/.background/background.tiff"
hdiutil create \
-volname "$VOL_NAME" \
-srcfolder "$STAGE" \
-fs HFS+ \
-format UDRW \
-ov \
"$RW_DMG"
DEVICE="$(hdiutil attach \
-readwrite \
-noverify \
-noautoopen \
-mountpoint "$MOUNT" \
"$RW_DMG" \
| awk '/^\/dev\// { print $1; exit }')"
test -n "$DEVICE"
test -d "$MOUNT/$APP_NAME"
sleep 2
osascript <<APPLESCRIPT
tell application "Finder"
tell disk "$VOL_NAME"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {200, 140, 200 + $WIN_W, 140 + $WIN_H}
set theViewOptions to the icon view options of container window
set arrangement of theViewOptions to not arranged
set icon size of theViewOptions to $ICON_SIZE
set text size of theViewOptions to 13
set background picture of theViewOptions to file ".background:background.tiff"
set position of item "$APP_NAME" of container window to {$APP_X, $ICON_Y}
set position of item "Applications" of container window to {$APPS_X, $ICON_Y}
close
open
update
delay 2
end tell
end tell
APPLESCRIPT
SetFile -a C "$MOUNT" 2>/dev/null || true
sync
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 \
|| { sleep 3; hdiutil detach "$DEVICE" -force; }
DEVICE=""
rm -f "$OUT_DMG"
hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -ov -o "$OUT_DMG"
echo "Created $OUT_DMG"
+61 -17
View File
@@ -1,33 +1,33 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
import glob
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
import certifi
block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
certifi_cafile = certifi.where()
# Collect gi (PyGObject) submodules and data so pystray._appindicator works
gi_hiddenimports = collect_submodules('gi')
gi_datas = collect_data_files('gi')
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
# Collect GObject typelib files from the system
typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0')
typelib_datas = []
for d in typelib_dirs:
typelib_datas.append((d, 'gi_typelibs'))
appindicator_binaries = [
(path, '.')
for pattern in ('/usr/lib/*/libappindicator3.so.1',
'/usr/lib/libappindicator3.so.1', '/usr/lib64/libappindicator3.so.1')
for path in glob.glob(pattern)
]
a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas,
binaries=appindicator_binaries,
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')],
hiddenimports=[
'httpx',
'h2',
'pystray._appindicator',
'PIL._tkinter_finder',
'customtkinter',
@@ -36,21 +36,65 @@ a = Analysis(
'cryptography.hazmat.primitives.ciphers.modes',
'cryptography.hazmat.backends.openssl',
'gi',
'_gi',
'gi.repository.GLib',
'gi.repository.GObject',
'gi.repository.Gtk',
'gi.repository.Gdk',
'gi.repository.DBus',
'gi.repository.AppIndicator3',
'gi.repository.AyatanaAppIndicator3',
] + gi_hiddenimports,
],
hookspath=[],
hooksconfig={},
hooksconfig={
'gi': {
'icons': [],
'themes': [],
'languages': ['en', 'ru'],
},
},
runtime_hooks=[],
excludes=[],
excludes=[
'PIL._avif',
'PIL._webp',
'PIL._imagingtk',
],
noarchive=False,
cipher=block_cipher,
)
a.datas += [('certifi/cacert.pem', certifi_cafile, 'DATA')]
_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 = {
'_avif', '_webp', '_imagingtk',
'FpxImagePlugin', 'MicImagePlugin',
}
a.binaries = [
(name, path, typ)
for name, path, typ in a.binaries
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
]
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
if os.path.exists(icon_path):
a.datas += [('icon.ico', icon_path, 'DATA')]
+30 -8
View File
@@ -1,22 +1,33 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
certifi_datas = collect_data_files('certifi')
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
pathex=[],
binaries=[],
datas=[],
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[
'rumps',
'httpx',
'h2',
'tkinter',
'customtkinter',
'pystray._darwin',
'PIL._tkinter_finder',
'objc',
'Foundation',
'AppKit',
'PyObjCTools',
'PyObjCTools.AppHelper',
'PyObjCTools.MachSignals',
'cryptography.hazmat.primitives.ciphers',
'cryptography.hazmat.primitives.ciphers.algorithms',
'cryptography.hazmat.primitives.ciphers.modes',
@@ -25,11 +36,24 @@ a = Analysis(
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
excludes=[
'PIL._avif',
'PIL._webp',
],
noarchive=False,
cipher=block_cipher,
)
_PIL_EXCLUDE_PYDS = {
'_avif', '_webp',
'FpxImagePlugin', 'MicImagePlugin',
}
a.binaries = [
(name, path, typ)
for name, path, typ in a.binaries
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
]
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns')
if not os.path.exists(icon_path):
icon_path = None
@@ -77,7 +101,5 @@ app = BUNDLE(
'LSMinimumSystemVersion': '10.15',
'LSUIElement': True,
'NSHighResolutionCapable': True,
'NSAppleEventsUsageDescription':
'TG WS Proxy needs to display dialogs.',
},
)
+36
View File
@@ -0,0 +1,36 @@
# UTF-8
#
# For more details about fixed file info 'ffi' see:
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 11, 3, 0),
prodvers=(1, 11, 3, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo(
[
StringTable(
u'040904B0',
[
StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.11.3.0'),
StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.11.3.0'),
]
)
]
),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
)
+26 -3
View File
@@ -3,18 +3,25 @@
import sys
import os
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
certifi_datas = collect_data_files('certifi')
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/')],
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[
'httpx',
'h2',
'pystray._win32',
'PIL._tkinter_finder',
'customtkinter',
@@ -26,14 +33,29 @@ a = Analysis(
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
excludes=[
'PIL._avif',
'PIL._webp',
'PIL._imagingtk',
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
_PIL_EXCLUDE_PYDS = {
'_avif', '_webp', '_imagingtk',
'FpxImagePlugin', 'MicImagePlugin',
}
a.binaries = [
(name, path, typ)
for name, path, typ in a.binaries
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
]
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
version_path = os.path.join(os.path.dirname(SPEC), 'version_info.txt')
if os.path.exists(icon_path):
a.datas += [('icon.ico', icon_path, 'DATA')]
@@ -50,7 +72,7 @@ exe = EXE(
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx=False,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
@@ -60,4 +82,5 @@ exe = EXE(
codesign_identity=None,
entitlements_file=None,
icon=icon_path if os.path.exists(icon_path) else None,
version=version_path if os.path.exists(version_path) else None,
)
+6 -1
View File
@@ -1 +1,6 @@
__version__ = "1.3.0"
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener
__version__ = "1.11.3"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+130
View File
@@ -0,0 +1,130 @@
"""
AES-CTR shim.
Prefers `cryptography` if available (desktop / Docker). Falls back to a
ctypes wrapper over the system OpenSSL `libcrypto` for environments where
installing `cryptography` is painful (Entware on routers, embedded boxes
without a Rust toolchain). The public surface mimics the small subset of
`cryptography.hazmat.primitives.ciphers` that this project actually uses:
Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor().update(data)
"""
from __future__ import annotations
try:
from cryptography.hazmat.primitives.ciphers import ( # noqa: F401
Cipher, algorithms, modes,
)
except ImportError:
import ctypes
import ctypes.util
def _load_libcrypto():
name = ctypes.util.find_library("crypto")
candidates = []
if name:
candidates.append(name)
candidates += [
"libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so.1.0.0",
"libcrypto.so", "/opt/lib/libcrypto.so",
"/opt/lib/libcrypto.so.1.1", "/opt/lib/libcrypto.so.3",
]
last_err = None
for c in candidates:
try:
return ctypes.CDLL(c)
except OSError as e:
last_err = e
raise RuntimeError(
"libcrypto not found; install openssl-util or "
"`opkg install libopenssl`. Last error: %r" % last_err
)
_libcrypto = _load_libcrypto()
_libcrypto.EVP_CIPHER_CTX_new.restype = ctypes.c_void_p
_libcrypto.EVP_CIPHER_CTX_free.argtypes = [ctypes.c_void_p]
_libcrypto.EVP_aes_128_ctr.restype = ctypes.c_void_p
_libcrypto.EVP_aes_192_ctr.restype = ctypes.c_void_p
_libcrypto.EVP_aes_256_ctr.restype = ctypes.c_void_p
_libcrypto.EVP_EncryptInit_ex.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_char_p, ctypes.c_char_p,
]
_libcrypto.EVP_EncryptInit_ex.restype = ctypes.c_int
_libcrypto.EVP_EncryptUpdate.argtypes = [
ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
ctypes.c_char_p, ctypes.c_int,
]
_libcrypto.EVP_EncryptUpdate.restype = ctypes.c_int
_EVP_BY_KEY = {
16: _libcrypto.EVP_aes_128_ctr,
24: _libcrypto.EVP_aes_192_ctr,
32: _libcrypto.EVP_aes_256_ctr,
}
class algorithms:
class AES:
__slots__ = ("key",)
def __init__(self, key: bytes):
if len(key) not in _EVP_BY_KEY:
raise ValueError("AES key must be 16/24/32 bytes")
self.key = bytes(key)
class modes:
class CTR:
__slots__ = ("iv",)
def __init__(self, iv: bytes):
if len(iv) != 16:
raise ValueError("CTR IV must be 16 bytes")
self.iv = bytes(iv)
class _CtrStream:
__slots__ = ("_ctx",)
def __init__(self, key: bytes, iv: bytes):
ctx = _libcrypto.EVP_CIPHER_CTX_new()
if not ctx:
raise RuntimeError("EVP_CIPHER_CTX_new failed")
self._ctx = ctx
evp = _EVP_BY_KEY[len(key)]()
if _libcrypto.EVP_EncryptInit_ex(ctx, evp, None, key, iv) != 1:
_libcrypto.EVP_CIPHER_CTX_free(ctx)
self._ctx = None
raise RuntimeError("EVP_EncryptInit_ex failed")
def update(self, data: bytes) -> bytes:
if not data:
return b""
outlen = ctypes.c_int(0)
buf = ctypes.create_string_buffer(len(data) + 16)
if _libcrypto.EVP_EncryptUpdate(
self._ctx, buf, ctypes.byref(outlen), bytes(data), len(data)
) != 1:
raise RuntimeError("EVP_EncryptUpdate failed")
return buf.raw[:outlen.value]
def __del__(self):
ctx = getattr(self, "_ctx", None)
if ctx:
_libcrypto.EVP_CIPHER_CTX_free(ctx)
self._ctx = None
class Cipher:
__slots__ = ("_key", "_iv")
def __init__(self, algorithm, mode):
if not isinstance(algorithm, algorithms.AES):
raise TypeError("only AES is supported")
if not isinstance(mode, modes.CTR):
raise TypeError("only CTR mode is supported")
self._key = algorithm.key
self._iv = mode.iv
def encryptor(self) -> _CtrStream:
return _CtrStream(self._key, self._iv)
# CTR is symmetric — decryption == encryption with the same keystream.
decryptor = encryptor
+43
View File
@@ -0,0 +1,43 @@
import random
from collections import Counter
from typing import Dict, List, Iterator
class _Balancer:
def __init__(self):
self.domains: List[str] = []
self._dc_to_domain: Dict[int, str] = {}
def update_domains_list(self, domains_list: List[str]) -> None:
if Counter(self.domains) == Counter(domains_list):
return
self.domains = domains_list[:]
self._dc_to_domain = {
dc_id: random.choice(self.domains)
for dc_id in (1, 2, 3, 4, 5, 203)
}
def update_domain_for_dc(self, dc_id: int, domain: str) -> bool:
if self._dc_to_domain.get(dc_id) == domain:
return False
self._dc_to_domain[dc_id] = domain
return True
def get_domains_for_dc(self, dc_id: int) -> Iterator[str]:
current_domain = self._dc_to_domain.get(dc_id)
if current_domain is not None:
yield current_domain
shuffled_domains = self.domains[:]
random.shuffle(shuffled_domains)
for domain in shuffled_domains:
if domain != current_domain:
yield domain
balancer = _Balancer()
+493
View File
@@ -0,0 +1,493 @@
import asyncio
import logging
import struct
import time
from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlencode
from .utils import *
from .stats import stats
from .balancer import balancer
from .config import proxy_config
from .raw_websocket import RawWebSocket
from .pool import cf_worker_pool
from .network_debug import WsActivity
from .cf_h2 import bridge_h2
log = logging.getLogger('tg-mtproto-proxy')
_st_I_le = struct.Struct('<I')
TCP_BACKOFF_INITIAL = 30.0
TCP_BACKOFF_MAX = 3600.0
_tcp_failures: Dict[Tuple[str, int], int] = {}
_tcp_retry_after: Dict[Tuple[str, int], float] = {}
_tcp_connecting: Set[Tuple[str, int]] = set()
def reset_tcp_backoff() -> None:
_tcp_failures.clear()
_tcp_retry_after.clear()
_tcp_connecting.clear()
class CryptoCtx:
__slots__ = ('clt_dec', 'clt_enc', 'tg_enc', 'tg_dec')
def __init__(self, clt_dec, clt_enc, tg_enc, tg_dec):
self.clt_dec = clt_dec # decrypt from client
self.clt_enc = clt_enc # encrypt to client
self.tg_enc = tg_enc # encrypt to telegram
self.tg_dec = tg_dec # decrypt from telegram
class MsgSplitter:
"""
Splits TCP stream data into individual MTProto transport packets
so each can be sent as a separate WS frame.
"""
__slots__ = ('_proto', '_cipher_buf', '_plain_buf', '_disabled')
def __init__(self, proto_int: int):
self._proto = proto_int
self._cipher_buf = bytearray()
self._plain_buf = bytearray()
self._disabled = False
def split(self, chunk: bytes, plain: bytes) -> List[bytes]:
if not chunk:
return []
if self._disabled:
return [chunk]
self._cipher_buf.extend(chunk)
self._plain_buf.extend(plain)
parts = []
offset = 0
buf_len = len(self._cipher_buf)
while offset < buf_len:
packet_len = self._next_packet_len(offset, buf_len - offset)
if packet_len is None:
break
if packet_len <= 0:
parts.append(bytes(self._cipher_buf) if offset == 0 else bytes(self._cipher_buf[offset:]))
offset = buf_len
self._disabled = True
break
if offset == 0 and packet_len == buf_len:
parts.append(bytes(self._cipher_buf))
else:
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
offset += packet_len
if offset:
del self._cipher_buf[:offset]
del self._plain_buf[:offset]
return parts
def flush(self) -> List[bytes]:
if not self._cipher_buf:
return []
tail = bytes(self._cipher_buf)
self._cipher_buf.clear()
self._plain_buf.clear()
return [tail]
def _next_packet_len(self, offset: int, avail: int) -> Optional[int]:
if avail <= 0:
return None
if self._proto == PROTO_ABRIDGED_INT:
return self._next_abridged_len(offset, avail)
if self._proto in (PROTO_INTERMEDIATE_INT,
PROTO_PADDED_INTERMEDIATE_INT):
return self._next_intermediate_len(offset, avail)
return 0
def _next_abridged_len(self, offset: int, avail: int) -> Optional[int]:
first = self._plain_buf[offset]
if first in (0x7F, 0xFF):
if avail < 4:
return None
payload_len = int.from_bytes(
self._plain_buf[offset + 1:offset + 4], 'little') * 4
header_len = 4
else:
payload_len = (first & 0x7F) * 4
header_len = 1
if payload_len <= 0:
return 0
packet_len = header_len + payload_len
if avail < packet_len:
return None
return packet_len
def _next_intermediate_len(self, offset: int, avail: int) -> Optional[int]:
if avail < 4:
return None
payload_len = _st_I_le.unpack_from(self._plain_buf, offset)[0] & 0x7FFFFFFF
if payload_len <= 0:
return 0
packet_len = 4 + payload_len
if avail < packet_len:
return None
return packet_len
async def do_fallback(reader, writer, relay_init, label,
dc: int, is_test_dc: bool, is_media: bool, media_tag: str,
ctx: CryptoCtx, splitter=None, *, h2_pool=None, proto_tag=None):
ip_table = DC_TEST_IPS if is_test_dc else DC_DEFAULT_IPS
fallback_dst = ip_table.get(dc)
use_cf = proxy_config.fallback_cfproxy and not is_test_dc
worker_domains = proxy_config.cfproxy_worker_domains
methods: List[str] = []
if worker_domains and fallback_dst:
methods.append('cf_worker')
if use_cf:
methods.append('cf')
if fallback_dst:
methods.append('tcp')
for method in methods:
if method == 'cf_worker' and fallback_dst:
ok = await _cfproxy_worker_fallback(
reader, writer, relay_init, label, ctx,
dc=dc, is_test_dc=is_test_dc, is_media=is_media,
fallback_dst=fallback_dst, splitter=splitter)
if ok:
return True
elif method == 'cf':
if is_media and h2_pool is not None and proto_tag is not None:
channel = await h2_pool.open(dc, label)
if channel is not None:
stats.connections_h2 += 1
stats.connections_cfproxy += 1
await bridge_h2(reader, writer, channel, ctx, proto_tag)
return True
ok = await _cfproxy_fallback(
reader, writer, relay_init, label, ctx,
dc=dc, is_media=is_media,
splitter=splitter)
if ok:
return True
elif method == 'tcp' and fallback_dst:
ok = await _tcp_fallback(
reader, writer, fallback_dst, 443,
relay_init, label, ctx)
if ok:
return True
return False
async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
ctx: CryptoCtx,
dc: int, is_test_dc: bool, is_media: bool,
fallback_dst: str,
splitter=None):
media_tag = ' media' if is_media else ''
worker_domains = proxy_config.cfproxy_worker_domains
if not worker_domains:
return False
pooled = None if is_test_dc else await cf_worker_pool.get(
dc, fallback_dst, worker_domains)
if pooled:
ws, worker_domain = pooled
log.info("[%s] DC%d%s -> CF worker pool hit via %s for %s",
label, dc, media_tag, worker_domain, fallback_dst)
else:
query = urlencode({
'dst': fallback_dst,
'dc': str(dc),
})
path = f'/apiws?{query}'
ws = None
for worker_domain in cf_worker_pool.available_domains(worker_domains):
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
label, dc, media_tag, worker_domain, fallback_dst)
try:
ws = await RawWebSocket.connect(worker_domain, worker_domain,
timeout=10.0, path=path,
secure=not proxy_config.disable_secure)
break
except Exception as exc:
cf_worker_pool.report_failure(worker_domain, exc)
log.warning("[%s] DC%d%s CF worker %s failed: %s",
label, dc, media_tag, worker_domain, repr(exc))
continue
if ws is None:
return False
stats.connections_cfproxy += 1
await ws.send(relay_init)
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
dc=dc, is_media=is_media,
splitter=None)
return True
async def _cfproxy_fallback(reader, writer, relay_init, label,
ctx: CryptoCtx,
dc: int, is_media: bool,
splitter=None):
media_tag = ' media' if is_media else ''
ws = None
chosen_domain = None
log.info("[%s] DC%d%s -> trying CF proxy",
label, dc, media_tag)
connect_started = time.monotonic()
for base_domain in balancer.get_domains_for_dc(dc):
domain = f'kws{dc}.{base_domain}'
try:
ws = await RawWebSocket.connect(domain, domain, timeout=10.0,
secure=not proxy_config.disable_secure)
chosen_domain = base_domain
break
except Exception as exc:
log.warning("[%s] DC%d%s CF proxy failed: %s",
label, dc, media_tag, repr(exc))
if ws is None:
return False
log.debug('[%s] WS CONNECT DC%d%s route=cf setup_ms=%.0f',
label, dc, media_tag, (time.monotonic() - connect_started) * 1000)
if chosen_domain and balancer.update_domain_for_dc(dc, chosen_domain):
log.info("[%s] Switched active CF domain", label)
stats.connections_cfproxy += 1
await ws.send(relay_init)
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
dc=dc, is_media=is_media,
splitter=splitter)
return True
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
key = (dst, port)
if key in _tcp_connecting or time.monotonic() < _tcp_retry_after.get(key, 0):
log.debug("[%s] TCP fallback to %s:%d skipped (backoff or connecting)",
label, dst, port)
return False
_tcp_connecting.add(key)
rw = None
try:
try:
log.info("[%s] TCP fallback to %s:%d", label, dst, port)
rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10)
rw.write(relay_init)
await rw.drain()
except Exception as exc:
failures = _tcp_failures.get(key, 0) + 1
_tcp_failures[key] = failures
delay = min(TCP_BACKOFF_INITIAL * 2 ** min(failures - 1, 7),
TCP_BACKOFF_MAX)
_tcp_retry_after[key] = time.monotonic() + delay
log.warning("[%s] TCP fallback to %s:%d failed: %r; retry in %.0fs",
label, dst, port, exc, delay)
return False
finally:
_tcp_connecting.discard(key)
_tcp_failures.pop(key, None)
_tcp_retry_after.pop(key, None)
stats.connections_tcp_fallback += 1
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
return True
finally:
if rw is not None:
rw.close()
try:
await rw.wait_closed()
except (OSError, ConnectionError):
pass
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
ctx: CryptoCtx,
dc=None, is_media=False,
splitter: Optional[MsgSplitter] = None):
"""
Bidirectional TCP(client) <-> WS(telegram) with re-encryption.
client ciphertext → decrypt(clt_key) → encrypt(tg_key) → WS
WS data → decrypt(tg_key) → encrypt(clt_key) → client TCP
"""
dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?"
up_bytes = 0
down_bytes = 0
up_packets = 0
down_packets = 0
start_time = asyncio.get_running_loop().time()
close_reason = 'normal'
activity = WsActivity(label, dc_tag) if log.isEnabledFor(logging.DEBUG) else None
async def tcp_to_ws():
nonlocal up_bytes, up_packets, close_reason
try:
while True:
chunk = await reader.read(65536)
if not chunk:
if splitter:
tail = splitter.flush()
if tail:
if activity is not None:
activity.send_started()
await ws.send(tail[0])
if activity is not None:
activity.sent(len(tail[0]))
break
n = len(chunk)
if activity is not None:
activity.input(n)
stats.bytes_up += n
up_bytes += n
up_packets += 1
plain = ctx.clt_dec.update(chunk)
chunk = ctx.tg_enc.update(plain)
if splitter:
parts = splitter.split(chunk, plain)
if not parts:
continue
if activity is not None:
activity.send_started()
if len(parts) > 1:
await ws.send_batch(parts)
else:
await ws.send(parts[0])
if activity is not None:
activity.sent(sum(map(len, parts)))
else:
if activity is not None:
activity.send_started()
await ws.send(chunk)
if activity is not None:
activity.sent(len(chunk))
except asyncio.CancelledError:
return
except (ConnectionError, OSError) as e:
close_reason = f"client: {type(e).__name__}"
except Exception as e:
close_reason = f"client: {type(e).__name__}: {e}"
log.debug("[%s] tcp->ws ended: %s", label, e)
async def ws_to_tcp():
nonlocal down_bytes, down_packets, close_reason
try:
while True:
data = await ws.recv()
if data is None:
if close_reason == 'normal':
close_reason = 'upstream: ws_close'
break
n = len(data)
if activity is not None:
activity.received(n)
stats.bytes_down += n
down_bytes += n
down_packets += 1
plain = ctx.tg_dec.update(data)
data = ctx.clt_enc.update(plain)
writer.write(data)
await writer.drain()
if activity is not None:
activity.delivered(n)
except asyncio.CancelledError:
return
except (ConnectionError, OSError) as e:
close_reason = f"upstream: {type(e).__name__}"
except asyncio.IncompleteReadError:
close_reason = 'upstream: tcp_reset'
except Exception as e:
close_reason = f"upstream: {type(e).__name__}: {e}"
log.debug("[%s] ws->tcp ended: %s", label, e)
tasks = [asyncio.create_task(tcp_to_ws()),
asyncio.create_task(ws_to_tcp())]
try:
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except BaseException:
pass
elapsed = asyncio.get_running_loop().time() - start_time
if activity is not None:
activity.close()
log.info("[%s] %s WS session closed (%s): "
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
label, dc_tag, close_reason,
human_bytes(up_bytes), up_packets,
human_bytes(down_bytes), down_packets,
elapsed)
try:
await ws.close()
except BaseException:
pass
try:
writer.close()
await writer.wait_closed()
except BaseException:
pass
async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
label, ctx: CryptoCtx):
"""Bidirectional TCP <-> TCP with re-encryption."""
async def forward(src, dst_w, is_up):
try:
while True:
data = await src.read(65536)
if not data:
break
n = len(data)
if is_up:
stats.bytes_up += n
plain = ctx.clt_dec.update(data)
data = ctx.tg_enc.update(plain)
else:
stats.bytes_down += n
plain = ctx.tg_dec.update(data)
data = ctx.clt_enc.update(plain)
dst_w.write(data)
await dst_w.drain()
except asyncio.CancelledError:
pass
except Exception as e:
log.debug("[%s] forward ended: %s", label, e)
tasks = [
asyncio.create_task(forward(reader, remote_writer, True)),
asyncio.create_task(forward(remote_reader, writer, False)),
]
try:
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except BaseException:
pass
for w in (writer, remote_writer):
try:
w.close()
await w.wait_closed()
except BaseException:
pass
+998
View File
@@ -0,0 +1,998 @@
from __future__ import annotations
import asyncio
import logging
import os
import struct
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
import httpx
from .balancer import balancer
from .h2_transport import H2Transport
from .stats import stats
from .utils import (
DC_DEFAULT_IPS, PROTO_TAG_ABRIDGED,
PROTO_TAG_SECURE,
)
log = logging.getLogger('tg-mtproto-proxy')
MAX_PACKET = 4 * 1024 * 1024
MAX_PLAIN_PADDING = 256
MAX_CHANNEL_REQUESTS = 8
MAX_CHANNEL_BYTES = 8 * 1024 * 1024
MAX_LANE_REQUESTS = 64
MAX_LANE_BYTES = 32 * 1024 * 1024
MAX_LANE_REPLY_BYTES = 32 * 1024 * 1024
DOMAIN_COOLDOWN = 30.0
REQUEST_TIMEOUT = 65.0
SETUP_TIMEOUT = 8.0
REPLAY_IDLE_SECONDS = 1.0
REPLAY_REQUEST_SECONDS = 3.0
REPLAY_CHECK_SECONDS = 0.25
REPLAY_MAX_AGE = 30.0
REPLAY_HISTORY_BYTES = 64 * 1024
REPLAY_HISTORY_PACKETS = 16
MAX_CHANNEL_REPLAYS = 2
REPLAY_SLOT_SECONDS = 30.0
REPLAY_RETRY_SECONDS = 2.0
REPLAY_MAX_ATTEMPTS = 3
SLOW_RESPONSE_SECONDS = 3.0
DIAGNOSTIC_INTERVAL = 5.0
@dataclass
class _RequestTrace:
packet: int
started: float
replay: bool
size: int
request: int = 0
stream: int = 0
phase: str = 'queued'
sent_at: Optional[float] = None
headers_at: Optional[float] = None
finished_at: Optional[float] = None
received: int = 0
cf_ray: str = '-'
def summary(self, now: float) -> str:
end = self.finished_at if self.finished_at is not None else now
headers = self.headers_at if self.headers_at is not None else end
send_ms = -1 if self.sent_at is None else (self.sent_at - self.started) * 1000
headers_ms = -1 if self.sent_at is None else max(0, headers - self.sent_at) * 1000
return ('req=%d pkt=%d sid=%d replay=%d phase=%s age_ms=%.0f '
'send_ms=%.0f wait_headers_ms=%.0f up=%d down=%d ray=%s' % (
self.request, self.packet, self.stream, self.replay, self.phase,
(end - self.started) * 1000,
send_ms, headers_ms,
self.size, self.received, self.cf_ray))
class _NativeDeliveryError(ConnectionError):
"""The local receiver disconnected"""
class _ReplyBufferFull(BufferError):
"""A local memory limit"""
class _MTProtoTransportError(Exception):
"""An endpoint error that the native client must receive, not just EOF."""
def __init__(self, code: int, source: str):
super().__init__('%d (%s)' % (code, source))
self.code = code
@dataclass
class _ReplayPacket:
sent_at: float
body: bytes
sequence: int
attempts: int = 0
last_attempt_at: float = 0.0
pending: Optional[asyncio.Task] = None
originals: Set[asyncio.Task] = field(default_factory=set, repr=False)
retired: bool = False
reply_id: Optional[bytes] = None
@property
def replayed(self) -> bool:
return self.last_attempt_at > 0 or self.attempts > 0
async def _read_packet(reader, decryptor, tag: bytes) -> Tuple[bytes, bool]:
async def plaintext(count: int) -> bytes:
return decryptor.update(await reader.readexactly(count))
if tag == PROTO_TAG_ABRIDGED:
first = (await plaintext(1))[0]
quick, words = bool(first & 0x80), first & 0x7f
if words == 0x7f:
words = int.from_bytes(await plaintext(3), 'little')
length = words * 4
else:
value, = struct.unpack('<I', await plaintext(4))
quick, length = bool(value & 0x80000000), value & 0x7fffffff
if not 24 <= length <= MAX_PACKET + (MAX_PLAIN_PADDING if tag == PROTO_TAG_SECURE else 0):
raise ValueError('native packet length outside supported range: %d' % length)
body = await plaintext(length)
if tag == PROTO_TAG_SECURE:
if body[:8] == b'\x00' * 8:
packet_length = 20 + int.from_bytes(body[16:20], 'little')
max_padding = MAX_PLAIN_PADDING
else:
packet_length = 24 + ((length - 24) // 16) * 16
max_padding = 15
padding = length - packet_length
if not 24 <= packet_length <= length or padding > max_padding:
raise ValueError('invalid padded native packet: frame=%d packet=%d padding=%d limit=%d' % (
length, packet_length, padding, max_padding))
body = body[:packet_length]
if len(body) % 4 or len(body) > MAX_PACKET:
raise ValueError('invalid native packet alignment or size')
return body, quick
def _encode_reply(body: bytes, tag: bytes) -> bytes:
if not body or len(body) % 4 or len(body) > MAX_PACKET:
raise ValueError('invalid HTTP MTProto response length: %d' % len(body))
if tag == PROTO_TAG_ABRIDGED:
words = len(body) // 4
prefix = bytes([words]) if words < 0x7f else b'\x7f' + words.to_bytes(3, 'little')
return prefix + body
padding = os.urandom(os.urandom(1)[0] % 4) if tag == PROTO_TAG_SECURE else b''
return struct.pack('<I', len(body) + len(padding)) + body + padding
class _HttpChannel:
def __init__(self, lane, channel_id: int, label: str):
self.lane, self.channel_id, self.label = lane, channel_id, label
self.closed = False
self.transport_error: Optional[int] = None
self.pending: Dict[asyncio.Task, int] = {}
self.pending_since: Dict[asyncio.Task, float] = {}
self.sent_since: Dict[asyncio.Task, float] = {}
self.pending_bytes = 0
self.reply_bytes = 0
self.queue = asyncio.Queue(maxsize=16)
self.capacity = asyncio.Condition()
self.recovery_wakeup = asyncio.Event()
self.requests = self.up = self.down = self.quick_requests = 0
self.started = time.monotonic()
self.receiving_tasks: Set[asyncio.Task] = set()
self.delivering_since: Optional[float] = None
self.replay_pending: Set[asyncio.Task] = set()
self.replay_history: List[_ReplayPacket] = []
self.replay_key: Optional[bytes] = None
self.replay_requests = 0
self.poll_requests = 0
self.first_poll_gap = 0.0
self.replay_rotations = 0
self.capacity_rotations = 0
self.last_progress = self.started
self.last_native_send = self.started
self.close_lock = asyncio.Lock()
self.send_lock = asyncio.Lock()
self.close_done = False
self.request_traces: Dict[asyncio.Task, _RequestTrace] = {}
self.recent_traces = deque(maxlen=16)
self.upload_wait_since: Optional[float] = None
self.upload_wait_phase = '-'
self.upload_wait_bytes = 0
self.http_bytes = self.small_responses = self.large_responses = 0
self.flow_sample = (self.started, 0, 0, 0, 0, 0, 0, 0)
lane.channels.add(self)
@property
def receiving_http(self) -> int:
return len(self.receiving_tasks)
@property
def native_backpressure(self) -> bool:
return bool(self.reply_bytes) or self.delivering_since is not None
def _has_capacity(self, length: int) -> bool:
return len(self.pending) < MAX_CHANNEL_REQUESTS and self.pending_bytes + length <= MAX_CHANNEL_BYTES
def _wake_receiver(self) -> None:
if not self.closed and not self.pending and not self.native_backpressure:
self.recovery_wakeup.set()
def _overdue_original(self, entry: _ReplayPacket, now: float) -> bool:
return entry.retired or any(task in self.sent_since and task not in self.receiving_tasks
and now - self.sent_since[task] >= REPLAY_REQUEST_SECONDS
for task in entry.originals)
def _replay_limit(self, entry: _ReplayPacket) -> int:
return REPLAY_MAX_ATTEMPTS if entry.originals or entry.retired or entry is self.replay_history[-1] else 0
def _capacity_candidate(self, now: float):
for task, sent in sorted(self.sent_since.items(), key=lambda pair: (
pair[0] not in self.replay_pending, pair[1])):
if (task.done() or task in self.receiving_tasks
or now - sent < REPLAY_REQUEST_SECONDS):
continue
if task in self.replay_pending:
return task, None
entry = next((entry for entry in self.replay_history if task in entry.originals), None)
if entry is not None and now - entry.sent_at <= REPLAY_MAX_AGE:
return task, entry
return None
async def _make_upload_room(self, now: float) -> None:
if self.closed or self.upload_wait_since is None:
return
if self.upload_wait_phase == 'channel-capacity':
if self._has_capacity(self.upload_wait_bytes):
return
channels = [self]
elif self.upload_wait_phase == 'lane-capacity':
if self.lane._has_capacity(self.upload_wait_bytes):
return
channels = sorted(self.lane.channels, key=lambda channel: min(
channel.sent_since.values(), default=now))
else:
return
for channel in channels:
candidate = channel._capacity_candidate(now)
if candidate is None:
continue
task, entry = candidate
channel.sent_since.pop(task, None)
if entry is not None:
entry.retired = True
trace = channel.request_traces.get(task)
if trace is not None:
trace.phase = 'capacity-' + trace.phase
if now >= self.lane.next_capacity_log:
self.lane.next_capacity_log = now + DIAGNOSTIC_INTERVAL
log.debug('H2 CAPACITY lane=%d waiting_channel=%d release_channel=%d '
'reason=%s replay=%d wait_ms=%.0f inflight=%d',
self.lane.lane_id, self.channel_id, channel.channel_id,
self.upload_wait_phase, task in channel.replay_pending,
(now - self.upload_wait_since) * 1000, self.lane.inflight)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
channel.capacity_rotations += 1
self.lane.capacity_rotations += 1
return
def _replay_blocker(self, entry: _ReplayPacket, now: float) -> Optional[str]:
if not 0 <= now - entry.sent_at <= REPLAY_MAX_AGE:
return 'expired'
if entry.attempts >= self._replay_limit(entry):
return 'budget-used'
if self.native_backpressure:
return 'native-backpressure'
if entry.originals.intersection(self.receiving_tasks):
return 'original-body'
if entry.pending in self.receiving_tasks:
return 'replay-body'
if entry.replayed and now - entry.last_attempt_at < REPLAY_RETRY_SECONDS:
return 'backoff-or-progress'
if self._overdue_original(entry, now):
return None
if (entry is not self.replay_history[-1]
or any(task is not entry.pending for task in self.pending)):
return 'receiver-active'
if entry.replayed and now - self.last_progress < REPLAY_IDLE_SECONDS:
return 'backoff-or-progress'
return None
def _replay_due(self, entry: _ReplayPacket, now: float) -> bool:
return self._replay_blocker(entry, now) is None
def _wait_state(self, now: float) -> str:
oldest = min(self.pending_since.values(), default=now)
recovery_left = sum(max(0, self._replay_limit(entry) - entry.attempts)
for entry in self.replay_history if now - entry.sent_at <= REPLAY_MAX_AGE)
waiting_headers = sum(task not in self.replay_pending and task not in self.receiving_tasks
and now - since >= REPLAY_REQUEST_SECONDS
for task, since in self.pending_since.items())
return ('channel=%d pending=%d replays=%d receiving=%d ready_bytes=%d '
'native_write=%d idle_ms=%.0f oldest_ms=%.0f native_age_ms=%.0f '
'recovery_left=%d waiting_headers=%d' % (
self.channel_id, len(self.pending), len(self.replay_pending),
self.receiving_http, self.reply_bytes, self.delivering_since is not None,
(now - self.last_progress) * 1000, (now - oldest) * 1000,
(now - self.last_native_send) * 1000, recovery_left, waiting_headers))
def _request_detail(self, trace: _RequestTrace, now: float) -> str:
entry = next((entry for entry in self.replay_history if entry.sequence == trace.packet), None)
recovery = 'not-retained'
attempts = '-'
if entry is not None:
attempts = '%d/%d' % (entry.attempts, self._replay_limit(entry))
blocker = self._replay_blocker(entry, now)
if blocker is not None:
recovery = blocker
elif entry.pending is not None:
recovery = ('replay-body' if entry.pending in self.receiving_tasks else
'probe-%.0fms' % ((now - self.pending_since.get(entry.pending, now)) * 1000))
elif len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
recovery = 'replay-slots'
elif not self._has_capacity(len(entry.body)):
recovery = 'channel-capacity'
elif not self.lane._has_capacity(len(entry.body)):
recovery = 'lane-capacity'
elif self.send_lock.locked():
recovery = 'send-lock'
else:
recovery = 'eligible'
related = [item for item in self.recent_traces if item.packet == trace.packet][-2:]
return ('channel=%d %s attempts=%s recovery=%s recent=[%s]' % (
self.channel_id, trace.summary(now), attempts, recovery,
'; '.join(item.summary(now) for item in related)))
def _flow_state(self, now: float) -> str:
previous = self.flow_sample
current = (now, self.requests, self.replay_requests, self.http_bytes,
self.down, self.small_responses, self.large_responses, self.poll_requests)
self.flow_sample = current
poll_gap, self.first_poll_gap = self.first_poll_gap, 0.0
probes = [trace.summary(now) for trace in self.request_traces.values() if trace.replay][:2]
return ('channel=%d window_ms=%.0f requests=%d replays=%d http_down=%d native_down=%d '
'small=%d large=%d pending=%d ready=%d upload_wait=%s/%.0fms native_write_ms=%.0f '
'capacity_rotations=%d polls=%d first_poll_ms=%.0f probes=[%s]' % (
self.channel_id, (now - previous[0]) * 1000,
*(current[index] - previous[index] for index in range(1, 7)),
len(self.pending), self.reply_bytes, self.upload_wait_phase,
0 if self.upload_wait_since is None else (now - self.upload_wait_since) * 1000,
0 if self.delivering_since is None else (now - self.delivering_since) * 1000,
self.capacity_rotations,
current[7] - previous[7], poll_gap * 1000,
'; '.join(probes)))
def _fail(self, error: Exception) -> None:
if self.closed:
return
if isinstance(error, _MTProtoTransportError):
self.transport_error = error.code
self.closed = True
self.recovery_wakeup.set()
while not self.queue.empty():
self.queue.get_nowait()
self.reply_bytes = 0
self.queue.put_nowait(error)
async def send(self, body: bytes, quick: bool, replay: bool = False) -> bool:
if replay and self.send_lock.locked():
return False
if not replay:
self.upload_wait_since = time.monotonic()
self.upload_wait_phase = 'send-lock'
self.upload_wait_bytes = len(body)
try:
async with self.send_lock:
return await self._send(body, quick, replay)
finally:
if not replay:
self.upload_wait_since = None
self.upload_wait_phase = '-'
self.upload_wait_bytes = 0
async def _send(self, body: bytes, quick: bool, replay: bool) -> bool:
async with self.capacity:
if replay and not self._has_capacity(len(body)):
return False
if not replay:
self.upload_wait_phase = 'channel-capacity'
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(len(body))),
REQUEST_TIMEOUT)
if self.closed:
raise ConnectionError('H2 channel closed')
if not replay:
self.upload_wait_phase = 'lane-capacity'
if not await self.lane._reserve(len(body), wait=not replay):
return False
if self.closed:
await self.lane._release(len(body))
raise ConnectionError('H2 channel closed')
replay_packet = self._find_packet(body) if replay else None
now = time.monotonic()
if replay and (replay_packet is None or replay_packet.pending is not None
or not self._replay_due(replay_packet, now)):
await self.lane._release(len(body))
return False
self.pending_bytes += len(body)
self.requests += 1
self.replay_requests += int(replay)
self.up += len(body)
stats.bytes_up += len(body)
if not replay:
self.last_native_send = now
native_packet = self._remember_packet(body, now) if not replay else None
if quick:
self.quick_requests += 1
packet = replay_packet or native_packet
trace = (_RequestTrace(packet.sequence if packet is not None else self.requests,
now, replay, len(body))
if log.isEnabledFor(logging.DEBUG) else None)
task = asyncio.create_task(self._run_request(body, replay, packet, trace))
self.pending[task] = len(body)
self.pending_since[task] = now
if trace is not None:
self.request_traces[task] = trace
if native_packet is not None:
native_packet.originals.add(task)
self._trim_history(now)
if replay:
if not self._overdue_original(replay_packet, now):
self.poll_requests += 1
if not replay_packet.replayed:
self.first_poll_gap = max(self.first_poll_gap, now - self.last_progress)
self.replay_pending.add(task)
replay_packet.attempts += 1
replay_packet.last_attempt_at = now
replay_packet.pending = task
return True
def _find_packet(self, body: bytes) -> Optional[_ReplayPacket]:
return next((entry for entry in self.replay_history if entry.body == body), None)
def _remember_packet(self, body: bytes, now: float) -> Optional[_ReplayPacket]:
key = body[:8]
if key == b'\x00' * 8:
return None
if self.replay_key != key:
self.replay_history.clear()
self.replay_key = key
if len(body) > REPLAY_HISTORY_BYTES:
return None
packet = self._find_packet(body)
if packet is None:
packet = _ReplayPacket(now, body, self.requests)
self.replay_history.append(packet)
return packet
def _trim_history(self, now: float) -> None:
retained_bytes = sum(len(entry.body) for entry in self.replay_history)
while len(self.replay_history) > REPLAY_HISTORY_PACKETS or retained_bytes > REPLAY_HISTORY_BYTES:
victim = next((entry for entry in self.replay_history if not entry.originals
and not (entry.retired and entry.attempts < REPLAY_MAX_ATTEMPTS
and now - entry.sent_at <= REPLAY_MAX_AGE)),
self.replay_history[0])
self.replay_history.remove(victim)
retained_bytes -= len(victim.body)
async def _run_request(self, body: bytes, replay: bool, packet: Optional[_ReplayPacket],
trace: Optional[_RequestTrace]) -> None:
task = asyncio.current_task()
try:
reply = await self.lane._post(body, self.channel_id, replay=replay, channel=self)
self.last_progress = time.monotonic()
if reply and not self.closed:
if packet is not None and reply[:24] != packet.reply_id:
packet.reply_id = reply[:24]
packet.attempts = 0
if self.queue.full() or self.reply_bytes + len(reply) > MAX_CHANNEL_BYTES:
raise BufferError('H2 client response queue exceeded bound')
self.reply_bytes += len(reply)
self.queue.put_nowait(reply)
if trace is not None:
trace.phase = 'complete'
except asyncio.CancelledError:
if trace is not None:
trace.phase = 'cancelled-' + trace.phase
raise
except _MTProtoTransportError as exc:
if trace is not None:
trace.phase = 'transport-error'
stats.h2_errors += 1
if not self.closed:
log.warning('[%s] H2 lane=%d channel=%d host=%s MTProto transport error=%s '
'request=%d encrypted=%d bytes=%d replay=%d; reporting to client',
self.label, self.lane.lane_id, self.channel_id, self.lane.host, exc,
trace.request if trace is not None else 0,
body[:8] != b'\x00' * 8, len(body), replay)
self._fail(exc)
except Exception as exc:
if trace is not None:
trace.phase = 'error-' + type(exc).__name__
stats.h2_errors += 1
if isinstance(exc, (httpx.HTTPError, OSError, ValueError, BufferError)):
log.warning('[%s] H2 lane=%d channel=%d request failed: %s: %s; '
'detail=[%s]; '
'closing native channel, reconnect may select another domain',
self.label, self.lane.lane_id, self.channel_id, type(exc).__name__, exc,
trace.summary(time.monotonic()) if trace is not None else '-')
else:
log.exception('[%s] H2 lane=%d channel=%d unexpected request failure',
self.label, self.lane.lane_id, self.channel_id)
self._fail(exc)
finally:
await self._finish_request(task, packet)
async def _finish_request(self, task: asyncio.Task, packet: Optional[_ReplayPacket] = None) -> None:
length = self.pending.pop(task, None)
if length is None:
return
trace = self.request_traces.pop(task, None)
if trace is not None:
trace.finished_at = time.monotonic()
self.recent_traces.append(trace)
self.pending_bytes -= length
self.pending_since.pop(task, None)
self.sent_since.pop(task, None)
self.replay_pending.discard(task)
if packet is None:
packet = next((entry for entry in self.replay_history
if task in entry.originals or entry.pending is task), None)
if packet is not None:
packet.originals.discard(task)
if packet.pending is task:
packet.pending = None
await self.lane._release(length)
async with self.capacity:
self.capacity.notify_all()
self._wake_receiver()
async def _recover(self) -> None:
while not self.closed:
wakeup = asyncio.create_task(self.recovery_wakeup.wait())
try:
# wait_for can swallow cancellation when the wakeup completes
# at the same time on Python 3.8-3.11 (CPython #86296).
await asyncio.wait((wakeup,), timeout=REPLAY_CHECK_SECONDS)
finally:
wakeup.cancel()
await asyncio.gather(wakeup, return_exceptions=True)
self.recovery_wakeup.clear()
now = time.monotonic()
self.lane.log_wait(now)
await self._make_upload_room(now)
if self.closed or self.native_backpressure:
continue
def priority(entry):
overdue = self._overdue_original(entry, now)
return (not overdue, entry.sequence if overdue else -entry.sequence)
candidates = sorted(self.replay_history, key=priority)
for entry in candidates:
if not self._replay_due(entry, now):
continue
replace = entry.pending
if replace is None and len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
replace = next((task for task in self.pending if task in self.replay_pending
and task not in self.receiving_tasks), None)
if replace is None:
continue
if replace is not None:
sent = self.sent_since.get(replace)
if (replace in self.receiving_tasks
or sent is None or now - sent < REPLAY_SLOT_SECONDS):
continue
replace.cancel()
await asyncio.gather(replace, return_exceptions=True)
self.replay_rotations += 1
await self.send(entry.body, False, replay=True)
break
async def receive(self) -> bytes:
value = await self.queue.get()
if isinstance(value, Exception):
raise value
self.reply_bytes -= len(value)
self._wake_receiver()
return value
async def close(self) -> None:
async with self.close_lock:
if self.close_done:
return
waiting = self._wait_state(time.monotonic())
if log.isEnabledFor(logging.DEBUG) and self.request_traces:
now = time.monotonic()
traces = sorted(self.request_traces.values(), key=lambda trace: trace.started)[:3]
log.debug('H2 DETAIL CLOSE lane=%d [%s]', self.lane.lane_id,
'; '.join(self._request_detail(trace, now) for trace in traces))
self._fail(ConnectionError('H2 channel closed'))
self.closed = True
tasks = list(self.pending)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
for task in list(self.pending):
await self._finish_request(task)
self.replay_pending.clear()
self.replay_history.clear()
self.sent_since.clear()
self.request_traces.clear()
self.recent_traces.clear()
self.lane.channels.discard(self)
self.close_done = True
log.debug('[%s] H2 channel=%d closed lane=%d requests=%d replays=%d rotations=%d capacity_rotations=%d up=%d down=%d '
'quickack=%d duration=%.1fs remaining_channels=%d; %s',
self.label, self.channel_id, self.lane.lane_id, self.requests,
self.replay_requests, self.replay_rotations, self.capacity_rotations, self.up, self.down, self.quick_requests,
time.monotonic() - self.started, len(self.lane.channels), waiting)
class _HttpLane:
def __init__(self, host: str, lane_id: int):
self.host, self.lane_id = host, lane_id
self.channels: Set[_HttpChannel] = set()
self.failed_until = 0.0
self.closed = False
self.tcp_connections = self.requests = self.responses = self.errors = 0
self.slow_headers = self.slow_bodies = self.replays = 0
self.max_headers_seconds = self.max_body_seconds = 0.0
self.next_wait_log = self.next_slow_log = 0.0
self.next_capacity_log = 0.0
self.capacity_rotations = 0
self.next_large_slow_log = 0.0
self.inflight = self.queued_bytes = self.max_inflight = 0
self.reply_buffer_bytes = 0
self.capacity = asyncio.Condition()
self.client = httpx.AsyncClient(
transport=H2Transport(max_streams=MAX_LANE_REQUESTS),
trust_env=False,
timeout=httpx.Timeout(REQUEST_TIMEOUT, connect=8, pool=REQUEST_TIMEOUT),
)
async def _trace(self, event: str, info: Dict) -> None:
if event == 'connection.connect_tcp.started':
self.tcp_connections += 1
stats.h2_tcp_connections += 1
log.debug('H2 lane=%d TCP_OPEN connection=%d host=%s channels=%d',
self.lane_id, self.tcp_connections, self.host, len(self.channels))
async def _preflight(self) -> None:
started = time.monotonic()
async with self.client.stream('HEAD', 'https://' + self.host + '/api',
extensions={'trace': self._trace}) as response:
if response.http_version != 'HTTP/2':
raise ValueError('CF /api preflight negotiated ' + response.http_version)
if not (200 <= response.status_code < 300 or response.status_code in (405, 501)):
raise ConnectionError('CF /api preflight HTTP %d cf_ray=%s' % (
response.status_code, response.headers.get('cf-ray', '-')))
log.debug('H2 lane=%d ready host=%s status=%d ms=%.0f',
self.lane_id, self.host, response.status_code,
(time.monotonic() - started) * 1000)
def _has_capacity(self, length: int) -> bool:
return self.inflight < MAX_LANE_REQUESTS and self.queued_bytes + length <= MAX_LANE_BYTES
async def _reserve(self, length: int, wait: bool = True) -> bool:
async with self.capacity:
if not wait and not self._has_capacity(length):
return False
if wait:
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(length)),
REQUEST_TIMEOUT)
if self.closed:
raise ConnectionError('H2 lane closed')
self.inflight += 1
self.queued_bytes += length
self.max_inflight = max(self.max_inflight, self.inflight)
return True
async def _release(self, length: int) -> None:
async with self.capacity:
self.inflight -= 1
self.queued_bytes -= length
self.capacity.notify_all()
async def _post(self, body: bytes, channel_id: int, replay: bool = False,
channel: Optional[_HttpChannel] = None) -> bytes:
self.requests += 1
self.replays += int(replay)
stats.h2_requests += 1
request_id = self.requests
started = time.monotonic()
buffered = 0
receiving = False
request_task = asyncio.current_task()
trace = channel.request_traces.get(request_task) if channel is not None else None
if trace is not None:
trace.request = request_id
trace.phase = 'connect-or-slot'
async def request_trace(event, info):
await self._trace(event, info)
if event == 'http2.send_request_body.complete' and channel is not None:
channel.sent_since[request_task] = time.monotonic()
if trace is not None:
if event == 'connection.connect_tcp.started':
trace.phase = 'connect'
elif event == 'http2.wait_for_stream.started':
trace.phase = 'stream-slot'
elif event == 'http2.send_request_headers.started':
trace.stream = info['stream_id']
trace.phase = 'upload'
elif event == 'http2.send_request_body.complete':
trace.sent_at = time.monotonic()
trace.phase = 'headers' if trace.headers_at is None else 'body'
def response_started():
nonlocal receiving
if trace is not None and trace.headers_at is None:
trace.headers_at = time.monotonic()
trace.phase = 'body'
if channel is not None and not receiving:
channel.receiving_tasks.add(request_task)
channel.last_progress = time.monotonic()
receiving = True
try:
async with self.client.stream(
'POST', 'https://' + self.host + '/api', content=body,
headers={'content-type': 'application/octet-stream', 'accept-encoding': 'identity'},
extensions={'trace': request_trace,
'h2_response_started': response_started}) as response:
headers_at = time.monotonic()
if trace is not None:
trace.cf_ray = ''.join(c for c in response.headers.get('cf-ray', '-')[:64]
if c.isascii() and (c.isalnum() or c == '-'))
self.slow_headers += int(headers_at - started >= SLOW_RESPONSE_SECONDS)
self.max_headers_seconds = max(self.max_headers_seconds, headers_at - started)
if response.http_version != 'HTTP/2':
raise ValueError('CF /api downgraded to ' + response.http_version)
if response.status_code in (403, 404, 429, 444):
raise _MTProtoTransportError(-response.status_code, 'HTTP %d cf_ray=%s' % (
response.status_code, response.headers.get('cf-ray', '-')))
if response.status_code != 200:
raise ConnectionError('CF /api HTTP %d cf_ray=%s' % (
response.status_code, response.headers.get('cf-ray', '-')))
if 'text/html' in response.headers.get('content-type', '').lower():
raise ValueError('CF /api returned HTML instead of MTProto')
response_started()
length_header = response.headers.get('content-length')
declared_length = int(length_header) if length_header is not None else None
if declared_length is not None and not 0 <= declared_length <= MAX_PACKET:
raise BufferError('H2 response Content-Length exceeds native packet bound')
content = bytearray()
async for chunk in response.aiter_bytes():
if len(content) + len(chunk) > MAX_PACKET:
raise BufferError('H2 response exceeds native packet bound')
if self.reply_buffer_bytes + len(chunk) > MAX_LANE_REPLY_BYTES:
raise _ReplyBufferFull('H2 lane response buffers exceeded bound')
self.reply_buffer_bytes += len(chunk)
buffered += len(chunk)
content.extend(chunk)
if trace is not None:
trace.received += len(chunk)
if channel is not None:
channel.last_progress = time.monotonic()
received = len(content)
if (declared_length is not None
and response.headers.get('content-encoding', 'identity') == 'identity'
and received != declared_length):
raise ValueError('incomplete H2 response body')
if content and len(content) % 4:
raise ValueError('CF /api response is not an aligned MTProto packet')
if len(content) == 4:
code, = struct.unpack('<i', content)
if code < 0:
raise _MTProtoTransportError(code, 'HTTP 200 payload')
self.responses += 1
if channel is not None:
channel.http_bytes += received
channel.small_responses += int(received <= 4096)
channel.large_responses += int(received > 4096)
finished = time.monotonic()
body_seconds = finished - headers_at
self.max_body_seconds = max(self.max_body_seconds, body_seconds)
self.slow_bodies += int(body_seconds >= SLOW_RESPONSE_SECONDS)
next_slow = self.next_large_slow_log if received > 4096 else self.next_slow_log
if finished - started >= SLOW_RESPONSE_SECONDS and finished >= next_slow:
if received > 4096:
self.next_large_slow_log = finished + DIAGNOSTIC_INTERVAL
else:
self.next_slow_log = finished + DIAGNOSTIC_INTERVAL
log.debug('H2 SLOW lane=%d channel=%d request=%d bytes=%d replay=%d '
'headers_ms=%.0f body_ms=%.0f detail=[%s]',
self.lane_id, channel_id, request_id, received, replay,
(headers_at - started) * 1000, body_seconds * 1000,
trace.summary(finished) if trace is not None else '-')
return bytes(content)
except _MTProtoTransportError:
self.errors += 1
raise
except _ReplyBufferFull:
raise
except (httpx.HTTPError, OSError, ValueError, BufferError):
self.errors += 1
self.failed_until = time.monotonic() + DOMAIN_COOLDOWN
raise
finally:
self.reply_buffer_bytes -= buffered
if receiving:
channel.receiving_tasks.discard(request_task)
def log_wait(self, now: float) -> None:
if not log.isEnabledFor(logging.DEBUG) or now < self.next_wait_log:
return
self.next_wait_log = now + 1.0
waiting = [channel for channel in self.channels
if ((channel.delivering_since is not None
and now - channel.delivering_since >= SLOW_RESPONSE_SECONDS) or any(
task not in channel.replay_pending
and task not in channel.receiving_tasks
and now - started >= SLOW_RESPONSE_SECONDS
for task, started in channel.pending_since.items()))
and now - channel.last_native_send <= REPLAY_MAX_AGE]
if waiting:
self.next_wait_log = now + DIAGNOSTIC_INTERVAL
waiting.sort(key=lambda channel: channel.channel_id)
log.debug('H2 WAIT lane=%d affected=%d inflight=%d body_buffer=%d [%s]',
self.lane_id, len(waiting), self.inflight, self.reply_buffer_bytes,
'; '.join(channel._wait_state(now) for channel in waiting[:8]))
details = sorted(((channel, trace) for channel in waiting for trace in channel.request_traces.values()
if not trace.replay and now - trace.started >= SLOW_RESPONSE_SECONDS),
key=lambda pair: pair[1].started)[:12]
if details:
log.debug('H2 DETAIL lane=%d [%s]', self.lane_id,
'; '.join(channel._request_detail(trace, now) for channel, trace in details))
def log_flow(self, now: float) -> None:
if log.isEnabledFor(logging.DEBUG) and self.channels:
channels = sorted(self.channels, key=lambda channel: channel.channel_id)
for offset in range(0, len(channels), 8):
log.debug('H2 FLOW lane=%d [%s]', self.lane_id,
'; '.join(channel._flow_state(now) for channel in channels[offset:offset + 8]))
def summary(self) -> str:
return ('lane=%d host=%s channels=%d tcp_connections=%d requests=%d replies=%d '
'errors=%d inflight=%d max_inflight=%d queued_bytes=%d reply_buffer_bytes=%d '
'replays=%d capacity_rotations=%d slow_headers=%d slow_bodies=%d max_headers_ms=%.0f max_body_ms=%.0f' % (
self.lane_id, self.host, len(self.channels), self.tcp_connections,
self.requests, self.responses, self.errors, self.inflight,
self.max_inflight, self.queued_bytes, self.reply_buffer_bytes,
self.replays, self.capacity_rotations, self.slow_headers, self.slow_bodies,
self.max_headers_seconds * 1000, self.max_body_seconds * 1000))
async def close(self) -> None:
self.closed = True
async with self.capacity:
self.capacity.notify_all()
await asyncio.gather(*(channel.close() for channel in list(self.channels)))
await self.client.aclose()
log.debug('H2 CLOSED %s', self.summary())
class CfH2Pool:
def __init__(self):
for name in ('httpx', 'httpcore', 'hpack', 'h2'):
logging.getLogger(name).setLevel(logging.WARNING)
self.lanes: Dict[str, _HttpLane] = {}
self.locks: Dict[str, asyncio.Lock] = {}
self.failed_until: Dict[str, float] = {}
self.setup_retry_after: Dict[int, float] = {}
self.next_lane = self.next_channel = 0
self.closed = False
async def open(self, dc: int, label: str) -> Optional[_HttpChannel]:
if self.closed:
raise ConnectionError('H2 pool closed')
if time.monotonic() < self.setup_retry_after.get(dc, 0):
return None
try:
return await asyncio.wait_for(self._open(dc, label), SETUP_TIMEOUT)
except asyncio.TimeoutError:
self.setup_retry_after[dc] = time.monotonic() + DOMAIN_COOLDOWN
stats.h2_errors += 1
log.warning('[%s] H2 DC%d setup timed out; using WS for %.0fs',
label, dc, DOMAIN_COOLDOWN)
return None
async def _open(self, dc: int, label: str) -> Optional[_HttpChannel]:
if self.closed:
raise ConnectionError('H2 pool closed')
if dc not in DC_DEFAULT_IPS:
log.warning('[%s] H2 unsupported DC%d -> existing WS route', label, dc)
return None
for base_domain in balancer.get_domains_for_dc(dc):
host = 'kws%d.%s' % (dc, base_domain)
async with self.locks.setdefault(host, asyncio.Lock()):
if self.closed:
raise ConnectionError('H2 pool closed')
lane = self.lanes.get(host)
if time.monotonic() < max(self.failed_until.get(host, 0),
lane.failed_until if lane else 0):
continue
if lane is None:
self.next_lane += 1
lane = _HttpLane(host, self.next_lane)
try:
await lane._preflight()
except (httpx.HTTPError, OSError, ValueError) as exc:
stats.h2_errors += 1
self.failed_until[host] = time.monotonic() + DOMAIN_COOLDOWN
log.warning('[%s] H2 lane=%d preflight failed host=%s: %s: %s',
label, lane.lane_id, host, type(exc).__name__, exc)
await lane.close()
continue
except asyncio.CancelledError:
await lane.close()
raise
if self.closed:
await lane.close()
raise ConnectionError('H2 pool closed during preflight')
self.lanes[host] = lane
self.next_channel += 1
channel = _HttpChannel(lane, self.next_channel, label)
balancer.update_domain_for_dc(dc, base_domain)
log.info('[%s] ROUTE DC%d media -> H2 lane=%d channel=%d host=%s '
'channels=%d tcp_connections=%d', label, dc, lane.lane_id,
channel.channel_id, host, len(lane.channels), lane.tcp_connections)
return channel
log.debug('[%s] H2 DC%d unavailable; using WS', label, dc)
return None
def log_stats(self) -> None:
for lane in self.lanes.values():
if lane.channels:
log.debug('H2 STATS %s', lane.summary())
def log_flow(self, now: float) -> None:
for lane in self.lanes.values():
lane.log_flow(now)
async def close(self) -> None:
self.closed = True
await asyncio.gather(*(lane.close() for lane in self.lanes.values()))
self.lanes.clear()
async def bridge_h2(reader, writer, channel: _HttpChannel, ctx, tag: bytes) -> None:
async def write_native(data):
try:
writer.write(ctx.clt_enc.update(data))
await writer.drain()
except OSError as exc:
raise _NativeDeliveryError(str(exc)) from exc
async def upload() -> None:
while True:
try:
body, quick = await _read_packet(reader, ctx.clt_dec, tag)
except OSError as exc:
raise _NativeDeliveryError(str(exc)) from exc
await channel.send(body, quick)
async def download() -> None:
while True:
body = await channel.receive()
if channel.closed:
raise _NativeDeliveryError('H2 channel closed before native response')
channel.delivering_since = time.monotonic()
try:
await write_native(_encode_reply(body, tag))
finally:
channel.delivering_since = None
channel.down += len(body)
stats.bytes_down += len(body)
channel.last_progress = time.monotonic()
channel._wake_receiver()
tasks = [asyncio.create_task(upload()), asyncio.create_task(download()),
asyncio.create_task(channel._recover())]
try:
try:
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if channel.transport_error is not None:
await write_native(_encode_reply(struct.pack('<i', channel.transport_error), tag))
channel.down += 4
stats.bytes_down += 4
else:
for task in done:
task.result()
except asyncio.IncompleteReadError:
log.debug('[%s] H2 channel=%d native EOF', channel.label, channel.channel_id)
except _NativeDeliveryError as exc:
log.debug('[%s] H2 channel=%d native closed: %s', channel.label, channel.channel_id, exc)
except (httpx.HTTPError, OSError, ValueError, BufferError, asyncio.TimeoutError) as exc:
if not channel.closed:
log.warning('[%s] H2 channel=%d bridge ended: %s: %s',
channel.label, channel.channel_id, type(exc).__name__, exc)
finally:
await channel.close()
+226
View File
@@ -0,0 +1,226 @@
import logging
import os
import string
import random
import socket as _socket
import threading
from dataclasses import dataclass, field
from typing import Dict, List
from urllib.request import Request
from .balancer import balancer
from .utils import build_github_opener
log = logging.getLogger('tg-mtproto-proxy')
CFPROXY_DOMAINS_URL = (
"https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main"
"/.github/cfproxy-domains.txt"
)
_CFPROXY_ENC: List[str] = [
'virkgj.com',
'vmmzovy.com',
'mkuosckvso.com',
'zaewayzmplad.com',
'twdmbzcm.com',
'awzwsldi.com',
'clngqrflngqin.com',
'tjacxbqtj.com',
'bxaxtxmrw.com',
'dmohrsgmohcrwb.com',
'vwbmtmoi.com',
'khgrre.com',
'ulihssf.com',
'tmhqsdqmfpmk.com',
'xwuwoqbm.com',
'orgcnunpj.com',
'zhkuldz.com',
'zypoljnslxa.com',
'efabnxaowuzs.com',
'zaftuzsftqdq.com'
]
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
def _dd(s: str) -> str:
"""Only for decoding CF proxy domains"""
if not s[-4:] == '.com':
return s
p, n = s[:-4], sum(c.isalpha() for c in s[:-4])
return ''.join(
chr((ord(c) - (97 if c > '`' else 65) - n) % 26 + (97 if c > '`' else 65))
if c.isalpha() else c for c in p
) + _S
CFPROXY_DEFAULT_DOMAINS: List[str] = [_dd(d) for d in _CFPROXY_ENC]
_CFPROXY_MIN_VALID_DOMAINS = 3
@dataclass
class ProxyConfig:
port: int = 1443
host: str = '127.0.0.1'
secret: str = field(default_factory=lambda: os.urandom(16).hex())
dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'})
buffer_size: int = 256 * 1024
pool_size: int = 4
fallback_cfproxy: bool = True
cfproxy_user_domains: List[str] = field(default_factory=list)
cfproxy_worker_domains: List[str] = field(default_factory=list)
cfproxy_h2_media: bool = True
disable_secure: bool = False
fake_tls_domain: str = ''
proxy_protocol: bool = False
force_test_dc: bool = False
@property
def h2_enabled(self) -> bool:
return (self.cfproxy_h2_media and self.fallback_cfproxy
and not self.disable_secure and not self.force_test_dc)
proxy_config = ProxyConfig()
def coerce_domain_list(value) -> List[str]:
if isinstance(value, str):
items = value.replace(',', ' ').replace(';', ' ').split()
elif isinstance(value, (list, tuple)):
items: List[str] = []
for entry in value:
if isinstance(entry, str):
items.extend(entry.replace(',', ' ').replace(';', ' ').split())
else:
return []
seen = set()
result: List[str] = []
for item in items:
item = item.strip()
if not item:
continue
key = item.lower()
if key in seen:
continue
seen.add(key)
result.append(item)
return result
def _fetch_cfproxy_domain_list() -> List[str]:
try:
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
headers={'User-Agent': 'tg-ws-proxy'})
with build_github_opener().open(req, timeout=10) as resp:
text = resp.read().decode('utf-8', errors='replace')
encoded = [
line.strip() for line in text.splitlines()
if line.strip() and not line.startswith('#')
]
return [_dd(d) for d in encoded]
except Exception as exc:
log.warning("Failed to fetch CF proxy domain list: %s", repr(exc))
return []
def _is_valid_domain(domain: str) -> bool:
if not domain or len(domain) > 253:
return False
if domain.startswith('.') or domain.endswith('.'):
return False
labels = domain.split('.')
if len(labels) < 2:
return False
for label in labels:
if not label or len(label) > 63:
return False
if label[0] == '-' or label[-1] == '-':
return False
if not all(ch.isalnum() or ch == '-' for ch in label):
return False
# TLD should contain letters and be at least 2 chars.
tld = labels[-1]
if len(tld) < 2 or not any(ch.isalpha() for ch in tld):
return False
return True
def _normalize_domain_pool(domains: List[str]) -> List[str]:
seen = set()
normalized: List[str] = []
for domain in domains:
item = domain.strip().lower()
if not _is_valid_domain(item):
continue
if item in seen:
continue
seen.add(item)
normalized.append(item)
return normalized
def refresh_cfproxy_domains() -> None:
if proxy_config.cfproxy_user_domains:
return
fetched = _fetch_cfproxy_domain_list()
pool = _normalize_domain_pool(fetched)
if len(pool) >= _CFPROXY_MIN_VALID_DOMAINS:
balancer.update_domains_list(pool)
log.info("CF proxy domain pool updated from GitHub (%d domains)", len(pool))
return
if fetched:
log.warning(
"Ignoring fetched CF proxy domains due to low-quality payload "
"(total=%d, valid=%d, required>=%d); keeping current domain pool",
len(fetched), len(pool), _CFPROXY_MIN_VALID_DOMAINS,
)
else:
log.warning(
"CF proxy domain refresh failed or empty response; "
"keeping current domain pool",
)
_refresh_stop: threading.Event = threading.Event()
def start_cfproxy_domain_refresh() -> None:
global _refresh_stop
_refresh_stop.set()
_refresh_stop = threading.Event()
stop = _refresh_stop
balancer.update_domains_list(CFPROXY_DEFAULT_DOMAINS)
def _loop():
refresh_cfproxy_domains()
while not stop.wait(timeout=3600):
refresh_cfproxy_domains()
threading.Thread(target=_loop, daemon=True, name='cfproxy-domains-refresh').start()
def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
dc_redirects: Dict[int, str] = {}
for entry in dc_ip_list:
if ':' not in entry:
err = ValueError(
f"Invalid --dc-ip format {entry!r}, expected DC:IP")
err.entry = entry
err.kind = "format"
raise err
dc_s, ip_s = entry.split(':', 1)
try:
dc_n = int(dc_s)
_socket.inet_pton(_socket.AF_INET, ip_s)
except (ValueError, OSError):
err = ValueError(f"Invalid --dc-ip {entry!r}")
err.entry = entry
err.kind = "invalid"
raise err from None
dc_redirects[dc_n] = ip_s
return dc_redirects
+84
View File
@@ -0,0 +1,84 @@
import asyncio
import logging
import signal
import socket
import sys
import threading
log = logging.getLogger('tg-mtproto-proxy')
def _replace_self_pipe(loop):
reader, writer = socket.socketpair()
try:
reader.setblocking(False)
writer.setblocking(False)
if threading.current_thread() is threading.main_thread():
previous_fd = signal.set_wakeup_fd(writer.fileno())
if previous_fd != loop._csock.fileno():
signal.set_wakeup_fd(previous_fd)
except BaseException:
reader.close()
writer.close()
raise
old_reader, old_writer = loop._ssock, loop._csock
loop._ssock, loop._csock = reader, writer
old_reader.close()
old_writer.close()
class _SelfPipeGuard:
"""Work around https://github.com/python/cpython/issues/156333"""
def __init__(self, loop):
self.loop = loop
self.read_self_pipe = loop._loop_self_reading
def __call__(self, future=None):
loop = self.loop
if future is not None:
if loop._self_reading_future is not future:
return
try:
data = future.result()
except OSError as exc:
reason = str(exc)
except BaseException:
return self.read_self_pipe(future)
else:
if data:
return self.read_self_pipe(future)
reason = 'EOF'
loop._self_reading_future = None
self._recover(reason)
return
return self.read_self_pipe(future)
def _recover(self, reason):
loop = self.loop
if loop.is_closed() or loop._self_reading_future is not None:
return
try:
_replace_self_pipe(loop)
except OSError as exc:
log.warning('Windows event loop wakeup socket recovery failed: %s; '
'retry in 1s', exc)
loop.call_later(1, self._recover, reason)
return
log.warning('Windows event loop wakeup socket lost (%s); recreated', reason)
self.read_self_pipe()
def install_self_pipe_guard(loop):
if sys.platform != 'win32' or not isinstance(loop, asyncio.ProactorEventLoop):
return
if not isinstance(loop._loop_self_reading, _SelfPipeGuard):
guard = _SelfPipeGuard(loop)
loop._loop_self_reading = guard
future = loop._self_reading_future
if future is not None:
future.remove_done_callback(guard.read_self_pipe)
future.add_done_callback(guard)
+463
View File
@@ -0,0 +1,463 @@
from __future__ import annotations
import asyncio
import hmac
import hashlib
import os
import random
import ssl
import struct
import time
import logging
from typing import Dict, FrozenSet, NamedTuple, Optional, Tuple
from .stats import stats
from .utils import set_x25519
try:
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
except ImportError:
X25519PrivateKey = None
log = logging.getLogger('tg-mtproto-proxy')
TLS_RECORD_HANDSHAKE = 0x16
TLS_RECORD_CCS = 0x14
TLS_RECORD_APPDATA = 0x17
TLS_VERSION_10 = b'\x03\x01'
TLS_VERSION_12 = b'\x03\x03'
TLS_VERSION_13 = b'\x03\x04'
CLIENT_RANDOM_OFFSET = 11
CLIENT_RANDOM_LEN = 32
SESSION_ID_OFFSET = 44
SESSION_ID_LEN = 32
TIMESTAMP_TOLERANCE = 120
TLS_APPDATA_MAX = 16384
EXT_SUPPORTED_VERSIONS = 0x002b
EXT_KEY_SHARE = 0x0033
GROUP_X25519 = 0x001d
GROUP_X25519MLKEM768 = 0x11ec
X25519_KEY_LEN = 32
X25519MLKEM768_CLIENT_SHARE_LEN = 1216
MLKEM768_CIPHERTEXT_LEN = 1088
# TLS 1.3 suites in the order a typical server picks them from the client list
CIPHER_PREFERENCE = (0x1301, 0x1303, 0x1302)
# Bounds for the single ApplicationData record that follows CCS; the low bound
# matches the smallest record telemt emits, the high one is the TLS limit
APPDATA_MIN = 64
# Without a site profile stay near the 64-byte record telemt passed DPI with,
# far from the 1900-2100 bytes of the classic MTProxy answer
FALLBACK_APPDATA_RANGE = (64, 160)
SERVER_HELLO_DELAY = (0.008, 0.024)
PROFILE_PROBE_TIMEOUT = 5
_CCS_FRAME = b'\x14\x03\x03\x00\x01\x01'
_SH_RANDOM_OFF = 11
# Size of the first encrypted record the masking site sends after ServerHello;
# set once at startup by probe_appdata_len()
_profiled_appdata_len: Optional[int] = None
class ClientHelloInfo(NamedTuple):
cipher_suites: FrozenSet[int]
key_shares: Dict[int, int] # named group -> key_exchange length
_EMPTY_HELLO_INFO = ClientHelloInfo(frozenset(), {})
def verify_client_hello(data: bytes, secret: bytes) -> Optional[Tuple[bytes, bytes, int]]:
n = len(data)
# 5 (record hdr) + 6 (hs type+len+version) + 32 (random) = 43
if n < 43:
return None
if data[0] != TLS_RECORD_HANDSHAKE:
return None
if data[5] != 0x01:
return None
client_random = bytes(data[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN])
zeroed = bytearray(data)
zeroed[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = b'\x00' * CLIENT_RANDOM_LEN
expected = hmac.new(secret, bytes(zeroed), hashlib.sha256).digest()
if not hmac.compare_digest(expected[:28], client_random[:28]):
return None
ts_xor = bytes(client_random[28 + i] ^ expected[28 + i] for i in range(4))
timestamp = struct.unpack('<I', ts_xor)[0]
now = int(time.time())
if abs(now - timestamp) > TIMESTAMP_TOLERANCE:
return None
session_id = b'\x00' * SESSION_ID_LEN
if n >= SESSION_ID_OFFSET + SESSION_ID_LEN and data[43] == 0x20:
session_id = bytes(data[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN])
return client_random, session_id, timestamp
def parse_client_hello(data: bytes) -> ClientHelloInfo:
"""Extract offered cipher suites and key_share groups from a ClientHello.
Returns empty info on malformed input so the caller falls back to the
plain X25519 / TLS_AES_128_GCM_SHA256 answer.
"""
try:
pos = 5 + 4 + 2 + CLIENT_RANDOM_LEN
pos += 1 + data[pos]
suites_len = struct.unpack_from('>H', data, pos)[0]
pos += 2
suites_end = pos + suites_len
if suites_end > len(data):
return _EMPTY_HELLO_INFO
suites = frozenset(
struct.unpack_from('>H', data, off)[0]
for off in range(pos, suites_end - 1, 2))
pos = suites_end
pos += 1 + data[pos]
ext_total = struct.unpack_from('>H', data, pos)[0]
pos += 2
ext_end = min(pos + ext_total, len(data))
key_shares: Dict[int, int] = {}
while pos + 4 <= ext_end:
ext_type, ext_len = struct.unpack_from('>HH', data, pos)
pos += 4
if ext_type == EXT_KEY_SHARE and pos + 2 <= ext_end:
shares_end = min(pos + 2 + struct.unpack_from('>H', data, pos)[0],
ext_end)
off = pos + 2
while off + 4 <= shares_end:
group, key_len = struct.unpack_from('>HH', data, off)
off += 4 + key_len
if off <= shares_end:
key_shares.setdefault(group, key_len)
pos += ext_len
except (IndexError, struct.error):
return _EMPTY_HELLO_INFO
return ClientHelloInfo(suites, key_shares)
def _select_cipher_suite(info: ClientHelloInfo) -> int:
for suite in CIPHER_PREFERENCE:
if suite in info.cipher_suites:
return suite
return CIPHER_PREFERENCE[0]
def _x25519_public_key() -> bytes:
if X25519PrivateKey is not None:
return X25519PrivateKey.generate().public_key().public_bytes_raw()
# Real public keys are field elements below 2^255; keep the top bit clear
key = bytearray(os.urandom(X25519_KEY_LEN))
key[-1] &= 0x7f
return bytes(key)
def _server_key_share(info: ClientHelloInfo) -> Tuple[int, bytes]:
# Mirror the hybrid post-quantum group when the client offers it, as
# current browsers and servers do; the client never derives keys from it
if info.key_shares.get(GROUP_X25519MLKEM768) == X25519MLKEM768_CLIENT_SHARE_LEN:
return GROUP_X25519MLKEM768, (os.urandom(MLKEM768_CIPHERTEXT_LEN)
+ _x25519_public_key())
return GROUP_X25519, _x25519_public_key()
def _appdata_len() -> int:
if _profiled_appdata_len is not None:
return _profiled_appdata_len
return random.randint(*FALLBACK_APPDATA_RANGE)
def build_server_hello(secret: bytes, client_random: bytes, session_id: bytes,
info: ClientHelloInfo = _EMPTY_HELLO_INFO) -> bytes:
"""ServerHello + CCS + one ApplicationData record.
Telegram clients parse exactly these three records and check the HMAC
over all of them, so the shape must stay fixed while sizes may vary.
"""
group, key_exchange = _server_key_share(info)
extensions = (
struct.pack('>HHH', EXT_SUPPORTED_VERSIONS, 2, 0x0304)
+ struct.pack('>HHHH', EXT_KEY_SHARE, 4 + len(key_exchange),
group, len(key_exchange))
+ key_exchange
)
body = (
TLS_VERSION_12
+ b'\x00' * 32
+ bytes([len(session_id)]) + session_id
+ struct.pack('>H', _select_cipher_suite(info))
+ b'\x00'
+ struct.pack('>H', len(extensions)) + extensions
)
message = b'\x02' + struct.pack('>I', len(body))[1:] + body
server_hello = (bytes([TLS_RECORD_HANDSHAKE]) + TLS_VERSION_12
+ struct.pack('>H', len(message)) + message)
appdata_len = _appdata_len()
app_record = (bytes([TLS_RECORD_APPDATA]) + TLS_VERSION_12
+ struct.pack('>H', appdata_len) + os.urandom(appdata_len))
response = server_hello + _CCS_FRAME + app_record
hmac_input = client_random + response
server_random = hmac.new(secret, hmac_input, hashlib.sha256).digest()
final = bytearray(response)
final[_SH_RANDOM_OFF:_SH_RANDOM_OFF + 32] = server_random
return bytes(final)
def set_profiled_appdata_len(length: Optional[int]) -> None:
global _profiled_appdata_len
if length is None:
_profiled_appdata_len = None
else:
_profiled_appdata_len = max(APPDATA_MIN, min(length, TLS_APPDATA_MAX))
def first_appdata_record_len(data: bytes) -> Optional[int]:
"""Length of the first ApplicationData record in a server's first flight.
Returns None while more data is needed, 0 if the flight cannot contain
one (alert, TLS 1.2 handshake, garbage).
"""
pos = 0
while pos + 5 <= len(data):
rtype = data[pos]
rec_len = struct.unpack_from('>H', data, pos + 3)[0]
if rtype == TLS_RECORD_APPDATA:
return rec_len
if rtype not in (TLS_RECORD_HANDSHAKE, TLS_RECORD_CCS):
return 0
pos += 5 + rec_len
return None
async def probe_appdata_len(domain: str, port: int = 443,
timeout: float = PROFILE_PROBE_TIMEOUT) -> Optional[int]:
"""Measure the first encrypted record the masking site sends after ServerHello.
Only the raw first flight is read; the TLS handshake is never completed.
"""
if not getattr(ssl, 'HAS_TLSv1_3', False):
log.warning("Fake TLS profile: local ssl lacks TLS 1.3, "
"using random record sizes")
return None
writer = None
try:
ctx = ssl.create_default_context()
set_x25519(ctx)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
ctx.set_alpn_protocols(['h2', 'http/1.1'])
outgoing = ssl.MemoryBIO()
tls = ctx.wrap_bio(ssl.MemoryBIO(), outgoing, server_hostname=domain)
try:
tls.do_handshake()
except ssl.SSLWantReadError:
pass
client_hello = outgoing.read()
# Share one deadline across connecting, sending, and reading the flight.
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
reader, writer = await asyncio.wait_for(
asyncio.open_connection(domain, port), timeout=timeout)
writer.write(client_hello)
await asyncio.wait_for(writer.drain(), timeout=deadline - loop.time())
flight = bytearray()
while True:
remaining = deadline - loop.time()
if remaining <= 0:
raise asyncio.TimeoutError
chunk = await asyncio.wait_for(
reader.read(16384), timeout=remaining)
if not chunk:
return None
flight.extend(chunk)
length = first_appdata_record_len(bytes(flight))
if length is not None:
return length or None
except Exception as exc:
log.warning("Fake TLS profile: cannot probe %s:%d: %r",
domain, port, exc)
return None
finally:
if writer is not None:
writer.close()
def wrap_tls_record(data: bytes) -> bytes:
parts = []
offset = 0
while offset < len(data):
chunk = data[offset:offset + TLS_APPDATA_MAX]
parts.append(
b'\x17\x03\x03'
+ struct.pack('>H', len(chunk))
+ chunk
)
offset += len(chunk)
return b''.join(parts)
class FakeTlsStream:
__slots__ = ('_reader', '_writer', '_read_buf', '_read_left')
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
self._reader = reader
self._writer = writer
self._read_buf = bytearray()
self._read_left = 0
async def readexactly(self, n: int) -> bytes:
while len(self._read_buf) < n:
payload = await self._read_tls_payload()
if not payload:
raise asyncio.IncompleteReadError(bytes(self._read_buf), n)
self._read_buf.extend(payload)
result = bytes(self._read_buf[:n])
del self._read_buf[:n]
return result
async def read(self, n: int) -> bytes:
if self._read_buf:
chunk = bytes(self._read_buf[:n])
del self._read_buf[:n]
return chunk
payload = await self._read_tls_payload()
if not payload:
return b''
if len(payload) > n:
self._read_buf.extend(payload[n:])
return payload[:n]
return payload
async def _read_tls_payload(self) -> bytes:
if self._read_left > 0:
data = await self._reader.read(self._read_left)
if not data:
return b''
self._read_left -= len(data)
return data
while True:
hdr = await self._reader.readexactly(5)
rtype = hdr[0]
rec_len = struct.unpack('>H', hdr[3:5])[0]
if rtype == TLS_RECORD_CCS:
if rec_len > 0:
await self._reader.readexactly(rec_len)
continue
if rtype != TLS_RECORD_APPDATA:
return b''
data = await self._reader.read(min(rec_len, 65536))
if not data:
return b''
remaining = rec_len - len(data)
if remaining > 0:
self._read_left = remaining
return data
def write(self, data: bytes) -> None:
self._writer.write(wrap_tls_record(data))
async def drain(self) -> None:
await self._writer.drain()
def close(self) -> None:
self._writer.close()
async def wait_closed(self) -> None:
await self._writer.wait_closed()
def get_extra_info(self, name, default=None):
return self._writer.get_extra_info(name, default)
@property
def transport(self):
return self._writer.transport
def is_closing(self):
return self._writer.is_closing()
async def proxy_to_masking_domain(reader, writer, initial_data: bytes,
domain: str, label: str) -> None:
try:
up_reader, up_writer = await asyncio.wait_for(
asyncio.open_connection(domain, 443), timeout=10)
except Exception as exc:
log.warning("[%s] masking: cannot connect to %s:443: %s",
label, domain, repr(exc))
return
log.debug("[%s] masking -> %s:443", label, domain)
stats.connections_masked += 1
try:
if initial_data:
up_writer.write(initial_data)
await up_writer.drain()
async def _relay(src, dst):
try:
while True:
chunk = await src.read(16384)
if not chunk:
break
dst.write(chunk)
await dst.drain()
except (ConnectionResetError, BrokenPipeError, OSError,
asyncio.CancelledError):
pass
finally:
try:
dst.close()
await dst.wait_closed()
except Exception:
pass
await asyncio.gather(
_relay(reader, up_writer),
_relay(up_reader, writer),
)
except Exception:
pass
finally:
try:
up_writer.close()
except Exception:
pass
+366
View File
@@ -0,0 +1,366 @@
from __future__ import annotations
import asyncio
import time
from collections import deque
from functools import lru_cache
import httpx
from h2.config import H2Configuration
from h2.connection import H2Connection
from h2.errors import ErrorCodes
from h2.events import (
ConnectionTerminated, DataReceived, RemoteSettingsChanged,
ResponseReceived, StreamEnded, StreamReset, WindowUpdated,
)
from h2.exceptions import H2Error, StreamClosedError
from h2.settings import SettingCodes, Settings
from .utils import create_ssl_context
STREAM_RECEIVE_WINDOW = 256 * 1024
CONNECTION_RECEIVE_WINDOW = 4 * 1024 * 1024
IO_BATCH_SIZE = 64 * 1024
@lru_cache(maxsize=1)
def _default_ssl_context():
context = create_ssl_context()
context.set_alpn_protocols(['h2'])
return context
class _ResponseStream(httpx.AsyncByteStream):
def __init__(self, connection, stream_id, read_timeout):
self.connection = connection
self.stream_id = stream_id
self.read_timeout = read_timeout
self.changed = asyncio.Event()
self.chunks = deque()
self.consumed_credit = 0
self.headers = None
self.response_started = None
self.error = None
self.ended = False
self.closed = False
self.extensions = {'http_version': b'HTTP/2', 'stream_id': stream_id}
async def wait(self):
try:
await asyncio.wait_for(self.changed.wait(), self.read_timeout)
except asyncio.TimeoutError as exc:
error = httpx.ReadTimeout('Timed out waiting for H2 stream %d' % self.stream_id)
# An idle socket can remain established after the path stops
# delivering data. Reconnect once its last active request times
# out, while preserving unrelated requests and buffered replies.
if not any(stream is not self and not stream.ended
for stream in self.connection.streams.values()):
self.connection.fail(error)
raise error from exc
def check_error(self):
if self.error is not None:
raise self.error
async def __aiter__(self):
while not self.closed:
self.changed.clear()
self.check_error()
if self.chunks:
data, credit = self.chunks.popleft()
yield data
self.connection.consumed(self, credit)
elif self.ended:
return
else:
await self.wait()
async def aclose(self):
if not self.closed:
self.closed = True
self.chunks.clear()
self.connection.release(self)
self.changed.set()
class _Connection:
def __init__(self, reader, writer, max_streams, write_timeout):
self.reader, self.writer = reader, writer
self.max_streams = max_streams
self.write_timeout = write_timeout
self.h2 = H2Connection(config=H2Configuration(client_side=True))
self.h2.local_settings = Settings(
client=True, initial_values={SettingCodes.ENABLE_PUSH: 0,
SettingCodes.INITIAL_WINDOW_SIZE: STREAM_RECEIVE_WINDOW})
self.streams = {}
self.changed = asyncio.Event()
self.write_lock = asyncio.Lock()
self.settings_received = False
self.error = None
self.h2.initiate_connection()
self.h2.increment_flow_control_window(CONNECTION_RECEIVE_WINDOW - 65535)
self.flush()
self.reader_task = asyncio.create_task(self.read_loop())
def get_extra_info(self, name):
return self.writer.get_extra_info(name)
def flush(self):
data = self.h2.data_to_send()
if data:
self.writer.write(data)
return True
return False
async def drain(self):
try:
async with self.write_lock:
await asyncio.wait_for(self.writer.drain(), self.write_timeout)
except asyncio.TimeoutError as exc:
error = httpx.WriteTimeout('H2 connection write timed out')
self.fail(error)
raise error from exc
except OSError as exc:
error = httpx.WriteError(str(exc))
self.fail(error)
raise error from exc
def fail(self, error):
if self.error is None:
self.error = error
for stream in self.streams.values():
if not stream.ended:
stream.error = error
stream.changed.set()
self.changed.set()
self.writer.close()
async def read_loop(self):
try:
while self.error is None:
data = await self.reader.read(IO_BATCH_SIZE)
if not data:
raise httpx.ReadError('H2 peer closed the connection')
events = self.h2.receive_data(data)
terminated = any(isinstance(e, ConnectionTerminated) for e in events)
connection_credit = 0
for event in events:
if isinstance(event, RemoteSettingsChanged):
self.settings_received = True
self.changed.set()
elif isinstance(event, WindowUpdated):
self.changed.set()
elif isinstance(event, ConnectionTerminated):
raise httpx.RemoteProtocolError('H2 GOAWAY error=%s last_stream=%s' % (
event.error_code, event.last_stream_id))
else:
stream = self.streams.get(getattr(event, 'stream_id', None))
if isinstance(event, DataReceived):
if event.flow_controlled_length and not terminated:
connection_credit += event.flow_controlled_length
if stream is not None:
if event.data:
stream.chunks.append((event.data, event.flow_controlled_length))
elif not terminated:
self.consumed(stream, event.flow_controlled_length)
elif stream is not None:
if isinstance(event, ResponseReceived):
stream.headers = event.headers
if stream.response_started is not None:
stream.response_started()
elif isinstance(event, StreamEnded):
stream.ended = True
elif isinstance(event, StreamReset):
stream.error = httpx.RemoteProtocolError(
'H2 stream %d reset: %s' % (event.stream_id, event.error_code))
self.changed.set()
if stream is not None:
stream.changed.set()
if connection_credit:
self.h2.increment_flow_control_window(connection_credit)
if self.flush():
await self.drain()
except asyncio.CancelledError:
self.fail(httpx.ReadError('H2 reader stopped'))
raise
except (OSError, H2Error, httpx.HTTPError) as exc:
error = exc if isinstance(exc, httpx.HTTPError) else httpx.RemoteProtocolError(str(exc))
self.fail(error)
def check_error(self):
if self.error is not None:
raise self.error
async def reserve(self, timeouts):
timeout = timeouts.get('pool')
deadline = None if timeout is None else time.monotonic() + timeout
while True:
self.changed.clear()
self.check_error()
if self.settings_received and len(self.streams) < min(
self.max_streams, self.h2.remote_settings.max_concurrent_streams):
stream_id = self.h2.get_next_available_stream_id()
stream = _ResponseStream(self, stream_id, timeouts.get('read'))
self.streams[stream_id] = stream
return stream
remaining = None if deadline is None else max(0, deadline - time.monotonic())
try:
await asyncio.wait_for(self.changed.wait(), remaining)
except asyncio.TimeoutError as exc:
raise httpx.PoolTimeout('H2 stream capacity exhausted') from exc
async def send_body(self, request, stream):
async for data in request.stream:
offset = 0
while offset < len(data):
self.changed.clear()
self.check_error()
stream.check_error()
count = min(len(data) - offset, IO_BATCH_SIZE,
self.h2.local_flow_control_window(stream.stream_id))
if count <= 0:
try:
await asyncio.wait_for(self.changed.wait(), self.write_timeout)
except asyncio.TimeoutError as exc:
raise httpx.WriteTimeout('H2 upload flow control timed out') from exc
continue
batch_end = offset + count
while offset < batch_end:
frame_end = min(batch_end, offset + self.h2.max_outbound_frame_size)
self.h2.send_data(stream.stream_id, data[offset:frame_end])
offset = frame_end
self.flush()
await self.drain()
self.h2.end_stream(stream.stream_id)
self.flush()
await self.drain()
async def request(self, request):
timeouts = request.extensions.get('timeout', {})
trace = request.extensions.get('trace')
if trace:
await trace('http2.wait_for_stream.started', {})
stream = await self.reserve(timeouts)
stream.response_started = request.extensions.get('h2_response_started')
stream_id = stream.stream_id
try:
headers = [(b':method', request.method.encode('ascii')),
(b':scheme', request.url.raw_scheme),
(b':authority', request.headers['host'].encode('ascii')),
(b':path', request.url.raw_path)]
headers.extend((k.lower(), v) for k, v in request.headers.raw if k.lower() not in (
b'host', b'connection', b'keep-alive', b'proxy-connection',
b'upgrade', b'transfer-encoding'))
self.h2.send_headers(stream_id, headers)
self.flush()
if trace:
await trace('http2.send_request_headers.started', {'stream_id': stream_id})
await self.send_body(request, stream)
if trace:
await trace('http2.send_request_body.complete', {'stream_id': stream_id})
while stream.headers is None:
stream.changed.clear()
stream.check_error()
await stream.wait()
stream.check_error()
status = next(int(v) for k, v in stream.headers if k == b':status')
return httpx.Response(status, headers=[(k, v) for k, v in stream.headers
if not k.startswith(b':')],
stream=stream, extensions=stream.extensions)
except H2Error as exc:
await stream.aclose()
raise httpx.LocalProtocolError(str(exc)) from exc
except BaseException:
await stream.aclose()
raise
def consumed(self, stream, credit):
if credit and not stream.ended and not stream.closed and self.error is None:
stream.consumed_credit += credit
if stream.chunks and stream.consumed_credit < IO_BATCH_SIZE:
return
try:
self.h2.increment_flow_control_window(stream.consumed_credit, stream.stream_id)
stream.consumed_credit = 0
self.flush()
except StreamClosedError:
pass
def release(self, stream):
self.streams.pop(stream.stream_id, None)
if self.error is None:
try:
self.h2.reset_stream(stream.stream_id, ErrorCodes.CANCEL)
self.flush()
except StreamClosedError:
pass
except OSError as exc:
self.fail(httpx.WriteError(str(exc)))
self.changed.set()
async def aclose(self):
self.fail(httpx.ReadError('H2 connection closed'))
self.reader_task.cancel()
await asyncio.gather(self.reader_task, return_exceptions=True)
try:
await asyncio.wait_for(self.writer.wait_closed(), 2)
except (OSError, asyncio.TimeoutError):
pass
class H2Transport(httpx.AsyncBaseTransport):
def __init__(self, ssl_context=None, max_streams=64):
if ssl_context is None:
self.ssl_context = _default_ssl_context()
else:
self.ssl_context = ssl_context
self.ssl_context.set_alpn_protocols(['h2'])
self.max_streams = max_streams
self.connection = None
self.origin = None
self.connect_lock = asyncio.Lock()
self.closed = False
async def handle_async_request(self, request):
origin = (request.url.host, request.url.port or 443)
if request.url.scheme != 'https' or self.origin not in (None, origin):
raise httpx.UnsupportedProtocol('CF H2 transport requires a single HTTPS origin')
async with self.connect_lock:
if self.closed:
raise httpx.ConnectError('H2 transport closed')
self.origin = origin
if self.connection is None or self.connection.error is not None:
if self.connection is not None:
await self.connection.aclose()
timeouts = request.extensions.get('timeout', {})
trace = request.extensions.get('trace')
if trace:
await trace('connection.connect_tcp.started', {})
writer = None
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(
*origin, ssl=self.ssl_context, server_hostname=origin[0]),
timeouts.get('connect'))
if writer.get_extra_info('ssl_object').selected_alpn_protocol() != 'h2':
raise httpx.RemoteProtocolError('CF origin did not negotiate h2')
self.connection = _Connection(reader, writer, self.max_streams, timeouts.get('write'))
if trace:
await trace('connection.start_tls.complete', {'return_value': self.connection})
except BaseException as exc:
if self.connection is not None:
self.connection.fail(httpx.ConnectError('H2 connection setup interrupted'))
if writer is not None:
writer.close()
if isinstance(exc, asyncio.TimeoutError):
raise httpx.ConnectTimeout('H2 TLS connect timed out') from exc
if isinstance(exc, OSError):
raise httpx.ConnectError(str(exc)) from exc
raise
connection = self.connection
return await connection.request(request)
async def aclose(self):
self.closed = True
async with self.connect_lock:
if self.connection is not None:
await self.connection.aclose()
+88
View File
@@ -0,0 +1,88 @@
import logging
import time
from collections import defaultdict
log = logging.getLogger('tg-mtproto-proxy')
class WsActivity:
active = set()
def __init__(self, label, dc):
self.label, self.dc = label, dc
self.started = time.monotonic()
self.native_up = self.ws_up = self.ws_down = self.native_down = 0
self.first_input = self.first_reply = self.last_rx = self.last_tx = None
self.awaiting_rx = self.sending = self.writing_native = None
self.max_rx_gap = self.max_send = self.max_native_write = 0.0
self.sample = (self.started, 0, 0, 0, 0)
self.active.add(self)
log.debug('[%s] WS OPEN %s', self.label, self.dc)
def input(self, length):
self.native_up += length
if self.first_input is None:
self.first_input = time.monotonic()
def send_started(self):
self.sending = time.monotonic()
def sent(self, length):
now = time.monotonic()
self.ws_up += length
if self.sending is not None:
self.max_send = max(self.max_send, now - self.sending)
if self.awaiting_rx is None and (self.last_rx is None or self.last_rx < self.sending):
self.awaiting_rx = self.sending
self.last_tx, self.sending = now, None
def received(self, length):
now = time.monotonic()
self.ws_down += length
self.last_rx = now
if self.awaiting_rx is not None:
self.max_rx_gap = max(self.max_rx_gap, now - self.awaiting_rx)
if self.first_reply is None and self.first_input is not None:
self.first_reply = now - self.first_input
self.awaiting_rx = None
self.writing_native = now
def delivered(self, length):
self.native_down += length
if self.writing_native is not None:
self.max_native_write = max(self.max_native_write, time.monotonic() - self.writing_native)
self.writing_native = None
def summary(self, now):
current = (now, self.native_up, self.ws_up, self.ws_down, self.native_down)
previous, self.sample = self.sample, current
return ('client=%s window_ms=%.0f native_up=%d ws_up=%d ws_down=%d native_down=%d '
'rx_gap_ms=%.0f last_rx_ms=%.0f ws_write_ms=%.0f native_write_ms=%.0f' % (
self.label, (now - previous[0]) * 1000,
*(current[index] - previous[index] for index in range(1, 5)),
0 if self.awaiting_rx is None else (now - self.awaiting_rx) * 1000,
-1 if self.last_rx is None else (now - self.last_rx) * 1000,
0 if self.sending is None else (now - self.sending) * 1000,
0 if self.writing_native is None else (now - self.writing_native) * 1000))
def close(self):
now = time.monotonic()
self.active.discard(self)
log.debug('WS END %s %s first_reply_ms=%.0f max_rx_gap_ms=%.0f '
'max_ws_write_ms=%.0f max_native_write_ms=%.0f',
self.dc, self.summary(now),
-1 if self.first_reply is None else self.first_reply * 1000,
self.max_rx_gap * 1000, self.max_send * 1000, self.max_native_write * 1000)
def log_ws_flow(now):
if not log.isEnabledFor(logging.DEBUG):
return
groups = defaultdict(list)
for activity in WsActivity.active:
groups[activity.dc].append(activity)
for dc, activities in sorted(groups.items()):
activities.sort(key=lambda item: item.label)
for offset in range(0, len(activities), 8):
log.debug('WS FLOW %s [%s]', dc,
'; '.join(item.summary(now) for item in activities[offset:offset + 8]))
+386
View File
@@ -0,0 +1,386 @@
import asyncio
import logging
import random
import time
from collections import deque
from urllib.parse import urlencode
from typing import Dict, List, Optional, Tuple
from .raw_websocket import RawWebSocket, WsHandshakeError
from .stats import stats
from .config import proxy_config
from .utils import ws_domains, DC_DEFAULT_IPS, WS_PATH, WS_PATH_TEST
log = logging.getLogger('tg-mtproto-proxy')
class _WsPool:
WS_POOL_MAX_AGE = 120.0
WS_POOL_CHECK_INTERVAL = 5.0
REFILL_BACKOFF_INITIAL = 1.0
REFILL_BACKOFF_MAX = 3600.0
def __init__(self):
self._idle: Dict[Tuple[int, bool, bool], deque] = {}
self._refilling: Dict[Tuple[int, bool, bool], asyncio.Task] = {}
self._rotating: Dict[Tuple[int, bool, bool], asyncio.Task] = {}
self._refill_failures: Dict[Tuple[int, bool, bool], int] = {}
self._refill_after: Dict[Tuple[int, bool, bool], float] = {}
self.try_fronting_first = False
async def get(self, dc: int, is_media: bool, *, is_test_dc: bool = False
) -> Optional[RawWebSocket]:
target_ip = proxy_config.dc_redirects.get(dc)
if not target_ip or proxy_config.pool_size <= 0:
return None
key = (dc, is_media, is_test_dc)
domains = ws_domains(dc, is_media)
now = time.monotonic()
bucket = self._idle.get(key)
if bucket is None:
bucket = deque()
self._idle[key] = bucket
while bucket:
ws, created = bucket.popleft()
age = now - created
if self._is_stale(ws, created, now):
asyncio.create_task(self._quiet_close(ws))
continue
stats.pool_hits += 1
log.debug("WS pool hit DC%d%s%s (age=%.1fs, left=%d)",
dc, 't' if is_test_dc else '', 'm' if is_media else '', age, len(bucket))
self._schedule_refill(key, target_ip, domains)
return ws
stats.pool_misses += 1
self._schedule_refill(key, target_ip, domains)
return None
def _is_stale(self, ws, created, now):
return (now - created >= self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing() or ws.reader.at_eof()
or ws.reader.exception() is not None)
def _schedule_refill(self, key, target_ip, domains):
if proxy_config.pool_size <= 0:
return
self._schedule_rotation(key, target_ip, domains)
if (key in self._refilling
or time.monotonic() < self._refill_after.get(key, 0)):
return
self._refilling[key] = asyncio.create_task(
self._refill(key, target_ip, domains))
async def _refill(self, key, target_ip, domains):
dc, is_media, is_test_dc = key
tasks = []
adopted = set()
try:
bucket = self._idle.setdefault(key, deque())
needed = proxy_config.pool_size - len(bucket)
if needed <= 0:
return
connected = 0
tasks = [asyncio.create_task(
self._connect_one(target_ip, domains,
WS_PATH_TEST if is_test_dc else WS_PATH))
for _ in range(needed)]
for t in asyncio.as_completed(tasks):
try:
ws = await t
if ws:
if self._refilling.get(key) is not asyncio.current_task():
return
bucket.append((ws, time.monotonic()))
adopted.add(ws)
connected += 1
except Exception as exc:
log.debug("WS pool connect failed: %r", exc)
if connected:
self._refill_failures.pop(key, None)
self._refill_after.pop(key, None)
else:
failures = self._refill_failures.get(key, 0) + 1
self._refill_failures[key] = failures
delay = min(
self.REFILL_BACKOFF_INITIAL
* (2 ** min(failures - 1, 12)),
self.REFILL_BACKOFF_MAX,
)
self._refill_after[key] = time.monotonic() + delay
log.info(
"WS pool refill failed for DC%d%s%s, retry in %.0fs",
dc, 't' if is_test_dc else '', 'm' if is_media else '', delay)
log.debug("WS pool refilled DC%d%s%s: %d ready",
dc, 't' if is_test_dc else '', 'm' if is_media else '', len(bucket))
finally:
for task in tasks:
if not task.done():
task.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
for ws in results:
if ws is not None and not isinstance(ws, BaseException) and ws not in adopted:
await self._quiet_close(ws)
if self._refilling.get(key) is asyncio.current_task():
self._refilling.pop(key, None)
def _schedule_rotation(self, key, target_ip, domains):
if key in self._rotating:
return
self._rotating[key] = asyncio.create_task(
self._rotate(key, target_ip, domains))
async def _rotate(self, key, target_ip, domains):
dc, is_media, is_test_dc = key
try:
while proxy_config.pool_size > 0:
bucket = self._idle.setdefault(key, deque())
now = time.monotonic()
expired = []
ready = deque()
while bucket:
ws, created = bucket.popleft()
if self._is_stale(ws, created, now):
expired.append(ws)
else:
ready.append((ws, created))
bucket.extend(ready)
if expired:
for ws in expired:
asyncio.create_task(self._quiet_close(ws))
log.debug(
"WS pool rotated DC%d%s%s: %d stale, %d ready",
dc, 't' if is_test_dc else '', 'm' if is_media else '',
len(expired), len(bucket))
if len(bucket) < proxy_config.pool_size:
self._schedule_refill(key, target_ip, domains)
wake_at = now + self.WS_POOL_CHECK_INTERVAL
if bucket:
wake_at = min(wake_at, min(
created + self.WS_POOL_MAX_AGE for _, created in bucket))
if key not in self._refilling and self._refill_after.get(key, 0) > now:
wake_at = min(wake_at, self._refill_after[key])
await asyncio.sleep(max(0, wake_at - time.monotonic()))
finally:
if self._rotating.get(key) is asyncio.current_task():
self._rotating.pop(key, None)
async def _connect_one(self, target_ip, domains, path=WS_PATH) -> Optional[RawWebSocket]:
for domain in domains:
modes = (True, False) if self.try_fronting_first else (False, True)
for fronted in modes:
try:
ws = await RawWebSocket.connect(
target_ip, domain, timeout=7 if fronted else 8, path=path,
sni="sprinthost.ru" if fronted else None)
except Exception as exc:
stats.ws_errors += 1
log.debug("WS pool connect %s%s via %s (fronting=%s): %r",
domain, path, target_ip, fronted, exc)
continue
self.try_fronting_first = fronted
if fronted:
stats.connections_fronting += 1
log.debug("WS pool connected %s%s via %s (fronting=%s)",
domain, path, target_ip, fronted)
return ws
return None
async def _quiet_close(self, ws):
try:
await ws.close()
except Exception:
pass
async def warmup(self):
for dc, target_ip in proxy_config.dc_redirects.items():
if target_ip is None:
continue
for is_media in (False, True):
domains = ws_domains(dc, is_media)
key = (dc, is_media, proxy_config.force_test_dc)
self._schedule_refill(key, target_ip, domains)
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
def reset(self):
loop = asyncio.get_running_loop()
for task in list(self._rotating.values()) + list(self._refilling.values()):
if not task.done() and task.get_loop() is loop:
task.cancel()
for bucket in self._idle.values():
for ws, _ in bucket:
try:
ws.writer.close()
except Exception as exc:
log.debug("WS pool close failed: %r", exc)
self._idle.clear()
self._refilling.clear()
self._rotating.clear()
self._refill_failures.clear()
self._refill_after.clear()
self.try_fronting_first = False
async def close(self):
tasks = list(self._rotating.values()) + list(self._refilling.values())
self.reset()
await asyncio.gather(*tasks, return_exceptions=True)
class _CfWorkerPool:
WS_POOL_MAX_AGE = 100.0
PER_DC_LIMIT = 1
def __init__(self):
self._idle: Dict[int, deque] = {}
self._refilling: Dict[int, asyncio.Task] = {}
self._exhausted_until: Dict[str, float] = {}
async def get(self, dc: int, fallback_dst: str,
worker_domains: List[str]
) -> Optional[Tuple[RawWebSocket, str]]:
now = time.monotonic()
bucket = self._idle.get(dc)
if bucket is None:
bucket = deque()
self._idle[dc] = bucket
while bucket:
ws, created, worker_domain = bucket.popleft()
age = now - created
if (age > self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing()):
asyncio.create_task(self._quiet_close(ws))
continue
stats.cf_pool_hits += 1
log.debug(
"CF worker pool hit DC%d via %s (age=%.1fs, left=%d)",
dc, worker_domain, age, len(bucket))
self._schedule_refill(dc, fallback_dst, worker_domains)
return ws, worker_domain
stats.cf_pool_misses += 1
return None
def _schedule_refill(self, dc, fallback_dst, worker_domains):
if dc in self._refilling:
return
self._refilling[dc] = asyncio.create_task(self._refill(
dc, fallback_dst, list(worker_domains)))
async def _refill(self, dc, fallback_dst, worker_domains):
try:
bucket = self._idle.setdefault(dc, deque())
target_size = min(proxy_config.pool_size, self.PER_DC_LIMIT)
needed = target_size - len(bucket)
if needed <= 0:
return
for _ in range(needed):
connected = await self._connect_one(
worker_domains, fallback_dst, dc)
if connected is None:
break
ws, worker_domain = connected
if self._refilling.get(dc) is not asyncio.current_task():
await self._quiet_close(ws)
return
bucket.append((ws, time.monotonic(), worker_domain))
log.debug("CF worker pool refilled DC%d: %d ready",
dc, len(bucket))
finally:
if self._refilling.get(dc) is asyncio.current_task():
self._refilling.pop(dc, None)
async def _connect_one(self, worker_domains, fallback_dst, dc):
query = urlencode({
'dst': fallback_dst,
'dc': str(dc),
})
path = f'/apiws?{query}'
for worker_domain in self.available_domains(worker_domains):
try:
ws = await RawWebSocket.connect(
worker_domain, worker_domain, timeout=8, path=path,
secure=not proxy_config.disable_secure)
return ws, worker_domain
except Exception as exc:
self.report_failure(worker_domain, exc)
return None
def available_domains(self, worker_domains: List[str]) -> List[str]:
now = time.time()
domains = []
for domain in worker_domains:
if domain in domains:
continue
exhausted_until = self._exhausted_until.get(domain, 0)
if exhausted_until > now:
continue
if exhausted_until:
self._exhausted_until.pop(domain, None)
domains.append(domain)
random.shuffle(domains)
return domains
def report_failure(self, worker_domain: str, exc: Exception) -> None:
return # TODO: check status code after daily limit reached
if not isinstance(exc, WsHandshakeError) or exc.status_code != 429:
return
now = time.time()
if self._exhausted_until.get(worker_domain, 0) > now:
return
exhausted_until = now + (86400 - (now % 86400))
self._exhausted_until[worker_domain] = exhausted_until
log.warning(
"CF worker %s reached its request limit, disabled for %d seconds", worker_domain, int(exhausted_until - now))
async def _quiet_close(self, ws):
try:
await ws.close()
except Exception:
pass
async def warmup(self):
cf_fallbacks = {
dc: ip for dc, ip in DC_DEFAULT_IPS.items()
if dc not in proxy_config.dc_redirects
}
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
return
worker_domains = list(proxy_config.cfproxy_worker_domains)
for dc, fallback_dst in cf_fallbacks.items():
self._schedule_refill(dc, fallback_dst, worker_domains)
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
def reset(self):
loop = asyncio.get_running_loop()
for task in self._refilling.values():
if not task.done() and task.get_loop() is loop:
task.cancel()
for bucket in self._idle.values():
for ws, _, _ in bucket:
try:
ws.writer.close()
except Exception as exc:
log.debug("CF worker pool close failed: %r", exc)
self._idle.clear()
self._refilling.clear()
self._exhausted_until.clear()
async def close(self):
tasks = list(self._refilling.values())
self.reset()
await asyncio.gather(*tasks, return_exceptions=True)
ws_pool = _WsPool()
cf_worker_pool = _CfWorkerPool()
+297
View File
@@ -0,0 +1,297 @@
import os
import logging
import base64
import struct
import asyncio
import socket as _socket
from typing import List, Optional, Tuple
from .config import proxy_config
from .utils import create_ssl_context
log = logging.getLogger('tg-mtproto-proxy')
_st_BB = struct.Struct('>BB')
_st_BBH = struct.Struct('>BBH')
_st_BBQ = struct.Struct('>BBQ')
_st_BB4s = struct.Struct('>BB4s')
_st_BBH4s = struct.Struct('>BBH4s')
_st_BBQ4s = struct.Struct('>BBQ4s')
_st_H = struct.Struct('>H')
_st_Q = struct.Struct('>Q')
_XOR_TABLES = tuple(bytes(value ^ key for value in range(256)) for key in range(256))
_ssl_ctx = create_ssl_context()
_ssl_ctx_fronting = create_ssl_context(check_hostname=False)
class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str,
headers: Optional[dict] = None, location: Optional[str] = None):
self.status_code = status_code
self.status_line = status_line
self.headers = headers or {}
self.location = location
super().__init__(f"HTTP {status_code}: {status_line}")
@property
def is_redirect(self) -> bool:
return self.status_code in (301, 302, 303, 307, 308)
def _xor_mask(data: bytes, mask: bytes) -> bytes:
if not data:
return data
n = len(data)
if n >= 512:
masked = bytearray(data)
for offset in range(4):
masked[offset::4] = masked[offset::4].translate(_XOR_TABLES[mask[offset]])
return bytes(masked)
mask_rep = (mask * (n // 4 + 1))[:n]
return (int.from_bytes(data, 'big') ^
int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
def set_sock_opts(transport, buffer_size):
sock = transport.get_extra_info('socket')
if sock is None:
return
try:
sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
except (OSError, AttributeError):
pass
try:
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVBUF, buffer_size)
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_SNDBUF, buffer_size)
except OSError:
pass
class RawWebSocket:
__slots__ = ('reader', 'writer', '_closed', '_frag')
OP_CONT = 0x0
OP_BINARY = 0x2
OP_CLOSE = 0x8
OP_PING = 0x9
OP_PONG = 0xA
MAX_MESSAGE_LEN = 16 * 1024 * 1024
def __init__(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter):
self.reader = reader
self.writer = writer
self._closed = False
self._frag = bytearray()
@staticmethod
async def connect(host: str, domain: str, timeout: float = 10.0,
path: str = '/apiws', *,
sni: Optional[str] = None, secure = True) -> 'RawWebSocket':
ssl_context = _ssl_ctx_fronting if sni else _ssl_ctx
if sni is None:
sni = domain
reader, writer = await asyncio.wait_for(
(
asyncio.open_connection(
host, 443,
ssl=ssl_context,
server_hostname=sni,
)
if secure
else asyncio.open_connection(host, 80)
),
timeout=min(timeout, 10),
)
try:
set_sock_opts(writer.transport, proxy_config.buffer_size)
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f'GET {path} HTTP/1.1\r\n'
f'Host: {domain}\r\n'
f'Upgrade: websocket\r\n'
f'Connection: Upgrade\r\n'
f'Sec-WebSocket-Key: {ws_key}\r\n'
f'Sec-WebSocket-Version: 13\r\n'
f'Sec-WebSocket-Protocol: binary\r\n'
f'\r\n'
)
writer.write(req.encode())
await writer.drain()
response_lines: list[str] = []
while True:
line = await asyncio.wait_for(reader.readline(),
timeout=timeout)
if line in (b'\r\n', b'\n', b''):
break
response_lines.append(
line.decode('utf-8', errors='replace').strip())
if not response_lines:
raise WsHandshakeError(0, 'empty response')
first_line = response_lines[0]
parts = first_line.split(' ', 2)
try:
status_code = int(parts[1]) if len(parts) >= 2 else 0
except ValueError:
status_code = 0
if status_code == 101:
return RawWebSocket(reader, writer)
headers: dict[str, str] = {}
for hl in response_lines[1:]:
if ':' in hl:
k, v = hl.split(':', 1)
headers[k.strip().lower()] = v.strip()
raise WsHandshakeError(status_code, first_line, headers,
location=headers.get('location'))
except BaseException:
writer.close()
raise
async def send(self, data: bytes):
if self._closed:
raise ConnectionError("WebSocket closed")
frame = self._build_frame(self.OP_BINARY, data, mask=True)
self.writer.write(frame)
await self.writer.drain()
async def send_batch(self, parts: List[bytes]):
if self._closed:
raise ConnectionError("WebSocket closed")
for part in parts:
self.writer.write(
self._build_frame(self.OP_BINARY, part, mask=True))
await self.writer.drain()
async def recv(self) -> Optional[bytes]:
while not self._closed:
opcode, payload, fin = await self._read_frame()
if opcode == self.OP_CLOSE:
self._closed = True
code, reason = self._parse_close(payload)
log.debug("WS OP_CLOSE from upstream: code=%s reason=%r",
code, reason)
try:
self.writer.write(self._build_frame(
self.OP_CLOSE,
payload[:2] if payload else b'', mask=True))
await self.writer.drain()
except Exception:
pass
return None
if opcode == self.OP_PING:
try:
self.writer.write(
self._build_frame(self.OP_PONG, payload, mask=True))
await self.writer.drain()
except Exception:
pass
continue
if opcode == self.OP_PONG:
continue
if opcode in (self.OP_CONT, 0x1, self.OP_BINARY):
if fin and not self._frag:
return payload
self._frag.extend(payload)
if len(self._frag) > self.MAX_MESSAGE_LEN:
raise ConnectionError(
f"WS message too large: {len(self._frag)} bytes")
if not fin:
continue
message = bytes(self._frag)
self._frag.clear()
return message
continue
return None
async def close(self):
if self._closed:
return
self._closed = True
try:
self.writer.write(
self._build_frame(self.OP_CLOSE, b'', mask=True))
await self.writer.drain()
except Exception:
pass
try:
self.writer.close()
await self.writer.wait_closed()
except Exception:
pass
_WS_CLOSE_REASONS = {
1000: 'normal', 1001: 'going_away', 1002: 'protocol_error',
1003: 'unsupported_data', 1006: 'abnormal', 1007: 'bad_data',
1008: 'policy_violation', 1009: 'too_big', 1010: 'missing_extension',
1011: 'internal_error',
}
@classmethod
def _parse_close(cls, payload: Optional[bytes]) -> Tuple[Optional[int], str]:
if not payload or len(payload) < 2:
return None, ''
try:
code = int.from_bytes(payload[:2], 'big')
text = payload[2:].decode('utf-8', errors='replace')
name = cls._WS_CLOSE_REASONS.get(code)
return code, f"{text} ({name})" if name else text
except Exception:
return None, ''
@staticmethod
def _build_frame(opcode: int, data: bytes,
mask: bool = False) -> bytes:
length = len(data)
fb = 0x80 | opcode
if not mask:
if length < 126:
return _st_BB.pack(fb, length) + data
if length < 65536:
return _st_BBH.pack(fb, 126, length) + data
return _st_BBQ.pack(fb, 127, length) + data
mask_key = os.urandom(4)
masked = _xor_mask(data, mask_key)
if length < 126:
return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked
if length < 65536:
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
async def _read_frame(self) -> Tuple[int, bytes, bool]:
hdr = await self.reader.readexactly(2)
fin = bool(hdr[0] & 0x80)
opcode = hdr[0] & 0x0F
length = hdr[1] & 0x7F
if length == 126:
length = _st_H.unpack(await self.reader.readexactly(2))[0]
elif length == 127:
length = _st_Q.unpack(await self.reader.readexactly(8))[0]
if length > self.MAX_MESSAGE_LEN:
raise ConnectionError(f"WS frame too large: {length} bytes")
if hdr[1] & 0x80:
mask_key = await self.reader.readexactly(4)
payload = await self.reader.readexactly(length)
return opcode, _xor_mask(payload, mask_key), fin
payload = await self.reader.readexactly(length)
return opcode, payload, fin
+51
View File
@@ -0,0 +1,51 @@
from .utils import human_bytes
class _Stats:
def __init__(self):
self.connections_total = 0
self.connections_active = 0
self.connections_ws = 0
self.connections_h2 = 0
self.h2_tcp_connections = 0
self.h2_requests = 0
self.h2_errors = 0
self.connections_tcp_fallback = 0
self.connections_cfproxy = 0
self.connections_fronting = 0
self.connections_bad = 0
self.connections_masked = 0
self.ws_errors = 0
self.bytes_up = 0
self.bytes_down = 0
self.pool_hits = 0
self.pool_misses = 0
self.cf_pool_hits = 0
self.cf_pool_misses = 0
def summary(self) -> str:
pool_total = self.pool_hits + self.pool_misses
pool_s = (f"{self.pool_hits}/{pool_total}"
if pool_total else "n/a")
cf_pool_total = self.cf_pool_hits + self.cf_pool_misses
cf_pool_s = (f"{self.cf_pool_hits}/{cf_pool_total}"
if cf_pool_total else "n/a")
return (f"total={self.connections_total} "
f"active={self.connections_active} "
f"ws={self.connections_ws} "
f"h2={self.connections_h2} "
f"h2_tcp={self.h2_tcp_connections} "
f"h2_req={self.h2_requests} "
f"h2_err={self.h2_errors} "
f"tcp_fb={self.connections_tcp_fallback} "
f"cf={self.connections_cfproxy} "
f"front={self.connections_fronting} "
f"bad={self.connections_bad} "
f"masked={self.connections_masked} "
f"err={self.ws_errors} "
f"pool={pool_s} "
f"cf_pool={cf_pool_s} "
f"up={human_bytes(self.bytes_up)} "
f"down={human_bytes(self.bytes_down)}")
stats = _Stats()
+563 -1115
View File
File diff suppressed because it is too large. Load diff
+166
View File
@@ -0,0 +1,166 @@
import socket as _socket
import urllib.request
import http.client
import ssl
import logging
import os
import re
import sys
import certifi
from typing import Optional, Dict, List
from urllib.request import Request
ZERO_64 = b'\x00' * 64
HANDSHAKE_LEN = 64
SKIP_LEN = 8
PREKEY_LEN = 32
KEY_LEN = 32
IV_LEN = 16
PROTO_TAG_POS = 56
DC_IDX_POS = 60
PROTO_TAG_ABRIDGED = b'\xef\xef\xef\xef'
PROTO_TAG_INTERMEDIATE = b'\xee\xee\xee\xee'
PROTO_TAG_SECURE = b'\xdd\xdd\xdd\xdd'
PROTO_ABRIDGED_INT = 0xEFEFEFEF
PROTO_INTERMEDIATE_INT = 0xEEEEEEEE
PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD
RESERVED_FIRST_BYTES = {0xEF}
RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
b'\x47\x45\x54\x20', b'\xee\xee\xee\xee',
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02',
b'\x4F\x50\x54\x49'}
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
_GITHUB_IPS: Dict[str, str] = {
"release-assets.githubusercontent.com": "185.199.109.133",
"raw.githubusercontent.com": "185.199.109.133",
}
DC_DEFAULT_IPS: Dict[int, str] = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100'
}
DC_TEST_IPS: Dict[int, str] = {
1: '149.154.175.10',
2: '149.154.167.40',
3: '149.154.175.117',
}
WS_PATH = '/apiws'
WS_PATH_TEST = WS_PATH + '_test'
def ws_domains(dc: int, is_media: bool) -> List[str]:
if dc == 203:
dc = 2
if not is_media:
return [f'kws{dc}.web.telegram.org']
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
def human_bytes(n: int) -> str:
for unit in ('B', 'KB', 'MB', 'GB'):
if abs(n) < 1024:
return f"{n:.1f}{unit}"
n /= 1024 # type: ignore
return f"{n:.1f}TB"
def get_link_host(host: str) -> Optional[str]:
if host == '0.0.0.0':
try:
with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s:
_s.connect(('8.8.8.8', 80))
link_host = _s.getsockname()[0]
except OSError:
link_host = '127.0.0.1'
return link_host
else:
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):
def https_open(self, req: Request):
host = req.host.split(":")[0]
ip = _GITHUB_IPS.get(host)
if not ip:
return super().https_open(req)
pinned = ip
class _Conn(http.client.HTTPSConnection):
def connect(self):
self.sock = _socket.create_connection(
(pinned, self.port or 443),
self.timeout,
self.source_address,
)
if self._tunnel_host:
self._tunnel()
self.sock = self._context.wrap_socket(
self.sock, server_hostname=self._tunnel_host or self.host
)
try:
return self.do_open(_Conn, req, context=self._context)
except Exception:
return super().https_open(req)
def set_x25519(context: ssl.SSLContext) -> None:
try:
context.set_ecdh_curve('X25519')
except Exception:
pass
def create_ssl_context(*, check_hostname: bool = True) -> ssl.SSLContext:
bundle_dir = getattr(sys, '_MEIPASS', None)
cafile = (os.path.join(bundle_dir, 'certifi', 'cacert.pem')
if bundle_dir else certifi.where())
context = ssl.create_default_context(cafile=cafile)
context.load_default_certs()
set_x25519(context)
context.check_hostname = check_hostname
return context
def build_github_opener() -> urllib.request.OpenerDirector:
return urllib.request.build_opener(
_PinnedHTTPSHandler(context=create_ssl_context()))
+17 -5
View File
@@ -7,7 +7,7 @@ name = "tg-ws-proxy"
dynamic=["version"]
description = "Telegram Desktop WebSocket Bridge Proxy"
readme = "README.md"
readme = "docs/README.md"
requires-python = ">=3.8"
license = { name = "MIT", file = "LICENSE" }
@@ -22,7 +22,7 @@ keywords = [
"proxy",
"bypass",
"websocket",
"socks5",
"mtproto",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -36,6 +36,8 @@ classifiers = [
dependencies = [
"pyperclip==1.9.0",
"certifi",
"httpx[http2]==0.28.1",
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
@@ -45,9 +47,9 @@ dependencies = [
"cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'",
"Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'",
"customtkinter==5.2.2; platform_system != 'Darwin'",
"pystray==0.19.5; platform_system != 'Darwin'",
"rumps==0.4.0; platform_system == 'Darwin'",
"customtkinter==5.2.2",
"pystray==0.19.5",
"pyobjc-framework-Cocoa>=9.0; platform_system == 'Darwin'",
"Pillow==12.1.0; platform_system == 'Darwin'",
]
@@ -71,3 +73,13 @@ packages = ["proxy", "ui", "utils"]
[tool.hatch.version]
path = "proxy/__init__.py"
[tool.ruff]
target-version = "py38"
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "B", "C4"]
ignore = ["F403", "F405", "B023"]
[tool.ruff.lint.per-file-ignores]
"macos.py" = ["E402"]
View File
Whitespace-only changes.
+259
View File
@@ -0,0 +1,259 @@
import os
import asyncio
import time
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from proxy._aes import Cipher, algorithms, modes
from proxy.bridge import MsgSplitter, bridge_ws_reencrypt
from proxy.network_debug import WsActivity, log_ws_flow
from proxy.utils import (
PROTO_ABRIDGED_INT,
PROTO_INTERMEDIATE_INT,
PROTO_PADDED_INTERMEDIATE_INT,
)
def _relay_init() -> bytes:
return os.urandom(64)
def _encryptor(relay_init: bytes):
enc = Cipher(
algorithms.AES(relay_init[8:40]), modes.CTR(relay_init[40:56])
).encryptor()
enc.update(b'\x00' * 64)
return enc
def _abridged(payload: bytes) -> bytes:
words = len(payload) // 4
if words < 0x7F:
return bytes([words]) + payload
return b'\x7f' + words.to_bytes(3, 'little') + payload
def _intermediate(payload: bytes) -> bytes:
return len(payload).to_bytes(4, 'little') + payload
class MsgSplitterTest(unittest.TestCase):
def _split(self, proto_int, packets, chunk_sizes=None):
relay_init = _relay_init()
splitter = MsgSplitter(proto_int)
enc = _encryptor(relay_init)
plain = b''.join(packets)
stream = enc.update(plain)
chunks = []
if chunk_sizes is None:
chunks = [stream]
else:
offset = 0
for size in chunk_sizes:
chunks.append(stream[offset:offset + size])
offset += size
if offset < len(stream):
chunks.append(stream[offset:])
parts = []
offset = 0
for chunk in chunks:
parts.extend(splitter.split(chunk, plain[offset:offset + len(chunk)]))
offset += len(chunk)
return splitter, stream, parts
def test_abridged_stream_splits_into_packets(self):
packets = [_abridged(b'a' * 4), _abridged(b'b' * 16), _abridged(b'c' * 40)]
_, stream, parts = self._split(PROTO_ABRIDGED_INT, packets)
self.assertEqual(len(parts), 3)
self.assertEqual(b''.join(parts), stream)
self.assertEqual([len(p) for p in parts], [5, 17, 41])
def test_intermediate_stream_splits_into_packets(self):
packets = [_intermediate(b'a' * 8), _intermediate(b'b' * 12)]
_, stream, parts = self._split(PROTO_INTERMEDIATE_INT, packets)
self.assertEqual(len(parts), 2)
self.assertEqual(b''.join(parts), stream)
def test_padded_intermediate_uses_intermediate_framing(self):
packets = [_intermediate(b'z' * 20)]
_, stream, parts = self._split(PROTO_PADDED_INTERMEDIATE_INT, packets)
self.assertEqual(parts, [stream])
def test_partial_packet_is_buffered_until_complete(self):
packets = [_abridged(b'a' * 20)]
_, stream, parts = self._split(
PROTO_ABRIDGED_INT, packets, chunk_sizes=[1] * (len(packets[0]) - 1)
)
self.assertEqual(parts, [stream])
def test_split_preserves_stream_across_arbitrary_chunking(self):
packets = [_intermediate(bytes([i]) * 16) for i in range(8)]
_, stream, parts = self._split(
PROTO_INTERMEDIATE_INT, packets, chunk_sizes=[7, 3, 50, 11]
)
self.assertEqual(b''.join(parts), stream)
self.assertEqual(len(parts), 8)
def test_empty_chunk_yields_nothing(self):
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
self.assertEqual(splitter.split(b'', b''), [])
def test_zero_length_packet_disables_splitting(self):
relay_init = _relay_init()
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
enc = _encryptor(relay_init)
plain = (0).to_bytes(4, 'little') + b'tail'
stream = enc.update(plain)
parts = splitter.split(stream, plain)
self.assertEqual(parts, [stream])
self.assertEqual(splitter.split(b'raw', b'raw'), [b'raw'])
def test_flush_returns_buffered_tail_once(self):
relay_init = _relay_init()
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
enc = _encryptor(relay_init)
plain = _intermediate(b'x' * 32)[:10]
partial = enc.update(plain)
self.assertEqual(splitter.split(partial, plain), [])
self.assertEqual(splitter.flush(), [partial])
self.assertEqual(splitter.flush(), [])
def test_extended_and_quick_ack_headers_can_cross_chunk_boundaries(self):
for proto, frame in ((PROTO_ABRIDGED_INT, _abridged),
(PROTO_INTERMEDIATE_INT, _intermediate),
(PROTO_PADDED_INTERMEDIATE_INT, _intermediate)):
for size in (16, 512, 65536):
packet = bytearray(frame(b'p' * size))
packet[0 if proto == PROTO_ABRIDGED_INT else 3] |= 0x80
packets = [bytes(packet), frame(b't' * 8)]
for header_split in (1, 2, 3):
with self.subTest(proto=proto, size=size, header_split=header_split):
_, stream, parts = self._split(
proto, packets, chunk_sizes=[header_split, 1, 11, size - 8])
self.assertEqual(parts, [stream[:len(packet)], stream[len(packet):]])
class WsReencryptTest(unittest.IsolatedAsyncioTestCase):
async def test_relay_preserves_encrypted_packets_and_incomplete_tail(self):
for proto, frame in ((PROTO_ABRIDGED_INT, _abridged),
(PROTO_INTERMEDIATE_INT, _intermediate),
(PROTO_PADDED_INTERMEDIATE_INT, _intermediate)):
with self.subTest(proto=proto):
packets = [frame(b'a' * 8), frame(b'b' * 16),
frame(b'c' * 70000), frame(b'd' * 32)[:7]]
client_init, relay_init = _relay_init(), _relay_init()
reader = asyncio.StreamReader()
reader.feed_data(_encryptor(client_init).update(b''.join(packets)))
reader.feed_eof()
sent = []
async def send(data):
sent.append(data)
async def send_batch(parts):
sent.extend(parts)
async def recv():
await asyncio.Future()
ws = SimpleNamespace(send=send, send_batch=send_batch, recv=recv,
close=AsyncMock())
writer = Mock(wait_closed=AsyncMock())
ctx = SimpleNamespace(clt_dec=_encryptor(client_init), tg_enc=_encryptor(relay_init))
await asyncio.wait_for(bridge_ws_reencrypt(
reader, writer, ws, 'framing-test', ctx, splitter=MsgSplitter(proto)), 1)
decoder = _encryptor(relay_init)
self.assertEqual([decoder.update(part) for part in sent], packets)
ws.close.assert_awaited_once()
class WsDiagnosticsTest(unittest.IsolatedAsyncioTestCase):
async def test_flow_distinguishes_upstream_send_from_native_backpressure(self):
class Cipher:
def update(self, data):
return data
class Socket:
def __init__(self):
self.queue = asyncio.Queue()
self.sending = asyncio.Event()
self.release = asyncio.Event()
self.sent = []
async def send(self, data):
self.sent.append(data)
self.sending.set()
await self.release.wait()
async def recv(self):
return await self.queue.get()
async def close(self):
pass
class Writer:
def __init__(self):
self.data = bytearray()
self.written = asyncio.Event()
self.release = asyncio.Event()
def write(self, data):
self.data.extend(data)
self.written.set()
async def drain(self):
await self.release.wait()
def close(self):
pass
async def wait_closed(self):
pass
ws, writer = Socket(), Writer()
reader = asyncio.StreamReader()
ctx = SimpleNamespace(clt_dec=Cipher(), clt_enc=Cipher(), tg_dec=Cipher(), tg_enc=Cipher())
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
bridge = asyncio.create_task(bridge_ws_reencrypt(reader, writer, ws, 'owned-test', ctx, dc=203))
try:
reader.feed_data(b'private-native-payload')
await asyncio.wait_for(ws.sending.wait(), 1)
activity = next(item for item in WsActivity.active if item.label == 'owned-test')
self.assertIsNotNone(activity.sending)
self.assertEqual(activity.ws_up, 0)
log_ws_flow(time.monotonic())
# A reply arriving before send drain finishes must not create
# a spurious wait for another response when drain resumes.
await ws.queue.put(b'private-upstream-payload')
await asyncio.wait_for(writer.written.wait(), 1)
self.assertEqual(activity.ws_down, len(writer.data))
self.assertEqual(activity.native_down, 0)
self.assertIsNotNone(activity.writing_native)
log_ws_flow(time.monotonic())
ws.release.set()
writer.release.set()
for _ in range(100):
if activity.native_down and activity.ws_up:
break
await asyncio.sleep(.001)
self.assertEqual(activity.native_down, len(writer.data))
self.assertIsNone(activity.awaiting_rx)
self.assertEqual(ws.sent, [b'private-native-payload'])
self.assertEqual(bytes(writer.data), b'private-upstream-payload')
reader.feed_eof()
await asyncio.wait_for(bridge, 1)
finally:
bridge.cancel()
await asyncio.gather(bridge, return_exceptions=True)
self.assertFalse(WsActivity.active)
output = '\n'.join(captured.output)
self.assertIn('WS FLOW DC203', output)
self.assertIn('WS END DC203', output)
self.assertNotIn('private-', output)
if __name__ == '__main__':
unittest.main()
+1103
View File
File diff suppressed because it is too large. Load diff
+87
View File
@@ -0,0 +1,87 @@
import unittest
from proxy.config import (
CFPROXY_DEFAULT_DOMAINS,
_is_valid_domain,
_normalize_domain_pool,
coerce_domain_list,
parse_dc_ip_list,
)
class ParseDcIpListTest(unittest.TestCase):
def test_parses_multiple_entries(self):
self.assertEqual(
parse_dc_ip_list(['2:149.154.167.220', '4:1.2.3.4']),
{2: '149.154.167.220', 4: '1.2.3.4'},
)
def test_last_entry_wins_for_duplicate_dc(self):
self.assertEqual(parse_dc_ip_list(['2:1.1.1.1', '2:2.2.2.2']), {2: '2.2.2.2'})
def test_rejects_missing_separator(self):
with self.assertRaises(ValueError) as ctx:
parse_dc_ip_list(['2-1.2.3.4'])
self.assertEqual(ctx.exception.kind, 'format')
def test_rejects_short_form_ipv4(self):
for entry in ('2:149.154', '2:1.2.3.4.5', '2:999.1.1.1', '2:abc'):
with self.subTest(entry=entry), self.assertRaises(ValueError) as ctx:
parse_dc_ip_list([entry])
self.assertEqual(ctx.exception.kind, 'invalid')
def test_rejects_non_numeric_dc(self):
with self.assertRaises(ValueError):
parse_dc_ip_list(['x:1.2.3.4'])
class CoerceDomainListTest(unittest.TestCase):
def test_splits_on_common_separators(self):
self.assertEqual(
coerce_domain_list('a.com, b.com; c.com d.com'),
['a.com', 'b.com', 'c.com', 'd.com'],
)
def test_deduplicates_case_insensitively_keeping_first(self):
self.assertEqual(coerce_domain_list(['A.com', 'a.com']), ['A.com'])
def test_flattens_sequences_and_skips_non_strings(self):
self.assertEqual(coerce_domain_list(['a.com b.com', 5, None]), ['a.com', 'b.com'])
def test_returns_empty_for_unsupported_types(self):
self.assertEqual(coerce_domain_list(None), [])
self.assertEqual(coerce_domain_list(42), [])
class DomainValidationTest(unittest.TestCase):
def test_accepts_ordinary_domains(self):
for domain in ('example.com', 'a-b.co.uk', 'x.io'):
self.assertTrue(_is_valid_domain(domain), domain)
def test_rejects_malformed_domains(self):
for domain in ('', 'nodot', '.leading.com', 'trailing.com.',
'-bad.com', 'bad-.com', 'a..com', 'a.1',
'a.' + 'b' * 64, 'a' * 250 + '.com'):
self.assertFalse(_is_valid_domain(domain), domain)
def test_normalize_lowercases_dedupes_and_drops_invalid(self):
self.assertEqual(
_normalize_domain_pool(['B.com ', 'b.com', 'nodot', 'a.com']),
['b.com', 'a.com'],
)
class DefaultDomainsTest(unittest.TestCase):
def test_decoded_defaults_are_valid_domains(self):
self.assertTrue(CFPROXY_DEFAULT_DOMAINS)
for domain in CFPROXY_DEFAULT_DOMAINS:
self.assertTrue(_is_valid_domain(domain), domain)
def test_decoded_defaults_are_unique(self):
self.assertEqual(
len(set(CFPROXY_DEFAULT_DOMAINS)), len(CFPROXY_DEFAULT_DOMAINS)
)
if __name__ == '__main__':
unittest.main()
+278
View File
@@ -0,0 +1,278 @@
import asyncio
import signal
import socket
import sys
import threading
import time
import unittest
from unittest.mock import AsyncMock, Mock, patch
from proxy import event_loop
async def _wait_for_replacement(loop, old_writer, timeout=1):
async def wait():
while loop._csock is old_writer:
await asyncio.sleep(.005)
await asyncio.wait_for(wait(), timeout)
def _wake_later(loop, callback):
def send():
time.sleep(.03)
loop.call_soon_threadsafe(callback)
thread = threading.Thread(target=send, daemon=True)
thread.start()
return thread
@unittest.skipUnless(sys.platform == 'win32', 'Windows proactor self-pipe')
class WindowsSelfPipeTest(unittest.TestCase):
def setUp(self):
self.loop = asyncio.ProactorEventLoop()
self.addCleanup(self.loop.close)
def test_eof_replaces_sockets_and_stops_idle_callbacks(self):
loop = self.loop
read = Mock(wraps=loop._loop_self_reading)
loop._loop_self_reading = read
async def run():
# Install after the original callback has armed a read, as can
# happen when the proxy starts in an already running event loop.
await asyncio.sleep(.01)
event_loop.install_self_pipe_guard(loop)
callback = loop._loop_self_reading
event_loop.install_self_pipe_guard(loop)
self.assertIs(loop._loop_self_reading, callback)
old_reader, old_writer = loop._ssock, loop._csock
internal_fds = loop._internal_fds
with patch.object(event_loop.socket, 'socketpair', wraps=socket.socketpair) as pair:
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
self.assertEqual(pair.call_count, 1)
self.assertEqual(old_reader.fileno(), -1)
self.assertEqual(old_writer.fileno(), -1)
self.assertEqual(loop._internal_fds, internal_fds)
self.assertFalse(loop._self_reading_future.done())
read.reset_mock()
await asyncio.sleep(.05)
read.assert_not_called()
completed = loop.create_future()
thread = _wake_later(loop, lambda: completed.set_result(True))
started = time.monotonic()
try:
self.assertTrue(await asyncio.wait_for(completed, 1))
self.assertLess(time.monotonic() - started, .5)
finally:
thread.join(timeout=1)
with self.assertLogs(event_loop.log, 'WARNING') as messages:
loop.run_until_complete(run())
self.assertEqual(len(messages.output), 1)
self.assertIn('EOF', messages.output[0])
def test_repeated_eof_preserves_existing_tcp_connection(self):
async def run():
loop = self.loop
event_loop.install_self_pipe_guard(loop)
finished = loop.create_future()
async def echo(reader, writer):
try:
while True:
data = await reader.read(128)
if not data:
break
writer.write(data)
await writer.drain()
finally:
writer.close()
await writer.wait_closed()
finished.set_result(None)
server = await asyncio.start_server(echo, '127.0.0.1', 0)
writer = None
try:
port = server.sockets[0].getsockname()[1]
reader, writer = await asyncio.open_connection('127.0.0.1', port)
for index in range(5):
old_writer = loop._csock
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
payload = f'packet {index}'.encode()
writer.write(payload)
await writer.drain()
data = await asyncio.wait_for(reader.readexactly(len(payload)), 1)
self.assertEqual(data, payload)
finally:
if writer is not None:
writer.close()
await writer.wait_closed()
await asyncio.wait_for(finished, 1)
server.close()
await server.wait_closed()
with self.assertLogs(event_loop.log, 'WARNING'):
self.loop.run_until_complete(run())
def test_reset_rebuilds_but_cancelled_and_stale_callbacks_do_not(self):
async def run():
loop = self.loop
event_loop.install_self_pipe_guard(loop)
await asyncio.sleep(0)
old_writer = loop._csock
pending = loop._self_reading_future
pending.cancel()
failed = loop.create_future()
failed.set_exception(ConnectionResetError('self-pipe reset'))
loop._self_reading_future = failed
loop._loop_self_reading(failed)
self.assertIsNot(loop._csock, old_writer)
replacement = loop._csock
with patch.object(loop, 'call_exception_handler') as report:
loop._loop_self_reading(failed) # queued callback from the old pipe
loop._loop_self_reading(pending) # cancellation during shutdown
report.assert_not_called()
self.assertIs(loop._csock, replacement)
await asyncio.sleep(.01)
with self.assertLogs(event_loop.log, 'WARNING') as messages:
self.loop.run_until_complete(run())
self.assertEqual(len(messages.output), 1)
def test_reset_on_read_armed_before_installation_is_recovered(self):
async def run():
loop = self.loop
await asyncio.sleep(.01)
event_loop.install_self_pipe_guard(loop)
old_writer = loop._csock
loop._self_reading_future.set_exception(ConnectionResetError('self-pipe reset'))
await _wait_for_replacement(loop, old_writer)
self.assertFalse(loop._self_reading_future.done())
with self.assertLogs(event_loop.log, 'WARNING') as messages:
self.loop.run_until_complete(run())
self.assertEqual(len(messages.output), 1)
def test_recovery_in_background_proxy_thread(self):
errors = []
async def run():
loop = asyncio.get_running_loop()
event_loop.install_self_pipe_guard(loop)
await asyncio.sleep(0)
old_writer = loop._csock
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
self.assertFalse(loop._self_reading_future.done())
def worker():
try:
asyncio.run(run())
except BaseException as exc:
errors.append(exc)
with self.assertLogs(event_loop.log, 'WARNING'):
thread = threading.Thread(target=worker, daemon=True)
thread.start()
thread.join(timeout=2)
self.assertFalse(thread.is_alive())
self.assertFalse(errors, errors)
def test_allocation_failure_retries_without_spinning(self):
async def run():
loop = self.loop
event_loop.install_self_pipe_guard(loop)
await asyncio.sleep(0)
original_pair = socket.socketpair
attempts = 0
def allocate():
nonlocal attempts
attempts += 1
if attempts == 1:
raise OSError('temporarily out of sockets')
return original_pair()
with patch.object(event_loop.socket, 'socketpair', side_effect=allocate):
old_reader, old_writer = loop._ssock, loop._csock
old_writer.shutdown(socket.SHUT_WR)
await asyncio.sleep(.05)
self.assertEqual(attempts, 1)
self.assertIs(loop._csock, old_writer)
self.assertNotEqual(old_reader.fileno(), -1)
self.assertIsNone(loop._self_reading_future)
await _wait_for_replacement(loop, old_writer, timeout=2)
self.assertEqual(attempts, 2)
with self.assertLogs(event_loop.log, 'WARNING'):
self.loop.run_until_complete(run())
def test_signal_wakeup_fd_is_moved_only_when_owned_by_the_loop(self):
async def run():
loop = self.loop
event_loop.install_self_pipe_guard(loop)
await asyncio.sleep(0)
external_reader, external_writer = socket.socketpair()
external_writer.setblocking(False)
try:
for external in (False, True):
with self.subTest(external=external):
if external:
signal.set_wakeup_fd(external_writer.fileno())
old_writer = loop._csock
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
fd = signal.set_wakeup_fd(-1)
expected = external_writer if external else loop._csock
self.assertEqual(fd, expected.fileno())
signal.set_wakeup_fd(loop._csock.fileno())
finally:
external_reader.close()
external_writer.close()
with self.assertLogs(event_loop.log, 'WARNING'):
self.loop.run_until_complete(run())
@unittest.skipUnless(sys.platform == 'win32', 'Windows proactor self-pipe')
class ProxySelfPipeTest(unittest.IsolatedAsyncioTestCase):
async def test_proxy_without_h2_can_stop_from_thread_after_eof(self):
from proxy import tg_ws_proxy
from proxy.config import ProxyConfig
config = ProxyConfig(port=0, dc_redirects={}, pool_size=0, cfproxy_h2_media=False)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, 'start_cfproxy_domain_refresh'), \
patch.object(tg_ws_proxy.ws_pool, 'warmup', AsyncMock()), \
patch.object(tg_ws_proxy.cf_worker_pool, 'warmup', AsyncMock()), \
self.assertLogs(event_loop.log, 'WARNING'):
task = asyncio.create_task(tg_ws_proxy._run(stop))
thread = None
try:
async def listening():
while tg_ws_proxy._server_instance is None:
await asyncio.sleep(.005)
await asyncio.wait_for(listening(), 1)
self.assertIsNone(tg_ws_proxy.cf_h2_pool)
old_writer = loop._csock
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
thread = _wake_later(loop, stop.set)
started = time.monotonic()
await asyncio.wait_for(task, 1)
self.assertLess(time.monotonic() - started, .5)
self.assertIsNone(tg_ws_proxy._server_instance)
finally:
if thread is not None:
thread.join(timeout=1)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
if __name__ == '__main__':
unittest.main()
+426
View File
@@ -0,0 +1,426 @@
import asyncio
import datetime
import hashlib
import hmac
import os
import ssl
import struct
import tempfile
import time
import unittest
from unittest.mock import AsyncMock, Mock, patch
from proxy import fake_tls
from proxy.fake_tls import (
APPDATA_MIN,
CLIENT_RANDOM_LEN,
CLIENT_RANDOM_OFFSET,
EXT_KEY_SHARE,
EXT_SUPPORTED_VERSIONS,
FALLBACK_APPDATA_RANGE,
GROUP_X25519,
GROUP_X25519MLKEM768,
MLKEM768_CIPHERTEXT_LEN,
SESSION_ID_LEN,
SESSION_ID_OFFSET,
TLS_APPDATA_MAX,
TLS_RECORD_APPDATA,
TLS_RECORD_CCS,
TLS_RECORD_HANDSHAKE,
X25519_KEY_LEN,
X25519MLKEM768_CLIENT_SHARE_LEN,
build_server_hello,
first_appdata_record_len,
parse_client_hello,
probe_appdata_len,
set_profiled_appdata_len,
verify_client_hello,
wrap_tls_record,
)
SECRET = bytes.fromhex('00112233445566778899aabbccddeeff')
# Telegram's current ClientHello offers these, like Chrome
TELEGRAM_SUITES = (0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f)
TELEGRAM_SHARES = ((GROUP_X25519MLKEM768, X25519MLKEM768_CLIENT_SHARE_LEN),
(GROUP_X25519, X25519_KEY_LEN))
def _sign_client_hello(body: bytearray, secret: bytes, timestamp: int) -> bytes:
digest = hmac.new(secret, bytes(body), hashlib.sha256).digest()
client_random = bytearray(digest[:CLIENT_RANDOM_LEN])
ts_bytes = struct.pack('<I', timestamp)
for i in range(4):
client_random[28 + i] = digest[28 + i] ^ ts_bytes[i]
body[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = client_random
return bytes(body)
def _structured_client_hello(suites=TELEGRAM_SUITES, shares=TELEGRAM_SHARES,
secret: bytes = SECRET) -> bytes:
key_share_list = b''.join(struct.pack('>HH', group, length) + os.urandom(length)
for group, length in shares)
extensions = (
struct.pack('>HH', 0x0000, 0)
+ struct.pack('>HHH', EXT_KEY_SHARE, 2 + len(key_share_list),
len(key_share_list))
+ key_share_list
+ struct.pack('>HHB', EXT_SUPPORTED_VERSIONS, 3, 2) + b'\x03\x04'
)
hello = (
b'\x03\x03' + b'\x00' * CLIENT_RANDOM_LEN
+ b'\x20' + os.urandom(SESSION_ID_LEN)
+ struct.pack('>H', 2 * len(suites))
+ b''.join(struct.pack('>H', s) for s in suites)
+ b'\x01\x00'
+ struct.pack('>H', len(extensions)) + extensions
)
message = b'\x01' + struct.pack('>I', len(hello))[1:] + hello
body = bytearray(b'\x16\x03\x01' + struct.pack('>H', len(message)) + message)
return _sign_client_hello(body, secret, int(time.time()))
def _split_records(data: bytes):
records = []
pos = 0
while pos < len(data):
rtype = data[pos]
length = struct.unpack_from('>H', data, pos + 3)[0]
records.append((rtype, data[pos + 5:pos + 5 + length]))
pos += 5 + length
return records
def _server_hello_fields(record: bytes):
# record = handshake message: type(1) len(3) version(2) random(32) sid ...
pos = 4 + 2 + 32
pos += 1 + record[pos]
cipher = struct.unpack_from('>H', record, pos)[0]
pos += 3
ext_end = pos + 2 + struct.unpack_from('>H', record, pos)[0]
pos += 2
extensions = []
while pos < ext_end:
ext_type, ext_len = struct.unpack_from('>HH', record, pos)
extensions.append((ext_type, record[pos + 4:pos + 4 + ext_len]))
pos += 4 + ext_len
return cipher, extensions
def _client_hello(secret: bytes = SECRET, timestamp: int = None,
session_id: bytes = None) -> bytes:
if timestamp is None:
timestamp = int(time.time())
if session_id is None:
session_id = os.urandom(SESSION_ID_LEN)
body = bytearray(517)
body[0] = TLS_RECORD_HANDSHAKE
body[1:3] = b'\x03\x01'
struct.pack_into('>H', body, 3, len(body) - 5)
body[5] = 0x01
body[43] = 0x20
body[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN] = session_id
return _sign_client_hello(body, secret, timestamp)
class VerifyClientHelloTest(unittest.TestCase):
def test_accepts_well_formed_hello(self):
session_id = os.urandom(SESSION_ID_LEN)
now = int(time.time())
result = verify_client_hello(_client_hello(timestamp=now,
session_id=session_id), SECRET)
self.assertIsNotNone(result)
client_random, got_session_id, ts = result
self.assertEqual(len(client_random), CLIENT_RANDOM_LEN)
self.assertEqual(got_session_id, session_id)
self.assertEqual(ts, now)
def test_rejects_wrong_secret(self):
other = bytes.fromhex('ffeeddccbbaa99887766554433221100')
self.assertIsNone(verify_client_hello(_client_hello(), other))
def test_rejects_stale_timestamp(self):
stale = int(time.time()) - 3600
self.assertIsNone(verify_client_hello(_client_hello(timestamp=stale), SECRET))
def test_rejects_tampered_body(self):
hello = bytearray(_client_hello())
hello[300] ^= 0xFF
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
def test_rejects_short_and_non_handshake_records(self):
self.assertIsNone(verify_client_hello(b'\x16\x03\x01\x00\x10', SECRET))
hello = bytearray(_client_hello())
hello[0] = 0x17
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
hello = bytearray(_client_hello())
hello[5] = 0x02
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
class ParseClientHelloTest(unittest.TestCase):
def test_extracts_suites_and_key_shares(self):
info = parse_client_hello(_structured_client_hello())
self.assertEqual(info.cipher_suites, frozenset(TELEGRAM_SUITES))
self.assertEqual(info.key_shares, dict(TELEGRAM_SHARES))
def test_structured_hello_still_verifies(self):
self.assertIsNotNone(verify_client_hello(_structured_client_hello(), SECRET))
def test_malformed_hello_yields_empty_info(self):
self.assertEqual(parse_client_hello(b'\x16\x03\x01\x00'),
fake_tls._EMPTY_HELLO_INFO)
truncated = _structured_client_hello()[:60]
self.assertEqual(parse_client_hello(truncated), fake_tls._EMPTY_HELLO_INFO)
def test_unstructured_hello_does_not_raise(self):
parse_client_hello(_client_hello())
class BuildServerHelloTest(unittest.TestCase):
def setUp(self):
set_profiled_appdata_len(None)
def tearDown(self):
set_profiled_appdata_len(None)
def _build(self, hello: bytes, client_random: bytes = None) -> bytes:
client_random = client_random or os.urandom(CLIENT_RANDOM_LEN)
return build_server_hello(SECRET, client_random, os.urandom(SESSION_ID_LEN),
parse_client_hello(hello))
def test_flight_is_server_hello_ccs_and_one_appdata_record(self):
records = _split_records(self._build(_structured_client_hello()))
self.assertEqual([r[0] for r in records],
[TLS_RECORD_HANDSHAKE, TLS_RECORD_CCS, TLS_RECORD_APPDATA])
self.assertEqual(records[1][1], b'\x01')
def test_hmac_covers_whole_flight(self):
client_random = os.urandom(CLIENT_RANDOM_LEN)
response = self._build(_structured_client_hello(), client_random)
zeroed = bytearray(response)
zeroed[11:11 + 32] = b'\x00' * 32
expected = hmac.new(SECRET, client_random + bytes(zeroed),
hashlib.sha256).digest()
self.assertEqual(response[11:11 + 32], expected)
def test_mirrors_post_quantum_group_when_offered(self):
records = _split_records(self._build(_structured_client_hello()))
cipher, extensions = _server_hello_fields(records[0][1])
self.assertEqual([e[0] for e in extensions],
[EXT_SUPPORTED_VERSIONS, EXT_KEY_SHARE])
key_share = extensions[1][1]
group, length = struct.unpack_from('>HH', key_share)
self.assertEqual(group, GROUP_X25519MLKEM768)
self.assertEqual(length, MLKEM768_CIPHERTEXT_LEN + X25519_KEY_LEN)
self.assertEqual(len(key_share), 4 + length)
self.assertEqual(cipher, 0x1301)
def test_falls_back_to_x25519(self):
hello = _structured_client_hello(shares=((GROUP_X25519, X25519_KEY_LEN),))
records = _split_records(self._build(hello))
_, extensions = _server_hello_fields(records[0][1])
group, length = struct.unpack_from('>HH', extensions[1][1])
self.assertEqual((group, length), (GROUP_X25519, X25519_KEY_LEN))
# real X25519 public keys are below 2^255
self.assertEqual(extensions[1][1][-1] & 0x80, 0)
def test_ignores_post_quantum_share_of_wrong_size(self):
hello = _structured_client_hello(
shares=((GROUP_X25519MLKEM768, 100), (GROUP_X25519, X25519_KEY_LEN)))
records = _split_records(self._build(hello))
_, extensions = _server_hello_fields(records[0][1])
self.assertEqual(struct.unpack_from('>H', extensions[1][1])[0], GROUP_X25519)
def test_picks_cipher_offered_by_client(self):
hello = _structured_client_hello(suites=(0x1302, 0x1303))
cipher, _ = _server_hello_fields(_split_records(self._build(hello))[0][1])
self.assertEqual(cipher, 0x1303)
def test_telegram_flight_matches_telemt_size(self):
# telemt answered every Telegram client with exactly 1290 bytes when the
# masking site's first encrypted record was below the 64-byte floor
set_profiled_appdata_len(30)
self.assertEqual(len(self._build(_structured_client_hello())), 1290)
def test_uses_profiled_appdata_len(self):
set_profiled_appdata_len(3000)
records = _split_records(self._build(_structured_client_hello()))
self.assertEqual(len(records[2][1]), 3000)
def test_profiled_len_is_clamped(self):
set_profiled_appdata_len(10)
self.assertEqual(fake_tls._profiled_appdata_len, APPDATA_MIN)
set_profiled_appdata_len(10 ** 6)
self.assertEqual(fake_tls._profiled_appdata_len, TLS_APPDATA_MAX)
def test_fallback_len_stays_in_range(self):
for _ in range(20):
records = _split_records(self._build(_structured_client_hello()))
self.assertGreaterEqual(len(records[2][1]), FALLBACK_APPDATA_RANGE[0])
self.assertLessEqual(len(records[2][1]), FALLBACK_APPDATA_RANGE[1])
def test_echoes_session_id_and_binds_client_random(self):
session_id = os.urandom(SESSION_ID_LEN)
client_random = os.urandom(CLIENT_RANDOM_LEN)
response = build_server_hello(SECRET, client_random, session_id)
self.assertEqual(response[0], TLS_RECORD_HANDSHAKE)
self.assertEqual(
response[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN],
session_id,
)
zeroed = bytearray(response)
zeroed[11:11 + 32] = b'\x00' * 32
expected = hmac.new(SECRET, client_random + bytes(zeroed),
hashlib.sha256).digest()
self.assertEqual(response[11:11 + 32], expected)
def test_padding_length_varies_between_calls(self):
sizes = {
len(build_server_hello(SECRET, os.urandom(32), os.urandom(32)))
for _ in range(20)
}
self.assertGreater(len(sizes), 1)
def _record(rtype: int, length: int) -> bytes:
return bytes([rtype]) + b'\x03\x03' + struct.pack('>H', length) + b'\x00' * length
class FirstAppdataRecordLenTest(unittest.TestCase):
def test_finds_record_after_server_hello_and_ccs(self):
flight = (_record(TLS_RECORD_HANDSHAKE, 122) + _record(TLS_RECORD_CCS, 1)
+ _record(TLS_RECORD_APPDATA, 27) + _record(TLS_RECORD_APPDATA, 3000))
self.assertEqual(first_appdata_record_len(flight), 27)
def test_needs_more_data_until_header_arrives(self):
partial = _record(TLS_RECORD_HANDSHAKE, 122) + _record(TLS_RECORD_CCS, 1)
self.assertIsNone(first_appdata_record_len(partial))
self.assertIsNone(first_appdata_record_len(partial + b'\x17\x03'))
def test_alert_means_no_profile(self):
self.assertEqual(first_appdata_record_len(_record(0x15, 2)), 0)
def _self_signed_context() -> ssl.SSLContext:
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=1))
.sign(key, hashes.SHA256()))
with tempfile.TemporaryDirectory() as tmp:
cert_path = os.path.join(tmp, 'cert.pem')
key_path = os.path.join(tmp, 'key.pem')
with open(cert_path, 'wb') as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
with open(key_path, 'wb') as f:
f.write(key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert_path, key_path)
return ctx
@unittest.skipUnless(getattr(ssl, 'HAS_TLSv1_3', False), 'needs TLS 1.3 in ssl')
class ProbeAppdataLenTest(unittest.TestCase):
def test_continuous_records_do_not_extend_probe_timeout(self):
async def scenario():
async def read(_size):
await asyncio.sleep(0.01)
return _record(TLS_RECORD_CCS, 1)
reader = Mock(read=AsyncMock(side_effect=read))
writer = Mock(drain=AsyncMock())
with patch.object(fake_tls.asyncio, 'open_connection',
AsyncMock(return_value=(reader, writer))):
result = await asyncio.wait_for(
probe_appdata_len('localhost', timeout=0.1), timeout=2)
self.assertIsNone(result)
self.assertGreater(reader.read.await_count, 1)
writer.close.assert_called_once()
asyncio.run(scenario())
def test_blocked_drain_obeys_probe_timeout(self):
async def scenario():
reader = Mock(read=AsyncMock())
writer = Mock(drain=AsyncMock(side_effect=asyncio.Event().wait))
with patch.object(fake_tls.asyncio, 'open_connection',
AsyncMock(return_value=(reader, writer))):
result = await asyncio.wait_for(
probe_appdata_len('localhost', timeout=0.1), timeout=2)
self.assertIsNone(result)
reader.read.assert_not_called()
hello = writer.write.call_args[0][0]
self.assertEqual(parse_client_hello(hello).key_shares,
{GROUP_X25519: X25519_KEY_LEN})
writer.close.assert_called_once()
asyncio.run(scenario())
def test_measures_first_encrypted_record_of_real_tls13_server(self):
async def scenario():
server = await asyncio.start_server(
lambda r, w: None, '127.0.0.1', 0, ssl=_self_signed_context())
port = server.sockets[0].getsockname()[1]
try:
return await probe_appdata_len('127.0.0.1', port, timeout=3)
finally:
server.close()
await server.wait_closed()
length = asyncio.run(scenario())
self.assertIsNotNone(length)
self.assertGreater(length, 0)
def test_unreachable_site_gives_no_profile(self):
async def scenario():
server = await asyncio.start_server(lambda r, w: None, '127.0.0.1', 0)
port = server.sockets[0].getsockname()[1]
server.close()
await server.wait_closed()
return await probe_appdata_len('127.0.0.1', port, timeout=1)
self.assertIsNone(asyncio.run(scenario()))
class WrapTlsRecordTest(unittest.TestCase):
def test_short_payload_becomes_one_record(self):
wrapped = wrap_tls_record(b'hello')
self.assertEqual(wrapped, b'\x17\x03\x03\x00\x05hello')
def test_long_payload_is_chunked_to_the_record_limit(self):
payload = os.urandom(TLS_APPDATA_MAX + 100)
wrapped = wrap_tls_record(payload)
offset = 0
chunks = []
while offset < len(wrapped):
length = struct.unpack('>H', wrapped[offset + 3:offset + 5])[0]
self.assertLessEqual(length, TLS_APPDATA_MAX)
chunks.append(wrapped[offset + 5:offset + 5 + length])
offset += 5 + length
self.assertEqual(len(chunks), 2)
self.assertEqual(b''.join(chunks), payload)
def test_empty_payload_produces_no_records(self):
self.assertEqual(wrap_tls_record(b''), b'')
if __name__ == '__main__':
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
"""H2 belongs to CF fallback, never to the direct route."""
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from proxy import bridge, tg_ws_proxy
from proxy.config import proxy_config
from proxy.utils import PROTO_TAG_INTERMEDIATE
class H2RoutingTest(unittest.IsolatedAsyncioTestCase):
async def test_direct_pool_hit_never_opens_h2(self):
reader = Mock()
writer = Mock()
writer.get_extra_info.return_value = None
writer.wait_closed = AsyncMock()
ws = Mock(send=AsyncMock())
pool = SimpleNamespace(open=AsyncMock())
with patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.51'}), \
patch.object(proxy_config, 'force_test_dc', False), \
patch.object(tg_ws_proxy, 'cf_h2_pool', pool), \
patch.object(tg_ws_proxy, 'set_sock_opts'), \
patch.object(tg_ws_proxy, '_read_client_init', AsyncMock(
return_value=(b'', reader, writer, 'test'))), \
patch.object(tg_ws_proxy, '_try_handshake', return_value=(
2, True, PROTO_TAG_INTERMEDIATE, b'')), \
patch.object(tg_ws_proxy, '_build_crypto_ctx'), \
patch.object(tg_ws_proxy.ws_pool, 'get', AsyncMock(return_value=ws)), \
patch.object(tg_ws_proxy, 'bridge_ws_reencrypt', AsyncMock()) as direct, \
patch.object(tg_ws_proxy, 'do_fallback', AsyncMock()) as fallback:
await tg_ws_proxy._handle_client(reader, writer, b'')
direct.assert_awaited_once()
fallback.assert_not_awaited()
pool.open.assert_not_awaited()
async def test_pool_miss_goes_straight_to_fallback_including_test_dcs(self):
for dc, forced, expected_test in [(2, False, False),
(10002, False, True),
(2, True, True)]:
with self.subTest(dc=dc, forced=forced):
reader = Mock()
writer = Mock(wait_closed=AsyncMock())
writer.get_extra_info.return_value = None
with patch.object(proxy_config, 'force_test_dc', forced), \
patch.object(tg_ws_proxy, 'set_sock_opts'), \
patch.object(tg_ws_proxy, '_read_client_init', AsyncMock(
return_value=(b'', reader, writer, 'test'))), \
patch.object(tg_ws_proxy, '_try_handshake', return_value=(
dc, False, PROTO_TAG_INTERMEDIATE, b'')), \
patch.object(tg_ws_proxy, '_build_crypto_ctx'), \
patch.object(tg_ws_proxy.ws_pool, 'get', AsyncMock(return_value=None)) as get, \
patch('proxy.raw_websocket.RawWebSocket.connect', AsyncMock()) as connect, \
patch.object(tg_ws_proxy, 'do_fallback', AsyncMock(return_value=True)) as fallback:
await tg_ws_proxy._handle_client(reader, writer, b'')
get.assert_awaited_once_with(2, False, is_test_dc=expected_test)
fallback.assert_awaited_once()
self.assertEqual(fallback.call_args.args[4:7], (2, expected_test, False))
connect.assert_not_awaited()
async def test_cf_fallback_order_and_route_guards(self):
cases = [
# media, test DC, CF enabled, Worker succeeds, H2 opens, expected
(True, False, True, False, True, ['h2', 'bridge']),
(True, False, True, False, False, ['h2', 'ws']),
(False, False, True, False, True, ['ws']),
(True, True, True, False, True, ['tcp']),
(True, False, False, False, True, ['tcp']),
(True, False, True, True, True, ['worker']),
]
for media, test_dc, cf, worker, opens, expected in cases:
with self.subTest(expected=expected, media=media, test_dc=test_dc, cf=cf):
calls = []
async def open_h2(*args):
calls.append('h2')
return object() if opens else None
def route(name):
async def run(*args, **kwargs):
calls.append(name)
return True
return run
pool = SimpleNamespace(open=AsyncMock(side_effect=open_h2))
with patch.object(proxy_config, 'fallback_cfproxy', cf), \
patch.object(proxy_config, 'cfproxy_worker_domains',
['worker.example'] if worker else []), \
patch.object(bridge, 'bridge_h2', side_effect=route('bridge')), \
patch.object(bridge, '_cfproxy_fallback', side_effect=route('ws')), \
patch.object(bridge, '_cfproxy_worker_fallback', side_effect=route('worker')), \
patch.object(bridge, '_tcp_fallback', side_effect=route('tcp')):
result = await bridge.do_fallback(
Mock(), Mock(), b'', 'test', 2, test_dc, media, '', Mock(),
h2_pool=pool, proto_tag=PROTO_TAG_INTERMEDIATE)
self.assertTrue(result)
self.assertEqual(calls, expected)
+127
View File
@@ -0,0 +1,127 @@
"""Default/CLI/GUI configuration must select the same effective H2 route."""
import asyncio
import contextlib
import io
import json
import logging
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from proxy.config import ProxyConfig
from proxy import tg_ws_proxy
from utils import tray_common
from utils.default_config import default_tray_config
from ui.settings import prepare_settings
class H2SettingsTest(unittest.TestCase):
def test_cli_default_opt_out_and_existing_launch_script(self):
cases = [([], True), (['--no-h2'], False),
(['--cfproxy-h2-media'], True), (['--no-cfproxy'], False),
(['--no-secure'], False), (['--force-test-dc'], False)]
for flags, enabled in cases:
with self.subTest(flags=flags):
config = ProxyConfig()
observed = []
async def run():
observed.append(config.h2_enabled)
root = logging.getLogger()
previous_handlers, previous_level = root.handlers[:], root.level
try:
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, '_run', run), \
patch('sys.argv', ['proxy', '--secret', 'ab' * 16] + flags):
tg_ws_proxy.main()
self.assertEqual(observed, [enabled])
finally:
for handler in root.handlers[:]:
if handler not in previous_handlers:
root.removeHandler(handler)
handler.close()
root.setLevel(previous_level)
def test_help_advertises_opt_out_only(self):
output = io.StringIO()
with patch('sys.argv', ['proxy', '--help']), contextlib.redirect_stdout(output):
with self.assertRaises(SystemExit) as raised:
tg_ws_proxy.main()
self.assertEqual(raised.exception.code, 0)
self.assertIn('--no-h2', output.getvalue())
self.assertNotIn('--cfproxy-h2-media', output.getvalue())
def test_saved_setting_migration_and_runtime_restart(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'config.json'
path.write_text(json.dumps({'port': 1444}), encoding='utf-8')
with patch.object(tray_common, 'CONFIG_FILE', path), \
patch.object(tray_common, 'ensure_dirs'), \
patch.object(tray_common, '_apply_ui_language'):
config = tray_common.load_config()
self.assertTrue(config['h2'])
change = prepare_settings(config, {'h2': False}, default_tray_config())
self.assertTrue(change.requires_restart)
tray_common.save_config(change.config)
saved = tray_common.load_config()
self.assertFalse(saved['h2'])
runtime = ProxyConfig()
with patch.object(tray_common, 'proxy_config', runtime):
self.assertTrue(tray_common.apply_proxy_config(saved))
self.assertFalse(runtime.h2_enabled)
saved['h2'] = True
self.assertTrue(tray_common.apply_proxy_config(saved))
self.assertTrue(runtime.h2_enabled)
for key, value in [('cfproxy', False), ('no_secure', True),
('force_test_dc', True)]:
self.assertTrue(tray_common.apply_proxy_config({**saved, key: value}))
self.assertFalse(runtime.h2_enabled)
self.assertTrue(runtime.cfproxy_h2_media)
class H2ServerLifecycleTest(unittest.IsolatedAsyncioTestCase):
async def test_stop_and_cancellation_close_default_h2_and_listener_tasks(self):
for cancel in (False, True):
with self.subTest(cancel=cancel):
config = ProxyConfig(port=0, dc_redirects={}, pool_size=0)
stop = asyncio.Event()
before = asyncio.all_tasks()
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, 'start_cfproxy_domain_refresh'), \
patch.object(tg_ws_proxy.ws_pool, 'warmup', AsyncMock()), \
patch.object(tg_ws_proxy.cf_worker_pool, 'warmup', AsyncMock()):
server = asyncio.create_task(tg_ws_proxy._run(stop))
writer = None
try:
async def listening():
while tg_ws_proxy._server_instance is None:
await asyncio.sleep(.005)
await asyncio.wait_for(listening(), 1)
pool = tg_ws_proxy.cf_h2_pool
self.assertIsNotNone(pool)
port = tg_ws_proxy._server_instance.sockets[0].getsockname()[1]
_, writer = await asyncio.open_connection('127.0.0.1', port)
if cancel:
server.cancel()
with self.assertRaises(asyncio.CancelledError):
await asyncio.wait_for(server, 2)
else:
stop.set()
await asyncio.wait_for(server, 2)
self.assertTrue(pool.closed)
self.assertIsNone(tg_ws_proxy.cf_h2_pool)
self.assertIsNone(tg_ws_proxy._server_instance)
self.assertFalse(tg_ws_proxy._client_tasks)
self.assertFalse(asyncio.all_tasks() - before)
finally:
server.cancel()
await asyncio.gather(server, return_exceptions=True)
if writer is not None:
writer.close()
await writer.wait_closed()
if __name__ == '__main__':
unittest.main()
File diff suppressed because it is too large. Load diff
+70
View File
@@ -0,0 +1,70 @@
import json
import unittest
from pathlib import Path
LOCALES_DIR = Path(__file__).parents[1] / "ui" / "i18n"
# Values that are intentionally identical across locales
# (brand names, technical terms, format-only strings).
# Note: settings.language is deliberately "Language" in ru.json —
# upstream keeps the language selector label in English on purpose.
ALLOWED_UNTRANSLATED = frozenset({
"app.name",
"button.test_loading",
"connectivity.cfworker_title",
"connectivity.error_line",
"first_run.manual_mtproto",
"language.en",
"language.ru",
"section.cfproxy",
"section.cfworker",
"settings.language",
})
def _load(code: str) -> dict:
with open(LOCALES_DIR / f"{code}.json", encoding="utf-8") as f:
return json.load(f)
class I18nParityTest(unittest.TestCase):
def test_en_ru_key_sets_match(self):
en = _load("en")
ru = _load("ru")
self.assertEqual(
set(en), set(ru),
f"Missing in ru: {sorted(set(en) - set(ru))}; "
f"missing in en: {sorted(set(ru) - set(en))}",
)
def test_ru_has_no_untouched_english_leftovers(self):
en = _load("en")
ru = _load("ru")
leftovers = sorted(
key for key in set(en) & set(ru)
if en[key] == ru[key] and key not in ALLOWED_UNTRANSLATED
)
self.assertEqual(leftovers, [])
def test_format_placeholders_match(self):
import string
en = _load("en")
ru = _load("ru")
for key in set(en) & set(ru):
en_fields = {
name for _, name, _, _ in string.Formatter().parse(en[key])
if name
}
ru_fields = {
name for _, name, _, _ in string.Formatter().parse(ru[key])
if name
}
self.assertEqual(
en_fields, ru_fields,
f"Placeholder mismatch for key {key!r}",
)
if __name__ == "__main__":
unittest.main()
+405
View File
@@ -0,0 +1,405 @@
import hashlib
import io
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
from contextlib import ExitStack
from http.client import IncompleteRead
from pathlib import Path
from unittest import mock
from utils import linux_update, update_check
class _Proc:
def __init__(self, returncode, stdout=b'', stderr=b''):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class AssetNameTest(unittest.TestCase):
def test_names_match_release_assets(self):
self.assertEqual(linux_update.asset_name('binary'), 'TgWsProxy_linux_amd64')
self.assertEqual(linux_update.asset_name('deb'), 'TgWsProxy_linux_amd64.deb')
self.assertEqual(linux_update.asset_name('rpm'), 'TgWsProxy_linux_amd64.rpm')
class DetectInstallKindTest(unittest.TestCase):
def _detect(self, *, machine='x86_64', tools=(), owned_by=None, writable=True):
exe = Path('/usr/bin/tg-ws-proxy')
def _which(name):
return '/usr/bin/' + name if name in tools else None
def _run_quiet(cmd):
return cmd[0] == owned_by
with mock.patch.object(linux_update.platform, 'machine', return_value=machine), \
mock.patch.object(linux_update.shutil, 'which', side_effect=_which), \
mock.patch.object(linux_update, '_run_quiet', side_effect=_run_quiet), \
mock.patch.object(linux_update.os, 'access', return_value=writable), \
mock.patch.object(linux_update, '_is_root', return_value=False):
return linux_update.detect_install_kind(exe)
def test_deb_package_with_pkexec(self):
self.assertEqual(
self._detect(tools=('dpkg-query', 'pkexec', 'apt-get'), owned_by='dpkg-query'),
'deb',
)
def test_rpm_package_with_pkexec(self):
self.assertEqual(
self._detect(tools=('rpm', 'pkexec'), owned_by='rpm'), 'rpm',
)
def test_package_without_pkexec_is_unsupported(self):
self.assertIsNone(self._detect(tools=('dpkg-query',), owned_by='dpkg-query'))
def test_aur_install_is_unsupported(self):
self.assertIsNone(
self._detect(tools=('pacman', 'pkexec'), owned_by='pacman'),
)
def test_standalone_writable_binary(self):
self.assertEqual(self._detect(tools=()), 'binary')
def test_read_only_dir_is_supported_via_pkexec(self):
self.assertEqual(self._detect(tools=('pkexec',), writable=False), 'binary')
def test_read_only_dir_without_pkexec_is_unsupported(self):
self.assertIsNone(self._detect(tools=(), writable=False))
def test_non_amd64_is_unsupported(self):
self.assertIsNone(self._detect(machine='aarch64'))
class InstallCmdTest(unittest.TestCase):
package_path = Path(tempfile.gettempdir()) / 'pkg'
def _cmd(self, kind, tools):
with mock.patch.object(
linux_update.shutil, 'which',
side_effect=lambda n: '/usr/bin/' + n if n in tools else None,
), mock.patch.object(linux_update, '_is_root', return_value=False):
return linux_update.install_cmd(kind, self.package_path)
def test_deb_prefers_apt_get_and_elevates(self):
cmd = self._cmd('deb', ('apt-get',))
self.assertEqual(cmd[:3], ['pkexec', 'apt-get', 'install'])
self.assertEqual(cmd[-1], str(self.package_path))
def test_deb_falls_back_to_dpkg(self):
self.assertEqual(self._cmd('deb', ()), ['pkexec', 'dpkg', '-i', str(self.package_path)])
def test_rpm_prefers_dnf_without_removing_packages(self):
cmd = self._cmd('rpm', ('dnf',))
self.assertEqual(cmd, ['pkexec', 'dnf', 'install', '-y', str(self.package_path)])
self.assertNotIn('--allowerasing', cmd)
def test_rpm_falls_back_to_rpm_binary(self):
self.assertEqual(
self._cmd('rpm', ()), ['pkexec', 'rpm', '-U', str(self.package_path)],
)
def test_unknown_kind_is_rejected(self):
with self.assertRaises(ValueError):
self._cmd('binary', ('apt-get',))
def test_root_does_not_use_pkexec(self):
with mock.patch.object(linux_update.shutil, 'which', return_value=None), \
mock.patch.object(linux_update, '_is_root', return_value=True):
self.assertEqual(
linux_update.install_cmd('deb', self.package_path),
['dpkg', '-i', str(self.package_path)],
)
class ApplyUpdateTest(unittest.TestCase):
def _download(self, payload=b'new'):
def _fn(url, dest_dir, suffix='.tmp', digest=''):
path = Path(dest_dir) / ('new' + suffix)
path.write_bytes(payload)
return path
return _fn
def test_writable_binary_is_replaced_in_place(self):
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch('utils.linux_update.download_asset', self._download()):
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
self.assertEqual(exe.read_bytes(), b'new')
self.assertEqual(list(Path(tmp).iterdir()), [exe])
@unittest.skipUnless(sys.platform == 'linux', 'Linux executable permissions')
def test_replaced_binary_has_executable_permissions(self):
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch('utils.linux_update.download_asset', self._download()):
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
def test_read_only_dir_binary_is_installed_with_elevation(self):
exe = Path('/usr/local/bin/TgWsProxy')
with tempfile.TemporaryDirectory() as tmp:
def _download(url, dest_dir, suffix='.tmp', digest=''):
path = Path(tmp) / ('new' + suffix)
path.write_bytes(b'new')
return path
run = mock.Mock(return_value=_Proc(0))
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch.object(linux_update.os, 'access', return_value=False), \
mock.patch.object(linux_update, '_is_root', return_value=False), \
mock.patch('utils.linux_update.download_asset', _download), \
mock.patch.object(linux_update.subprocess, 'run', run):
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
cmd = run.call_args[0][0]
self.assertEqual(cmd[:3], ['pkexec', 'sh', '-c'])
self.assertEqual(cmd[-1], str(exe))
def test_deb_install_failure_returns_stderr(self):
run = mock.Mock(return_value=_Proc(1, stderr=b'dpkg: error'))
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
mock.patch('utils.linux_update.download_asset', self._download()), \
mock.patch.object(linux_update.subprocess, 'run', run), \
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
mock.patch.object(linux_update, '_is_root', return_value=True):
err = linux_update.apply_update('deb', 'https://x.invalid/a.deb')
self.assertEqual(err, 'dpkg: error')
def test_deb_install_success_removes_downloaded_package(self):
holder = {}
def _download(url, dest_dir, suffix='.tmp', digest=''):
path = Path(dest_dir) / ('pkg' + suffix)
path.write_bytes(b'deb')
holder['path'] = path
return path
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
mock.patch('utils.linux_update.download_asset', _download), \
mock.patch.object(linux_update.subprocess, 'run',
return_value=_Proc(0)), \
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
mock.patch.object(linux_update, '_is_root', return_value=True):
self.assertIsNone(linux_update.apply_update('deb', 'https://x.invalid/a.deb'))
self.assertFalse(holder['path'].exists())
def test_digest_is_passed_to_the_downloader(self):
seen = {}
def _download(url, dest_dir, suffix='.tmp', digest=''):
seen['digest'] = digest
path = Path(dest_dir) / ('new' + suffix)
path.write_bytes(b'new')
return path
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch('utils.linux_update.download_asset', _download):
linux_update.apply_update('binary', 'https://x.invalid/a', digest='sha256:ab')
self.assertEqual(seen['digest'], 'sha256:ab')
def test_download_failure_is_reported(self):
for error in (OSError('boom'), ValueError('invalid checksum'), IncompleteRead(b'partial', 100)):
with self.subTest(error=error), \
mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \
mock.patch.object(linux_update, 'download_asset', side_effect=error), \
mock.patch.object(linux_update, '_run_install') as install:
self.assertEqual(linux_update.apply_update('binary', 'https://x.invalid/a'), str(error))
install.assert_not_called()
def test_corrupt_download_never_changes_binary_or_runs_a_package_manager(self):
for kind in ('binary', 'deb', 'rpm'):
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
opener = mock.Mock()
opener.open.return_value = io.BytesIO(b'corrupt')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch.object(update_check, 'build_github_opener', return_value=opener), \
mock.patch.object(linux_update, '_run_install') as install:
error = linux_update.apply_update(kind, 'https://example.invalid/asset',
digest='sha256:' + '0' * 64)
self.assertIn('SHA-256', error)
install.assert_not_called()
self.assertEqual(exe.read_bytes(), b'old')
self.assertEqual(list(Path(tmp).iterdir()), [exe])
def test_verified_download_is_installed(self):
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
opener = mock.Mock()
opener.open.return_value = io.BytesIO(b'new')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch.object(update_check, 'build_github_opener', return_value=opener):
self.assertIsNone(linux_update.apply_update(
'binary', 'https://example.invalid/asset',
digest='sha256:' + hashlib.sha256(b'new').hexdigest(),
))
self.assertEqual(exe.read_bytes(), b'new')
self.assertEqual(list(Path(tmp).iterdir()), [exe])
def test_install_timeout_cleans_download_directory(self):
holder = {}
def download(url, dest_dir, **kwargs):
holder['directory'] = dest_dir
path = dest_dir / 'update.deb'
path.write_bytes(b'package')
return path
with mock.patch.object(linux_update, 'download_asset', side_effect=download), \
mock.patch.object(linux_update, '_run_install', side_effect=subprocess.TimeoutExpired('apt-get', 600)):
self.assertIsNotNone(linux_update.apply_update('deb', 'https://example.invalid/asset'))
self.assertFalse(holder['directory'].exists())
@unittest.skipUnless(sys.platform == 'linux', 'Linux install(1) and executable replacement')
class ElevatedBinaryReplacementTest(unittest.TestCase):
def test_replaces_a_running_executable_with_special_characters_in_its_path(self):
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
exe = directory / 'app $(touch unwanted); тест'
src = directory / 'download with spaces'
shutil.copy2(shutil.which('sleep'), exe)
src.write_bytes(b'new')
process = subprocess.Popen([str(exe), '30'])
try:
with mock.patch.object(linux_update, '_is_root', return_value=True):
cmd = linux_update.replace_binary_cmd(src, exe)
self.assertEqual(subprocess.run(cmd, cwd=tmp, timeout=10).returncode, 0)
self.assertIsNone(process.poll())
self.assertEqual(exe.read_bytes(), b'new')
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
self.assertEqual(set(directory.iterdir()), {exe, src})
finally:
process.terminate()
process.wait(timeout=5)
def test_failed_copy_preserves_the_old_binary_and_removes_the_stage(self):
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'app'
exe.write_bytes(b'old')
with mock.patch.object(linux_update, '_is_root', return_value=True):
cmd = linux_update.replace_binary_cmd(Path(tmp) / 'missing', exe)
result = subprocess.run(cmd, stderr=subprocess.PIPE, timeout=10)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(exe.read_bytes(), b'old')
self.assertEqual(list(Path(tmp).iterdir()), [exe])
class ExternalCommandsEnvTest(unittest.TestCase):
def test_commands_use_system_libraries_without_changing_parent_environment(self):
bundle = str(Path(tempfile.gettempdir()) / '_MEItest')
probe = (
'import os, sys; '
'assert os.environ.get("LD_LIBRARY_PATH", "") == sys.argv[1]; '
'assert "_PYI_APPLICATION_HOME_DIR" not in os.environ'
)
for original in ('/usr/lib', None):
for runner, expected in ((linux_update._run_quiet, True),
(linux_update._run_install, None)):
with self.subTest(original=original, runner=runner.__name__):
env = dict(os.environ)
env.update(LD_LIBRARY_PATH=bundle, _PYI_APPLICATION_HOME_DIR=bundle)
env.pop('LD_LIBRARY_PATH_ORIG', None)
if original is not None:
env['LD_LIBRARY_PATH_ORIG'] = original
with mock.patch.dict(os.environ, env, clear=True), \
mock.patch.object(sys, '_MEIPASS', bundle, create=True):
self.assertEqual(
runner([sys.executable, '-c', probe, original or '']), expected,
)
self.assertEqual(os.environ['LD_LIBRARY_PATH'], bundle)
self.assertEqual(os.environ['_PYI_APPLICATION_HOME_DIR'], bundle)
class RelaunchEnvTest(unittest.TestCase):
def _env(self, env, meipass='/tmp/_MEI1'):
with mock.patch.dict(linux_update.os.environ, env, clear=True), \
mock.patch.object(linux_update.sys, '_MEIPASS', meipass, create=True):
return linux_update.relaunch_env()
def test_bundle_paths_are_dropped_and_originals_restored(self):
out = self._env({
'_PYI_APPLICATION_HOME_DIR': '/tmp/_MEI1',
'_MEIPASS': '/tmp/_MEI1',
'LD_LIBRARY_PATH': '/tmp/_MEI1',
'LD_LIBRARY_PATH_ORIG': '/usr/lib',
'GI_TYPELIB_PATH': '/tmp/_MEI1',
'PATH': os.pathsep.join(('/tmp/_MEI1', '/usr/bin')),
'HOME': '/home/u',
})
self.assertEqual(out['LD_LIBRARY_PATH'], '/usr/lib')
self.assertNotIn('GI_TYPELIB_PATH', out)
self.assertEqual(out['PATH'], '/usr/bin')
self.assertNotIn('_MEIPASS', out)
self.assertNotIn('_PYI_APPLICATION_HOME_DIR', out)
self.assertEqual(out['HOME'], '/home/u')
def test_environment_without_bundle_is_unchanged(self):
env = {'PATH': '/usr/bin', 'HOME': '/home/u'}
with mock.patch.dict(linux_update.os.environ, env, clear=True):
with mock.patch.object(linux_update.sys, '_MEIPASS', None, create=True):
out = linux_update.relaunch_env()
self.assertEqual(out, env)
class RestartAfterUpdateTest(unittest.TestCase):
def test_restart_releases_resources_before_launch_and_preserves_arguments(self):
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
import linux
events = []
exe = str(Path(tempfile.gettempdir()) / 'proxy')
with mock.patch.object(linux, 'stop_proxy', side_effect=lambda: events.append('stop')), \
mock.patch.object(linux, 'release_lock', side_effect=lambda: events.append('unlock')), \
mock.patch.object(linux_update, 'exe_path', return_value=Path(exe)), \
mock.patch.object(linux, 'relaunch_env', return_value={'PATH': '/usr/bin'}), \
mock.patch.object(sys, 'argv', ['proxy', '--portable']), \
mock.patch.object(linux.subprocess, 'Popen', side_effect=lambda *a, **k: events.append('launch')) as launch, \
mock.patch.object(linux.os, '_exit', side_effect=lambda code: events.append('exit')):
linux._restart_after_update()
self.assertEqual(events, ['stop', 'unlock', 'launch', 'exit'])
self.assertEqual(launch.call_args[0][0], [exe, '--portable'])
self.assertEqual(launch.call_args[1]['env'], {'PATH': '/usr/bin'})
def test_launch_failure_recovers_the_running_proxy_instead_of_exiting(self):
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
import linux
with ExitStack() as stack:
mocks = {name: stack.enter_context(mock.patch.object(linux, name)) for name in (
'stop_proxy', 'release_lock', 'acquire_lock', 'start_proxy', '_show_error',
)}
exit_process = stack.enter_context(mock.patch.object(linux.os, '_exit'))
stack.enter_context(mock.patch.object(linux.subprocess, 'Popen', side_effect=OSError('launch failed')))
linux._restart_after_update()
mocks['acquire_lock'].assert_called_once_with()
mocks['start_proxy'].assert_called_once_with(linux._config, mocks['_show_error'])
mocks['_show_error'].assert_called_once()
exit_process.assert_not_called()
if __name__ == '__main__':
unittest.main()
+271
View File
@@ -0,0 +1,271 @@
import asyncio
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 _CfWorkerPool, _WsPool
from proxy.raw_websocket import RawWebSocket, WsHandshakeError
from proxy.utils import ws_domains
class _StopRotation(Exception):
pass
def _open_ws():
transport = SimpleNamespace(is_closing=lambda: False)
writer = mock.Mock(transport=transport, drain=mock.AsyncMock(),
wait_closed=mock.AsyncMock())
return RawWebSocket(asyncio.StreamReader(), writer)
class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
async def test_warmup_preserves_media_ws_with_or_without_h2(self):
for cf, secure, opted_out, media in [(True, True, False, True),
(False, True, False, True),
(True, False, False, True),
(True, True, True, True)]:
with self.subTest(cf=cf, secure=secure, opted_out=opted_out), \
mock.patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.220'}), \
mock.patch.object(proxy_config, 'fallback_cfproxy', cf), \
mock.patch.object(proxy_config, 'disable_secure', not secure), \
mock.patch.object(proxy_config, 'cfproxy_h2_media', not opted_out), \
mock.patch.object(proxy_config, 'force_test_dc', False):
pool = _WsPool()
with mock.patch.object(pool, '_schedule_refill') as refill:
await pool.warmup()
keys = [call.args[0] for call in refill.call_args_list]
self.assertIn((2, False, False), keys)
self.assertEqual((2, True, False) in keys, media)
async def test_refills_partially_populated_bucket(self):
pool = _WsPool()
key = (2, False, False)
pool._idle[key] = deque([
(_open_ws(), time.monotonic()),
(_open_ws(), time.monotonic()),
])
async def stop_after_one_iteration(_delay):
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'])
class WsPoolTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.pool = _WsPool()
self.key = (2, False, False)
self.pool.WS_POOL_CHECK_INTERVAL = .01
for name, value in [('dc_redirects', {2: '192.0.2.1'}),
('pool_size', 1), ('force_test_dc', False)]:
patcher = mock.patch.object(proxy_config, name, value)
patcher.start()
self.addCleanup(patcher.stop)
self.addAsyncCleanup(self.pool.close)
async def wait_for(self, predicate):
async def wait():
while not predicate():
await asyncio.sleep(.001)
await asyncio.wait_for(wait(), 1)
async def test_regular_sessions_never_use_media_domain(self):
for dc in (2, 4):
with self.subTest(dc=dc), mock.patch(
'proxy.pool.RawWebSocket.connect',
side_effect=WsHandshakeError(302, 'redirect')) as connect:
self.assertIsNone(await self.pool._connect_one('192.0.2.1', ws_domains(dc, False)))
self.assertEqual([call.args[1] for call in connect.call_args_list],
[f'kws{dc}.web.telegram.org'] * 2)
async def test_media_tries_regular_domain_after_both_modes_fail(self):
for error in (asyncio.TimeoutError(), ConnectionResetError(),
WsHandshakeError(302, 'redirect'), WsHandshakeError(500, 'error')):
with self.subTest(error=type(error)), mock.patch(
'proxy.pool.RawWebSocket.connect',
side_effect=[error, error, _open_ws()]) as connect:
self.assertIsNotNone(await self.pool._connect_one('192.0.2.1', ws_domains(2, True)))
self.assertEqual([call.args[1] for call in connect.call_args_list],
['kws2-1.web.telegram.org'] * 2 + ['kws2.web.telegram.org'])
async def test_concurrent_preference_change_does_not_skip_fronting(self):
ws = _open_ws()
async def connect(*args, **kwargs):
if kwargs['sni'] is None:
self.pool.try_fronting_first = True
raise asyncio.TimeoutError()
return ws
with mock.patch('proxy.pool.RawWebSocket.connect', side_effect=connect) as dial:
self.assertIs(await self.pool._connect_one('192.0.2.1', ws_domains(2, False)), ws)
self.assertEqual(dial.await_count, 2)
async def test_miss_returns_without_waiting_and_starts_only_one_refill(self):
started = asyncio.Event()
async def connect(*args):
started.set()
await asyncio.Future()
with mock.patch.object(self.pool, '_connect_one', side_effect=connect) as dial:
for _ in range(10):
self.assertIsNone(await self.pool.get(2, False))
await asyncio.wait_for(started.wait(), 1)
self.assertEqual(dial.await_count, 1)
await self.pool.close()
async def test_empty_pool_recovers_in_background_after_backoff(self):
ws = _open_ws()
self.pool.REFILL_BACKOFF_INITIAL = .02
with mock.patch.object(self.pool, '_connect_one',
side_effect=[None, ws]) as dial:
self.assertIsNone(await self.pool.get(2, False))
await self.wait_for(lambda: self.key in self.pool._refill_after)
self.assertEqual(dial.await_count, 1)
# No new client requests are needed to resume refilling.
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
self.assertEqual(dial.await_count, 2)
self.assertNotIn(self.key, self.pool._refill_failures)
await self.pool.close()
async def test_ready_connection_is_available_before_slower_attempt(self):
ready = _open_ws()
slow_started = asyncio.Event()
calls = 0
async def connect(*args):
nonlocal calls
calls += 1
if calls == 1:
slow_started.set()
await asyncio.Future()
return ready
with mock.patch.object(proxy_config, 'pool_size', 2), \
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
await self.pool.get(2, False)
await asyncio.wait_for(slow_started.wait(), 1)
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
self.assertIs(await self.pool.get(2, False), ready)
await self.pool.close()
async def test_stale_connections_are_skipped(self):
for state in ('expired', 'eof', 'exception', 'closed', 'closing'):
with self.subTest(state=state):
stale, good = _open_ws(), _open_ws()
created = time.monotonic()
if state == 'expired':
created -= self.pool.WS_POOL_MAX_AGE
elif state == 'eof':
stale.reader.feed_eof()
elif state == 'exception':
stale.reader.set_exception(ConnectionResetError())
elif state == 'closed':
stale._closed = True
else:
stale.writer.transport.is_closing = lambda: True
self.pool._idle[self.key] = deque([(stale, created), (good, time.monotonic())])
with mock.patch.object(self.pool, '_schedule_refill'):
self.assertIs(await self.pool.get(2, False), good)
await asyncio.sleep(0)
async def test_hit_does_not_reset_failed_refill_backoff(self):
self.pool._idle[self.key] = deque([(_open_ws(), time.monotonic())])
self.pool._refill_failures[self.key] = 3
self.pool._refill_after[self.key] = time.monotonic() + 60
with mock.patch.object(self.pool, '_schedule_rotation'):
self.assertIsNotNone(await self.pool.get(2, False))
self.assertEqual(self.pool._refill_failures[self.key], 3)
self.assertFalse(self.pool._refilling)
async def test_zero_size_and_unconfigured_dc_do_not_connect(self):
with mock.patch.object(self.pool, '_connect_one') as dial:
self.assertIsNone(await self.pool.get(4, False))
with mock.patch.object(proxy_config, 'pool_size', 0):
await self.pool.warmup()
self.assertIsNone(await self.pool.get(2, False))
self.assertFalse(self.pool._rotating)
self.assertFalse(self.pool._refilling)
dial.assert_not_called()
async def test_test_dc_has_separate_pool_and_path(self):
prod = _open_ws()
self.pool._idle[self.key] = deque([(prod, time.monotonic())])
with mock.patch.object(self.pool, '_connect_one', return_value=_open_ws()) as dial:
self.assertIsNone(await self.pool.get(2, False, is_test_dc=True))
test_key = (2, False, True)
await self.wait_for(lambda: bool(self.pool._idle.get(test_key)))
dial.assert_awaited_once_with('192.0.2.1', ['kws2.web.telegram.org'], '/apiws_test')
self.assertIs(self.pool._idle[self.key][0][0], prod)
await self.pool.close()
async def test_close_cancels_refill_and_closes_unclaimed_connections(self):
ready = _open_ws()
idle = _open_ws()
started = asyncio.Event()
self.pool._idle[self.key] = deque([(idle, time.monotonic())])
async def connect(*args):
started.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
# Completion raced with shutdown; it must not leak into the pool.
return ready
with mock.patch.object(proxy_config, 'pool_size', 2), \
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
self.pool._schedule_refill(self.key, '192.0.2.1', ws_domains(2, False))
await asyncio.wait_for(started.wait(), 1)
await self.pool.close()
self.assertFalse(self.pool._idle)
self.assertFalse(self.pool._rotating)
self.assertFalse(self.pool._refilling)
ready.writer.close.assert_called_once()
idle.writer.close.assert_called_once()
class CfWorkerPoolTest(unittest.IsolatedAsyncioTestCase):
async def test_close_cancels_refill_and_closes_unclaimed_connections(self):
pool = _CfWorkerPool()
ready = _open_ws()
idle = _open_ws()
started = asyncio.Event()
pool._idle[2] = deque([(idle, time.monotonic(), 'a.example')])
async def connect(*args):
started.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
return ready, 'b.example'
with mock.patch.object(proxy_config, 'pool_size', 1), \
mock.patch.object(pool, '_connect_one', side_effect=connect):
pool._schedule_refill(4, '192.0.2.4', ['b.example'])
await asyncio.wait_for(started.wait(), 1)
await pool.close()
self.assertFalse(pool._idle)
self.assertFalse(pool._refilling)
ready.writer.close.assert_called_once()
idle.writer.close.assert_called_once()
if __name__ == '__main__':
unittest.main()
+188
View File
@@ -0,0 +1,188 @@
import asyncio
import unittest
from unittest.mock import AsyncMock, Mock, patch
from proxy.raw_websocket import RawWebSocket, WsHandshakeError, _xor_mask
def _raw_frame(opcode, data, fin=True):
b0 = (0x80 if fin else 0x00) | opcode
n = len(data)
if n < 126:
return bytes([b0, n]) + data
if n < 65536:
return bytes([b0, 126]) + n.to_bytes(2, 'big') + data
return bytes([b0, 127]) + n.to_bytes(8, 'big') + data
class _NullWriter:
def write(self, data):
pass
async def drain(self):
pass
def _recv(chunks, cls=RawWebSocket):
async def _run():
reader = asyncio.StreamReader()
for chunk in chunks:
reader.feed_data(chunk)
reader.feed_eof()
ws = cls(reader, _NullWriter())
return ws, await ws.recv()
return asyncio.run(_run())
class XorMaskTest(unittest.TestCase):
def test_matches_bytewise_xor_at_fast_path_and_frame_boundaries(self):
for length in (0, 1, 2, 3, 4, 5, 125, 126, 127, 255, 511, 512, 513, 514, 515,
16384, 65535, 65536, 65537, 1024 * 1024 + 3):
data = (bytes(range(256)) * (length // 256 + 1))[:length]
for mask in (b'\x00' * 4, b'\xff' * 4, b'\x01\x7f\x80\xff'):
with self.subTest(length=length, mask=mask):
expected = bytes(value ^ mask[index % 4] for index, value in enumerate(data))
self.assertEqual(_xor_mask(data, mask), expected)
def test_roundtrip(self):
data = bytes(range(256)) * 3
mask = b'\x01\x02\x03\x04'
self.assertEqual(_xor_mask(_xor_mask(data, mask), mask), data)
def test_empty_payload(self):
self.assertEqual(_xor_mask(b'', b'\x01\x02\x03\x04'), b'')
class BuildFrameTest(unittest.TestCase):
def test_masked_frame_wire_format_across_all_length_encodings(self):
mask = b'\x01\x7f\x80\xff'
for length in (1, 125, 126, 511, 512, 513, 65535, 65536, 65537):
payload = (bytes(range(256)) * (length // 256 + 1))[:length]
with self.subTest(length=length), patch('proxy.raw_websocket.os.urandom', return_value=mask):
frame = RawWebSocket._build_frame(RawWebSocket.OP_BINARY, payload, mask=True)
if length < 126:
header = bytes([0x82, 0x80 | length])
elif length < 65536:
header = b'\x82\xfe' + length.to_bytes(2, 'big')
else:
header = b'\x82\xff' + length.to_bytes(8, 'big')
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
self.assertEqual(frame, header + mask + masked)
def test_short_unmasked_frame(self):
self.assertEqual(
RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'abc'),
b'\x82\x03abc',
)
def test_extended_length_selects_16bit_header(self):
frame = RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'x' * 200)
self.assertEqual(frame[:2], b'\x82\x7e')
self.assertEqual(int.from_bytes(frame[2:4], 'big'), 200)
def test_masked_frame_sets_mask_bit_and_is_reversible(self):
payload = b'payload'
frame = RawWebSocket._build_frame(
RawWebSocket.OP_BINARY, payload, mask=True)
self.assertTrue(frame[1] & 0x80)
self.assertEqual(_xor_mask(frame[6:], frame[2:6]), payload)
class RecvTest(unittest.TestCase):
def test_returns_unfragmented_message(self):
_, msg = _recv([_raw_frame(RawWebSocket.OP_BINARY, b'hello')])
self.assertEqual(msg, b'hello')
def test_reassembles_fragmented_message(self):
_, msg = _recv([
_raw_frame(RawWebSocket.OP_BINARY, b'AAA', False),
_raw_frame(RawWebSocket.OP_CONT, b'BBB', False),
_raw_frame(RawWebSocket.OP_CONT, b'CCC', True),
])
self.assertEqual(msg, b'AAABBBCCC')
def test_control_frame_between_fragments_is_skipped(self):
_, msg = _recv([
_raw_frame(RawWebSocket.OP_BINARY, b'AAA', False),
_raw_frame(RawWebSocket.OP_PONG, b''),
_raw_frame(RawWebSocket.OP_CONT, b'BBB', True),
])
self.assertEqual(msg, b'AAABBB')
def test_close_frame_returns_none(self):
ws, msg = _recv([_raw_frame(RawWebSocket.OP_CLOSE, b'\x03\xe8')])
self.assertIsNone(msg)
self.assertTrue(ws._closed)
def test_oversized_frame_is_rejected_before_reading_payload(self):
header = bytes([0x82, 127]) + (1 << 40).to_bytes(8, 'big')
with self.assertRaises(ConnectionError):
_recv([header])
def test_reassembled_message_exceeding_limit_is_rejected(self):
class _Capped(RawWebSocket):
__slots__ = ()
MAX_MESSAGE_LEN = 1500
chunk = b'x' * 1024
with self.assertRaises(ConnectionError):
_recv([
_raw_frame(RawWebSocket.OP_BINARY, chunk, False),
_raw_frame(RawWebSocket.OP_CONT, chunk, False),
], cls=_Capped)
class ParseCloseTest(unittest.TestCase):
def test_known_code_gets_name(self):
code, reason = RawWebSocket._parse_close(b'\x03\xe8bye')
self.assertEqual(code, 1000)
self.assertIn('normal', reason)
def test_empty_payload(self):
self.assertEqual(RawWebSocket._parse_close(b''), (None, ''))
class HandshakeErrorTest(unittest.TestCase):
def test_redirect_status_codes(self):
for code in (301, 302, 303, 307, 308):
self.assertTrue(WsHandshakeError(code, '').is_redirect)
for code in (0, 200, 429, 502):
self.assertFalse(WsHandshakeError(code, '').is_redirect)
class HandshakeCleanupTest(unittest.IsolatedAsyncioTestCase):
async def test_cancelling_pending_upgrade_closes_socket(self):
reader = asyncio.StreamReader()
writer = Mock(drain=AsyncMock())
started = asyncio.Event()
async def drain():
started.set()
writer.drain.side_effect = drain
with patch('proxy.raw_websocket.asyncio.open_connection',
AsyncMock(return_value=(reader, writer))), \
patch('proxy.raw_websocket.set_sock_opts'):
task = asyncio.create_task(RawWebSocket.connect('192.0.2.1', 'example.org'))
await asyncio.wait_for(started.wait(), 1)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
writer.close.assert_called_once()
async def test_failed_upgrade_closes_socket(self):
for error in (ConnectionResetError(), asyncio.TimeoutError()):
with self.subTest(error=type(error)):
reader = Mock(readline=AsyncMock(side_effect=error))
writer = Mock(drain=AsyncMock())
with patch('proxy.raw_websocket.asyncio.open_connection',
AsyncMock(return_value=(reader, writer))), \
patch('proxy.raw_websocket.set_sock_opts'):
with self.assertRaises(type(error)):
await RawWebSocket.connect('192.0.2.1', 'example.org')
writer.close.assert_called_once()
if __name__ == '__main__':
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
import asyncio
import time
import unittest
from unittest.mock import AsyncMock, Mock, patch
from proxy import bridge
class TcpFallbackTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
bridge.reset_tcp_backoff()
self.addCleanup(bridge.reset_tcp_backoff)
self.key = ('192.0.2.1', 443)
self.remote = Mock(drain=AsyncMock(), wait_closed=AsyncMock())
async def fallback(self, dst='192.0.2.1', port=443):
return await bridge._tcp_fallback(
Mock(), Mock(), dst, port, b'init', 'test', Mock())
async def test_failure_backoff_doubles_caps_and_skips_new_connections(self):
with patch.object(bridge.asyncio, 'open_connection',
AsyncMock(side_effect=asyncio.TimeoutError())) as connect:
for delay in (30, 60, 120, 240, 480, 960, 1920, 3600, 3600):
bridge._tcp_retry_after[self.key] = 0
start = time.monotonic()
self.assertFalse(await self.fallback())
remaining = bridge._tcp_retry_after[self.key] - start
self.assertGreaterEqual(remaining, delay - .001)
self.assertLess(remaining, delay + 1)
count = connect.await_count
self.assertFalse(await self.fallback())
self.assertEqual(connect.await_count, count)
self.assertFalse(bridge._tcp_connecting)
async def test_other_ips_and_ports_are_not_blocked(self):
bridge._tcp_retry_after[self.key] = time.monotonic() + 60
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))) as connect, \
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()):
self.assertTrue(await self.fallback('192.0.2.2'))
self.assertTrue(await self.fallback(port=80))
self.assertEqual(connect.await_count, 2)
async def test_success_clears_backoff_and_allows_following_sessions(self):
bridge._tcp_failures[self.key] = 5
bridge._tcp_retry_after[self.key] = 0
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))) as connect, \
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()):
self.assertTrue(await self.fallback())
self.assertNotIn(self.key, bridge._tcp_failures)
self.assertNotIn(self.key, bridge._tcp_retry_after)
self.assertTrue(await self.fallback())
self.assertEqual(connect.await_count, 2)
async def test_concurrent_requests_only_start_one_connection_attempt(self):
started = asyncio.Event()
release = asyncio.Event()
async def connect(*args):
started.set()
await release.wait()
raise ConnectionRefusedError()
with patch.object(bridge.asyncio, 'open_connection', side_effect=connect) as dial:
first = asyncio.create_task(self.fallback())
try:
await asyncio.wait_for(started.wait(), 1)
results = await asyncio.gather(*(self.fallback() for _ in range(8)))
self.assertEqual(results, [False] * 8)
self.assertEqual(dial.await_count, 1)
release.set()
self.assertFalse(await first)
finally:
first.cancel()
await asyncio.gather(first, return_exceptions=True)
async def test_init_failure_closes_socket_and_enters_backoff(self):
self.remote.drain.side_effect = ConnectionResetError()
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))), \
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()) as forward:
self.assertFalse(await self.fallback())
self.remote.close.assert_called_once()
forward.assert_not_awaited()
self.assertIn(self.key, bridge._tcp_retry_after)
async def test_cancellation_releases_attempt_without_marking_ip_blocked(self):
started = asyncio.Event()
async def drain():
started.set()
await asyncio.Future()
self.remote.drain.side_effect = drain
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))):
task = asyncio.create_task(self.fallback())
await asyncio.wait_for(started.wait(), 1)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
self.remote.close.assert_called_once()
self.assertFalse(bridge._tcp_connecting)
self.assertFalse(bridge._tcp_failures)
if __name__ == '__main__':
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
import unittest
from unittest import mock
from utils import tray_common
class CheckUpdateAsyncTest(unittest.TestCase):
def test_disabled_check_does_not_start_a_thread(self):
called = []
with mock.patch.object(tray_common.threading, 'Thread') as thread:
tray_common.check_update_async(
{'check_updates': False}, lambda: False, lambda v, u: called.append(v),
)
thread.assert_not_called()
self.assertEqual(called, [])
def test_update_is_reported_to_the_handler(self):
status = {'has_update': True, 'latest': '2.0.0', 'html_url': 'https://x.invalid/r'}
seen = []
with mock.patch.object(tray_common.threading, 'Thread') as thread, \
mock.patch.object(tray_common.time, 'sleep'), \
mock.patch('utils.update_check.run_check'), \
mock.patch('utils.update_check.get_status', return_value=status):
tray_common.check_update_async(
{'check_updates': True}, lambda: False,
lambda version, url: seen.append((version, url)),
)
thread.call_args.kwargs['target']()
self.assertEqual(seen, [('2.0.0', 'https://x.invalid/r')])
def test_no_update_means_no_handler_call(self):
seen = []
with mock.patch.object(tray_common.threading, 'Thread') as thread, \
mock.patch.object(tray_common.time, 'sleep'), \
mock.patch('utils.update_check.run_check'), \
mock.patch('utils.update_check.get_status',
return_value={'has_update': False}):
tray_common.check_update_async(
{'check_updates': True}, lambda: False,
lambda version, url: seen.append(version),
)
thread.call_args.kwargs['target']()
self.assertEqual(seen, [])
if __name__ == '__main__':
unittest.main()
+337
View File
@@ -0,0 +1,337 @@
import hashlib
import io
import json
import os
import sys
import tempfile
import threading
import unittest
from http.client import IncompleteRead
from pathlib import Path
from unittest import mock
from urllib.error import HTTPError, URLError
from utils import update_check
from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt
PAYLOAD = b'payload'
DIGEST = 'sha256:' + hashlib.sha256(PAYLOAD).hexdigest()
class ParseVersionTupleTest(unittest.TestCase):
def test_plain_and_prefixed_versions(self):
self.assertEqual(_parse_version_tuple('1.9.1'), (1, 9, 1))
self.assertEqual(_parse_version_tuple('v1.9.1'), (1, 9, 1))
self.assertEqual(_parse_version_tuple(' V2.0 '), (2, 0))
def test_trailing_suffixes_are_truncated_to_digits(self):
self.assertEqual(_parse_version_tuple('1.9.1rc2'), (1, 9, 1))
self.assertEqual(_parse_version_tuple('1.9.1-beta'), (1, 9, 1))
def test_empty_and_non_numeric_segments(self):
self.assertEqual(_parse_version_tuple(''), (0,))
self.assertEqual(_parse_version_tuple(None), (0,))
self.assertEqual(_parse_version_tuple('1.x.3'), (1, 0, 3))
class VersionGtTest(unittest.TestCase):
def test_newer_versions(self):
self.assertTrue(_version_gt('1.9.2', '1.9.1'))
self.assertTrue(_version_gt('1.10.0', '1.9.9'))
self.assertTrue(_version_gt('2.0', '1.9.9'))
def test_equal_and_older_versions(self):
self.assertFalse(_version_gt('1.9.1', '1.9.1'))
self.assertFalse(_version_gt('1.9.1', '1.9.2'))
self.assertFalse(_version_gt('1.9', '1.9.0'))
def test_shorter_version_is_padded_with_zeros(self):
self.assertTrue(_version_gt('1.9.1', '1.9'))
self.assertFalse(_version_gt('1.9', '1.9.1'))
class ExtractAssetsTest(unittest.TestCase):
def test_keeps_name_url_and_digest(self):
data = {'assets': [{
'name': 'TgWsProxy_windows.exe',
'browser_download_url': 'https://example.invalid/a.exe',
'digest': 'sha256:abc',
}]}
self.assertEqual(_extract_assets(data), [{
'name': 'TgWsProxy_windows.exe',
'url': 'https://example.invalid/a.exe',
'digest': 'sha256:abc',
}])
def test_drops_entries_without_name_or_url(self):
data = {'assets': [
{'name': 'a.exe'},
{'browser_download_url': 'https://example.invalid/b.exe'},
]}
self.assertEqual(_extract_assets(data), [])
def test_missing_digest_becomes_empty_string(self):
data = {'assets': [{
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe',
}]}
self.assertEqual(_extract_assets(data)[0]['digest'], '')
def test_empty_input(self):
self.assertEqual(_extract_assets(None), [])
self.assertEqual(_extract_assets({}), [])
class _Response(io.BytesIO):
def __init__(self, body, content_length='auto'):
super().__init__(body)
if content_length == 'auto':
content_length = len(body)
self.headers = {} if content_length is None else {
'Content-Length': str(content_length),
}
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
return False
def _opener(response):
opener = mock.Mock()
opener.open.return_value = response
return mock.Mock(return_value=opener)
class DownloadAssetTest(unittest.TestCase):
def _download(self, response, digest=DIGEST):
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
path = update_check.download_asset(
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=digest,
)
try:
return path.read_bytes(), sorted(p.name for p in Path(tmp).iterdir())
finally:
path.unlink(missing_ok=True)
def test_complete_download_is_kept(self):
body, files = self._download(_Response(b'payload'))
self.assertEqual(body, b'payload')
self.assertEqual(len(files), 1)
def test_matching_digest_is_accepted(self):
digest = 'sha256:' + hashlib.sha256(b'payload').hexdigest()
body, _ = self._download(_Response(b'payload'), digest=digest)
self.assertEqual(body, b'payload')
def test_invalid_or_missing_digest_is_rejected_before_any_download(self):
for digest in ('', None, 123, 'md5:whatever', 'sha256:ab', 'sha256:' + 'z' * 64):
with self.subTest(digest=digest), tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(update_check, 'build_github_opener') as opener:
with self.assertRaises(ValueError):
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=digest)
opener.assert_not_called()
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_truncated_download_raises_and_leaves_no_file(self):
with tempfile.TemporaryDirectory() as tmp:
resp = _Response(b'pay', content_length=None)
with mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
with self.assertRaises(OSError):
update_check.download_asset(
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=DIGEST,
)
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_digest_mismatch_raises_and_leaves_no_file(self):
with tempfile.TemporaryDirectory() as tmp:
resp = _Response(b'payload')
with mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
with self.assertRaises(OSError):
update_check.download_asset(
'https://example.invalid/a', Path(tmp),
suffix='.bin', digest='sha256:' + '0' * 64,
)
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_missing_content_length_is_tolerated(self):
body, _ = self._download(_Response(b'payload', content_length=None))
self.assertEqual(body, b'payload')
def test_same_length_corruption_is_rejected(self):
with self.assertRaisesRegex(OSError, 'SHA-256'):
self._download(_Response(b'payloae'))
def test_uppercase_checksum_is_accepted(self):
body, _ = self._download(_Response(PAYLOAD), digest=DIGEST.upper())
self.assertEqual(body, PAYLOAD)
def test_stream_failure_removes_partial_file(self):
for error in (TimeoutError('read timed out'), IncompleteRead(b'pay', 4)):
with self.subTest(error=error), tempfile.TemporaryDirectory() as tmp:
response = mock.MagicMock()
response.__enter__.return_value = response
response.read.side_effect = [b'pay', error]
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
with self.assertRaises(type(error)):
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=DIGEST)
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_hash_covers_every_chunk_and_read_timeout_is_set(self):
body = PAYLOAD * 20000
opener = _opener(_Response(body))
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(update_check, 'build_github_opener', opener):
path = update_check.download_asset(
'https://example.invalid/a', Path(tmp),
digest='sha256:' + hashlib.sha256(body).hexdigest(), timeout=7,
)
self.assertEqual(path.read_bytes(), body)
opener.return_value.open.assert_called_once_with('https://example.invalid/a', timeout=7)
class FindAssetTest(unittest.TestCase):
def test_returns_asset_with_digest(self):
assets = [{'name': 'a.deb', 'url': 'https://example.invalid/a.deb', 'digest': DIGEST}]
with mock.patch.dict(update_check._state, {'assets': assets}):
self.assertEqual(update_check.find_asset('a.deb'), assets[0])
self.assertIsNone(update_check.find_asset('b.deb'))
def test_ignores_asset_without_url(self):
with mock.patch.dict(update_check._state, {'assets': [{'name': 'a.deb', 'url': ''}]}):
self.assertIsNone(update_check.find_asset('a.deb'))
def test_assets_without_a_valid_checksum_require_manual_installation(self):
for digest in ('', 'sha256:ab', 'md5:' + '0' * 32):
asset = {'name': 'a.exe', 'url': 'https://example.invalid/a.exe', 'digest': digest}
with self.subTest(digest=digest), mock.patch.dict(update_check._state, {'assets': [asset]}):
self.assertIsNone(update_check.find_asset('a.exe'))
class RunCheckTest(unittest.TestCase):
def setUp(self):
self.patch = mock.patch.object(update_check, '_state', update_check.get_status())
self.patch.start()
self.addCleanup(self.patch.stop)
def _check(self, data, current='1.11.1'):
with mock.patch.object(update_check, 'fetch_latest_release', return_value=data):
update_check.run_check(current)
return update_check.get_status()
def test_versions_are_compared_and_assets_published_together(self):
release = {'tag_name': ' v2.0 ', 'assets': [{
'name': 'app.exe', 'browser_download_url': 'https://example.invalid/app.exe', 'digest': DIGEST,
}]}
for current, has_update, ahead in (('1.11.1', True, False), ('2.0.0', False, False), ('2.1', False, True)):
with self.subTest(current=current):
state = self._check(release, current)
self.assertTrue(state['checked'])
self.assertIsNone(state['error'])
self.assertEqual((state['has_update'], state['ahead_of_release']), (has_update, ahead))
self.assertEqual(state['latest'], '2.0')
self.assertEqual(state['html_url'], update_check.RELEASES_PAGE_URL)
self.assertEqual(state['assets'][0]['digest'], DIGEST)
def test_invalid_response_is_an_error_not_a_claim_that_the_app_is_current(self):
for data in (None, [], {}, {'tag_name': None}, {'tag_name': ''}, {'tag_name': 'oops'},
{'tag_name': 'v2.0', 'assets': 'invalid'}):
with self.subTest(data=data):
state = self._check(data)
self.assertTrue(state['error'])
self.assertFalse(state['has_update'])
self.assertIsNone(state['latest'])
self.assertEqual(state['assets'], [])
def test_release_tag_suffixes_keep_the_existing_version_comparison(self):
state = self._check({'tag_name': 'v1.12.0-hotfix'})
self.assertIsNone(state['error'])
self.assertTrue(state['has_update'])
self.assertEqual(state['latest'], '1.12.0-hotfix')
def test_network_failure_clears_old_assets_and_does_not_prevent_retry(self):
errors = (URLError('offline'), HTTPError('https://example.invalid', 403, 'rate limit', {}, None),
TimeoutError(), IncompleteRead(b'{', 5), ValueError('bad json'))
for error in errors:
with self.subTest(error=error):
self._check({'tag_name': 'v2.0', 'assets': [{
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
}]})
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=error):
update_check.run_check('1.11.1')
state = update_check.get_status()
self.assertTrue(state['error'])
self.assertEqual(state['assets'], [])
self.assertFalse(state['has_update'])
self.assertIsNone(self._check({'tag_name': 'v2.1'})['error'])
def test_readers_do_not_see_partially_updated_status(self):
previous = self._check({'tag_name': 'v1.11.1'})
started, finish = threading.Event(), threading.Event()
def fetch():
started.set()
finish.wait(timeout=5)
return {'tag_name': 'v2.0'}
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=fetch):
worker = threading.Thread(target=update_check.run_check, args=('1.11.1',))
worker.start()
try:
self.assertTrue(started.wait(timeout=5))
self.assertEqual(update_check.get_status(), previous)
finally:
finish.set()
worker.join(timeout=5)
self.assertEqual(update_check.get_status()['latest'], '2.0')
def test_asset_and_status_snapshots_cannot_modify_internal_state(self):
self._check({'tag_name': 'v2.0', 'assets': [{
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
}]})
asset = update_check.find_asset('a.exe')
asset['digest'] = 'bad'
snapshot = update_check.get_status()
snapshot['assets'][0]['digest'] = 'bad'
snapshot['assets'].clear()
self.assertEqual(update_check.find_asset('a.exe')['digest'], DIGEST)
def test_old_cache_is_ignored_and_no_new_cache_is_written(self):
from utils import tray_common
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / '.update_check_cache.json'
old = '{"last_attempt_at": "invalid", "assets": "invalid"}'
path.write_text(old, encoding='utf-8')
with mock.patch.object(tray_common, 'APP_DIR', Path(tmp)):
self.assertTrue(self._check({'tag_name': 'v2.0'})['has_update'])
self.assertEqual(path.read_text(encoding='utf-8'), old)
self.assertEqual(list(Path(tmp).iterdir()), [path])
def test_fetch_is_one_request_without_etag_and_with_timeout(self):
opener = _opener(_Response(json.dumps({'tag_name': 'v2.0'}).encode()))
with mock.patch.object(update_check, 'build_github_opener', opener):
self.assertEqual(update_check.fetch_latest_release(timeout=7)['tag_name'], 'v2.0')
args, kwargs = opener.return_value.open.call_args
self.assertEqual(args[0].full_url, update_check.RELEASES_LATEST_API)
self.assertFalse(args[0].has_header('If-none-match'))
self.assertEqual(kwargs['timeout'], 7)
class RelaunchEnvTest(unittest.TestCase):
def test_old_and_new_bootloader_variables_and_bundle_subdirectories_are_removed(self):
bundle = str(Path(tempfile.gettempdir()) / '_MEItest')
env = {
'_PYI_APPLICATION_HOME_DIR': bundle, '_PYI_ARCHIVE_FILE': 'old.exe',
'_MEIPASS': bundle, '_MEIPASS2': bundle,
'PATH': os.pathsep.join((bundle, bundle + os.sep + 'lib', bundle + '-other')),
}
with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(sys, '_MEIPASS', bundle, create=True):
result = update_check.relaunch_env()
self.assertEqual(result, {'PATH': bundle + '-other', 'PYINSTALLER_RESET_ENVIRONMENT': '1'})
if __name__ == '__main__':
unittest.main()
+169
View File
@@ -0,0 +1,169 @@
import hashlib
import io
import sys
import tempfile
import unittest
from contextlib import ExitStack
from pathlib import Path
from unittest import mock
from utils import update_check, windows_update
class AssetNameTest(unittest.TestCase):
def test_keeps_the_running_build_even_when_renamed_or_emulated(self):
builds = (
('win-amd64', (3, 11), 'TgWsProxy_windows.exe'),
('win-amd64', (3, 8), 'TgWsProxy_windows_7_64bit.exe'),
('win32', (3, 8), 'TgWsProxy_windows_7_32bit.exe'),
('win-arm64', (3, 11), 'TgWsProxy_windows_arm64.exe'),
('unknown', (3, 11), None),
)
for platform, version, expected in builds:
with self.subTest(platform=platform, version=version), \
mock.patch.object(windows_update.sysconfig, 'get_platform', return_value=platform), \
mock.patch.object(sys, 'version_info', version), \
mock.patch.object(sys, 'executable', 'renamed.exe'):
self.assertEqual(windows_update.asset_name(), expected)
class ApplyUpdateTest(unittest.TestCase):
def setUp(self):
stack = ExitStack()
self.addCleanup(stack.close)
directory = stack.enter_context(tempfile.TemporaryDirectory())
self.exe = Path(directory) / 'renamed app.exe'
self.exe.write_bytes(b'old')
self.backup = self.exe.with_name('renamed app_oldtgws.exe')
self.opener = mock.Mock()
self.opener.open.return_value = io.BytesIO(b'new')
self.asset = {'url': 'https://example.invalid/new.exe',
'digest': 'sha256:' + hashlib.sha256(b'new').hexdigest()}
stack.enter_context(mock.patch.object(sys, 'executable', str(self.exe)))
stack.enter_context(mock.patch.object(sys, 'argv', [str(self.exe), '--portable']))
stack.enter_context(mock.patch.object(sys, '_MEIPASS', None, create=True))
stack.enter_context(mock.patch.object(update_check, 'build_github_opener', return_value=self.opener))
stack.enter_context(mock.patch.object(windows_update.subprocess, 'DETACHED_PROCESS', 8, create=True))
stack.enter_context(mock.patch.object(windows_update.subprocess, 'CREATE_NEW_PROCESS_GROUP', 512, create=True))
self.launch = stack.enter_context(mock.patch.object(windows_update.subprocess, 'Popen'))
self.prepare = mock.Mock()
def test_verified_update_keeps_legacy_backup_and_launch_arguments(self):
def prepare():
self.assertEqual(self.exe.read_bytes(), b'new')
self.assertEqual(self.backup.read_bytes(), b'old')
self.launch.assert_not_called()
windows_update.apply_update(self.asset, prepare)
self.assertEqual(self.exe.read_bytes(), b'new')
self.assertEqual(self.backup.read_bytes(), b'old')
self.assertEqual(set(self.exe.parent.iterdir()), {self.exe, self.backup})
self.assertEqual(self.launch.call_args[0][0], [str(self.exe), '--portable'])
self.assertEqual(self.launch.call_args[1]['creationflags'], 520)
def test_missing_or_corrupt_checksum_does_not_replace_or_launch(self):
for digest in ('', 'sha256:' + '0' * 64):
with self.subTest(digest=digest):
self.asset['digest'] = digest
with self.assertRaises((OSError, ValueError)):
windows_update.apply_update(self.asset, self.prepare)
self.assertEqual(self.exe.read_bytes(), b'old')
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
self.prepare.assert_not_called()
self.launch.assert_not_called()
def test_launch_failure_restores_the_old_executable(self):
self.launch.side_effect = OSError('launch failed')
with self.assertRaisesRegex(OSError, 'launch failed'):
windows_update.apply_update(self.asset, self.prepare)
self.prepare.assert_called_once_with()
self.assertEqual(self.exe.read_bytes(), b'old')
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
def test_rename_failure_leaves_current_executable_and_cleans_download(self):
with mock.patch.object(Path, 'rename', side_effect=PermissionError('in use')):
with self.assertRaises(PermissionError):
windows_update.apply_update(self.asset, self.prepare)
self.assertEqual(self.exe.read_bytes(), b'old')
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
self.prepare.assert_not_called()
self.launch.assert_not_called()
def test_replacement_failure_rolls_back_and_cleans_download(self):
replace = Path.replace
def fail_download_move(path, target):
if path.suffix == '.tmp':
raise PermissionError('move failed')
return replace(path, target)
with mock.patch.object(Path, 'replace', fail_download_move):
with self.assertRaisesRegex(PermissionError, 'move failed'):
windows_update.apply_update(self.asset, self.prepare)
self.assertEqual(self.exe.read_bytes(), b'old')
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
self.prepare.assert_not_called()
self.launch.assert_not_called()
def test_restart_uses_a_fresh_bundle_and_resets_dll_directory(self):
dll = mock.Mock()
with mock.patch.object(sys, '_MEIPASS', 'old-bundle'), \
mock.patch.object(windows_update.ctypes, 'windll', dll, create=True):
windows_update.apply_update(self.asset, self.prepare)
self.assertEqual(self.launch.call_args[1]['env']['PYINSTALLER_RESET_ENVIRONMENT'], '1')
calls = dll.kernel32.SetDllDirectoryW.call_args_list
self.assertEqual(calls[0], mock.call(None))
self.assertEqual(calls[1].args[0].value, 'old-bundle')
@unittest.skipUnless(sys.platform == 'win32', 'Windows tray integration')
class UpdateFlowTest(unittest.TestCase):
def setUp(self):
import windows
self.windows = windows
def test_fallback_dialog_actually_installs_the_selected_asset(self):
app = self.windows
asset = {'url': 'https://example.invalid/new.exe', 'digest': 'sha256:' + '0' * 64}
with mock.patch.object(app, 'ctk', None), \
mock.patch.object(app._u32, 'MessageBoxW', return_value=app._IDYES), \
mock.patch.object(app, '_perform_update') as update:
app.update_ctk_form('update', asset=asset)
update.assert_called_once_with(asset)
def test_failed_launch_recovers_proxy_and_lock_instead_of_exiting(self):
app = self.windows
def fail(asset, before_restart):
before_restart()
raise OSError('launch failed')
with ExitStack() as stack:
mocks = {name: stack.enter_context(mock.patch.object(app, name)) for name in (
'apply_update', 'stop_proxy', 'release_lock', '_release_win_mutex',
'_acquire_win_mutex', 'acquire_lock', 'start_proxy', '_show_error',
)}
exit_process = stack.enter_context(mock.patch.object(app.os, '_exit'))
mocks['apply_update'].side_effect = fail
mocks['_acquire_win_mutex'].return_value = None
app._perform_update({'url': 'asset', 'digest': 'digest'})
mocks['stop_proxy'].assert_called_once_with()
mocks['release_lock'].assert_called_once_with()
mocks['_release_win_mutex'].assert_called_once_with()
mocks['acquire_lock'].assert_called_once_with()
mocks['start_proxy'].assert_called_once_with(app._config, mocks['_show_error'])
mocks['_show_error'].assert_called_once()
exit_process.assert_not_called()
def test_download_failure_leaves_proxy_and_locks_running(self):
app = self.windows
with mock.patch.object(app, 'apply_update', side_effect=OSError('checksum mismatch')), \
mock.patch.object(app, 'stop_proxy') as stop, \
mock.patch.object(app, 'release_lock') as unlock, \
mock.patch.object(app, '_show_error'), \
mock.patch.object(app.os, '_exit') as exit_process:
app._perform_update({})
stop.assert_not_called()
unlock.assert_not_called()
exit_process.assert_not_called()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import logging
import queue
import threading
log = logging.getLogger("tg-ws-tray")
class BackgroundTask:
def __init__(self, owner):
self.owner = owner
self.running = False
self.closed = False
self.pending = None
owner.bind("<Destroy>", self._on_destroy, add="+")
def _on_destroy(self, event):
if event.widget is self.owner:
self.closed = True
if self.pending is not None:
self.owner.after_cancel(self.pending)
self.pending = None
def start(self, work, complete, finish):
if self.running or self.closed:
return
self.running = True
results = queue.Queue()
def run():
try:
results.put((True, work()))
except Exception:
log.exception("Background task failed")
results.put((False, None))
def poll():
self.pending = None
if self.closed:
return
try:
success, result = results.get_nowait()
except queue.Empty:
self.pending = self.owner.after(50, poll)
return
self.running = False
try:
if success:
complete(result)
finally:
if not self.closed:
finish()
threading.Thread(target=run, daemon=True, name="ui-background-task").start()
self.pending = self.owner.after(50, poll)
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
import base64
import os
import socket as _socket
from contextlib import nullcontext
from tkinter import messagebox
from typing import Any
from proxy.utils import create_ssl_context
from ui.i18n import t
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
_CFWORKER_TEST_DST = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100',
}
def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict:
ctx = create_ssl_context() if secure else None
port = 443 if secure else 80
results = {}
for dc, connect_host, sni_host, req_host, path in cases:
try:
with _socket.create_connection((connect_host, port), timeout=5) as raw:
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()
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {req_host}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"Sec-WebSocket-Protocol: binary\r\n"
f"\r\n"
).encode()
ssock.sendall(req)
ssock.settimeout(5)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = ssock.recv(512)
if not chunk:
break
buf += chunk
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
if "101" in first:
results[dc] = True
else:
results[dc] = first or t("connectivity.no_response")
ssock.close()
raw.close()
except _socket.timeout:
results[dc] = t("connectivity.timeout")
except OSError as exc:
msg = str(exc)
results[dc] = msg[:60] if len(msg) > 60 else msg
return results
def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
host = f"kws{dc}.{domain}"
cases.append((dc, host, host, host, "/apiws"))
return _run_connectivity_test(cases, secure=secure)
def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
dst = _CFWORKER_TEST_DST[dc]
path = f"/apiws?dst={dst}&dc={dc}&media=0"
cases.append((dc, domain, domain, domain, path))
return _run_connectivity_test(cases, secure=secure)
def run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains}
def run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains}
def run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple:
merged: dict = {}
best_domain = None
for domain in reversed(domains):
res = _run_cfproxy_connectivity_test(domain, secure=secure)
if all(v is True for v in res.values()):
return domain, res
for dc, v in res.items():
if v is True:
merged[dc] = True
best_domain = domain
elif dc not in merged:
merged[dc] = v
return best_domain, merged
def show_connectivity_results(title_base: str, results: dict,
domain: str = '', label_prefix: str = 'DC',
auto_mode: bool = False,
unavailable_message: str = '', *, parent: Any) -> None:
ok = [dc for dc, v in results.items() if v is True]
total = len(_CFPROXY_TEST_DCS)
if auto_mode:
if domain:
title = t("connectivity.available", title=title_base)
msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
else:
title = t("connectivity.unavailable", title=title_base)
msg = unavailable_message
else:
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
title = t("connectivity.all_ok", title=title_base)
msg = t("connectivity.all_ok_domain", total=total, domain=domain)
elif not ok:
title = t("connectivity.unavailable", title=title_base)
errors = "\n".join(
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
for dc, v in fail
)
msg = t("connectivity.none_ok", domain=domain, errors=errors)
else:
title = t("connectivity.partial", title=title_base)
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = "\n".join(
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
for dc, v in fail
)
msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list)
messagebox.showinfo(title, msg, parent=parent)
def show_multi_connectivity_results(title_base: str, per_domain: dict,
label_prefix: str = 'DC', *, parent: Any) -> None:
total = len(_CFPROXY_TEST_DCS)
all_ok = True
any_ok = False
blocks = []
for domain, results in per_domain.items():
ok = [dc for dc, v in results.items() if v is True]
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
any_ok = True
blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
elif not ok:
all_ok = False
blocks.append(t("connectivity.multi_fail", domain=domain))
else:
all_ok = False
any_ok = True
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail)
blocks.append(
t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
)
if all_ok:
title = t("connectivity.all_ok", title=title_base)
elif any_ok:
title = t("connectivity.partial", title=title_base)
else:
title = t("connectivity.unavailable", title=title_base)
msg = "\n\n".join(blocks)
messagebox.showinfo(title, msg, parent=parent)
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from typing import Any
from ui.ctk_theme import CtkTheme
from ui.ctk_tooltip import attach_tooltip_to_widgets
def create_entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = {
"font": (theme.ui_font_family, 13), "corner_radius": radius,
"fg_color": theme.bg, "border_color": theme.field_border,
"border_width": 1, "text_color": theme.text_primary,
}
if var is not None:
opts["textvariable"] = var
if width:
opts["width"] = width
opts["height"] = height
opts.update(kw)
return ctk.CTkEntry(parent, **opts)
def create_checkbox(ctk, parent, theme, text, variable):
return ctk.CTkCheckBox(
parent, text=text, variable=variable,
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2, border_color=theme.field_border,
)
def create_label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
weight = "bold" if bold else "normal"
return ctk.CTkLabel(
parent, text=text,
font=(theme.ui_font_family, size, weight),
text_color=theme.text_secondary if secondary else theme.text_primary,
anchor="w", **kw,
)
def create_labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
col = ctk.CTkFrame(parent, fg_color="transparent")
lbl = create_label(ctk, col, theme, label_text)
lbl.pack(anchor="w", pady=(0, 2))
var = ctk.StringVar(master=parent, value=str(value))
ent = create_entry(ctk, col, theme, var=var, width=width)
if pack_fill:
ent.pack(fill="x")
else:
ent.pack(anchor="w")
if tip:
attach_tooltip_to_widgets([lbl, ent, col], tip)
return col, var
def create_config_section(
ctk: Any,
parent: Any,
theme: CtkTheme,
title: str,
*,
bottom_spacer: int = 6,
) -> Any:
wrap = ctk.CTkFrame(parent, fg_color="transparent")
wrap.pack(fill="x", pady=(0, bottom_spacer))
create_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
card = ctk.CTkFrame(
wrap, fg_color=theme.field_bg, corner_radius=10,
border_width=1, border_color=theme.field_border,
)
card.pack(fill="x")
inner = ctk.CTkFrame(card, fg_color="transparent")
inner.pack(fill="x", padx=10, pady=8)
return inner
def create_combobox(ctk, parent, theme, *, variable, values, command=None):
return ctk.CTkComboBox(
parent, values=values, variable=variable, command=command,
height=32, font=(theme.ui_font_family, 12),
text_color=theme.text_primary, fg_color=theme.bg,
border_color=theme.field_border, button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8, state="readonly",
)
+46 -35
View File
@@ -1,8 +1,3 @@
"""
Общая светлая тема и фабрика окон CustomTkinter для tray-приложений (Windows / Linux).
Цвета и отступы задаются в одном месте — правки темы не дублируются по платформам.
"""
from __future__ import annotations
import sys
@@ -13,11 +8,7 @@ from typing import Any, Callable, Optional, Tuple
_tk_variable_del_guard_installed = False
def _install_tkinter_variable_del_guard() -> None:
"""
Убирает «Exception ignored» при выходе процесса: Tcl уже разрушен, а GC ещё
вызывает Variable.__del__ (StringVar и т.д.) — напр. окно CTk в фоновом потоке.
"""
def install_tkinter_variable_del_guard() -> None:
global _tk_variable_del_guard_installed
if _tk_variable_del_guard_installed:
return
@@ -32,24 +23,24 @@ def _install_tkinter_variable_del_guard() -> None:
tkinter.Variable.__del__ = _safe_variable_del # type: ignore[assignment]
_tk_variable_del_guard_installed = True
# Размеры и отступы (единые для диалогов настроек и первого запуска)
CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560)
CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14)
FIRST_RUN_SIZE: Tuple[int, int] = (520, 440)
FIRST_RUN_SIZE: Tuple[int, int] = (520, 480)
FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24)
@dataclass(frozen=True)
class CtkTheme:
"""Палитра Telegram-style и семейства шрифтов для UI и моноширинного текста."""
tg_blue: tuple = ("#3390ec", "#3390ec")
tg_blue_hover: tuple = ("#2b7cd4", "#2b7cd4")
bg: tuple = ("#ffffff", "#1e1e1e")
field_bg: tuple = ("#f0f2f5", "#2b2b2b")
field_border: tuple = ("#d6d9dc", "#3a3a3a")
text_primary: tuple = ("#000000", "#ffffff")
text_secondary: tuple = ("#707579", "#aaaaaa")
tg_blue: str = "#3390ec"
tg_blue_hover: str = "#2b7cd4"
bg: str = "#ffffff"
field_bg: str = "#f0f2f5"
field_border: str = "#d6d9dc"
text_primary: str = "#000000"
text_secondary: str = "#707579"
ui_font_family: str = "Sans"
mono_font_family: str = "Monospace"
@@ -60,18 +51,20 @@ def ctk_theme_for_platform() -> CtkTheme:
return CtkTheme()
def apply_ctk_appearance(ctk: Any) -> None:
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("blue")
_APPEARANCE_MODE_MAP = {"auto": "system", "light": "Light", "dark": "Dark"}
def apply_ctk_appearance(ctk: Any, mode: str = "auto") -> None:
ctk.set_appearance_mode(_APPEARANCE_MODE_MAP.get(mode, "system"))
ctk.set_default_color_theme("blue")
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}")
def create_ctk_root(
def create_ctk_toplevel(
ctk: Any,
*,
title: str,
@@ -81,21 +74,39 @@ def create_ctk_root(
topmost: bool = True,
after_create: Optional[Callable[[Any], None]] = None,
) -> Any:
"""
Создаёт CTk: глобальная тема, заголовок, без ресайза, по центру экрана, фон из палитры.
after_create — опционально: установка иконки окна (различается по ОС).
"""
_install_tkinter_variable_del_guard()
apply_ctk_appearance(ctk)
root = ctk.CTk()
root = ctk.CTkToplevel()
if sys.platform == "win32":
def handle_ctrl_shortcut(event: Any) -> Optional[str]:
shortcuts = {65: ("a", "<<SelectAll>>"), 67: ("c", "<<Copy>>"), 86: ("v", "<<Paste>>")}
shortcut = shortcuts.get(event.keycode)
if shortcut and event.keysym.lower() != shortcut[0] and not event.state & 0x20000:
event.widget.event_generate(shortcut[1])
return "break"
return None
root.bind("<Control-KeyPress>", handle_ctrl_shortcut, add="+")
root.title(title)
root.resizable(False, False)
if topmost:
root.attributes("-topmost", True)
center_ctk_geometry(root, width, height)
root.configure(fg_color=theme.bg)
if topmost:
root.attributes("-topmost", True)
root.lift()
root.focus_force()
if after_create:
after_create(root)
_after_id = root.after(300, lambda: after_create(root))
_orig_destroy = root.destroy
def _safe_destroy():
try:
root.after_cancel(_after_id)
except Exception:
pass
_orig_destroy()
root.destroy = _safe_destroy
return root
@@ -109,4 +120,4 @@ def main_content_frame(
) -> Any:
frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0)
frame.pack(fill="both", expand=True, padx=padx, pady=pady)
return frame
return frame
+13 -21
View File
@@ -1,16 +1,10 @@
"""
Всплывающие подсказки для CustomTkinter / tk: задержка, Toplevel без рамки, wrap.
"""
from __future__ import annotations
import tkinter as tk
import customtkinter as ctk
from typing import Any, List, Optional
class CtkTooltip:
"""Показ текста при наведении на виджет."""
def __init__(
self,
widget: Any,
@@ -24,13 +18,15 @@ class CtkTooltip:
self.delay_ms = delay_ms
self.wraplength = wraplength
self._after_id: Optional[str] = None
self._tip: Optional[tk.Toplevel] = None
self._tip: Optional[ctk.CTkToplevel] = None
widget.bind("<Enter>", self._schedule, add="+")
widget.bind("<Leave>", self._hide, add="+")
widget.bind("<Button>", self._hide, add="+")
widget.bind("<Destroy>", self._on_destroy, add="+")
def _schedule(self, _event: Any = None) -> None:
if self.widget is None:
return
self._cancel_after()
self._after_id = self.widget.after(self.delay_ms, self._show)
@@ -52,27 +48,24 @@ class CtkTooltip:
except Exception:
return
tw = tk.Toplevel(self.widget.winfo_toplevel())
tw = ctk.CTkToplevel(self.widget.winfo_toplevel())
tw.wm_overrideredirect(True)
try:
tw.wm_attributes("-topmost", True)
except Exception:
pass
tw.configure(bg="#2b2b2b")
lbl = tk.Label(
tw.configure(fg_color="#2b2b2b")
lbl = ctk.CTkLabel(
tw,
text=self.text,
justify="left",
wraplength=self.wraplength,
background="#2b2b2b",
foreground="#f0f0f0",
relief="flat",
borderwidth=0,
padx=10,
pady=8,
font=("Segoe UI", 10) if _is_windows() else None,
fg_color="#2b2b2b",
text_color="#f0f0f0",
corner_radius=0,
font=("Segoe UI", 14) if _is_windows() else None,
)
lbl.pack()
lbl.pack(padx=10, pady=8)
x = self.widget.winfo_rootx() + 12
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
tw.wm_geometry(f"+{x}+{y}")
@@ -89,6 +82,7 @@ class CtkTooltip:
def _on_destroy(self, _event: Any = None) -> None:
self._hide()
self.widget = None
def _is_windows() -> bool:
@@ -104,11 +98,9 @@ def attach_ctk_tooltip(
delay_ms: int = 450,
wraplength: int = 320,
) -> None:
"""Повесить подсказку на виджет (CTk или tk)."""
CtkTooltip(widget, text, delay_ms=delay_ms, wraplength=wraplength)
def attach_tooltip_to_widgets(widgets: List[Any], text: str, **kwargs: Any) -> None:
"""Одна и та же подсказка на несколько виджетов (подпись + поле)."""
for w in widgets:
attach_ctk_tooltip(w, text, **kwargs)
+54 -481
View File
@@ -1,66 +1,17 @@
"""
Общая разметка CustomTkinter для tray (Windows / Linux): настройки и первый запуск.
Логика сохранения и колбэки остаются в платформенных модулях.
"""
from __future__ import annotations
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from typing import Any, Callable, Tuple
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status
from ui.ctk_theme import (
FIRST_RUN_FRAME_PAD,
CtkTheme,
main_content_frame,
from proxy import get_link_host
from ui.ctk_controls import create_checkbox
from ui.ctk_theme import FIRST_RUN_FRAME_PAD, CtkTheme, main_content_frame
from ui.ctk_tooltip import attach_ctk_tooltip
from ui.i18n import t
from ui.settings_form import (
TrayConfigFormWidgets as TrayConfigFormWidgets,
install_tray_config_form as install_tray_config_form,
validate_config_form as validate_config_form,
)
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
# Подсказки для формы настроек (новые пользователи)
_TIP_HOST = (
"Адрес, на котором прокси принимает SOCKS5-подключения.\n"
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
)
_TIP_PORT = (
"Порт SOCKS5. В Telegram Desktop в настройках прокси должен быть "
"указан тот же порт"
)
_TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 2:149.154.167.220. "
"Прокси по этим правилам направляет трафик к нужным серверам Telegram"
)
_TIP_VERBOSE = (
"Если включено, в файл логов пишется больше подробностей — "
"необходимо при поиске неполадок"
)
_TIP_BUF_KB = (
"Размер буфера приёма/передачи в килобайтах.\n"
"Больше значение — больше выделение памяти на сокет"
)
_TIP_POOL = (
"Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\n"
"Увеличение может помочь при высокой нагрузке"
)
_TIP_LOG_MB = (
"Максимальный размер файла лога; при достижении лимита файл перезаписывается"
)
_TIP_AUTOSTART = (
"Запускать TG WS Proxy при входе в Windows. "
"Если вы переместите программу в другую папку, автозапуска сбросится"
)
_TIP_CHECK_UPDATES = (
"При запуске проверять наличие обновлений"
)
_TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
# Внутренняя ширина полей относительно ширины окна настроек (см. CONFIG_DIALOG_SIZE)
_CONFIG_FORM_INNER_WIDTH = 396
def tray_settings_scroll_and_footer(
@@ -68,10 +19,6 @@ def tray_settings_scroll_and_footer(
content_parent: Any,
theme: CtkTheme,
) -> Tuple[Any, Any]:
"""
Нижняя панель под кнопки и прокручиваемая область для формы (форма не обрезает кнопки).
Возвращает (scroll_frame, footer_frame).
"""
footer = ctk.CTkFrame(content_parent, fg_color=theme.bg)
footer.pack(side="bottom", fill="x")
scroll = ctk.CTkScrollableFrame(
@@ -82,399 +29,13 @@ def tray_settings_scroll_and_footer(
scrollbar_button_hover_color=theme.text_secondary,
)
scroll.pack(fill="both", expand=True)
try:
scroll._parent_canvas.configure(yscrollincrement=4)
except Exception:
pass
return scroll, footer
def _config_section(
ctk: Any,
parent: Any,
theme: CtkTheme,
title: str,
*,
bottom_spacer: int = 6,
) -> Any:
"""Заголовок секции и карточка с рамкой для группировки полей."""
wrap = ctk.CTkFrame(parent, fg_color="transparent")
wrap.pack(fill="x", pady=(0, bottom_spacer))
ctk.CTkLabel(
wrap,
text=title,
font=(theme.ui_font_family, 12, "bold"),
text_color=theme.text_primary,
anchor="w",
).pack(anchor="w", pady=(0, 2))
card = ctk.CTkFrame(
wrap,
fg_color=theme.field_bg,
corner_radius=10,
border_width=1,
border_color=theme.field_border,
)
card.pack(fill="x")
inner = ctk.CTkFrame(card, fg_color="transparent")
inner.pack(fill="x", padx=10, pady=8)
return inner
@dataclass
class TrayConfigFormWidgets:
host_var: Any
port_var: Any
dc_textbox: Any
verbose_var: Any
adv_entries: List[Any]
adv_keys: Tuple[str, ...]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
def install_tray_config_form(
ctk: Any,
frame: Any,
theme: CtkTheme,
cfg: dict,
default_config: dict,
*,
show_autostart: bool = False,
autostart_value: bool = False,
) -> TrayConfigFormWidgets:
"""Поля настроек прокси внутри уже созданного `frame`."""
header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel(
header,
text="Настройки прокси",
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary,
anchor="w",
).pack(side="left")
ctk.CTkLabel(
header,
text=f"v{__version__}",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
anchor="e",
).pack(side="right")
inner_w = _CONFIG_FORM_INNER_WIDTH
conn = _config_section(ctk, frame, theme, "Подключение SOCKS5")
host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x")
host_col = ctk.CTkFrame(host_row, fg_color="transparent")
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
host_lbl = ctk.CTkLabel(
host_col,
text="IP-адрес",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
anchor="w",
)
host_lbl.pack(anchor="w", pady=(0, 2))
host_var = ctk.StringVar(value=cfg.get("host", default_config["host"]))
host_entry = ctk.CTkEntry(
host_col,
textvariable=host_var,
width=160,
height=36,
font=(theme.ui_font_family, 13),
corner_radius=10,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
)
host_entry.pack(fill="x", pady=(0, 0))
attach_tooltip_to_widgets([host_lbl, host_entry, host_col], _TIP_HOST)
port_col = ctk.CTkFrame(host_row, fg_color="transparent")
port_col.pack(side="left")
port_lbl = ctk.CTkLabel(
port_col,
text="Порт",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
anchor="w",
)
port_lbl.pack(anchor="w", pady=(0, 2))
port_var = ctk.StringVar(value=str(cfg.get("port", default_config["port"])))
port_entry = ctk.CTkEntry(
port_col,
textvariable=port_var,
width=100,
height=36,
font=(theme.ui_font_family, 13),
corner_radius=10,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
)
port_entry.pack(anchor="w")
attach_tooltip_to_widgets([port_lbl, port_entry, port_col], _TIP_PORT)
dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)")
dc_lbl = ctk.CTkLabel(
dc_inner,
text="По одному правилу на строку, формат: номер:IP",
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
)
dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
dc_inner,
width=inner_w,
height=88,
font=(theme.mono_font_family, 12),
corner_radius=10,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
)
dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC)
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
verbose_cb = ctk.CTkCheckBox(
log_inner,
text="Подробное логирование (verbose)",
variable=verbose_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
)
verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE)
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x")
adv_rows = [
("Буфер, КБ (по умолчанию 256)", "buf_kb", _TIP_BUF_KB),
("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL),
("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB),
]
for lbl, key, tip in adv_rows:
col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent")
col_frame.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
adv_l = ctk.CTkLabel(
col_frame,
text=lbl,
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
)
adv_l.pack(anchor="w", pady=(0, 2))
adv_e = ctk.CTkEntry(
col_frame,
width=inner_w,
height=32,
font=(theme.ui_font_family, 13),
corner_radius=8,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
textvariable=ctk.StringVar(
value=str(cfg.get(key, default_config[key]))
),
)
adv_e.pack(fill="x")
attach_tooltip_to_widgets([adv_l, adv_e, col_frame], tip)
adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
upd_inner = _config_section(ctk, frame, theme, "Обновления")
st = get_status()
check_updates_var = ctk.BooleanVar(
value=bool(
cfg.get("check_updates", default_config.get("check_updates", True))
)
)
upd_cb = ctk.CTkCheckBox(
upd_inner,
text="Проверять обновления при запуске",
variable=check_updates_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
if st.get("error"):
upd_status = "Не удалось связаться с GitHub. Проверьте сеть."
elif not st.get("checked"):
upd_status = "Статус появится после фоновой проверки при запуске."
elif st.get("has_update") and st.get("latest"):
upd_status = (
f"На GitHub доступна версия {st['latest']} "
f"(у вас {__version__})."
)
elif st.get("ahead_of_release") and st.get("latest"):
upd_status = (
f"У вас {__version__} — новее последнего релиза на GitHub "
f"({st['latest']})."
)
else:
upd_status = "Установлена последняя известная версия с GitHub."
ctk.CTkLabel(
upd_inner,
text=upd_status,
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
justify="left",
wraplength=inner_w,
).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
open_rel_btn = ctk.CTkButton(
upd_inner,
text="Открыть страницу релиза",
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),
)
open_rel_btn.pack(anchor="w")
autostart_var = None
if show_autostart:
sys_inner = _config_section(
ctk, frame, theme, "Запуск Windows", bottom_spacer=4
)
autostart_var = ctk.BooleanVar(value=autostart_value)
as_cb = ctk.CTkCheckBox(
sys_inner,
text="Автозапуск при включении компьютера",
variable=autostart_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
)
as_cb.pack(anchor="w", pady=(0, 4))
as_hint = ctk.CTkLabel(
sys_inner,
text="Если переместить программу в другую папку, запись автозапуска может сброситься.",
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
justify="left",
wraplength=inner_w,
)
as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART)
return TrayConfigFormWidgets(
host_var=host_var,
port_var=port_var,
dc_textbox=dc_textbox,
verbose_var=verbose_var,
adv_entries=adv_entries,
adv_keys=adv_keys,
autostart_var=autostart_var,
check_updates_var=check_updates_var,
)
def merge_adv_from_form(
widgets: TrayConfigFormWidgets,
base: Dict[str, Any],
default_config: dict,
) -> None:
"""Дополняет base значениями buf_kb / pool_size / log_max_mb (in-place)."""
for i, key in enumerate(widgets.adv_keys):
col_frame = widgets.adv_entries[i]
entry = col_frame.winfo_children()[1]
try:
val = float(entry.get().strip())
if key in ("buf_kb", "pool_size"):
val = int(val)
base[key] = val
except ValueError:
base[key] = default_config[key]
def validate_config_form(
widgets: TrayConfigFormWidgets,
default_config: dict,
*,
include_autostart: bool,
) -> Union[dict, str]:
"""
Возвращает словарь полей конфига или строку ошибки для показа пользователю.
"""
import socket as _sock
host_val = widgets.host_var.get().strip()
try:
_sock.inet_aton(host_val)
except OSError:
return "Некорректный IP-адрес."
try:
port_val = int(widgets.port_var.get().strip())
if not (1 <= port_val <= 65535):
raise ValueError
except ValueError:
return "Порт должен быть числом 1-65535"
lines = [
l.strip()
for l in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
if l.strip()
]
try:
tg_ws_proxy.parse_dc_ip_list(lines)
except ValueError as e:
return str(e)
new_cfg: Dict[str, Any] = {
"host": host_val,
"port": port_val,
"dc_ip": lines,
"verbose": widgets.verbose_var.get(),
}
if include_autostart:
new_cfg["autostart"] = (
widgets.autostart_var.get()
if widgets.autostart_var is not None
else False
)
merge_adv_from_form(widgets, new_cfg, default_config)
if widgets.check_updates_var is not None:
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
return new_cfg
def install_tray_config_buttons(
ctk: Any,
frame: Any,
@@ -492,22 +53,22 @@ def install_tray_config_buttons(
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
btn_frame.pack(fill="x", pady=(0, 0))
save_btn = ctk.CTkButton(
btn_frame, text="Сохранить", height=38,
btn_frame, text=t("button.save"), height=38,
font=(theme.ui_font_family, 14, "bold"), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff",
command=on_save)
save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8))
attach_ctk_tooltip(save_btn, _TIP_SAVE)
attach_ctk_tooltip(save_btn, t("tip.save"))
cancel_btn = ctk.CTkButton(
btn_frame, text="Отмена", height=38,
btn_frame, text=t("button.cancel"), height=38,
font=(theme.ui_font_family, 14), corner_radius=10,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=on_cancel)
cancel_btn.pack(side="right", fill="x", expand=True)
attach_ctk_tooltip(cancel_btn, _TIP_CANCEL)
attach_ctk_tooltip(cancel_btn, t("tip.cancel"))
def populate_first_run_window(
@@ -517,12 +78,11 @@ def populate_first_run_window(
*,
host: str,
port: int,
secret: str,
on_done: Callable[[bool], None],
) -> None:
"""
Содержимое окна первого запуска. on_done(open_in_telegram) — по «Начать» и по закрытию окна.
"""
tg_url = f"tg://socks?server={host}&port={port}"
link_host = get_link_host(host)
tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
fpx, fpy = FIRST_RUN_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@@ -533,26 +93,43 @@ def populate_first_run_window(
width=4, height=32, corner_radius=2)
accent_bar.pack(side="left", padx=(0, 12))
ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее",
ctk.CTkLabel(title_frame, text=t("first_run.title"),
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary).pack(side="left")
sections = [
("Как подключить Telegram Desktop:", True),
(" Автоматически:", True),
(" ПКМ по иконке в трее → «Открыть в Telegram»", False),
(f" Или ссылка: {tg_url}", False),
("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → {host} : {port} (без логина/пароля)", False),
(t("first_run.how_to"), True),
(t("first_run.auto"), True),
(t("first_run.auto_hint"), False),
(t("first_run.auto_link", url=tg_url), False),
("\n" + t("first_run.manual"), True),
(t("first_run.manual_path"), False),
(t("first_run.manual_mtproto", host=link_host, port=port), False),
(t("first_run.manual_secret", secret=secret), False),
]
textbox = ctk.CTkTextbox(
frame,
font=(theme.ui_font_family, 13),
fg_color=theme.bg,
border_width=0,
text_color=theme.text_primary,
activate_scrollbars=False,
wrap="word",
height=275,
)
textbox._textbox.tag_configure("bold", font=(theme.ui_font_family, 13, "bold"))
textbox._textbox.configure(spacing1=1, spacing3=1)
for text, bold in sections:
weight = "bold" if bold else "normal"
ctk.CTkLabel(frame, text=text,
font=(theme.ui_font_family, 13, weight),
text_color=theme.text_primary,
anchor="w", justify="left").pack(anchor="w", pady=1)
if text.startswith("\n"):
textbox.insert("end", "\n")
text = text[1:]
if bold:
textbox.insert("end", text + "\n", "bold")
else:
textbox.insert("end", text + "\n")
textbox.configure(state="disabled")
textbox.pack(anchor="w", fill="x")
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
@@ -560,17 +137,13 @@ def populate_first_run_window(
corner_radius=0).pack(fill="x", pady=(0, 12))
auto_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(frame, text="Открыть прокси в Telegram сейчас",
variable=auto_var, font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2,
border_color=theme.field_border).pack(anchor="w", pady=(0, 16))
create_checkbox(ctk, frame, theme, t("first_run.open_now"),
auto_var).pack(anchor="w", pady=(0, 16))
def on_ok():
on_done(auto_var.get())
ctk.CTkButton(frame, text="Начать", width=180, height=42,
ctk.CTkButton(frame, text=t("button.start"), width=180, height=42,
font=(theme.ui_font_family, 15, "bold"), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff",
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
import json
import locale
import os
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Tuple, Union
LocaleInput = Union[str, "LocaleEnum"]
class LocaleEnum(str, Enum):
russian = "ru"
english = "en"
@classmethod
def parse(cls, value: LocaleInput) -> LocaleEnum:
if isinstance(value, cls):
return value
try:
return cls(value)
except ValueError:
return _DEFAULT_LOCALE
_module_path = Path(__file__)
if not _module_path.is_absolute():
_module_path = Path.cwd() / _module_path
_LOCALES_DIR = _module_path.parent
_DEFAULT_LOCALE = LocaleEnum.english
_translations: Dict[str, str] = {}
_current_lang: LocaleEnum = _DEFAULT_LOCALE
_config_value: LocaleEnum = _DEFAULT_LOCALE
_LANGUAGE_TO_LABEL: Dict[LocaleEnum, str] = {}
_LABEL_TO_LANGUAGE: Dict[str, LocaleEnum] = {}
def _locale_json_files() -> Tuple[str, ...]:
return tuple(
p.stem for p in sorted(_LOCALES_DIR.glob("*.json")) if p.stem != "manifest"
)
def supported_languages() -> Tuple[str, ...]:
"""Locale codes that have a JSON catalog on disk (e.g. ru, en)."""
return _locale_json_files()
def content_locales() -> Tuple[LocaleEnum, ...]:
return tuple(
LocaleEnum(stem)
for stem in _locale_json_files()
if stem in LocaleEnum._value2member_map_
)
def detect_system_language() -> LocaleEnum:
"""Pick the best locale from available catalogs, else Russian."""
available = content_locales()
if not available:
return _DEFAULT_LOCALE
for getter in (locale.getlocale, locale.getdefaultlocale):
try:
loc = getter()
if loc and loc[0]:
code = loc[0].split("_")[0].lower()
try:
candidate = LocaleEnum(code)
if candidate in available:
return candidate
except ValueError:
pass
except Exception:
pass
for env_key in ("LC_ALL", "LC_MESSAGES", "LANG"):
val = os.environ.get(env_key, "")
if val:
code = val.split(".")[0].split("_")[0].lower()
try:
candidate = LocaleEnum(code)
if candidate in available:
return candidate
except ValueError:
pass
return _DEFAULT_LOCALE
def resolve_language(config_value: LocaleInput) -> LocaleEnum:
loc = LocaleEnum.parse(config_value)
if loc.value in supported_languages():
return loc
return _DEFAULT_LOCALE
def _load_locale(lang: LocaleEnum) -> Dict[str, str]:
path = _LOCALES_DIR / f"{lang.value}.json"
with open(path, encoding="utf-8") as f:
return json.load(f)
def set_language(config_value: LocaleInput) -> LocaleEnum:
global _translations, _current_lang, _config_value
_config_value = LocaleEnum.parse(config_value)
_current_lang = resolve_language(_config_value)
_translations = _load_locale(_current_lang)
refresh_language_option_maps()
return _current_lang
def get_language() -> LocaleEnum:
return _current_lang
def get_config_language() -> LocaleEnum:
return _config_value
def t(key: str, **kwargs: Any) -> str:
text = _translations.get(key, key)
if kwargs:
try:
return text.format(**kwargs)
except (KeyError, IndexError, ValueError):
return text
return text
def language_option_labels() -> List[Tuple[LocaleEnum, str]]:
"""Config values and display labels for the language combobox."""
return [
(loc, t(f"language.{loc.value}"))
for loc in content_locales()
]
def language_label_for_config(value: LocaleInput) -> str:
loc = LocaleEnum.parse(value)
labels = language_option_labels()
for cfg_val, label in labels:
if cfg_val == loc:
return label
return labels[0][1] if labels else _DEFAULT_LOCALE.value
def refresh_language_option_maps() -> None:
global _LANGUAGE_TO_LABEL, _LABEL_TO_LANGUAGE
_LANGUAGE_TO_LABEL = dict(language_option_labels())
_LABEL_TO_LANGUAGE = {label: val for val, label in _LANGUAGE_TO_LABEL.items()}
def language_from_label(label: str) -> LocaleEnum:
return _LABEL_TO_LANGUAGE.get(label, _DEFAULT_LOCALE)
def label_from_language(value: LocaleInput) -> str:
loc = LocaleEnum.parse(value)
return _LANGUAGE_TO_LABEL.get(
loc,
_LANGUAGE_TO_LABEL.get(_DEFAULT_LOCALE, _DEFAULT_LOCALE.value),
)
set_language(detect_system_language())
+155
View File
@@ -0,0 +1,155 @@
{
"app.name": "TG WS Proxy",
"app.error_title": "TG WS Proxy — Error",
"app.settings_title": "TG WS Proxy — Settings",
"app.update_title": "TG WS Proxy — Update",
"language.ru": "Русский",
"language.en": "English",
"appearance.auto": "Auto",
"appearance.light": "Light",
"appearance.dark": "Dark",
"settings.title": "Settings",
"settings.language": "Language",
"settings.theme": "Theme",
"section.interface": "Interface",
"section.mtproto": "MTProto Connection",
"section.dc": "Telegram Data Centers (DC → IP)",
"section.cfproxy": "Cloudflare Proxy",
"label.h2_enable": "Media multiplexing (HTTP/2)",
"tip.h2": "Share one HTTP/2 connection per data center and domain for media downloads through Cloudflare.\nRequires CF proxy and TLS enabled.",
"section.cfworker": "Cloudflare Worker",
"section.logs": "Logs & Performance",
"section.updates": "Updates",
"section.windows_startup": "Windows Startup",
"label.host": "IP address",
"label.port": "Port",
"label.secret": "Secret",
"label.dc_hint": "One rule per line, format: number:IP",
"label.cf_enable": "Enable CF proxy",
"label.cf_custom_domain": "Custom domain",
"label.cfworker_domains": "Cloudflare Worker domains (comma-separated)",
"label.verbose": "Verbose logging",
"label.no_secure": "Disable TLS for CF-proxy and CF-worker",
"label.buf_kb": "Buffer, KB (default 256)",
"label.pool_size": "WebSocket session pool (default 4)",
"label.log_max_mb": "Max log size, MB (default 5)",
"label.check_updates": "Check for updates on startup",
"label.autostart": "Start on system boot",
"label.autostart_hint": "If you move the app to another folder, the autostart entry may reset.",
"tip.host": "Address the proxy listens on.\nUsually 127.0.0.1 for localhost, 0.0.0.0 for all interfaces",
"tip.port": "Proxy port. Telegram Desktop proxy settings must use the same port",
"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.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.pool": "How many ready WebSocket connections to keep per data center.\n0 disables the direct DC->IP WebSocket route",
"tip.log_mb": "Maximum log file size; the file is overwritten when the limit is reached",
"tip.autostart": "Launch TG WS Proxy on Windows login. If you move the app to another folder, autostart will reset",
"tip.check_updates": "Check for updates on startup",
"tip.cfproxy": "Use Cloudflare proxy for unreachable data centers",
"tip.cfproxy_domain": "Your own domains proxied through Cloudflare for HTTP/2 and WS connections.\nSeparate multiple domains with commas.\nIf empty — chosen automatically from supported domains",
"tip.cfproxy_user_domain_cb": "Specify your own domains instead of automatic selection",
"tip.cfworker_domain": "Cloudflare Worker domains (e.g. name.account.workers.dev).\nSeparate multiple domains with commas.\nThe proxy routes connections to Telegram DCs by IP through them",
"tip.save": "Save settings",
"tip.cancel": "Close without saving changes",
"button.save": "Save",
"button.cancel": "Cancel",
"button.test": "Test",
"button.test_loading": "...",
"button.open_release": "Open release page",
"button.start": "Get started",
"button.update": "Update",
"button.check_updates": "Check for updates",
"button.page": "Page",
"button.close": "Close",
"button.donate": "Donate ♥",
"validation.bad_host": "Invalid IP address.",
"validation.bad_port": "Port must be a number between 1 and 65535",
"validation.bad_secret_len": "Secret must be exactly 32 hex characters (16 bytes).",
"validation.bad_secret_hex": "Secret must contain only hex characters (0-9, a-f).",
"validation.dc_format": "Invalid DC:IP format: {entry}",
"validation.dc_invalid": "Invalid DC:IP entry: {entry}",
"connectivity.cfproxy_title": "CF Proxy",
"connectivity.cfworker_title": "CF Worker",
"connectivity.timeout": "timeout",
"connectivity.no_response": "no response",
"connectivity.available": "{title}: available",
"connectivity.unavailable": "{title}: unavailable",
"connectivity.all_ok": "{title}: all working",
"connectivity.partial": "{title}: partially working",
"connectivity.auto_ok": "✓ {title} works. {ok} of {total} servers reachable.",
"connectivity.all_ok_domain": "✓ All {total} servers reachable via {domain}.",
"connectivity.none_ok": "✗ No servers respond via {domain}.\n\nErrors:\n{errors}",
"connectivity.partial_detail": "Domain: {domain}\n\n✓ Working: {ok_list}\n\n✗ Unreachable:\n{fail_list}",
"connectivity.error_line": " {prefix}{dc}: {error}",
"connectivity.cf_auto_fail": "✗ None of the automatic CF domains respond.",
"connectivity.multi_all_ok": "✓ {domain}: all {total} servers reachable",
"connectivity.multi_fail": "✗ {domain}: unavailable",
"connectivity.multi_partial": "~ {domain}: working {ok_list}; unreachable {fail_list}",
"updates.status_error": "Could not reach GitHub. Check your network.",
"updates.status_pending": "Updates have not been checked yet.",
"updates.status_checking": "Checking for updates...",
"updates.status_available": "Version {latest} is available on GitHub (you have {current}).",
"updates.status_ahead": "You have {current} — newer than the latest GitHub release ({latest}).",
"updates.status_latest": "Latest known version from GitHub is installed.",
"first_run.title": "Proxy is running in the system tray",
"first_run.how_to": "How to connect Telegram Desktop:",
"first_run.auto": " Automatically:",
"first_run.auto_hint": " Right-click tray icon → «Open in Telegram»",
"first_run.auto_link": " Or copy the link, send it to yourself in TG and click it: {url}",
"first_run.manual": " Manually:",
"first_run.manual_path": " Settings → Advanced → Connection type → Proxy",
"first_run.manual_mtproto": " MTProto → {host} : {port}",
"first_run.manual_secret": " Secret: dd{secret}",
"first_run.open_now": "Open proxy in Telegram now",
"tray.open_telegram": "Open in Telegram ({host}:{port})",
"tray.copy_link": "Copy link",
"tray.restart": "Restart proxy",
"tray.settings": "Settings...",
"tray.logs": "Open logs",
"tray.update": "Update ({current} → {new})",
"tray.exit": "Exit",
"dialog.restart_title": "Restart?",
"dialog.restart_body": "Settings saved.\n\nRestart the proxy now?",
"dialog.already_running": "Application is already running, check the tray",
"dialog.log_not_found": "Log file has not been created yet.",
"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_fail": "Failed to copy link:\n{error}",
"dialog.open_tg_fail": "Could not open Telegram automatically.\n\n{detail}",
"dialog.open_tg_fail_clipboard": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
"dialog.open_tg_fail_manual": "Install pyperclip to copy to clipboard, or open manually:\n{url}",
"dialog.pyperclip_missing": "Install pyperclip to copy to clipboard.",
"dialog.log_open_fail": "Failed to open log file:\n{error}",
"dialog.autostart_fail": "Failed to change autostart.\n\nTry running the app as a user with registry permissions.\n\nError: {error}",
"update.available": "New version available: {version}",
"update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?",
"update.ask_install": "New version available: {version}\n\nUpdate now? The app will restart.",
"update.install_fail": "Failed to install the update:\n{error}\n\nOpen the release page in the browser?",
"update.downloading": "Downloading...",
"update.restarting": "Restarting...",
"update.error": "Error: {msg}",
"error.dc_config": "DC → IP configuration error.",
"diagnostics.port_busy": "Failed to start proxy:\nPort is already in use by another application.\n\nClose the app using this port, or change the port in proxy settings and restart.",
"diagnostics.permission": "Failed to start proxy:\nAccess to address/port denied (firewall, antivirus, or permissions).\n\nChange the port to a random value in 10000–50000 in settings, check firewall/antivirus, and restart.",
"diagnostics.bad_address": "Failed to start proxy:\nInvalid or unavailable listen address.\n\nCheck the solution at the link opened in your browser.\nVerify host and port in proxy settings and restart.",
"ipv6.warning": "IPv6 connectivity is enabled on your computer.\n\nTelegram may try to connect over IPv6, which is not supported and may cause errors.\n\nIf the proxy does not work or logs show IPv6 connection attempts, try disabling IPv6 connection attempts in Telegram proxy settings. If that does not help, try disabling IPv6 system-wide.\n\nThis warning is shown only once."
}
+155
View File
@@ -0,0 +1,155 @@
{
"app.name": "TG WS Proxy",
"app.error_title": "TG WS Proxy — Ошибка",
"app.settings_title": "TG WS Proxy — Настройки",
"app.update_title": "TG WS Proxy — обновление",
"language.ru": "Русский",
"language.en": "English",
"appearance.auto": "Авто",
"appearance.light": "Светлая",
"appearance.dark": "Тёмная",
"settings.title": "Настройки",
"settings.language": "Language",
"settings.theme": "Тема",
"section.interface": "Интерфейс",
"section.mtproto": "Подключение MTProto",
"section.dc": "Датацентры Telegram (DC → IP)",
"section.cfproxy": "Cloudflare Proxy",
"label.h2_enable": "Мультиплексирование медиа (HTTP/2)",
"tip.h2": "Объединять загрузку медиа через Cloudflare в общее HTTP/2-соединение для каждого датацентра и домена.\nТребуется включённый CF-прокси и включённый TLS.",
"section.cfworker": "Cloudflare Worker",
"section.logs": "Логи и производительность",
"section.updates": "Обновления",
"section.windows_startup": "Запуск Windows",
"label.host": "IP-адрес",
"label.port": "Порт",
"label.secret": "Ключ",
"label.dc_hint": "По одному правилу на строку, формат: номер:IP",
"label.cf_enable": "Включить CF-прокси",
"label.cf_custom_domain": "Свой домен",
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
"label.verbose": "Подробное логирование (verbose)",
"label.no_secure": "Выключить TLS для CF-прокси и CF-worker",
"label.buf_kb": "Буфер, КБ (по умолчанию 256)",
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
"label.check_updates": "Проверять обновления при запуске",
"label.autostart": "Автозапуск при включении компьютера",
"label.autostart_hint": "Если переместить программу в другую папку, запись автозапуска может сброситься.",
"tip.host": "Адрес, на котором прокси принимает подключения.\nОбычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы",
"tip.port": "Порт прокси. В Telegram Desktop в настройках прокси должен быть указан тот же порт",
"tip.secret": "Секретный ключ для авторизации клиентов",
"tip.dc": "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\nКаждая строка: «номер:IP», например 4:149.154.167.220. Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\nЕсли у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220",
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
"tip.no_secure": "Если включено, прокси будет использовать 80 порт для CF-прокси и CF-worker подключений",
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
"tip.pool": "Сколько готовых WebSocket-соединений держать для каждого датацентра.\n0 отключает прямой DC->IP WebSocket-маршрут",
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
"tip.autostart": "Запускать TG WS Proxy при входе в Windows. Если вы переместите программу в другую папку, автозапуск сбросится",
"tip.check_updates": "При запуске проверять наличие обновлений",
"tip.cfproxy": "Использовать Cloudflare прокси для недоступных датацентров",
"tip.cfproxy_domain": "Ваши собственные домены, проксируемые через Cloudflare, для HTTP/2 и WS-подключений.\nНесколько доменов указывайте через запятую.\nЕсли не указаны — выбираются автоматически из поддерживаемых доменов",
"tip.cfproxy_user_domain_cb": "Указать свои домены вместо автоматического выбора",
"tip.cfworker_domain": "Домены Cloudflare Worker (например, name.account.workers.dev).\nНесколько доменов указывайте через запятую.\nПрокси передает через них подключение к Telegram DC по IP",
"tip.save": "Сохранить настройки",
"tip.cancel": "Закрыть окно без сохранения изменений",
"button.save": "Сохранить",
"button.cancel": "Отмена",
"button.test": "Тест",
"button.test_loading": "...",
"button.open_release": "Открыть страницу релиза",
"button.start": "Начать",
"button.update": "Обновить",
"button.check_updates": "Проверить обновления",
"button.page": "Страница",
"button.close": "Закрыть",
"button.donate": "Поддержать ♥",
"validation.bad_host": "Некорректный IP-адрес.",
"validation.bad_port": "Порт должен быть числом 1-65535",
"validation.bad_secret_len": "Ключ должен содержать ровно 32 hex-символа (16 байт).",
"validation.bad_secret_hex": "Ключ должен состоять только из hex-символов (0-9, a-f).",
"validation.dc_format": "Неверный формат DC:IP: {entry}",
"validation.dc_invalid": "Неверная запись DC:IP: {entry}",
"connectivity.cfproxy_title": "CF-прокси",
"connectivity.cfworker_title": "CF Worker",
"connectivity.timeout": "таймаут",
"connectivity.no_response": "нет ответа",
"connectivity.available": "{title}: доступен",
"connectivity.unavailable": "{title}: недоступен",
"connectivity.all_ok": "{title}: всё работает",
"connectivity.partial": "{title}: частично работает",
"connectivity.auto_ok": "✓ {title} работает. {ok} из {total} серверов доступны.",
"connectivity.all_ok_domain": "✓ Все {total} серверов доступны через {domain}.",
"connectivity.none_ok": "✗ Ни один сервер не отвечает через {domain}.\n\nОшибки:\n{errors}",
"connectivity.partial_detail": "Домен: {domain}\n\n✓ Работают: {ok_list}\n\n✗ Недоступны:\n{fail_list}",
"connectivity.error_line": " {prefix}{dc}: {error}",
"connectivity.cf_auto_fail": "✗ Ни один из автоматических CF-доменов не отвечает.",
"connectivity.multi_all_ok": "✓ {domain}: все {total} серверов доступны",
"connectivity.multi_fail": "✗ {domain}: недоступен",
"connectivity.multi_partial": "~ {domain}: работают {ok_list}; недоступны {fail_list}",
"updates.status_error": "Не удалось связаться с GitHub. Проверьте сеть.",
"updates.status_pending": "Обновления ещё не проверялись.",
"updates.status_checking": "Проверка обновлений...",
"updates.status_available": "На GitHub доступна версия {latest} (у вас {current}).",
"updates.status_ahead": "У вас {current} — новее последнего релиза на GitHub ({latest}).",
"updates.status_latest": "Установлена последняя известная версия с GitHub.",
"first_run.title": "Прокси запущен и работает в системном трее",
"first_run.how_to": "Как подключить Telegram Desktop:",
"first_run.auto": " Автоматически:",
"first_run.auto_hint": " ПКМ по иконке в трее → «Открыть в Telegram»",
"first_run.auto_link": " Или скопировать ссылку, отправить её себе в TG и нажать по ней: {url}",
"first_run.manual": " Вручную:",
"first_run.manual_path": " Настройки → Продвинутые → Тип подключения → Прокси",
"first_run.manual_mtproto": " MTProto → {host} : {port}",
"first_run.manual_secret": " Ключ: dd{secret}",
"first_run.open_now": "Открыть прокси в Telegram сейчас",
"tray.open_telegram": "Открыть в Telegram ({host}:{port})",
"tray.copy_link": "Скопировать ссылку",
"tray.restart": "Перезапустить прокси",
"tray.settings": "Настройки...",
"tray.logs": "Открыть логи",
"tray.update": "Обновить ({current} → {new})",
"tray.exit": "Выход",
"dialog.restart_title": "Перезапустить?",
"dialog.restart_body": "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
"dialog.already_running": "Приложение уже запущено, проверьте трей.",
"dialog.log_not_found": "Файл логов ещё не создан.",
"dialog.ctk_missing": "customtkinter не установлен.",
"dialog.copy_ok": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
"dialog.copy_fail": "Не удалось скопировать ссылку:\n{error}",
"dialog.open_tg_fail": "Не удалось открыть Telegram автоматически.\n\n{detail}",
"dialog.open_tg_fail_clipboard": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
"dialog.open_tg_fail_manual": "Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}",
"dialog.pyperclip_missing": "Установите пакет pyperclip для копирования в буфер обмена.",
"dialog.log_open_fail": "Не удалось открыть файл логов:\n{error}",
"dialog.autostart_fail": "Не удалось изменить автозапуск.\n\nПопробуйте запустить приложение от имени пользователя с правами на реестр.\n\nОшибка: {error}",
"update.available": "Доступна новая версия: {version}",
"update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?",
"update.ask_install": "Доступна новая версия: {version}\n\nОбновить сейчас? Приложение будет перезапущено.",
"update.install_fail": "Не удалось установить обновление:\n{error}\n\nОткрыть страницу релиза в браузере?",
"update.downloading": "Скачивание...",
"update.restarting": "Перезапуск...",
"update.error": "Ошибка: {msg}",
"error.dc_config": "Ошибка конфигурации DC → IP.",
"diagnostics.port_busy": "Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.",
"diagnostics.permission": "Не удалось запустить прокси:\nДоступ к адресу/порту запрещён (брандмауэр, антивирус или права доступа).\n\nИзмените порт на случайный в диапазоне 10000–50000 в настройках, проверьте брандмауэр/антивирус и перезапустите.",
"diagnostics.bad_address": "Не удалось запустить прокси:\nНекорректный или недоступный адрес для прослушивания.\n\nПроверьте решение по открывшейся в браузере ссылке.\nПроверьте host и порт в настройках прокси и перезапустите.",
"ipv6.warning": "На вашем компьютере включена поддержка подключения по IPv6.\n\nTelegram может пытаться подключаться через IPv6, что не поддерживается и может привести к ошибкам.\n\nЕсли прокси не работает или в логах присутствуют ошибки, связанные с попытками подключения по IPv6 - попробуйте отключить в настройках прокси Telegram попытку соединения по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 в системе.\n\nЭто предупреждение будет показано только один раз."
}
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import math
import socket
from copy import deepcopy
from dataclasses import dataclass
from typing import Union
from proxy import coerce_domain_list, parse_dc_ip_list
from ui.i18n import t
UI_ONLY_KEYS = frozenset({
"appearance", "autostart", "check_updates", "language"
})
@dataclass(frozen=True)
class SettingsChange:
config: dict
changed_keys: frozenset
@property
def requires_restart(self) -> bool:
return bool(self.changed_keys - UI_ONLY_KEYS)
def prepare_settings(current: dict, values: dict, defaults: dict) -> SettingsChange:
config = deepcopy(defaults)
config.update(deepcopy(current))
changed_keys = frozenset(
key for key, value in values.items() if value != config.get(key)
)
config.update(deepcopy(values))
return SettingsChange(config, changed_keys)
def validate_settings(values: dict, defaults: dict) -> Union[dict, str]:
config = dict(values)
host = values["host"].strip()
try:
socket.inet_aton(host)
except OSError:
return t("validation.bad_host")
try:
port = int(values["port"].strip())
if not 1 <= port <= 65535:
raise ValueError
except ValueError:
return t("validation.bad_port")
lines = [line.strip() for line in values["dc_ip"].splitlines() if line.strip()]
try:
parse_dc_ip_list(lines)
except ValueError as exc:
entry = getattr(exc, "entry", None)
if entry is None:
return str(exc)
key = "dc_format" if getattr(exc, "kind", "invalid") == "format" else "dc_invalid"
return t(f"validation.{key}", entry=entry)
secret = values["secret"].strip()
if len(secret) != 32:
return t("validation.bad_secret_len")
if any(char not in "0123456789abcdefABCDEF" for char in secret):
return t("validation.bad_secret_hex")
config.update(host=host, port=port, secret=secret, dc_ip=lines)
for key in ("buf_kb", "pool_size", "log_max_mb"):
try:
value = float(values[key].strip())
if not math.isfinite(value):
raise ValueError
config[key] = int(value) if key != "log_max_mb" else value
except (ValueError, OverflowError):
config[key] = defaults[key]
for key in ("cfproxy_user_domain", "cfproxy_worker_domain"):
if key in config:
config[key] = coerce_domain_list(config[key])
return config
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import logging
import threading
from copy import deepcopy
from tkinter import messagebox
from ui.settings_form import validate_config_form
from ui.i18n import set_language, t
from ui.settings import prepare_settings
log = logging.getLogger("tg-ws-tray")
class SettingsDialog:
def __init__(
self, *, ctk, root, widgets, config, defaults, persist,
refresh_menu, finish, restart, include_autostart=False,
apply_autostart=None, read_autostart=None,
):
self.ctk = ctk
self.root = root
self.widgets = widgets
self.config = config
self.defaults = defaults
self.persist = persist
self.refresh_menu = refresh_menu
self.finish = finish
self.restart = restart
self.include_autostart = include_autostart
self.apply_autostart = apply_autostart
self.read_autostart = read_autostart
self.closed = False
def cancel(self):
if self.closed:
return
mode = self.config.get("appearance", "auto")
self.ctk.set_appearance_mode("system" if mode == "auto" else mode)
set_language(self.config.get("language", self.defaults["language"]))
self.refresh_menu()
self._finish()
def _finish(self):
self.closed = True
self.finish()
def save(self):
if self.closed:
return
values = validate_config_form(
self.widgets, self.defaults, include_autostart=self.include_autostart,
)
if isinstance(values, str):
messagebox.showerror(t("app.error_title"), values, parent=self.root)
return
change = prepare_settings(self.config, values, self.defaults)
autostart_changed = (
self.include_autostart
and self.read_autostart is not None
and bool(values["autostart"]) != self.read_autostart()
)
if not change.changed_keys and not autostart_changed:
self._finish()
return
try:
self.persist(change.config)
except (OSError, ValueError, TypeError) as exc:
log.exception("Failed to save settings")
messagebox.showerror(t("app.error_title"), str(exc), parent=self.root)
return
self.config.update(deepcopy(change.config))
set_language(change.config["language"])
changed_keys = change.changed_keys | ({"autostart"} if autostart_changed else set())
log.info("Settings saved: %s", ", ".join(sorted(changed_keys)))
if self.apply_autostart is not None:
self.apply_autostart(bool(change.config.get("autostart", False)))
self.refresh_menu()
do_restart = change.requires_restart and messagebox.askyesno(
t("dialog.restart_title"), t("dialog.restart_body"), parent=self.root,
)
self._finish()
if do_restart:
threading.Thread(
target=self.restart, args=(deepcopy(change.config),),
daemon=True, name="proxy-restart",
).start()
+619
View File
@@ -0,0 +1,619 @@
from __future__ import annotations
import os
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Union
from proxy import __version__, coerce_domain_list
from proxy.balancer import balancer
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
from ui.background_task import BackgroundTask
from ui.connectivity import (
run_cfproxy_multi_test, run_cfproxy_auto_test, run_cfworker_multi_test,
show_connectivity_results, show_multi_connectivity_results,
)
from ui.ctk_controls import (
create_entry, create_checkbox, create_label, create_labeled_entry,
create_config_section, create_combobox,
)
from ui.ctk_theme import CtkTheme
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
from ui.settings import validate_settings
from ui.i18n import (
label_from_language, language_from_label, language_option_labels,
set_language, get_language, t,
)
_INNER_W = 396
_APPEARANCE_KEYS = ("auto", "light", "dark")
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
def _get_doc_url(doc_name: str) -> str:
lang = get_language().value
lang_folder = "EN" if lang == "en" else "RU"
return f"https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/{lang_folder}/{doc_name}.md"
def _appearance_options() -> List[str]:
return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS]
def _appearance_from_cfg(value: str) -> str:
if value in _APPEARANCE_KEYS:
return t(f"appearance.{value}")
return t("appearance.auto")
def _appearance_to_cfg(label: str) -> str:
for key in _APPEARANCE_KEYS:
if t(f"appearance.{key}") == label:
return key
return "auto"
@dataclass
class TrayConfigFormWidgets:
host_var: Any
port_var: Any
secret_var: Any
dc_textbox: Any
verbose_var: Any
no_secure_var: Any
advanced_vars: Dict[str, Any]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
cfproxy_var: Optional[Any] = None
h2_var: Optional[Any] = None
cfproxy_user_domain_enabled_var: Optional[Any] = None
cfproxy_user_domain_var: Optional[Any] = None
cfproxy_worker_enabled_var: Optional[Any] = None
cfproxy_worker_domain_var: Optional[Any] = None
appearance_var: Optional[Any] = None
language_var: Optional[Any] = None
def install_tray_config_form(
ctk: Any,
frame: Any,
theme: CtkTheme,
cfg: dict,
default_config: dict,
*,
show_autostart: bool = False,
autostart_value: bool = False,
on_update_click: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets:
set_language(cfg.get("language", default_config["language"]))
no_secure_var = ctk.BooleanVar(master=frame, value=cfg.get("no_secure", False))
_create_header(ctk, frame, theme)
appearance_var, language_var = _create_interface(ctk, frame, theme, cfg, default_config)
host_var, port_var, secret_var = _create_connection(ctk, frame, theme, cfg, default_config)
dc_textbox = _create_dc(ctk, frame, theme, cfg, default_config)
cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var = _create_cfproxy(
ctk, frame, theme, cfg, default_config, no_secure_var,
)
cfproxy_worker_enabled_var, cfproxy_worker_domain_var = _create_cfworker(
ctk, frame, theme, cfg, default_config, no_secure_var,
)
verbose_var, advanced_vars = _create_logging(
ctk, frame, theme, cfg, default_config, no_secure_var,
)
check_updates_var = _create_updates(ctk, frame, theme, cfg, default_config, on_update_click)
autostart_var = _create_autostart(ctk, frame, theme, show_autostart, autostart_value)
return TrayConfigFormWidgets(
host_var=host_var, port_var=port_var, secret_var=secret_var,
dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var,
advanced_vars=advanced_vars,
autostart_var=autostart_var, check_updates_var=check_updates_var,
cfproxy_var=cfproxy_var,
h2_var=h2_var,
cfproxy_user_domain_enabled_var=cf_custom_cb_var,
cfproxy_user_domain_var=cfproxy_user_domain_var,
cfproxy_worker_enabled_var=cfproxy_worker_enabled_var,
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
appearance_var=appearance_var,
language_var=language_var,
)
def _create_header(ctk, frame, theme):
header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel(
header, text=t("settings.title"),
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, anchor="w",
).pack(side="left")
ctk.CTkLabel(
header, text=f"v{__version__}",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, anchor="e",
).pack(side="right", padx=(4, 0))
ctk.CTkButton(
header, text=t("button.donate"), width=90, height=28,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color="#22c55e", hover_color="#16a34a",
text_color="#ffffff", border_width=0,
command=lambda: (
header.winfo_toplevel().iconify(),
webbrowser.open(_get_doc_url("Funding")),
),
).pack(side="right", padx=(0, 6))
def _create_interface(ctk, frame, theme, cfg, default_config):
lang_cfg = cfg.get("language", default_config["language"])
appearance_var = ctk.StringVar(
master=frame, value=_appearance_from_cfg(cfg.get("appearance", "auto"))
)
def _on_appearance_change(choice: str) -> None:
cfg_val = _appearance_to_cfg(choice)
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
ui_inner = create_config_section(ctk, frame, theme, t("section.interface"))
ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent")
ui_row.pack(fill="x")
lang_col = ctk.CTkFrame(ui_row, fg_color="transparent")
lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8))
theme_col = ctk.CTkFrame(ui_row, fg_color="transparent")
theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0))
language_var = ctk.StringVar(master=frame, value=label_from_language(lang_cfg))
create_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
anchor="w", pady=(0, 2)
)
create_combobox(
ctk, lang_col, theme, variable=language_var,
values=[label for _, label in language_option_labels()],
).pack(fill="x")
create_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
anchor="w", pady=(0, 2)
)
create_combobox(
ctk, theme_col, theme, variable=appearance_var,
values=_appearance_options(), command=_on_appearance_change,
).pack(fill="x")
return appearance_var, language_var
def _create_connection(ctk, frame, theme, cfg, default_config):
conn = create_config_section(ctk, frame, theme, t("section.mtproto"))
host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x")
host_col, host_var = create_labeled_entry(
ctk, host_row, theme, t("label.host"),
cfg.get("host", default_config["host"]),
tip=t("tip.host"), width=160, pack_fill=True,
)
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
port_col, port_var = create_labeled_entry(
ctk, host_row, theme, t("label.port"),
cfg.get("port", default_config["port"]),
tip=t("tip.port"), width=100,
)
port_col.pack(side="left")
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
secret_row.pack(fill="x")
secret_col, secret_var = create_labeled_entry(
ctk, secret_row, theme, t("label.secret"),
cfg.get("secret", default_config["secret"]),
tip=t("tip.secret"), width=160, pack_fill=True,
)
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
regen_col.pack(side="left", anchor="s")
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
ctk.CTkButton(
regen_col, text="↺", width=36, height=36,
font=(theme.ui_font_family, 18), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: secret_var.set(os.urandom(16).hex()),
).pack()
return host_var, port_var, secret_var
def _create_dc(ctk, frame, theme, cfg, default_config):
dc_inner = create_config_section(ctk, frame, theme, t("section.dc"))
dc_lbl = create_label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
dc_inner, width=_INNER_W, height=88,
font=(theme.mono_font_family, 12), corner_radius=10,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
return dc_textbox
def _create_cfproxy(ctk, frame, theme, cfg, default_config, no_secure_var):
cf_inner = create_config_section(ctk, frame, theme, t("section.cfproxy"))
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_row.pack(fill="x", pady=(0, 4))
cfproxy_var = ctk.BooleanVar(
master=frame, value=cfg.get("cfproxy", default_config.get("cfproxy", True))
)
cf_cb = create_checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
cf_cb.pack(side="left", padx=(0, 16))
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
cf_test = BackgroundTask(frame.winfo_toplevel())
def _on_cf_test():
secure = not no_secure_var.get()
user_domains = (
coerce_domain_list(cfproxy_user_domain_var.get())
if cf_custom_cb_var.get() else []
)
if cf_test.running:
return
_cf_test_widget.configure(text=t("button.test_loading"), state="disabled")
def finish():
_cf_test_widget.configure(text=t("button.test"), state="normal")
if user_domains:
cf_test.start(
lambda: run_cfproxy_multi_test(user_domains, secure=secure),
lambda per: show_multi_connectivity_results(
t("connectivity.cfproxy_title"), per, label_prefix="kws",
parent=frame.winfo_toplevel(),
),
finish,
)
else:
domains = list(balancer.domains)
cf_test.start(
lambda: run_cfproxy_auto_test(domains, secure=secure),
lambda result: show_connectivity_results(
t("connectivity.cfproxy_title"), result[1],
domain=result[0] or "", auto_mode=True,
unavailable_message=t("connectivity.cf_auto_fail"),
parent=frame.winfo_toplevel(),
),
finish,
)
_cf_test_widget = ctk.CTkButton(
cf_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cf_test,
)
_cf_test_widget.pack(side="right")
h2_var = ctk.BooleanVar(master=frame, value=cfg.get("h2", default_config.get("h2", True)))
h2_cb = create_checkbox(ctk, cf_inner, theme, t("label.h2_enable"), h2_var)
h2_cb.pack(anchor="w", pady=(2, 8))
attach_ctk_tooltip(h2_cb, t("tip.h2"))
def _sync_h2(*_):
enabled = cfproxy_var.get() and not no_secure_var.get() and not cfg.get("force_test_dc", False)
h2_cb.configure(state="normal" if enabled else "disabled")
cfproxy_var.trace_add("write", _sync_h2)
no_secure_var.trace_add("write", _sync_h2)
_sync_h2()
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_custom_row.pack(fill="x")
saved_user_domains = coerce_domain_list(
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
)
cf_custom_cb_var = ctk.BooleanVar(
master=frame, value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains))
)
cf_custom_cb = create_checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
ctk.CTkButton(
cf_custom_row, text="?", width=28, height=32,
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_get_doc_url("CfProxy")),
).pack(side="right")
cfproxy_user_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_user_domains))
cf_domain_entry = create_entry(
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
height=32, radius=8,
)
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
def _sync_domain_entry(*_):
state = "normal" if cf_custom_cb_var.get() else "disabled"
cf_domain_entry.configure(state=state)
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
_sync_domain_entry()
return cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var
def _create_cfworker(ctk, frame, theme, cfg, default_config, no_secure_var):
cf_worker_inner = create_config_section(ctk, frame, theme, t("section.cfworker"))
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_row.pack(fill="x", pady=(0, 4))
cf_worker_lbl = create_label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2))
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_input.pack(fill="x")
saved_worker_domains = coerce_domain_list(
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
)
cfproxy_worker_enabled_var = ctk.BooleanVar(
master=frame, value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains))
)
cf_worker_cb = create_checkbox(
ctk, cf_worker_input, theme, t("label.cf_custom_domain"),
cfproxy_worker_enabled_var,
)
cf_worker_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_worker_cb, t("tip.cfworker_domain"))
cfproxy_worker_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_worker_domains))
cf_worker_entry = create_entry(
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
height=32, radius=8,
)
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain"))
worker_test = BackgroundTask(frame.winfo_toplevel())
def _sync_cfworker_test_button(*_):
btn = _cfworker_test_widget
enabled = (
not worker_test.running and cfproxy_worker_enabled_var.get()
and bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
)
btn.configure(state="normal" if enabled else "disabled")
def _on_cfworker_test():
secure = not no_secure_var.get()
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
if worker_test.running or not cfproxy_worker_enabled_var.get() or not domains:
return
_cfworker_test_widget.configure(text=t("button.test_loading"), state="disabled")
def finish():
_cfworker_test_widget.configure(text=t("button.test"))
_sync_cfworker_test_button()
worker_test.start(
lambda: run_cfworker_multi_test(domains, secure=secure),
lambda per: show_multi_connectivity_results(
t("connectivity.cfworker_title"), per, label_prefix="DC",
parent=frame.winfo_toplevel(),
),
finish,
)
ctk.CTkButton(
cf_worker_input, text="?", width=28, height=32,
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_get_doc_url("CfWorker")),
).pack(side="right")
_cfworker_test_widget = ctk.CTkButton(
cf_worker_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cfworker_test,
)
_cfworker_test_widget.pack(side="right")
def _sync_cfworker_entry(*_):
state = "normal" if cfproxy_worker_enabled_var.get() else "disabled"
cf_worker_entry.configure(state=state)
_sync_cfworker_test_button()
cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry)
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
_sync_cfworker_entry()
return cfproxy_worker_enabled_var, cfproxy_worker_domain_var
def _create_logging(ctk, frame, theme, cfg, default_config, no_secure_var):
log_inner = create_config_section(ctk, frame, theme, t("section.logs"))
verbose_var = ctk.BooleanVar(master=frame, value=cfg.get("verbose", False))
verbose_cb = create_checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
no_secure_cb = create_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.pack(fill="x")
adv_rows = [
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
(t("label.pool_size"), "pool_size", t("tip.pool")),
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
]
advanced_vars = {}
for label_text, key, tip in adv_rows:
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
adv_l = create_label(ctk, col, theme, label_text, size=11)
adv_l.pack(anchor="w", pady=(0, 2))
advanced_vars[key] = ctk.StringVar(master=frame, value=str(cfg.get(key, default_config[key])))
adv_e = create_entry(
ctk, col, theme, width=_INNER_W, height=32, radius=8,
var=advanced_vars[key],
)
adv_e.pack(fill="x")
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
return verbose_var, advanced_vars
def _create_updates(ctk, frame, theme, cfg, default_config, on_update_click):
upd_inner = create_config_section(ctk, frame, theme, t("section.updates"))
check_updates_var = ctk.BooleanVar(
master=frame, value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
)
upd_cb = create_checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
status_label = create_label(ctk, upd_inner, theme, "", size=11,
justify="left", wraplength=_INNER_W)
status_label.pack(anchor="w", pady=(0, 8))
check_task = BackgroundTask(frame.winfo_toplevel())
def check():
if check_task.running or check_task.closed:
return
check_button.configure(state="disabled")
status_label.configure(text=t("updates.status_checking"))
check_task.start(
lambda: run_check(__version__), lambda _: refresh(),
lambda: check_button.configure(state="normal"),
)
check_button = ctk.CTkButton(
upd_inner, text=t("button.check_updates"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", command=check,
)
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
upd_btn_row.pack(fill="x")
upd_btn_row.grid_columnconfigure(0, weight=1)
release_button = ctk.CTkButton(
upd_btn_row, 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,
)
release_button.grid(row=0, column=0, sticky="ew")
update_button = 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,
)
def refresh():
st = get_status()
if st.get("error"):
status = t("updates.status_error")
elif not st.get("checked"):
status = t("updates.status_pending")
elif st.get("has_update") and st.get("latest"):
status = t("updates.status_available", latest=st["latest"], current=__version__)
elif st.get("ahead_of_release") and st.get("latest"):
status = t("updates.status_ahead", current=__version__, latest=st["latest"])
else:
status = t("updates.status_latest")
status_label.configure(text=status)
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
release_button.configure(command=lambda: webbrowser.open(rel_url))
can_update = bool(st.get("has_update") and on_update_click is not None)
upd_btn_row.grid_columnconfigure(1, weight=int(can_update))
release_button.grid_configure(padx=(0, 4) if can_update else 0)
if can_update:
update_button.grid(row=0, column=1, sticky="ew", padx=(4, 0))
else:
update_button.grid_remove()
def sync_manual_check(*_):
if check_updates_var.get():
check_button.pack_forget()
else:
check_button.pack(fill="x", pady=(0, 8), before=upd_btn_row)
check_updates_var.trace_add("write", sync_manual_check)
sync_manual_check()
refresh()
return check_updates_var
def _create_autostart(ctk, frame, theme, show_autostart, autostart_value):
autostart_var = None
if show_autostart:
sys_inner = create_config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
autostart_var = ctk.BooleanVar(master=frame, value=autostart_value)
as_cb = create_checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
as_cb.pack(anchor="w", pady=(0, 4))
as_hint = create_label(
ctk, sys_inner, theme,
t("label.autostart_hint"),
size=11, justify="left", wraplength=_INNER_W,
)
as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
return autostart_var
def validate_config_form(
widgets: TrayConfigFormWidgets,
default_config: dict,
*,
include_autostart: bool,
) -> Union[dict, str]:
values = {
"host": widgets.host_var.get(),
"port": widgets.port_var.get(),
"secret": widgets.secret_var.get(),
"dc_ip": widgets.dc_textbox.get("1.0", "end"),
"verbose": bool(widgets.verbose_var.get()),
}
for key, var in widgets.advanced_vars.items():
values[key] = var.get()
for key in (
"check_updates", "cfproxy", "h2", "cfproxy_user_domain_enabled",
"cfproxy_worker_enabled", "no_secure",
):
var = getattr(widgets, f"{key}_var")
if var is not None:
values[key] = bool(var.get())
for key in ("cfproxy_user_domain", "cfproxy_worker_domain"):
var = getattr(widgets, f"{key}_var")
if var is not None:
values[key] = var.get()
if include_autostart:
values["autostart"] = (
bool(widgets.autostart_var.get())
if widgets.autostart_var is not None else False
)
if widgets.appearance_var is not None:
values["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
if widgets.language_var is not None:
values["language"] = language_from_label(widgets.language_var.get()).value
return validate_settings(values, default_config)
+19 -3
View File
@@ -5,10 +5,14 @@
from __future__ import annotations
import sys
import os
from typing import Any, Dict
from copy import deepcopy
from ui.i18n import detect_system_language
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"port": 1080,
"port": 1443,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
@@ -16,12 +20,24 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
"cfproxy": True,
"h2": True,
"cfproxy_user_domain_enabled": False,
"cfproxy_user_domain": [],
"cfproxy_worker_enabled": False,
"cfproxy_worker_domain": [],
"force_test_dc": False,
"no_secure": False,
"appearance": "auto",
}
def default_tray_config() -> Dict[str, Any]:
"""Новая копия конфига по умолчанию для текущей ОС."""
cfg = dict(_TRAY_DEFAULTS_COMMON)
cfg = deepcopy(_TRAY_DEFAULTS_COMMON)
cfg["secret"] = os.urandom(16).hex()
cfg["language"] = detect_system_language().value
if sys.platform == "win32":
cfg["autostart"] = False
return cfg
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import errno
import webbrowser
from typing import Optional, Tuple, Callable
# Windows WinSock error codes (exc.winerror); errno may differ from POSIX.
_WSA_EACCES = 10013
_WSA_EFAULT = 10014
_WSA_EADDRINUSE = 10048
_WSA_EADDRNOTAVAIL = 10049
def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[Callable]]:
"""Map a listen-socket bind failure to a user-facing message.
Returns None when the exception is not a recognizable bind failure,
so callers can fall back to generic handling.
"""
from ui.i18n import t
if not isinstance(exc, OSError):
return None
err = exc.errno
winerror = getattr(exc, "winerror", None)
if err == errno.EADDRINUSE or winerror == _WSA_EADDRINUSE:
return t("diagnostics.port_busy"), None
if err == errno.EACCES or winerror == _WSA_EACCES:
return t("diagnostics.permission"), None
if (winerror in (_WSA_EFAULT, _WSA_EADDRNOTAVAIL)
or err in (errno.EADDRNOTAVAIL, errno.EFAULT)):
return t("diagnostics.bad_address"), lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103")
return None, None
+154
View File
@@ -0,0 +1,154 @@
"""
In-place update on Linux.
Detects how the app is installed (deb/rpm package or a standalone binary)
and updates it without a manual download: packages go through the package
manager, a standalone binary is replaced with the release file. Without
write access the install runs through pkexec. Unsupported setups (AUR, pip,
non-amd64, no pkexec) yield None and the caller shows the release page.
"""
from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import sys
import tempfile
from http.client import HTTPException
from pathlib import Path
from typing import List, Optional
from utils.update_check import download_asset, relaunch_env
BINARY_ASSET = "TgWsProxy_linux_amd64"
# install kind -> release asset suffix
ASSET_SUFFIX = {"binary": "", "deb": ".deb", "rpm": ".rpm"}
_INSTALL_TIMEOUT_SEC = 600.0
def _run_quiet(cmd: List[str]) -> bool:
try:
return subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=15,
env=relaunch_env(),
).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def exe_path() -> Path:
return Path(os.path.realpath(sys.executable))
def _is_root() -> bool:
return hasattr(os, "geteuid") and os.geteuid() == 0
def _can_elevate() -> bool:
return _is_root() or bool(shutil.which("pkexec"))
def _elevate() -> List[str]:
return [] if _is_root() else ["pkexec"]
def detect_install_kind(exe: Optional[Path] = None) -> Optional[str]:
"""How the app is installed: "deb", "rpm", "binary" or None."""
exe = exe or exe_path()
if platform.machine().lower() not in ("x86_64", "amd64"):
return None # releases ship amd64 only
if shutil.which("pacman") and _run_quiet(["pacman", "-Qo", str(exe)]):
return None # AUR install, updated by its own package manager
can_elevate = _can_elevate()
if shutil.which("dpkg-query") and _run_quiet(["dpkg-query", "-S", str(exe)]):
return "deb" if can_elevate else None
if shutil.which("rpm") and _run_quiet(["rpm", "-qf", str(exe)]):
return "rpm" if can_elevate else None
# replacing the binary needs a writable directory or root
if os.access(str(exe.parent), os.W_OK) or can_elevate:
return "binary"
return None
def asset_name(kind: str) -> str:
return BINARY_ASSET + ASSET_SUFFIX[kind]
def install_cmd(kind: str, path: Path) -> List[str]:
"""Command that installs the downloaded package."""
elevate = _elevate()
if kind == "deb":
if shutil.which("apt-get"):
return elevate + [
"apt-get", "install", "-y", str(path),
]
return elevate + ["dpkg", "-i", str(path)]
if kind == "rpm":
if shutil.which("dnf"):
return elevate + ["dnf", "install", "-y", str(path)]
return elevate + ["rpm", "-U", str(path)]
raise ValueError("no install command for kind: {0}".format(kind))
def replace_binary_cmd(src: Path, exe: Path) -> List[str]:
"""Stage beside the executable so an interrupted copy cannot destroy it."""
script = '''set -e
staged=$(mktemp "$2.XXXXXX")
trap 'rm -f -- "$staged"' EXIT
install -m 755 -- "$1" "$staged"
mv -fT -- "$staged" "$2"
'''
return _elevate() + ["sh", "-c", script, "sh", str(src), str(exe)]
def _run_install(cmd: List[str], log: Optional[logging.Logger] = None) -> Optional[str]:
if log:
log.info("Linux update: %s", " ".join(cmd))
proc = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=_INSTALL_TIMEOUT_SEC,
env=relaunch_env(),
)
if proc.returncode == 0:
return None
err = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip()
return err[-500:] or "exit code {0}".format(proc.returncode)
def apply_update(
kind: str, url: str, digest: str = "",
log: Optional[logging.Logger] = None,
) -> Optional[str]:
"""Download and install the update.
Returns:
Error text, or None on success.
"""
exe = exe_path()
suffix = ASSET_SUFFIX[kind] or ".new"
# with a writable directory download next to the binary and os.replace it
in_place = kind == "binary" and os.access(str(exe.parent), os.W_OK)
if log:
log.info("Linux update (%s): downloading %s", kind, url)
try:
with tempfile.TemporaryDirectory(prefix="tgws-update-", dir=exe.parent if in_place else None) as directory:
tmp = download_asset(url, Path(directory), suffix=suffix, digest=digest)
if kind != "binary":
return _run_install(install_cmd(kind, tmp), log=log)
if in_place:
os.chmod(str(tmp), 0o755)
os.replace(str(tmp), str(exe))
return None
return _run_install(replace_binary_cmd(tmp, exe), log=log)
except (OSError, ValueError, HTTPException, subprocess.SubprocessError) as exc:
return str(exc)
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more