Compare commits

..

3 Commits

Author SHA1 Message Date
a3072ccbfb feat: 管理界面改为 token 登录门禁
此前不填 token 也能进入各页面,只是没有内容。新增受鉴权保护的
/api/auth/session 探针与登录页,路由守卫在进入任何非登录页前先探针,
未通过则跳登录页并记住原目标;接口返回 401 时自动清 token 回登录页;
顶栏加退出按钮,撤掉设置页里的 token 卡片。本机未设 ADMIN_TOKEN 时
探针直接通过,开发体验不变。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-03 17:38:38 +08:00
43a753963c feat: 新增 deploy Skill 与 LA 生产部署文档
线上为 1 核 1.9G 机器,放弃 Docker:Node 直跑 + systemd 守护,
前端本地构建后由服务端静态托管,HTTPS 与反代由 aaPanel 的 nginx 管理。
部署脚本支持 web/server/all 三种目标,含本地类型检查与单测、
暂存目录原子换入、健康检查失败自动回滚源码、本地与远端源码树指纹比对
及公网校验。首次部署与一次性配置写在 docs/DEPLOY.md。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-03 17:38:38 +08:00
6ea1f28940 fix: 反代场景下按 X-Forwarded 头还原订阅基址
部署在 nginx 之后时 Node 只看到明文 http 与内网 Host,Surge 的
#!MANAGED-CONFIG 与 Clash 的 provider URL 会指向不可达地址。
读取 X-Forwarded-Proto/X-Forwarded-Host 还原外部 origin。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-03 17:38:38 +08:00
12 changed files with 517 additions and 22 deletions

View File

@@ -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 代理环境变量sshpnpmcurl 都直连。
## 结果怎么读
- 最后一行 `部署完成:<目标> @ <git rev>` 才算成功。把这一行连同校验段落转述给用户。
- 输出里出现 `(工作区有未提交改动)` 时要提醒用户:线上跑的是未提交的代码,之后应当提交或有意识地保持。
- `前端产物指纹不一致` 几乎只有一种原因:推送中断。重跑一次 `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` 与环境变量指向它。

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# proxy-station 部署脚本:把前端产物/后端源码推到 LA 服务器Node 直跑 + systemdnginx 由 aaPanel 管)
#
# deploy.sh [web|server|all] [--skip-checks] [--skip-build] [--host <ssh别名>]
#
# web 只构建并替换前端静态产物(服务端按请求读盘,不需要重启)
# server 只推送 shared/ 与 server/ 源码,安装生产依赖并重启 systemd 服务
# all 两者都做(默认)
#
# 数据库、/etc/proxy-station/env、nginx 站点与证书一律不碰——那些是一次性配置,见 docs/DEPLOY.md。
set -euo pipefail
# 会话注入的 HTTP 代理只供模型 API 使用sshpnpmcurl 一律直连
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 "部署校验有未通过项,见上"

View File

@@ -19,6 +19,9 @@ npx vitest run parse # 按文件名
npx vitest run -t "round-trip" # 按用例名 npx vitest run -t "round-trip" # 按用例名
npx vitest # watch 模式 npx vitest # watch 模式
# 部署到 LA 生产机web|server|all详见 .claude/skills/deploy/SKILL.md 与 docs/DEPLOY.md
bash .claude/skills/deploy/scripts/deploy.sh all
# 校验生成的 Clash 配置(最有价值的一条端到端验证) # 校验生成的 Clash 配置(最有价值的一条端到端验证)
curl -s "http://localhost:3000/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml curl -s "http://localhost:3000/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
``` ```

59
docs/DEPLOY.md Normal file
View File

@@ -0,0 +1,59 @@
# 生产部署LA 节点,无 Docker
服务器资源有限1 核1.9G),采用 Node 直跑 + systemd 守护,前端在本机构建后由服务端静态托管,反向代理与证书由 aaPanel 管理。
## 拓扑
```
客户端 ──HTTPS──▶ nginxaaPanel 站点 ps.la.njcqtechaicoding.com泛域名证书
└─ proxy_pass http://127.0.0.1:3000 ──▶ proxy-stationsystemd用户 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/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
```

View File

@@ -22,6 +22,8 @@ const app = new Hono();
app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' })); app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' }));
app.use('/api/*', adminAuth); app.use('/api/*', adminAuth);
// 鉴权探针:能到这里说明已通过 adminAuthtoken 正确,或未设 token 且来源为本机/局域网)
app.get('/api/auth/session', (c) => c.json({ ok: true, tokenRequired: !!env.adminToken }));
app.route('/api/nodes', nodesRoute); app.route('/api/nodes', nodesRoute);
app.route('/api/groups', groupsRoute); app.route('/api/groups', groupsRoute);
app.route('/api/rules', rulesRoute); app.route('/api/rules', rulesRoute);

View File

@@ -34,9 +34,18 @@ function touchToken(id: string, count: number) {
.run(); .run();
} }
/** 订阅 URL 基址:直接取本次请求的 origin——客户端能请求到这里origin 就一定可达 */ /**
function baseUrl(c: { req: { url: string } }): string { * 订阅 URL 基址:取本次请求的 origin——客户端能请求到这里origin 就一定可达。
return new URL(c.req.url).origin; * 部署在 nginx 等反代之后时Node 只看到明文 http 与内网 Host需读 X-Forwarded-ProtoHost 还原外部地址,
* 否则 Surge 的 #!MANAGED-CONFIG 与 Clash 的 provider URL 会指向不可达的 http://127.0.0.1。
*/
function baseUrl(c: { req: { url: string; header: (name: string) => string | undefined } }): string {
const url = new URL(c.req.url);
const proto = c.req.header('x-forwarded-proto')?.split(',')[0].trim();
const host = c.req.header('x-forwarded-host')?.split(',')[0].trim();
if (proto) url.protocol = `${proto}:`;
if (host) url.host = host;
return url.origin;
} }
export const subRoute = new Hono() export const subRoute = new Hono()

View File

@@ -3,9 +3,23 @@ import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui'; import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui';
import { themeOverrides } from './theme'; import { themeOverrides } from './theme';
import { useAuthStore } from './stores/auth';
import { UNAUTHORIZED_EVENT } from './api/client';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore();
// 任何接口回 401本地 token 已失效,清掉并回登录页
window.addEventListener(UNAUTHORIZED_EVENT, () => {
auth.invalidate();
if (!route.meta.public) router.replace({ path: '/login', query: { redirect: route.fullPath } });
});
function logout() {
auth.logout();
router.replace('/login');
}
const navItems = [ const navItems = [
{ path: '/nodes', label: '节点' }, { path: '/nodes', label: '节点' },
@@ -22,7 +36,8 @@ const active = computed(() => route.path);
<NConfigProvider :theme-overrides="themeOverrides"> <NConfigProvider :theme-overrides="themeOverrides">
<NMessageProvider> <NMessageProvider>
<NDialogProvider> <NDialogProvider>
<div class="shell"> <RouterView v-if="route.meta.public" />
<div v-else class="shell">
<header class="topbar"> <header class="topbar">
<div class="topbar-inner"> <div class="topbar-inner">
<div class="brand"> <div class="brand">
@@ -43,6 +58,7 @@ const active = computed(() => route.path);
<div class="legend"> <div class="legend">
<span class="legend-item"><i class="lamp lamp-direct" />直连出口</span> <span class="legend-item"><i class="lamp lamp-direct" />直连出口</span>
<span class="legend-item"><i class="lamp lamp-warp" />WARP 出口</span> <span class="legend-item"><i class="lamp lamp-warp" />WARP 出口</span>
<button v-if="auth.tokenRequired" class="logout" @click="logout">退出</button>
</div> </div>
</div> </div>
</header> </header>
@@ -156,6 +172,22 @@ const active = computed(() => route.path);
white-space: nowrap; white-space: nowrap;
} }
.logout {
appearance: none;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font: 600 12px var(--font-body);
padding: 4px 12px;
border-radius: 999px;
cursor: pointer;
}
.logout:hover {
color: var(--text);
border-color: var(--text);
}
.content { .content {
max-width: 1240px; max-width: 1240px;
margin: 0 auto; margin: 0 auto;
@@ -178,7 +210,7 @@ const active = computed(() => route.path);
overflow-x: auto; overflow-x: auto;
} }
.legend { .legend-item {
display: none; display: none;
} }

View File

@@ -1,4 +1,5 @@
const ADMIN_TOKEN_KEY = 'proxy-station:admin-token'; const ADMIN_TOKEN_KEY = 'proxy-station:admin-token';
export const UNAUTHORIZED_EVENT = 'proxy-station:unauthorized';
export function getAdminToken(): string { export function getAdminToken(): string {
return localStorage.getItem(ADMIN_TOKEN_KEY) || ''; return localStorage.getItem(ADMIN_TOKEN_KEY) || '';
@@ -30,6 +31,8 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
}); });
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) { if (!res.ok) {
// token 失效或被更换:广播给路由层回登录页。用事件而不是直接 import router避免 client → router → store → client 的循环依赖
if (res.status === 401 && !path.startsWith('/api/auth/')) window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT));
throw new ApiError(res.status, (data as any)?.error || `请求失败(${res.status}`); throw new ApiError(res.status, (data as any)?.error || `请求失败(${res.status}`);
} }
return data as T; return data as T;

127
web/src/pages/LoginPage.vue Normal file
View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NButton, NInput } from 'naive-ui';
import { useAuthStore } from '../stores/auth';
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const token = ref('');
const busy = ref(false);
const error = ref(auth.lastError);
async function submit() {
if (!token.value.trim() || busy.value) return;
busy.value = true;
error.value = '';
const ok = await auth.login(token.value);
busy.value = false;
if (ok) {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
router.replace(redirect.startsWith('/') ? redirect : '/');
} else {
error.value = auth.lastError || 'token 不正确';
token.value = '';
}
}
</script>
<template>
<div class="gate">
<form class="card" @submit.prevent="submit">
<div class="brand">
<span class="brand-name display">Proxy Station</span>
<span class="brand-strip"><i class="strip-direct" /><i class="strip-warp" /></span>
</div>
<p class="label">管理入口</p>
<p class="hint">填入服务端 <code>ADMIN_TOKEN</code> 进入token 保存在本浏览器退出时清除</p>
<NInput
v-model:value="token"
type="password"
show-password-on="click"
placeholder="ADMIN_TOKEN"
class="mono"
size="large"
:disabled="busy"
autofocus
@keyup.enter="submit"
/>
<p v-if="error" class="error" role="alert">{{ error }}</p>
<NButton type="primary" size="large" block attr-type="submit" :loading="busy" :disabled="!token.trim()">进入</NButton>
</form>
</div>
</template>
<style scoped>
.gate {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.card {
width: 100%;
max-width: 380px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 28px 28px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.brand {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.brand-name {
font-size: 20px;
}
.brand-strip {
display: flex;
height: 3px;
width: 72px;
border-radius: 2px;
overflow: hidden;
}
.brand-strip i {
flex: 1;
}
.strip-direct {
background: var(--direct);
}
.strip-warp {
background: var(--warp);
}
.hint {
margin: 0;
font-size: 13px;
color: var(--muted);
line-height: 1.6;
}
.hint code {
font-family: var(--font-mono);
font-size: 12px;
}
.error {
margin: 0;
font-size: 13px;
color: var(--danger);
}
</style>

View File

@@ -1,14 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { NButton, NCard, NInput, NSpace, NTabPane, NTabs, useMessage } from 'naive-ui'; import { NButton, NCard, NInput, NTabPane, NTabs, useMessage } from 'naive-ui';
import { getAdminToken, setAdminToken } from '../api/client';
import { useSubscriptionsStore } from '../stores/subscriptions'; import { useSubscriptionsStore } from '../stores/subscriptions';
const store = useSubscriptionsStore(); const store = useSubscriptionsStore();
const message = useMessage(); const message = useMessage();
const sections = ref<Record<string, string>>({}); const sections = ref<Record<string, string>>({});
const adminToken = ref(getAdminToken());
const activeSection = ref('General'); const activeSection = ref('General');
onMounted(async () => { onMounted(async () => {
@@ -29,26 +27,12 @@ async function saveSections() {
message.success('已保存'); message.success('已保存');
} }
function saveAdminToken() {
setAdminToken(adminToken.value.trim());
message.success(adminToken.value.trim() ? '已保存到本浏览器' : '已清除');
}
</script> </script>
<template> <template>
<div> <div>
<h1 class="display page-title">设置</h1> <h1 class="display page-title">设置</h1>
<NCard title="管理鉴权" size="small" class="block">
<p class="hint">
服务端设置了 ADMIN_TOKEN 环境变量时管理接口需要携带同样的 token此处保存的值仅存在本浏览器 localStorage
</p>
<NSpace>
<NInput v-model:value="adminToken" type="password" show-password-on="click" placeholder="ADMIN_TOKEN" class="mono" style="width: 320px" />
<NButton @click="saveAdminToken">保存</NButton>
</NSpace>
</NCard>
<NCard title="Surge 原文段落" size="small" class="block"> <NCard title="Surge 原文段落" size="small" class="block">
<p class="hint"> <p class="hint">
这些段落按原文透传进 Surge 配置[General][MITM] 此处直接编辑文本ShadowRocketClashMeta 不使用这些内容 这些段落按原文透传进 Surge 配置[General][MITM] 此处直接编辑文本ShadowRocketClashMeta 不使用这些内容

View File

@@ -1,13 +1,28 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from './stores/auth';
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', redirect: '/nodes' }, { path: '/', redirect: '/nodes' },
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { title: '登录', public: true } },
{ path: '/nodes', component: () => import('./pages/NodesPage.vue'), meta: { title: '节点' } }, { path: '/nodes', component: () => import('./pages/NodesPage.vue'), meta: { title: '节点' } },
{ path: '/groups', component: () => import('./pages/GroupsPage.vue'), meta: { title: '策略组' } }, { path: '/groups', component: () => import('./pages/GroupsPage.vue'), meta: { title: '策略组' } },
{ path: '/rules', component: () => import('./pages/RulesPage.vue'), meta: { title: '规则' } }, { path: '/rules', component: () => import('./pages/RulesPage.vue'), meta: { title: '规则' } },
{ path: '/subscriptions', component: () => import('./pages/SubscriptionsPage.vue'), meta: { title: '订阅' } }, { path: '/subscriptions', component: () => import('./pages/SubscriptionsPage.vue'), meta: { title: '订阅' } },
{ path: '/settings', component: () => import('./pages/SettingsPage.vue'), meta: { title: '设置' } }, { path: '/settings', component: () => import('./pages/SettingsPage.vue'), meta: { title: '设置' } },
{ path: '/:pathMatch(.*)*', redirect: '/nodes' },
], ],
}); });
// 任何非公开页面进入前都要先通过一次探针;直接在地址栏敲子页面路径同样会被拦到登录页
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (to.meta.public) {
if (auth.status === 'unknown') await auth.check();
return auth.status === 'ok' ? { path: '/' } : true;
}
if (auth.status !== 'ok') await auth.check();
if (auth.status === 'ok') return true;
return { path: '/login', query: to.fullPath !== '/' ? { redirect: to.fullPath } : {} };
});

62
web/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,62 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { api, ApiError, getAdminToken, setAdminToken } from '../api/client';
interface SessionDto {
ok: boolean;
tokenRequired: boolean;
}
/**
* 登录态只存在内存里:每次刷新都重新探针一次 /api/auth/session。
* token 本身仍存 localStorage刷新后免重登探针失败就清掉它。
*/
export const useAuthStore = defineStore('auth', () => {
const status = ref<'unknown' | 'ok' | 'denied'>('unknown');
const tokenRequired = ref(false);
const lastError = ref('');
async function check(): Promise<boolean> {
try {
const s = await api.get<SessionDto>('/api/auth/session');
tokenRequired.value = s.tokenRequired;
status.value = 'ok';
lastError.value = '';
return true;
} catch (e) {
status.value = 'denied';
if (e instanceof ApiError) {
// 401 = 需要 token403 = 服务端未设 token 且来源不是本机/局域网,填 token 也无济于事
tokenRequired.value = e.status === 401;
lastError.value = e.status === 401 ? '' : e.message;
} else {
lastError.value = '无法连接服务端';
}
return false;
}
}
async function login(token: string): Promise<boolean> {
setAdminToken(token.trim());
const ok = await check();
if (!ok) {
setAdminToken('');
if (tokenRequired.value) lastError.value = 'token 不正确';
}
return ok;
}
function logout() {
setAdminToken('');
status.value = 'denied';
}
/** 收到任何接口的 401 时调用:本地 token 已失效,回到未登录态 */
function invalidate() {
if (getAdminToken()) setAdminToken('');
status.value = 'denied';
tokenRequired.value = true;
}
return { status, tokenRequired, lastError, check, login, logout, invalidate };
});