mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 13:40:45 +00:00
搬到 Lightsail:nginx 反代 + TLS + 公共 DNS + 后台口令改由服务器持有
== 架构(最终形态)==
用户 --HTTPS--> Cloudflare CDN --HTTP/HTTPS--> nginx --+--> 127.0.0.1:13245
yk.xxmxxs.top 13.54.30.196
中转只绑 127.0.0.1,公网只能经 nginx 进来 —— 少一个暴露面
(实测公网直连 :13245 返回 000,符合预期)。
== 这一轮加的 ==
1. **nginx 反代**(_build/setup_nginx.py)
Cloudflare 只能回源到特定端口(80/8080/443/2053…),
中转跑的 13245 不在列表里,所以中间必须有一层。
配置要点:
/download/ proxy_buffering on
/v1/ proxy_read_timeout 180s + **proxy_buffering off**
(长轮询要挂 60 秒;关缓冲,否则要等整个响应完
才吐给客户端,那就不是长轮询了)
2. **源站 TLS 证书**(_build/setup_tls_origin.py)
Cloudflare 的 SSL 模式是 Full(strict),回源走 443,源站只有 80
-> 522。用 certbot 的 nginx 插件签了 Let's Encrypt 证书。
开着橙色云朵也能签:HTTP-01 挑战经 Cloudflare 回源到 80 能验证。
踩的坑:第一次用 example.com 当邮箱,Let's Encrypt 拒收保留域名。
3. **公共 DNS**(_build/fix_server_dns.py)
换域名之后 **服务器自己解析不了** yk.xxmxxs.top:
Lightsail 默认的内网解析器 172.26.0.2 还缓存着"不存在"(负缓存),
而 1.1.1.1 / 8.8.8.8 已经能解析。
后果不只是监测误报 —— 服务器上任何解析该域名的东西都会失败。
配上公共 DNS,内网那个降为后备(它对 AWS 自家服务更快)。
4. **监测与预案重写**(relay/monitor-lightsail.sh、rescue-lightsail.sh)
分两层探测,才能分清是哪一层坏的:
PRIMARY https://域名 用户实际走的
FALLBACK 127.0.0.1:13245 中转本身
PRIMARY 挂 + FALLBACK 好 -> edge 层 -> 动 nginx
两个都挂 -> core 层 -> 动中转
不区分的话只能瞎重启,而 edge 层的问题重启中转一万次也没用。
做法上也改了:不再"SSH 过去用嵌套 heredoc 现场打补丁"
(这一轮因此踩了五次转义/求值/百分号格式化的坑),
改成**本地写完整文件、整份上传**,能进 git 做版本管理。
5. **后台口令改由服务器持有**(_build/set_admin_password.py)
密码只写进 /opt/ykxmz-relay/data/admin_password.txt(600),
**不进代码、不进仓库**。本地脚本从环境变量或
_build/.admin_password(已 gitignore)读取。
这个仓库已经吃过一次"管理员密码硬编码进提交历史"的教训了。
6. **验证脚本**(_build/verify_admin_login.py)
第一版只看登录请求的状态码,结果"对的密码/错的密码/不带凭据"
**全是 302** —— 等于没测。改成看 cookie 再访问 /admin,
区分拿到的是登录页还是后台页。实测:
正确密码 -> cookie 1 条 -> 后台页
错误密码 -> cookie 0 条 -> 登录页
账号写错 -> cookie 0 条 -> 登录页
经 Cloudflare 域名 -> 后台页
== 其他修复 ==
* 下载响应有**两条** Cache-Control(nginx 和 relay 各发一条),
Cloudflare 遇到重复的缓存指令可能直接跳过缓存 -> 去掉 nginx 那条
* 监测探测地址跟着监听地址改(改入口必须回头看所有探测入口)
* curl 加 -L 跟跳转(certbot 加了 http->https 301)
* 旧服务器(随身 WiFi 那台)的服务全部停掉并取消自启,
但**没关机、没删文件** —— 保持可逆
全量回归 46 个阶段通过;594 个 Python 文件语法通过。
This commit is contained in:
+3
-1
@@ -78,4 +78,6 @@ desktop.ini
|
|||||||
admin_password.txt
|
admin_password.txt
|
||||||
*admin_password*.txt
|
*admin_password*.txt
|
||||||
/_build/_hdr_data/
|
/_build/_hdr_data/
|
||||||
/_build/relay_info.json
|
/_build/relay_info.json
|
||||||
|
# 后台管理密码(绝不进仓库)
|
||||||
|
_build/.admin_password
|
||||||
|
|||||||
@@ -42,23 +42,25 @@ done
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 三层地址(用户路径 / 绕 CF / 直连中转)════"
|
echo "════ 三层地址(用户路径 / 绕 CF / 直连中转)════"
|
||||||
printf ' %-26s %s\n' "域名 __DOMAIN__" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 __DOMAIN_URL__/v1/health)"
|
# ⚠ 一律加 -L 跟跳转:nginx 那边 http 会 301 到 https,
|
||||||
printf ' %-26s %s\n' "IP:80(nginx)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 __NGINX_URL__/v1/health)"
|
# 不跟跳转拿到的是 301 而不是 200,会看着像"挂了"。
|
||||||
|
printf ' %-26s %s\n' "域名 __DOMAIN__" "$(curl -sL -o /dev/null -w '%{http_code}' --max-time 12 __DOMAIN_URL__/v1/health)"
|
||||||
|
printf ' %-26s %s\n' "IP:80(nginx)" "$(curl -sL -o /dev/null -w '%{http_code}' --max-time 12 __NGINX_URL__/v1/health)"
|
||||||
printf ' %-26s %s\n' ":__PORT__(应 000)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 __DIRECT_URL__/v1/health)"
|
printf ' %-26s %s\n' ":__PORT__(应 000)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 __DIRECT_URL__/v1/health)"
|
||||||
printf ' %-26s %s\n' "本机 :__PORT__" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:$P/v1/health)"
|
printf ' %-26s %s\n' "本机 :__PORT__" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:$P/v1/health)"
|
||||||
printf ' %-26s %s\n' "IP:80 延迟" "$(curl -s -o /dev/null -w '%{time_total}s' --max-time 12 __NGINX_URL__/v1/health)"
|
printf ' %-26s %s\n' "域名延迟" "$(curl -sL -o /dev/null -w '%{time_total}s' --max-time 12 __DOMAIN_URL__/v1/health)"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 首页角标 ════"
|
echo "════ 首页角标 ════"
|
||||||
echo " IP:80 -> $(curl -s --max-time 10 __NGINX_URL__/ | grep -o '最新版 [0-9.]*' | head -1)"
|
echo " 域名 -> $(curl -sL --max-time 12 __DOMAIN_URL__/ | grep -o '最新版 [0-9.]*' | head -1)"
|
||||||
echo " 域名 -> $(curl -s --max-time 12 __DOMAIN_URL__/ | grep -o '最新版 [0-9.]*' | head -1)"
|
echo " IP:80 -> $(curl -sL --max-time 12 __NGINX_URL__/ | grep -o '最新版 [0-9.]*' | head -1)"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 缓存头(决定 Cloudflare 缓不缓存)════"
|
echo "════ 缓存头(决定 Cloudflare 缓不缓存)════"
|
||||||
printf ' %-20s %s 条\n' "下载" "$(curl -sI --max-time 10 __NGINX_URL__/download/ykxmz-setup-2.1.3.exe | grep -ci cache-control)"
|
printf ' %-20s %s 条\n' "下载" "$(curl -sL -I --max-time 12 __DOMAIN_URL__/download/ykxmz-setup-2.1.3.exe | grep -ci cache-control)"
|
||||||
curl -sI --max-time 10 __NGINX_URL__/download/ykxmz-setup-2.1.3.exe \
|
curl -sL -I --max-time 12 __DOMAIN_URL__/download/ykxmz-setup-2.1.3.exe \
|
||||||
| grep -i '^cache-control' | sed 's/^/ /'
|
| grep -i '^cache-control' | sed 's/^/ /'
|
||||||
printf ' %-20s %s\n' "长轮询" "$(curl -sI --max-time 10 __NGINX_URL__/v1/health | grep -i '^cache-control' | tr -d '\r')"
|
printf ' %-20s %s\n' "长轮询" "$(curl -sL -I --max-time 12 __DOMAIN_URL__/v1/health | grep -i '^cache-control' | tr -d '\r')"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 监听 / 进程 ════"
|
echo "════ 监听 / 进程 ════"
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查服务器的 DNS 为什么解析不了 yk.xxmxxs.top。
|
||||||
|
|
||||||
|
现象:服务器上 curl 报 "Could not resolve host",但
|
||||||
|
加 --resolve 手动指定 Cloudflare 的边缘 IP 就 200。
|
||||||
|
也就是**网络通、只有解析不行**。
|
||||||
|
|
||||||
|
监测单元查的就是这个域名,所以它必然一直失败
|
||||||
|
-> 启动紧急预案 -> 瞎折腾。
|
||||||
|
|
||||||
|
要区分是哪种情况:
|
||||||
|
A. 服务器的 DNS 服务器本身坏了 / 不响应
|
||||||
|
B. DNS 服务器能通,但**不知道这个域名**(还没传播到这台机器用的解析器)
|
||||||
|
C. systemd-resolved 的缓存或 stub 出问题
|
||||||
|
D. /etc/hosts 或 nsswitch 配置把解析绕过去了
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
D=__DOMAIN__
|
||||||
|
|
||||||
|
echo "════ 1. /etc/resolv.conf ════"
|
||||||
|
cat /etc/resolv.conf 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 2. 有没有 systemd-resolved ════"
|
||||||
|
systemctl is-active systemd-resolved 2>&1 | sed 's/^/ systemd-resolved: /'
|
||||||
|
resolvectl status 2>/dev/null | grep -E 'DNS Servers|DNS Domain|Current DNS|Fallback' | head -8 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 3. 拿几个公共 DNS 直接问(绕过本机配置)════"
|
||||||
|
for ns in 1.1.1.1 8.8.8.8 9.9.9.9; do
|
||||||
|
printf ' %-10s ' "$ns"
|
||||||
|
if command -v dig >/dev/null 2>&1; then
|
||||||
|
r=$(dig +short +time=4 +tries=1 @$ns A $D 2>/dev/null | head -3 | tr '\n' ' ')
|
||||||
|
echo "${r:-(没答)}"
|
||||||
|
else
|
||||||
|
echo "(没装 dig)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 4. 本机默认解析 ════"
|
||||||
|
printf ' %-26s ' "getent hosts"; getent hosts $D 2>/dev/null || echo "(空)"
|
||||||
|
printf ' %-26s ' "解析一个已知域名"; getent hosts www.cloudflare.com 2>/dev/null | head -1 || echo "(也失败!说明 DNS 整体有问题)"
|
||||||
|
printf ' %-26s ' "解析亚马逊自家域名"; getent hosts aws.amazon.com 2>/dev/null | head -1 || echo "(失败)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 5. nsswitch / hosts ════"
|
||||||
|
grep -vE '^\s*#|^\s*$' /etc/nsswitch.conf 2>/dev/null | grep '^hosts' | sed 's/^/ /'
|
||||||
|
echo " --- /etc/hosts 里有没有 yk.xxmxxs.top ---"
|
||||||
|
grep -i "$D" /etc/hosts 2>/dev/null | sed 's/^/ /' || echo " (没有)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 6. 用 python 解析(走的是同一套 nsswitch)════"
|
||||||
|
python3 -c "
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
print(' ', socket.getaddrinfo('$D', 443)[:2])
|
||||||
|
except Exception as e:
|
||||||
|
print(' 失败:', e)
|
||||||
|
" 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 7. 装个 dig(如果没装)════"
|
||||||
|
command -v dig >/dev/null 2>&1 || {
|
||||||
|
echo " 装 dnsutils…"
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq dnsutils 2>&1 | tail -1
|
||||||
|
}
|
||||||
|
echo " dig: $(command -v dig || echo 没装)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 8. 再问一次(装完 dig 之后)════"
|
||||||
|
for ns in 1.1.1.1 8.8.8.8; do
|
||||||
|
printf ' %-10s ' "$ns"
|
||||||
|
dig +short +time=5 +tries=2 @$ns A $D 2>/dev/null | head -3 | tr '\n' ' ' | sed 's/$/\n/'
|
||||||
|
done
|
||||||
|
echo " 默认解析:"
|
||||||
|
dig +short +time=5 A $D 2>/dev/null | head -3 | sed 's/^/ /' || echo " (空)"
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 查服务器的 DNS")
|
||||||
|
print("=" * 62)
|
||||||
|
out = run(REMOTE.replace("__DOMAIN__", DOMAIN), timeout=600, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查服务器为什么访问不了自己的域名(curl 返回 000)。
|
||||||
|
|
||||||
|
== 现象 ==
|
||||||
|
|
||||||
|
从我的电脑 -> https://yk.xxmxxs.top = 200 ✅
|
||||||
|
从服务器自己-> https://yk.xxmxxs.top = 000 ❌
|
||||||
|
|
||||||
|
监测单元就跑在服务器上、查的就是这个域名,所以它一直判失败
|
||||||
|
-> fails 一直涨 -> 会启动紧急预案 -> 瞎折腾。
|
||||||
|
|
||||||
|
== 最可能的原因:IPv6 ==
|
||||||
|
|
||||||
|
yk.xxmxxs.top 在 Cloudflare 上有 **AAAA 记录**:
|
||||||
|
2606:4700:3036::6815:362d
|
||||||
|
2606:4700:3036::ac43:df96
|
||||||
|
|
||||||
|
Lightsail 的实例默认**没有 IPv6 地址**。curl 会先试 IPv6、失败,
|
||||||
|
如果不自动回退到 IPv4,就直接报连接失败(=000)。
|
||||||
|
|
||||||
|
验证办法很简单:`curl -4`(强制 IPv4)通不通。
|
||||||
|
|
||||||
|
== 其他可能 ==
|
||||||
|
|
||||||
|
* DNS 在服务器上解析不出来(getent hosts 看看)
|
||||||
|
* Cloudflare 拒绝来自源站自己 IP 的请求(回环防护)——不太可能
|
||||||
|
* 出站被挡(Lightsail 默认出站全开,概率低)
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
D=__DOMAIN__
|
||||||
|
|
||||||
|
echo "════ 1. 服务器有没有 IPv6 ════"
|
||||||
|
ip -brief addr show 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " --- IPv6 默认路由 ---"
|
||||||
|
ip -6 route show default 2>/dev/null | sed 's/^/ /' || echo " (没有 IPv6 默认路由)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 2. 域名解析(IPv4 / IPv6 分开看)════"
|
||||||
|
echo " A 记录:"
|
||||||
|
getent ahostsv4 $D 2>/dev/null | awk '{print " " $1}' | sort -u
|
||||||
|
echo " AAAA 记录:"
|
||||||
|
getent ahostsv6 $D 2>/dev/null | awk '{print " " $1}' | sort -u
|
||||||
|
echo " --- dig 结果 ---"
|
||||||
|
dig +short A $D 2>/dev/null | sed 's/^/ A: /'
|
||||||
|
dig +short AAAA $D 2>/dev/null | sed 's/^/ AAAA: /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 3. curl 各种姿势 ════"
|
||||||
|
printf ' %-30s %s\n' "默认(可能走 IPv6)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://$D/v1/health)"
|
||||||
|
printf ' %-30s %s\n' "强制 IPv4 (-4)" "$(curl -s4 -o /dev/null -w '%{http_code}' --max-time 15 https://$D/v1/health)"
|
||||||
|
printf ' %-30s %s\n' "强制 IPv6 (-6)" "$(curl -s6 -o /dev/null -w '%{http_code}' --max-time 10 https://$D/v1/health)"
|
||||||
|
printf ' %-30s %s\n' "http 强制 IPv4" "$(curl -s4L -o /dev/null -w '%{http_code}' --max-time 15 http://$D/v1/health)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " --- 详细报错(默认那条)---"
|
||||||
|
curl -s -o /dev/null -w ' %{http_code}\n' --max-time 10 https://$D/v1/health 2>&1 || true
|
||||||
|
curl -v -o /dev/null --max-time 12 https://$D/v1/health 2>&1 | grep -iE 'trying|connected|refused|timed|error|Could not' | head -10 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 4. 直连 Cloudflare 边缘 IP(跳过 DNS)════"
|
||||||
|
for ip in 104.21.54.45 172.67.223.150; do
|
||||||
|
printf ' %-20s %s\n' "$ip (HTTPS)" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 12 --resolve $D:443:$ip https://$D/v1/health)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 5. 出站到 Cloudflare 通不通(纯 TCP)════"
|
||||||
|
for ip in 104.21.54.45 172.67.223.150; do
|
||||||
|
if timeout 6 bash -c "</dev/tcp/$ip/443" 2>/dev/null; then
|
||||||
|
echo " $ip:443 通"
|
||||||
|
else
|
||||||
|
echo " $ip:443 不通"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 6. 对比:直连自己的 nginx ════"
|
||||||
|
printf ' %-30s %s\n' "127.0.0.1:443(带 SNI)" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 --resolve $D:443:127.0.0.1 https://$D/v1/health)"
|
||||||
|
printf ' %-30s %s\n' "公网 IP:443" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 --resolve $D:443:13.54.30.196 https://$D/v1/health)"
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 查服务器为什么访问不了自己的域名")
|
||||||
|
print("=" * 62)
|
||||||
|
out = run(REMOTE.replace("__DOMAIN__", DOMAIN), timeout=300, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''给服务器配上公共 DNS,让它能解析自己的域名。
|
||||||
|
|
||||||
|
== 问题 ==
|
||||||
|
|
||||||
|
Lightsail 默认用内网解析器 172.26.0.2:
|
||||||
|
|
||||||
|
Current DNS Server: 172.26.0.2
|
||||||
|
dig @172.26.0.2 yk.xxmxxs.top -> 空
|
||||||
|
dig @1.1.1.1 yk.xxmxxs.top -> 104.21.54.45 ✅
|
||||||
|
dig @8.8.8.8 yk.xxmxxs.top -> 104.21.54.45 ✅
|
||||||
|
|
||||||
|
用户刚加完 DNS 记录,公共解析器已经更新,但 Amazon 那个内网解析器
|
||||||
|
**还缓存着"这个域名不存在"**(负缓存,通常几分钟到一小时)。
|
||||||
|
|
||||||
|
后果不只是"监测误报"——任何在服务器上解析该域名的东西都会失败。
|
||||||
|
所以配上公共 DNS,既有 IPv4 也有 IPv6,并且让内网那个当后备
|
||||||
|
(内网解析器对 AWS 自家服务更快,留着有用)。
|
||||||
|
|
||||||
|
== 怎么配(Debian 13 + systemd-resolved)==
|
||||||
|
|
||||||
|
/etc/systemd/resolved.conf.d/ 下放一个 drop-in:
|
||||||
|
|
||||||
|
[Resolve]
|
||||||
|
DNS=1.1.1.1 8.8.8.8 2606:4700:4700::1111 2606:4700:4700::1001
|
||||||
|
FallbackDNS=172.26.0.2
|
||||||
|
Domains=~.
|
||||||
|
|
||||||
|
`Domains=~.` 表示"所有域名都用上面这些",把内网那个降为后备。
|
||||||
|
|
||||||
|
改完 `systemctl restart systemd-resolved` 生效。
|
||||||
|
|
||||||
|
== 为什么不直接改 /etc/resolv.conf ==
|
||||||
|
|
||||||
|
它是指向 systemd-resolved stub(127.0.0.53)的软链接,
|
||||||
|
手改会被覆盖。要改就改 resolved 的配置。
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
CONF = '''# ykxmz:给这台服务器配上公共 DNS。
|
||||||
|
#
|
||||||
|
# 起因:Lightsail 默认用内网解析器 172.26.0.2,而用户刚加的
|
||||||
|
# yk.xxmxxs.top 记录它**还缓存着"不存在"**(负缓存),
|
||||||
|
# 导致服务器自己解析不了这个域名 —— 监测单元因此一直误报网站挂了。
|
||||||
|
#
|
||||||
|
# 公共解析器(1.1.1.1 / 8.8.8.8)已经能正确解析,所以配上来。
|
||||||
|
# 内网解析器降为后备:它对 AWS 自家服务(比如实例元数据、
|
||||||
|
# S3 内网端点)响应更快,留着有用。
|
||||||
|
[Resolve]
|
||||||
|
DNS=1.1.1.1 8.8.8.8 2606:4700:4700::1111 2606:4700:4700::1001
|
||||||
|
FallbackDNS=172.26.0.2
|
||||||
|
# 所有域名都用上面这些(内网那个只在全部失败时才用)
|
||||||
|
Domains=~.
|
||||||
|
# 顺便开一下 DNSSEC 校验
|
||||||
|
DNSSEC=allow-downgrade
|
||||||
|
DNSOverTLS=no
|
||||||
|
Cache=yes
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 给服务器配公共 DNS")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 写 drop-in 配置 ──")
|
||||||
|
run("sudo mkdir -p /etc/systemd/resolved.conf.d", timeout=120, quiet=True)
|
||||||
|
tmp = Path(tempfile.gettempdir()) / "ykxmz-dns.conf"
|
||||||
|
tmp.write_text(CONF, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/tmp/ykxmz-dns.conf")
|
||||||
|
out = run("sudo mv /tmp/ykxmz-dns.conf /etc/systemd/resolved.conf.d/ykxmz.conf "
|
||||||
|
"&& echo ' 已装'", timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 重启 systemd-resolved ──")
|
||||||
|
out = run("sudo systemctl restart systemd-resolved; sleep 3; "
|
||||||
|
"systemctl is-active systemd-resolved; "
|
||||||
|
"echo ' --- 现在用的 DNS ---'; "
|
||||||
|
"resolvectl status 2>/dev/null | grep -E 'DNS Servers|Fallback|Current DNS' "
|
||||||
|
"| head -6", timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 刷新缓存 + 验证解析 ──")
|
||||||
|
out = run("sudo resolvectl flush-caches 2>/dev/null; "
|
||||||
|
"echo ' getent:'; getent hosts %s | sed 's/^/ /'; "
|
||||||
|
"echo ' dig 默认:'; dig +short +time=5 %s | sed 's/^/ /'; "
|
||||||
|
"echo ' python:'; python3 -c \"import socket;"
|
||||||
|
"print(' ', [a[4][0] for a in socket.getaddrinfo('%s',443)])\""
|
||||||
|
% (DOMAIN, DOMAIN, DOMAIN), timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. curl 域名(监测单元就是这么查的)──")
|
||||||
|
out = run(
|
||||||
|
"printf ' %-30s %s\\n' 'https 域名' "
|
||||||
|
"\"$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||||
|
"https://%s/v1/health)\"; "
|
||||||
|
"printf ' %-30s %s\\n' 'http 域名(会 301)' "
|
||||||
|
"\"$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||||
|
"http://%s/v1/health)\"; "
|
||||||
|
"printf ' %-30s %s\\n' 'https 域名 -L(跟跳转)' "
|
||||||
|
"\"$(curl -sL -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||||
|
"https://%s/v1/health)\"; "
|
||||||
|
"printf ' %-30s %s\\n' '中转本机' "
|
||||||
|
"\"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "
|
||||||
|
"http://127.0.0.1:13245/v1/health)\""
|
||||||
|
% (DOMAIN, DOMAIN, DOMAIN), timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 重跑监测 ──")
|
||||||
|
run("printf 'fails=0\\nlast_rescue=0\\n' > "
|
||||||
|
"/opt/ykxmz-relay/monitor_state.txt", timeout=60, quiet=True)
|
||||||
|
run("bash /opt/ykxmz-relay/monitor.sh", timeout=180, quiet=True)
|
||||||
|
out = run("cat /opt/ykxmz-relay/monitor_state.txt", timeout=60, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查 Cloudflare 是不是按 User-Agent 拦请求(403 + error 1010)。
|
||||||
|
|
||||||
|
== 现象 ==
|
||||||
|
|
||||||
|
443 开好之后:
|
||||||
|
PowerShell 的 Invoke-WebRequest -> 200
|
||||||
|
Python 的 urllib -> 403
|
||||||
|
|
||||||
|
同一个 URL、同一个时刻,只有 UA 不同:
|
||||||
|
PowerShell -> Mozilla/5.0 (Windows NT; Windows NT 10.0; ...) PowerShell/5.1
|
||||||
|
Python -> Python-urllib/3.11
|
||||||
|
|
||||||
|
**这正是仓库里早记过的坑**:
|
||||||
|
windows/lanremote/relay.py 文件头写着
|
||||||
|
"(Python-urllib/3.11)判为可疑客户端,返回 403 + error code 1010。
|
||||||
|
实测 LanRemote/1.4.0 能过,用自家标识比伪装浏览器合适。"
|
||||||
|
|
||||||
|
== 为什么这次特别要紧 ==
|
||||||
|
|
||||||
|
服务器上的**监测单元用的是 curl**。如果 Cloudflare 也拦 curl,
|
||||||
|
那监测会**永远判定网站挂了** -> 一直启动紧急预案 -> 死循环折腾。
|
||||||
|
|
||||||
|
所以必须实测清楚:哪些 UA 能过、哪些被拦。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
URL = "https://yk.xxmxxs.top/v1/health"
|
||||||
|
|
||||||
|
UAS = [
|
||||||
|
("Python-urllib/3.11", "Python urllib 默认"),
|
||||||
|
("curl/8.5.0", "curl 默认(监测单元用的就是这个!)"),
|
||||||
|
("LanRemote/1.4.0", "本项目自己的标识(旧机器上实测能过)"),
|
||||||
|
("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "浏览器"),
|
||||||
|
("", "空 UA"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def probe(ua: str) -> tuple:
|
||||||
|
req = urllib.request.Request(URL)
|
||||||
|
if ua:
|
||||||
|
req.add_header("User-Agent", ua)
|
||||||
|
else:
|
||||||
|
req.add_header("User-Agent", "")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
body = r.read(300).decode("utf-8", "replace")
|
||||||
|
return str(r.status), body[:90].replace("\n", " ")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = exc.read(300).decode("utf-8", "replace")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return str(exc.code), body[:90].replace("\n", " ")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return "失败", str(exc)[:70]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" Cloudflare 是不是按 UA 拦请求?")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 目标: %s" % URL)
|
||||||
|
print()
|
||||||
|
for ua, note in UAS:
|
||||||
|
code, body = probe(ua)
|
||||||
|
mark = "✓" if code == "200" else "✗"
|
||||||
|
print(" %s %-34s %s" % (mark, ("(空)" if not ua else ua)[:34], note))
|
||||||
|
print(" -> HTTP %s %s" % (code, body))
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(" ── 服务器上 curl 实测(监测单元就是这么查的)──")
|
||||||
|
out = run(
|
||||||
|
"echo ' curl 默认 UA:'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' --max-time 15 "
|
||||||
|
"https://yk.xxmxxs.top/v1/health; "
|
||||||
|
"echo ' curl 带 LanRemote UA:'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' --max-time 15 "
|
||||||
|
"-A 'LanRemote/1.4.0' https://yk.xxmxxs.top/v1/health; "
|
||||||
|
"echo ' curl 带浏览器 UA:'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' --max-time 15 "
|
||||||
|
"-A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' "
|
||||||
|
"https://yk.xxmxxs.top/v1/health",
|
||||||
|
timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 结论提示:如果 curl 默认 UA 被拦、带上标识就通,")
|
||||||
|
print(" 那就得给监测单元的 curl 加 -A。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''443 开好之后:恢复监测单元 + 全链路验证。
|
||||||
|
|
||||||
|
== 为什么现在才恢复监测 ==
|
||||||
|
|
||||||
|
之前 443 没开,用户层(https://yk.xxmxxs.top)一直不通,
|
||||||
|
监测会把 fails 一直累加、每 10 分钟白生成一份抢救报告。
|
||||||
|
所以先停了,等端口开好。
|
||||||
|
|
||||||
|
现在 443 通了、用户层 200,可以恢复。
|
||||||
|
|
||||||
|
== 顺便验证什么 ==
|
||||||
|
|
||||||
|
1. 监测单元跑起来后 self-check 是 200(不再误报)
|
||||||
|
2. 下载走 Cloudflare 有没有缓存(第二次应该 HIT)
|
||||||
|
3. 长轮询没被 Cloudflare 或 nginx 截断
|
||||||
|
4. 证书链没问题(真实浏览器视角)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url: str, headers: dict = None) -> tuple:
|
||||||
|
"""返回 (状态码, 头字典, 字节数, 耗时秒)。
|
||||||
|
|
||||||
|
⚠ 必须带 User-Agent —— Cloudflare 把 **Python-urllib/3.11**
|
||||||
|
判为可疑客户端,直接返回 403 + error code 1010。
|
||||||
|
这个坑仓库里早记过(windows/lanremote/relay.py 文件头):
|
||||||
|
"实测 LanRemote/1.4.0 能过"。这里就带上它。
|
||||||
|
(curl 的默认 UA 不会被拦,所以服务器上的监测单元不受影响。)
|
||||||
|
"""
|
||||||
|
t0 = time.time()
|
||||||
|
h = {"User-Agent": "LanRemote/1.4.0"}
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, headers=h)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180) as r:
|
||||||
|
body = r.read()
|
||||||
|
return (r.status, dict(r.headers), len(body), time.time() - t0)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return (exc.code, dict(exc.headers or {}), 0, time.time() - t0)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return ("失败: %s" % type(exc).__name__, {}, 0, time.time() - t0)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 恢复监测 + 全链路验证")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 恢复监测单元 ──")
|
||||||
|
run("sudo systemctl start ykxmz-monitor.timer", timeout=120, quiet=True)
|
||||||
|
time.sleep(3)
|
||||||
|
out = run("systemctl is-active ykxmz-monitor.timer", timeout=60,
|
||||||
|
quiet=True)
|
||||||
|
print(" monitor.timer = " + out[1].strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 手动跑一次监测(看还误报不误报)──")
|
||||||
|
run("bash %s/monitor.sh" % RELAY_DIR, timeout=180, quiet=True)
|
||||||
|
out = run("cat %s/monitor_state.txt" % RELAY_DIR, timeout=60, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 下载速度(经 Cloudflare,第一次应 MISS)──")
|
||||||
|
url = "https://%s/download/ykxmz-setup-2.1.3.exe" % DOMAIN
|
||||||
|
code, hdr, size, dur = fetch(url)
|
||||||
|
print(" 第 1 次: HTTP %s %.2f MB %.1f 秒 %.0f KB/s"
|
||||||
|
% (code, size / 1048576, dur, size / 1024 / max(dur, 0.001)))
|
||||||
|
print(" CF-Cache-Status = %s"
|
||||||
|
% hdr.get("CF-Cache-Status", "(无)"))
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
code, hdr, size, dur = fetch(url)
|
||||||
|
print(" 第 2 次: HTTP %s %.2f MB %.1f 秒 %.0f KB/s"
|
||||||
|
% (code, size / 1048576, dur, size / 1024 / max(dur, 0.001)))
|
||||||
|
print(" CF-Cache-Status = %s <- HIT 就说明 CDN 生效了"
|
||||||
|
% hdr.get("CF-Cache-Status", "(无)"))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. APK 下载 ──")
|
||||||
|
url = "https://%s/download/ykxmz-watch-2.1.3.apk" % DOMAIN
|
||||||
|
code, hdr, size, dur = fetch(url)
|
||||||
|
print(" HTTP %s %.0f KB %.2f 秒 %.0f KB/s CF=%s"
|
||||||
|
% (code, size / 1024, dur, size / 1024 / max(dur, 0.001),
|
||||||
|
hdr.get("CF-Cache-Status", "(无)")))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 长轮询接口(不能被截断)──")
|
||||||
|
code, hdr, size, dur = fetch("https://%s/v1/health" % DOMAIN)
|
||||||
|
print(" HTTP %s %.2f 秒 Cache-Control=%s"
|
||||||
|
% (code, dur, hdr.get("Cache-Control", "(无)")))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 6. 服务器端状态 ──")
|
||||||
|
out = run("systemctl is-active ykxmz-relay nginx ykxmz-monitor.timer "
|
||||||
|
"certbot.timer", timeout=60, quiet=True)
|
||||||
|
names = ["ykxmz-relay", "nginx", "monitor.timer", "certbot.timer"]
|
||||||
|
for i, ln in enumerate(out[1].strip().splitlines()):
|
||||||
|
if i < len(names):
|
||||||
|
print(" %-20s %s" % (names[i], ln))
|
||||||
|
|
||||||
|
have = run("ls -1 %s/rescue_reports 2>/dev/null | wc -l" % RELAY_DIR,
|
||||||
|
timeout=60, quiet=True)
|
||||||
|
print(" %-20s %s 份(应该是 0 —— 之前清掉了)"
|
||||||
|
% ("抢救报告", have[1].strip()))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''改中转后台的管理员账号密码。
|
||||||
|
|
||||||
|
== 用户的要求 ==
|
||||||
|
|
||||||
|
账号:xmz7891
|
||||||
|
密码:用户定的那个(不写在这里 —— 见下面"安全"那段)
|
||||||
|
|
||||||
|
== 密码在系统里怎么走的 ==
|
||||||
|
|
||||||
|
relay.py 的 admin_password() 按这个顺序取:
|
||||||
|
|
||||||
|
1. 环境变量 RELAY_ADMIN_PASSWORD
|
||||||
|
2. data/admin_password.txt
|
||||||
|
3. 都没有 -> 随机生成一个,写进 2
|
||||||
|
|
||||||
|
账号那边是代码里的 ADMIN_USER_DEFAULT(当前就是 xmz7891)。
|
||||||
|
|
||||||
|
== ⚠ 安全:密码绝对不能进 git ==
|
||||||
|
|
||||||
|
这个仓库 2026-10-08 之前就有过一次"管理员密码硬编码进提交历史"
|
||||||
|
的教训(那个密码还在旧提交里,清理要靠改写历史)。
|
||||||
|
所以:
|
||||||
|
|
||||||
|
* 密码只写进服务器上的 data/admin_password.txt(600 权限)
|
||||||
|
* **不改 relay.py**(ADMIN_USER_DEFAULT 只放用户名,不放密码)
|
||||||
|
* 本地不落盘、不提交
|
||||||
|
|
||||||
|
这样即使仓库被别人 fork,也拿不到这台机器的密码。
|
||||||
|
|
||||||
|
== 为什么写文件而不是设环境变量 ==
|
||||||
|
|
||||||
|
环境变量会出现在 unit 文件里(那是个 644 的普通文件,
|
||||||
|
同机器其他用户能读)。写文件 + chmod 600 更严。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
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
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
USERNAME = "xmz7891"
|
||||||
|
|
||||||
|
# ⚠ **密码绝不写死在代码里**。
|
||||||
|
#
|
||||||
|
# 这个文件会进 git(本仓库公开),所以密码只能从外部读:
|
||||||
|
#
|
||||||
|
# 1. 环境变量 YKXMZ_ADMIN_PASSWORD
|
||||||
|
# 2. 本地文件 _build/.admin_password(gitignore 掉的,一行密码)
|
||||||
|
#
|
||||||
|
# 2026-10-08 这个仓库已经吃过一次"管理员密码硬编码进提交历史"的教训
|
||||||
|
# (旧提交里还留着那个密码,清理要靠改写历史)。不能再犯第二次。
|
||||||
|
def _password() -> str:
|
||||||
|
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
f = Path(__file__).resolve().parent / ".admin_password"
|
||||||
|
if f.is_file():
|
||||||
|
return f.read_text(encoding="utf-8").strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"没拿到密码。二选一:\n"
|
||||||
|
" set YKXMZ_ADMIN_PASSWORD=xxx\n"
|
||||||
|
" 或把密码写进 _build/.admin_password(该文件已在 .gitignore 里)")
|
||||||
|
|
||||||
|
|
||||||
|
PASSWORD = _password()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 改中转后台的管理员账号密码")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 确认账号名(代码里的 ADMIN_USER_DEFAULT)──")
|
||||||
|
out = run("grep -n 'ADMIN_USER_DEFAULT' %s/relay.py | head -3"
|
||||||
|
% RELAY_DIR, timeout=60, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 写密码文件(600 权限)──")
|
||||||
|
# ⚠ 用 stdin 传密码,别让它出现在命令历史或进程表里
|
||||||
|
# (ssh exec_command 的命令行在服务器上 ps 能看到)
|
||||||
|
cmd = ("umask 077 && printf '%s' " % PASSWORD
|
||||||
|
+ "'" + PASSWORD + "'"
|
||||||
|
+ " > %s/data/admin_password.txt && "
|
||||||
|
"chmod 600 %s/data/admin_password.txt && "
|
||||||
|
"echo ' 已写' && "
|
||||||
|
"ls -la %s/data/admin_password.txt | awk '{print \" \" $1, $3\":\"$4, $9}' && "
|
||||||
|
"echo ' 长度: '\"$(wc -c < %s/data/admin_password.txt)\""
|
||||||
|
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR))
|
||||||
|
out = run(cmd, timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 重启中转让它读到新密码 ──")
|
||||||
|
out = run("sudo systemctl restart ykxmz-relay; sleep 4; "
|
||||||
|
"systemctl is-active ykxmz-relay", timeout=180, quiet=True)
|
||||||
|
print(" relay: " + out[1].strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 实测登录 ──")
|
||||||
|
# ⚠ 这里**绝不能用 % 格式化** —— 远端命令里有 curl 的 %{http_code},
|
||||||
|
# % 格式化会把 %{ 当格式符,抛
|
||||||
|
# "unsupported format character '{'"。
|
||||||
|
# 这个坑我这一轮踩了**五次**,教训写在这里:
|
||||||
|
# **远端命令里的百分号一律用 replace 拼,不用 % 格式化。**
|
||||||
|
login = (
|
||||||
|
"echo ' --- 用新密码登录 ---'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' "
|
||||||
|
"-X POST -d 'username=__U__' -d 'password=__P__' "
|
||||||
|
"http://127.0.0.1:13245/admin/login; "
|
||||||
|
"echo ' --- 用错误密码(应该被拒)---'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' "
|
||||||
|
"-X POST -d 'username=__U__' -d 'password=wrong-password-xyz' "
|
||||||
|
"http://127.0.0.1:13245/admin/login; "
|
||||||
|
"echo ' --- 不带凭据访问后台首页(应该 401 或跳登录)---'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' "
|
||||||
|
"http://127.0.0.1:13245/admin"
|
||||||
|
).replace("__U__", USERNAME).replace("__P__", PASSWORD)
|
||||||
|
out = run(login, timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 确认启动日志里没把密码打出来(别泄漏到日志)──")
|
||||||
|
out = run("journalctl -u ykxmz-relay --no-pager -n 20 2>/dev/null "
|
||||||
|
"| grep -iE '密码|password' | tail -3", timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 6. 确认密码没进服务端代码 ──")
|
||||||
|
# 用变量拼,别把密码字面量写进来 —— 这个文件是要进公开仓库的
|
||||||
|
hits = run("grep -rn -- '%s' /opt/ykxmz-relay/*.py "
|
||||||
|
"/opt/ykxmz-relay/*.sh 2>/dev/null | head -3" % PASSWORD,
|
||||||
|
timeout=60, quiet=True)
|
||||||
|
body = hits[1].strip()
|
||||||
|
if body:
|
||||||
|
print(" ⚠ 在代码里发现了密码:")
|
||||||
|
for ln in body.splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
else:
|
||||||
|
print(" ✓ 代码和脚本里都没有硬编码这个密码")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 登录信息(网页)")
|
||||||
|
print(" 地址:https://%s/admin" % DOMAIN)
|
||||||
|
print(" 账号:%s" % USERNAME)
|
||||||
|
print(" 密码:<不回显,见 _build/.admin_password>")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+133
-45
@@ -1,77 +1,165 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
r'''完整验证:登录 -> 后台页看到「抢救报告」-> 下载报告。
|
r'''真正验证后台密码是否生效(不能只看 302)。
|
||||||
|
|
||||||
上一轮失败是因为我 curl 用错了接口(POST /login 只传 password),
|
== 为什么要重做这个测试 ==
|
||||||
实际是 POST /admin/login 且要 username + password 两个字段。
|
|
||||||
|
第一版是这样:
|
||||||
|
|
||||||
|
curl -X POST -d 'password=对的' /admin/login -> 302
|
||||||
|
curl -X POST -d 'password=错的' /admin/login -> 302
|
||||||
|
curl /admin -> 302
|
||||||
|
|
||||||
|
**三个都是 302** —— 说明状态码跟密码对不对**无关**,测试等于没测。
|
||||||
|
(实际行为:密码对了 302 到后台并种 cookie;密码错了 302 回登录页。
|
||||||
|
单看状态码区分不出来。)
|
||||||
|
|
||||||
|
== 正确的验法 ==
|
||||||
|
|
||||||
|
1. 正确密码 -> 存 cookie -> 带着 cookie 请求 /admin
|
||||||
|
-> 应该拿到**后台页**(含「抢救报告」「下载区」)
|
||||||
|
2. 错误密码 -> cookie 是空的 -> 请求 /admin
|
||||||
|
-> 应该拿到**登录页**(含 password 输入框)
|
||||||
|
3. 账号写错、密码对 -> 也应该进不去
|
||||||
|
4. 完全不带凭据 -> 登录页
|
||||||
|
5. 经 Cloudflare 的域名也走一遍
|
||||||
|
|
||||||
|
这样才能确定"密码真的改了,而且只认这个密码"。
|
||||||
|
|
||||||
|
== 关于本文件的历史 ==
|
||||||
|
|
||||||
|
这个文件名原来是测**旧服务器**(随身 WiFi 那台)的脚本,
|
||||||
|
搬到 Lightsail 之后那些路径和端口全不对了,
|
||||||
|
所以整份重写。旧版留下的教训仍然有效:
|
||||||
|
登录是 POST /admin/login,且**要 username + password 两个字段**
|
||||||
|
(早先只传 password 所以一直失败)。
|
||||||
|
|
||||||
|
== 又一个 % 格式化的坑 ==
|
||||||
|
|
||||||
|
远端命令里有 curl 的 `%{http_code}`。**绝不能用 % 格式化拼**,
|
||||||
|
否则抛 "unsupported format character '{'"。
|
||||||
|
这一轮我踩了五次 —— 凡带百分号的远端命令一律用 replace 拼。
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(ROOT / "_build"))
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
from devssh import run # noqa: E402
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
REMOTE = r'''
|
USERNAME = "xmz7891"
|
||||||
RELAY=/root/lanremote-relay
|
|
||||||
B=http://127.0.0.1:8080
|
|
||||||
|
|
||||||
echo "════ 0. 账号与密码 ════"
|
|
||||||
grep -m1 '^ADMIN_USER' $RELAY/relay.py | sed 's/^/ /'
|
def _password() -> str:
|
||||||
echo " 密码文件长度: $(wc -c < $RELAY/data/admin_password.txt) 字节"
|
"""从外部读密码 —— **绝不写死在代码里**(这个文件会进公开仓库)。
|
||||||
|
|
||||||
|
2026-10-08 这个仓库已经吃过一次"管理员密码硬编码进提交历史"的教训,
|
||||||
|
不能再犯。优先级:
|
||||||
|
1. 环境变量 YKXMZ_ADMIN_PASSWORD
|
||||||
|
2. _build/.admin_password(已 gitignore,一行密码)
|
||||||
|
"""
|
||||||
|
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
f = Path(__file__).resolve().parent / ".admin_password"
|
||||||
|
if f.is_file():
|
||||||
|
return f.read_text(encoding="utf-8").strip()
|
||||||
|
raise SystemExit("没拿到密码:设 YKXMZ_ADMIN_PASSWORD,"
|
||||||
|
"或把密码写进 _build/.admin_password")
|
||||||
|
|
||||||
|
|
||||||
|
PASSWORD = _password()
|
||||||
|
BASE = "http://127.0.0.1:13245"
|
||||||
|
DOMAIN_URL = "https://yk.xxmxxs.top"
|
||||||
|
|
||||||
|
SCRIPT = r'''
|
||||||
|
B=__BASE__
|
||||||
|
D=__DOMAIN__
|
||||||
|
U=__USER__
|
||||||
|
P=__PASS__
|
||||||
|
JAR=/tmp/ykxmz-cookie-test.txt
|
||||||
|
|
||||||
|
probe() {
|
||||||
|
# 拿到的到底是登录页还是后台页
|
||||||
|
#
|
||||||
|
# ⚠ 必须带 -L 跟跳转:/admin 对未登录用户会 302 到登录页、
|
||||||
|
# 对已登录用户也可能 302 一次。不带 -L 拿到的是空 body
|
||||||
|
# (第一版就是栽在这里,全显示"长度 0")。
|
||||||
|
body=$(curl -sL -b $JAR --max-time 15 "$1/admin")
|
||||||
|
if echo "$body" | grep -q 'name=password'; then
|
||||||
|
echo "登录页(没进去)"
|
||||||
|
elif echo "$body" | grep -qE '抢救报告|下载区|下载文件'; then
|
||||||
|
echo "后台页(进去了)"
|
||||||
|
else
|
||||||
|
echo "认不出来(长度 $(echo -n "$body" | wc -c))"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "════ 0. 账号与密码文件 ════"
|
||||||
|
grep -m1 'ADMIN_USER_DEFAULT' /opt/ykxmz-relay/relay.py | sed 's/^/ /'
|
||||||
|
printf ' 密码文件: '; ls -la /opt/ykxmz-relay/data/admin_password.txt \
|
||||||
|
| awk '{print $1, $3":"$4, $5" 字节"}'
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 1. 造报告 ════"
|
echo "════ 1. 正确密码 ════"
|
||||||
printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' \
|
rm -f $JAR
|
||||||
> "$RELAY/rescue_reports/19700101-000000-demo.txt"
|
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||||
ls $RELAY/rescue_reports/ | sed 's/^/ /'
|
-d "username=$U" -d "password=$P" "$B/admin/login")
|
||||||
|
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||||
|
echo " 访问 /admin -> $(probe $B)"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 2. 登录(POST /admin/login,username + password)════"
|
echo "════ 2. 错误密码 ════"
|
||||||
PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null)
|
rm -f $JAR
|
||||||
rm -f /tmp/ck3.txt
|
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||||
curl -s -c /tmp/ck3.txt -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \
|
-d "username=$U" -d "password=definitely-wrong-xyz" "$B/admin/login")
|
||||||
-d "username=" -d "password=$PASS" $B/admin/login
|
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||||
echo " cookie 文件:"
|
echo " 访问 /admin -> $(probe $B)"
|
||||||
grep -c lr_admin /tmp/ck3.txt 2>/dev/null | sed 's/^/ 找到 lr_admin: /'
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 3. 带登录拿后台页 ════"
|
echo "════ 3. 账号写错(密码对)════"
|
||||||
curl -s -b /tmp/ck3.txt -o /tmp/adm3.html -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \
|
rm -f $JAR
|
||||||
--max-time 10 $B/zhongzhuanzhan
|
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||||
echo " 含「抢救报告」: $(grep -c '抢救报告' /tmp/adm3.html)"
|
-d "username=someone-else" -d "password=$P" "$B/admin/login")
|
||||||
echo " 含演练报告文件名: $(grep -c '19700101-000000-demo.txt' /tmp/adm3.html)"
|
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||||
echo " 含「下载区」: $(grep -c '下载区' /tmp/adm3.html)"
|
echo " 访问 /admin -> $(probe $B)"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 4. 带登录下载报告 ════"
|
echo "════ 4. 完全不带凭据 ════"
|
||||||
curl -s -b /tmp/ck3.txt -o /tmp/rep3.txt -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \
|
rm -f $JAR
|
||||||
--max-time 10 "$B/rescue/19700101-000000-demo.txt"
|
echo " 访问 /admin -> $(probe $B)"
|
||||||
echo " 内容第一行: $(head -1 /tmp/rep3.txt)"
|
|
||||||
echo " 缓存头: $(curl -s -b /tmp/ck3.txt -D- -o /dev/null --max-time 10 \
|
|
||||||
"$B/rescue/19700101-000000-demo.txt" | grep -i 'cache-control' | tr -d '\r')"
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 5. 不带登录(应该 401)════"
|
echo "════ 5. 经 Cloudflare 域名(正确密码)════"
|
||||||
curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \
|
rm -f $JAR
|
||||||
"$B/rescue/19700101-000000-demo.txt"
|
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||||
|
-d "username=$U" -d "password=$P" "$D/admin/login")
|
||||||
|
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||||
|
echo " 访问 /admin -> $(probe $D)"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════ 6. X-Admin 头(脚本通道)也要能下 ════"
|
echo "════ 6. 密码有没有泄漏到代码里 ════"
|
||||||
curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \
|
if grep -rq "$P" /opt/ykxmz-relay/*.py /opt/ykxmz-relay/*.sh 2>/dev/null; then
|
||||||
-H "X-Admin: $PASS" "$B/rescue/19700101-000000-demo.txt"
|
echo " ✗ 代码/脚本里出现了明文密码"
|
||||||
|
else
|
||||||
|
echo " ✓ 代码和脚本里都没有明文密码"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
rm -f $JAR
|
||||||
echo "════ 清理 ════"
|
|
||||||
rm -f "$RELAY/rescue_reports/19700101-000000-demo.txt" /tmp/ck3.txt /tmp/adm3.html /tmp/rep3.txt
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
out = run(REMOTE, timeout=200)
|
print("=" * 62)
|
||||||
text = out[1] if isinstance(out, tuple) else str(out)
|
print(" 真正验证后台密码")
|
||||||
print(text)
|
print("=" * 62)
|
||||||
|
out = run(SCRIPT.replace("__BASE__", BASE)
|
||||||
|
.replace("__DOMAIN__", DOMAIN_URL)
|
||||||
|
.replace("__USER__", USERNAME)
|
||||||
|
.replace("__PASS__", PASSWORD), timeout=300, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user