Files
ykxmz/windows/run_agent.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

653 lines
23 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 -*-
"""被控端启动入口。
双击 EXE 的完整流程:
第一次运行
弹出一个窗口「第一次使用」→ 输入 6 位配对码
可勾选:设置开机自启 / 顺便放行防火墙
↓ 点确定
黑终端一闪(提权跑一键修复防火墙,会先弹一次 UAC)
↓
窗口消失,程序转入后台静默运行 —— 屏幕上再没有任何东西
之后每次开机
直接后台静默运行,一个窗口都没有
手表
输那 6 位数字就连上
要重新设置:ykxmz.exe --setup
命令行参数:
--setup 重新打开设置窗口
--silent 直接静默运行(开机自启用的就是这个)
--add-device [分钟] 静默运行 + 临时允许新设备配对
--autostart on|off 开关开机自启
--status 打印状态后退出
--port 9000 指定端口
--new-code 换一组配对码
--reset-devices 清空已配对设备
"""
from __future__ import annotations
import argparse
import os
import sys
from lanremote import APP_NAME, __version__
from lanremote.config import Store, app_dir, data_dir
from lanremote.instance import InstanceLock
def parse_args(argv=None):
parser = argparse.ArgumentParser(
prog="lanremote-agent", description=f"{APP_NAME} v{__version__}")
parser.add_argument("--port", type=int, help="监听端口(默认 8765)")
parser.add_argument("--new-code", action="store_true", help="换一组配对码")
parser.add_argument("--reset-devices", action="store_true", help="清空已配对设备")
parser.add_argument("--silent", action="store_true",
help="全静默运行:没有任何窗口,也没有终端")
parser.add_argument("--add-device", nargs="?", type=int, const=10,
default=None, metavar="分钟",
help="静默运行,但临时允许新设备配对(默认 10 分钟)")
parser.add_argument("--autostart", choices=["on", "off"],
help="开关开机自启")
parser.add_argument("--status", action="store_true", help="打印状态后退出")
parser.add_argument("--setup", action="store_true",
help="重新打开设置窗口")
parser.add_argument("--netcheck", nargs="?", type=int, const=90,
default=None, metavar="秒",
help="网络自检(自开控制台,不需要装 Python)")
parser.add_argument("--fix-firewall", action="store_true",
help="提权修复防火墙")
parser.add_argument("--relay", default=None, metavar="地址",
help="设置中转服务器地址(跨网络用)。"
"可用分号分隔多个,按顺序尝试,例如:"
"http://192.168.2.156:8080;https://relay.xxmxxs.top"
" —— 局域网优先,断了自动切隧道。"
"传空字符串关闭")
parser.add_argument("--relay-status", action="store_true",
help="查看中转连接状态")
parser.add_argument("--uninstall", action="store_true",
help="卸载:停进程、删开机自启、删数据目录")
return parser.parse_args(argv)
# ======================================================================
# 命令行小工具
# ======================================================================
def cli_autostart(action: str) -> int:
from lanremote.autostart import enable, disable, status
try:
print(enable() if action == "on" else disable())
except ValueError as exc:
print(f"失败: {exc}")
return 1
info = status()
print(f"当前: {'已开启' if info['enabled'] else '未开启'}")
return 0
def cli_uninstall() -> int:
"""卸载。会开一个控制台让用户看到过程。"""
attach_console()
from lanremote.uninstall import run as do_uninstall
try:
print(do_uninstall())
except Exception as exc: # noqa: BLE001
print(f"卸载出错:{exc}")
return 1
print()
input("按回车关闭…")
return 0
def attach_console() -> None:
"""给 --windowed 程序自己开一个控制台窗口。
打包成 --windowed 后进程没有控制台,直接 print 什么都看不到。
AllocConsole 可以临时开一个,并把 stdout/stderr 接过去。
"""
if os.name != "nt":
return
try:
import ctypes
# 从 .bat 双击启动时已经有控制台了,再 AllocConsole 会失败并且输出丢失
if ctypes.windll.kernel32.GetConsoleWindow():
return
ctypes.windll.kernel32.AllocConsole()
sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1)
sys.stderr = open("CONOUT$", "w", encoding="utf-8", buffering=1)
try:
sys.stdin = open("CONIN$", "r", encoding="utf-8")
except OSError:
pass
except Exception: # noqa: BLE001
pass
def wait_key() -> None:
try:
print()
input("按回车键关闭这个窗口…")
except Exception: # noqa: BLE001
pass
class _Tee:
"""把输出同时送到控制台和文件。"""
def __init__(self, *streams):
self.streams = streams
def write(self, text):
for stream in self.streams:
try:
stream.write(text)
stream.flush()
except Exception: # noqa: BLE001
pass
def flush(self):
for stream in self.streams:
try:
stream.flush()
except Exception: # noqa: BLE001
pass
def cli_netcheck(seconds: int) -> int:
"""网络自检:监听端口,看手表的包到底有没有到这台电脑。
EXE 是 --windowed 打包的,本来没有控制台。这里自己开一个,
同时把结果写一份到「网络自检结果.txt」——
控制台万一没显示出来(被杀软拦、权限受限等),
用户还能从文件里看到结论。wlzj.bat 跑完会自动打开这个文件。
"""
attach_console()
original = sys.stdout
path = app_dir() / "网络自检结果.txt"
handle = None
try:
handle = open(path, "w", encoding="utf-8")
except OSError:
handle = None
if handle is not None:
sys.stdout = _Tee(original, handle)
code = 1
try:
from lanremote import netcheck
code = netcheck.run_check(int(seconds or 90))
except Exception as exc: # noqa: BLE001
print(f"自检运行失败: {exc}")
finally:
if handle is not None:
try:
sys.stdout = original
handle.close()
except Exception: # noqa: BLE001
pass
try:
print()
print(f"结果已保存到:{path}")
except Exception: # noqa: BLE001
pass
wait_key()
return code
def cli_fix_firewall() -> int:
"""提权修复防火墙(会弹一次 UAC,然后黑终端一闪)。"""
store = Store(data_dir())
ok, detail = fix_firewall_with_flash(store)
attach_console()
print(("已修复:" if ok else "未完成:") + str(detail))
wait_key()
return 0 if ok else 1
def cli_relay(url: str) -> int:
"""设置中转地址。传空字符串就关掉。"""
store = Store(data_dir())
url = (url or "").strip().rstrip("/")
store.set("relay_url", url)
attach_console()
if not url:
print("已关闭中转(以后只在局域网内可用)")
wait_key()
return 0
if not url.startswith(("http://", "https://")):
url = "https://" + url
store.set("relay_url", url)
print(f"中转地址已设为:{url}")
print()
print("正在测试能不能连上…")
try:
from lanremote.relay import RelayLink
link = RelayLink(store)
if not link.start():
print(" 启动失败")
wait_key()
return 1
# 等它登记成功
import time as _t
for _ in range(30):
_t.sleep(1)
status = link.status()
if status.get("online"):
print(f" ✓ 已连上中转(设备 {status.get('deviceId')},"
f"配对码 {status.get('code')})")
link.stop()
wait_key()
return 0
if status.get("error"):
print(f" … {status['error']}")
print(f" ✗ 30 秒内没能连上 最后错误:{link.status().get('error')}")
link.stop()
except Exception as exc: # noqa: BLE001
print(f" ✗ 测试失败:{exc}")
wait_key()
return 1
def cli_relay_status() -> int:
"""看现在的中转状态。"""
from lanremote.relay import RelayLink
store = Store(data_dir())
url = str(store.get("relay_url") or "") or "192.168.2.156:8080;https://relay.xxmxxs.top"
attach_console()
if not url:
print("没有配置中转(只在局域网内可用)")
print("要跨网络请运行:ykxmz.exe --relay https://你的中转地址")
wait_key()
return 0
print(f"中转地址:{url}")
print("正在测…")
link = RelayLink(store)
link.start()
import time as _t
for _ in range(30):
_t.sleep(1)
status = link.status()
if status.get("online"):
print(f" ✓ 在线")
print(f" 设备标识 {status.get('deviceId')}")
print(f" 配对码 {status.get('code')}")
print(f" 本次已服务 {status.get('sessions')} 个手表会话")
link.stop()
wait_key()
return 0
print(f" ✗ 不在线 最后错误:{link.status().get('error')}")
link.stop()
wait_key()
return 1
def cli_status() -> int:
from lanremote.autostart import status as auto_status
store = Store(data_dir())
auto = auto_status()
print(f"{APP_NAME} v{__version__}")
print(f" 数据目录: {data_dir()}")
print(f" 监听端口: {store.get('port', 8765)}")
print(f" 配对码: {store.pairing_code() or '(还没生成)'}")
print(f" 已配设备: {len(store.device_list())} 台")
print(f" 允许新配对: {'是' if store.get('allow_new_pairing', True) else '否'}")
print(f" 开机自启: {'已开启' if auto['enabled'] else '未开启'}")
if auto["enabled"]:
print(f" 命令行: {auto['command']}")
return 0
# 设完配对码之后留多久给手表来配对
SETUP_PAIR_MINUTES = 60
# 一台设备都没配对过时,自动开多久的配对窗口(自愈用)
FIRST_PAIR_MINUTES = 120
# ======================================================================
# 黑终端一闪:提权修防火墙
# ======================================================================
def fix_firewall_with_flash(store) -> tuple[bool, str]:
"""提权执行防火墙修复。
会先弹一次 UAC;确认之后黑色终端窗口一闪,script 跑完自动关掉。
这就是设置流程里那个"闪过的黑窗口"。
"""
from lanremote import firewall
from lanremote.discovery import DISCOVERY_PORT
tcp_port = int(store.get("port", 8765) or 8765)
udp_port = int(DISCOVERY_PORT)
try:
return firewall.apply_elevated(tcp_port, udp_port)
except Exception as exc: # noqa: BLE001
return False, f"防火墙修复失败: {exc}"
# ======================================================================
# 静默运行
# ======================================================================
def run_silent(store, allow_pair_minutes: int | None) -> int:
"""全静默运行:没有窗口,没有终端,只在后台监听。"""
import time
from lanremote.actions import Actions
from lanremote.headless import HeadlessUI
from lanremote.server import RemoteServer
log_file = data_dir() / "silent.log"
# 静默模式下默认**不接受新配对**(防止局域网里任何人接进来)。
# 需要加设备时用 --add-device 临时开一个时间窗。
#
# 自愈:如果一台设备都还没配对过,就必须把配对打开 ——
# 否则用户设完码会永远连不上(报"被控端已关闭新设备配对")。
# 配对码仍然是门槛,所以不影响安全;第一台配对成功后自动关闭。
if not store.device_list():
allow_pair_minutes = max(allow_pair_minutes or 0,
FIRST_PAIR_MINUTES)
store.set("close_pair_after_first", True)
if allow_pair_minutes:
store.set("allow_new_pairing", True)
deadline = time.time() + max(1, allow_pair_minutes) * 60
else:
store.set("allow_new_pairing", False)
deadline = 0.0
ui = HeadlessUI(store, log_file=log_file)
ui.log(f"{APP_NAME} v{__version__} 静默启动")
if not store.get("setup_done", False):
ui.log("注意:还没有设置过配对码。请先运行:ykxmz.exe --setup")
ui.log(f"当前临时配对码是 {store.pairing_code()}")
lock = InstanceLock()
if not lock.try_claim():
ui.log("已有实例在运行,退出")
return 0
server = RemoteServer(store, None, ui=ui)
server.actions = Actions(ui, store)
from lanremote.features import Features
server.features = Features(server.actions)
server.pairing_enabled = bool(store.get("allow_new_pairing", True))
ui.server = server
try:
server.start()
except (OSError, OverflowError, ValueError) as exc:
ui.log(f"启动失败:{exc}")
lock.release()
return 1
code = store.pairing_code()
if allow_pair_minutes and code:
ui.log(f"配对窗口已开 {allow_pair_minutes} 分钟,配对码 {code}")
ui.log(f"正在监听 TCP {server.port} / UDP 8766")
import threading
def watchdog():
time.sleep(1.0)
while not ui._stopping:
time.sleep(5.0)
if allow_pair_minutes and deadline and time.time() > deadline:
store.set("allow_new_pairing", False)
server.pairing_enabled = False
ui.log("配对窗口已关闭")
threading.Thread(target=watchdog, daemon=True, name="pair-window").start()
try:
ui.run_forever()
except KeyboardInterrupt:
pass
finally:
try:
server.stop()
except Exception: # noqa: BLE001
pass
ui.stop()
lock.release()
# 必须用 os._exit 结束。
# 界面线程拥有 Tk 解释器,正常走解释器退出会报
# "Tcl_AsyncDelete: async handler deleted by the wrong thread",
# 而且进程退出码会变成 -2147483645(崩溃码)。
# os._exit 跳过解释器收尾,干净退出。
sys.stdout.flush()
sys.stderr.flush()
os._exit(0)
# ======================================================================
# 首次设置
# ======================================================================
def check_code_available(store, code: str):
"""这个配对码能用吗?
能用(或没法判断)返回 None;被别的电脑占了返回一段说明文字。
检查走中转的只读接口 /v1/code/check —— 不注册,所以不会误占。
连不上中转时返回 None(放行):不能因为服务器连不上就装不了,
真有问题等转静默之后还会再提示一次。
"""
if not code or len(code) != 6 or not code.isdigit():
return "配对码必须是 6 位数字"
target = str(store.get("relay_url") or "").strip()
if not target:
# 没配过就用内置默认值(定义在 server.py)
from lanremote.server import DEFAULT_RELAY_URL
target = DEFAULT_RELAY_URL
if not target.strip():
return None # 没配中转,没法检查
try:
from lanremote.relay import RelayLink
link = RelayLink(store, log=lambda _m: None)
# start() 会去连中转并登记 —— 但我们只用它的 candidate 解析,
# 不用它登记(登记会占用配对码)。
link.candidates = []
for piece in target.replace(",", ";").split(";"):
piece = piece.strip().rstrip("/")
if not piece:
continue
if not piece.startswith(("http://", "https://")):
from lanremote.relay import _guess_scheme
piece = _guess_scheme(piece) + piece
link.candidates.append(piece)
link.url = link.candidates[0] if link.candidates else ""
reply = link._check_code(code)
except Exception: # noqa: BLE001
return None # 检查失败就当没问题
if not reply.get("ok"):
return None # 服务器没回应,放行
if not reply.get("taken"):
return None # 没人用,可以用
where = reply.get("deviceName") or "另一台电脑"
return (
f"配对码 {code} 已经被「{where}」占用了。\n\n"
f"一个配对码同时只能给一台电脑用。\n"
f"请换一个别的 6 位数字。\n\n"
f"(想继续用这个码的话,可以去中转管理页把占用它的那台设备删掉)"
)
def _tell_firewall_problem(detail: str) -> None:
"""防火墙没放行成功,说明为什么这次没生效。"""
message = (
"防火墙没有放行成功,所以这次**没有**把配对码交给服务器。\n\n"
f"原因:{detail or '未知'}\n\n"
"该怎么办:\n"
" 1. 右键点 ykxmz.exe →「以管理员身份运行」,重新走一遍设置\n"
" 2. 或者手动放行:控制面板 → Windows Defender 防火墙 →\n"
" 允许应用通过防火墙 → 找到 ykxmz → 勾上「专用网络」\n\n"
"放行之后再打开 ykxmz.exe 就行了。"
)
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
messagebox.showerror("还没生效:防火墙没放行", message)
root.destroy()
return
except Exception: # noqa: BLE001
pass
print("=" * 56)
print("防火墙没放行,配对码未提交到服务器")
print(message)
print("=" * 56)
def _tell_code_problem(message: str) -> None:
"""把配对码的问题告诉用户。有界面就弹窗,没有就打印。"""
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
messagebox.showerror("配对码不能用", message)
root.destroy()
return
except Exception: # noqa: BLE001
pass
print("=" * 56)
print("配对码不能用")
print(message)
print("=" * 56)
def run_setup(store) -> int:
"""弹设置窗口 → 存码 → 黑终端一闪修防火墙 → 转静默。"""
from lanremote.autostart import enable, is_enabled
from lanremote.ui import SetupWindow
# 先检查配对码能不能用,**通过了才去改防火墙**。
#
# 踩过的坑:以前是先存码、开自启、修防火墙,最后才转静默——
# 而"码被别的电脑占了"这件事要等转静默之后才发现。
# 那时防火墙已经改完了,用户在设置窗口里也没机会换码。
result = None
while True:
window = SetupWindow(store).show()
if window is None:
return 0 # 用户关掉了窗口(放弃安装)
code = window.get("code", "")
problem = check_code_available(store, code)
if problem is None:
result = window
break
# 被占了:说清楚,让他回去换一个
_tell_code_problem(problem)
store.set_pairing_code(result["code"])
store.set("setup_done", True)
# 开机自启
if result["autostart"]:
try:
if not is_enabled():
enable()
except Exception: # noqa: BLE001
pass
# 黑终端一闪:修防火墙
firewall_ok = True
firewall_note = ""
if result["firewall"]:
firewall_ok, firewall_note = fix_firewall_with_flash(store)
# 放行防火墙之前,配对码**不加入服务器**。
#
# 为什么:注册(= 配对码挂到服务器上)如果发生在防火墙之前,
# 那么防火墙没放行成功时,服务器上会留着一个谁也连不上的码,
# 而且它会把码占住,用户重装都换不了。
# 所以这里卡一道:防火墙没成功就不注册。
if not firewall_ok:
_tell_firewall_problem(firewall_note)
return 3
# 关键:设完码必须开一个配对窗口。
# 之前这里传 None(= 不开窗口 → allow_new_pairing=False),
# 结果用户刚设完码,被控端立刻就把门锁上,手表第一次配对必然失败,
# 报"被控端已关闭新设备配对"。
# 第一台设备配对成功后服务端会自动把窗口关上。
store.set("close_pair_after_first", True)
return run_silent(store, SETUP_PAIR_MINUTES)
def main(argv=None) -> int:
try:
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
except Exception:
pass
args = parse_args(argv)
if args.autostart:
return cli_autostart(args.autostart)
if args.status:
return cli_status()
# 这两个是给 .bat 调的:EXE 自带功能,不需要系统装 Python
if args.netcheck is not None:
return cli_netcheck(args.netcheck)
if args.fix_firewall:
return cli_fix_firewall()
if args.relay is not None:
return cli_relay(args.relay)
if args.relay_status:
return cli_relay_status()
if args.uninstall:
return cli_uninstall()
store = Store(data_dir())
if args.port:
if not (1 <= int(args.port) <= 65535):
print(f"端口 {args.port} 不合法,应在 1-65535 之间")
return 2
store.set("port", int(args.port))
if args.reset_devices:
store.devices = {}
store.save_devices()
if args.new_code or not store.pairing_code():
store.new_pairing_code()
# 显式要求静默 / 临时开配对窗口
if args.silent or args.add_device is not None:
return run_silent(store, args.add_device)
# --setup:重新设置
if args.setup:
return run_setup(store)
# 第一次运行:设置窗口 → 修防火墙 → 静默
if not store.get("setup_done", False):
return run_setup(store)
# 已经设置过:直接静默,屏幕上什么都不出现
return run_silent(store, None)
if __name__ == "__main__":
sys.exit(main())