ykxmz 1.5.0:修复手表端 TLS 证书校验 + MTU 修复网络稳定性

被控端
- 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%)
- 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败)
- 每分钟回头找局域网,自动切回快 400 倍的路径
- 画面心跳帧:静止时省流量,客户端仍知道流是活的
- 锁屏时给出可操作的提示

手表端
- 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException
- 连接错误翻译成人话(证书/时间/解析/超时分别提示)
- 经中转连上后用服务器报的地址刷新已配对记录
- 新增局域网连接页面、服务器延迟检测

服务器端
- 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502)
- 网络调优脚本 net-tune.sh(开机自动应用)
- 管理页:批量删除、网络体检、下载区
- 隧道改 HTTP/2 + 双连接

文档
- 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
This commit is contained in:
xmz7891
2026-10-06 19:01:05 +08:00
commit b62ac865ea
3195 changed files with 203292 additions and 0 deletions
+442
View File
@@ -0,0 +1,442 @@
# -*- coding: utf-8 -*-
"""Windows 防火墙处理。
真机上踩到的坑(这是「能搜到但连不上」的最终根因):
Windows 防火墙的**阻止规则优先级高于放行规则**。
当 Windows 弹「是否允许此应用通过防火墙」而用户点了取消时,
系统会自动为该程序创建 Block 规则。
只要存在一条针对本程序的 Block 规则,后面加多少条 Allow 都不管用。
而且 Windows 是按**程序完整路径**建规则的 —— 换个目录解压就会
再建一条,所以同一台机器上能攒下十几条。
又因为防火墙不过滤回环(127.0.0.1)流量,本地自测会全部通过,
真机却永远超时。这是最容易漏掉的一类问题。
本模块负责:检测阻止规则 + 提权清理 + 重建放行。
注意:netsh 的输出会跟随系统语言本地化(中文系统上是「规则名称/操作/阻止」),
所以字段名必须中英文都认,不能只匹配英文。
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
IS_WINDOWS = sys.platform.startswith("win")
RULE_TCP = "LAN Remote TCP"
RULE_UDP = "LAN Remote UDP"
# 程序级放行规则 —— 这是治本的那一条
RULE_APP_TCP = "LAN Remote App TCP"
RULE_APP_UDP = "LAN Remote App UDP"
# Windows 自动创建的规则名 = 小写可执行文件名
# 修复防火墙时要清掉的旧规则名。
# ykxmz.exe 是现在的名字;lanremote-agent.exe 是改名前的老名字——
# 保留它,好让老用户升级上来时把旧规则清掉,不然两条会打架。
PROGRAM_RULE_NAMES = ("ykxmz.exe", "lanremote-agent.exe",
"python.exe", "pythonw.exe")
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
# netsh 字段名(英文 / 中文)
_FIELD_LABELS = {
"RuleName": ("Rule Name", "规则名称"),
"Enabled": ("Enabled", "已启用"),
"Direction": ("Direction", "方向"),
"Profiles": ("Profiles", "配置文件"),
"Protocol": ("Protocol", "协议"),
"LocalPort": ("LocalPort", "本地端口"),
"Program": ("Program", "程序"),
"Action": ("Action", "操作"),
}
_BLOCK_VALUES = ("block", "阻止")
_ALLOW_VALUES = ("allow", "允许")
_NO_MATCH_MARKERS = ("no rules match", "没有与指定条件", "没有与指定條件",
"匹配的规则", "沒有")
def _run(cmd: list[str], timeout: int = 30) -> subprocess.CompletedProcess:
return subprocess.run(
cmd, capture_output=True, timeout=timeout,
creationflags=_NO_WINDOW,
encoding="mbcs" if IS_WINDOWS else "utf-8", errors="replace")
def executable_path() -> str:
"""当前程序的可执行文件路径(打包后是 EXE,源码模式是 python.exe)。"""
try:
return os.path.abspath(sys.executable)
except Exception:
return ""
def target_executable() -> str:
"""修复脚本应该放行哪个程序。
正常情况下就是当前进程自己(打包后 sys.executable 是 EXE)。
但源码模式下它是 python.exe —— 如果机器上已经有一条指向打包版 EXE
的程序级放行规则,就沿用那个目标,避免源码模式跑一次修复
把打包版的规则冲掉(真机上就这么踩过一次)。
"""
current = executable_path()
if getattr(sys, "frozen", False):
return current
existing = app_rule_program()
base = os.path.basename(existing or "").lower()
if (existing and base.endswith(".exe") and "python" not in base
and os.path.isfile(existing)):
return existing
return current
def app_rule_exists() -> bool:
"""是否已经有针对本程序的放行规则。
没有这条规则时,Windows 会在程序监听端口时弹窗询问;
没人点「允许」就会自动生成阻止规则 —— 于是"删了又回来"。
"""
return rule_exists(RULE_APP_TCP) and rule_exists(RULE_APP_UDP)
def app_rule_program() -> str:
"""读出程序级放行规则实际指向的可执行文件。
真机上踩到的坑:规则被写成了 python.exe(源码模式的值),
于是打包后的 ykxmz.exe 仍然没有程序级放行,
系统继续自动生成阻止规则 —— 表现为"删了又回来"。
所以必须核对路径,不能只看规则是否存在。
"""
if not IS_WINDOWS:
return ""
try:
proc = _run(["netsh", "advfirewall", "firewall", "show", "rule",
f"name={RULE_APP_TCP}", "verbose"])
except Exception:
return ""
for info in _split_rules(proc.stdout or ""):
program = (info.get("Program") or "").strip('"')
if program:
return program
return ""
def app_rule_ok() -> bool:
"""程序级放行规则存在,**且**指向当前真正运行的这个可执行文件。"""
if not app_rule_exists():
return False
want = executable_path()
got = app_rule_program()
if not want or not got:
return False
return os.path.normcase(os.path.abspath(got)) == os.path.normcase(os.path.abspath(want))
def is_elevated() -> bool:
if not IS_WINDOWS:
return hasattr(os, "geteuid") and os.geteuid() == 0
try:
import ctypes
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def _parse_fields(chunk: str) -> dict:
"""把一段 netsh 规则文本解析成字段字典(中英文冒号都支持)。"""
info: dict = {}
for raw in chunk.splitlines():
line = raw.strip()
if not line or ("=" in line and ":" not in line and ":" not in line):
continue
parts = re.split(r"[::]", line, maxsplit=1)
if len(parts) != 2:
continue
label = parts[0].strip()
value = parts[1].strip()
for key, aliases in _FIELD_LABELS.items():
if label in aliases:
info[key] = value
break
return info
def _split_rules(text: str) -> list[dict]:
"""把 netsh 的长输出切成一条条规则。"""
rules = []
for chunk in re.split(r"\r?\n\r?\n", text or ""):
info = _parse_fields(chunk)
if info.get("RuleName"):
rules.append(info)
return rules
def rule_exists(rule_name: str) -> bool:
if not IS_WINDOWS:
return True
try:
proc = _run(["netsh", "advfirewall", "firewall", "show", "rule",
f"name={rule_name}"])
except Exception:
return False
if proc.returncode != 0:
return False
text = ((proc.stdout or "") + (proc.stderr or "")).lower()
for marker in _NO_MATCH_MARKERS:
if marker in text:
return False
# 输出为空(或明显不是规则详情)时**不能**当成"规则存在" ——
# 本地化提示语没收录、netsh 被拦、超时都可能给出空输出,
# 那时候报"已放行"就是在骗用户。
if len(text.strip()) < 10:
return False
if rule_name.lower() not in text:
return False
return True
def _port_hits(info: dict, ports: tuple) -> bool:
"""这条规则的 LocalPort 是否命中我们关心的端口。"""
raw = (info.get("LocalPort") or "").strip()
if not raw or raw.lower() in ("any", "任意"):
return False
pieces = [p.strip() for p in raw.replace(",", ",").split(",")]
for piece in pieces:
if not piece:
continue
for port in ports:
if not port:
continue
if piece == str(port):
return True
# 端口段,如 8000-9000
if "-" in piece:
try:
low, high = piece.split("-", 1)
if int(low) <= int(port) <= int(high):
return True
except ValueError:
continue
return False
def list_block_rules(tcp_port: int = 0, udp_port: int = 0) -> list[dict]:
"""列出可能挡住我们的入站阻止规则。
两类都算:
* 程序级:Program 指向 lanremote-agent.exe / python.exe
* 端口级:LocalPort 命中我们的 TCP/UDP 端口
第二类原来完全没覆盖。而"UDP 能搜到、TCP 连不上"往往就是
一条按端口封的规则 —— 组策略、第三方安全软件、手工建规则都会这样。
"""
if not IS_WINDOWS:
return []
try:
proc = _run(["netsh", "advfirewall", "firewall", "show", "rule",
"name=all", "dir=in", "verbose"], timeout=60)
except Exception:
return []
blocks: list[dict] = []
for info in _split_rules(proc.stdout or ""):
action = (info.get("Action") or "").strip().lower()
if action not in _BLOCK_VALUES:
continue
program = (info.get("Program") or "").strip('"')
base = os.path.basename(program).lower() if program else ""
if base in PROGRAM_RULE_NAMES:
info["ProgramBase"] = base
info["Kind"] = "program"
blocks.append(info)
continue
if _port_hits(info, (tcp_port, udp_port)):
info["Kind"] = "port"
blocks.append(info)
return blocks
def check(tcp_port: int, udp_port: int) -> dict:
"""综合状态:放行规则 + 阻止规则。"""
if not IS_WINDOWS:
return {"supported": False, "ok": True, "elevated": True,
"tcp": True, "udp": True, "blocked": [], "blocked_count": 0,
"message": "非 Windows,无需处理"}
tcp = rule_exists(RULE_TCP)
udp = rule_exists(RULE_UDP)
app_rule = app_rule_ok()
elevated = is_elevated()
# 阻止规则按程序完整路径生效,所以只统计**针对当前这份程序**的。
# 测试目录的副本、源码模式下的 python.exe 被阻止,与本安装无关,
# 不该在这里报红(真机上就是这么误报的)。
all_blocked = list_block_rules(tcp_port, udp_port)
exe_now = executable_path()
if exe_now:
want = os.path.normcase(os.path.abspath(exe_now))
blocked = [
item for item in all_blocked
if os.path.normcase(
os.path.abspath((item.get("Program") or "").strip('"'))) == want
]
else:
blocked = all_blocked
blocked_others = len(all_blocked) - len(blocked)
# 「能不能连上」只看两件事:端口放行在不在 + 有没有阻止规则。
# 程序级放行影响的是「以后会不会复发」,不影响当下能不能连。
ok = tcp and udp and not blocked
if not ok:
severity = "error"
if blocked:
message = f"存在 {len(blocked)} 条阻止规则(优先级高于放行,必须清掉)"
elif not (tcp and udp):
message = ("端口放行规则缺失,其他设备连不上"
if not elevated
else "端口放行规则缺失(当前是管理员,可直接修复)")
else:
message = "防火墙未放行,其他设备无法连接"
elif not app_rule:
severity = "warn"
got = app_rule_program()
if app_rule_exists() and got:
message = ("能连上。但程序级放行规则指向的不是当前这份程序,"
"以后系统可能再生成阻止规则,建议修复一次")
else:
message = ("能连上。但缺少程序级放行规则,"
"以后系统可能再生成阻止规则,建议修复一次")
else:
severity = "ok"
message = "防火墙已放行"
return {
"supported": True,
"tcp": tcp,
"udp": udp,
"app_rule": app_rule,
"app_rule_program": app_rule_program(),
"blocked": blocked,
"blocked_count": len(blocked),
"blocked_others": blocked_others,
"ok": ok,
"severity": severity,
"elevated": elevated,
"message": message,
"tcp_port": tcp_port,
"udp_port": udp_port,
}
def build_script(tcp_port: int, udp_port: int,
program_names: tuple[str, ...] = PROGRAM_RULE_NAMES) -> str:
"""生成提权修复脚本:先删阻止规则,再重建放行规则。"""
lines = [
"@echo off",
"chcp 936 >nul",
"setlocal",
'echo [1/4] 删除针对本程序的「阻止」规则 ...',
]
for name in program_names:
lines.append(f'netsh advfirewall firewall delete rule name="{name}" dir=in >nul 2>nul')
lines += [
'echo [2/4] 删除旧的放行规则 ...',
f'netsh advfirewall firewall delete rule name="{RULE_TCP}" >nul 2>nul',
f'netsh advfirewall firewall delete rule name="{RULE_UDP}" >nul 2>nul',
'echo [3/4] 重新放行 ...',
f'netsh advfirewall firewall add rule name="{RULE_TCP}" '
f'dir=in action=allow protocol=TCP localport={tcp_port} profile=any',
f'netsh advfirewall firewall add rule name="{RULE_UDP}" '
f'dir=in action=allow protocol=UDP localport={udp_port} profile=any',
"echo.",
"echo ------------------------------------------------------------",
"echo [4/4] 添加程序级放行(治本:防止系统再自动生成阻止规则)...",
"echo ------------------------------------------------------------",
]
exe = target_executable()
if exe:
lines += [
f'netsh advfirewall firewall delete rule name="{RULE_APP_TCP}" >nul 2>nul',
f'netsh advfirewall firewall delete rule name="{RULE_APP_UDP}" >nul 2>nul',
f'netsh advfirewall firewall add rule name="{RULE_APP_TCP}" '
f'dir=in action=allow program="{exe}" enable=yes protocol=TCP profile=any',
f'netsh advfirewall firewall add rule name="{RULE_APP_UDP}" '
f'dir=in action=allow program="{exe}" enable=yes protocol=UDP profile=any',
"echo.",
]
lines += [
"echo 完成。",
"endlocal",
"exit /b 0",
"",
]
return "\r\n".join(lines)
def write_script(tcp_port: int, udp_port: int) -> str:
"""写一份可手动右键运行的修复脚本,放在程序目录。"""
from .config import app_dir
path = app_dir() / "fxfhq.bat"
try:
# .bat 必须用 GBK 存盘,且 build_script 已保证 CRLF 换行;
# 裸 LF 会让 cmd 逐段误读,把每条命令都当成无效命令
path.write_text(build_script(tcp_port, udp_port), encoding="gbk", errors="replace")
except OSError:
pass
return str(path)
def apply_elevated(tcp_port: int, udp_port: int) -> tuple[bool, str]:
"""提权执行修复。会弹一次 UAC。"""
if not IS_WINDOWS:
return False, "仅支持 Windows"
script_path = write_script(tcp_port, udp_port)
if not os.path.isfile(script_path):
return False, "无法写入修复脚本"
if is_elevated():
try:
proc = _run(["cmd", "/c", script_path], timeout=90)
ok = rule_exists(RULE_TCP) and not list_block_rules()
detail = (proc.stdout or proc.stderr or "").strip()
return ok, ("已修复(管理员权限)" if ok else f"未完全生效:{detail[:160]}")
except Exception as exc: # noqa: BLE001
return False, f"执行失败: {exc}"
try:
proc = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"Start-Process -FilePath '{script_path}' -Verb RunAs -Wait"],
capture_output=True, timeout=240, creationflags=_NO_WINDOW,
encoding="mbcs", errors="replace")
except subprocess.TimeoutExpired:
return False, "提权操作超时"
except Exception as exc: # noqa: BLE001
return False, f"提权失败: {exc}"
ok = rule_exists(RULE_TCP) and not list_block_rules()
if ok:
return True, "已修复"
combined = ((proc.stdout or "") + (proc.stderr or "")).strip()
if "canceled" in combined.lower() or "取消" in combined:
return False, "你取消了管理员授权"
return False, "修复未完全生效,请手动右键运行「fxfhq.bat」"
def manual_hint(tcp_port: int, udp_port: int) -> str:
names = " / ".join(f'"{n}"' for n in PROGRAM_RULE_NAMES)
return (
"手动修复(管理员 PowerShell):\n"
f" netsh advfirewall firewall delete rule name={names} dir=in\n"
f' netsh advfirewall firewall add rule name="{RULE_TCP}" '
f"dir=in action=allow protocol=TCP localport={tcp_port} profile=any\n"
f' netsh advfirewall firewall add rule name="{RULE_UDP}" '
f"dir=in action=allow protocol=UDP localport={udp_port} profile=any"
)