mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-19 08:20:44 +00:00
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:
@@ -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
|
||||
|
||||
- **隧道稳定性加固**(继续修「网站还是容易挂」)。查到隧道自己的诊断输出:
|
||||
|
||||
@@ -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 个阶段全部通过。
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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 "不通"))
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user