mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-06-23 15:01:08 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fed772049b | |||
| 91d39a5ebe | |||
| 5cbac657dc | |||
| ee6c34e065 | |||
| ce6a456bd1 | |||
| 5bc5001c4d | |||
| 2afd80825b |
@@ -13,3 +13,8 @@ khgrre.com
|
|||||||
ulihssf.com
|
ulihssf.com
|
||||||
tmhqsdqmfpmk.com
|
tmhqsdqmfpmk.com
|
||||||
xwuwoqbm.com
|
xwuwoqbm.com
|
||||||
|
orgcnunpj.com
|
||||||
|
zhkuldz.com
|
||||||
|
zypoljnslxa.com
|
||||||
|
efabnxaowuzs.com
|
||||||
|
zaftuzsftqdq.com
|
||||||
|
|||||||
+30
-36
@@ -272,30 +272,10 @@ jobs:
|
|||||||
python3.12 -m pip install .
|
python3.12 -m pip install .
|
||||||
python3.12 -m pip install pyinstaller==6.13.0
|
python3.12 -m pip install pyinstaller==6.13.0
|
||||||
|
|
||||||
- name: Create macOS icon from ICO
|
- name: Create macOS icon
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python3.12 - <<'PY'
|
python3.12 macos.py --render-app-icon icon.icns
|
||||||
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
|
|
||||||
|
|
||||||
- name: Build app with PyInstaller
|
- name: Build app with PyInstaller
|
||||||
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
|
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
|
||||||
@@ -303,6 +283,11 @@ jobs:
|
|||||||
- name: Validate universal2 app bundle
|
- name: Validate universal2 app bundle
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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"
|
||||||
|
|
||||||
found=0
|
found=0
|
||||||
while IFS= read -r -d '' file; do
|
while IFS= read -r -d '' file; do
|
||||||
if file "$file" | grep -q "Mach-O"; then
|
if file "$file" | grep -q "Mach-O"; then
|
||||||
@@ -326,22 +311,31 @@ jobs:
|
|||||||
- name: Create DMG
|
- name: Create DMG
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
APP_NAME="TG WS Proxy"
|
packaging/dmg/build_dmg.sh \
|
||||||
DMG_TEMP="dist/dmg_temp"
|
"dist/TG WS Proxy.app" \
|
||||||
|
"TG WS Proxy" \
|
||||||
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 \
|
|
||||||
"dist/TgWsProxy_macos_universal.dmg"
|
"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
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@
|
|||||||
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
|
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
|
||||||
- [Файлы конфигурации Tray-приложения](./TrayConfig.md)
|
- [Файлы конфигурации Tray-приложения](./TrayConfig.md)
|
||||||
- [Установка из исходников](./BuildFromSource.md)
|
- [Установка из исходников](./BuildFromSource.md)
|
||||||
- [Руководство для контрибьюторов](../CONTRIBUTING.md)
|
- [Руководство для контрибьюторов](./CONTRIBUTING.md)
|
||||||
|
|
||||||
## Windows: быстрый вход
|
## Windows: быстрый вход
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
- **Порт:** `1443` (или переопределенный вами)
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
- **Secret:** из настроек или логов
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
|
## Портативный режим
|
||||||
|
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
|
||||||
|
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
|
||||||
|
|
||||||
## Установка из исходников
|
## Установка из исходников
|
||||||
|
|
||||||
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||||
|
|||||||
@@ -9,16 +9,53 @@ import webbrowser
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
try:
|
|
||||||
import rumps
|
|
||||||
except ImportError:
|
|
||||||
rumps = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
except ImportError:
|
except ImportError:
|
||||||
Image = ImageDraw = ImageFont = None
|
Image = ImageDraw = ImageFont = None
|
||||||
|
|
||||||
|
|
||||||
|
def render_app_icon(size: int):
|
||||||
|
scale = size / 1024
|
||||||
|
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
outer = tuple(round(value * scale) for value in (92, 92, 932, 932))
|
||||||
|
draw.ellipse(outer, fill=(0, 151, 221, 255))
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(
|
||||||
|
"/System/Library/Fonts/Helvetica.ttc",
|
||||||
|
round(430 * scale),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
box = draw.textbbox((0, 0), "T", font=font)
|
||||||
|
width = box[2] - box[0]
|
||||||
|
height = box[3] - box[1]
|
||||||
|
draw.text(
|
||||||
|
(
|
||||||
|
(size - width) / 2 - box[0],
|
||||||
|
(size - height) / 2 - box[1] - round(10 * scale),
|
||||||
|
),
|
||||||
|
"T",
|
||||||
|
font=font,
|
||||||
|
fill=(255, 255, 255, 255),
|
||||||
|
)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--render-app-icon":
|
||||||
|
if Image is None:
|
||||||
|
raise SystemExit("Pillow is required to render the macOS app icon")
|
||||||
|
output_path = sys.argv[2] if len(sys.argv) > 2 else "icon.icns"
|
||||||
|
render_app_icon(1024).save(output_path, format="ICNS")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import rumps
|
||||||
|
except ImportError:
|
||||||
|
rumps = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pyperclip
|
import pyperclip
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -144,26 +181,10 @@ def _ask_cfworker_domain(default: str) -> Optional[str]:
|
|||||||
def _make_menubar_icon(size: int = 44):
|
def _make_menubar_icon(size: int = 44):
|
||||||
if Image is None:
|
if Image is None:
|
||||||
return None
|
return None
|
||||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
return render_app_icon(size)
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
margin = size // 11
|
|
||||||
draw.ellipse([margin, margin, size - margin, size - margin], fill=(0, 0, 0, 255))
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 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]
|
|
||||||
draw.text(
|
|
||||||
((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
|
|
||||||
"T", fill=(255, 255, 255, 255), font=font,
|
|
||||||
)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_menubar_icon() -> None:
|
def _ensure_menubar_icon() -> None:
|
||||||
if MENUBAR_ICON_PATH.exists():
|
|
||||||
return
|
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
img = _make_menubar_icon(44)
|
img = _make_menubar_icon(44)
|
||||||
if img:
|
if img:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Executable
+93
@@ -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"
|
||||||
+18
-30
@@ -266,26 +266,6 @@ async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: Crypt
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _ws_keepalive(ws, interval: float):
|
|
||||||
"""Send periodic WS PING frames to keep the upstream flow warm.
|
|
||||||
|
|
||||||
A non-positive interval disables keepalive. The loop exits on send
|
|
||||||
failure so a dead upstream is detected promptly instead of lingering
|
|
||||||
until the next client packet (see issue #646).
|
|
||||||
"""
|
|
||||||
if interval <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
interval = max(1.0, interval) # reasonable minimum
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
await ws.send_ping()
|
|
||||||
except (asyncio.CancelledError, ConnectionError, OSError):
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||||
ctx: CryptoCtx,
|
ctx: CryptoCtx,
|
||||||
dc=None, is_media=False,
|
dc=None, is_media=False,
|
||||||
@@ -302,9 +282,10 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
up_packets = 0
|
up_packets = 0
|
||||||
down_packets = 0
|
down_packets = 0
|
||||||
start_time = asyncio.get_running_loop().time()
|
start_time = asyncio.get_running_loop().time()
|
||||||
|
close_reason = 'normal'
|
||||||
|
|
||||||
async def tcp_to_ws():
|
async def tcp_to_ws():
|
||||||
nonlocal up_bytes, up_packets
|
nonlocal up_bytes, up_packets, close_reason
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = await reader.read(65536)
|
chunk = await reader.read(65536)
|
||||||
@@ -330,17 +311,22 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
await ws.send(parts[0])
|
await ws.send(parts[0])
|
||||||
else:
|
else:
|
||||||
await ws.send(chunk)
|
await ws.send(chunk)
|
||||||
except (asyncio.CancelledError, ConnectionError, OSError):
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
|
except (ConnectionError, OSError) as e:
|
||||||
|
close_reason = f"client: {type(e).__name__}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
close_reason = f"client: {type(e).__name__}: {e}"
|
||||||
log.debug("[%s] tcp->ws ended: %s", label, e)
|
log.debug("[%s] tcp->ws ended: %s", label, e)
|
||||||
|
|
||||||
async def ws_to_tcp():
|
async def ws_to_tcp():
|
||||||
nonlocal down_bytes, down_packets
|
nonlocal down_bytes, down_packets, close_reason
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await ws.recv()
|
data = await ws.recv()
|
||||||
if data is None:
|
if data is None:
|
||||||
|
if close_reason == 'normal':
|
||||||
|
close_reason = 'upstream: ws_close'
|
||||||
break
|
break
|
||||||
n = len(data)
|
n = len(data)
|
||||||
stats.bytes_down += n
|
stats.bytes_down += n
|
||||||
@@ -350,30 +336,32 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
data = ctx.clt_enc.update(plain)
|
data = ctx.clt_enc.update(plain)
|
||||||
writer.write(data)
|
writer.write(data)
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
except (asyncio.CancelledError, ConnectionError, OSError):
|
except asyncio.CancelledError:
|
||||||
return
|
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:
|
except Exception as e:
|
||||||
|
close_reason = f"upstream: {type(e).__name__}: {e}"
|
||||||
log.debug("[%s] ws->tcp ended: %s", label, e)
|
log.debug("[%s] ws->tcp ended: %s", label, e)
|
||||||
|
|
||||||
tasks = [asyncio.create_task(tcp_to_ws()),
|
tasks = [asyncio.create_task(tcp_to_ws()),
|
||||||
asyncio.create_task(ws_to_tcp())]
|
asyncio.create_task(ws_to_tcp())]
|
||||||
keepalive = asyncio.create_task(
|
|
||||||
_ws_keepalive(ws, proxy_config.ws_keepalive_interval))
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
finally:
|
finally:
|
||||||
keepalive.cancel()
|
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
t.cancel()
|
t.cancel()
|
||||||
for t in (*tasks, keepalive):
|
for t in tasks:
|
||||||
try:
|
try:
|
||||||
await t
|
await t
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
elapsed = asyncio.get_running_loop().time() - start_time
|
elapsed = asyncio.get_running_loop().time() - start_time
|
||||||
log.info("[%s] %s WS session closed: "
|
log.info("[%s] %s WS session closed (%s): "
|
||||||
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
||||||
label, dc_tag,
|
label, dc_tag, close_reason,
|
||||||
human_bytes(up_bytes), up_packets,
|
human_bytes(up_bytes), up_packets,
|
||||||
human_bytes(down_bytes), down_packets,
|
human_bytes(down_bytes), down_packets,
|
||||||
elapsed)
|
elapsed)
|
||||||
|
|||||||
+6
-2
@@ -34,7 +34,12 @@ _CFPROXY_ENC: List[str] = [
|
|||||||
'khgrre.com',
|
'khgrre.com',
|
||||||
'ulihssf.com',
|
'ulihssf.com',
|
||||||
'tmhqsdqmfpmk.com',
|
'tmhqsdqmfpmk.com',
|
||||||
'xwuwoqbm.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))
|
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
|
||||||
|
|
||||||
@@ -67,7 +72,6 @@ class ProxyConfig:
|
|||||||
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
||||||
fake_tls_domain: str = ''
|
fake_tls_domain: str = ''
|
||||||
proxy_protocol: bool = False
|
proxy_protocol: bool = False
|
||||||
ws_keepalive_interval: float = 30.0
|
|
||||||
|
|
||||||
|
|
||||||
proxy_config = ProxyConfig()
|
proxy_config = ProxyConfig()
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@ class _WsPool:
|
|||||||
|
|
||||||
|
|
||||||
class _CfWorkerPool:
|
class _CfWorkerPool:
|
||||||
WS_POOL_MAX_AGE = 120.0
|
WS_POOL_MAX_AGE = 100.0
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._idle: Dict[Tuple[int, str], deque] = {}
|
self._idle: Dict[Tuple[int, str], deque] = {}
|
||||||
|
|||||||
+25
-7
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
|
import logging
|
||||||
import base64
|
import base64
|
||||||
import struct
|
import struct
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -8,6 +9,8 @@ import socket as _socket
|
|||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
from .config import proxy_config
|
from .config import proxy_config
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
|
||||||
_st_BB = struct.Struct('>BB')
|
_st_BB = struct.Struct('>BB')
|
||||||
_st_BBH = struct.Struct('>BBH')
|
_st_BBH = struct.Struct('>BBH')
|
||||||
@@ -154,19 +157,15 @@ class RawWebSocket:
|
|||||||
self._build_frame(self.OP_BINARY, part, mask=True))
|
self._build_frame(self.OP_BINARY, part, mask=True))
|
||||||
await self.writer.drain()
|
await self.writer.drain()
|
||||||
|
|
||||||
async def send_ping(self, payload: bytes = b''):
|
|
||||||
if self._closed:
|
|
||||||
raise ConnectionError("WebSocket closed")
|
|
||||||
frame = self._build_frame(self.OP_PING, payload, mask=True)
|
|
||||||
self.writer.write(frame)
|
|
||||||
await self.writer.drain()
|
|
||||||
|
|
||||||
async def recv(self) -> Optional[bytes]:
|
async def recv(self) -> Optional[bytes]:
|
||||||
while not self._closed:
|
while not self._closed:
|
||||||
opcode, payload = await self._read_frame()
|
opcode, payload = await self._read_frame()
|
||||||
|
|
||||||
if opcode == self.OP_CLOSE:
|
if opcode == self.OP_CLOSE:
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
code, reason = self._parse_close(payload)
|
||||||
|
log.debug("WS OP_CLOSE from upstream: code=%s reason=%r",
|
||||||
|
code, reason)
|
||||||
try:
|
try:
|
||||||
self.writer.write(self._build_frame(
|
self.writer.write(self._build_frame(
|
||||||
self.OP_CLOSE,
|
self.OP_CLOSE,
|
||||||
@@ -209,6 +208,25 @@ class RawWebSocket:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
@staticmethod
|
||||||
def _build_frame(opcode: int, data: bytes,
|
def _build_frame(opcode: int, data: bytes,
|
||||||
mask: bool = False) -> bytes:
|
mask: bool = False) -> bytes:
|
||||||
|
|||||||
@@ -593,10 +593,6 @@ def main():
|
|||||||
ap.add_argument('--proxy-protocol', action='store_true',
|
ap.add_argument('--proxy-protocol', action='store_true',
|
||||||
help='Accept PROXY protocol v1 header '
|
help='Accept PROXY protocol v1 header '
|
||||||
'(for use behind nginx/haproxy with proxy_protocol on)')
|
'(for use behind nginx/haproxy with proxy_protocol on)')
|
||||||
ap.add_argument('--ws-keepalive', type=float, default=30.0, metavar='SEC',
|
|
||||||
help='Seconds between WebSocket keepalive PINGs to the '
|
|
||||||
'upstream (default 30, 0 to disable). Keeps idle '
|
|
||||||
'sessions alive through NAT/firewall timeouts.')
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not args.dc_ip:
|
if not args.dc_ip:
|
||||||
@@ -633,7 +629,6 @@ def main():
|
|||||||
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
|
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
|
||||||
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
||||||
proxy_config.proxy_protocol = args.proxy_protocol
|
proxy_config.proxy_protocol = args.proxy_protocol
|
||||||
proxy_config.ws_keepalive_interval = max(0, args.ws_keepalive)
|
|
||||||
|
|
||||||
log_level = logging.DEBUG if args.verbose else logging.INFO
|
log_level = logging.DEBUG if args.verbose else logging.INFO
|
||||||
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
|
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
|
||||||
|
|||||||
+57
-13
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей).
|
Проверка новой версии через GitHub Releases API
|
||||||
|
|
||||||
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
|
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
|
||||||
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
|
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
|
||||||
@@ -18,6 +18,7 @@ from proxy.utils import build_github_opener
|
|||||||
|
|
||||||
REPO = "Flowseal/tg-ws-proxy"
|
REPO = "Flowseal/tg-ws-proxy"
|
||||||
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||||
|
RELEASES_BY_TAG_API = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}?t={{timestamp}}"
|
||||||
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
|
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
|
||||||
|
|
||||||
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
|
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
|
||||||
@@ -223,19 +224,60 @@ def run_check(current_version: str) -> None:
|
|||||||
_state["html_url"] = RELEASES_PAGE_URL
|
_state["html_url"] = RELEASES_PAGE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_release_by_tag(
|
||||||
|
tag: str, timeout: float = 12.0,
|
||||||
|
) -> Tuple[Optional[dict], int]:
|
||||||
|
if not tag:
|
||||||
|
return None, 0
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": "tg-ws-proxy-update-check",
|
||||||
|
}
|
||||||
|
req = Request(
|
||||||
|
RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())),
|
||||||
|
headers=headers,
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||||
|
code = getattr(resp, "status", None) or resp.getcode()
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
return json.loads(raw), int(code)
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code in [304, 404]:
|
||||||
|
return None, e.code
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_assets(data: Optional[dict]) -> list:
|
||||||
|
if not data:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
|
||||||
|
for a in (data.get("assets") or [])
|
||||||
|
if a.get("name") and a.get("browser_download_url")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_status() -> Dict[str, Any]:
|
def get_status() -> Dict[str, Any]:
|
||||||
"""Снимок состояния после run_check (для подписей в настройках)."""
|
"""Снимок состояния после run_check (для подписей в настройках)."""
|
||||||
return dict(_state)
|
return dict(_state)
|
||||||
|
|
||||||
|
|
||||||
def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
|
def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
|
||||||
assets = _state.get("assets") or []
|
new_assets = _state.get("assets") or []
|
||||||
if not assets:
|
if not new_assets:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Try SHA256 match against release asset digests
|
target_name = None
|
||||||
|
|
||||||
|
# SHA256 match
|
||||||
try:
|
try:
|
||||||
import hashlib
|
import hashlib
|
||||||
|
data, code = fetch_release_by_tag(f"v{current_version}")
|
||||||
|
if code == 200 and data:
|
||||||
|
cur_assets = _extract_assets(data)
|
||||||
|
if cur_assets:
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
with open(exe_path, "rb") as f:
|
with open(exe_path, "rb") as f:
|
||||||
while True:
|
while True:
|
||||||
@@ -244,14 +286,16 @@ def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
|
|||||||
break
|
break
|
||||||
h.update(chunk)
|
h.update(chunk)
|
||||||
exe_sha = h.hexdigest().lower()
|
exe_sha = h.hexdigest().lower()
|
||||||
for a in assets:
|
for a in cur_assets:
|
||||||
d = (a.get("digest") or "").lower()
|
d = (a.get("digest") or "").lower()
|
||||||
if d.startswith("sha256:") and d[7:] == exe_sha:
|
if d.startswith("sha256:") and d[7:] == exe_sha:
|
||||||
return a["url"], a["name"]
|
target_name = a["name"]
|
||||||
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback
|
# Fallback
|
||||||
|
if not target_name or target_name not in [a.get("name") for a in new_assets]:
|
||||||
import platform
|
import platform
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
@@ -265,16 +309,16 @@ def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
|
|||||||
is_modern = True
|
is_modern = True
|
||||||
|
|
||||||
if is_arm64:
|
if is_arm64:
|
||||||
name = "TgWsProxy_windows_arm64.exe"
|
target_name = "TgWsProxy_windows_arm64.exe"
|
||||||
elif is_modern:
|
elif is_modern:
|
||||||
name = "TgWsProxy_windows.exe"
|
target_name = "TgWsProxy_windows.exe"
|
||||||
elif is_64:
|
elif is_64:
|
||||||
name = "TgWsProxy_windows_7_64bit.exe"
|
target_name = "TgWsProxy_windows_7_64bit.exe"
|
||||||
else:
|
else:
|
||||||
name = "TgWsProxy_windows_7_32bit.exe"
|
target_name = "TgWsProxy_windows_7_32bit.exe"
|
||||||
|
|
||||||
for a in assets:
|
for a in new_assets:
|
||||||
if a.get("name") == name:
|
if a.get("name") == target_name:
|
||||||
return a["url"], a["name"]
|
return a["url"], a["name"]
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
+1
-1
@@ -333,7 +333,7 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
|
|||||||
return
|
return
|
||||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||||
ver = st.get("latest") or "?"
|
ver = st.get("latest") or "?"
|
||||||
asset = get_update_asset(Path(sys.executable)) if IS_FROZEN else None
|
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
|
||||||
choice = update_ctk_form(
|
choice = update_ctk_form(
|
||||||
f"Доступна новая версия: {ver}",
|
f"Доступна новая версия: {ver}",
|
||||||
download_url=asset[0] if asset else None,
|
download_url=asset[0] if asset else None,
|
||||||
|
|||||||
Reference in New Issue
Block a user