diff --git a/_build/_cmsg_lightsail.txt b/_build/_cmsg_lightsail.txt new file mode 100644 index 0000000..b400bba --- /dev/null +++ b/_build/_cmsg_lightsail.txt @@ -0,0 +1,62 @@ +搬到 Lightsail 真服务器 + 加 nginx 给 Cloudflare 回源 + +== 新架构 == + + 用户 --HTTPS--> Cloudflare CDN --HTTP:80--> nginx + (yk.xxmxxs.top) 13.54.30.196 + | + v + 127.0.0.1:13245 (中转) + +旧架构是"Cloudflare 隧道"(cloudflared 走 7844 到那台 4G 小机器), +那条线路对 7844 是间歇性阻断的(443 通、7844 不通), +隧道每 3~10 分钟掉一次、公网跟着 530 —— 是"网站容易挂"的主因。 + +新架构**不用隧道**:新机器有公网 IP,Cloudflare 走标准的 +反代(回源 80 端口),这是完全不同的东西,没有 7844 问题。 + +== 为什么要 nginx 这一层 == + +Cloudflare 只能回源到特定端口: + HTTP: 80 / 8080 / 8880 / 2052 / 2082 / 2086 / 2095 + HTTPS: 443 / 2053 / 2083 / 2087 / 2096 / 8443 +中转跑的 13245 不在列表里,所以中间放一层 nginx。 + +加了 nginx 之后顺便把中转**改成只监听 127.0.0.1** —— +公网只能通过 nginx 进来,少一个暴露面。实测 +http://13.54.30.196:13245 现在是连不上的(000),符合预期。 + +== nginx 配置要点 == + + /download/ proxy_buffering on + 显式 Cache-Control 604800 immutable + (文件名带版本号、内容永不改变,放心长缓存; + Cloudflare 缓存后第二次下载不回源) + /v1/ proxy_read_timeout 180s + **proxy_buffering off** + (长轮询挂 60 秒,nginx 默认 60 秒就断;而且必须关缓冲, + 否则要等整个响应完才吐给客户端,那就不是长轮询了) + / 普通反代 + server_tokens off + client_max_body_size 200m + +== 代码修复 == + +relay.py 的 DATA_DIR 默认值原来是写死的 /root/lanremote-relay。 +新机器上服务以 admin 普通用户跑(它没理由要 root),而 /root 是 0700, +部署时直接 PermissionError。改成"脚本所在目录下的 data/"。 + +== 客户端 == + +2.1.3 里已经让手表端支持"没有证书的中转"(裸 IP 走明文 + +usesCleartextTraffic)。等域名和 Cloudflare 接好之后就是全程 HTTPS 了。 + +== 新增的运维脚本 == + + _build/lssh.py Lightsail 的 SSH 助手(密钥登录) + _build/deploy_lightsail.py 首次部署中转 + _build/deploy_lightsail_monitor.py 装监测单元 + 预案 + _build/deploy_lightsail_release.py 按版本部署代码和成品 + _build/check_lightsail.py 看状态 + _build/setup_nginx.py 装配 nginx + relay/rescue-lightsail.sh 紧急预案(针对新架构重写: + 没有隧道了,改成重启中转 / 强杀重拉 / + 查防火墙 / 重启整机[默认关]) diff --git a/_build/deploy_lightsail_release.py b/_build/deploy_lightsail_release.py new file mode 100644 index 0000000..c27747d --- /dev/null +++ b/_build/deploy_lightsail_release.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +r'''把一个版本的代码和成品部署到 Lightsail。 + +用法: + python _build/deploy_lightsail_release.py 2.1.3 + python _build/deploy_lightsail_release.py 2.1.3 --code-only + python _build/deploy_lightsail_release.py 2.1.3 --files-only + +== 为什么写成文件 == + +这一轮我用 `python -c @"..."@` 传带 `$(...)` 的远端命令, +PowerShell 把 `$(systemctl ...)` 当成本机的子表达式执行了 +(报 "无法将 systemctl 项识别为 cmdlet"),还吃掉引号导致 +Python 语法错。凡是超过两行的远端命令,一律写成文件跑。 +''' +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, upload # noqa: E402 + +RELAY_DIR = "/opt/ykxmz-relay" +PORT = "13245" +PUBLIC = "http://13.54.30.196:%s" % PORT + +CODE = ["relay.py", "admin.py", "landing.py", "showcase.py"] +ARTIFACTS = ["ykxmz-setup-{v}.exe", "ykxmz-watch-{v}.apk", + "ykxmz-watch-{v}-说明.txt", "ykxmz-server-{v}.zip"] + + +def main() -> int: + args = [a for a in sys.argv[1:] if a] + if not args: + print(__doc__) + return 1 + version = args[0] + code_only = "--code-only" in args + files_only = "--files-only" in args + + print("=" * 62) + print(" 部署 %s 到 Lightsail" % version) + print("=" * 62) + + if not code_only: + print() + print(" ── 1. 传成品到 downloads/ ──") + run("mkdir -p %s/downloads" % RELAY_DIR, timeout=60, quiet=True) + for tpl in ARTIFACTS: + name = tpl.format(v=version) + local = ROOT / "dist" / name + if not local.is_file(): + print(" [跳过] %s 不存在" % name) + continue + upload(str(local), "%s/downloads/%s" % (RELAY_DIR, name)) + + if not files_only: + print() + print(" ── 2. 传服务端代码 ──") + for name in CODE: + local = ROOT / "relay" / name + if not local.is_file(): + print(" [跳过] %s 不存在" % name) + continue + upload(str(local), "%s/%s" % (RELAY_DIR, name)) + + print() + print(" ── 3. 语法检查 + 重启 ──") + 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" + % RELAY_DIR, timeout=120, quiet=True) + print(" " + out[1].strip().splitlines()[-1]) + + 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. 复核 ──") + script = ( + 'printf " %-18s %s\\n" 本机 "' + '$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 ' + 'http://127.0.0.1:' + PORT + '/v1/health)"\n' + 'printf " %-18s %s\\n" 公网 "' + '$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 15 ' + + PUBLIC + '/v1/health)"\n' + 'printf " %-18s %s\\n" 公网首页 "' + '$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 15 ' + + PUBLIC + '/)"\n' + 'printf " %-18s %s\\n" 公网延迟 "' + '$(curl -s -o /dev/null -w \'%{time_total}s\' --max-time 15 ' + + PUBLIC + '/v1/health)"\n' + 'printf " %-18s %s\\n" 角标 "' + '$(curl -s --max-time 10 http://127.0.0.1:' + PORT + + '/ | grep -o "最新版 [0-9.]*" | head -1)"\n' + 'printf " %-18s %s\\n" 监听 "' + '$(ss -tln 2>/dev/null | grep ":' + PORT + ' " | wc -l) 个"\n' + 'printf " %-18s %s\\n" 监测 "' + 'fails=$(grep -m1 ^fails= ' + RELAY_DIR + + '/monitor_state.txt 2>/dev/null | cut -d= -f2); ' + 'echo ${fails:-?} 次连续失败"\n' + 'printf " %-18s %s\\n" 抢救报告 "' + '$(ls ' + RELAY_DIR + '/rescue_reports 2>/dev/null | wc -l) 份"\n' + ) + out = run(script, timeout=180, quiet=True) + for ln in out[1].strip().splitlines(): + print(ln) + + print() + print(" 公网地址:%s" % PUBLIC) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/setup_nginx.py b/_build/setup_nginx.py new file mode 100644 index 0000000..f126f21 --- /dev/null +++ b/_build/setup_nginx.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +r'''在 Lightsail 上装 nginx,把 yk.xxmxxs.top 反代到中转。 + +== 架构 == + + 用户 ──HTTPS──> Cloudflare CDN ──HTTP:80──> nginx ──> 127.0.0.1:13245 + (yk.xxmxxs.top) 13.54.30.196 + +== 为什么要加 nginx 这一层 == + +1. **Cloudflare 只能回源到特定端口** + (HTTP: 80/8080/8880/2052/2082/2086/2095, + HTTPS: 443/2053/2083/2087/2096/8443) + 中转跑的 13245 **不在列表里** —— Cloudflare 没法直接回源。 + +2. **顺便把中转藏起来** + 加了 nginx 之后,可以让 relay 只监听 127.0.0.1, + 公网只能通过 nginx 进来。少一个暴露面。 + +3. **以后上 HTTPS 有地方挂证书** + 现在先用 Cloudflare 的"灵活"模式(CF→源站走 HTTP), + 以后要"完全(严格)"就把源站证书挂到 nginx 上。 + +== 关于端口 80 的安全 == + +nginx 监听 80 是**必须的**(Cloudflare 的开源端口)。 +但要注意:如果有人知道 13.54.30.196,也能直接打 80 绕过 Cloudflare。 +所以配置里加了: + + * 只接受来自 Cloudflare 网段的请求(可选,见 allowlist 那段) + * 或者更简单:不启用 allowlist,但把 relay 绑到 127.0.0.1, + 反正绕不绕过都是同一个网站,没有额外信息泄露 + +这里选后者(简单、不容易把自己锁在外面)。 +''' +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" +RELAY_PORT = "13245" + +NGINX_SITE = '''# ykxmz 中转 —— 给 Cloudflare 回源用的 +# +# Cloudflare 只能回源到特定端口,中转跑的 %(port)s 不在列表里, +# 所以中间放一层 nginx(监听 80)。 +# +# 客户端拿到的大文件(安装包 15MB)会被 Cloudflare 缓存 —— +# 第二次下载就不回源了,速度取决于离用户最近的 CF 边缘节点。 + +server { + listen 80; + listen [::]:80; + server_name %(domain)s; + + # 日志:单独一份,方便排查"到底是 CF 打的还是别人直接打的" + access_log /var/log/nginx/ykxmz-access.log; + error_log /var/log/nginx/ykxmz-error.log; + + # ---- 下载:让 Cloudflare 放心缓存 ---- + # + # 文件名带版本号(ykxmz-setup-2.1.3.exe),同一个名字内容永不改变, + # 所以可以长缓存。中转自己也发过 Cache-Control, + # 这里显式再写一遍是为了让 CF 一定看到。 + location /download/ { + proxy_pass http://127.0.0.1:%(port)s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; + + proxy_buffering on; + proxy_cache_valid 200 7d; + add_header Cache-Control "public, max-age=604800, immutable" always; + } + + # ---- 长轮询 ---- + # + # 中转的 /v1/agent/poll 要挂 60 秒。nginx 默认 60 秒就断, + # 必须放宽,否则手表那边会一直掉线重连。 + location /v1/ { + proxy_pass http://127.0.0.1:%(port)s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; + + proxy_read_timeout 180s; + proxy_send_timeout 180s; + proxy_connect_timeout 30s; + # 长轮询不能缓冲,否则要等整个响应完才吐给客户端 —— 那就不是长轮询了 + proxy_buffering off; + proxy_cache off; + } + + # ---- 其余(官网 / 管理页 / 抢救报告 / 截图)---- + location / { + proxy_pass http://127.0.0.1:%(port)s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; + + proxy_read_timeout 180s; + proxy_connect_timeout 30s; + } + + # 别把 nginx 版本号报出去 + server_tokens off; + client_max_body_size 200m; +} +''' + + +def main() -> int: + print("=" * 62) + print(" 装 nginx,把 %s 反代到中转" % DOMAIN) + print("=" * 62) + + print() + print(" ── 1. 装 nginx ──") + out = run("which nginx >/dev/null 2>&1 && echo ALREADY || " + "(sudo apt-get update -qq && " + "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx) " + "&& echo OK; nginx -v 2>&1", timeout=600, quiet=True) + for ln in out[1].strip().splitlines()[-4:]: + print(" " + ln) + + print() + print(" ── 2. 写站点配置 ──") + conf = NGINX_SITE % {"domain": DOMAIN, "port": RELAY_PORT} + tmp = Path(tempfile.gettempdir()) / "ykxmz.conf" + tmp.write_text(conf, encoding="utf-8", newline="\n") + upload(str(tmp), "/tmp/ykxmz.conf") + out = run("sudo mv /tmp/ykxmz.conf /etc/nginx/sites-available/ykxmz && " + "sudo ln -sf /etc/nginx/sites-available/ykxmz " + "/etc/nginx/sites-enabled/ykxmz && " + # 关掉默认站点,免得抢 80 + "sudo rm -f /etc/nginx/sites-enabled/default && " + "sudo nginx -t 2>&1 | tail -3", timeout=180, quiet=True) + for ln in out[1].strip().splitlines(): + print(" " + ln) + + print() + print(" ── 3. 重启 nginx ──") + out = run("sudo systemctl enable nginx >/dev/null 2>&1; " + "sudo systemctl restart nginx; sleep 2; " + "systemctl is-active nginx", timeout=180, quiet=True) + print(" nginx: " + out[1].strip()) + + print() + print(" ── 4. 中转改为只监听 127.0.0.1(不再直接对公网)──") + # 公网只能通过 nginx 进来,少一个暴露面。 + out = run("sudo sed -i 's/^Environment=RELAY_BIND=.*$/" + "Environment=RELAY_BIND=127.0.0.1/' " + "/etc/systemd/system/ykxmz-relay.service && " + "grep RELAY_BIND /etc/systemd/system/ykxmz-relay.service && " + "sudo systemctl daemon-reload && " + "sudo systemctl restart ykxmz-relay && sleep 3 && " + "systemctl is-active ykxmz-relay", timeout=180, quiet=True) + for ln in out[1].strip().splitlines(): + print(" " + ln) + + print() + print(" ── 5. 验证 ──") + script = ( + 'printf " %-22s %s\\n" "nginx 直连(本机:80)" ' + '"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 ' + 'http://127.0.0.1:80/v1/health)"\n' + 'printf " %-22s %s\\n" "带 Host 头" ' + '"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 ' + '-H \'Host: ' + DOMAIN + '\' http://127.0.0.1:80/v1/health)"\n' + 'printf " %-22s %s\\n" "公网 80(IP)" ' + '"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 15 ' + 'http://13.54.30.196:80/v1/health)"\n' + 'printf " %-22s %s\\n" "中转本机(13245)" ' + '"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 ' + 'http://127.0.0.1:13245/v1/health)"\n' + 'printf " %-22s %s\\n" "中转是否还暴露公网" ' + '"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 ' + 'http://13.54.30.196:13245/v1/health)"\n' + ) + out = run(script, timeout=180, quiet=True) + for ln in out[1].strip().splitlines(): + print(ln) + + print() + print(" 说明:上面「中转是否还暴露公网」应该是 000(连不上)——") + print(" 那正说明它已经只认 127.0.0.1,公网只能走 nginx。") + return 0 + + +if __name__ == "__main__": + sys.exit(main())