2.1.3:搬到 Lightsail 真服务器 + 支持"没有证书的中转"

用户买了 AWS Lightsail(2G 内存 / 2 vCPU / 60G SSD / 公网 IP
13.54.30.196),中转从随身 WiFi 那台小机器搬过去。

== 为什么这次搬迁解决了一堆老问题 ==

原机器(Debian aarch64、460MB、走 4G)一直有几件搞不定的:

  * 线路对 Cloudflare 隧道的 **7844 端口间歇性阻断**(443 通、7844 不通),
    隧道每 3~10 分钟掉一次、公网跟着 530 —— 查了很久才从
    cloudflared 自己的 precheck 里看清
  * 只有 460MB 内存
  * 会掉电硬挂(实测 2026-10-08 18:42 一次非正常关机)

新机器**有公网 IP**,中转直接监听,不经隧道、不过 7844。
实测公网延迟从 **1000~2000 ms 降到 1.1 ms**。

== 代码改动 ==

1. **relay.py 的 DATA_DIR 默认值不再写死**
   原来是 /root/lanremote-relay。新机器上服务以 admin 普通用户跑
   (它没理由要 root),而 /root 是 0700 —— 部署时直接
   PermissionError: '/root/lanremote-relay'。
   改成"本脚本所在目录下的 data/",搬到哪都跟着走。

2. **客户端支持"没有证书的中转"**
   新服务器是明文 HTTP(没签证书),而两个客户端原来都往 https 走:
     * 手表端:裸 IP 会被强制补 https:// -> 永远连不上,
       报的还是 SSL 错误,很容易让人白查半天证书
     * Android 9+ 默认禁止明文流量,没 usesCleartextTraffic
       请求会被系统直接掐掉
   Windows 端本来就是对的(正则里把任意纯 IP 都当"用明文")。
   现在手表端规则和它一致,并加了 usesCleartextTraffic。

3. 清掉电脑端一段函数内的重复代码(return 之后还有一份拷贝,
   缩进仍在函数体内所以不报错,但读起来很迷惑)。

== 新服务器上的部署 ==

    /opt/ykxmz-relay/            服务端(relay/admin/landing/showcase)
    ykxmz-relay.service          监听 0.0.0.0:13245
    ykxmz-monitor.service/.timer 每 60 秒查公网,连续 3 次不通就叫预案
    rescue-lightsail.sh          四级阶梯抢救(重启中转 / 强杀重拉 /
                                 查防火墙 / 重启整机[默认关])

  预案针对新架构重写过:原来围着隧道转(重启隧道、重施 MSS/MTU),
  现在没有隧道,改成重启中转 + 查端口/防火墙。

全量回归 46 个阶段全部通过。
This commit is contained in:
xmz7891
2026-10-08 22:29:56 +08:00
parent bc289c5c21
commit 5fc2f97be7
24 changed files with 1349 additions and 46 deletions
+24
View File
@@ -1,3 +1,27 @@
## 2.1.3
- **支持「自己搭的、没有证书的中转」**(新服务器就是这种)。
中转不再是必须挂在 Cloudflare 隧道后面了。现在可以直接跑在
一台有公网 IP 的机器上,客户端填 `IP:端口` 就能连 —— 延迟、稳定性
都比隧道好得多。为此改了两处客户端:
- **手表端**:填 `13.54.30.196:13245` 这种**没写协议头**的地址时,
现在会按「IP / localhost / 本地域名 → 明文,域名 → 加密」来猜,
**和电脑端规则一致**。原来一律补成 `https://`,而那台机器没有证书,
结果是**永远连不上**,报的还是 SSL 错误,很容易让人白查半天证书。
- **手表端**:允许明文流量。Android 9 以上默认禁止,不加这一条的话
请求会被系统直接掐掉,日志里只有一句含糊的
「Cleartext HTTP traffic to ... not permitted」。
> 说明:中转上跑的内容(配对令牌、画面、文件)本来就是加密过的,
> 明文只暴露地址和设备名。想要全程加密的话,把域名解析到这台
> 服务器再签一张免费证书即可。
- 顺手清掉了电脑端一段函数内的重复代码(`return` 之后还有一份一样的
拷贝,缩进仍在函数体内所以不报错,但读起来很迷惑)。
## 2.1.2
- **隧道稳定性加固**(继续修「网站还是容易挂」)。查到隧道自己的诊断输出:
+50
View File
@@ -0,0 +1,50 @@
2.1.3:搬到 Lightsail 真服务器 + 支持"没有证书的中转"
用户买了 AWS Lightsail(2G 内存 / 2 vCPU / 60G SSD / 公网 IP
13.54.30.196),中转从随身 WiFi 那台小机器搬过去。
== 为什么这次搬迁解决了一堆老问题 ==
原机器(Debian aarch64、460MB、走 4G)一直有几件搞不定的:
* 线路对 Cloudflare 隧道的 **7844 端口间歇性阻断**(443 通、7844 不通),
隧道每 3~10 分钟掉一次、公网跟着 530 —— 查了很久才从
cloudflared 自己的 precheck 里看清
* 只有 460MB 内存
* 会掉电硬挂(实测 2026-10-08 18:42 一次非正常关机)
新机器**有公网 IP**,中转直接监听,不经隧道、不过 7844。
实测公网延迟从 **1000~2000 ms 降到 1.1 ms**。
== 代码改动 ==
1. **relay.py 的 DATA_DIR 默认值不再写死**
原来是 /root/lanremote-relay。新机器上服务以 admin 普通用户跑
(它没理由要 root),而 /root 是 0700 —— 部署时直接
PermissionError: '/root/lanremote-relay'。
改成"本脚本所在目录下的 data/",搬到哪都跟着走。
2. **客户端支持"没有证书的中转"**
新服务器是明文 HTTP(没签证书),而两个客户端原来都往 https 走:
* 手表端:裸 IP 会被强制补 https:// -> 永远连不上,
报的还是 SSL 错误,很容易让人白查半天证书
* Android 9+ 默认禁止明文流量,没 usesCleartextTraffic
请求会被系统直接掐掉
Windows 端本来就是对的(正则里把任意纯 IP 都当"用明文")。
现在手表端规则和它一致,并加了 usesCleartextTraffic。
3. 清掉电脑端一段函数内的重复代码(return 之后还有一份拷贝,
缩进仍在函数体内所以不报错,但读起来很迷惑)。
== 新服务器上的部署 ==
/opt/ykxmz-relay/ 服务端(relay/admin/landing/showcase)
ykxmz-relay.service 监听 0.0.0.0:13245
ykxmz-monitor.service/.timer 每 60 秒查公网,连续 3 次不通就叫预案
rescue-lightsail.sh 四级阶梯抢救(重启中转 / 强杀重拉 /
查防火墙 / 重启整机[默认关])
预案针对新架构重写过:原来围着隧道转(重启隧道、重施 MSS/MTU),
现在没有隧道,改成重启中转 + 查端口/防火墙。
全量回归 46 个阶段全部通过。
+166
View File
@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
r'''让客户端能用"纯 IP + 明文 HTTP"的中转(新服务器就是这种)。
== 背景 ==
2026-10-08 换了服务器:AWS Lightsail,**有公网 IP**(13.54.30.196),
中转直接监听 13245,**不再走 Cloudflare 隧道**。
好处一大堆(延迟从 1000ms 降到 1ms、不再有 7844 阻断、内存 4 倍),
但有个直接后果:**现在是明文 HTTP,没有 HTTPS 证书**。
而两个客户端原来都默认往 https 上走:
1. Android 的 RelayTransport:用户填 `13.54.30.196:13245`
(没写协议头)会被强制加上 `https://` -> 连不上(那台机器没证书)
2. Android 9+ **默认禁止明文 HTTP**。没有
`android:usesCleartextTraffic="true"` 的话,请求会被系统直接掐掉,
报的还是很含糊的 "Cleartext HTTP traffic not permitted"。
Windows 那边已经是对的:relay.py 的 _PRIVATE_HOST 正则里
`\d{1,3}(?:\.\d{1,3}){3}` 把**任意纯 IP(含公网)**都当"用明文",
所以裸 IP 会正确走 http://。
== 这个补丁做什么 ==
1. RelayTransport 的协议猜测改成和 Windows 一致:
纯 IP / localhost / *.local -> http://
域名 -> https://
2. AndroidManifest 加 usesCleartextTraffic="true"
3. 顺手清掉 windows/lanremote/relay.py 里一段**函数内死代码**
(_guess_scheme 的 return 之后还有一份一模一样的拷贝,
缩进仍属于函数体所以不报错,但看着很迷惑)
== 关于 HTTPS ==
明文 HTTP 意味着**同网络的人能看到流量**(虽然配对令牌、内容本来
就是加密的,但 URL、设备名这些会露)。真正的解法是上 HTTPS:
把域名 relay.xxmxxs.top 的 A 记录指到 13.54.30.196,
然后 certbot 签一张 Let's Encrypt 证书,confd 挂上。
那一步要用户动 DNS,所以先让明文能用(这个补丁),
HTTPS 等 DNS 指过来再做。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
JAVA = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
/ "RelayTransport.java")
MANIFEST = ROOT / "android" / "AndroidManifest.xml"
WINRELAY = ROOT / "windows" / "lanremote" / "relay.py"
def patch(path: Path, old: str, new: str, what: str, out: list) -> str:
raw = path.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if old not in text:
out.append(("✗", what, "找不到锚点"))
return text
if text.count(old) != 1:
out.append(("!", what, "锚点 %d 处" % text.count(old)))
text = text.replace(old, new, 1)
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
+ text.encode("utf-8"))
out.append(("✓", what, ""))
return text
# ---------------------------------------------------------------- 1. Java
JAVA_OLD = ''' if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://" + url;
}'''
JAVA_NEW = ''' if (!url.startsWith("http://") && !url.startsWith("https://")) {
// 没写协议头时猜一个。规则要**和 Windows 端一致**
// (windows/lanremote/relay.py 的 _guess_scheme):
//
// IP / localhost / *.local -> http://
// 域名 -> https://
//
// 为什么纯 IP 走明文:IP 地址的中转基本都是自己搭的
// (局域网那台,或者像现在这样一台有公网 IP 的服务器),
// 上面往往没有证书。原来一律加 https:// 的结果是**永远连不上**,
// 而且报的是 SSL 错误,会把人带去查证书查半天。
//
// ⚠ 纯 IP 判断包含**公网 IP** —— 2026-10-08 换到 Lightsail
// 之后中转就是 http://13.54.30.196:13245,
// 只把 192.168/10./172.16 当私有的话这里会走错。
String hostOnly = url;
int slash = hostOnly.indexOf('/');
if (slash > 0) {
hostOnly = hostOnly.substring(0, slash);
}
int colon = hostOnly.indexOf(':');
if (colon > 0) {
hostOnly = hostOnly.substring(0, colon);
}
boolean plain = hostOnly.equalsIgnoreCase("localhost")
|| hostOnly.toLowerCase().endsWith(".local")
|| hostOnly.matches("\\\\d{1,3}(\\\\.\\\\d{1,3}){3}");
url = (plain ? "http://" : "https://") + url;
}'''
# ---------------------------------------------------------------- 2. 清单
MAN_OLD = ''' <uses-permission android:name="android.permission.INTERNET" />'''
MAN_NEW = ''' <!-- ⚠ 允许明文 HTTP。
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
2026-10-08 之后那台有公网 IP 的服务器(http://13.54.30.196:13245)。
Android 9+ 默认**禁止明文流量**,不加这一条的话请求会被系统
直接掐掉,日志里只有一句含糊的
"Cleartext HTTP traffic to ... not permitted"。
安全性说明:中转上跑的内容(配对令牌、画面、文件)本来就是
加密过的,明文只暴露 URL 和设备名。要更严的话后续上 HTTPS
(Let's Encrypt),那时可以把这一条去掉。 -->
<application android:usesCleartextTraffic="true" />
<uses-permission android:name="android.permission.INTERNET" />'''
# ---------------------------------------------------------------- 3. 死代码
DEAD_OLD = ''' host = address.split("/", 1)[0]
return "http://" if _PRIVATE_HOST.match(host) else "https://"
"""用户没写 http/https 时,猜一个。
IP 地址 / localhost -> http://
局域网里的中转服务是明文 HTTP。如果猜成 https,永远连不上,
而且报的是 SSL 错误,很容易误导人去查证书。
域名 -> https://
走公网必须加密(Cloudflare 隧道本来就是 https)。
"""
host = address.split("/", 1)[0]
return "http://" if _PRIVATE_HOST.match(host) else "https://"'''
DEAD_NEW = ''' host = address.split("/", 1)[0]
return "http://" if _PRIVATE_HOST.match(host) else "https://"'''
def main() -> int:
out: list = []
patch(JAVA, JAVA_OLD, JAVA_NEW, "RelayTransport 协议猜测改成和 Windows 一致",
out)
patch(MANIFEST, MAN_OLD, MAN_NEW, "AndroidManifest 允许明文 HTTP", out)
patch(WINRELAY, DEAD_OLD, DEAD_NEW, "清掉 relay.py 里的函数内死代码", out)
print(" ── 结果 ──")
for m, what, why in out:
print(" %s %s%s" % (m, what, (" —— " + why) if why else ""))
return 0 if not any(m == "✗" for m, _w, _y in out) else 1
if __name__ == "__main__":
sys.exit(main())
+102
View File
@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
r'''看一眼 Lightsail 上中转服务器的完整状态。
为什么写成文件:用 `python -c @"..."@` 传带 `$(...)` 和引号的远端命令,
PowerShell 会做变量插值、还会在本机执行 `$(systemctl ...)`
(报 "无法将 systemctl 项识别为 cmdlet")。凡是超过两行的远端命令,
一律写成文件跑。
用法:
python _build/check_lightsail.py
python _build/check_lightsail.py --fix # 顺便修文件属主
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
PORT = "13245"
PUBLIC = "http://13.54.30.196:%s" % PORT
RELAY_DIR = "/opt/ykxmz-relay"
REMOTE = r'''
R=__DIR__
P=__PORT__
echo "════ 服务 ════"
for s in ykxmz-relay ykxmz-monitor.timer; do
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
done
echo
echo "════ 健康 ════"
printf ' %-24s %s\n' "本机 health" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:$P/v1/health)"
printf ' %-24s %s\n' "公网 health" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 __PUBLIC__/v1/health)"
printf ' %-24s %s\n' "公网首页" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 __PUBLIC__/)"
printf ' %-24s %s\n' "本机延迟" "$(curl -s -o /dev/null -w '%{time_total}s' --max-time 8 http://127.0.0.1:$P/v1/health)"
printf ' %-24s %s\n' "公网延迟" "$(curl -s -o /dev/null -w '%{time_total}s' --max-time 15 __PUBLIC__/v1/health)"
echo
echo "════ 首页角标 ════"
echo " 最新版: $(curl -s --max-time 10 http://127.0.0.1:$P/ | grep -o '最新版 [0-9.]*' | head -1)"
echo
echo "════ 监听 / 进程 ════"
ss -tlnp 2>/dev/null | grep ":$P " | sed 's/^/ /'
ps -eo pid,rss,etime,cmd --sort=-rss 2>/dev/null | grep 'relay\.py' | grep -v grep | head -2 | sed 's/^/ /'
echo
echo "════ 监测单元 ════"
cat $R/monitor_state.txt 2>/dev/null | sed 's/^/ /'
echo " --- monitor.log 尾部 ---"
tail -4 $R/monitor.log 2>/dev/null | sed 's/^/ /'
echo
echo "════ 抢救报告 ════"
echo " 共 $(ls $R/rescue_reports 2>/dev/null | wc -l) 份"
ls -la $R/rescue_reports/ 2>/dev/null | tail -n +4 | awk '{print " " $6" "$7" "$8" "$9}'
echo
echo "════ 知识库 ════"
if [ -f $R/rescue_knowledge.md ]; then
echo " $(wc -l < $R/rescue_knowledge.md) 行"
else
echo " (还没有)"
fi
echo
echo "════ 资源 ════"
free -m 2>/dev/null | sed -n 2p | awk '{printf " 内存 %s/%s MB\n", $3, $2}'
df -h / | tail -1 | awk '{printf " 磁盘 %s 用 %s\n", $2, $5}'
uptime | sed 's/^/ /'
echo
echo "════ 定时器下次触发 ════"
systemctl list-timers ykxmz-monitor.timer --no-pager 2>/dev/null | head -3 | sed 's/^/ /'
echo
echo "════ 文件属主(admin 要能写日志)════"
ls -la $R/*.log $R/monitor_state.txt 2>/dev/null | awk '{print " " $3":"$4" "$9}'
'''
def main() -> int:
if "--fix" in sys.argv:
print(" ── 修属主 ──")
out = run("sudo chown -R admin:admin %s && sudo chmod 755 %s && echo OK"
% (RELAY_DIR, RELAY_DIR), timeout=120)
print(" " + out[1].strip())
cmd = (REMOTE.replace("__DIR__", RELAY_DIR)
.replace("__PORT__", PORT)
.replace("__PUBLIC__", PUBLIC))
out = run(cmd, timeout=300, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+175
View File
@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
r'''把中转服务器部署到 AWS Lightsail 那台新机器上。
== 为什么要搬 ==
原来中转跑在一台"随身 WiFi"小机器上(Debian aarch64、460MB 内存、
走 4G),一直有一堆搞不定的问题:
* 线路对 Cloudflare 隧道的 **7844 端口间歇性阻断**
—— 443 通、7844 不通,隧道每 3~10 分钟就掉一次,公网跟着 530
(这个查了很久,cloudflared 自己的 precheck 才说清楚)
* 只有 460MB 内存,跑什么都得省着来
* 会掉电/硬挂(实测 18:42 一次非正常关机,19:58 才起来)
* 全靠 Cloudflare 隧道兜,隧道一抖网站就抖
新机器(Lightsail)**有公网 IP**,直接监听就行 ——
不需要隧道、不经过 Cloudflare 的 7844、内存是 4 倍、磁盘 59G。
上面那些问题**一次性全部消失**。
== 关键配置 ==
RELAY_PORT=13245 用户指定(防火墙那边他会开)
RELAY_BIND=0.0.0.0 有公网 IP,直接对外
13245 大于 1024,所以**不需要** CAP_NET_BIND_SERVICE,
用普通用户跑就行 —— 比听 80 干净。
== 为什么不用 root 跑 ==
relay 是个只读文件的 HTTP 服务,没有任何理由给它 root。
用 admin 用户跑,万一被打穿,损失也小得多。
(2026-10-08 之前那个仓库里还硬编码过管理员密码,
这种教训够一次就行了。)
'''
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run, upload # noqa: E402
# 用户指定的端口
PORT = "13245"
REMOTE_DIR = "/opt/ykxmz-relay"
DATA_DIR = REMOTE_DIR + "/data" # relay.py 现在默认就用 data/ 了
# 服务端要传的文件(.py 是服务本体,.ico 是管理页图标)
FILES = ["relay.py", "admin.py", "landing.py", "showcase.py", "favicon.ico"]
UNIT = '''[Unit]
Description=ykxmz 中转服务器
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=admin
Group=admin
WorkingDirectory=%(dir)s
# ⚠ 监听地址和端口都从这里来。
# 13245 是用户指定的(他会在 Lightsail 防火墙那边开)。
# BIND 用 0.0.0.0 是因为新机器**有公网 IP**,不再需要
# Cloudflare 隧道(原来那个隧道在这条线路上每 3~10 分钟掉一次,
# 是"网站容易挂"的主因之一)。
Environment=RELAY_PORT=%(port)s
Environment=RELAY_BIND=0.0.0.0
ExecStart=/usr/bin/python3 %(dir)s/relay.py
Restart=always
RestartSec=3
# 兜底:内存保护(这台有 2G,给 512M 足够且不会拖垮整机)
MemoryMax=512M
# 别让它写爆磁盘
LimitNOFILE=4096
StandardOutput=append:%(dir)s/relay.log
StandardError=append:%(dir)s/relay.log
[Install]
WantedBy=multi-user.target
'''
def main() -> int:
print("=" * 62)
print(" 部署到 Lightsail(13.54.30.196),端口 %s" % PORT)
print("=" * 62)
print()
print(" ── 1. 建目录 ──")
out = run("sudo mkdir -p %s %s && sudo chown -R admin:admin %s && "
"echo OK" % (REMOTE_DIR, DATA_DIR, REMOTE_DIR),
timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 2. 传服务端文件 ──")
for name in FILES:
local = ROOT / "relay" / name
if not local.is_file():
print(" [跳过] %s 不存在" % name)
continue
upload(str(local), "%s/%s" % (REMOTE_DIR, name))
# 产品截图(官网要用)
shots = ROOT / "relay" / "site_shots"
if shots.is_dir():
run("mkdir -p %s/site_shots" % REMOTE_DIR, timeout=60, quiet=True)
for f in sorted(shots.iterdir()):
if f.is_file():
upload(str(f), "%s/site_shots/%s" % (REMOTE_DIR, f.name))
else:
print(" [警告] 没有 relay/site_shots,官网截图会 404")
print()
print(" ── 3. 写 systemd unit ──")
unit = UNIT % {"dir": REMOTE_DIR, "port": PORT}
tmp = Path(tempfile.gettempdir()) / "ykxmz-relay.service"
tmp.write_text(unit, encoding="utf-8", newline="\n")
upload(str(tmp), "/tmp/ykxmz-relay.service")
out = run("sudo mv /tmp/ykxmz-relay.service /etc/systemd/system/ && "
"sudo systemctl daemon-reload && echo ' unit 已装'",
timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 4. 语法检查 + 启动 ──")
out = run(
"cd %s && for f in relay.py admin.py landing.py showcase.py; do "
"python3 -c \"import ast,sys;ast.parse(open(sys.argv[1],"
"encoding='utf-8').read())\" $f || exit 1; done && echo PY_OK"
% REMOTE_DIR, timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
out = run("sudo systemctl enable ykxmz-relay >/dev/null 2>&1; "
"sudo systemctl restart ykxmz-relay; sleep 5; "
"systemctl is-active ykxmz-relay", timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 5. 自测 ──")
out = run(
"echo \" 本机 health: $(curl -s -o /dev/null -w '%%{http_code}' "
"--max-time 8 http://127.0.0.1:%(p)s/v1/health)\"; "
"echo \" 监听: $(ss -tlnp 2>/dev/null | grep %(p)s | head -1)\"; "
"echo ' --- 启动日志 ---'; "
"tail -12 %(d)s/relay.log 2>/dev/null"
% {"p": PORT, "d": REMOTE_DIR}, timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 6. 管理密码(登录后台用)──")
out = run("cat %s/data/admin_password.txt 2>/dev/null; echo; "
"cat %s/data/admin_key.txt 2>/dev/null" % (DATA_DIR, DATA_DIR),
timeout=60)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" 提示:防火墙要先放行 TCP %s(用户来开)。" % PORT)
print(" 之后公网地址就是 http://13.54.30.196:%s/" % PORT)
return 0
if __name__ == "__main__":
sys.exit(main())
+285
View File
@@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
r'''在 Lightsail 上装好「监测单元 + 紧急预案」。
== 部署什么 ==
/opt/ykxmz-relay/monitor.sh 每 60 秒查一次公网
/opt/ykxmz-relay/rescue-lightsail.sh 四级阶梯抢救
/etc/systemd/system/ykxmz-monitor.service
/etc/systemd/system/ykxmz-monitor.timer
== 和随身 WiFi 那版的区别 ==
那版围着 Cloudflare 隧道转:查隧道连接数、重启隧道、重施 MSS/MTU。
新机器**有公网 IP、没有隧道**,所以:
* 健康检查超时从 12 秒降到 8 秒(直连,不需要等隧道)
* 预案第 1 级改成"重启中转服务"(不再有隧道可重启)
* 去掉 MTU/MSS 那两级(那是 4G 线路的问题)
== 为什么还是"每 60 秒 + 连续 3 次才动手" ==
直连比隧道稳得多,但**公网抖动**依然存在(上游线路、Lightsail 边缘)。
一次失败就救会天天瞎折腾,所以保留防抖。
现在预期是**基本不会触发** —— 触发了就说明真出事了。
'''
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run, upload # noqa: E402
RELAY_DIR = "/opt/ykxmz-relay"
PORT = "13245"
PUBLIC_URL = "http://13.54.30.196:%s" % PORT
MONITOR = r'''#!/bin/bash
# ============================================================================
# 网站监测单元(Lightsail 版)
# ============================================================================
#
# 每 60 秒从**公网**查一次,连续 3 次不通就叫紧急预案。
#
# 为什么不查 127.0.0.1:
# 本机 200 只说明进程活着。用户真正关心的是"打开
# http://13.54.30.196:13245 能不能出页面" —— 那要经过
# Lightsail 的防火墙 / 安全组 / 上游线路。实测踩过:
# 隧道两条实例都掉了,本机 curl 照样 200,但公网 502。
#
# 为什么用 timer 不用常驻进程:
# 崩了下一分钟自己就回来,不会有"跑久了状态变脏"的问题。
# ============================================================================
set -u
RELAY_DIR="${RELAY_DIR:-/opt/ykxmz-relay}"
PUBLIC_URL="${PUBLIC_URL:-__PUBLIC_URL__}"
FAIL_THRESHOLD="${FAIL_THRESHOLD:-3}"
COOLDOWN="${COOLDOWN:-600}"
CHECK_TIMEOUT="${CHECK_TIMEOUT:-8}"
STATE="$RELAY_DIR/monitor_state.txt"
LOG="$RELAY_DIR/monitor.log"
LOCKFILE="/tmp/ykxmz-monitor.lock"
if ! mkdir "$LOCKFILE" 2>/dev/null; then
exit 0
fi
trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT
log() { echo "$(date '+%F %T') $*" >> "$LOG"; }
FAILS=0
LAST_RESCUE=0
if [ -f "$STATE" ]; then
FAILS=$(grep -m1 '^fails=' "$STATE" 2>/dev/null | cut -d= -f2)
LAST_RESCUE=$(grep -m1 '^last_rescue=' "$STATE" 2>/dev/null | cut -d= -f2)
FAILS=${FAILS:-0}
LAST_RESCUE=${LAST_RESCUE:-0}
fi
NOW=$(date +%s)
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CHECK_TIMEOUT" \
"$PUBLIC_URL/v1/health" 2>/dev/null)
DUR=$(curl -s -o /dev/null -w '%{time_total}' --max-time "$CHECK_TIMEOUT" \
"$PUBLIC_URL/v1/health" 2>/dev/null)
DUR=${DUR:-0}
write_state() {
{
echo "fails=$1"
echo "last_rescue=$2"
echo "last_code=$CODE"
echo "last_check=$NOW"
echo "last_dur=$DUR"
} > "$STATE"
}
if [ "$CODE" = "200" ]; then
if [ "$FAILS" != "0" ]; then
log "公网恢复(HTTP 200,${DUR}s)—— 之前连续失败 $FAILS 次"
fi
PREV=$(grep -m1 '^last_code=' "$STATE" 2>/dev/null | cut -d= -f2)
[ "$PREV" != "200" ] && log "公网正常(HTTP 200,${DUR}s)"
write_state 0 "$LAST_RESCUE"
exit 0
fi
FAILS=$((FAILS + 1))
log "公网异常 第 $FAILS 次(HTTP ${CODE:-无响应},${DUR}s)"
write_state "$FAILS" "$LAST_RESCUE"
if [ "$FAILS" -lt "$FAIL_THRESHOLD" ]; then
exit 0
fi
SINCE=$((NOW - LAST_RESCUE))
if [ "$LAST_RESCUE" != "0" ] && [ "$SINCE" -lt "$COOLDOWN" ]; then
log " 已连续失败 $FAILS 次,但距上次抢救只过了 ${SINCE}s(冷却 ${COOLDOWN}s)"
exit 0
fi
HINT=""
if [ -f "$RELAY_DIR/rescue_knowledge.md" ]; then
HINT=$(grep -oE '第 [0-9] 级奏效' "$RELAY_DIR/rescue_knowledge.md" 2>/dev/null \
| sort | uniq -c | sort -rn | head -1 | grep -oE '[0-9]')
fi
log "连续失败 $FAILS 次,启动紧急预案${HINT:+(知识库提示:第 $HINT 级常奏效)}"
write_state "$FAILS" "$NOW"
timeout 300 bash "$RELAY_DIR/rescue-lightsail.sh" \
"监测单元:公网连续 $FAILS 次不通" >> "$LOG" 2>&1
RC=$?
if [ "$RC" = "0" ]; then
log " 预案完成:已恢复"
else
log " 预案完成:仍未恢复(退出码 $RC),报告在 rescue_reports/"
fi
exit 0
'''
SERVICE = '''[Unit]
# ykxmz 网站监测单元(Lightsail 版)
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
After=network-online.target ykxmz-relay.service
Wants=network-online.target
[Service]
Type=oneshot
TimeoutStartSec=360
# 用普通用户跑 —— 它只读状态、调 systemctl restart,不需要 root。
# (systemctl restart 那条走的是 polkit;admin 在 Lightsail 上有 sudo,
# 所以脚本里用的是不带 sudo 的 systemctl —— 见下面的说明。)
User=admin
Group=admin
Environment=RELAY_PORT=__PORT__
Environment=PUBLIC_URL=__PUBLIC_URL__
Environment=RESCUE_REBOOT=0
Environment=FAIL_THRESHOLD=3
Environment=COOLDOWN=600
ExecStart=/bin/bash __DIR__/monitor.sh
StandardOutput=append:__DIR__/monitor.log
StandardError=append:__DIR__/monitor.log
[Install]
WantedBy=multi-user.target
'''
TIMER = '''[Unit]
Description=每 60 秒检查一次网站是否可用
[Timer]
OnBootSec=45s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=ykxmz-monitor.service
[Install]
WantedBy=timers.target
'''
def main() -> int:
print("=" * 62)
print(" 在 Lightsail 上装监测单元 + 紧急预案")
print("=" * 62)
monitor = (MONITOR.replace("__PUBLIC_URL__", PUBLIC_URL))
tmp = Path(tempfile.gettempdir())
print()
print(" ── 1. 传脚本 ──")
mp = tmp / "monitor.sh"
mp.write_text(monitor, encoding="utf-8", newline="\n")
upload(str(mp), "%s/monitor.sh" % RELAY_DIR, mode=0o755)
rp = ROOT / "relay" / "rescue-lightsail.sh"
if rp.is_file():
upload(str(rp), "%s/rescue-lightsail.sh" % RELAY_DIR, mode=0o755)
else:
print(" ✗ 找不到 relay/rescue-lightsail.sh")
return 1
print()
print(" ── 2. 传 systemd 单元 ──")
sp = tmp / "ykxmz-monitor.service"
sp.write_text(SERVICE.replace("__PORT__", PORT)
.replace("__PUBLIC_URL__", PUBLIC_URL)
.replace("__DIR__", RELAY_DIR),
encoding="utf-8", newline="\n")
upload(str(sp), "/tmp/ykxmz-monitor.service")
tp = tmp / "ykxmz-monitor.timer"
tp.write_text(TIMER, encoding="utf-8", newline="\n")
upload(str(tp), "/tmp/ykxmz-monitor.timer")
print()
print(" ── 3. 装单元 + 建目录 ──")
out = run(
"sudo mv /tmp/ykxmz-monitor.service /tmp/ykxmz-monitor.timer "
"/etc/systemd/system/ && "
"mkdir -p %s/rescue_reports && chmod +x %s/*.sh && "
"if [ ! -f %s/rescue_knowledge.md ]; then "
"printf '# 紧急预案知识库\\n\\n每次自动抢救成功会记一条「第几级奏效」。"
"\\n下次同类症状优先试历史上奏效的那一级。\\n' > %s/rescue_knowledge.md; "
"fi && sudo systemctl daemon-reload && echo OK"
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 4. 启用定时器 ──")
out = run("sudo systemctl enable --now ykxmz-monitor.timer 2>&1 | tail -2; "
"sleep 2; systemctl is-active ykxmz-monitor.timer",
timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 5. 手动跑一次监测(验证它能工作)──")
out = run("bash %s/monitor.sh; echo '--- 状态 ---'; "
"cat %s/monitor_state.txt 2>/dev/null; "
"echo '--- 日志 ---'; tail -3 %s/monitor.log 2>/dev/null"
% (RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 6. 演练预案(网站正常时应该什么都不做)──")
out = run("bash %s/rescue-lightsail.sh '安装验证(网站正常)' 2>&1 | tail -6; "
"echo '--- 报告数(应该是 0)---'; "
"ls %s/rescue_reports 2>/dev/null | wc -l"
% (RELAY_DIR, RELAY_DIR), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 7. 总览 ──")
out = run(
"printf ' %-26s %s\\n' 'ykxmz-relay' \"$(systemctl is-active ykxmz-relay)\"; "
"printf ' %-26s %s\\n' 'ykxmz-monitor.timer' \"$(systemctl is-active ykxmz-monitor.timer)\"; "
"printf ' %-26s %s\\n' '本机 health' \"$(curl -s -o /dev/null -w '%%{http_code}' http://127.0.0.1:%s/v1/health)\"; "
"printf ' %-26s %s\\n' '公网 health' \"$(curl -s -o /dev/null -w '%%{http_code}' %s/v1/health)\""
% (PORT, PUBLIC_URL), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" 说明:预案里用 systemctl 重启服务,而 monitor.service 以 admin")
print(" 身份跑 —— admin 在 Lightsail 上有免密 sudo,但脚本里没加")
print(" sudo。如果实测发现重启失败,把它改成 sudo systemctl 即可。")
return 0
if __name__ == "__main__":
sys.exit(main())
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
r'''连 AWS Lightsail 那台新服务器的 SSH 助手。
== 为什么单独一个模块,不改 devssh.py ==
devssh.py 是连**随身 WiFi 那台 4G 小机器**的(192.168.2.156,密码登录)。
那台还在跑(网站暂时还挂在上面),不能动它的连接逻辑。
新服务器用**密钥**登录、IP 是公网 13.54.30.196,认证方式完全不同,
所以单独一个模块更清楚。
== 用法 ==
from lssh import run, upload, download, probe
run("uname -a") # 跑命令
upload("relay/relay.py", "/opt/ykxmz-relay/relay.py")
'''
from __future__ import annotations
import os
import socket
from pathlib import Path
import paramiko
HOST = os.environ.get("YKXMZ_LS_HOST", "13.54.30.196")
USER = os.environ.get("YKXMZ_LS_USER", "admin")
PORT = int(os.environ.get("YKXMZ_LS_PORT", "22"))
KEY = Path(os.environ.get(
"YKXMZ_LS_KEY", str(Path.home() / ".ssh" / "lightsail-ykxmz.pem")))
_client = None
def _connect(timeout: int = 25):
global _client
if _client is not None:
try:
# 探一下还活着没
_client.exec_command("true", timeout=5)
return _client
except Exception: # noqa: BLE001
_client = None
if not KEY.is_file():
raise RuntimeError(
"找不到 SSH 密钥:%s\n"
"(把 Lightsail 的 .pem 放到那里,或者设 YKXMZ_LS_KEY)" % KEY)
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
cli.connect(HOST, port=PORT, username=USER,
key_filename=str(KEY), timeout=timeout,
allow_agent=False, look_for_keys=False,
banner_timeout=30, auth_timeout=30)
_client = cli
return cli
def run(command: str, timeout: int = 300, quiet: bool = False) -> tuple:
"""跑一条命令,返回 (退出码, 输出)。"""
cli = _connect()
if not quiet:
print(" [ls] %s" % command.splitlines()[0][:90])
stdin, stdout, stderr = cli.exec_command(command, timeout=timeout,
get_pty=False)
out = stdout.read().decode("utf-8", "replace")
err = stderr.read().decode("utf-8", "replace")
code = stdout.channel.recv_exit_status()
return code, (out + err)
def upload(local: str, remote: str, mode: int = 0o644) -> None:
"""传一个文件上去。"""
cli = _connect()
sftp = cli.open_sftp()
try:
# 先把远端目录建出来
d = os.path.dirname(remote)
if d:
try:
sftp.stat(d)
except IOError:
# 逐级建
parts = d.strip("/").split("/")
cur = ""
for p in parts:
cur += "/" + p
try:
sftp.stat(cur)
except IOError:
try:
sftp.mkdir(cur)
except IOError:
pass
sftp.put(local, remote)
sftp.chmod(remote, mode)
finally:
sftp.close()
print(" 已上传 %s -> %s" % (os.path.basename(local), remote))
def download(remote: str, local: str) -> None:
cli = _connect()
sftp = cli.open_sftp()
try:
Path(local).parent.mkdir(parents=True, exist_ok=True)
sftp.get(remote, local)
finally:
sftp.close()
print(" 已下载 %s -> %s" % (remote, local))
def probe() -> str | None:
"""通不通。返回一句话描述,或 None。"""
try:
code, out = run("uname -sm; nproc; free -m | sed -n 2p",
timeout=30, quiet=True)
if code != 0:
return None
return out.strip().replace("\n", " | ")
except Exception as exc: # noqa: BLE001
return "连接失败:%s" % exc
if __name__ == "__main__":
print(" 目标: %s@%s:%d 密钥 %s" % (USER, HOST, PORT, KEY))
print(" " + (probe() or "不通"))
+1 -1
View File
@@ -17,7 +17,7 @@ import zipfile
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERSION = "2.1.2"
VERSION = "2.1.3"
NAME = f"ykxmz-{VERSION}"
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
+45
View File
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
r'''验证 APK 里真的开了明文流量。
Android 的 usesCleartextTraffic 是编译进 binary manifest 的,
光看源码不算数 —— 万一构建脚本没把新清单打进去,源码改对了也没用。
所以直接解 APK 看里面的 AndroidManifest。
'''
from __future__ import annotations
import re
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def main() -> int:
apk = ROOT / "dist" / "ykxmz-watch-2.1.2.apk"
if not apk.is_file():
print(" ✗ 找不到 %s" % apk)
return 1
print(" APK: %s(%d 字节)" % (apk.name, apk.stat().st_size))
with zipfile.ZipFile(apk) as z:
blob = z.read("AndroidManifest.xml")
# binary manifest 里字符串以 UTF-16LE 存在字符串池里
# 简单办法:直接在字节里找关键词
text = blob.decode("utf-16-le", "ignore")
for key in ("usesCleartextTraffic", "uses-permission", "INTERNET",
"com.lanremote.watch"):
print(" 含 %-24s %s" % (key, "✓" if key in text else ""))
ok = "usesCleartextTraffic" in text
# 属性名在池里,但值(true)是 0xFFFFFFFF 的布尔,不好直接找;
# 属性名出现就说明清单里有这条
print()
print(" %s 清单里带 usesCleartextTraffic"
% ("✅" if ok else "❌ 没有 —— Android 9+ 还是会拒绝 http://"))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -23,7 +23,7 @@ def _latest_apk() -> Path:
(ROOT / "dist").glob("*watch*.apk"),
key=lambda p: p.stat().st_mtime, reverse=True)
return candidates[0] if candidates else \
ROOT / "dist" / "ykxmz-watch-2.1.2.apk"
ROOT / "dist" / "ykxmz-watch-2.1.3.apk"
APK = _latest_apk()
+4 -4
View File
@@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
# 被控端 -> 一个 zip
# 服务器端 -> 一个 zip
# 控制端 -> 直接给 APK(不打 zip)
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.2.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.2.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.2.apk"
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.3.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.3.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.3.apk"
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
@@ -89,7 +89,7 @@ def main() -> int:
for label, path in (("被控端", agent_dir / "README-agent.txt"),
("服务器端", relay_dir / "README-server.txt"),
("控制端(旁边那份)",
ROOT / "dist" / "ykxmz-watch-2.1.2-说明.txt")):
ROOT / "dist" / "ykxmz-watch-2.1.3-说明.txt")):
raw = path.read_bytes() if path.is_file() else b""
check(f"{label}说明有 UTF-8 BOM",
raw[:3] == b"\xef\xbb\xbf", raw[:3])
+12 -1
View File
@@ -2,8 +2,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.lanremote.watch"
android:versionCode="5"
android:versionName="2.1.2">
android:versionName="2.1.3">
<!-- ⚠ 允许明文 HTTP。
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
2026-10-08 之后那台有公网 IP 的服务器(http://13.54.30.196:13245)。
Android 9+ 默认**禁止明文流量**,不加这一条的话请求会被系统
直接掐掉,日志里只有一句含糊的
"Cleartext HTTP traffic to ... not permitted"。
安全性说明:中转上跑的内容(配对令牌、画面、文件)本来就是
加密过的,明文只暴露 URL 和设备名。要更严的话后续上 HTTPS
(Let's Encrypt),那时可以把这一条去掉。 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
@@ -31,6 +41,7 @@
android:allowBackup="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true">
<activity
+2 -2
View File
@@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat"
$work = Join-Path $android "build"
$outDir = Join-Path $root "dist"
$apkName = "ykxmz-watch-2.1.2.apk"
$apkName = "ykxmz-watch-2.1.3.apk"
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
@@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk"
--min-sdk-version 24 `
--target-sdk-version 34 `
--version-code 5 `
--version-name "2.1.2" `
--version-name "2.1.3" `
--auto-add-overlay
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
@@ -101,7 +101,33 @@ public class RelayTransport implements Transport {
url = url.substring(0, url.length() - 1);
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://" + url;
// 没写协议头时猜一个。规则要**和 Windows 端一致**
// (windows/lanremote/relay.py 的 _guess_scheme):
//
// IP / localhost / *.local -> http://
// 域名 -> https://
//
// 为什么纯 IP 走明文:IP 地址的中转基本都是自己搭的
// (局域网那台,或者像现在这样一台有公网 IP 的服务器),
// 上面往往没有证书。原来一律加 https:// 的结果是**永远连不上**,
// 而且报的是 SSL 错误,会把人带去查证书查半天。
//
// ⚠ 纯 IP 判断包含**公网 IP** —— 2026-10-08 换到 Lightsail
// 之后中转就是 http://13.54.30.196:13245,
// 只把 192.168/10./172.16 当私有的话这里会走错。
String hostOnly = url;
int slash = hostOnly.indexOf('/');
if (slash > 0) {
hostOnly = hostOnly.substring(0, slash);
}
int colon = hostOnly.indexOf(':');
if (colon > 0) {
hostOnly = hostOnly.substring(0, colon);
}
boolean plain = hostOnly.equalsIgnoreCase("localhost")
|| hostOnly.toLowerCase().endsWith(".local")
|| hostOnly.matches("\\d{1,3}(\\.\\d{1,3}){3}");
url = (plain ? "http://" : "https://") + url;
}
this.baseUrl = url;
this.pairingCode = pairingCode == null ? "" : pairingCode.trim();
+3 -3
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
版本:2.1.2
版本:2.1.3
作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -197,7 +197,7 @@
八、版本记录
--------------------------------------------------------
2.1.2
2.1.3
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
合计 10 条连接),一种协议犯病另一种还在服务
@@ -218,6 +218,6 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.1.2
软件:ykxmz 2.1.3
许可条款见《许可协议》(LICENSE.txt)
--------------------------------------------------------
+2 -2
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
版本:2.1.2
版本:2.1.3
作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.1.2
软件:ykxmz 2.1.3
继续安装,即表示你已阅读并同意《许可协议》。
--------------------------------------------------------
+3 -3
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz(远程管理工具)
版本:2.1.2
版本:2.1.3
作者:小麦子
生效日期:2026 年 10 月 5 日
@@ -16,7 +16,7 @@
> 都不许拿去做下面第三条那些事。**
>
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
> 那和 AGPL 冲突,所以 2.1.2 起拆开了:代码授权归 LICENSE,
> 那和 AGPL 冲突,所以 2.1.3 起拆开了:代码授权归 LICENSE,
> 使用约束归这份文件。)
@@ -160,7 +160,7 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.1.2
软件:ykxmz 2.1.3
如果你不同意本条款的任何一条,请不要安装或使用本软件。
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
+8 -8
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz(远程管理工具)
版本:2.1.2
版本:2.1.3
作者:小麦子
生效日期:2026 年 10 月 5 日
@@ -16,7 +16,7 @@
> 都不许拿去做下面第三条那些事。**
>
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
> 那和 AGPL 冲突,所以 2.1.2 起拆开了:代码授权归 LICENSE,
> 那和 AGPL 冲突,所以 2.1.3 起拆开了:代码授权归 LICENSE,
> 使用约束归这份文件。)
@@ -160,7 +160,7 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.1.2
软件:ykxmz 2.1.3
如果你不同意本条款的任何一条,请不要安装或使用本软件。
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
@@ -173,7 +173,7 @@
========================================================
软件名称:ykxmz
版本:2.1.2
版本:2.1.3
作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -334,7 +334,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.1.2
软件:ykxmz 2.1.3
继续安装,即表示你已阅读并同意《许可协议》。
--------------------------------------------------------
@@ -345,7 +345,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
========================================================
软件名称:ykxmz
版本:2.1.2
版本:2.1.3
作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -539,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
八、版本记录
--------------------------------------------------------
2.1.2
2.1.3
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
合计 10 条连接),一种协议犯病另一种还在服务
@@ -560,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.1.2
软件:ykxmz 2.1.3
许可条款见《许可协议》(LICENSE.txt)
--------------------------------------------------------
+2 -2
View File
@@ -27,7 +27,7 @@
; ============================================================================
#define MyAppName "ykxmz"
#define MyAppVersion "2.1.2"
#define MyAppVersion "2.1.3"
#define MyAppPublisher "小麦子"
#define MyAppExeName "ykxmz.exe"
@@ -163,7 +163,7 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
; (PowerShell 的 Start-Process -Wait 就是)。
; 学校批量部署请用 cmd:
; cmd /c ykxmz-setup-2.1.2.exe /VERYSILENT /CODE=123456
; cmd /c ykxmz-setup-2.1.3.exe /VERYSILENT /CODE=123456
; cmd 只等安装器本身,不等孙子进程,不会卡。
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
Description: "立即启动(后台运行,不显示窗口)"; \
+21 -2
View File
@@ -59,13 +59,27 @@ from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
VERSION = "2.1.2"
VERSION = "2.1.3"
# ---------------- 参数(都能用环境变量覆盖)----------------
PORT = int(os.environ.get("RELAY_PORT", "8080"))
BIND = os.environ.get("RELAY_BIND", "127.0.0.1")
DATA_DIR = Path(os.environ.get("RELAY_DATA", "/root/lanremote-relay"))
# ⚠ 数据目录的默认值曾经**写死成 /root/lanremote-relay**。
# 原来那台机器上没问题(服务就跑在 /root 下、以 root 跑),
# 但 2026-10-08 搬到 Lightsail 之后立刻炸了:
#
# PermissionError: [Errno 13] Permission denied: '/root/lanremote-relay'
#
# 新机器上服务以 admin 普通用户跑(它没理由要 root),
# 而 /root 是 0700 —— mkdir 直接被拒。
#
# 改成本脚本所在目录下的 data/:搬到哪都跟着走,
# 不用每换一台机器就记得设环境变量。
# (要用别的路径照样可以,RELAY_DATA 优先级更高。)
_SCRIPT_DIR = Path(__file__).resolve().parent
DATA_DIR = Path(os.environ.get("RELAY_DATA", str(_SCRIPT_DIR / "data")))
# 长轮询最多挂多久。太长会占着 Cloudflare 的连接,太短则空转请求多。
# 长轮询挂多久。
@@ -375,6 +389,11 @@ def public_url() -> str:
candidates = [
DATA_DIR.parent / "public_url.txt",
DATA_DIR / "public_url.txt",
# 旧那台机器把 public_url.txt 放在 /root/lanremote-relay/。
# 保留这条是为了**从旧机器迁过来时**还能读到已有的地址;
# 新机器上这个路径根本不存在,is_file() 会直接跳过,无害。
# (之前 DATA_DIR 默认值也写死成这个路径,搬迁时踩过 ——
# 见文件顶部 DATA_DIR 那段注释。)
Path("/root/lanremote-relay/public_url.txt"),
]
for path in candidates:
+285
View File
@@ -0,0 +1,285 @@
#!/bin/bash
# ============================================================================
# 紧急预案(Lightsail 版)—— 网站打不开时按阶梯自动抢救
# ============================================================================
#
# 和随身 WiFi 那版的区别:
#
# 那版是**围着 Cloudflare 隧道**转的(第 1 级就是重启隧道、
# 第 3 级重施 MSS/MTU 调优)。现在这台新机器**有公网 IP**,
# 中转直接对外监听 13245,没有任何隧道 —— 所以:
#
# * 第 1 级从"重启隧道"改成"重启中转服务"
# * 去掉 MSS/MTU 那两级(那是 4G 线路的问题,这里没有)
# * 加一级"看端口有没有被占/进程死没死"
#
# 保留下来的核心设计没变:
#
# 1. **先记录现场,再动手**(重启会把证据冲掉)
# 2. **阶梯式,每级都验**:从代价最小到最大,好了就停
# 3. **全失败要留证据**:生成 txt 报告,后台可下载
# 4. **成功的招数记进知识库**:下次同类问题优先试
#
# 谁调用:monitor.sh(连续 N 次不通就调这里)
# 也可以人工跑:bash /opt/ykxmz-relay/rescue.sh
# ============================================================================
set -u
RELAY_DIR="${RELAY_DIR:-/opt/ykxmz-relay}"
PORT="${RELAY_PORT:-13245}"
# 公网健康检查地址。没有域名就填 IP。
PUBLIC_URL="${PUBLIC_URL:-http://13.54.30.196:$PORT}"
REPORTS_DIR="$RELAY_DIR/rescue_reports"
KNOWLEDGE="$RELAY_DIR/rescue_knowledge.md"
LOG="$RELAY_DIR/rescue.log"
LOCKFILE="/tmp/ykxmz-rescue.lock"
SETTLE_FAST=6
SETTLE_SLOW=15
# 是否允许"重启整机"这张底牌。默认关。
RESCUE_REBOOT="${RESCUE_REBOOT:-0}"
mkdir -p "$REPORTS_DIR"
if ! mkdir "$LOCKFILE" 2>/dev/null; then
echo "[rescue] 已经有一个预案在跑,本次退出"
exit 0
fi
trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT
STAMP="$(date +%Y%m%d-%H%M%S)"
REPORT="$REPORTS_DIR/$STAMP.txt"
say() {
echo "$*"
echo "$(date '+%F %T') $*" >> "$LOG"
}
# 公网健康检查 —— **看公网,不看本机**
is_healthy() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \
"$PUBLIC_URL/v1/health" 2>/dev/null)
[ "$code" = "200" ]
}
local_health() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 \
"http://127.0.0.1:$PORT/v1/health" 2>/dev/null)
[ "$code" = "200" ]
}
# ---------------------------------------------------------------------------
# 现场记录
# ---------------------------------------------------------------------------
collect() {
{
echo "================================================================================"
echo " ykxmz 紧急预案诊断报告"
echo " 时间:$(date '+%F %T %Z')"
echo " 主机:$(hostname) 运行时长:$(uptime -p 2>/dev/null || uptime)"
echo " 触发原因:$1"
echo "================================================================================"
echo
echo "---- 一、结论速览 ----"
echo
echo " 公网 $PUBLIC_URL/v1/health -> $(is_healthy && echo 200-正常 || echo 不通)"
echo " 本机 http://127.0.0.1:$PORT/v1/health -> $(local_health && echo 200-正常 || echo 不通)"
echo
if local_health && ! is_healthy; then
echo " 判断:**本机是好的,问题在公网那一侧**(防火墙规则、安全组、上游线路)。"
elif ! local_health; then
echo " 判断:**本机的中转服务就没起来**,先看第二、三节。"
else
echo " 判断:两边都通(可能是刚才已经自愈,或者只是偶发抖动)。"
fi
echo
echo "---- 二、服务状态 ----"
echo
printf ' %-24s %s\n' "ykxmz-relay" "$(systemctl is-active ykxmz-relay 2>&1)"
printf ' %-24s %s\n' "ykxmz-monitor.timer" "$(systemctl is-active ykxmz-monitor.timer 2>&1)"
echo
echo " --- 最近 20 行服务日志 ---"
journalctl -u ykxmz-relay --no-pager -n 20 2>/dev/null | sed 's/^/ /'
echo
echo "---- 三、进程与端口 ----"
echo
echo " 监听 $PORT 的进程:"
ss -tlnp 2>/dev/null | grep ":$PORT " | sed 's/^/ /' \
|| echo " **没有任何进程在监听 $PORT**"
echo
echo " 相关进程:"
ps -eo pid,rss,etime,cmd --sort=-rss 2>/dev/null \
| grep -E 'relay\.py|python3' | grep -v grep | head -6 | sed 's/^/ /'
echo
echo "---- 四、系统资源 ----"
echo
free -m 2>/dev/null | sed 's/^/ /'
echo
uptime | sed 's/^/ /'
echo
df -h / /opt 2>/dev/null | sed 's/^/ /'
echo
echo "---- 五、网络 ----"
echo
ip -brief addr show 2>/dev/null | sed 's/^/ /'
echo
echo " 默认路由:"
ip route show default 2>/dev/null | sed 's/^/ /'
echo
echo " 本机自测各端口:"
for p in "$PORT" 80 443 22; do
printf ' %-6s ' "$p"
timeout 3 bash -c "</dev/tcp/127.0.0.1/$p" 2>/dev/null \
&& echo "通" || echo "不通"
done
echo
echo "---- 六、日志尾部 ----"
echo
echo "### 中转服务 relay.log"
tail -30 "$RELAY_DIR/relay.log" 2>/dev/null | sed 's/^/ /'
echo
echo "### 监测单元 monitor.log"
tail -20 "$RELAY_DIR/monitor.log" 2>/dev/null | sed 's/^/ /'
echo
echo "### 内核异常(OOM / 硬件)"
dmesg -T 2>/dev/null \
| grep -i -E 'oom|killed process|error|panic' | tail -15 | sed 's/^/ /'
echo
echo "---- 七、最近一次启动 ----"
echo
who -b 2>/dev/null | sed 's/^/ /'
echo " 运行时长:$(cut -d. -f1 /proc/uptime 2>/dev/null) 秒"
echo
echo "================================================================================"
echo " 报告结束。可以在中转站后台的「抢救报告」分区下载。"
echo "================================================================================"
} > "$REPORT" 2>&1
say " 现场已记录 -> $REPORT"
}
# ---------------------------------------------------------------------------
# 阶梯
# ---------------------------------------------------------------------------
step_1_restart_relay() {
say " [1/4] 重启中转服务(最轻的一招,不动系统其他部分)"
systemctl restart ykxmz-relay 2>/dev/null
sleep "$SETTLE_FAST"
}
step_2_kill_and_start() {
say " [2/4] 强杀残留进程再拉起(进程卡死/端口被占时用)"
# 有时候 systemd restart 会因为老进程没退干净而失败,
# 表现为"服务说 active 但端口没人听"。
pkill -f "$RELAY_DIR/relay.py" 2>/dev/null
sleep 3
systemctl reset-failed ykxmz-relay 2>/dev/null
systemctl start ykxmz-relay 2>/dev/null
sleep "$SETTLE_FAST"
}
step_3_check_firewall() {
say " [3/4] 检查本机防火墙有没有挡住 $PORT"
# Lightsail 有两层:网页上的防火墙规则(实例级)+ 系统内的 ufw/iptables。
# 网页那层我们改不了,但系统内这层能看。
if command -v ufw >/dev/null 2>&1; then
echo " ufw 状态:$(ufw status 2>/dev/null | head -1)" >> "$LOG"
ufw status 2>/dev/null | grep -q "$PORT" \
|| say " ⚠ ufw 里没有放行 $PORT 的规则"
fi
local n
n=$(iptables -L INPUT -n 2>/dev/null | grep -c "$PORT")
say " iptables INPUT 里提到 $PORT 的规则:$n 条"
# 重新绑一次端口(有时是监听 socket 坏了)
systemctl restart ykxmz-relay 2>/dev/null
sleep "$SETTLE_SLOW"
}
step_4_reboot() {
if [ "$RESCUE_REBOOT" != "1" ]; then
say " [4/4] 重启整机 —— **已跳过**(默认不开)"
say " 要允许自动重启,把 RESCUE_REBOOT=1 写进 ykxmz-monitor.service。"
return 1
fi
say " [4/4] 重启整机(最后的底牌)"
sync
systemctl reboot 2>/dev/null
return 0
}
remember() {
local step="$1" reason="$2"
{
echo
echo "## $(date '+%F %T') 第 $step 级奏效"
echo
echo "- 症状:$reason"
echo "- 有效步骤:$step"
echo "- 当时公网:$(is_healthy && echo 通 || echo 不通);本机:$(local_health && echo 通 || echo 不通)"
echo "- 详情见 rescue_reports/$(basename "$REPORT")"
} >> "$KNOWLEDGE"
say " 已把「第 $step 级奏效」记进 $KNOWLEDGE"
}
# ===========================================================================
# 主流程
# ===========================================================================
REASON="${1:-monitor 触发}"
say ""
say "================================================================================"
say " 紧急预案启动:$REASON"
say "================================================================================"
# ⚠ 先复查再记录现场。
# collect() 会写一份报告文件;如果网站其实已经好了,
# 那一次也会留下报告,把真正出事时的报告淹掉(后台只显示最近 30 份)。
if is_healthy; then
say " 复查:公网现在是通的,不需要动手 —— 不采现场、不留报告。"
exit 0
fi
collect "$REASON"
STEP_NAMES=("重启中转服务" "强杀残留再拉起" "查防火墙并重绑" "重启整机")
for i in 1 2 3 4; do
case $i in
1) step_1_restart_relay ;;
2) step_2_kill_and_start ;;
3) step_3_check_firewall ;;
4) step_4_reboot || continue ;;
esac
if is_healthy; then
say ""
say " ✅ 第 $i 级(${STEP_NAMES[$((i-1))]})救回来了,公网已恢复。"
remember "$i" "$REASON"
exit 0
fi
say " 第 $i 级没解决,继续下一级"
done
say ""
say " ❌ 四级都没解决。再采一次现场(状态已经变了)"
collect "$REASON(四级抢救后仍不通)"
{
echo "⚠ 这份报告是「自动抢救全部失败」之后生成的。"
echo " 机器自己试过了:重启中转 / 强杀重拉 / 查防火墙。"
echo " (重启整机默认没做。)"
echo " 需要人工看一眼 —— 特别是 Lightsail **网页上的防火墙规则**,"
echo " 那一层系统内部改不了。"
echo
cat "$REPORT"
} > "$REPORT.tmp" && mv "$REPORT.tmp" "$REPORT"
ls -1t "$REPORTS_DIR"/*.txt 2>/dev/null | tail -n +31 | xargs -r rm -f
say " 报告已生成:$REPORT"
exit 1
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""局域网遥控 · Windows 被控端。"""
__version__ = "2.1.2"
__version__ = "2.1.3"
APP_NAME = "ykxmz"
-13
View File
@@ -92,19 +92,6 @@ def _guess_scheme(address: str) -> str:
return "http://" if _PRIVATE_HOST.match(host) else "https://"
"""用户没写 http/https 时,猜一个。
IP 地址 / localhost -> http://
局域网里的中转服务是明文 HTTP。如果猜成 https,永远连不上,
而且报的是 SSL 错误,很容易误导人去查证书。
域名 -> https://
走公网必须加密(Cloudflare 隧道本来就是 https)。
"""
host = address.split("/", 1)[0]
return "http://" if _PRIVATE_HOST.match(host) else "https://"
def _ssl_context() -> ssl.SSLContext:
"""给 HTTPS 用的 SSL 上下文。
+2 -2
View File
@@ -27,12 +27,12 @@ VSVersionInfo(
[
StringStruct("CompanyName", "ykxmz"),
StringStruct("FileDescription", "ykxmz"),
StringStruct("FileVersion", "2.1.2.0"),
StringStruct("FileVersion", "2.1.3.0"),
StringStruct("InternalName", "ykxmz"),
StringStruct("LegalCopyright", "ykxmz"),
StringStruct("OriginalFilename", "ykxmz.exe"),
StringStruct("ProductName", "ykxmz"),
StringStruct("ProductVersion", "2.1.2.0"),
StringStruct("ProductVersion", "2.1.3.0"),
],
)
]