Files
ykxmz/windows/lanremote/firewall.py
T
xmz7891 b62ac865ea 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 + 双连接

文档
- 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
2026-10-06 19:01:05 +08:00

443 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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"
)