From 0c891030df8d2d766df66cab006871dd693cde5b Mon Sep 17 00:00:00 2001 From: ezequielnick <107352853+ezequielnick@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:27:12 +0800 Subject: [PATCH] feat: airport plugin --- .githooks/pre-commit | 0 .gitignore | 1 - .prettierignore | 1 + changelog.md | 50 +- deploy/gateway/.dockerignore | 14 + deploy/gateway/.env.example | 24 + deploy/gateway/Caddyfile | 8 + deploy/gateway/Dockerfile | 19 + deploy/gateway/README.md | 285 +++++++++ deploy/gateway/admin.mjs | 19 + deploy/gateway/check-vectors.mjs | 29 + deploy/gateway/deploy.sh | 48 ++ deploy/gateway/docker-compose.yml | 32 + deploy/gateway/package.json | 18 + deploy/gateway/src/admin.mjs | 129 ++++ deploy/gateway/src/admin.test.mjs | 95 +++ deploy/gateway/src/auth.mjs | 95 +++ deploy/gateway/src/auth.test.mjs | 113 ++++ deploy/gateway/src/codes.mjs | 31 + deploy/gateway/src/codes.test.mjs | 49 ++ deploy/gateway/src/config.mjs | 25 + deploy/gateway/src/config.test.mjs | 44 ++ deploy/gateway/src/crypto.mjs | 90 +++ deploy/gateway/src/crypto.test.mjs | 72 +++ deploy/gateway/src/db.mjs | 108 ++++ deploy/gateway/src/db.test.mjs | 95 +++ deploy/gateway/src/gateway.mjs | 104 ++++ deploy/gateway/src/gateway.test.mjs | 391 ++++++++++++ deploy/gateway/src/http.mjs | 50 ++ deploy/gateway/src/http.test.mjs | 54 ++ deploy/gateway/src/nonces.mjs | 44 ++ deploy/gateway/src/nonces.test.mjs | 59 ++ deploy/gateway/src/origin.mjs | 54 ++ deploy/gateway/src/origin.test.mjs | 97 +++ deploy/gateway/src/prompt.mjs | 68 ++ deploy/gateway/src/ratelimit.mjs | 23 + deploy/gateway/src/ratelimit.test.mjs | 28 + .../gateway/src/server.integration.test.mjs | 198 ++++++ deploy/gateway/src/server.mjs | 87 +++ docs/plugin/PROVIDER_INTEGRATION_v2.md | 540 ++++++++++++++++ docs/plugin/机场服务端对接指南-v2.md | 589 ++++++++++++++++++ extra/sidecar/sysproxy.win32-x64-msvc.node | Bin 598016 -> 0 bytes package.json | 4 + pnpm-lock.yaml | 6 + scripts/plugin/example-gateway.mjs | 106 ++++ scripts/plugin/gen-cpx.mjs | 24 + scripts/plugin/gen-sign-vectors.mjs | 85 +++ src/main/config/plugin.test.ts | 66 ++ src/main/config/plugin.ts | 64 ++ src/main/config/profile.ts | 57 ++ src/main/core/profileUpdater.test.ts | 51 ++ src/main/core/profileUpdater.ts | 33 +- .../plugin/__fixtures__/sign-vectors.json | 24 + src/main/resolve/plugin/backoff.test.ts | 18 + src/main/resolve/plugin/backoff.ts | 17 + src/main/resolve/plugin/descriptor.test.ts | 100 +++ src/main/resolve/plugin/descriptor.ts | 79 +++ src/main/resolve/plugin/device.test.ts | 55 ++ src/main/resolve/plugin/device.ts | 68 ++ src/main/resolve/plugin/discovery.test.ts | 79 +++ src/main/resolve/plugin/discovery.ts | 72 +++ src/main/resolve/plugin/gateway-url.test.ts | 54 ++ src/main/resolve/plugin/gateway-url.ts | 37 ++ .../resolve/plugin/gateway.netguard.test.ts | 15 + src/main/resolve/plugin/gateway.test.ts | 197 ++++++ src/main/resolve/plugin/gateway.ts | 220 +++++++ src/main/resolve/plugin/http-client.test.ts | 137 ++++ src/main/resolve/plugin/http-client.ts | 121 ++++ src/main/resolve/plugin/index.test.ts | 387 ++++++++++++ src/main/resolve/plugin/index.ts | 332 ++++++++++ src/main/resolve/plugin/net-guard.test.ts | 133 ++++ src/main/resolve/plugin/net-guard.ts | 146 +++++ src/main/resolve/plugin/oauth.test.ts | 95 +++ src/main/resolve/plugin/oauth.ts | 96 +++ src/main/resolve/plugin/sign-vectors.test.ts | 33 + src/main/resolve/plugin/vault.test.ts | 127 ++++ src/main/resolve/plugin/vault.ts | 71 +++ src/main/utils/dirs.ts | 12 + src/main/utils/ipc.ts | 15 + src/preload/index.ts | 10 +- .../plugins/plugin-install-modal.tsx | 121 ++++ .../src/components/plugins/plugin-item.tsx | 92 +++ .../src/components/profiles/profile-item.tsx | 16 +- src/renderer/src/hooks/use-plugin-config.tsx | 21 + src/renderer/src/locales/en-US.json | 35 +- src/renderer/src/locales/fa-IR.json | 35 +- src/renderer/src/locales/ru-RU.json | 35 +- src/renderer/src/locales/zh-CN.json | 31 +- src/renderer/src/locales/zh-TW.json | 31 +- src/renderer/src/main.tsx | 21 +- src/renderer/src/pages/profiles.tsx | 59 +- src/renderer/src/utils/ipc.ts | 14 + src/shared/types.d.ts | 77 ++- tsconfig.node.json | 1 + tsconfig.web.json | 1 + vitest.config.ts | 8 + 96 files changed, 7651 insertions(+), 73 deletions(-) mode change 100644 => 100755 .githooks/pre-commit create mode 100644 deploy/gateway/.dockerignore create mode 100644 deploy/gateway/.env.example create mode 100644 deploy/gateway/Caddyfile create mode 100644 deploy/gateway/Dockerfile create mode 100644 deploy/gateway/README.md create mode 100755 deploy/gateway/admin.mjs create mode 100644 deploy/gateway/check-vectors.mjs create mode 100755 deploy/gateway/deploy.sh create mode 100644 deploy/gateway/docker-compose.yml create mode 100644 deploy/gateway/package.json create mode 100644 deploy/gateway/src/admin.mjs create mode 100644 deploy/gateway/src/admin.test.mjs create mode 100644 deploy/gateway/src/auth.mjs create mode 100644 deploy/gateway/src/auth.test.mjs create mode 100644 deploy/gateway/src/codes.mjs create mode 100644 deploy/gateway/src/codes.test.mjs create mode 100644 deploy/gateway/src/config.mjs create mode 100644 deploy/gateway/src/config.test.mjs create mode 100644 deploy/gateway/src/crypto.mjs create mode 100644 deploy/gateway/src/crypto.test.mjs create mode 100644 deploy/gateway/src/db.mjs create mode 100644 deploy/gateway/src/db.test.mjs create mode 100644 deploy/gateway/src/gateway.mjs create mode 100644 deploy/gateway/src/gateway.test.mjs create mode 100644 deploy/gateway/src/http.mjs create mode 100644 deploy/gateway/src/http.test.mjs create mode 100644 deploy/gateway/src/nonces.mjs create mode 100644 deploy/gateway/src/nonces.test.mjs create mode 100644 deploy/gateway/src/origin.mjs create mode 100644 deploy/gateway/src/origin.test.mjs create mode 100644 deploy/gateway/src/prompt.mjs create mode 100644 deploy/gateway/src/ratelimit.mjs create mode 100644 deploy/gateway/src/ratelimit.test.mjs create mode 100644 deploy/gateway/src/server.integration.test.mjs create mode 100644 deploy/gateway/src/server.mjs create mode 100644 docs/plugin/PROVIDER_INTEGRATION_v2.md create mode 100644 docs/plugin/机场服务端对接指南-v2.md delete mode 100644 extra/sidecar/sysproxy.win32-x64-msvc.node create mode 100644 scripts/plugin/example-gateway.mjs create mode 100644 scripts/plugin/gen-cpx.mjs create mode 100644 scripts/plugin/gen-sign-vectors.mjs create mode 100644 src/main/config/plugin.test.ts create mode 100644 src/main/config/plugin.ts create mode 100644 src/main/core/profileUpdater.test.ts create mode 100644 src/main/resolve/plugin/__fixtures__/sign-vectors.json create mode 100644 src/main/resolve/plugin/backoff.test.ts create mode 100644 src/main/resolve/plugin/backoff.ts create mode 100644 src/main/resolve/plugin/descriptor.test.ts create mode 100644 src/main/resolve/plugin/descriptor.ts create mode 100644 src/main/resolve/plugin/device.test.ts create mode 100644 src/main/resolve/plugin/device.ts create mode 100644 src/main/resolve/plugin/discovery.test.ts create mode 100644 src/main/resolve/plugin/discovery.ts create mode 100644 src/main/resolve/plugin/gateway-url.test.ts create mode 100644 src/main/resolve/plugin/gateway-url.ts create mode 100644 src/main/resolve/plugin/gateway.netguard.test.ts create mode 100644 src/main/resolve/plugin/gateway.test.ts create mode 100644 src/main/resolve/plugin/gateway.ts create mode 100644 src/main/resolve/plugin/http-client.test.ts create mode 100644 src/main/resolve/plugin/http-client.ts create mode 100644 src/main/resolve/plugin/index.test.ts create mode 100644 src/main/resolve/plugin/index.ts create mode 100644 src/main/resolve/plugin/net-guard.test.ts create mode 100644 src/main/resolve/plugin/net-guard.ts create mode 100644 src/main/resolve/plugin/oauth.test.ts create mode 100644 src/main/resolve/plugin/oauth.ts create mode 100644 src/main/resolve/plugin/sign-vectors.test.ts create mode 100644 src/main/resolve/plugin/vault.test.ts create mode 100644 src/main/resolve/plugin/vault.ts create mode 100644 src/renderer/src/components/plugins/plugin-install-modal.tsx create mode 100644 src/renderer/src/components/plugins/plugin-item.tsx create mode 100644 src/renderer/src/hooks/use-plugin-config.tsx create mode 100644 vitest.config.ts diff --git a/.githooks/pre-commit b/.githooks/pre-commit old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore index 12008ea9..78f4cc66 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,3 @@ party.md CLAUDE.md agent.md tsconfig.node.tsbuildinfo -docs \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 9c6b791d..39faa84b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ out dist +.claude pnpm-lock.yaml LICENSE.md tsconfig.json diff --git a/changelog.md b/changelog.md index 1134dee1..e8092bfa 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,9 @@ +# 2.0.0 + +## 新功能 (Feat) + +- 机场插件机制,服务商请参考: [`docs/plugin/机场服务端对接指南-v2.md`](../../docs/plugin/机场服务端对接指南-v2.md) + # 1.9.6 ## 新功能 (Feat) @@ -50,47 +56,3 @@ - 优化流量时间范围显示格式 - 更新依赖 - 使用最新发布的 sysproxy-rs - -# 1.9.5 - -## 新功能 (Feat) - -- 更新 mihomo 内核 -- 新增 WebDAV 备份文件名包含设备名称 -- 新增单个日志文件大小限制和截断机制 -- 新增每个订阅独立的 User-Agent 配置 -- 新增配置开关和覆写保存时使用 Mihomo 热重载生效 -- 新增热重载切换配置后的连接关闭选项 -- 新增自定义托盘图标支持及裁剪选择器 -- 新增托盘策略组延迟测试入口 -- 新增网络延迟测试自定义目标地址 -- 新增 fake-ip-filter 规则模式支持 -- 新增删除配置文件的确认弹窗 -- 使用 mshta 在 Electron 初始化前同步检测 PowerShell 版本 - -## 修复 (Fix) - -- 修复 Win7 兼容性问题 -- 修复日志清理正则表达式以正确匹配带前缀的文件名 -- 修复渲染端和配置相关的类型安全回归 -- 修复订阅超时时间为空时未回退到全局超时时间的问题 -- 修复空 `lan-allowed-ips` 导致局域网访问异常的问题 -- 修复智能覆写在规则覆写前应用导致配置结果异常的问题 -- 修复 `mihomoHotReloadConfig` IPC 调用白名单缺失并稳定 TUN 保存流程 -- 修复受保护配置工作目录删除失败的问题 -- 修复连接表中国旗表情未正确显示的问题 -- 修复订阅自动更新在异常间隔或错误配置下可能触发高频刷新请求的问题 -- 修复 nameserver-policy 未正确保存和应用的问题 -- 修复托盘图标裁剪弹窗的暗色模式显示异常 -- 修复 Windows TUN 模式下的自代理循环问题 - -## 性能优化 (Performance) - -- 优化连接页渲染并减少代理组轮询 -- 优化软件启动和退出速度 - -## 其他 (Chore) - -- 新增提交前格式、lint 和类型检查钩子 -- 重构 rule-item 额外字段处理逻辑并补充类型定义 -- 更新依赖 diff --git a/deploy/gateway/.dockerignore b/deploy/gateway/.dockerignore new file mode 100644 index 00000000..109892b1 --- /dev/null +++ b/deploy/gateway/.dockerignore @@ -0,0 +1,14 @@ +# Keep tests, fixtures (incl. the self-signed test cert), and dev-only tooling out of the image. +**/*.test.mjs +src/__fixtures__ +check-vectors.mjs +.env +.env.bak +*.db +*.db-* +node_modules +README.md +Dockerfile +docker-compose.yml +Caddyfile +deploy.sh diff --git a/deploy/gateway/.env.example b/deploy/gateway/.env.example new file mode 100644 index 00000000..44209458 --- /dev/null +++ b/deploy/gateway/.env.example @@ -0,0 +1,24 @@ +# Public domain. An A/AAAA DNS record MUST point at this VPS before deploying — +# Caddy uses it to obtain a Let's Encrypt certificate. Required. +DOMAIN=gw.example.com + +# Default per-user device limit for `cpx-admin add-user` (override per user with --limit). +DEVICE_LIMIT_DEFAULT=3 + +# ---- Advanced (sensible defaults; uncomment only to override) ---- +# gateway origin written into /.well-known/cpx-gateway (defaults to https://$DOMAIN) +# PUBLIC_ORIGIN=https://gw.example.com +# Allowed client/server clock skew for signed requests (ms) +# CLOCK_SKEW_MS=300000 +# Upstream subscription fetch timeout (ms) and max size (bytes) +# SUB_TIMEOUT_MS=30000 +# SUB_MAX_BYTES=10485760 +# Pending nonces kept per device +# NONCE_POOL_MAX=8 +# Login attempts allowed per IP per window +# LOGIN_MAX=10 +# LOGIN_WINDOW_MS=60000 +# Set to "true" to retire this gateway: /challenge and /config return 410 gateway_retired +# RETIRED=false +# Path (inside the container) to a private CA cert for the subscription origin +# ORIGIN_CA_FILE= diff --git a/deploy/gateway/Caddyfile b/deploy/gateway/Caddyfile new file mode 100644 index 00000000..529d3d93 --- /dev/null +++ b/deploy/gateway/Caddyfile @@ -0,0 +1,8 @@ +# Caddy obtains and renews a Let's Encrypt certificate for {$DOMAIN} automatically. +# It terminates TLS and reverse-proxies to the gateway container on the internal network. +# To receive cert-expiry notices, add a global block above: { email you@example.com } + +{$DOMAIN} { + encode gzip + reverse_proxy gateway:8080 +} diff --git a/deploy/gateway/Dockerfile b/deploy/gateway/Dockerfile new file mode 100644 index 00000000..26787589 --- /dev/null +++ b/deploy/gateway/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-slim + +WORKDIR /app + +# Zero runtime dependencies — the gateway uses only Node built-ins (including node:sqlite). +# No `npm install` needed. Copy sources and expose the admin CLI on PATH. +COPY package.json ./ +COPY src ./src +COPY admin.mjs ./ + +RUN chmod +x /app/admin.mjs \ + && ln -sf /app/admin.mjs /usr/local/bin/cpx-admin \ + && mkdir -p /data + +ENV NODE_ENV=production +EXPOSE 8080 + +# Run node as PID 1 so it receives stop signals directly. +CMD ["node", "--experimental-sqlite", "--disable-warning=ExperimentalWarning", "src/server.mjs"] diff --git a/deploy/gateway/README.md b/deploy/gateway/README.md new file mode 100644 index 00000000..6eb5c77a --- /dev/null +++ b/deploy/gateway/README.md @@ -0,0 +1,285 @@ +# cpx-gateway + +机场插件 v2 的参考网关实现。适合先跑通协议的服务商直接部署测试和参考实现使用。 + +它提供: + +- OAuth authorize 登录页:`/oauth/authorize` +- 网关发现文件:`/.well-known/cpx-gateway` +- 网关接口:`/enroll`、`/challenge`、`/config`、`/revoke` +- SQLite 账号、设备和订阅 URL 管理 +- Caddy 自动申请和续期 HTTPS 证书 + +实现只使用 Node.js 内置模块,包括 `node:sqlite`。镜像构建时不执行 `npm install`。 + +相关文档: + +- 中文对接指南:[`docs/plugin/机场服务端对接指南-v2.md`](../../docs/plugin/机场服务端对接指南-v2.md) +- 英文协议文档:[`docs/plugin/PROVIDER_INTEGRATION_v2.md`](../../docs/plugin/PROVIDER_INTEGRATION_v2.md) + +--- + +## 部署结构 + +默认部署使用一个公网域名,同时作为登录域名和网关域名。 + +```text +Client browser + -> https:///oauth/authorize + -> Caddy + -> gateway:8080 + +Client updater + -> https:///.well-known/cpx-gateway + -> https:///challenge + -> https:///config + -> Caddy + -> gateway:8080 + -> hidden subscription origin +``` + +Compose 服务: + +| 服务 | 作用 | +| --------- | ---------------------------------------------- | +| `caddy` | 监听 80/443,申请 Let's Encrypt 证书,反代网关 | +| `gateway` | Node.js 网关进程,只暴露在 compose 内部网络 | + +数据卷: + +| 卷 | 内容 | +| -------------- | -------------------------------- | +| `gateway_data` | SQLite 数据库 `/data/gateway.db` | +| `caddy_data` | Caddy 证书和状态 | +| `caddy_config` | Caddy 运行配置 | + +--- + +## 前置条件 + +1. 一台公网 VPS,已安装 Docker 和 Docker Compose v2。 +2. 一个域名,A/AAAA 记录已经指向这台 VPS。 +3. VPS 防火墙和安全组放行 TCP 80、443。 + +客户端会校验 HTTPS、公网 host 和 well-known 文件。不要用 IP、`localhost`、内网域名或自签证书部署给真实用户。 + +--- + +## 部署 + +在 VPS 上进入本目录: + +```bash +cd deploy/gateway +./deploy.sh +``` + +首次运行时,脚本会复制 `.env.example` 为 `.env`,询问公网域名,并执行: + +```bash +docker compose up -d --build +``` + +等待 Caddy 申请证书后检查发现文件: + +```bash +curl https:///.well-known/cpx-gateway +``` + +正常响应类似: + +```json +{ + "spec": "cpx-plugin/2", + "gateway": "https://", + "endpoints": { + "enroll": "/enroll", + "challenge": "/challenge", + "config": "/config", + "revoke": "/revoke" + } +} +``` + +查看容器状态: + +```bash +docker compose ps +docker compose logs -f gateway +docker compose logs -f caddy +``` + +--- + +## 账号管理 + +每个账号对应一个隐藏订阅 URL。该 URL 由网关在服务端请求,客户端不会拿到。 + +添加用户: + +```bash +docker compose exec gateway cpx-admin add-user alice 'https://origin.example.com/sub?token=xxxx' --limit 3 +``` + +命令会提示输入密码。密码只保存 scrypt hash。 + +常用命令: + +```bash +docker compose exec gateway cpx-admin list-users +docker compose exec gateway cpx-admin list-users --show-sub +docker compose exec gateway cpx-admin set-sub alice 'https://origin.example.com/sub?token=yyyy' +docker compose exec gateway cpx-admin set-limit alice 5 +docker compose exec gateway cpx-admin passwd alice +docker compose exec gateway cpx-admin list-devices alice +docker compose exec gateway cpx-admin revoke-device +docker compose exec gateway cpx-admin del-user alice +``` + +说明: + +- `list-users` 默认只显示订阅 URL 的 host。 +- `list-users --show-sub` 会打印完整订阅 URL,只在需要排障时使用。 +- `revoke-device` 删除设备绑定。客户端下次更新会进入重新登录流程。 +- `del-user` 会删除用户及其设备。 + +--- + +## 生成 `.cpx` + +`.cpx` 是公开插件描述文件,不含用户信息、token、网关密钥或订阅 URL。所有用户可以使用同一份文件。 + +在仓库根目录运行: + +```bash +node scripts/plugin/gen-cpx.mjs https:///oauth/authorize "Your Airport" https:// your-airport.cpx +``` + +分发 `your-airport.cpx`。用户在 Clash Party 中导入后,会通过系统浏览器打开登录页。登录成功后,客户端注册设备并拉取该账号绑定的 Clash YAML。 + +--- + +## 请求链路 + +首次登录: + +1. 客户端请求 `https:///.well-known/cpx-gateway`。 +2. 客户端生成 Ed25519 设备密钥和 `deviceId`。 +3. 系统浏览器打开 `https:///oauth/authorize?...`。 +4. 用户输入账号密码。 +5. 网关签发一次性 `code`,绑定 PKCE、`redirect_uri`、`client_id`,TTL 默认 60 秒。 +6. 客户端调用 `/enroll`,提交 `code`、PKCE verifier、设备公钥和 `deviceId`。 +7. 网关写入设备绑定。 + +订阅更新: + +1. 客户端调用 `/challenge` 领取 nonce。 +2. 客户端用设备私钥签名。 +3. 客户端调用 `/config`。 +4. 网关校验 nonce、时钟偏差和 Ed25519 签名。 +5. 网关用该账号的隐藏订阅 URL 拉取 Clash YAML。 +6. 网关把 YAML 返回给客户端。 + +删除插件: + +1. 客户端调用 `/revoke`。 +2. 网关验签后删除设备绑定。 +3. 客户端删除本地状态。 + +--- + +## 运维 + +升级: + +```bash +git pull +cd deploy/gateway +./deploy.sh +``` + +账号和设备数据保存在 `gateway_data` 卷中,升级容器不会删除。 + +备份数据库到当前目录: + +```bash +docker compose exec -T gateway cat /data/gateway.db > gateway.db.backup +``` + +恢复数据库: + +```bash +docker compose down +docker run --rm -i -v gateway_gateway_data:/data busybox sh -c 'cat > /data/gateway.db' < gateway.db.backup +docker compose up -d +``` + +网关退役: + +```bash +if grep -q '^RETIRED=' .env; then + sed -i.bak 's/^RETIRED=.*/RETIRED=true/' .env +else + printf '\nRETIRED=true\n' >> .env +fi +docker compose up -d +``` + +退役后,`/challenge` 和 `/config` 返回 `410` / `gateway_retired`。客户端会回到登录域名重新发现网关。 + +--- + +## 配置 + +配置文件为 `.env`。可参考 [`.env.example`](.env.example)。 + +| 变量 | 默认值 | 说明 | +| ---------------------- | ------------------ | ---------------------------------------------------- | +| `DOMAIN` | 无 | 公网域名,必填 | +| `PUBLIC_ORIGIN` | `https://$DOMAIN` | 写入 `/.well-known/cpx-gateway` 的 gateway origin | +| `DEVICE_LIMIT_DEFAULT` | `3` | 新用户默认设备数上限,可被 `add-user --limit` 覆盖 | +| `CLOCK_SKEW_MS` | `300000` | `/config`、`/revoke` 签名时间戳允许偏差 | +| `CODE_TTL_MS` | `60000` | authorize code 有效期 | +| `NONCE_TTL_MS` | `60000` | challenge nonce 有效期 | +| `NONCE_POOL_MAX` | `8` | 单设备待用 nonce 数上限 | +| `LOGIN_MAX` | `10` | 单 IP 登录尝试次数上限 | +| `LOGIN_WINDOW_MS` | `60000` | 登录限流窗口 | +| `SUB_TIMEOUT_MS` | `30000` | 拉取隐藏订阅的超时 | +| `SUB_MAX_BYTES` | `10485760` | 隐藏订阅响应体上限 | +| `RETIRED` | `false` | 设置为 `true` 时返回网关退役信号 | +| `ORIGIN_CA_FILE` | 空 | 订阅 origin 使用私有 CA 时,在容器内指定 CA 文件路径 | +| `PORT` | `8080` | gateway 容器内监听端口 | +| `DB_PATH` | `/data/gateway.db` | SQLite 数据库路径 | + +如果登录域名和网关域名需要拆开,保持 `.cpx` 里的 `loginUrl` 指向登录域名,同时把登录域名上的 `/.well-known/cpx-gateway` 的 `gateway` 指向新的公网网关 origin。当前参考部署默认两者使用同一个域名。 + +--- + +## 开发和自测 + +本目录不需要安装依赖。需要 Node.js >= 22.5.0。 + +```bash +cd deploy/gateway +npm test +npm run check-vectors +npm start +``` + +说明: + +- `npm test` 使用 `node:test` 跑网关测试。 +- `npm run check-vectors` 使用客户端签名向量检查 Ed25519 互通。 +- `npm start` 在本地启动 HTTP 网关,默认监听 `:8080`。真实客户端接入仍需要公网 HTTPS 和合法 well-known。 + +--- + +## 安全边界 + +- 隐藏订阅 URL 是管理员配置项。网关只要求 HTTPS,并设置超时和响应体大小上限;不拦截私网地址,便于把 origin 放在内网。 +- 密码使用 scrypt hash 保存。 +- authorize code 和 nonce 都是一次性短 TTL。 +- 登录接口按 IP 限流。 +- `/config` 和 `/revoke` 使用 Ed25519 设备签名和一次性 nonce 防重放。 +- 日志不要记录密码、完整订阅 URL、code、nonce、签名或完整 Clash YAML。 +- Clash 节点内容最终会返回客户端,这是客户端运行 Mihomo 的必要输入。本实现保护的是订阅 URL、origin host 和服务端 API。 diff --git a/deploy/gateway/admin.mjs b/deploy/gateway/admin.mjs new file mode 100755 index 00000000..f9ca970f --- /dev/null +++ b/deploy/gateway/admin.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env -S node --experimental-sqlite --disable-warning=ExperimentalWarning +// cpx-admin CLI entrypoint. Wires the real account DB + a no-echo password reader into +// the tested command logic (src/admin.mjs). +import { loadConfig } from './src/config.mjs' +import { openDb } from './src/db.mjs' +import { runAdmin } from './src/admin.mjs' +import { readPassword } from './src/prompt.mjs' + +const config = loadConfig() +const db = openDb(config.dbPath) +const code = await runAdmin(process.argv.slice(2), { + db, + deviceLimitDefault: config.deviceLimitDefault, + readPassword, + out: (s) => console.log(s), + err: (s) => console.error(s) +}) +db.close() +process.exit(code) diff --git a/deploy/gateway/check-vectors.mjs b/deploy/gateway/check-vectors.mjs new file mode 100644 index 00000000..a817ce40 --- /dev/null +++ b/deploy/gateway/check-vectors.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Interop proof: the gateway's crypto reproduces the client's recorded sign vectors +// byte-for-byte and verifies the recorded signatures. If this passes, signatures the +// app produces with device.ts will verify here. Dev-only — reads the repo fixture by +// relative path and is NOT shipped in the container image. +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import assert from 'node:assert/strict' +import { buildSignInput, verifySignature } from './src/crypto.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const fixture = join(here, '../../src/main/resolve/plugin/__fixtures__/sign-vectors.json') + +const vectors = JSON.parse(readFileSync(fixture, 'utf-8')) +assert.ok(Array.isArray(vectors) && vectors.length > 0, 'no vectors found') + +for (const v of vectors) { + const nonce = Buffer.from(v.nonceB64, 'base64') + const input = buildSignInput(v.op, v.deviceId, v.nonceId, nonce, v.ts) + assert.equal(input.toString('hex'), v.inputHex, `canonical input mismatch (${v.deviceId})`) + assert.equal( + verifySignature(v.pubKeyB64, input, v.sigB64), + true, + `signature did not verify (${v.deviceId})` + ) +} + +console.log(`check-vectors: OK — ${vectors.length} client vectors verified against gateway crypto`) diff --git a/deploy/gateway/deploy.sh b/deploy/gateway/deploy.sh new file mode 100755 index 00000000..74d0e1ec --- /dev/null +++ b/deploy/gateway/deploy.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# One-click deploy for the cpx-gateway reference server (Caddy auto-HTTPS + gateway). +set -euo pipefail +cd "$(dirname "$0")" + +command -v docker >/dev/null 2>&1 || { + echo "Docker is required. Install Docker, then re-run: https://docs.docker.com/engine/install/" >&2 + exit 1 +} +docker compose version >/dev/null 2>&1 || { + echo "Docker Compose v2 is required (the 'docker compose' subcommand)." >&2 + exit 1 +} + +if [ ! -f .env ]; then + cp .env.example .env + read -rp "Public domain (its DNS A/AAAA record must already point at this VPS), e.g. gw.example.com: " DOMAIN + [ -n "$DOMAIN" ] || { echo "A domain is required." >&2; exit 1; } + sed -i.bak "s|^DOMAIN=.*|DOMAIN=${DOMAIN}|" .env && rm -f .env.bak + echo "Wrote .env (DOMAIN=${DOMAIN})." +fi + +DOMAIN=$(grep -E '^DOMAIN=' .env | cut -d= -f2-) + +echo "Building and starting containers..." +docker compose up -d --build + +cat < '' --limit 3 + + 3) Generate the .cpx plugin file for your users (run from the repository root): + node scripts/plugin/gen-cpx.mjs https://${DOMAIN}/oauth/authorize "Your Airport" https://${DOMAIN} your-airport.cpx + + 4) Distribute your-airport.cpx. Users import it in Clash Party, log in via the + system browser with the account you created, and the subscription loads automatically. + +Manage: docker compose exec gateway cpx-admin list-users | list-devices | revoke-device +Logs: docker compose logs -f gateway +Update: git pull && ./deploy.sh (account data persists in the gateway_data volume) +EOF diff --git a/deploy/gateway/docker-compose.yml b/deploy/gateway/docker-compose.yml new file mode 100644 index 00000000..0b5f6951 --- /dev/null +++ b/deploy/gateway/docker-compose.yml @@ -0,0 +1,32 @@ +services: + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - '80:80' + - '443:443' + environment: + DOMAIN: ${DOMAIN:?set DOMAIN in .env} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - gateway + + gateway: + build: . + restart: unless-stopped + env_file: .env + environment: + DB_PATH: /data/gateway.db + volumes: + - gateway_data:/data + # Not published to the host — only reachable inside the compose network as gateway:8080. + expose: + - '8080' + +volumes: + caddy_data: + caddy_config: + gateway_data: diff --git a/deploy/gateway/package.json b/deploy/gateway/package.json new file mode 100644 index 00000000..575fead6 --- /dev/null +++ b/deploy/gateway/package.json @@ -0,0 +1,18 @@ +{ + "name": "cpx-gateway", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Reference provider gateway for the clash-party airport-plugin v2 browser-login model", + "bin": { + "cpx-admin": "./admin.mjs" + }, + "scripts": { + "start": "node --experimental-sqlite --disable-warning=ExperimentalWarning src/server.mjs", + "test": "node --experimental-sqlite --disable-warning=ExperimentalWarning --test", + "check-vectors": "node --disable-warning=ExperimentalWarning check-vectors.mjs" + }, + "engines": { + "node": ">=22.5.0" + } +} diff --git a/deploy/gateway/src/admin.mjs b/deploy/gateway/src/admin.mjs new file mode 100644 index 00000000..785845eb --- /dev/null +++ b/deploy/gateway/src/admin.mjs @@ -0,0 +1,129 @@ +// Admin command logic, decoupled from I/O so it is testable. The bin wrapper +// (../admin.mjs) supplies a real db, a no-echo password reader, and console writers. +import { hashPassword } from './crypto.mjs' + +const USAGE = `Usage: cpx-admin ... + add-user [--limit N] + set-sub + passwd + set-limit + del-user + list-users [--show-sub] + list-devices + revoke-device ` + +function parse(rest) { + const pos = [] + const flags = {} + for (let i = 0; i < rest.length; i++) { + const t = rest[i] + if (t === '--limit') flags.limit = rest[++i] + else if (t === '--show-sub') flags.showSub = true + else if (t.startsWith('--')) flags[t.slice(2)] = true + else pos.push(t) + } + return { pos, flags } +} + +function hostOf(url) { + try { + return new URL(url).host + } catch { + return '(invalid url)' + } +} + +export async function runAdmin(args, deps) { + const { db, readPassword, out, err } = deps + const defaultLimit = deps.deviceLimitDefault ?? 3 + const [cmd, ...rest] = args + const { pos, flags } = parse(rest) + const need = (ok, msg) => { + if (!ok) err(msg) + return ok + } + const requireUser = (name) => { + if (db.getUser(name)) return true + err(`user "${name}" not found`) + return false + } + + switch (cmd) { + case 'add-user': { + const [username, subUrl] = pos + if (!need(username && subUrl, 'add-user requires ')) return 1 + if (db.getUser(username)) { + err(`user "${username}" already exists`) + return 1 + } + const password = await readPassword('Password: ') + if (!need(password, 'empty password')) return 1 + const deviceLimit = flags.limit ? Number(flags.limit) : defaultLimit + db.addUser({ username, pwdHash: hashPassword(password), subUrl, deviceLimit }) + out(`added user "${username}" (device limit ${deviceLimit})`) + return 0 + } + case 'set-sub': { + const [username, subUrl] = pos + if (!need(username && subUrl, 'set-sub requires ')) return 1 + if (!requireUser(username)) return 1 + db.setSub(username, subUrl) + out(`updated subscription for "${username}"`) + return 0 + } + case 'set-limit': { + const [username, n] = pos + if (!need(username && n, 'set-limit requires ')) return 1 + if (!requireUser(username)) return 1 + db.setLimit(username, Number(n)) + out(`set device limit ${Number(n)} for "${username}"`) + return 0 + } + case 'passwd': { + const [username] = pos + if (!need(username, 'passwd requires ')) return 1 + if (!requireUser(username)) return 1 + const password = await readPassword('Password: ') + if (!need(password, 'empty password')) return 1 + db.setPwd(username, hashPassword(password)) + out(`changed password for "${username}"`) + return 0 + } + case 'del-user': { + const [username] = pos + if (!need(username, 'del-user requires ')) return 1 + if (!requireUser(username)) return 1 + db.delUser(username) + out(`deleted user "${username}" and its devices`) + return 0 + } + case 'list-users': { + for (const u of db.listUsers()) { + const sub = flags.showSub ? u.subUrl : hostOf(u.subUrl) + out( + `${u.username}\tdevices=${u.deviceCount}/${u.deviceLimit}\t${sub}\t${new Date(u.created).toISOString()}` + ) + } + return 0 + } + case 'list-devices': { + const [username] = pos + if (!need(username, 'list-devices requires ')) return 1 + if (!requireUser(username)) return 1 + for (const d of db.listDevices(username)) { + out(`${d.deviceId}\t${new Date(d.created).toISOString()}`) + } + return 0 + } + case 'revoke-device': { + const [deviceId] = pos + if (!need(deviceId, 'revoke-device requires ')) return 1 + db.delDevice(deviceId) + out(`revoked device ${deviceId}`) + return 0 + } + default: + err(USAGE) + return 1 + } +} diff --git a/deploy/gateway/src/admin.test.mjs b/deploy/gateway/src/admin.test.mjs new file mode 100644 index 00000000..1234a7bf --- /dev/null +++ b/deploy/gateway/src/admin.test.mjs @@ -0,0 +1,95 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { openDb } from './db.mjs' +import { verifyPassword } from './crypto.mjs' +import { runAdmin } from './admin.mjs' + +function harness({ password = 'pw' } = {}) { + const lines = [] + const db = openDb(':memory:') + const deps = { + db, + readPassword: async () => password, + out: (s) => lines.push(String(s)), + err: (s) => lines.push('ERR:' + s) + } + return { db, deps, lines, text: () => lines.join('\n') } +} + +test('add-user creates a user with a hashed password and a device limit', async () => { + const h = harness({ password: 's3cret' }) + const code = await runAdmin( + ['add-user', 'alice', 'https://o.example/sub?t=1', '--limit', '5'], + h.deps + ) + assert.equal(code, 0) + const u = h.db.getUser('alice') + assert.equal(u.subUrl, 'https://o.example/sub?t=1') + assert.equal(u.deviceLimit, 5) + assert.equal(verifyPassword('s3cret', u.pwdHash), true) +}) + +test('add-user rejects a duplicate username with a non-zero exit', async () => { + const h = harness() + await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps) + const code = await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps) + assert.notEqual(code, 0) + assert.match(h.text(), /exists/i) +}) + +test('set-sub / set-limit / passwd update an existing user', async () => { + const h = harness({ password: 'new-pw' }) + await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps) + await runAdmin(['set-sub', 'alice', 'https://o/sub2'], h.deps) + await runAdmin(['set-limit', 'alice', '9'], h.deps) + await runAdmin(['passwd', 'alice'], h.deps) + const u = h.db.getUser('alice') + assert.equal(u.subUrl, 'https://o/sub2') + assert.equal(u.deviceLimit, 9) + assert.equal(verifyPassword('new-pw', u.pwdHash), true) +}) + +test('del-user removes the user and its devices', async () => { + const h = harness() + await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps) + h.db.upsertDevice({ deviceId: 'd1', username: 'alice', pubKey: 'K' }) + const code = await runAdmin(['del-user', 'alice'], h.deps) + assert.equal(code, 0) + assert.equal(h.db.getUser('alice'), undefined) + assert.equal(h.db.getDevice('d1'), undefined) +}) + +test('list-users hides the full subUrl by default and shows it with --show-sub', async () => { + const h = harness() + await runAdmin(['add-user', 'alice', 'https://secret.example/sub?token=XYZ'], h.deps) + await runAdmin(['list-users'], h.deps) + assert.match(h.text(), /secret\.example/) + assert.doesNotMatch(h.text(), /token=XYZ/) + await runAdmin(['list-users', '--show-sub'], h.deps) + assert.match(h.text(), /token=XYZ/) +}) + +test('list-devices and revoke-device manage bindings', async () => { + const h = harness() + await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps) + h.db.upsertDevice({ deviceId: 'dev-abc', username: 'alice', pubKey: 'K' }) + await runAdmin(['list-devices', 'alice'], h.deps) + assert.match(h.text(), /dev-abc/) + const code = await runAdmin(['revoke-device', 'dev-abc'], h.deps) + assert.equal(code, 0) + assert.equal(h.db.getDevice('dev-abc'), undefined) +}) + +test('operating on a missing user is a non-zero exit with a clear message', async () => { + const h = harness() + const code = await runAdmin(['set-sub', 'ghost', 'https://o/sub'], h.deps) + assert.notEqual(code, 0) + assert.match(h.text(), /not found/i) +}) + +test('an unknown command returns non-zero and prints usage', async () => { + const h = harness() + const code = await runAdmin(['frobnicate'], h.deps) + assert.notEqual(code, 0) + assert.match(h.text(), /usage/i) +}) diff --git a/deploy/gateway/src/auth.mjs b/deploy/gateway/src/auth.mjs new file mode 100644 index 00000000..11a82888 --- /dev/null +++ b/deploy/gateway/src/auth.mjs @@ -0,0 +1,95 @@ +// OAuth 2.0 authorize endpoint (the public login page). Renders a self-contained +// login form, validates credentials against the local account store, and issues a +// one-time authorization code bound to the PKCE challenge + loopback redirect. +import { verifyPassword } from './crypto.mjs' +import { sendHtml, redirect } from './http.mjs' + +const LOOPBACK_REDIRECT = /^http:\/\/(127\.0\.0\.1|localhost):\d{1,5}\/callback$/ +const OAUTH_FIELDS = [ + 'response_type', + 'client_id', + 'redirect_uri', + 'code_challenge', + 'code_challenge_method', + 'state', + 'scope' +] + +function escapeHtml(s) { + return String(s ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +// Returns null when valid, else a short reason string. +function validateParams(p) { + if (p.response_type !== 'code') return 'response_type must be code' + if (p.code_challenge_method !== 'S256') return 'code_challenge_method must be S256' + if (!p.code_challenge || !/^[A-Za-z0-9_-]{20,}$/.test(p.code_challenge)) + return 'invalid code_challenge' + if (!p.redirect_uri || !LOOPBACK_REDIRECT.test(p.redirect_uri)) return 'invalid redirect_uri' + if (!p.state) return 'missing state' + if (!p.client_id) return 'missing client_id' + return null +} + +function errorPage(reason) { + return `Login error + +

无法开始登录 / Cannot start login

${escapeHtml(reason)}

` +} + +function loginPage(p, errorMsg) { + const hidden = OAUTH_FIELDS.map( + (f) => `` + ).join('\n ') + const err = errorMsg ? `

${escapeHtml(errorMsg)}

` : '' + return ` +登录 / Sign in + +

登录 / Sign in

+ ${err} +
+ ${hidden} +

+

+

+
+

密码只输入在本页面(机场官网)。/ Your password is entered only here.

+` +} + +export function authorizeGet(query, res) { + const reason = validateParams(query) + if (reason) return sendHtml(res, 400, errorPage(reason)) + sendHtml(res, 200, loginPage(query)) +} + +export function authorizePost(form, ip, res, deps) { + if (!deps.rateLimiter.hit(ip)) { + return sendHtml(res, 429, errorPage('尝试过于频繁,请稍后再试 / Too many attempts')) + } + const reason = validateParams(form) + if (reason) return sendHtml(res, 400, errorPage(reason)) + + const user = deps.db.getUser(form.username) + if (!user || !verifyPassword(form.password ?? '', user.pwdHash)) { + return sendHtml(res, 200, loginPage(form, '账号或密码错误 / Wrong username or password')) + } + + const code = deps.codes.issue({ + username: user.username, + redirect_uri: form.redirect_uri, + client_id: form.client_id, + code_challenge: form.code_challenge + }) + const url = new URL(form.redirect_uri) + url.searchParams.set('code', code) + url.searchParams.set('state', form.state) + redirect(res, url.toString()) +} diff --git a/deploy/gateway/src/auth.test.mjs b/deploy/gateway/src/auth.test.mjs new file mode 100644 index 00000000..36247ef0 --- /dev/null +++ b/deploy/gateway/src/auth.test.mjs @@ -0,0 +1,113 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { openDb } from './db.mjs' +import { createCodeStore } from './codes.mjs' +import { createRateLimiter } from './ratelimit.mjs' +import { hashPassword } from './crypto.mjs' +import { authorizeGet, authorizePost } from './auth.mjs' + +const PARAMS = { + response_type: 'code', + client_id: 'mihomo-party', + redirect_uri: 'http://127.0.0.1:51000/callback', + code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + code_challenge_method: 'S256', + state: 'st-123', + scope: 'subscribe' +} + +function deps() { + const db = openDb(':memory:') + db.addUser({ + username: 'alice', + pwdHash: hashPassword('pw'), + subUrl: 'https://o/sub', + deviceLimit: 3 + }) + return { + db, + codes: createCodeStore(), + rateLimiter: createRateLimiter({ max: 5, windowMs: 1000 }) + } +} + +function mockRes() { + const r = { status: 0, headers: {}, body: '' } + r.writeHead = (s, h) => ((r.status = s), (r.headers = h || {}), r) + r.end = (b) => ((r.body = b ?? ''), undefined) + return r +} + +test('authorizeGet renders a self-contained login form with the OAuth params', () => { + const res = mockRes() + authorizeGet(PARAMS, res) + assert.equal(res.status, 200) + assert.match(res.body, /]*method="post"/i) + assert.match(res.body, /name="state"[^>]*value="st-123"/) + assert.match(res.body, /name="code_challenge"/) + assert.doesNotMatch(res.body, /' + authorizePost({ ...PARAMS, state: evil, username: 'alice', password: 'WRONG' }, '1.2.3.4', res, d) + assert.equal(res.status, 200) + assert.doesNotMatch(res.body, /