# -*- 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" )