diff --git a/.claude/skills/deploy/SKILL.md b/.claude/skills/deploy/SKILL.md new file mode 100644 index 0000000..a2a858d --- /dev/null +++ b/.claude/skills/deploy/SKILL.md @@ -0,0 +1,57 @@ +--- +name: deploy +description: 把 proxy-station 部署到 LA 生产服务器(ps.la.njcqtechaicoding.com)。可以只部署前端、只部署后端,或前后端一起。凡是用户提到「部署」「发布」「上线」「推到服务器」「更新线上」「让线上生效」「同步到 la」,或改完代码想在生产环境看效果,都用这个 Skill,即使没有明说「部署」二字。 +--- + +# 部署 proxy-station 到 LA + +线上是一台 1 核 1.9G 的机器,没有 Docker:后端用 Node 22 直跑、systemd 守护(服务名 `proxy-station`,代码在 `/opt/proxy-station`),前端在本机构建后由后端静态托管,HTTPS 与反代由 aaPanel 的 nginx 负责。首次部署与一次性配置(systemd 单元、`/etc/proxy-station/env`、nginx 站点、证书、数据库迁移)见 `docs/DEPLOY.md`,本 Skill 只负责日常的代码更新。 + +## 用法 + +```bash +bash .claude/skills/deploy/scripts/deploy.sh all # 前后端一起(默认) +bash .claude/skills/deploy/scripts/deploy.sh web # 只换前端产物,不重启服务 +bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码,装依赖并重启服务 +``` + +可选开关: + +| 开关 | 作用 | 什么时候用 | +|---|---|---| +| `--skip-checks` | 跳过本地 typecheck 与单测 | 本轮已经跑过且代码没再改 | +| `--skip-build` | 不重新构建前端,直接推现有 `web/dist` | 刚构建过,只是重推 | +| `--host <别名>` | 换目标机器(默认 `la`) | 以后有第二台时 | + +也可用环境变量覆盖 `DEPLOY_REMOTE_DIR`、`DEPLOY_SERVICE`、`DEPLOY_PUBLIC_URL`、`DEPLOY_RUN_USER`。 + +## 怎么选目标 + +- 改动只在 `web/` 下(页面、样式、前端逻辑)→ `web`。前端产物是静态文件,后端按请求读盘,换完立刻生效,不打断已连接的客户端拉订阅。 +- 改动在 `server/` 或 `shared/` → `server`。`shared/` 是前后端共用源码,动了它通常前端也要重建,这时用 `all` 更稳。 +- 拿不准就用 `all`,多花的只是一次前端构建的几秒钟。 + +## 脚本做了什么,以及为什么 + +1. **预检**:拒绝大写 ssh 别名(大写会被当作域名走 fake-IP 解析,连不上);确认远端目录与 systemd 单元存在,否则提示先做首次部署。 +2. **本地检查**:后端目标先跑 typecheck 与单测。线上没有测试环境,这是最后一道拦截。 +3. **推送**:用 tar 管道走 ssh,不依赖远端 rsync。先解到暂存目录再整体换入,旧版本保留为 `*.old`,中途失败不会留下半新半旧的目录。 +4. **后端**:`pnpm install --frozen-lockfile --prod` 只装 server 及其 workspace 依赖,然后重启服务,等 `/api/health` 最多 20 秒。不通过就打印日志、把源码换回 `.old` 并重启,让线上先恢复。 +5. **校验**:不用「命令没报错」当成功。前端比对本地与远端 `web/dist` 的文件树指纹,并检查公网首页是否已引用新的入口脚本;后端比对 `server/src` 与 `shared/src` 的指纹并确认服务 active;最后从本机访问公网健康检查,并确认管理接口不带 token 时返回 401。任何一项不过,脚本以失败退出。 + +脚本运行时会主动剥掉会话注入的 HTTP 代理环境变量,ssh/pnpm/curl 都直连。 + +## 结果怎么读 + +- 最后一行 `部署完成:<目标> @ ` 才算成功。把这一行连同校验段落转述给用户。 +- 输出里出现 `(工作区有未提交改动)` 时要提醒用户:线上跑的是未提交的代码,之后应当提交或有意识地保持。 +- `前端产物指纹不一致` 几乎只有一种原因:推送中断。重跑一次 `web` 即可。 +- `公网首页未引用 <入口脚本>` 而指纹一致:浏览器或中间缓存,稍等再试;aaPanel 的反代模板对 js 只缓存 1 分钟。 +- `pnpm install 失败`:多半是本地 `package.json` 改了但锁文件没更新。本地 `pnpm install` 后连锁文件一起提交,再部署。 +- 健康检查失败并回滚:回滚只恢复源码,不恢复依赖。到本地用 `pnpm --filter @proxy-station/server start` 复现,别在线上反复重启。 + +## 边界 + +- 不迁移数据库、不改 `ADMIN_TOKEN`、不碰 nginx 与证书。这些操作见 `docs/DEPLOY.md`,且每一项都应先跟用户确认。 +- 不做 git 提交。部署与提交是两件事,用户没要求就不要顺手提交。 +- 如果用户要部署到 LA 之外的机器,先确认那台机器已按 `docs/DEPLOY.md` 完成首次部署,再用 `--host` 与环境变量指向它。 diff --git a/.claude/skills/deploy/scripts/deploy.sh b/.claude/skills/deploy/scripts/deploy.sh new file mode 100755 index 0000000..153bf25 --- /dev/null +++ b/.claude/skills/deploy/scripts/deploy.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# proxy-station 部署脚本:把前端产物/后端源码推到 LA 服务器(Node 直跑 + systemd,nginx 由 aaPanel 管) +# +# deploy.sh [web|server|all] [--skip-checks] [--skip-build] [--host ] +# +# web 只构建并替换前端静态产物(服务端按请求读盘,不需要重启) +# server 只推送 shared/ 与 server/ 源码,安装生产依赖并重启 systemd 服务 +# all 两者都做(默认) +# +# 数据库、/etc/proxy-station/env、nginx 站点与证书一律不碰——那些是一次性配置,见 docs/DEPLOY.md。 +set -euo pipefail + +# 会话注入的 HTTP 代理只供模型 API 使用,ssh/pnpm/curl 一律直连 +unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY NO_PROXY no_proxy + +HOST="${DEPLOY_HOST:-la}" +REMOTE_DIR="${DEPLOY_REMOTE_DIR:-/opt/proxy-station}" +SERVICE="${DEPLOY_SERVICE:-proxy-station}" +RUN_USER="${DEPLOY_RUN_USER:-proxy-station}" +PUBLIC_URL="${DEPLOY_PUBLIC_URL:-https://ps.la.njcqtechaicoding.com}" +LOCAL_PORT="${DEPLOY_LOCAL_PORT:-3000}" + +TARGET=all +SKIP_CHECKS=0 +SKIP_BUILD=0 +while [ $# -gt 0 ]; do + case "$1" in + web|server|all) TARGET="$1" ;; + --skip-checks) SKIP_CHECKS=1 ;; + --skip-build) SKIP_BUILD=1 ;; + --host) shift; HOST="$1" ;; + -h|--help) sed -n '2,12p' "$0"; exit 0 ;; + *) echo "未知参数:$1(可用:web|server|all --skip-checks --skip-build --host <别名>)" >&2; exit 2 ;; + esac + shift +done + +ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || { cd "$(dirname "$0")/../../../.." && pwd; })" +cd "$ROOT" + +SSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 "$HOST") +step() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; } +fail() { printf '\033[1;31m!! %s\033[0m\n' "$*" >&2; exit 1; } + +# 本地与远端各算一遍「源码树指纹」:文件按路径排序后逐个取 sha256,再对哈希列表取 sha256。 +# 部署完两边一致就能确定推上去的就是本地这份,而不是靠「命令没报错」来猜。 +hash_cmd_local() { command -v sha256sum >/dev/null && echo sha256sum || echo 'shasum -a 256'; } +tree_hash_local() { + local h; h="$(hash_cmd_local)" + find "$@" -type f ! -name '*.test.ts' | LC_ALL=C sort | xargs $h | awk '{print $1}' | $h | awk '{print $1}' +} +tree_hash_remote() { + "${SSH[@]}" "cd '$REMOTE_DIR' && find $* -type f ! -name '*.test.ts' | LC_ALL=C sort | xargs sha256sum | awk '{print \$1}' | sha256sum | awk '{print \$1}'" +} + +# ---------- 预检 ---------- +step "预检:ssh 别名 ${HOST}、远端目录、服务状态" +case "$HOST" in *[[:upper:]]*) fail "ssh 别名请用小写(大写会被当成域名走 fake-IP 解析而连不上)";; esac +"${SSH[@]}" "test -d '$REMOTE_DIR/server' && test -f /etc/systemd/system/$SERVICE.service" \ + || fail "远端缺少 $REMOTE_DIR/server 或 systemd 单元,请先按 docs/DEPLOY.md 完成首次部署" +GIT_REV="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" +GIT_DIRTY="$(git status --porcelain 2>/dev/null | grep -q . && echo '(工作区有未提交改动)' || true)" +echo "本地版本:$GIT_REV $GIT_DIRTY" +echo "目标:$TARGET → $HOST:$REMOTE_DIR" + +# ---------- 本地检查与构建 ---------- +if [ "$TARGET" != web ] && [ "$SKIP_CHECKS" = 0 ]; then + step "本地类型检查与单测(--skip-checks 可跳过)" + pnpm -r typecheck >/dev/null || fail "typecheck 失败,先修再部署" + pnpm test 2>&1 | tail -4 +fi +if [ "$TARGET" != server ]; then + if [ "$SKIP_BUILD" = 0 ]; then + step "构建前端" + pnpm --filter @proxy-station/web build 2>&1 | tail -3 + else + step "跳过构建,使用现有 web/dist" + fi + test -f web/dist/index.html || fail "web/dist/index.html 不存在" +fi + +# ---------- 推送 ---------- +# macOS 的 bsdtar 会把文件 xattr 写成扩展头,远端 GNU tar 每个文件刷一行「unknown extended header」;两端各关一次 +TAR_EXCLUDES=(--no-xattrs --exclude='node_modules' --exclude='*.test.ts' --exclude='.DS_Store' --exclude='data' --exclude='*.db*' --exclude='.env') +export COPYFILE_DISABLE=1 +UNTAR='tar xzf - --warning=no-unknown-keyword' + +if [ "$TARGET" != server ]; then + step "推送前端产物 → $REMOTE_DIR/web/dist(原子替换,旧版保留为 dist.old)" + tar czf - "${TAR_EXCLUDES[@]}" web/dist | "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; rm -rf .staging-web; mkdir .staging-web; $UNTAR -C .staging-web; + rm -rf web/dist.old; [ -d web/dist ] && mv web/dist web/dist.old; mv .staging-web/web/dist web/dist; rm -rf .staging-web; + chown -R $RUN_USER:$RUN_USER web/dist" +fi + +if [ "$TARGET" != web ]; then + step "推送后端源码与锁文件 → ${REMOTE_DIR}(旧源码保留为 src.old 以便回滚)" + tar czf - "${TAR_EXCLUDES[@]}" package.json pnpm-workspace.yaml pnpm-lock.yaml \ + shared/package.json shared/tsconfig.json shared/src \ + server/package.json server/tsconfig.json server/src \ + web/package.json \ + | "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; rm -rf .staging; mkdir .staging; $UNTAR -C .staging; + for d in server/src shared/src; do rm -rf \"\$d.old\"; [ -d \"\$d\" ] && mv \"\$d\" \"\$d.old\"; mv \".staging/\$d\" \"\$d\"; done; + cp .staging/package.json .staging/pnpm-workspace.yaml .staging/pnpm-lock.yaml .; + cp .staging/shared/package.json .staging/shared/tsconfig.json shared/; + cp .staging/server/package.json .staging/server/tsconfig.json server/; + cp .staging/web/package.json web/; rm -rf .staging" + + step "远端安装生产依赖(frozen lockfile)" + "${SSH[@]}" "cd '$REMOTE_DIR' && CI=true pnpm install --frozen-lockfile --prod --filter '@proxy-station/server...' 2>&1 | tail -3 && chown -R $RUN_USER:$RUN_USER '$REMOTE_DIR'" \ + || fail "pnpm install 失败。锁文件与 package.json 不一致时先在本地 pnpm install 并提交锁文件" + + step "重启 $SERVICE 并等待健康检查" + "${SSH[@]}" "systemctl restart $SERVICE" + if ! "${SSH[@]}" "for i in \$(seq 1 20); do curl -sf -m 2 http://127.0.0.1:$LOCAL_PORT/api/health >/dev/null && exit 0; sleep 1; done; exit 1"; then + echo "健康检查 20 秒未通过,最近日志:" + "${SSH[@]}" "journalctl -u $SERVICE -n 30 --no-pager -o cat" || true + step "回滚到上一版源码" + "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; for d in server/src shared/src; do [ -d \"\$d.old\" ] && rm -rf \"\$d\" && mv \"\$d.old\" \"\$d\"; done; chown -R $RUN_USER:$RUN_USER .; systemctl restart $SERVICE" + fail "已回滚到旧源码并重启(锁文件与依赖未回滚)。请在本地复现后再部署" + fi +fi + +# ---------- 校验 ---------- +step "校验" +"${SSH[@]}" "printf 'rev=%s target=%s at=%s\n' '$GIT_REV' '$TARGET' \"\$(date -Is)\" > '$REMOTE_DIR/.deploy-stamp'" +OK=1 +if [ "$TARGET" != server ]; then + L="$(tree_hash_local web/dist)"; R="$(tree_hash_remote web/dist)" + [ "$L" = "$R" ] && echo "前端产物指纹一致:${L:0:12}" || { echo "前端产物指纹不一致:本地 ${L:0:12} 远端 ${R:0:12}"; OK=0; } + ASSET="$(grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' web/dist/index.html | head -1)" + if curl -sf -m 10 "$PUBLIC_URL/" | grep -q "$ASSET"; then echo "公网首页已引用新入口脚本 $ASSET"; else echo "公网首页未引用 ${ASSET}(缓存或部署失败)"; OK=0; fi +fi +if [ "$TARGET" != web ]; then + L="$(tree_hash_local server/src shared/src)"; R="$(tree_hash_remote server/src shared/src)" + [ "$L" = "$R" ] && echo "后端源码指纹一致:${L:0:12}" || { echo "后端源码指纹不一致:本地 ${L:0:12} 远端 ${R:0:12}"; OK=0; } + "${SSH[@]}" "systemctl is-active --quiet $SERVICE" && echo "systemd 服务 active" || { echo "systemd 服务未运行"; OK=0; } +fi +if curl -sf -m 10 "$PUBLIC_URL/api/health" | grep -q '"ok":true'; then echo "公网健康检查通过:$PUBLIC_URL/api/health"; else echo "公网健康检查失败"; OK=0; fi +CODE="$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$PUBLIC_URL/api/nodes")" +[ "$CODE" = 401 ] && echo "管理接口未带 token 返回 401,鉴权正常" || { echo "管理接口未带 token 返回 ${CODE},预期 401"; OK=0; } + +[ "$OK" = 1 ] && step "部署完成:$TARGET @ $GIT_REV → $PUBLIC_URL" || fail "部署校验有未通过项,见上" diff --git a/CLAUDE.md b/CLAUDE.md index 55bfd21..d3fa6ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,9 @@ npx vitest run parse # 按文件名 npx vitest run -t "round-trip" # 按用例名 npx vitest # watch 模式 +# 部署到 LA 生产机(web|server|all),详见 .claude/skills/deploy/SKILL.md 与 docs/DEPLOY.md +bash .claude/skills/deploy/scripts/deploy.sh all + # 校验生成的 Clash 配置(最有价值的一条端到端验证) curl -s "http://localhost:3000/sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml ``` diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..fbe8d2d --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,59 @@ +# 生产部署(LA 节点,无 Docker) + +服务器资源有限(1 核/1.9G),采用 Node 直跑 + systemd 守护,前端在本机构建后由服务端静态托管,反向代理与证书由 aaPanel 管理。 + +## 拓扑 + +``` +客户端 ──HTTPS──▶ nginx(aaPanel 站点 ps.la.njcqtechaicoding.com,泛域名证书) + └─ proxy_pass http://127.0.0.1:3000 ──▶ proxy-station(systemd,用户 proxy-station) +``` + +| 项目 | 位置 | +|---|---| +| 代码 | `/opt/proxy-station`(含 `web/dist` 构建产物,无 `web/src`) | +| 数据库 | `/opt/proxy-station/data/proxy-station.db` | +| 环境变量 | `/etc/proxy-station/env`(`PORT`/`DATA_DIR`/`ADMIN_TOKEN`,权限 600) | +| 服务 | `systemctl status proxy-station`,日志 `journalctl -u proxy-station -f` | +| nginx 站点 | aaPanel 站点 `ps.la.njcqtechaicoding.com`,反代配置在 `/www/server/panel/vhost/nginx/proxy/<域名>/` | +| 证书 | `/www/server/panel/vhost/cert/<域名>/`,由 acme.sh 续期钩子统一复制 | + +`ADMIN_TOKEN` 在 Web 界面「设置」页填入,存于浏览器 localStorage。 + +## 更新代码 + +日常更新用项目级 Skill `deploy`(对 Claude 说「部署」即可触发),也可直接跑脚本: + +```bash +bash .claude/skills/deploy/scripts/deploy.sh all # 前后端一起(默认) +bash .claude/skills/deploy/scripts/deploy.sh web # 只换前端产物,不重启 +bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码,装依赖并重启 +``` + +脚本自带本地 typecheck/单测、tar 管道推送、暂存目录原子换入、健康检查失败自动回滚源码,以及本地与远端的源码树指纹比对与公网校验。细节见 `.claude/skills/deploy/SKILL.md`。 + +SSH 别名必须用小写 `la`,大写会被当作域名解析。 + +## 迁移数据库 + +用 sqlite3 在线备份导出一致副本,避免直接拷贝带 WAL 的库文件: + +```bash +sqlite3 server/data/proxy-station.db ".backup /tmp/ps.db" +scp /tmp/ps.db la:/tmp/ps.db +ssh la 'systemctl stop proxy-station && install -m 644 -o proxy-station -g proxy-station /tmp/ps.db /opt/proxy-station/data/proxy-station.db && rm -f /opt/proxy-station/data/proxy-station.db-wal /opt/proxy-station/data/proxy-station.db-shm /tmp/ps.db && systemctl start proxy-station' +``` + +## 反向代理注意点 + +- aaPanel 的反代模板不带 `X-Forwarded-Proto`/`X-Forwarded-Host`,已手工补进反代配置。服务端 `routes/sub.ts` 的 `baseUrl()` 依赖这两个头还原外部地址,缺失时 Surge 的 `#!MANAGED-CONFIG` 与 Clash 的 provider URL 会变成 `http://`。 +- 站点通过面板类 `AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps` 创建(脚本模式见 edge-nodes 的 `09-aapanel-sites.py`),不要手写 vhost,否则面板里看不到。 +- 泛域名 `*.la.njcqtechaicoding.com` 的 DNS 已指向服务器,新子域无需单独加记录。 +- acme.sh 的 `--reloadcmd` 要把新站点加进复制循环,且证书源路径必须写死(原先引用未定义的 `$SRC`,续期后不会重新部署证书)。 + +## 验证 + +```bash +curl -s https://ps.la.njcqtechaicoding.com/api/health +curl -s "https://ps.la.njcqtechaicoding.com/sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml +```