Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6277e86d0f | |||
| 4baf24f6f8 | |||
| ec0942e1e7 | |||
| 1e13f35219 | |||
| e4de308ba4 | |||
| 97caae95b8 | |||
| 4561ee4cc3 | |||
| a53bbe743e | |||
| 7b1f49c182 | |||
| 8c8486f9af | |||
| 7d0f4c8065 | |||
| 3d521973ab | |||
| cb8e4bddd8 | |||
| 4cfe4d0ccb | |||
| 366695ffd6 | |||
| 2e631ed3b5 | |||
| 2698116696 | |||
| 973c2c021b | |||
| f1950ffb6f | |||
| 8850c94e96 | |||
| 4d67a90659 | |||
| cccbb7b56b | |||
| 06ba76e22b | |||
| e2ffce90d3 | |||
| 1b091f3be0 | |||
| a5aea9438a | |||
| 9b56e2667b | |||
| bac79017ae | |||
| 5c647235aa | |||
| f8e870fd6a | |||
| 84b042394d | |||
| e557a94ed0 | |||
| e1a17acbe4 | |||
| 3efd67297e | |||
| f6e565ffdc | |||
| ba5f71b716 |
@@ -0,0 +1,39 @@
|
||||
name: DB Backup
|
||||
|
||||
on:
|
||||
# 每日北京 02:00(= UTC 18:00)自动备份;也可手动触发。
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: db-backup
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
backup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Backup pangolin1 SQLite → NAS
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p /volume1/docker/backups/pangolin
|
||||
bash scripts/ci/backup-db.sh 2>&1 | tee /volume1/docker/backups/pangolin/_last-run.log
|
||||
exit "${PIPESTATUS[0]}"
|
||||
|
||||
- name: Notify (Telegram)
|
||||
if: always()
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
. scripts/ci/notify.sh
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
notify_ok "pangolin1 SQLite 备份成功(NAS)"
|
||||
else
|
||||
notify_fail "pangolin1 SQLite 备份失败"
|
||||
fi
|
||||
+35
-11
@@ -21,7 +21,7 @@ jobs:
|
||||
# ── Job 1: Lint (shellcheck) ─────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint — shellcheck
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -54,12 +54,25 @@ jobs:
|
||||
/mnt/scripts/ci/compile-backend.sh \
|
||||
/mnt/scripts/ci/release-server.sh \
|
||||
/mnt/scripts/ci/deploy-server.sh \
|
||||
/mnt/scripts/ci/test.sh
|
||||
/mnt/scripts/ci/test.sh \
|
||||
/mnt/scripts/ci/backup-db.sh \
|
||||
/mnt/scripts/ci/compile-android.sh \
|
||||
/mnt/scripts/ci/compile-windows.sh \
|
||||
/mnt/scripts/ci/release-client.sh \
|
||||
/mnt/scripts/ci/deploy-client.sh
|
||||
|
||||
- name: shellcheck CI 脚本(ci/)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD/ci:/mnt/ci:ro" \
|
||||
koalaman/shellcheck:stable \
|
||||
-S warning \
|
||||
/mnt/ci/scan-cleartext.sh
|
||||
|
||||
# ── Job 2: OpenAPI Sync Check ────────────────────────────────────────────
|
||||
openapi-check:
|
||||
name: OpenAPI Sync Check
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -76,7 +89,7 @@ jobs:
|
||||
# ── Job 3: Redline Word Scan (脱敏) ──────────────────────────────────────
|
||||
redline-scan:
|
||||
name: Redline Scan — 脱敏 (UI 文案)
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -84,10 +97,21 @@ jobs:
|
||||
- name: scan UI text resources for prohibited words
|
||||
run: bash ci/scan-redline.sh
|
||||
|
||||
# ── Job 3b: Cleartext Scan (Android 禁全局明文,#25 控制面已 https) ──────
|
||||
cleartext-scan:
|
||||
name: Cleartext Scan — Android 禁明文
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: scan Android manifest for global cleartext
|
||||
run: bash ci/scan-cleartext.sh
|
||||
|
||||
# ── Job 4: Flutter 客户端(分析 + 单测/组件测试)────────────────────────
|
||||
flutter-client:
|
||||
name: Flutter — analyze + test
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -110,7 +134,7 @@ jobs:
|
||||
# 规则与豁免见 ci/scan-portable-sql.sh 头注 + docs/dev-conventions.html 支柱 3。
|
||||
portable-sql-scan:
|
||||
name: Portable SQL — 可移植性 (mysql/sqlite)
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -123,7 +147,7 @@ jobs:
|
||||
# (改了 design/colors_and_type.css 没重生成,或手改了生成物)。
|
||||
codegen-drift:
|
||||
name: Codegen Drift — token 生成物未漂移
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -140,7 +164,7 @@ jobs:
|
||||
# -tags integration(需 docker 起 mysql/redis),见 go-integration job。
|
||||
go-server:
|
||||
name: Go — build + test
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -166,7 +190,7 @@ jobs:
|
||||
# 详见 scripts/e2e-smoke.sh + server/test/e2e/。
|
||||
e2e-smoke:
|
||||
name: E2E Smoke — L4 进程级端到端
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -190,7 +214,7 @@ jobs:
|
||||
# · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。
|
||||
go-integration:
|
||||
name: Go — integration (mysql/redis testcontainers)
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -205,7 +229,7 @@ jobs:
|
||||
# tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。
|
||||
golden:
|
||||
name: Golden — 视觉回归 (components + auth)
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Deploy Client
|
||||
|
||||
# Mirrors ~/code/jiu/.gitea/workflows/deploy-client.yml's tag→build→release→
|
||||
# deploy shape, trimmed to the two platforms drafted so far (Android +
|
||||
# Windows — no client-web/macOS/iOS jobs; pangolin has no Flutter-web build
|
||||
# in this pipeline and macOS/iOS are a separate not-yet-drafted phase, see
|
||||
# docs/superpowers/plans/2026-07-05-cicd.md Phase 3).
|
||||
#
|
||||
# TODO(controller) — RUNNER AVAILABILITY: per docs/ci-runner.md, pangolin
|
||||
# currently has exactly ONE registered Gitea Actions runner
|
||||
# ("mac-pangolin-2", label `nas:host`). Neither `runs-on: mac` nor
|
||||
# `runs-on: windows` below has any runner registered to pick it up yet — this
|
||||
# workflow will queue forever until that's fixed. Options: (a) register
|
||||
# mac-pangolin-2 with an additional `mac` label (it's already a mac host —
|
||||
# cheapest fix for build-android/release-deploy) and separately stand up +
|
||||
# register an actual Windows host runner labeled `windows` for build-windows
|
||||
# (no such machine exists per docs/ci-runner.md), or (b) repoint both at
|
||||
# `nas` and accept that Android/Windows builds then compete with the
|
||||
# docker-in-domain nas jobs on the same single mac host. This mirrors the
|
||||
# `runs-on: mac` / `runs-on: windows` split already planned in
|
||||
# docs/superpowers/plans/2026-07-05-cicd.md Task 7/10 — written that way here
|
||||
# for fidelity to that plan, NOT because the runners are confirmed to exist.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'client-v[0-9]*.[0-9]*.[0-9]*'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-client
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: mac
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compile (Android APK)
|
||||
env:
|
||||
RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci/compile-android.sh "$REF_NAME"
|
||||
|
||||
- name: Upload android artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: android
|
||||
path: dist/
|
||||
|
||||
build-windows:
|
||||
runs-on: windows
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compile (Windows installer)
|
||||
shell: bash
|
||||
env:
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci/compile-windows.sh "$REF_NAME"
|
||||
|
||||
- name: Upload windows artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows
|
||||
path: dist/
|
||||
|
||||
release-deploy:
|
||||
needs: [build-android, build-windows]
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 一次性下所有 artifact(不带 name),避免同 job 内两次复用 download-artifact
|
||||
# action → act 对其只读缓存 git 仓库做二次操作时 EACCES(pack idx 444)。
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: dist-raw/
|
||||
|
||||
- name: Flatten artifacts into dist/
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
find dist-raw -type f -exec cp {} dist/ \;
|
||||
echo "dist/ 内容:"; ls -la dist/
|
||||
|
||||
- name: Release → Forgejo
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci/release-client.sh "$REF_NAME"
|
||||
|
||||
- name: Deploy → pangolin1 (downloads/)
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci/deploy-client.sh "$REF_NAME"
|
||||
|
||||
- name: Notify
|
||||
if: always()
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
. scripts/ci/notify.sh
|
||||
if [ "$JOB_STATUS" = "success" ]; then
|
||||
notify_ok "client $REF_NAME released + deployed"
|
||||
else
|
||||
notify_fail "client $REF_NAME pipeline failed"
|
||||
fi
|
||||
@@ -12,18 +12,34 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-server:
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compile (Go 控制面, golang:1.25 容器内构建)
|
||||
# runner 镜像(catthehacker ubuntu:act-latest,label ubuntu-latest)自带 node
|
||||
# 但**不带 go** → 直接 `go build` 会 `go: command not found`(exit 127)。
|
||||
# 故先装 go:从 Go 官方**中国镜像** golang.google.cn 取(go.dev 在墙内不稳),
|
||||
# 版本对齐 server/go.mod 的 1.25.10;装到 /usr/local/go 并加进 $GITHUB_PATH
|
||||
# 供后续 Compile/Test 步骤共用。模块下载仍走 GOPROXY=goproxy.cn(见 _env.sh)。
|
||||
- name: Setup Go 1.25.10(CN 镜像)
|
||||
run: |
|
||||
mkdir -p "$HOME/.cache/pangolin-ci/gomod" "$HOME/.cache/pangolin-ci/gobuild"
|
||||
docker run --rm -v "$PWD:/w" -w /w \
|
||||
-v "$HOME/.cache/pangolin-ci/gomod:/go/pkg/mod" \
|
||||
-v "$HOME/.cache/pangolin-ci/gobuild:/root/.cache/go-build" \
|
||||
golang:1.25 bash scripts/ci/compile-backend.sh
|
||||
GO_VER=1.25.10
|
||||
# 供应链完整性:校验 sha256(取自 Go 官方 release JSON,pin 为字面量),
|
||||
# 防镜像被篡改/MITM 注入恶意工具链(它会编译要上生产的二进制)。校验失败即中止。
|
||||
GO_SHA256=42d4f7a32316aa66591eca7e89867256057a4264451aca10570a715b3637ba70
|
||||
curl -fsSL --max-time 180 --retry 3 --retry-delay 5 --retry-connrefused \
|
||||
"https://golang.google.cn/dl/go${GO_VER}.linux-amd64.tar.gz" -o /tmp/go.tgz
|
||||
echo "${GO_SHA256} /tmp/go.tgz" | sha256sum -c -
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tgz
|
||||
echo "/usr/local/go/bin" >> "$GITHUB_PATH"
|
||||
export PATH=/usr/local/go/bin:$PATH
|
||||
go version
|
||||
|
||||
# 直接在 runner 跑(不嵌套 docker,避免 DinD 挂载失败;go 由上一步装好)。
|
||||
- name: Compile (Go 控制面)
|
||||
run: bash scripts/ci/compile-backend.sh
|
||||
|
||||
- name: Test (go test)
|
||||
run: bash scripts/ci/test.sh server
|
||||
|
||||
@@ -12,22 +12,20 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-site:
|
||||
runs-on: nas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compile (Astro 官网, node:20 容器内构建)
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/w" -w /w \
|
||||
-e SITE_URL=https://pangolin.yanmeiai.com \
|
||||
node:20 bash scripts/ci/compile-site.sh
|
||||
# runner 镜像 catthehacker/ubuntu:act-latest 自带 node/npx,直接跑;
|
||||
# 不用嵌套 docker run(job 容器内的 $PWD 在宿主上不存在,DinD 挂载会失败)。
|
||||
- name: Compile (Astro 官网)
|
||||
env:
|
||||
SITE_URL: https://pangolin.yanmeiai.com
|
||||
run: bash scripts/ci/compile-site.sh
|
||||
|
||||
- name: Deploy → Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/w" -w /w \
|
||||
-e CLOUDFLARE_API_TOKEN -e CLOUDFLARE_ACCOUNT_ID \
|
||||
node:20 bash scripts/ci/deploy-site.sh
|
||||
run: bash scripts/ci/deploy-site.sh
|
||||
|
||||
@@ -56,6 +56,14 @@ client/android/app/src/main/java/
|
||||
client/ios/Flutter/ephemeral/
|
||||
client/pubspec.lock
|
||||
|
||||
# Android release 签名材料(本地或 CI 落盘,绝不入库)
|
||||
client/android/key.properties
|
||||
client/android/*.jks
|
||||
client/android/*.keystore
|
||||
|
||||
# CI 客户端产物暂存目录(scripts/ci/compile-{android,windows}.sh 输出,构建期生成)
|
||||
/dist/
|
||||
|
||||
# Claude worktrees(临时工作目录,本地专用)
|
||||
.claude/worktrees/
|
||||
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"account_id": "e585821c881c4cd23bc2530986edea9e",
|
||||
"project_name": "pangolin-site"
|
||||
}
|
||||
@@ -48,6 +48,10 @@ sudo VPS_IP=<公网IP> bash deploy/single-node/deploy.sh
|
||||
- 二进制:`cmd/{server,agent,nodectl,migrate}`;`go build ./...` 直接编译,免确认。
|
||||
- 控制面 HTTP API(`:8080`)+ gRPC agent 服务(`:9443`, mTLS);agent 自 enroll → 渲染
|
||||
sing-box 配置 → `systemctl restart sing-box`。客户端连节点真实出网。
|
||||
- **控制面 API 对外经 Cloudflare Tunnel**:`https://api.yanmeiai.com`(cloudflared 出站隧道,
|
||||
不监听入站端口)→ 源站 `pangolin-server` 只绑 `127.0.0.1:8080`,不外露、不放行防火墙。
|
||||
数据面 sing-box REALITY 仍独占入站 `:443`(未改动);gRPC agent mTLS 仍 `:9443`。详见
|
||||
`docs/control-plane-tls-tunnel.html`。
|
||||
|
||||
### 数据层:多数据库(一个环境变量切换)
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ fi
|
||||
|
||||
# ── 下载二进制压缩包 ──────────────────────────────────────────────────────────
|
||||
printf '==> 下载 %s…\n' "${ARCHIVE_FILE}"
|
||||
curl -fSL --retry 3 --retry-delay 2 \
|
||||
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
|
||||
-o "${ARCHIVE_CACHE}" \
|
||||
"${ARCHIVE_URL}"
|
||||
|
||||
@@ -197,7 +197,7 @@ if [[ "${TARGET_OS}" == "windows" ]]; then
|
||||
if [[ "${FORCE}" == false && -f "${WINTUN_OUT}" ]]; then
|
||||
printf '✓ wintun.dll 已存在,跳过(传 --force 重新下载)\n'
|
||||
else
|
||||
curl -fSL --retry 3 --retry-delay 2 \
|
||||
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
|
||||
-o "${WINTUN_ZIP_CACHE}" \
|
||||
"${WINTUN_URL}"
|
||||
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# scan-cleartext.sh — 禁止 Android manifest 重新开启全局明文(控制面已 https/CF Tunnel)。
|
||||
# usesCleartextTraffic="true" 会让全 app 允许明文 HTTP,退回 #25 之前的不安全态。
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="client/android/app/src/main/AndroidManifest.xml"
|
||||
if grep -q 'usesCleartextTraffic="true"' "$MANIFEST"; then
|
||||
echo "❌ $MANIFEST 含 usesCleartextTraffic=\"true\":控制面已 https,禁止全局明文。" >&2
|
||||
echo " 如个别调试域名确需明文,请用 res/xml/network_security_config.xml 按域白名单,勿开全局。" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Android manifest 未开启全局明文"
|
||||
@@ -22,6 +22,19 @@ if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
// ── release 签名(key.properties 不入库,由本地或 CI 生成)────────────────
|
||||
// 缺失时回退 debug 签名,保证本地 `flutter run --release`/无密钥环境仍可构建。
|
||||
// CI 侧由 scripts/ci/compile-android.sh 落盘:client/android/key.properties
|
||||
// (storeFile/storePassword/keyAlias/keyPassword)。
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
def hasReleaseSigning = keystorePropertiesFile.exists()
|
||||
if (hasReleaseSigning) {
|
||||
keystorePropertiesFile.withReader('UTF-8') { reader ->
|
||||
keystoreProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.pangolin.pangolin_vpn"
|
||||
compileSdkVersion flutter.compileSdkVersion
|
||||
@@ -49,9 +62,20 @@ android {
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseSigning) {
|
||||
release {
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
signingConfig hasReleaseSigning ? signingConfigs.release : signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
<application
|
||||
android:label="穿山甲"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true"><!-- 控制面 API 当前为 http(联调),Android 9+ 默认禁明文,需开;生产改 https 后可去掉 -->
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
|
||||
<!-- 主 Activity -->
|
||||
<activity
|
||||
|
||||
@@ -148,6 +148,14 @@ abstract class AppText {
|
||||
String get killSwitch;
|
||||
String get killSwitchSub;
|
||||
String get checkUpdate;
|
||||
String get webUserCenter; // 「用户中心(网页)」入口(App→Web SSO 免登)
|
||||
// 更新检查(设置页「检查更新」手动触发)
|
||||
String updateAvailableTitle(String version); // 「发现新版本 vX.Y.Z」
|
||||
String get updateNotesFallback; // 无 release_notes 时的兜底文案
|
||||
String get updateLater;
|
||||
String get updateDownload;
|
||||
String get updateUpToDate; // 检查后:已是最新版本
|
||||
String get updateCheckFailed; // 检查失败(网络异常)
|
||||
|
||||
// ── 套餐选择 ──
|
||||
String get choosePlan;
|
||||
|
||||
@@ -235,6 +235,20 @@ class StringsEn extends AppText {
|
||||
String get killSwitchSub => 'Block traffic if the link drops';
|
||||
@override
|
||||
String get checkUpdate => 'Check for updates';
|
||||
@override
|
||||
String get webUserCenter => 'User Center (Web)';
|
||||
@override
|
||||
String updateAvailableTitle(String version) => 'New version v$version available';
|
||||
@override
|
||||
String get updateNotesFallback => 'This update includes fixes and stability improvements.';
|
||||
@override
|
||||
String get updateLater => 'Later';
|
||||
@override
|
||||
String get updateDownload => 'Download update';
|
||||
@override
|
||||
String get updateUpToDate => 'You are up to date';
|
||||
@override
|
||||
String get updateCheckFailed => 'Update check failed, try again later';
|
||||
|
||||
@override
|
||||
String get choosePlan => 'Choose plan';
|
||||
|
||||
@@ -234,6 +234,20 @@ class StringsZh extends AppText {
|
||||
String get killSwitchSub => '断线时阻断网络,防止泄露';
|
||||
@override
|
||||
String get checkUpdate => '检查更新';
|
||||
@override
|
||||
String get webUserCenter => '用户中心(网页)';
|
||||
@override
|
||||
String updateAvailableTitle(String version) => '发现新版本 v$version';
|
||||
@override
|
||||
String get updateNotesFallback => '本次更新修复了一些问题并提升了稳定性,建议尽快更新。';
|
||||
@override
|
||||
String get updateLater => '稍后';
|
||||
@override
|
||||
String get updateDownload => '下载更新';
|
||||
@override
|
||||
String get updateUpToDate => '已是最新版本';
|
||||
@override
|
||||
String get updateCheckFailed => '检查更新失败,请稍后重试';
|
||||
|
||||
@override
|
||||
String get choosePlan => '选择套餐';
|
||||
|
||||
@@ -9,9 +9,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../services/web_launch.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/settings_provider.dart';
|
||||
import '../state/update_provider.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/pangolin_toast.dart';
|
||||
import '../widgets/update_dialog.dart';
|
||||
|
||||
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
||||
/// 有更新则弹 [showUpdateDialog]。
|
||||
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
|
||||
final info = await ref.read(updateCheckerProvider).check();
|
||||
if (!context.mounted) return;
|
||||
if (info == null) {
|
||||
showPangolinToast(context, t.updateCheckFailed);
|
||||
return;
|
||||
}
|
||||
if (!info.hasUpdate) {
|
||||
showPangolinToast(context, t.updateUpToDate);
|
||||
return;
|
||||
}
|
||||
await showUpdateDialog(context, t, info);
|
||||
}
|
||||
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
@@ -59,7 +79,8 @@ class SettingsPage extends ConsumerWidget {
|
||||
right: sw(isDark, (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light),
|
||||
),
|
||||
_Row(title: t.protocol, right: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
|
||||
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () {}),
|
||||
_Row(title: t.webUserCenter, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)),
|
||||
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () => _checkForUpdate(context, ref, t)),
|
||||
_Row(title: 'Version', last: true, right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//
|
||||
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
|
||||
// 由 --dart-define=PANGOLIN_API_URL 注入。
|
||||
// TODO(联调临时): 默认值改成测试节点,避免 release 构建漏传 dart-define;发版前改回 localhost 或正式控制面域名。
|
||||
// 控制面 API 基址(单源,全端 providers 共用)。默认走 CF Tunnel 的 https 域名;
|
||||
// 本地联调可 --dart-define=PANGOLIN_API_URL=http://127.0.0.1:8080 覆盖。
|
||||
const String kApiBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'http://103.119.13.48:8080',
|
||||
defaultValue: 'https://api.yanmeiai.com',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// web_launch.dart — App→Web 单点登录跳转(SSO 换票)。
|
||||
//
|
||||
// 「用户中心(网页)」入口:先向控制面签一张短时单次票据
|
||||
// (POST /v1/auth/web-ticket,需登录),再打开 用户中心网页版 的
|
||||
// /sso?t=<票>&redirect=<路径> 落地页兑票登录——避免用户在网页端重新输入密码。
|
||||
// 签票失败(未登录/网络异常)时降级为直接打开目标页(未登录态浏览)。
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../state/account_providers.dart';
|
||||
|
||||
/// 用户中心(网页版)基址。
|
||||
const String kWebUserCenterBaseUrl = 'https://app.yanmeiai.com';
|
||||
|
||||
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
|
||||
Future<void> openWebUserCenter(WidgetRef ref, {String path = '/'}) async {
|
||||
Uri target = Uri.parse('$kWebUserCenterBaseUrl$path');
|
||||
try {
|
||||
final resp = await ref.read(apiClientProvider).postJson('/v1/auth/web-ticket');
|
||||
final ticket = resp['ticket'] as String?;
|
||||
if (ticket != null && ticket.isNotEmpty) {
|
||||
target = Uri.parse(
|
||||
'$kWebUserCenterBaseUrl/sso?t=$ticket&redirect=${Uri.encodeComponent(path)}');
|
||||
}
|
||||
} catch (_) {
|
||||
// 签票失败(未登录/网络异常)降级:直接打开目标页(未登录态浏览)。
|
||||
}
|
||||
await launchUrl(target, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// update_provider.dart — 应用更新检查(轻量版,无后台轮询)。
|
||||
//
|
||||
// 手动触发(设置页「检查更新」)向控制面 GET $kApiBaseUrl/version 拉取最新版本信息,
|
||||
// 与本地版本(package_info_plus)做语义比较;有更新则由调用方(settings_page)弹窗
|
||||
// 展示(见 widgets/update_dialog.dart)。检查失败静默返回 null,不影响主流程。
|
||||
//
|
||||
// 对照 jiu client/lib/providers/update_provider.dart,去掉了定时轮询 Timer 与
|
||||
// dismiss 状态——按需求这里只做「手动检查」,启动期自动检查留作后续 TODO。
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../services/api_config.dart';
|
||||
|
||||
/// 一次更新检查的结果。
|
||||
class AppUpdateInfo {
|
||||
const AppUpdateInfo({
|
||||
required this.latestVersion,
|
||||
required this.buildNumber,
|
||||
required this.forceUpdate,
|
||||
required this.releaseNotes,
|
||||
required this.downloadUrls,
|
||||
required this.hasUpdate,
|
||||
});
|
||||
|
||||
final String latestVersion;
|
||||
final int buildNumber;
|
||||
final bool forceUpdate;
|
||||
final String releaseNotes;
|
||||
final Map<String, String> downloadUrls;
|
||||
final bool hasUpdate;
|
||||
}
|
||||
|
||||
/// 无状态更新检查服务,供设置页「检查更新」按钮直接调用。
|
||||
final updateCheckerProvider = Provider<UpdateChecker>((ref) => const UpdateChecker());
|
||||
|
||||
class UpdateChecker {
|
||||
const UpdateChecker();
|
||||
|
||||
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
|
||||
Future<AppUpdateInfo?> check() async {
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse('$kApiBaseUrl/version'))
|
||||
.timeout(const Duration(seconds: 8));
|
||||
if (resp.statusCode != 200) return null;
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
|
||||
final latestVersion = data['version'] as String? ?? '0.0.0';
|
||||
final buildNumber = (data['build_number'] as num?)?.toInt() ?? 0;
|
||||
final forceUpdate = data['force_update'] as bool? ?? false;
|
||||
final releaseNotes = data['release_notes'] as String? ?? '';
|
||||
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
|
||||
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
|
||||
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final hasUpdate = _isNewer(latestVersion, info.version);
|
||||
|
||||
return AppUpdateInfo(
|
||||
latestVersion: latestVersion,
|
||||
buildNumber: buildNumber,
|
||||
forceUpdate: forceUpdate,
|
||||
releaseNotes: releaseNotes,
|
||||
downloadUrls: downloadUrls,
|
||||
hasUpdate: hasUpdate,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 语义化版本比较:latest > current → true。容忍 1.1.4-dev / 1.1.4+7 等后缀。
|
||||
bool _isNewer(String latest, String current) {
|
||||
final l = _parse(latest);
|
||||
final c = _parse(current);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (l[i] > c[i]) return true;
|
||||
if (l[i] < c[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<int> _parse(String v) {
|
||||
final parts = v.split('.').map((s) {
|
||||
final m = RegExp(r'^\d+').firstMatch(s);
|
||||
return m == null ? 0 : int.parse(m.group(0)!);
|
||||
}).toList();
|
||||
while (parts.length < 3) {
|
||||
parts.add(0);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
||||
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
||||
if (Platform.isMacOS) return downloadUrls['macos'];
|
||||
if (Platform.isWindows) return downloadUrls['windows'];
|
||||
if (Platform.isIOS) return downloadUrls['ios'];
|
||||
if (Platform.isAndroid) return downloadUrls['android'];
|
||||
return downloadUrls['web'];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// update_dialog.dart — 「发现新版本」更新提示弹窗。
|
||||
//
|
||||
// 只做「展示 + 引导下载」:点「下载更新」用 url_launcher 打开对应平台的下载直链
|
||||
// (浏览器下载),不做应用内静默安装/自动重启。force_update=true 时不可关闭
|
||||
// (无「稍后」按钮、点遮罩/返回也关不掉)。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/update_provider.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
/// 展示更新弹窗。调用方(设置页「检查更新」)在拿到 `info.hasUpdate == true` 时调用。
|
||||
Future<void> showUpdateDialog(BuildContext context, AppText t, AppUpdateInfo info) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: !info.forceUpdate,
|
||||
builder: (ctx) => PopScope(
|
||||
canPop: !info.forceUpdate,
|
||||
child: _UpdateDialog(t: t, info: info),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _UpdateDialog extends StatelessWidget {
|
||||
const _UpdateDialog({required this.t, required this.info});
|
||||
final AppText t;
|
||||
final AppUpdateInfo info;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return AlertDialog(
|
||||
backgroundColor: c.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
|
||||
title: Row(children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(color: c.accentSubtle, shape: BoxShape.circle),
|
||||
child: Icon(PangolinIcons.zap, size: 18, color: c.accent),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.updateAvailableTitle(info.latestVersion),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
]),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
info.releaseNotes.isEmpty ? t.updateNotesFallback : info.releaseNotes,
|
||||
style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (!info.forceUpdate)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(t.updateLater, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final url = platformDownloadUrl(info.downloadUrls);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ dependencies:
|
||||
flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + 稳定 device_id 持久化
|
||||
shared_preferences: ^2.5.5
|
||||
package_info_plus: ^9.0.1
|
||||
url_launcher: ^6.3.0 # 打开外部链接(用户中心 SSO 免登 / 更新下载页)
|
||||
device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报)
|
||||
uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4)
|
||||
launch_at_startup: ^0.5.1
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/services/api_config.dart';
|
||||
|
||||
void main() {
|
||||
test('控制面基址默认走 https(禁止回退明文 http)', () {
|
||||
expect(kApiBaseUrl, startsWith('https://'),
|
||||
reason: '控制面已迁 CF Tunnel(api.yanmeiai.com);默认值不得是明文 http');
|
||||
expect(kApiBaseUrl, isNot(contains('103.119.13.48')),
|
||||
reason: '不得再硬编码节点 IP 作控制面基址');
|
||||
});
|
||||
}
|
||||
@@ -164,7 +164,7 @@ cat > "$ETC/server.env" <<EOF
|
||||
DB_DRIVER=sqlite
|
||||
DB_DSN=$DB_FILE
|
||||
REDIS_ADDR=127.0.0.1:6379
|
||||
ADDR=:$HTTP_PORT
|
||||
ADDR=127.0.0.1:$HTTP_PORT
|
||||
JWT_PRIVATE_KEY_PATH=$ETC/jwt_private.pem
|
||||
JWT_KEY_ID=node-key-1
|
||||
JWT_PUBLIC_KEYS=node-key-1:$ETC/jwt_public.pem
|
||||
@@ -175,8 +175,10 @@ CA_KEY_PATH=$ETC/ca.key
|
||||
CA_CERT_PATH=$ETC/ca.crt
|
||||
GRPC_CERT_PATH=$ETC/grpc.crt
|
||||
GRPC_KEY_PATH=$ETC/grpc.key
|
||||
PANGOLIN_PUBLIC_URL=http://$VPS_IP:$HTTP_PORT
|
||||
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
|
||||
PANGOLIN_RULES_DIR=$DATA_DIR/rules
|
||||
DOWNLOADS_DIR=$DATA_DIR/downloads
|
||||
VERSION_MANIFEST=$ETC/version.yaml
|
||||
EOF
|
||||
if [ -n "${SMTP_HOST:-}" ]; then
|
||||
cat >> "$ETC/server.env" <<EOF
|
||||
@@ -201,6 +203,24 @@ curl -fsSL -o "$RULES_DIR/geosite-cn.srs" \
|
||||
https://github.com/SagerNet/sing-geosite/raw/rule-set/geosite-cn.srs \
|
||||
|| log "WARN: geosite-cn.srs 拉取失败,国内分流将不可用"
|
||||
|
||||
# ── 6c. 客户端安装包下载目录 ──────────────────────────────────────────────────
|
||||
# scripts/ci/deploy-client.sh 把最新 pangolin-android.apk /
|
||||
# pangolin-windows-x64-setup.exe scp 到这里(每平台只保留最新一份),控制面
|
||||
# GET /downloads/<file> 静态服务、官网下载按钮直链。这里先建目录占位(CI 首次
|
||||
# 部署前跑本脚本也不会因目录缺失而 404 时找不到目录本身;DownloadsHandler 本身
|
||||
# 即便目录不存在也能正常注册路由,只是请求会 404)。
|
||||
DOWNLOADS_DIR="$DATA_DIR/downloads"
|
||||
install -d -m 755 "$DOWNLOADS_DIR"
|
||||
|
||||
# ── 6d. 客户端自动更新版本清单 ─────────────────────────────────────────────────
|
||||
# GET /version(公开、免鉴权)按 VERSION_MANIFEST(见上 server.env)读取该文件;
|
||||
# scripts/ci/release-client.sh 每次 client-v* 发版都会以本仓库这份文件为模板,
|
||||
# 改写 version/build_number 后 SSH 推到这个路径覆盖 —— 幂等重跑本脚本不应该把
|
||||
# 已发布的最新版本号退回仓库里的默认值,所以文件已存在时不覆盖。
|
||||
if [ ! -f "$ETC/version.yaml" ]; then
|
||||
install -m 644 "$HERE/version.yaml" "$ETC/version.yaml"
|
||||
fi
|
||||
|
||||
# ── 7. 迁移 + seed(SQLite)────────────────────────────────────────────────────
|
||||
log "执行迁移(sqlite)..."
|
||||
DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up
|
||||
@@ -257,6 +277,24 @@ sed "s#/usr/local/bin/sing-box#${SB_BIN}#g" \
|
||||
chmod 644 /etc/systemd/system/sing-box.service
|
||||
install -d -m 755 /etc/polkit-1/rules.d
|
||||
install -m 644 "$HERE/polkit/49-pangolin-singbox.rules" /etc/polkit-1/rules.d/
|
||||
|
||||
# ── cloudflared(控制面 API 出站隧道,Cloudflare apt 源)─────────────────────────
|
||||
if ! command -v cloudflared >/dev/null 2>&1; then
|
||||
log "安装 cloudflared(Cloudflare apt 源)..."
|
||||
install -m 0755 -d /usr/share/keyrings
|
||||
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
|
||||
-o /usr/share/keyrings/cloudflare-main.gpg
|
||||
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' \
|
||||
> /etc/apt/sources.list.d/cloudflared.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq cloudflared
|
||||
fi
|
||||
# apt 固定装到 /usr/bin;软链到 unit 期望的 /usr/local/bin(与其他 pangolin 二进制一致)。
|
||||
CFD_BIN=/usr/bin/cloudflared
|
||||
[ -x "$CFD_BIN" ] || die "cloudflared 安装失败:$CFD_BIN 不存在。"
|
||||
ln -sf "$CFD_BIN" /usr/local/bin/cloudflared
|
||||
install -m 644 "$HERE/systemd/cloudflared.service" /etc/systemd/system/
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
log "启动控制面(pangolin-server,以 $PUSER 运行)..."
|
||||
@@ -269,17 +307,22 @@ log "启用 sing-box + 启动 agent ..."
|
||||
systemctl enable sing-box.service
|
||||
systemctl enable --now pangolin-agent.service
|
||||
|
||||
# 放行控制面 API 端口(若 ufw 启用)。⚠️ 明文,生产应前置 TLS。
|
||||
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
ufw allow "${HTTP_PORT}/tcp" >/dev/null 2>&1 || true
|
||||
log "ufw 放行 ${HTTP_PORT}/tcp(控制面 API,明文)"
|
||||
# 控制面 API 已绑 127.0.0.1(经 cloudflared 隧道对外),不放行 8080/tcp。
|
||||
|
||||
# ── cloudflared:按需启用(需 /etc/pangolin/cloudflared.env 提供 TUNNEL_TOKEN)───
|
||||
if [ -f /etc/pangolin/cloudflared.env ]; then
|
||||
log "启用 cloudflared(检测到 /etc/pangolin/cloudflared.env)..."
|
||||
systemctl enable --now cloudflared.service
|
||||
else
|
||||
log "cloudflared 已安装但未启动:等 /etc/pangolin/cloudflared.env(TUNNEL_TOKEN)就绪后执行:"
|
||||
log " systemctl enable --now cloudflared.service"
|
||||
fi
|
||||
|
||||
# ── 11. 摘要 ──────────────────────────────────────────────────────────────────
|
||||
log "完成。单机栈已起(SQLite + pangolin 用户)。"
|
||||
cat <<EOF
|
||||
|
||||
控制面 API : http://$VPS_IP:$HTTP_PORT (⚠️ 明文;防火墙需放行 ${HTTP_PORT}/tcp)
|
||||
控制面 API : https://api.yanmeiai.com (CF Tunnel;源站 127.0.0.1:$HTTP_PORT,不外露/不放行)
|
||||
Node uuid : $NODE_UUID
|
||||
Endpoint : $ENDPOINT SNI=$REALITY_SNI (REALITY ${REALITY_PORT}/tcp)
|
||||
REALITY pbk : $REALITY_PBK
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Pangolin cloudflared (control-plane API tunnel → 127.0.0.1:8080)
|
||||
Documentation=https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
|
||||
After=network-online.target pangolin-server.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
# TUNNEL_TOKEN 从此文件注入(不入 unit 本体、不进 ps);cloudflared 自动读取 env TUNNEL_TOKEN。
|
||||
EnvironmentFile=/etc/pangolin/cloudflared.env
|
||||
ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel run
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# 出站隧道,无需 root:用非特权用户即可(与 pangolin-server 同用户)。
|
||||
User=pangolin
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,22 @@
|
||||
# version.yaml — 客户端自动更新版本清单(committed default)。
|
||||
#
|
||||
# deploy/single-node/deploy.sh 把这份文件安装到 /etc/pangolin/version.yaml
|
||||
# (VERSION_MANIFEST 默认路径,见 server/internal/httpapi/version.go)。
|
||||
# scripts/ci/release-client.sh 在每次 client-v* 发版时,以这份文件为模板改写
|
||||
# version / build_number 字段,再原样(SSH)推到 pangolin1 的
|
||||
# /etc/pangolin/version.yaml —— 控制面每次请求都重新读该文件,发版立即生效,
|
||||
# 不需要重启/重新部署控制面。
|
||||
#
|
||||
# download_urls 目前是固定「仅保留最新一份」的稳定 URL(deploy-client.sh 每次
|
||||
# 用同名文件覆盖),不是按版本变化的路径,因此这里不需要随发版改写;
|
||||
# macos / ios 产物尚未产出,先留空字符串。
|
||||
version: "1.0.48"
|
||||
build_number: 10048
|
||||
force_update: false
|
||||
release_notes: ""
|
||||
download_urls:
|
||||
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
|
||||
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
|
||||
macos: ""
|
||||
ios: ""
|
||||
changelog: []
|
||||
@@ -0,0 +1,136 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pangolin 控制面 TLS(Cloudflare Tunnel 前置)实现计划</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:24px 0 8px;color:var(--fg)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.86em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:#cdd3df}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:14px 0}
|
||||
.card h3{margin-top:0;color:var(--accent2)}
|
||||
.files{font-family:var(--mono);font-size:12px;color:var(--fg2);margin:6px 0 10px}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
ul,ol{padding-left:22px;margin:8px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
.phase{font-size:19px;margin:40px 0 6px;color:var(--accent);font-weight:700}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 文档索引</a>
|
||||
<h1>Pangolin 控制面 TLS(Cloudflare Tunnel 前置)实现计划</h1>
|
||||
<p class="sub">2026-07-06 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</code>(带 checkbox)</p>
|
||||
|
||||
<div class="lead">
|
||||
<b>目标:</b>把 pangolin-server 控制面 API 从明文 <code>http://103.119.13.48:8080</code> 迁到
|
||||
<code>https://api.yanmeiai.com</code>,经 Cloudflare Tunnel 前置(隐藏源站 IP、白嫖标准 443 + 免证书)。
|
||||
数据面 sing-box REALITY <code>:443</code> 全程不动。
|
||||
</div>
|
||||
|
||||
<h2>架构</h2>
|
||||
<p>
|
||||
pangolin1 上跑 <code>cloudflared</code> <b>出站</b>隧道(不监听任何入站端口 → 与 sing-box 独占的
|
||||
<code>:443</code> 零冲突),CF 边缘把 <code>api.yanmeiai.com</code> 的请求经隧道回送到
|
||||
<code>127.0.0.1:8080</code>。客户端(Flutter 四端共享 <code>kApiBaseUrl</code>)默认改 https 域名;
|
||||
控制面下发给客户端 sing-box 的 <code>.srs</code> 规则集下载基址(<code>PANGOLIN_PUBLIC_URL</code>)
|
||||
同步改 https。最后一步把 <code>:8080</code> 收回 loopback 并关防火墙,彻底退役明文口——该步有
|
||||
<b>上线顺序闸</b>(须待现网客户端更新后再做)。
|
||||
</p>
|
||||
|
||||
<h2>端口 / URL 布局</h2>
|
||||
<table>
|
||||
<tr><th>用途</th><th>对外</th><th>源站/绑定</th><th>本轮变更</th></tr>
|
||||
<tr><td>控制面 HTTP API</td><td><code>https://api.yanmeiai.com</code>(CF Tunnel)</td><td><code>127.0.0.1:8080</code></td><td>新增 CF Tunnel 前置 + 收 loopback</td></tr>
|
||||
<tr><td>数据面 sing-box REALITY</td><td><code>:443</code>(节点公网 IP)</td><td>同端口</td><td>不动</td></tr>
|
||||
<tr><td>gRPC agent(mTLS)</td><td>—(仅节点内)</td><td><code>:9443</code></td><td>不动</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>全局约束</h2>
|
||||
<ul>
|
||||
<li>Bash 禁 <code>$()</code> 命令替换、禁 <code>set -a</code>/<code>set +a</code>;需捕获输出拆多步或用管道。</li>
|
||||
<li>凭证走 Bitwarden/rbw,不写 <code>~/.env</code>/明文配置/git。Cloudflare 用 <code>cf-api</code> 封装(token 内部从 Bitwarden 取)。隧道 token 等密钥一律不入 git,只落 <code>/etc/pangolin/*</code>(已 gitignore)+ Bitwarden。</li>
|
||||
<li>改机器(装包/改配置/重启服务)前必须先问用户(只读操作除外)。</li>
|
||||
<li>pangolin1 = <code>103.119.13.48</code>,ssh 别名 <code>pangolin1</code>。数据面 sing-box REALITY 独占入站 <code>:443</code>,<b>不得触碰</b>;gRPC agent mTLS <code>:9443</code> 不动。</li>
|
||||
<li><b>上线顺序铁律:</b>现网客户端硬编码 <code>http://103.119.13.48:8080</code>。隧道与 https 端点必须<b>加法上线</b>(与旧口并存),客户端切 https 发版后,收 loopback 才能做,否则旧客户端全挂。</li>
|
||||
</ul>
|
||||
|
||||
<h2>6 个任务</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 1 · Cloudflare Tunnel 供给</h3>
|
||||
<div class="files">Create: deploy/single-node/systemd/cloudflared.service · Modify: deploy/single-node/deploy.sh</div>
|
||||
CF 账户侧(cf-api)建 remotely-managed 隧道 + ingress(<code>api.yanmeiai.com</code> → <code>http://localhost:8080</code>)+ 代理 CNAME;pangolin1 装 cloudflared(Debian apt 源)+ committed systemd unit(token 经 <code>EnvironmentFile</code> 注入,不入 unit 本体)。验证:<code>https://api.yanmeiai.com/healthz</code> 与旧的 <code>http://103.119.13.48:8080/healthz</code> <b>并存可用</b>(加法,不破坏现网)。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 2 · 客户端控制面基址切 https + Android 去明文</h3>
|
||||
<div class="files">Modify: client/lib/services/api_config.dart · client/android/.../AndroidManifest.xml · Create: client/test/unit/api_config_test.dart</div>
|
||||
先写守护测试(断言 <code>kApiBaseUrl</code> 必须 <code>https://</code> 且不含节点 IP)→ 确认失败 → 把 <code>kApiBaseUrl</code> 默认值改为 <code>https://api.yanmeiai.com</code>(仍保留 <code>String.fromEnvironment</code> 可本地覆盖)→ 测试转绿。同步移除 Android manifest 的 <code>android:usesCleartextTraffic="true"</code>(控制面已 https,不再需要明文豁免;iOS/macOS 无 ATS 配置,无需改动)。跑 <code>flutter analyze</code> + 全量单测。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 3 · CI 守护:Android release manifest 禁明文</h3>
|
||||
<div class="files">Create: ci/scan-cleartext.sh · Modify: .gitea/workflows/ci.yml</div>
|
||||
新增扫描脚本:manifest 一旦重新出现 <code>usesCleartextTraffic="true"</code> 就 CI 失败(防止将来有人把明文开关加回来,退回到 #25 之前的不安全态)。接入 <code>ci.yml</code> 新 job + shellcheck 列表。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 4 · <code>PANGOLIN_PUBLIC_URL</code> 切 https</h3>
|
||||
<div class="files">Modify: deploy/single-node/deploy.sh</div>
|
||||
该变量被嵌进客户端 sing-box 配置当 <code>.srs</code> 分流规则集下载基址(<code>clientconfig.go</code>)。不改的话新客户端仍去明文 IP 拉。改为 <code>https://api.yanmeiai.com</code>;与 Task 1 隧道并存,对新旧客户端都安全(URL 由服务端下发,客户端只是照着 GET)。pangolin1 上应用 + 重启 server,验证规则集经隧道可 200 下载。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 5 · 退役明文口(收 loopback + 关防火墙 + 修健康检查)<span class="tag warn">上线顺序闸</span></h3>
|
||||
<div class="files">Modify: deploy/single-node/deploy.sh · scripts/ci/deploy-server.sh</div>
|
||||
<b>此 Task 会切断外部 <code>http://103.119.13.48:8080</code>,只有当现网客户端都已更新到 Task 2 的 https 版本后才能执行</b>,执行前需与用户确认「旧客户端可弃」。内容:<code>ADDR</code> 收 <code>127.0.0.1:8080</code>;不再 ufw 放行 8080;<code>deploy-server.sh</code> 健康检查从「runner 远程 curl 公网 IP」改为「ssh 内本地 curl loopback」+「经隧道 curl https 域名」双路验证。验证:明文口不可达、隧道仍活、<code>ss</code> 显示 8080 仅监听 <code>127.0.0.1</code>。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 6 · 文档更新(本任务)</h3>
|
||||
<div class="files">Modify: CLAUDE.md · docs/index.html · docs/control-plane-tls-tunnel.html(本页)</div>
|
||||
CLAUDE.md 补充端口/URL 布局说明;生成本 HTML 阅读版并登记 <code>docs/index.html</code>「实现计划」分类;顺带修正 <code>deploy/single-node/deploy.sh</code> 摘要 echo 里残留的旧明文口描述(Task 4/5 落地后的措辞漂移)。
|
||||
</div>
|
||||
|
||||
<h2>上线顺序</h2>
|
||||
<p>
|
||||
Task 1(隧道加法)→ Task 4(<code>PANGOLIN_PUBLIC_URL</code>,新旧客户端皆安全)→ Task 2(客户端切 https,发版)→
|
||||
<b>待客户端更新</b> → Task 5(收口)。Task 3(CI 守护)、Task 6(文档)无顺序耦合,可随时并行推进。
|
||||
</p>
|
||||
|
||||
<h2>不在本轮</h2>
|
||||
<ul class="small">
|
||||
<li>#32 控制面 fallback(CF 域名被 SNI 封 → 客户端退回直连节点 IP 的 https 控制口)。</li>
|
||||
<li>控制面 API 的 CF WAF/rate-limit 规则精调。</li>
|
||||
<li>usercenter(web/usercenter)也接入同域名 API(其部署属 #30 30A)。</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -86,6 +86,11 @@
|
||||
</a>
|
||||
|
||||
<h2>实现计划 / Plans</h2>
|
||||
<a class="doc" href="control-plane-tls-tunnel.html">
|
||||
<div class="t">控制面 TLS(Cloudflare Tunnel 前置)实现计划 <span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</code>(含 checkbox)。把控制面 API 从明文 <code>http://103.119.13.48:8080</code> 迁到 <code>https://api.yanmeiai.com</code>(cloudflared 出站隧道前置,源站仅绑 127.0.0.1,数据面 sing-box REALITY :443 全程不动)。6 任务:CF Tunnel 供给 → 客户端切 https/Android 去明文 → CI 守护禁明文 → PANGOLIN_PUBLIC_URL 切 https → 退役明文口(收 loopback,带上线顺序闸)→ 文档。</div>
|
||||
<div class="path">docs/control-plane-tls-tunnel.html · 真相源 docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</div>
|
||||
</a>
|
||||
<a class="doc" href="cicd-plan.html">
|
||||
<div class="t">CI/CD 全流程 实现计划(#30)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-05-cicd.md</code>(含 checkbox)。三期 11 任务:Phase1 基座+官网+服务端(无签名可立即上线,服务端固化 F3/F4 备份/迁移/回滚) → Phase2 Android(接 release keystore 签名,解锁下载链接) → Phase3 macOS 公证 dmg + Windows 安装包。runner 混合 nas/mac/windows;密钥已建(对齐 jiu)。设计见 cicd-design.html。</div>
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
# 控制面 TLS(Cloudflare Tunnel 前置)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 pangolin-server 控制面 API 从明文 `http://103.119.13.48:8080` 迁到 `https://api.yanmeiai.com`,经 Cloudflare Tunnel 前置(隐藏源站 IP、白嫖标准 443 + 免证书),数据面 sing-box REALITY:443 完全不动。
|
||||
|
||||
**Architecture:** pangolin1 上跑 `cloudflared` **出站**隧道(不监听任何入站端口 → 与 sing-box 独占的 :443 零冲突),CF 边缘把 `api.yanmeiai.com` 的请求经隧道回送到 `127.0.0.1:8080`。客户端(Flutter 四端共享 `kApiBaseUrl`)默认改 https 域名;控制面下发给客户端 sing-box 的 `.srs` 规则集下载基址(`PANGOLIN_PUBLIC_URL`)同步改 https。最后一步把 `:8080` 收回 loopback 并关防火墙,彻底退役明文口——该步有上线顺序闸(须待现网客户端更新后再做)。
|
||||
|
||||
**Tech Stack:** Cloudflare Tunnel(remotely-managed / token 模式)、cloudflared(Debian 12 apt)、systemd、Go(pangolin-server,仅 env 变更零代码)、Flutter/Dart(`api_config.dart`)、Android manifest、Gitea Actions(`deploy-server.sh` 健康检查)、cf-api 封装(Bitwarden token)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Bash 禁 `$()` 命令替换**;禁 `set -a`/`set +a`。需捕获输出拆多步或用管道。
|
||||
- **凭证走 Bitwarden/rbw**,不写 `~/.env`/明文配置/git。Cloudflare 用 `cf-api` 封装(token 脚本内部从 Bitwarden 取,禁引用 `$CF_API_TOKEN`)。**隧道 token、私钥等 PII/密钥一律不入 git**,只落 `/etc/pangolin/*`(已 gitignore)+ Bitwarden。
|
||||
- **改机器(装包/改配置/重启服务)前必须先问用户**(只读操作除外)。本方案 Task 1B/2/5 会 ssh 改 pangolin1 与 CF 账户配置,执行到那几步先征得确认。
|
||||
- 回复中文件路径**一律绝对路径**。
|
||||
- git commit 结尾附:`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` 与 `Claude-Session:` 行;**不 force-push、不推 main**。
|
||||
- CF 账户 id `e585821c881c4cd23bc2530986edea9e`;zone `yanmeiai.com` id `2325730de45276d87180a8b66bd4cca0`。
|
||||
- pangolin1 = `103.119.13.48`,ssh 别名 `pangolin1`(root 免密)。数据面 sing-box REALITY 独占入站 `:443`,**不得触碰**。gRPC agent mTLS `:9443` 不动。
|
||||
- **上线顺序铁律**:现网客户端硬编码 `http://103.119.13.48:8080`。隧道与 https 端点必须**加法上线**(与旧口并存),客户端切 https 发版后,**Task 5(收 loopback + 关防火墙)才能做**,否则旧客户端全挂。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**新建:**
|
||||
- `deploy/single-node/systemd/cloudflared.service` — cloudflared 的 systemd unit(committed,`install -m 644` 到位,token 从 `/etc/pangolin/cloudflared.env` 经 `EnvironmentFile` 注入,不入 unit 本体)。
|
||||
- `client/test/unit/api_config_test.dart` — 守护测试:控制面基址必须 https(防回退明文)。
|
||||
- `ci/scan-cleartext.sh` — CI 守护:Android release manifest 不得含 `usesCleartextTraffic="true"`。
|
||||
|
||||
**修改:**
|
||||
- `deploy/single-node/deploy.sh` — server.env 里 `PANGOLIN_PUBLIC_URL` 改 https(Task 4);`ADDR` 改 loopback + 去掉 ufw 放行 8080(Task 5);新增 cloudflared 安装/enable(Task 1B)。
|
||||
- `client/lib/services/api_config.dart:6-9` — `kApiBaseUrl` 默认值改 `https://api.yanmeiai.com`(Task 3)。
|
||||
- `client/android/app/src/main/AndroidManifest.xml:30` — 移除 `android:usesCleartextTraffic="true"`(Task 3)。
|
||||
- `scripts/ci/deploy-server.sh:57` — 健康检查从「runner 远程 curl `http://IP:8080`」改为 ssh 内本地 `curl http://127.0.0.1:8080/healthz`(Task 5)。
|
||||
- `.gitea/workflows/ci.yml` — shellcheck 列表加 `ci/scan-cleartext.sh`;新增 cleartext-scan job(Task 3)。
|
||||
- `CLAUDE.md` + `docs/` — 端口/URL 布局更新(Task 6)。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Cloudflare Tunnel 供给(CF 账户侧 + pangolin1 装 cloudflared)
|
||||
|
||||
把隧道建起来、DNS 指过去、cloudflared 在 pangolin1 上连通,`https://api.yanmeiai.com/healthz` 与旧的 `http://103.119.13.48:8080/healthz` **并存可用**(加法,不破坏现网)。
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/single-node/systemd/cloudflared.service`
|
||||
- Modify: `deploy/single-node/deploy.sh`(安装/enable cloudflared)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 隧道域名 `https://api.yanmeiai.com` → `127.0.0.1:8080`;隧道 token 存于 Bitwarden item `pangolin-cloudflared-tunnel` 字段 `TUNNEL_TOKEN` + pangolin1 `/etc/pangolin/cloudflared.env`。后续 Task 3/4 依赖此域名可达。
|
||||
|
||||
### 1A — CF 侧:创建隧道 + ingress + DNS(cf-api,只读账户外均属改配置,先确认)
|
||||
|
||||
- [ ] **Step 1: 建 remotely-managed 隧道,取 token**
|
||||
|
||||
先确认 rbw 已解锁(`rbw unlock`)。运行:
|
||||
|
||||
```bash
|
||||
cf-api -X POST "/accounts/e585821c881c4cd23bc2530986edea9e/cfd_tunnel" \
|
||||
--data '{"name":"pangolin-api","config_src":"cloudflare"}'
|
||||
```
|
||||
|
||||
Expected: JSON `success:true`,`result.id`(隧道 UUID)、`result.token`(base64 长串)。**记下 `result.id` 为 `TUNNEL_ID`,`result.token` 为 `TUNNEL_TOKEN`。token 是密钥,不要落 git/明文文档。**
|
||||
|
||||
- [ ] **Step 2: 配 ingress(hostname → 本机 8080,兜底 404)**
|
||||
|
||||
```bash
|
||||
cf-api -X PUT "/accounts/e585821c881c4cd23bc2530986edea9e/cfd_tunnel/<TUNNEL_ID>/configurations" \
|
||||
--data '{"config":{"ingress":[{"hostname":"api.yanmeiai.com","service":"http://localhost:8080"},{"service":"http_status:404"}]}}'
|
||||
```
|
||||
|
||||
Expected: `success:true`,`result.config.ingress` 含上面两条。
|
||||
|
||||
- [ ] **Step 3: 建代理 CNAME `api` → 隧道**
|
||||
|
||||
```bash
|
||||
cf-api -X POST "/zones/2325730de45276d87180a8b66bd4cca0/dns_records" \
|
||||
--data '{"type":"CNAME","name":"api","content":"<TUNNEL_ID>.cfargotunnel.com","proxied":true,"comment":"pangolin 控制面 API(CF Tunnel → pangolin1:8080)"}'
|
||||
```
|
||||
|
||||
Expected: `success:true`,`result.name` = `api.yanmeiai.com`,`result.proxied` = true。
|
||||
|
||||
- [ ] **Step 4: token 存入 Bitwarden(留档)**
|
||||
|
||||
把 `TUNNEL_TOKEN` 存进 Bitwarden item `pangolin-cloudflared-tunnel`(字段 `TUNNEL_TOKEN`)。验证:
|
||||
|
||||
```bash
|
||||
rbw get pangolin-cloudflared-tunnel --field TUNNEL_TOKEN | head -c 12
|
||||
```
|
||||
|
||||
Expected: 打印 token 前 12 字符(证明可取回)。
|
||||
|
||||
### 1B — pangolin1:装 cloudflared + systemd 常驻(改机器,先确认)
|
||||
|
||||
- [ ] **Step 5: 写 committed systemd unit**
|
||||
|
||||
创建 `deploy/single-node/systemd/cloudflared.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Pangolin cloudflared (control-plane API tunnel → 127.0.0.1:8080)
|
||||
Documentation=https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
|
||||
After=network-online.target pangolin-server.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
# TUNNEL_TOKEN 从此文件注入(不入 unit 本体、不进 ps);cloudflared 自动读取 env TUNNEL_TOKEN。
|
||||
EnvironmentFile=/etc/pangolin/cloudflared.env
|
||||
ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel run
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# 出站隧道,无需 root:用非特权用户即可(与 pangolin-server 同用户)。
|
||||
User=pangolin
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
- [ ] **Step 6: deploy.sh 里安装 cloudflared 二进制 + unit + enable**
|
||||
|
||||
在 `deploy/single-node/deploy.sh` 的 systemd 安装段(现有 `install -m 644 .../pangolin-server.service` 一带,约 251-252 行)后追加。先加安装函数(Debian apt,无 `$()`):
|
||||
|
||||
```bash
|
||||
# ── cloudflared(控制面 API 出站隧道)──────────────────────────────
|
||||
if ! command -v cloudflared >/dev/null 2>&1; then
|
||||
log "安装 cloudflared(Cloudflare apt 源)"
|
||||
install -m 0755 -d /usr/share/keyrings
|
||||
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
|
||||
-o /usr/share/keyrings/cloudflare-main.gpg
|
||||
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' \
|
||||
> /etc/apt/sources.list.d/cloudflared.list
|
||||
apt-get update -qq && apt-get install -y -qq cloudflared
|
||||
# apt 装到 /usr/bin;软链到 unit 期望的 /usr/local/bin(与其他 pangolin 二进制一致)。
|
||||
[ -x /usr/local/bin/cloudflared ] || ln -sf "$(command -v cloudflared)" /usr/local/bin/cloudflared
|
||||
fi
|
||||
install -m 644 "$HERE/systemd/cloudflared.service" /etc/systemd/system/
|
||||
```
|
||||
|
||||
> 注:上面为示意锚点;`$(command -v cloudflared)` 违反禁 `$()` 规则——落地时改为:`CFD_BIN=/usr/bin/cloudflared` 后 `ln -sf "$CFD_BIN" /usr/local/bin/cloudflared`(apt 固定装到 `/usr/bin`)。
|
||||
|
||||
- [ ] **Step 7: 在 pangolin1 落 token env 文件 + 起服务**(ssh,改机器,先确认)
|
||||
|
||||
token 经用户剪贴板落地(不经过我、不入 git):
|
||||
|
||||
```bash
|
||||
# 本机把 token 通过 ssh 写到远端受限权限文件(避免出现在 ps/history):
|
||||
rbw get pangolin-cloudflared-tunnel --field TUNNEL_TOKEN | \
|
||||
ssh pangolin1 'install -m 600 -o pangolin -g pangolin /dev/stdin /etc/pangolin/cloudflared.env.tmp && \
|
||||
printf "TUNNEL_TOKEN=" | cat - /etc/pangolin/cloudflared.env.tmp > /etc/pangolin/cloudflared.env && \
|
||||
rm -f /etc/pangolin/cloudflared.env.tmp && chmod 600 /etc/pangolin/cloudflared.env'
|
||||
```
|
||||
|
||||
> 落地时若上面拼接别扭,改为本机 `printf 'TUNNEL_TOKEN=%s\n' "<token>"` 结果 ssh 管道写入;核心要求:`/etc/pangolin/cloudflared.env` 内容为单行 `TUNNEL_TOKEN=<token>`,mode 600,owner pangolin。
|
||||
|
||||
装 unit 并启动:
|
||||
|
||||
```bash
|
||||
scp deploy/single-node/systemd/cloudflared.service pangolin1:/etc/systemd/system/
|
||||
ssh pangolin1 'systemctl daemon-reload && systemctl enable --now cloudflared.service && sleep 3 && systemctl is-active cloudflared'
|
||||
```
|
||||
|
||||
Expected: `active`。
|
||||
|
||||
- [ ] **Step 8: 验证隧道连通(加法上线,不破坏旧口)**
|
||||
|
||||
```bash
|
||||
curl -fsS -m 10 https://api.yanmeiai.com/healthz && echo " <= 隧道 OK"
|
||||
curl -fsS -m 10 http://103.119.13.48:8080/healthz && echo " <= 旧口仍在(预期)"
|
||||
```
|
||||
|
||||
Expected: 两条都返回 `/healthz` 成功体。证明 https 端点上线、旧明文口并存(现网客户端不受影响)。
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/single-node/systemd/cloudflared.service deploy/single-node/deploy.sh
|
||||
git commit -m "feat(deploy): cloudflared 出站隧道前置控制面 API(api.yanmeiai.com→127.0.0.1:8080)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 客户端控制面基址切 https + Android 去明文(含守护测试)
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/lib/services/api_config.dart:6-9`
|
||||
- Modify: `client/android/app/src/main/AndroidManifest.xml:30`
|
||||
- Create: `client/test/unit/api_config_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 产出的 `https://api.yanmeiai.com`(须已可达)。
|
||||
- Produces: 全 Flutter 端(auth/nodes/account/connection providers 共享的)`kApiBaseUrl` 默认 = `https://api.yanmeiai.com`。
|
||||
|
||||
- [ ] **Step 1: 写守护测试(先失败)**
|
||||
|
||||
创建 `client/test/unit/api_config_test.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin/services/api_config.dart';
|
||||
|
||||
void main() {
|
||||
test('控制面基址默认走 https(禁止回退明文 http)', () {
|
||||
expect(kApiBaseUrl, startsWith('https://'),
|
||||
reason: '控制面已迁 CF Tunnel(api.yanmeiai.com);默认值不得是明文 http');
|
||||
expect(kApiBaseUrl, isNot(contains('103.119.13.48')),
|
||||
reason: '不得再硬编码节点 IP 作控制面基址');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `cd client && flutter test test/unit/api_config_test.dart`
|
||||
Expected: FAIL —— 当前默认 `http://103.119.13.48:8080` 两条断言都不满足。
|
||||
|
||||
- [ ] **Step 3: 改默认值为 https 域名**
|
||||
|
||||
`client/lib/services/api_config.dart:6-9`,把:
|
||||
|
||||
```dart
|
||||
const String kApiBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'http://103.119.13.48:8080',
|
||||
);
|
||||
```
|
||||
|
||||
改为(保留 `String.fromEnvironment` 让本地联调仍可 `--dart-define` 覆盖,只换默认值并更新注释):
|
||||
|
||||
```dart
|
||||
// 控制面 API 基址(单源,全端 providers 共用)。默认走 CF Tunnel 的 https 域名;
|
||||
// 本地联调可 --dart-define=PANGOLIN_API_URL=http://127.0.0.1:8080 覆盖。
|
||||
const String kApiBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'https://api.yanmeiai.com',
|
||||
);
|
||||
```
|
||||
|
||||
同时删掉第 5 行「TODO(联调临时)…发版前改回」那条注释(已落地)。
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `cd client && flutter test test/unit/api_config_test.dart`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 5: 移除 Android 全局明文开关**
|
||||
|
||||
`client/android/app/src/main/AndroidManifest.xml:30`,把 `<application>` 上的:
|
||||
|
||||
```
|
||||
android:usesCleartextTraffic="true"><!-- 控制面 API 当前为 http(联调),Android 9+ 默认禁明文,需开;生产改 https 后可去掉 -->
|
||||
```
|
||||
|
||||
改为(去掉该属性,闭合标签接到上一属性行;控制面已 https,不再需要明文豁免):
|
||||
|
||||
```
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
```
|
||||
|
||||
> iOS/macOS 无 ATS 配置(已确认),https 天然满足 ATS,**无需改任何 plist**。
|
||||
|
||||
- [ ] **Step 6: analyze + 全量单测**
|
||||
|
||||
Run: `cd client && flutter analyze --no-fatal-infos && flutter test test/unit test/widget test/contract`
|
||||
Expected: analyze 无 error;测试全绿(含新 `api_config_test`)。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add client/lib/services/api_config.dart client/android/app/src/main/AndroidManifest.xml client/test/unit/api_config_test.dart
|
||||
git commit -m "feat(client): 控制面基址默认 https://api.yanmeiai.com + 移除 Android 明文开关"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: CI 守护 —— Android release manifest 禁明文
|
||||
|
||||
防止将来有人把 `usesCleartextTraffic="true"` 加回来(回退明文)。
|
||||
|
||||
**Files:**
|
||||
- Create: `ci/scan-cleartext.sh`
|
||||
- Modify: `.gitea/workflows/ci.yml`(新增 job + shellcheck 列表)
|
||||
|
||||
- [ ] **Step 1: 写扫描脚本**
|
||||
|
||||
创建 `ci/scan-cleartext.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# scan-cleartext.sh — 禁止 Android manifest 重新开启全局明文(控制面已 https/CF Tunnel)。
|
||||
# usesCleartextTraffic="true" 会让全 app 允许明文 HTTP,退回 #25 之前的不安全态。
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="client/android/app/src/main/AndroidManifest.xml"
|
||||
if grep -q 'usesCleartextTraffic="true"' "$MANIFEST"; then
|
||||
echo "❌ $MANIFEST 含 usesCleartextTraffic=\"true\":控制面已 https,禁止全局明文。" >&2
|
||||
echo " 如个别调试域名确需明文,请用 res/xml/network_security_config.xml 按域白名单,勿开全局。" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Android manifest 未开启全局明文"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 本地跑一遍(应通过,因 Task 2 已移除)**
|
||||
|
||||
Run: `bash ci/scan-cleartext.sh`
|
||||
Expected: `✅ Android manifest 未开启全局明文`。
|
||||
|
||||
- [ ] **Step 3: 反向自测(临时加回应失败)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
sed -i.bak 's#android:icon="@mipmap/ic_launcher">#android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true">#' client/android/app/src/main/AndroidManifest.xml
|
||||
bash ci/scan-cleartext.sh; echo "exit=$?"
|
||||
mv client/android/app/src/main/AndroidManifest.xml.bak client/android/app/src/main/AndroidManifest.xml
|
||||
```
|
||||
Expected: 打印 ❌ 且 `exit=1`;还原后文件复原。
|
||||
|
||||
- [ ] **Step 4: 接入 CI**
|
||||
|
||||
`.gitea/workflows/ci.yml`:(a)在 lint job 的「shellcheck CI 脚本」列表(约 42-58 行)加 `/mnt/... ` 对应项前,先把 `ci/scan-cleartext.sh` 纳入 shellcheck——注意该文件在 `ci/` 非 `scripts/ci/`,复用已有的 redline-scan 挂载方式即可;(b)新增 job:
|
||||
|
||||
```yaml
|
||||
cleartext-scan:
|
||||
name: Cleartext Scan — Android 禁明文
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: scan Android manifest for global cleartext
|
||||
run: bash ci/scan-cleartext.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ci/scan-cleartext.sh .gitea/workflows/ci.yml
|
||||
git commit -m "ci: 守护 Android manifest 禁全局明文(#25 控制面已 https)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 服务端 `PANGOLIN_PUBLIC_URL` 切 https(规则集下载基址)
|
||||
|
||||
`PANGOLIN_PUBLIC_URL` 被嵌进**客户端 sing-box 配置**当 `.srs` 分流规则集下载基址(`clientconfig.go:150-161`,`download_detour:"direct"`)。不改的话新客户端仍去 `http://103.119.13.48:8080` 拉。此步与 Task 1 隧道并存,对新旧客户端都安全(URL 由服务端下发,客户端只是照着 GET)。
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy/single-node/deploy.sh:178`
|
||||
|
||||
- [ ] **Step 1: 改 deploy.sh 的 server.env 默认**
|
||||
|
||||
`deploy/single-node/deploy.sh:178`,把:
|
||||
|
||||
```
|
||||
PANGOLIN_PUBLIC_URL=http://$VPS_IP:$HTTP_PORT
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```
|
||||
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 pangolin1 应用 + 重启 server**(ssh,改机器,先确认)
|
||||
|
||||
```bash
|
||||
ssh pangolin1 "sed -i 's#^PANGOLIN_PUBLIC_URL=.*#PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com#' /etc/pangolin/server.env && systemctl restart pangolin-server && sleep 2 && systemctl is-active pangolin-server"
|
||||
```
|
||||
|
||||
Expected: `active`。
|
||||
|
||||
- [ ] **Step 3: 验证下发配置里规则集基址已是 https**
|
||||
|
||||
用一个测试账号取一份客户端配置(经隧道),断言规则集 URL 走 https:
|
||||
|
||||
```bash
|
||||
curl -fsS -m 10 https://api.yanmeiai.com/v1/rules/geoip-cn.srs -o /dev/null -w '%{http_code}\n'
|
||||
```
|
||||
|
||||
Expected: `200`(规则集经隧道可下载)。并在有测试 token 时抓一份 `/v1/...` 客户端配置,确认内嵌 `route.rule_set[].url` 前缀为 `https://api.yanmeiai.com`。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/single-node/deploy.sh
|
||||
git commit -m "feat(deploy): PANGOLIN_PUBLIC_URL 改 https://api.yanmeiai.com(客户端规则集走隧道)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 退役明文口 —— 8080 收 loopback + 关防火墙 + 修健康检查
|
||||
|
||||
> **⚠️ 上线顺序闸:此 Task 会切断外部 `http://103.119.13.48:8080`,只有当现网客户端都已更新到 Task 2 的 https 版本后才能执行。** 执行前与用户确认「旧客户端可弃」。做完后一切经隧道/loopback,数据面 :443 不受影响。
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy/single-node/deploy.sh:167`(ADDR 收 loopback)、`:272-275`(去掉 ufw 放行 8080)
|
||||
- Modify: `scripts/ci/deploy-server.sh:57`(健康检查改本地)
|
||||
|
||||
- [ ] **Step 1: deploy.sh — ADDR 绑 loopback**
|
||||
|
||||
`deploy/single-node/deploy.sh:167`,把 `ADDR=:$HTTP_PORT` 改为:
|
||||
|
||||
```
|
||||
ADDR=127.0.0.1:$HTTP_PORT
|
||||
```
|
||||
|
||||
- [ ] **Step 2: deploy.sh — 不再放行 8080(loopback 后无需外开)**
|
||||
|
||||
`deploy/single-node/deploy.sh:272-275` 的 ufw 放行段删除或改注释(8080 已 loopback,外部本就不可达):
|
||||
|
||||
```bash
|
||||
# 控制面 API 已绑 127.0.0.1(经 cloudflared 隧道对外),不放行 8080/tcp。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: deploy-server.sh — 健康检查改 ssh 内本地 curl**
|
||||
|
||||
`scripts/ci/deploy-server.sh:57`,把 runner 远程:
|
||||
|
||||
```
|
||||
curl -fsS -m 10 --retry 5 --retry-connrefused "http://${DEPLOY_HOST}:8080/healthz" >/dev/null && echo "healthz OK"
|
||||
```
|
||||
|
||||
改为经隧道校验对外可达 + ssh 内本地兜底(二选一或都留,推荐经隧道最贴近真实客户端路径):
|
||||
|
||||
```bash
|
||||
$SSH "root@${DEPLOY_HOST}" 'curl -fsS -m 10 --retry 5 --retry-connrefused http://127.0.0.1:8080/healthz >/dev/null && echo "healthz(local) OK"'
|
||||
curl -fsS -m 10 --retry 5 "https://api.yanmeiai.com/healthz" >/dev/null && echo "healthz(tunnel) OK"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 pangolin1 应用 loopback 绑定**(ssh,改机器,先确认客户端已迁移)
|
||||
|
||||
```bash
|
||||
ssh pangolin1 "sed -i 's#^ADDR=.*#ADDR=127.0.0.1:8080#' /etc/pangolin/server.env && systemctl restart pangolin-server && sleep 2 && systemctl is-active pangolin-server"
|
||||
```
|
||||
|
||||
Expected: `active`。
|
||||
|
||||
- [ ] **Step 5: 验证明文口已死、隧道仍活**
|
||||
|
||||
```bash
|
||||
curl -fsS -m 8 http://103.119.13.48:8080/healthz && echo "!! 不该还通" || echo "旧明文口已不可达(预期)"
|
||||
curl -fsS -m 10 https://api.yanmeiai.com/healthz && echo " <= 隧道仍 OK"
|
||||
ssh pangolin1 'ss -ltnp | grep ":8080" | grep 127.0.0.1 && echo "8080 已仅 loopback"'
|
||||
```
|
||||
|
||||
Expected: 明文口失败;隧道成功;`ss` 显示 8080 仅监听 `127.0.0.1`。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/single-node/deploy.sh scripts/ci/deploy-server.sh
|
||||
git commit -m "feat(deploy): 8080 收 loopback + 关 8080 防火墙 + 健康检查改本地/隧道(退役明文控制口)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 文档更新(端口/URL 布局)
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`(项目根,worktree 内那份)— 端口布局说明
|
||||
- Modify: `docs/index.html` — 登记本方案 HTML 阅读版
|
||||
|
||||
- [ ] **Step 1: 更新 CLAUDE.md 端口/URL 描述**
|
||||
|
||||
在 `deploy/ 结构` 或 server 段补一句:控制面 API 对外经 **CF Tunnel** `https://api.yanmeiai.com`(源站 `127.0.0.1:8080`,不外露);数据面 sing-box REALITY 仍独占 `:443`;gRPC agent mTLS `:9443`。
|
||||
|
||||
- [ ] **Step 2: 生成本方案 HTML 阅读版并登记 index**
|
||||
|
||||
按既有深色 HTML 家族样式,把本 plan 同内容生成 `docs/control-plane-tls-tunnel.html`,登记进 `docs/index.html` 的「实现计划」分类。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/control-plane-tls-tunnel.html docs/index.html
|
||||
git commit -m "docs: 控制面 CF Tunnel/端口布局说明 + 方案 HTML 登记 index"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ CF Tunnel 前置控制面 → Task 1。
|
||||
- ✅ 客户端默认 http→https → Task 2。
|
||||
- ✅ Android 移除 usesCleartextTraffic → Task 2(iOS/macOS 无 ATS 需改,已核实)。
|
||||
- ✅ server 8080 收 loopback → Task 5(带上线顺序闸)。
|
||||
- ✅ `PANGOLIN_PUBLIC_URL` 同步 https(Explore 发现的隐藏依赖)→ Task 4。
|
||||
- ✅ 健康检查随 loopback 调整 → Task 5。
|
||||
- ✅ 数据面 :443 不动 → 全程未触碰 sing-box(约束显式声明)。
|
||||
- ✅ fallback(域名被封退直连 IP)→ 明确拆到 #32,不在本轮。
|
||||
|
||||
**上线顺序验证:** Task 1(隧道加法)→ Task 4(PUBLIC_URL,新旧客户端皆安全)→ Task 2(客户端切 https,发版)→ **待客户端更新** → Task 5(收口)。Task 3(CI 守护)、Task 6(文档)无顺序耦合。
|
||||
|
||||
**Placeholder / 一致性:** Task 1B Step 6 的 `$(command -v cloudflared)` 已在注释显式提示落地时改为无 `$()` 写法(禁 `$()` 全局约束);token 全程不落 git;`kApiBaseUrl` 名称跨 Task 2/守护测试一致。
|
||||
|
||||
## 不在本轮
|
||||
- #32 控制面 fallback(CF 域名被 SNI 封 → 客户端退回直连节点 IP 的 https 控制口)。
|
||||
- 控制面 API 的 CF WAF/rate-limit 规则精调。
|
||||
- usercenter(web/usercenter)也接入同域名 API(其部署属 #30 30A)。
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# backup-db.sh — pangolin1 控制面 SQLite 每日备份到 NAS(异地容灾,#26 / F2)。
|
||||
#
|
||||
# 经 ssh 在 pangolin1 上用 sqlite3 online `.backup` 取 WAL 一致快照(不阻塞服务、
|
||||
# 不像裸 cp 那样可能拿到半写状态),远端先跑 PRAGMA integrity_check 确认没坏,再把
|
||||
# 快照 gzip 流回 runner,落到 NAS 备份盘(默认 /volume1/docker/backups/pangolin,
|
||||
# 该盘已挂进 runner 容器)。保留 30 天。异地 = 家里 NAS ≠ VPS 机房,构成容灾。
|
||||
#
|
||||
# 不在 pangolin1 上安装/常驻任何东西:调度在 Gitea Actions(backup.yml),每次经
|
||||
# ssh 临时跑一次 sqlite3。需环境:DEPLOY_SSH_KEY(secret)。从 repo 根调用。
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=scripts/ci/lib-ssh.sh
|
||||
. scripts/ci/lib-ssh.sh
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/volume1/docker/backups/pangolin}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# 时间戳文件名(bash 内建 strftime,免 $() 命令替换)。
|
||||
printf -v TS '%(%Y%m%d_%H%M%S)T' -1
|
||||
DEST="${BACKUP_DIR}/pangolin_${TS}.db.gz"
|
||||
|
||||
# setup_ssh 内部先注册 EXIT trap 清理临时私钥,再写 key/known_hosts,导出 $SSH。
|
||||
setup_ssh
|
||||
|
||||
echo "==> backup-db: sqlite3 .backup pangolin1(${DEPLOY_HOST})-> ${DEST}"
|
||||
# 远端单引号 heredoc:$BK/$$ 在 pangolin1 上展开。以 pangolin 用户(DB 属主)跑
|
||||
# online .backup + integrity_check;校验通过才 cat 回流;临时文件用完即删。
|
||||
# integrity_check 失败 → 远端非零退出 → 本地 pipefail 令整条命令失败 → set -e 中止。
|
||||
$SSH "root@${DEPLOY_HOST}" 'bash -s' <<'ENDSSH' | gzip > "$DEST"
|
||||
set -euo pipefail
|
||||
BK="/tmp/pangolin-bk-$$.db"
|
||||
trap 'rm -f "$BK"' EXIT
|
||||
runuser -u pangolin -- sqlite3 "/var/lib/pangolin/pangolin.db" ".backup ${BK}"
|
||||
if ! runuser -u pangolin -- sqlite3 "$BK" "PRAGMA integrity_check;" | grep -q '^ok$'; then
|
||||
echo "backup-db(remote): integrity_check 失败" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$BK"
|
||||
ENDSSH
|
||||
|
||||
# 本地二次校验:gzip 完好 + 非空 sanity。空 gzip 仅 ~20 字节;真备份(即便很小的库)
|
||||
# gzip 后也远大于 1KB,故用 >1k 区分「空/断流」与「有效备份」(不用 50k:小库压缩后可能 <50k)。
|
||||
gzip -t "$DEST"
|
||||
if ! find "$DEST" -size +1k -print -quit | grep -q .; then
|
||||
echo "==> backup-db: 备份异常小(<1k,疑似空/断流),中止" >&2
|
||||
rm -f "$DEST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 保留最近 30 天。
|
||||
find "$BACKUP_DIR" -name 'pangolin_*.db.gz' -mtime +30 -delete
|
||||
|
||||
echo "==> backup-db: 备份完成 ${DEST}"
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# compile-android.sh <tag> — build+embed libbox.aar, build a signed Flutter
|
||||
# Android APK, package into dist/.
|
||||
#
|
||||
# Mirrors ~/code/jiu/scripts/ci/compile-android.sh, adapted for pangolin:
|
||||
# - version derived via pangolin's ver_from_tag (client-v* tag; _env.sh),
|
||||
# not jiu's ad-hoc `${TAG#client-v}` strip.
|
||||
# - pangolin embeds sing-box as a native library (io.nekohasekai.libbox,
|
||||
# gomobile bind) — jiu's Flutter app has no embedded core at all. This
|
||||
# script MUST build+deploy libbox.aar (scripts/build-libbox.sh android)
|
||||
# before `flutter build apk`, or the APK links no VPN kernel at all.
|
||||
# Needs JDK 17 (gomobile bind javac step) — see CLAUDE.md "client/ 移动端"
|
||||
# and "已知坑" for why: JDK 21 is rejected outright, NDK 27 fails link.
|
||||
# - signing secrets: RELEASE_KEYSTORE (base64) + KEY_PASSWORD are the
|
||||
# secret names given for this task ("actual configured" names). jiu uses
|
||||
# 4 separate secrets (ANDROID_KEYSTORE_BASE64/ANDROID_KEYSTORE_PASSWORD/
|
||||
# ANDROID_KEY_ALIAS/ANDROID_KEY_PASSWORD), and pangolin's OWN prior CI
|
||||
# design docs (docs/cicd-design.html, docs/superpowers/plans/2026-07-05-
|
||||
# cicd.md, docs/superpowers/specs/2026-07-05-cicd-design.md) planned the
|
||||
# same 4-secret jiu-style naming. That is a real conflict with this
|
||||
# task's brief — see the TODO(controller) block below and the draft
|
||||
# report. This script follows the 2-secret brief as instructed, assuming
|
||||
# storePassword == keyPassword == KEY_PASSWORD and a derivable keyAlias
|
||||
# (default "pangolin", overridable via optional RELEASE_KEY_ALIAS).
|
||||
# - dart-define is PANGOLIN_API_URL (client/lib/services/api_config.dart),
|
||||
# not jiu's BASE_URL/PUBLIC_URL/APP_VERSION trio.
|
||||
# - build.gradle previously signed release builds with signingConfigs.debug
|
||||
# (no release signing config existed at all) — this task's draft also
|
||||
# wires client/android/app/build.gradle to add a `release` signingConfig
|
||||
# that reads client/android/key.properties (falls back to debug when
|
||||
# key.properties is absent, so local/no-secret builds still work). See
|
||||
# report for the exact diff.
|
||||
# - universal APK (`flutter build apk --release`, no --split-per-abi) — see
|
||||
# report for the size-vs-single-stable-URL tradeoff discussion; this
|
||||
# deviates from docs/superpowers/plans/2026-07-05-cicd.md Task 7 which
|
||||
# had planned --split-per-abi.
|
||||
# - output dist/pangolin-android.apk (stable name; deploy-client.sh keeps
|
||||
# only the latest one under this name on pangolin1).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/ci/_env.sh
|
||||
. "${SCRIPT_DIR}/_env.sh"
|
||||
|
||||
TAG="${1:?usage: compile-android.sh <tag>}"
|
||||
|
||||
# No $() anywhere (repo convention): ver_from_tag prints to stdout, captured
|
||||
# via a temp file + `read`, same pattern as release-server.sh.
|
||||
ver_file="/tmp/compile_android_ver.$$"
|
||||
ver_from_tag client "$TAG" > "$ver_file"
|
||||
VER=""
|
||||
# `|| true`: ver_from_tag uses printf '%s' (no trailing newline); `read`
|
||||
# hitting EOF without a newline returns 1 even though VER was assigned —
|
||||
# under `set -e` that would abort here. Same rationale as release-server.sh.
|
||||
read -r VER < "$ver_file" || true
|
||||
rm -f "$ver_file"
|
||||
|
||||
# versionCode 必须单调递增,否则 Android 无法覆盖升级安装(同 jiu 注释)。
|
||||
# 由版本号推导:major*10000 + minor*100 + patch(如 1.0.48 -> 10048)。
|
||||
# Pure parameter expansion (no `cut`/`$()`), and strip any `-suffix` off the
|
||||
# patch component so the arithmetic below stays numeric (e.g. "48-rc1"->48).
|
||||
MAJOR="${VER%%.*}"
|
||||
_REST="${VER#*.}"
|
||||
MINOR="${_REST%%.*}"
|
||||
PATCH="${_REST#*.}"
|
||||
PATCH="${PATCH%%-*}"
|
||||
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
|
||||
echo "==> compile-android: tag=${TAG} version=${VER} build=${BUILD}"
|
||||
|
||||
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
||||
|
||||
# ── [1/3] build+embed libbox.aar (io.nekohasekai.libbox) ────────────────────
|
||||
# Must run BEFORE `flutter build apk` — build.gradle's dependency block reads
|
||||
# app/kernel/dist/android/libbox.aar and just logs a warning (does not fail
|
||||
# the build!) if it's missing, which would silently ship an APK with no VPN
|
||||
# kernel. Fail loudly here instead.
|
||||
JAVA_HOME_17="${JAVA_HOME_17:-/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home}"
|
||||
if [ ! -d "$JAVA_HOME_17" ]; then
|
||||
echo "ERROR: JDK 17 not found at ${JAVA_HOME_17} (required for gomobile Android bind)." >&2
|
||||
echo " See CLAUDE.md 'libbox 构建' — install via 'brew install openjdk@17' or set JAVA_HOME_17." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "==> compile-android: building libbox.aar (JAVA_HOME=${JAVA_HOME_17}, NDK auto-detected >=28)"
|
||||
JAVA_HOME="$JAVA_HOME_17" bash scripts/build-libbox.sh android
|
||||
|
||||
LIBBOX_AAR="app/kernel/dist/android/libbox.aar"
|
||||
if [ ! -f "$LIBBOX_AAR" ]; then
|
||||
echo "ERROR: ${LIBBOX_AAR} not found after build-libbox.sh — aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── [2/3] sync pubspec version + release signing ────────────────────────────
|
||||
# BSD sed (macOS runner — mac-pangolin-2, see docs/ci-runner.md), same as jiu.
|
||||
sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" client/pubspec.yaml
|
||||
|
||||
# 必须 release 签名。缺任一签名 secret 直接失败,绝不回退 debug:debug 包用公开
|
||||
# 调试密钥签名,不能正式分发,也无法与正式版互相覆盖升级(同 jiu 注释)。
|
||||
: "${RELEASE_KEYSTORE:?缺少 RELEASE_KEYSTORE(base64 编码的 release keystore):未配置签名,构建中止(不回退 debug)}"
|
||||
: "${KEY_PASSWORD:?缺少 KEY_PASSWORD:未配置签名,构建中止}"
|
||||
|
||||
# TODO(controller): confirm this 2-secret assumption before relying on it.
|
||||
# - Assumes storePassword == keyPassword == KEY_PASSWORD (true only if the
|
||||
# keystore was generated with the same password for both).
|
||||
# - keyAlias is NOT among the secrets this task named, so it defaults to
|
||||
# "pangolin" below — override with RELEASE_KEY_ALIAS if the real alias
|
||||
# differs (e.g. keytool default "androiddebugkey" is NOT this — that
|
||||
# alias belongs to the debug keystore, never use it for release signing).
|
||||
# - pangolin's OWN prior CI design docs (docs/cicd-design.html §secrets,
|
||||
# docs/superpowers/plans/2026-07-05-cicd.md Task 6/7,
|
||||
# docs/superpowers/specs/2026-07-05-cicd-design.md) planned 4 separate
|
||||
# secrets instead: ANDROID_KEYSTORE_BASE64 / ANDROID_KEYSTORE_PASSWORD /
|
||||
# ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD. If THAT naming is what is
|
||||
# actually configured in Forgejo (not RELEASE_KEYSTORE/KEY_PASSWORD),
|
||||
# this script needs updating before first real run.
|
||||
RELEASE_KEY_ALIAS="${RELEASE_KEY_ALIAS:-pangolin}"
|
||||
|
||||
echo "==> compile-android: configuring release signing (keyAlias=${RELEASE_KEY_ALIAS})"
|
||||
KEYSTORE_PATH="${PWD}/client/android/pangolin-release.jks"
|
||||
printf '%s' "$RELEASE_KEYSTORE" | base64 --decode > "$KEYSTORE_PATH"
|
||||
cat > client/android/key.properties <<EOF
|
||||
storeFile=${KEYSTORE_PATH}
|
||||
storePassword=${KEY_PASSWORD}
|
||||
keyAlias=${RELEASE_KEY_ALIAS}
|
||||
keyPassword=${KEY_PASSWORD}
|
||||
EOF
|
||||
|
||||
# ── [3/3] flutter build apk (--split-per-abi) ───────────────────────────────
|
||||
# split-per-abi 而非 universal:嵌入的 libbox.so(gomobile 编的 sing-box)每 ABI
|
||||
# ~60MB,universal 三合一直接 232MB,做下载链接太大。拆分后取 **arm64-v8a**(覆盖
|
||||
# 现代绝大多数 Android 机)作为唯一下载,约 ~80MB;32 位老机(armeabi-v7a)/模拟器
|
||||
# (x86_64)本轮不分发(需要再加)。这样官网 + deploy-client 仍是单一稳定 URL。
|
||||
cd client
|
||||
flutter build apk --release --split-per-abi \
|
||||
"--dart-define=PANGOLIN_API_URL=${API_URL}"
|
||||
cd ..
|
||||
|
||||
# Locate the arm64-v8a APK (path differs across Flutter versions) and copy to dist/
|
||||
mkdir -p dist
|
||||
APK=""
|
||||
for cand in \
|
||||
client/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk \
|
||||
client/build/app/outputs/apk/release/app-arm64-v8a-release.apk; do
|
||||
if [ -f "$cand" ]; then APK="$cand"; break; fi
|
||||
done
|
||||
if [ -z "$APK" ]; then
|
||||
echo "ERROR: built arm64-v8a APK not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$APK" dist/pangolin-android.apk
|
||||
|
||||
# Clean up signing material so it never lingers on the runner workspace.
|
||||
rm -f client/android/key.properties "$KEYSTORE_PATH" 2>/dev/null || true
|
||||
|
||||
echo "==> compile-android: done — dist/ contents:"
|
||||
ls -lh dist/
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
# compile-windows.sh <tag> — build Flutter Windows desktop app, package into
|
||||
# dist/ via the existing Inno Setup script (client/windows/installer/pangolin.iss).
|
||||
#
|
||||
# Mirrors ~/code/jiu/scripts/ci/compile-windows.sh's short-path fix, adapted:
|
||||
# - version derived via pangolin's ver_from_tag, not jiu's ad-hoc strip.
|
||||
# - pangolin's Windows client does NOT embed libbox — it bundles a
|
||||
# downloaded sing-box.exe + wintun.dll as a subprocess (see
|
||||
# client/windows/README.md, CLAUDE.md "client/ 移动端"'s sibling desktop
|
||||
# note). Those are fetched by app/kernel/fetch-desktop-bin.sh, NOT built
|
||||
# by scripts/build-libbox.sh — no JDK/NDK/gomobile needed on Windows.
|
||||
# - pangolin already has a working local packaging path:
|
||||
# client/windows/build.ps1 + client/windows/installer/pangolin.iss. This
|
||||
# script re-implements that flow in bash (matching the repo's
|
||||
# jiu-mirrored bash-script-per-platform convention) rather than shelling
|
||||
# out to build.ps1, so it can apply jiu's short-path fix below.
|
||||
# - jiu's short-path fix: the Forgejo Windows runner checks out under
|
||||
# C:\Windows\System32\config\systemprofile\.cache\act\...\hostexecutor,
|
||||
# which triggers WOW64 filesystem redirection that breaks CMake/Flutter
|
||||
# native paths. jiu copies client/ to C:\jiu-build\client and builds
|
||||
# there. pangolin's windows/CMakeLists.txt additionally resolves the
|
||||
# kernel binaries via a path RELATIVE to CMAKE_SOURCE_DIR
|
||||
# (client/windows/../../app/kernel/dist/desktop/windows-* — see
|
||||
# client/windows/CMakeLists.txt "Pangolin sing-box kernel" section), so
|
||||
# this script must copy app/kernel/dist alongside client/ preserving the
|
||||
# SAME relative layout (BUILD_ROOT/client + BUILD_ROOT/app/kernel/dist),
|
||||
# not just client/ alone like jiu does — jiu has no such cross-directory
|
||||
# kernel reference.
|
||||
# - dart-define is PANGOLIN_API_URL, not jiu's BASE_URL/PUBLIC_URL/APP_VERSION.
|
||||
# - pangolin.iss hardcodes `#define MyAppVersion "1.0.47"` (no #ifndef guard
|
||||
# like jiu's jiu-installer.iss), so ISCC's /D command-line override would
|
||||
# be silently ignored. This script `sed`s the version into the copied
|
||||
# .iss file instead of passing /DAppVer.
|
||||
# - output dist/pangolin-windows-x64-setup.exe (stable name — the .iss's
|
||||
# own OutputBaseFilename is version-suffixed "pangolin-setup-<ver>.exe",
|
||||
# so this script copies+renames it to the stable name deploy-client.sh
|
||||
# and release-client.sh expect).
|
||||
# - unsigned (like jiu) — first install shows a SmartScreen warning; no
|
||||
# code-signing cert configured (see report).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/ci/_env.sh
|
||||
. "${SCRIPT_DIR}/_env.sh"
|
||||
|
||||
TAG="${1:?usage: compile-windows.sh <tag>}"
|
||||
|
||||
ver_file="/tmp/compile_windows_ver.$$"
|
||||
ver_from_tag client "$TAG" > "$ver_file"
|
||||
VER=""
|
||||
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
|
||||
rm -f "$ver_file"
|
||||
|
||||
echo "==> compile-windows: tag=${TAG} version=${VER}"
|
||||
|
||||
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
||||
|
||||
# ── [1/4] fetch sing-box.exe + wintun.dll into the ORIGINAL checkout ───────
|
||||
# Must run before the short-path copy below (step 2), so app/kernel/dist/
|
||||
# exists to be copied alongside client/.
|
||||
echo "==> compile-windows: fetching sing-box.exe + wintun.dll"
|
||||
bash app/kernel/fetch-desktop-bin.sh windows amd64
|
||||
|
||||
# ── [2/4] copy client/ + app/kernel/dist to a short path ───────────────────
|
||||
BUILD_ROOT="/c/pangolin-build"
|
||||
BUILD_CLIENT="${BUILD_ROOT}/client"
|
||||
|
||||
echo "==> copying client/ + app/kernel/dist to ${BUILD_ROOT}"
|
||||
rm -rf "$BUILD_ROOT"
|
||||
mkdir -p "${BUILD_ROOT}/app/kernel"
|
||||
cp -r client "$BUILD_CLIENT"
|
||||
cp -r app/kernel/dist "${BUILD_ROOT}/app/kernel/dist"
|
||||
|
||||
# `cp -r` turns Flutter's plugin symlinks (windows/flutter/ephemeral/.plugin_symlinks/*)
|
||||
# into real directories. flutter's createPluginSymlinks only cleans up *symlinks*, so it
|
||||
# then fails to create a symlink over the copied real dir (errno 183 / PathExists).
|
||||
# Drop all regenerable artifacts so the build below recreates them cleanly (same fix as jiu).
|
||||
rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
|
||||
"${BUILD_CLIENT}/.dart_tool" \
|
||||
"${BUILD_CLIENT}/build"
|
||||
|
||||
# ── [3/4] build ──────────────────────────────────────────────────────────────
|
||||
# NOTE(controller): `flutter create` on an existing project only fills in
|
||||
# scaffolding it manages (ephemeral/, generated_plugins.cmake, ...) and should
|
||||
# not overwrite files that already exist — jiu relies on exactly this. But
|
||||
# pangolin's windows/ tree is more heavily customized than jiu's (CMakeLists
|
||||
# kernel-bundling block, runner.exe.manifest requireAdministrator + the
|
||||
# /MANIFESTUAC:NO link flag — see client/windows/README.md "已知坑"). Verify
|
||||
# on first real CI run that none of those get clobbered.
|
||||
pushd "$BUILD_CLIENT" > /dev/null
|
||||
flutter create --platforms=windows . --project-name pangolin_vpn
|
||||
flutter build windows --release "--dart-define=PANGOLIN_API_URL=${API_URL}"
|
||||
popd > /dev/null
|
||||
|
||||
# ── [4/4] package with Inno Setup (existing client/windows/installer/pangolin.iss) ──
|
||||
ISCC=""
|
||||
for c in "/c/Program Files (x86)/Inno Setup 6/ISCC.exe" "/c/Program Files/Inno Setup 6/ISCC.exe"; do
|
||||
if [ -f "$c" ]; then ISCC="$c"; break; fi
|
||||
done
|
||||
if [ -z "$ISCC" ]; then
|
||||
echo "ERROR: Inno Setup (ISCC) not found. Install Inno Setup 6 on this runner first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ISS="${BUILD_CLIENT}/windows/installer/pangolin.iss"
|
||||
# pangolin.iss hardcodes MyAppVersion (no #ifndef guard) — sed it to match the
|
||||
# release tag rather than relying on an ISCC /D override (which would be
|
||||
# shadowed by the unconditional #define — see header note above).
|
||||
sed -i "s/^#define MyAppVersion .*/#define MyAppVersion \"${VER}\"/" "$ISS"
|
||||
|
||||
echo "==> building installer with Inno Setup (version ${VER})"
|
||||
# ISCC 是原生 Windows 程序,只认 Windows 路径(C:\...);传 MSYS 风格 /c/... 会被它
|
||||
# 当成选项 → "Unknown option: /c/...pangolin.iss"。故先用 cygpath 把 .iss 转成
|
||||
# Windows 路径再传(jiu 是直接写死 C:\ 字面量;这里用 cygpath 更稳)。
|
||||
# 不用 $() 命令替换(仓库约定):cygpath 输出落临时文件、read 读回。
|
||||
cygpath -w "$ISS" > /tmp/pangolin_iss_win.$$
|
||||
ISS_WIN=""
|
||||
read -r ISS_WIN < /tmp/pangolin_iss_win.$$
|
||||
rm -f /tmp/pangolin_iss_win.$$
|
||||
# MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL 关掉 Git Bash 对 /-开头参数的自动路径转换
|
||||
# (否则会把 .iss 里将来可能的 /D 定义也改坏)。ISS_WIN 已是 Windows 路径,原样传即可。
|
||||
MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$ISCC" "$ISS_WIN"
|
||||
|
||||
# pangolin.iss has no explicit OutputDir -> Inno defaults to {src}\Output
|
||||
# (relative to the .iss file), filename OutputBaseFilename=pangolin-setup-<ver>.
|
||||
mkdir -p dist
|
||||
SETUP_SRC="C:\\pangolin-build\\client\\windows\\installer\\Output\\pangolin-setup-${VER}.exe"
|
||||
echo "==> copying installer -> dist/pangolin-windows-x64-setup.exe"
|
||||
powershell -NoProfile -Command \
|
||||
"Copy-Item '${SETUP_SRC}' 'dist\\pangolin-windows-x64-setup.exe' -Force"
|
||||
|
||||
echo "==> compile-windows: done — dist/ contents:"
|
||||
ls -lh dist/
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy-client.sh <tag> — publish the latest Android/Windows client
|
||||
# installers to pangolin1's public downloads directory. Keeps only the single
|
||||
# latest file per platform (jiu-style "only latest" — see jiu's
|
||||
# deploy-client.sh /opt/jiu/downloads handling), overwriting whatever a
|
||||
# previous release published under the same stable filename.
|
||||
#
|
||||
# Mirrors ~/code/jiu/scripts/ci/deploy-client.sh's "only latest" downloads
|
||||
# publish, but uses pangolin's own lib-ssh.sh API (setup_ssh / $SSH /
|
||||
# $RSYNC_SSH, DEPLOY_HOST defaulting to pangolin1's IP — different from
|
||||
# jiu's lib-forgejo.sh-embedded setup_ssh/EC2_HOST convention) and drops
|
||||
# jiu's Flutter-web + version.yaml swap entirely: pangolin's client has no
|
||||
# web build in this pipeline, and no version.yaml self-update manifest.
|
||||
#
|
||||
# TODO(controller): this script assumes pangolin-server (or nginx/whatever
|
||||
# fronts pangolin1's public HTTP) serves ${DOWNLOADS_DIR} at /downloads/ —
|
||||
# that vhost/route wiring is a SEPARATE, not-yet-done task (same status as
|
||||
# docs/superpowers/plans/2026-07-05-cicd.md Task 8's "官网下载链接接
|
||||
# Android release", which is also not part of this draft).
|
||||
#
|
||||
# Usage: scripts/ci/deploy-client.sh <tag> (e.g. client-v1.0.49)
|
||||
# Requires env: DEPLOY_SSH_KEY (see lib-ssh.sh). Assumes compile-android.sh /
|
||||
# compile-windows.sh (or a prior download-artifact step) have already
|
||||
# produced dist/pangolin-android.apk and/or dist/pangolin-windows-x64-setup.exe.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/ci/lib-ssh.sh
|
||||
. "${SCRIPT_DIR}/lib-ssh.sh"
|
||||
|
||||
# TODO(controller): confirm this path. Proposed to sit alongside the existing
|
||||
# /var/lib/pangolin/ tree (pangolin.db lives at /var/lib/pangolin/pangolin.db
|
||||
# per deploy-server.sh) rather than under nginx's webroot directly, so the
|
||||
# web server config just needs one `location /downloads/ { alias ...; }`
|
||||
# (or equivalent) added — no file ownership overlap with the control-plane
|
||||
# SQLite DB / systemd units.
|
||||
DOWNLOADS_DIR=/var/lib/pangolin/downloads
|
||||
|
||||
TAG="${1:?usage: deploy-client.sh <tag>}"
|
||||
|
||||
# Refuse anything that isn't a strict client-vX.Y.Z[-suffix] tag before it can
|
||||
# reach the remote commands below (command-injection guard, mirroring
|
||||
# deploy-server.sh's rationale: an anchored regex, not a `case` glob, since a
|
||||
# trailing `*` would match shell metacharacters too).
|
||||
if ! [[ "$TAG" =~ ^client-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
|
||||
echo "deploy-client: refusing unexpected tag '$TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f dist/pangolin-android.apk ] && [ ! -f dist/pangolin-windows-x64-setup.exe ]; then
|
||||
echo "deploy-client: no artifacts found in dist/ — nothing to deploy" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
setup_ssh
|
||||
SCP="scp -i ${SSH_KEY_FILE} -P ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}"
|
||||
|
||||
echo "==> deploy-client: tag=${TAG} host=${DEPLOY_HOST} downloads_dir=${DOWNLOADS_DIR}"
|
||||
$SSH "root@${DEPLOY_HOST}" "mkdir -p ${DOWNLOADS_DIR}"
|
||||
|
||||
if [ -f dist/pangolin-android.apk ]; then
|
||||
echo "==> deploy-client: uploading pangolin-android.apk"
|
||||
$SCP dist/pangolin-android.apk "root@${DEPLOY_HOST}:/tmp/pangolin-android.apk.new"
|
||||
$SSH "root@${DEPLOY_HOST}" "rm -f ${DOWNLOADS_DIR}/pangolin-android.apk && mv /tmp/pangolin-android.apk.new ${DOWNLOADS_DIR}/pangolin-android.apk && chmod 644 ${DOWNLOADS_DIR}/pangolin-android.apk"
|
||||
fi
|
||||
|
||||
if [ -f dist/pangolin-windows-x64-setup.exe ]; then
|
||||
echo "==> deploy-client: uploading pangolin-windows-x64-setup.exe"
|
||||
$SCP dist/pangolin-windows-x64-setup.exe "root@${DEPLOY_HOST}:/tmp/pangolin-windows-x64-setup.exe.new"
|
||||
$SSH "root@${DEPLOY_HOST}" "rm -f ${DOWNLOADS_DIR}/pangolin-windows-x64-setup.exe && mv /tmp/pangolin-windows-x64-setup.exe.new ${DOWNLOADS_DIR}/pangolin-windows-x64-setup.exe && chmod 644 ${DOWNLOADS_DIR}/pangolin-windows-x64-setup.exe"
|
||||
fi
|
||||
|
||||
# TODO(controller): macOS artifact not yet produced by this draft — add the
|
||||
# same guarded upload+swap once compile-macos.sh lands (Phase 3).
|
||||
|
||||
echo "==> deploy-client: done"
|
||||
@@ -54,6 +54,16 @@ systemctl start pangolin-server
|
||||
systemctl is-active pangolin-server
|
||||
REMOTE
|
||||
|
||||
curl -fsS -m 10 --retry 5 --retry-connrefused "http://${DEPLOY_HOST}:8080/healthz" >/dev/null && echo "healthz OK"
|
||||
# 8080 现仅 loopback(经 cloudflared 隧道对外)。
|
||||
# 本地 /healthz 是本次二进制部署成败的**权威闸**:新 server 起来即通过。
|
||||
$SSH "root@${DEPLOY_HOST}" 'curl -fsS -m 10 --retry 5 --retry-connrefused http://127.0.0.1:8080/healthz >/dev/null && echo "healthz(local) OK"'
|
||||
# 隧道 /healthz 是端到端冒烟(最贴近真实客户端路径),但**非致命**:它依赖 cloudflared/CF 边缘,
|
||||
# 与「本次二进制是否健康」是两回事——CF 边缘抖动或隧道尚未 provision 不应判整次部署失败
|
||||
# (本地闸已证明 server 健康)。失败只告警,不 exit。
|
||||
if curl -fsS -m 10 --retry 3 "https://api.yanmeiai.com/healthz" >/dev/null; then
|
||||
echo "healthz(tunnel) OK"
|
||||
else
|
||||
echo "==> deploy-server: 警告 —— 隧道 https://api.yanmeiai.com/healthz 不通(CF 边缘抖动/隧道未就绪?);本地 healthz 已通过,不阻断部署。" >&2
|
||||
fi
|
||||
|
||||
echo "==> deploy-server: done"
|
||||
|
||||
@@ -43,11 +43,13 @@ forgejo_release_ensure() {
|
||||
get_code_file="/tmp/forgejo_get_code.$$"
|
||||
get_body_file="/tmp/forgejo_get_body.$$.json"
|
||||
|
||||
curl "${FORGEJO_CURL_TLS[@]}" -s -o "$get_body_file" -w '%{http_code}' \
|
||||
curl ${FORGEJO_CURL_TLS[@]+"${FORGEJO_CURL_TLS[@]}"} -s --max-time 60 --retry 2 --retry-connrefused -o "$get_body_file" -w '%{http_code}' \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases/tags/${tag}" \
|
||||
> "$get_code_file"
|
||||
read -r get_code < "$get_code_file"
|
||||
# `|| true`: curl -w '%{http_code}' 写入的值无结尾换行,read 到无换行 EOF 返回 1
|
||||
# (值已赋)→ set -e 会静默中止。容错该退出码;文件恒由上面的 curl 创建,不掩盖真错。
|
||||
read -r get_code < "$get_code_file" || true
|
||||
rm -f "$get_code_file"
|
||||
|
||||
if [ "$get_code" = "200" ]; then
|
||||
@@ -81,13 +83,13 @@ json.dump(
|
||||
)
|
||||
' > "$create_req_file"
|
||||
|
||||
curl "${FORGEJO_CURL_TLS[@]}" -s -o "$create_body_file" -w '%{http_code}' \
|
||||
curl ${FORGEJO_CURL_TLS[@]+"${FORGEJO_CURL_TLS[@]}"} -s --max-time 60 -o "$create_body_file" -w '%{http_code}' \
|
||||
-X POST "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @"$create_req_file" \
|
||||
> "$create_code_file"
|
||||
read -r create_code < "$create_code_file"
|
||||
read -r create_code < "$create_code_file" || true # 无结尾换行,见 forgejo_release_ensure 注释
|
||||
rm -f "$create_code_file" "$create_req_file"
|
||||
|
||||
if [ "$create_code" -lt 200 ] || [ "$create_code" -ge 300 ]; then
|
||||
@@ -108,7 +110,7 @@ _forgejo_read_release_id() {
|
||||
id_file="/tmp/forgejo_release_id.$$"
|
||||
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" \
|
||||
"$json_file" > "$id_file"
|
||||
read -r RELEASE_ID < "$id_file"
|
||||
read -r RELEASE_ID < "$id_file" || true # python 写入可能无结尾换行,见上注释
|
||||
rm -f "$id_file"
|
||||
export RELEASE_ID
|
||||
}
|
||||
@@ -125,12 +127,12 @@ forgejo_upload_asset() {
|
||||
code_file="/tmp/forgejo_upload_code.$$"
|
||||
body_file="/tmp/forgejo_upload_body.$$.json"
|
||||
|
||||
curl "${FORGEJO_CURL_TLS[@]}" -s -o "$body_file" -w '%{http_code}' \
|
||||
curl ${FORGEJO_CURL_TLS[@]+"${FORGEJO_CURL_TLS[@]}"} -s --max-time 300 -o "$body_file" -w '%{http_code}' \
|
||||
-X POST "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
> "$code_file"
|
||||
read -r code < "$code_file"
|
||||
read -r code < "$code_file" || true # curl http_code 无结尾换行,见上注释
|
||||
rm -f "$code_file"
|
||||
|
||||
echo "==> forgejo: uploaded ${file} (HTTP ${code})"
|
||||
|
||||
@@ -56,8 +56,12 @@ setup_ssh() {
|
||||
# abort the deploy.
|
||||
ssh-keyscan -p "${DEPLOY_PORT}" -H "${DEPLOY_HOST}" >> "${SSH_KNOWN_HOSTS_FILE}" 2>/dev/null || true
|
||||
|
||||
SSH="ssh -i ${SSH_KEY_FILE} -p ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}"
|
||||
RSYNC_SSH="ssh -i ${SSH_KEY_FILE} -p ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}"
|
||||
# IdentitiesOnly=yes:只用上面 -i 指定的 key,不把 agent/默认 key 也递上去
|
||||
# (否则会触发服务器 MaxAuthTries「Too many authentication failures」)。
|
||||
# BatchMode=yes:纯非交互,认证失败即退出,不回落到密码提示卡住。
|
||||
_ssh_opts="-i ${SSH_KEY_FILE} -p ${DEPLOY_PORT} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}"
|
||||
SSH="ssh ${_ssh_opts}"
|
||||
RSYNC_SSH="ssh ${_ssh_opts}"
|
||||
export SSH RSYNC_SSH SSH_KEY_FILE SSH_KNOWN_HOSTS_FILE DEPLOY_HOST DEPLOY_PORT
|
||||
echo "==> setup_ssh: key written to ${SSH_KEY_FILE}, known_hosts pinned (accept-new) for ${DEPLOY_HOST}:${DEPLOY_PORT}"
|
||||
}
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# release-client.sh <tag> — ensure the Forgejo release for a client-v* tag
|
||||
# exists and upload whichever client artifacts are present in dist/.
|
||||
#
|
||||
# Mirrors ~/code/jiu/scripts/ci/release-client.sh's asset-guarding pattern,
|
||||
# but uses pangolin's own lib-forgejo.sh API (forgejo_release_ensure /
|
||||
# forgejo_upload_asset — different names/signature than jiu's create_release/
|
||||
# upload_asset) and drops jiu's version.yaml/CHANGELOG bookkeeping entirely:
|
||||
# pangolin has no equivalent backend/config/version.yaml manifest — client
|
||||
# self-update checking is out of scope here (server just serves the release
|
||||
# assets; see deploy-client.sh).
|
||||
#
|
||||
# Multiple platform build jobs (android/windows/...) all upload into the SAME
|
||||
# release, guarded by `[ -f ... ]` since a given CI run may only have built a
|
||||
# subset (e.g. a partial manual re-dispatch, or before macOS/iOS land).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/ci/_env.sh
|
||||
. "${SCRIPT_DIR}/_env.sh"
|
||||
# shellcheck source=scripts/ci/lib-forgejo.sh
|
||||
. "${SCRIPT_DIR}/lib-forgejo.sh"
|
||||
|
||||
TAG="${1:?usage: release-client.sh <tag>}"
|
||||
|
||||
ver_file="/tmp/release_client_ver.$$"
|
||||
ver_from_tag client "$TAG" > "$ver_file"
|
||||
VER=""
|
||||
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 release-server.sh 同注释
|
||||
rm -f "$ver_file"
|
||||
|
||||
echo "==> release-client: tag=${TAG} ver=${VER}"
|
||||
|
||||
forgejo_release_ensure "$TAG" "client ${VER}"
|
||||
|
||||
if [ -f dist/pangolin-android.apk ]; then
|
||||
forgejo_upload_asset "$TAG" dist/pangolin-android.apk
|
||||
fi
|
||||
|
||||
if [ -f dist/pangolin-windows-x64-setup.exe ]; then
|
||||
forgejo_upload_asset "$TAG" dist/pangolin-windows-x64-setup.exe
|
||||
fi
|
||||
|
||||
# TODO(controller): macOS artifact not yet built by any compile-*.sh in this
|
||||
# draft (Phase 3 / Task 9 of docs/superpowers/plans/2026-07-05-cicd.md is out
|
||||
# of scope for this task). Once compile-macos.sh lands, add here:
|
||||
# if [ -f dist/pangolin-macos-x64.zip ]; then
|
||||
# forgejo_upload_asset "$TAG" dist/pangolin-macos-x64.zip
|
||||
# fi
|
||||
|
||||
echo "==> release-client: done — Release ${TAG} updated. dist/ contents:"
|
||||
ls -lh dist/ 2>/dev/null || true
|
||||
@@ -20,7 +20,9 @@ TAG="${1:?usage: release-server.sh <tag>}"
|
||||
ver_file="/tmp/release_server_ver.$$"
|
||||
ver_from_tag server "$TAG" > "$ver_file"
|
||||
VER=""
|
||||
read -r VER < "$ver_file"
|
||||
# `|| true`: ver_from_tag 用 printf '%s'(无结尾换行),read 到无换行的 EOF 会返回 1
|
||||
# (但 VER 已正确赋值)——set -e 下会静默退出。容错 read 的这个退出码,不掩盖真错。
|
||||
read -r VER < "$ver_file" || true
|
||||
rm -f "$ver_file"
|
||||
|
||||
echo "==> release-server: tag=${TAG} ver=${VER}"
|
||||
|
||||
+4
-9
@@ -21,15 +21,10 @@ TARGET="${1:-}"
|
||||
|
||||
case "$TARGET" in
|
||||
server)
|
||||
mkdir -p "$HOME/.cache/pangolin-ci/gomod" "$HOME/.cache/pangolin-ci/gobuild"
|
||||
echo "==> test: go test ./... (golang:1.25 容器)"
|
||||
docker run --rm \
|
||||
-v "$PWD/server:/app" -w /app \
|
||||
-v "$HOME/.cache/pangolin-ci/gomod:/go/pkg/mod" \
|
||||
-v "$HOME/.cache/pangolin-ci/gobuild:/root/.cache/go-build" \
|
||||
-e GOPROXY="$GOPROXY" \
|
||||
golang:1.25 \
|
||||
go test ./...
|
||||
# 直接在 runner 跑(不嵌套 docker,避免 DinD 挂载失败)。go.mod 要求的
|
||||
# go 1.25.x 若高于 runner 自带版本,Go 工具链会经 GOPROXY(_env.sh)自动下载。
|
||||
echo "==> test: go test ./..."
|
||||
( cd server && go test ./... )
|
||||
;;
|
||||
client)
|
||||
mkdir -p "$HOME/.cache/pangolin-ci/pubcache"
|
||||
|
||||
@@ -123,6 +123,8 @@ func main() {
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(apierr.Middleware)
|
||||
// CORS:Web 用户中心(app.yanmeiai.com)跨域调 /v1/*;原生端不受影响。
|
||||
r.Use(httpapi.NewCORS())
|
||||
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -130,6 +132,19 @@ func main() {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// Public (no auth): 客户端安装包下载(官网下载按钮直链)。CI
|
||||
// (scripts/ci/deploy-client.sh)把最新安装包 scp 到 DOWNLOADS_DIR,按平台固定
|
||||
// 文件名覆盖;目录不存在也不影响启动,只是请求 404(见 DownloadsHandler 注释)。
|
||||
downloadsHandler := httpapi.NewDownloadsHandler(os.Getenv("DOWNLOADS_DIR"))
|
||||
r.Get("/downloads/*", downloadsHandler.Serve)
|
||||
|
||||
// Public (no auth): 客户端自动更新版本清单。VERSION_MANIFEST 可配置清单路径
|
||||
// (默认 /etc/pangolin/version.yaml);deploy/single-node/deploy.sh 安装仓库内
|
||||
// 默认清单,scripts/ci/release-client.sh 在每次 client-v* 发版时改写其
|
||||
// version/build_number。每次请求都重新读文件,发版脚本改完立即生效,无需重启。
|
||||
versionHandler := httpapi.NewVersionHandler(os.Getenv("VERSION_MANIFEST"))
|
||||
r.Get("/version", versionHandler.Serve)
|
||||
|
||||
// Optional probe ingest route. sharedProbeStore is reused by the scheduler
|
||||
// (below) when both are enabled, so they share one Redis-backed store.
|
||||
var sharedProbeStore *probe.Store
|
||||
@@ -370,6 +385,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
v1.Post("/auth/login", authHandler.Login)
|
||||
v1.Post("/auth/refresh", authHandler.Refresh)
|
||||
v1.Post("/auth/logout", authHandler.Logout)
|
||||
// App→网页免登录换票的公开一端;票据本身即凭证,无需 Bearer(见下方
|
||||
// 受保护分组里的签票端 /auth/web-ticket)。
|
||||
v1.Post("/auth/web-ticket/exchange", authHandler.WebTicketExchange)
|
||||
if totpHandler != nil {
|
||||
v1.Post("/auth/login/totp", totpHandler.LoginTOTP)
|
||||
}
|
||||
@@ -397,6 +415,10 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
}
|
||||
})
|
||||
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
||||
if authHandler != nil {
|
||||
// 签票端要求已登录(拿当前 JWT 的 uid/uuid);兑换端见上方公开分组。
|
||||
protected.Post("/auth/web-ticket", authHandler.WebTicket)
|
||||
}
|
||||
protected.Get("/usage", usageHandler.ServeHTTP)
|
||||
protected.Get("/usage/devices", deviceUsageHandler.ServeHTTP)
|
||||
protected.Post("/ads/unlock", adsHandler.ServeHTTP)
|
||||
|
||||
@@ -83,4 +83,13 @@ var (
|
||||
MessageZH: "服务器内部错误,请稍后重试",
|
||||
MessageEn: "Internal server error, please try again later",
|
||||
}
|
||||
|
||||
// ErrTicketInvalid — the web-ticket (App→网页免登录一次性票据) is missing,
|
||||
// already used, or expired. Intentionally generic (mirrors ErrCodeInvalid) so
|
||||
// the three cases can't be distinguished from the response.
|
||||
ErrTicketInvalid = &apierr.Error{
|
||||
Code: "auth.ticket_invalid",
|
||||
MessageZH: "登录票据无效或已过期,请重新从 App 打开",
|
||||
MessageEn: "Login ticket is invalid or expired, please reopen from the app",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/auth/login", h.Login)
|
||||
r.Post("/auth/refresh", h.Refresh)
|
||||
r.Post("/auth/logout", h.Logout)
|
||||
r.Post("/auth/web-ticket/exchange", h.WebTicketExchange)
|
||||
}
|
||||
|
||||
// Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from
|
||||
@@ -158,6 +159,74 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
|
||||
|
||||
// webTicketRequest / webTicketResponse — POST /v1/auth/web-ticket (auth-required).
|
||||
type webTicketResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// WebTicket handles POST /v1/auth/web-ticket (auth-required, mounted in the
|
||||
// RequireAuth group — see main.go). Mints a one-time ticket for the currently
|
||||
// authenticated user so the app can open the web user-center pre-authenticated
|
||||
// (`https://<host>/sso?t=<ticket>`). Rate-limited to 1/sec/user.
|
||||
func (h *Handler) WebTicket(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIErr(w, ErrUnauthorized, 0)
|
||||
return
|
||||
}
|
||||
uuid, ok := UserUUIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIErr(w, ErrUnauthorized, 0)
|
||||
return
|
||||
}
|
||||
ticket, ttl, retryAfter, apiErr := h.svc.IssueWebTicket(r.Context(), uid, uuid)
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, retryAfter)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(webTicketResponse{Ticket: ticket, ExpiresIn: ttl})
|
||||
}
|
||||
|
||||
// webTicketExchangeRequest is the public exchange body.
|
||||
type webTicketExchangeRequest struct {
|
||||
Ticket string `json:"ticket"`
|
||||
Device deviceBody `json:"device"`
|
||||
}
|
||||
|
||||
// WebTicketExchange handles POST /v1/auth/web-ticket/exchange (public, no
|
||||
// bearer auth — the ticket itself is the credential). Validates+consumes the
|
||||
// one-time ticket and issues the SAME token-pair shape as a normal login for
|
||||
// the ticket's user, registering the device like Login does. Invalid, expired,
|
||||
// or already-used tickets all map to a generic 401.
|
||||
func (h *Handler) WebTicketExchange(w http.ResponseWriter, r *http.Request) {
|
||||
var req webTicketExchangeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Ticket == "" {
|
||||
writeAPIErr(w, ErrInvalidRequest, 0)
|
||||
return
|
||||
}
|
||||
pair, deviceLimit, apiErr := h.svc.LoginWithWebTicket(r.Context(), req.Ticket, clientIP(r), req.Device.toMeta())
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, 0)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(tokenPairResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
RefreshToken: pair.RefreshToken,
|
||||
ExpiresIn: pair.ExpiresIn,
|
||||
DeviceLimit: deviceLimit,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// decodeJSON decodes the request body, writing a 400 on malformed input.
|
||||
@@ -206,7 +275,7 @@ func statusFor(e *apierr.Error) int {
|
||||
return http.StatusConflict
|
||||
case ErrRateLimited.Code, ErrAccountLocked.Code:
|
||||
return http.StatusTooManyRequests
|
||||
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code:
|
||||
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code, ErrTicketInvalid.Code:
|
||||
return http.StatusUnauthorized
|
||||
case ErrAccountBanned.Code:
|
||||
return http.StatusForbidden
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
|
||||
//
|
||||
// The app (already holding a valid JWT) mints a one-time ticket via
|
||||
// IssueWebTicket and opens the system browser at .../sso?t=<ticket>. The web
|
||||
// user-center exchanges the ticket via LoginWithWebTicket for a normal token
|
||||
// pair — same shape as /auth/login — without ever seeing the app's own tokens.
|
||||
//
|
||||
// Storage: Redis only (no DB row). Tickets are single-instance, ephemeral
|
||||
// (60s TTL) credentials — a DB table would need its own cleanup job for what
|
||||
// Redis already gives for free via key expiry. Consumption uses GETDEL, which
|
||||
// is atomic server-side: a replayed/concurrent second exchange always loses
|
||||
// the race and gets redis.Nil, so single-use is enforced without a CAS dance.
|
||||
|
||||
const (
|
||||
// webTicketTTL is the ticket lifetime: long enough for the system browser
|
||||
// to open and hit the exchange endpoint, short enough to bound replay risk.
|
||||
webTicketTTL = 60 * time.Second
|
||||
// webTicketPrefix namespaces ticket keys in Redis.
|
||||
webTicketPrefix = "auth:webticket:"
|
||||
// scopeWebTicket is the rate-limit scope for ticket issuance (1/sec/user).
|
||||
scopeWebTicket = "webticket"
|
||||
)
|
||||
|
||||
func webTicketKey(ticket string) string { return webTicketPrefix + ticket }
|
||||
|
||||
// genWebTicket returns a fresh, cryptographically random one-time ticket. 32
|
||||
// bytes (256 bits) of entropy makes guessing infeasible within the 60s TTL.
|
||||
func genWebTicket() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("auth: gen web ticket: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// IssueWebTicket mints a one-time ticket for an already-authenticated user
|
||||
// (userID/userUUID come from the caller's validated JWT claims — see
|
||||
// Handler.WebTicket). Rate-limited to 1/sec/user as a rebuff-abuse backstop
|
||||
// (the endpoint already requires login). The ticket value itself never
|
||||
// carries a real token — only enough to look the user up on exchange.
|
||||
func (s *Service) IssueWebTicket(ctx context.Context, userID int64, userUUID string) (ticket string, ttlSeconds int, retryAfter time.Duration, apiErr *apierr.Error) {
|
||||
ok, ra, err := s.rl.Allow(ctx, scopeWebTicket, strconv.FormatInt(userID, 10), 1, time.Second)
|
||||
if err != nil {
|
||||
return "", 0, 0, ErrInternal
|
||||
}
|
||||
if !ok {
|
||||
return "", 0, ra, ErrRateLimited
|
||||
}
|
||||
|
||||
tk, err := genWebTicket()
|
||||
if err != nil {
|
||||
return "", 0, 0, ErrInternal
|
||||
}
|
||||
val := userID2UUID(userID, userUUID)
|
||||
if err := s.rdb.Set(ctx, webTicketKey(tk), val, webTicketTTL).Err(); err != nil {
|
||||
return "", 0, 0, ErrInternal
|
||||
}
|
||||
return tk, int(webTicketTTL.Seconds()), 0, nil
|
||||
}
|
||||
|
||||
// LoginWithWebTicket validates+atomically-consumes a one-time ticket and
|
||||
// issues a fresh token pair for its user, registering the device exactly like
|
||||
// a normal Login (best-effort — see recordLogin). Any invalid/expired/
|
||||
// already-used ticket collapses to ErrTicketInvalid (no distinguishing info
|
||||
// leaked).
|
||||
func (s *Service) LoginWithWebTicket(ctx context.Context, ticket, ip string, device DeviceMeta) (*TokenPair, *DeviceLimit, *apierr.Error) {
|
||||
if ticket == "" {
|
||||
return nil, nil, ErrTicketInvalid
|
||||
}
|
||||
|
||||
// GETDEL is atomic: concurrent/replayed exchanges of the same ticket race
|
||||
// on a single Redis command, so exactly one caller ever sees the value.
|
||||
val, err := s.rdb.GetDel(ctx, webTicketKey(ticket)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, nil, ErrTicketInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, ErrInternal
|
||||
}
|
||||
|
||||
userID, userUUID, ok := splitUserID2UUID(val)
|
||||
if !ok {
|
||||
// Our own format, corrupt only via a Redis-level anomaly — never surface
|
||||
// internals to the (unauthenticated) caller.
|
||||
return nil, nil, ErrInternal
|
||||
}
|
||||
|
||||
pair, jti, err := s.tokens.IssueWithJTI(ctx, userID, userUUID)
|
||||
if err != nil {
|
||||
return nil, nil, ErrInternal
|
||||
}
|
||||
dl := s.recordLogin(ctx, userID, jti, ip, device)
|
||||
return pair, dl, nil
|
||||
}
|
||||
|
||||
// userID2UUID / splitUserID2UUID encode the ticket's Redis value as
|
||||
// "<userID>:<userUUID>". UUIDs are hyphenated hex (no ':'), so a single
|
||||
// SplitN(2) round-trips unambiguously.
|
||||
func userID2UUID(userID int64, userUUID string) string {
|
||||
return strconv.FormatInt(userID, 10) + ":" + userUUID
|
||||
}
|
||||
|
||||
func splitUserID2UUID(val string) (userID int64, userUUID string, ok bool) {
|
||||
parts := strings.SplitN(val, ":", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
id, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return id, parts[1], true
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// newWebTicketHandler wires a Handler with BOTH the public routes (via
|
||||
// RegisterRoutes) and the auth-required /auth/web-ticket route, mirroring how
|
||||
// main.go mounts them in two separate groups (public vs RequireAuth).
|
||||
func newWebTicketHandler(t *testing.T, cfg ServiceConfig) (*Service, http.Handler) {
|
||||
t.Helper()
|
||||
svc, _, _ := newService(t, cfg)
|
||||
h := NewHandler(svc)
|
||||
r := chi.NewRouter()
|
||||
h.RegisterRoutes(r) // public: includes /auth/web-ticket/exchange
|
||||
r.Group(func(protected chi.Router) {
|
||||
protected.Use(RequireAuth(svc.tokens))
|
||||
protected.Post("/auth/web-ticket", h.WebTicket)
|
||||
})
|
||||
return svc, r
|
||||
}
|
||||
|
||||
// issueAccessToken mints a real, valid access token for a fresh user id/uuid
|
||||
// pair — bypassing full register/login, since the ticket flow only cares that
|
||||
// RequireAuth's middleware injects a valid uid/uuid into the request context.
|
||||
func issueAccessToken(t *testing.T, svc *Service) (accessToken string, userID int64, userUUID string) {
|
||||
t.Helper()
|
||||
userID = 42
|
||||
userUUID = uuid.NewString()
|
||||
pair, _, err := svc.tokens.IssueWithJTI(context.Background(), userID, userUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("issue access token: %v", err)
|
||||
}
|
||||
return pair.AccessToken, userID, userUUID
|
||||
}
|
||||
|
||||
func doAuthed(t *testing.T, h http.Handler, method, path, token string, body interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
_ = json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.RemoteAddr = "203.0.113.5:1234"
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestWebTicket_IssueAndExchange covers the full happy path: an authed app
|
||||
// mints a ticket, the (unauthenticated) web side exchanges it for a token
|
||||
// pair shaped exactly like a normal /auth/login response.
|
||||
func TestWebTicket_IssueAndExchange(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, wantUID, wantUUID := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue status = %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var issued webTicketResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
|
||||
t.Fatalf("decode issue response: %v", err)
|
||||
}
|
||||
if issued.Ticket == "" {
|
||||
t.Fatal("empty ticket")
|
||||
}
|
||||
if issued.ExpiresIn != 60 {
|
||||
t.Fatalf("expires_in = %d, want 60", issued.ExpiresIn)
|
||||
}
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket, "device": map[string]string{"platform": "web"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("exchange status = %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var pair tokenPairResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||
t.Fatalf("decode exchange response: %v", err)
|
||||
}
|
||||
if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 {
|
||||
t.Fatalf("bad token pair: %+v", pair)
|
||||
}
|
||||
|
||||
// The exchanged access token must authenticate as the SAME user the app
|
||||
// ticket was issued for.
|
||||
claims, err := svc.tokens.ParseAccess(pair.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("parse exchanged access token: %v", err)
|
||||
}
|
||||
if claims.UID != wantUID || claims.Subject != wantUUID {
|
||||
t.Fatalf("exchanged token identifies uid=%d uuid=%s, want uid=%d uuid=%s",
|
||||
claims.UID, claims.Subject, wantUID, wantUUID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_SingleUse_ReplayRejected: a second exchange of the same
|
||||
// ticket (replay) must fail 401, even though the first succeeded.
|
||||
func TestWebTicket_SingleUse_ReplayRejected(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, _, _ := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
var issued webTicketResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first exchange should succeed, got %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("replayed exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_Expired_Rejected simulates TTL expiry the same way the rest of
|
||||
// this package does (service_test.go TestService_CodeExpired): delete the
|
||||
// Redis key directly rather than fast-forwarding a clock.
|
||||
func TestWebTicket_Expired_Rejected(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, _, _ := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
var issued webTicketResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
|
||||
|
||||
svc.rdb.Del(context.Background(), webTicketKey(issued.Ticket))
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expired ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_Bogus_Rejected: a made-up ticket that was never issued.
|
||||
func TestWebTicket_Bogus_Rejected(t *testing.T) {
|
||||
_, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": "not-a-real-ticket"})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bogus ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_RequiresAuth: minting a ticket without a bearer token is
|
||||
// rejected by RequireAuth before it ever reaches the handler.
|
||||
func TestWebTicket_RequiresAuth(t *testing.T) {
|
||||
_, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", "", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated issue status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_RateLimited: a second ticket request within the same second
|
||||
// for the same user is rejected 429 (rebuff-abuse backstop; the endpoint
|
||||
// already requires login).
|
||||
func TestWebTicket_RateLimited(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, _, _ := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first issue status = %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second issue status = %d, want 429 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewCORS 返回一个 CORS 中间件:为白名单 Origin(精确匹配)补 CORS 响应头,并直接
|
||||
// 应答 OPTIONS 预检。Web 用户中心(app.yanmeiai.com,浏览器)跨域调用 /v1/*,浏览器
|
||||
// 会先发预检、并校验 Access-Control-Allow-Origin;原生移动/桌面客户端不是浏览器、
|
||||
// 不受 CORS 约束,故此前无需 CORS。
|
||||
//
|
||||
// Origin 白名单来自 CORS_ORIGINS(逗号分隔),缺省含 usercenter 的正式域与 pages.dev。
|
||||
// 认证走 Authorization: Bearer(非 cookie),所以不需要 Allow-Credentials。
|
||||
func NewCORS() func(http.Handler) http.Handler {
|
||||
raw := os.Getenv("CORS_ORIGINS")
|
||||
if raw == "" {
|
||||
raw = "https://app.yanmeiai.com,https://pangolin-usercenter.pages.dev"
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, o := range strings.Split(raw, ",") {
|
||||
o = strings.TrimSpace(o)
|
||||
if o != "" {
|
||||
allowed[o] = true
|
||||
}
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && allowed[origin] {
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
}
|
||||
// 预检请求直接 204(不落到业务路由,避免 /v1/... 的 OPTIONS 404)。
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCORS(t *testing.T) {
|
||||
t.Setenv("CORS_ORIGINS", "https://app.yanmeiai.com")
|
||||
mw := NewCORS()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||
h := mw(next)
|
||||
|
||||
// 允许的 Origin:补 Allow-Origin,普通请求继续。
|
||||
r := httptest.NewRequest("POST", "/v1/auth/login", nil)
|
||||
r.Header.Set("Origin", "https://app.yanmeiai.com")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.yanmeiai.com" {
|
||||
t.Fatalf("allowed origin: want header, got %q", got)
|
||||
}
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("non-preflight should reach next, got %d", w.Code)
|
||||
}
|
||||
|
||||
// OPTIONS 预检:204,不落到业务。
|
||||
r = httptest.NewRequest("OPTIONS", "/v1/auth/login", nil)
|
||||
r.Header.Set("Origin", "https://app.yanmeiai.com")
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight: want 204, got %d", w.Code)
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Methods") == "" {
|
||||
t.Fatalf("preflight missing Allow-Methods")
|
||||
}
|
||||
|
||||
// 未白名单 Origin:不补 Allow-Origin。
|
||||
r = httptest.NewRequest("POST", "/v1/auth/login", nil)
|
||||
r.Header.Set("Origin", "https://evil.example.com")
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("disallowed origin should get no header, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// DownloadsHandler 静态服务客户端安装包(pangolin-android.apk /
|
||||
// pangolin-windows-x64-setup.exe 等),供官网下载按钮直链。CI
|
||||
// (scripts/ci/deploy-client.sh)把最新安装包 scp 到该目录、按平台固定文件名覆盖。
|
||||
//
|
||||
// 无需鉴权(匿名下载),但:
|
||||
// - 不暴露目录列表(裸目录 / 不存在的文件一律 404,不用 http.FileServer 的默认
|
||||
// 目录浏览行为)
|
||||
// - 防目录穿越(拒绝任何解析后逃出 dir 的路径,而不仅仅是字符串里含 ".." 就拒,
|
||||
// 这样能正确处理 "foo/../bar" 这类仍落在 dir 内的写法,同时挡住真正逃逸的路径)
|
||||
// - DOWNLOADS_DIR 在启动时可以不存在(比如还没跑过一次 CI 部署),路由仍要能
|
||||
// 注册,只是请求会 404,不能让进程直接崩溃
|
||||
type DownloadsHandler struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewDownloadsHandler 指定安装包所在目录(DOWNLOADS_DIR,默认 /var/lib/pangolin/downloads)。
|
||||
func NewDownloadsHandler(dir string) *DownloadsHandler {
|
||||
if dir == "" {
|
||||
dir = "/var/lib/pangolin/downloads"
|
||||
}
|
||||
return &DownloadsHandler{dir: dir}
|
||||
}
|
||||
|
||||
// Serve 处理 GET /downloads/{file}(chi 通配 "*",支持任意文件名,不限定白名单,
|
||||
// 因为 CI 产出的安装包文件名随平台/版本命名策略变化,这里只做路径安全校验)。
|
||||
func (h *DownloadsHandler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "*")
|
||||
if name == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 拒绝任何路径分隔符之外的穿越:先按 "/" 做 Clean,再校验结果既不是绝对路径、
|
||||
// 也没有以 ".." 开头(即没有逃出 dir),最后拒绝空/根路径。这比单纯 strings.Contains(name, "..")
|
||||
// 更准确 —— 例如 "sub/../file.apk" 清洗后是 "file.apk",本来就没有逃逸,不该被误杀;
|
||||
// 而 "../etc/passwd" 清洗后以 ".." 开头,必须拒绝。
|
||||
cleaned := filepath.Clean("/" + name) // 前置 "/" 后 Clean,任何 ".." 都无法越过根
|
||||
cleaned = strings.TrimPrefix(cleaned, "/")
|
||||
if cleaned == "" || cleaned == "." || strings.HasPrefix(cleaned, "..") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
full := filepath.Join(h.dir, cleaned)
|
||||
// 双重保险:确认最终路径确实在 dir 之下(处理 dir 本身含 ".." 或符号链接等边角情况)。
|
||||
relDir, err := filepath.Abs(h.dir)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
absFull, err := filepath.Abs(full)
|
||||
if err != nil || (absFull != relDir && !strings.HasPrefix(absFull, relDir+string(filepath.Separator))) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(full)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil || st.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(cleaned)+"\"")
|
||||
// no-cache:CI「仅留最新」在同一稳定 URL 覆盖文件,不能让 CF/浏览器返回旧包。
|
||||
// no-cache = 可缓存但每次须回源校验;ServeContent 带 Last-Modified,未变则 304
|
||||
// (便宜),变了才传新字节 —— 既保证永远最新,又避免每次全量 82MB 回源。
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, filepath.Base(cleaned), st.ModTime(), f)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// buildDownloadsRouter mounts DownloadsHandler on a real chi router (mirrors
|
||||
// how main.go mounts it under "/downloads/*") so the wildcard param behaves
|
||||
// exactly as it does in production.
|
||||
func buildDownloadsRouter(dir string) chi.Router {
|
||||
h := NewDownloadsHandler(dir)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/downloads/*", h.Serve)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestDownloadsHandler_ServesExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := []byte("hello pangolin apk bytes")
|
||||
if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), content, 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
r := buildDownloadsRouter(dir)
|
||||
req := httptest.NewRequest("GET", "/downloads/pangolin-android.apk", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.String(); got != string(content) {
|
||||
t.Errorf("body = %q, want %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadsHandler_MissingFile404(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := buildDownloadsRouter(dir)
|
||||
|
||||
req := httptest.NewRequest("GET", "/downloads/nope.exe", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadsHandler_BareDirNotListed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
r := buildDownloadsRouter(dir)
|
||||
|
||||
req := httptest.NewRequest("GET", "/downloads/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("bare dir status = %d, want 404 (no directory listing)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadsHandler_PathTraversalBlocked(t *testing.T) {
|
||||
outerDir := t.TempDir()
|
||||
secretPath := filepath.Join(outerDir, "secret.txt")
|
||||
if err := os.WriteFile(secretPath, []byte("top secret"), 0o600); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(outerDir, "downloads")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir downloads: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), []byte("apk"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
r := buildDownloadsRouter(dir)
|
||||
|
||||
// net/http's ServeMux/chi normalize ".." segments in the URL path before
|
||||
// routing, so we exercise the handler directly with a raw URLParam to
|
||||
// simulate any escape attempt that might otherwise reach it, in addition
|
||||
// to the router-level request below.
|
||||
h := NewDownloadsHandler(dir)
|
||||
req := httptest.NewRequest("GET", "/downloads/../secret.txt", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("*", "../secret.txt")
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.Serve(rec, req)
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("direct traversal status = %d, want 404 (must not escape dir)", rec.Code)
|
||||
}
|
||||
if rec.Body.String() == "top secret" {
|
||||
t.Fatalf("traversal leaked secret file contents")
|
||||
}
|
||||
|
||||
// Router-level request: most HTTP clients/servers collapse ".." during URL
|
||||
// normalization, but confirm the end-to-end path also can't reach the file
|
||||
// outside dir and doesn't 200 with the secret's contents.
|
||||
req2 := httptest.NewRequest("GET", "/downloads/../secret.txt", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec2, req2)
|
||||
if rec2.Body.String() == "top secret" {
|
||||
t.Fatalf("router-level traversal leaked secret file contents (status=%d)", rec2.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// defaultVersionManifestPath is VERSION_MANIFEST's default when unset — mirrors
|
||||
// how server.env wires DOWNLOADS_DIR alongside DownloadsHandler's own built-in
|
||||
// default (see downloads.go / deploy/single-node/deploy.sh).
|
||||
const defaultVersionManifestPath = "/etc/pangolin/version.yaml"
|
||||
|
||||
// Fallback values used ONLY when the manifest file itself is missing (fresh
|
||||
// box that hasn't run deploy/single-node/deploy.sh's manifest-install step
|
||||
// yet, or a local dev server). These are hand-set, not auto-derived — once a
|
||||
// box is deployed, the real source of truth is the on-disk manifest that
|
||||
// scripts/ci/release-client.sh overwrites on every client-v* release.
|
||||
const (
|
||||
defaultManifestVersion = "1.0.48"
|
||||
defaultManifestBuildNumber = 10048
|
||||
)
|
||||
|
||||
// changelogSection / changelogEntry mirror jiu's backend/config/version.yaml
|
||||
// shape (~/code/jiu/backend/internal/handler/version.go) so any future shared
|
||||
// tooling (e.g. a website changelog widget) can treat both services'
|
||||
// /version responses identically. Pangolin's release pipeline doesn't
|
||||
// populate Changelog yet (no CHANGELOG-client.md parsing wired in
|
||||
// scripts/ci/release-client.sh) — the field exists for shape-compatibility
|
||||
// and always serializes as [] rather than null.
|
||||
type changelogSection struct {
|
||||
Type string `yaml:"type" json:"type"`
|
||||
Items []string `yaml:"items" json:"items"`
|
||||
}
|
||||
|
||||
type changelogEntry struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Date string `yaml:"date" json:"date"`
|
||||
Intro string `yaml:"intro" json:"intro"`
|
||||
Sections []changelogSection `yaml:"sections" json:"sections"`
|
||||
}
|
||||
|
||||
// versionManifest is the on-disk (and wire) shape of the auto-update
|
||||
// manifest. download_urls keys in practice: macos, windows, ios, android
|
||||
// (web is intentionally not used — pangolin's client is native-only).
|
||||
type versionManifest struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
BuildNumber int `yaml:"build_number" json:"build_number"`
|
||||
ForceUpdate bool `yaml:"force_update" json:"force_update"`
|
||||
ReleaseNotes string `yaml:"release_notes" json:"release_notes"`
|
||||
DownloadURLs map[string]string `yaml:"download_urls" json:"download_urls"`
|
||||
Changelog []changelogEntry `yaml:"changelog" json:"changelog"`
|
||||
}
|
||||
|
||||
// VersionHandler serves GET /version (public, no auth — mounted directly in
|
||||
// main.go next to /healthz and /downloads/*). Unlike most handlers in this
|
||||
// package it re-reads its manifest file from disk on EVERY request rather
|
||||
// than caching it in memory: scripts/ci/release-client.sh rewrites
|
||||
// /etc/pangolin/version.yaml's version/build_number on each client-v*
|
||||
// release, and that must take effect immediately without a control-plane
|
||||
// restart or redeploy (mirrors jiu's loadVersionConfig()-per-request).
|
||||
type VersionHandler struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// NewVersionHandler builds a VersionHandler reading the manifest at path.
|
||||
// path=="" falls back to defaultVersionManifestPath (in production this is
|
||||
// overridden via the VERSION_MANIFEST env var — see main.go).
|
||||
func NewVersionHandler(path string) *VersionHandler {
|
||||
if path == "" {
|
||||
path = defaultVersionManifestPath
|
||||
}
|
||||
return &VersionHandler{path: path}
|
||||
}
|
||||
|
||||
// defaultManifest is returned when the manifest file doesn't exist yet, so
|
||||
// GET /version still answers usefully (client update-checks shouldn't hard
|
||||
// fail just because deploy/single-node/deploy.sh hasn't run on this box).
|
||||
func defaultManifest() versionManifest {
|
||||
return versionManifest{
|
||||
Version: defaultManifestVersion,
|
||||
BuildNumber: defaultManifestBuildNumber,
|
||||
ForceUpdate: false,
|
||||
ReleaseNotes: "",
|
||||
DownloadURLs: map[string]string{
|
||||
"android": "https://api.yanmeiai.com/downloads/pangolin-android.apk",
|
||||
"windows": "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe",
|
||||
"macos": "",
|
||||
"ios": "",
|
||||
},
|
||||
Changelog: []changelogEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// loadManifest reads+parses h.path. A missing file is NOT an error (falls
|
||||
// back to defaultManifest()); any other read/parse failure IS, so ServeHTTP
|
||||
// can 500 instead of silently masking a corrupt manifest written by a bad
|
||||
// release-client.sh run.
|
||||
func (h *VersionHandler) loadManifest() (versionManifest, error) {
|
||||
data, err := os.ReadFile(h.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return defaultManifest(), nil
|
||||
}
|
||||
return versionManifest{}, err
|
||||
}
|
||||
var m versionManifest
|
||||
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||
return versionManifest{}, err
|
||||
}
|
||||
// Keep the JSON response shape stable (empty object/array, never null)
|
||||
// regardless of what the manifest on disk happens to omit.
|
||||
if m.DownloadURLs == nil {
|
||||
m.DownloadURLs = map[string]string{}
|
||||
}
|
||||
if m.Changelog == nil {
|
||||
m.Changelog = []changelogEntry{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Serve handles GET /version. Named Serve (not ServeHTTP) to match
|
||||
// DownloadsHandler's convention in this package (see downloads.go).
|
||||
func (h *VersionHandler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
m, err := h.loadManifest()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "version manifest unavailable"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(m)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// buildVersionRouter mounts VersionHandler on a real chi router (mirrors how
|
||||
// main.go mounts GET /version) so routing behaves exactly as in production.
|
||||
func buildVersionRouter(path string) chi.Router {
|
||||
h := NewVersionHandler(path)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/version", h.Serve)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestVersionHandler_ServesManifestFromDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "version.yaml")
|
||||
yamlContent := `version: "1.2.3"
|
||||
build_number: 10203
|
||||
force_update: true
|
||||
release_notes: "测试发布说明"
|
||||
download_urls:
|
||||
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
|
||||
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
|
||||
macos: ""
|
||||
ios: ""
|
||||
changelog:
|
||||
- version: "1.2.3"
|
||||
date: "2026-07-06"
|
||||
intro: "小版本更新"
|
||||
sections:
|
||||
- type: "新增"
|
||||
items:
|
||||
- "示例条目"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yamlContent), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
r := buildVersionRouter(path)
|
||||
req := httptest.NewRequest("GET", "/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var got versionManifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
|
||||
if got.Version != "1.2.3" {
|
||||
t.Errorf("version = %q, want %q", got.Version, "1.2.3")
|
||||
}
|
||||
if got.BuildNumber != 10203 {
|
||||
t.Errorf("build_number = %d, want %d", got.BuildNumber, 10203)
|
||||
}
|
||||
if !got.ForceUpdate {
|
||||
t.Errorf("force_update = false, want true")
|
||||
}
|
||||
if got.ReleaseNotes != "测试发布说明" {
|
||||
t.Errorf("release_notes = %q, want %q", got.ReleaseNotes, "测试发布说明")
|
||||
}
|
||||
if got.DownloadURLs["android"] != "https://api.yanmeiai.com/downloads/pangolin-android.apk" {
|
||||
t.Errorf("download_urls.android = %q", got.DownloadURLs["android"])
|
||||
}
|
||||
if got.DownloadURLs["windows"] != "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe" {
|
||||
t.Errorf("download_urls.windows = %q", got.DownloadURLs["windows"])
|
||||
}
|
||||
if got.DownloadURLs["macos"] != "" || got.DownloadURLs["ios"] != "" {
|
||||
t.Errorf("expected empty macos/ios download URLs, got macos=%q ios=%q", got.DownloadURLs["macos"], got.DownloadURLs["ios"])
|
||||
}
|
||||
if len(got.Changelog) != 1 || got.Changelog[0].Version != "1.2.3" {
|
||||
t.Errorf("changelog = %+v, want 1 entry for version 1.2.3", got.Changelog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler_MissingFileReturnsDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "does-not-exist.yaml")
|
||||
|
||||
r := buildVersionRouter(path)
|
||||
req := httptest.NewRequest("GET", "/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200 (missing file falls back to default manifest); body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var got versionManifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if got.Version == "" {
|
||||
t.Errorf("default manifest: version should not be empty")
|
||||
}
|
||||
if got.DownloadURLs["android"] == "" {
|
||||
t.Errorf("default manifest: download_urls.android should not be empty")
|
||||
}
|
||||
if got.DownloadURLs["windows"] == "" {
|
||||
t.Errorf("default manifest: download_urls.windows should not be empty")
|
||||
}
|
||||
if got.Changelog == nil {
|
||||
t.Errorf("default manifest: changelog should be [] not null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler_JSONShape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "version.yaml")
|
||||
if err := os.WriteFile(path, []byte(`version: "1.0.0"
|
||||
build_number: 10000
|
||||
force_update: false
|
||||
release_notes: ""
|
||||
download_urls:
|
||||
android: "https://example.com/a.apk"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
r := buildVersionRouter(path)
|
||||
req := httptest.NewRequest("GET", "/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("unmarshal raw response: %v", err)
|
||||
}
|
||||
for _, key := range []string{"version", "build_number", "force_update", "release_notes", "download_urls", "changelog"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Errorf("response missing expected top-level key %q; body=%s", key, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// changelog must serialize as an array even when the manifest omits it —
|
||||
// front-ends should be able to blindly .map()/range over it.
|
||||
if string(raw["changelog"]) != "[]" {
|
||||
t.Errorf("changelog = %s, want []", raw["changelog"])
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler_DefaultPathFallback(t *testing.T) {
|
||||
h := NewVersionHandler("")
|
||||
if h.path != defaultVersionManifestPath {
|
||||
t.Errorf("NewVersionHandler(\"\").path = %q, want %q", h.path, defaultVersionManifestPath)
|
||||
}
|
||||
}
|
||||
+34
-5
@@ -253,12 +253,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<h1>feature+windows — 项目 TODO</h1>
|
||||
<div class="header-meta">生成于 2026-06-30 · 真相源 todo/todo.json</div>
|
||||
<div class="header-meta">生成于 2026-07-01 · 真相源 todo/todo.json</div>
|
||||
<div class="stats">
|
||||
<div class="stat-pill"><strong>16</strong>全部</div>
|
||||
<div class="stat-pill"><strong>17</strong>全部</div>
|
||||
<div class="stat-pill"><strong>8</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>0</strong>开发中</div>
|
||||
<div class="stat-pill"><strong>0</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>1</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>8</strong>已验收</div>
|
||||
|
||||
</div>
|
||||
@@ -563,12 +563,41 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
</div>
|
||||
<div class="section-block" id="section-done">
|
||||
<div class="section-title st-done" data-toggle="done">
|
||||
🔍 待验收 <span class="s-count">0</span>
|
||||
🔍 待验收 <span class="s-count">1</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-done">
|
||||
<ul class="todo-list" id="list-done">
|
||||
<p class="empty-tip">暂无条目</p>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="18"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="3"
|
||||
data-tags="mac,前端">
|
||||
<div class="card-header">
|
||||
<span class="item-id">#18</span>
|
||||
<span class="item-title">看门狗后台唤醒误判节点死→假重连</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-3">三级</span>
|
||||
|
||||
<button class="reject-btn" data-id="18" data-title="看门狗后台唤醒误判节点死→假重连">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">app/Mac 后台或睡眠一段时间再打开,连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,_lastUrltestOk 在挂起期冻住→唤醒时误判>45s→_onNodeUnhealthy 触发 disconnect+connect 假重连(智能模式还自动切节点),但隧道没掉。修:_onStats 记 _lastStatsAt,路径A 仅在 stats 仍在流但 urltest 停更时才判死;stats 整体停(挂起)则跳过。connection_provider.dart 一处。</div>
|
||||
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-07-01</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+18
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "feature+windows — 项目 TODO",
|
||||
"updated_at": "2026-06-30T14:47:39.344Z"
|
||||
"updated_at": "2026-06-30T23:10:43.458Z"
|
||||
},
|
||||
"seq": 17,
|
||||
"seq": 18,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -254,6 +254,22 @@
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"title": "看门狗后台唤醒误判节点死→假重连",
|
||||
"desc": "app/Mac 后台或睡眠一段时间再打开,连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,_lastUrltestOk 在挂起期冻住→唤醒时误判>45s→_onNodeUnhealthy 触发 disconnect+connect 假重连(智能模式还自动切节点),但隧道没掉。修:_onStats 记 _lastStatsAt,路径A 仅在 stats 仍在流但 urltest 停更时才判死;stats 整体停(挂起)则跳过。connection_provider.dart 一处。",
|
||||
"level": "mid",
|
||||
"tier": 3,
|
||||
"tags": [
|
||||
"mac",
|
||||
"前端"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-30T23:00:25.193Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const viewport: Viewport = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh" data-theme="light">
|
||||
<html lang="zh" data-theme="light" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
// app/sso/page.tsx — App→网页免登录落地页(镜像 jiu 的 web/sso.njk)。
|
||||
//
|
||||
// 流程:App 已持正常登录态,先 POST /v1/auth/web-ticket 签一次性票据(60s TTL、
|
||||
// 单次可用),再打开系统浏览器到 https://<usercenter>/sso/?t=<ticket>&redirect=<本站路径>。
|
||||
// 本页凭票 POST /v1/auth/web-ticket/exchange(公开端点)兑换与 login() 同形状的
|
||||
// token pair,经与登录页相同的 setSession(见 lib/api/http.ts::exchangeWebTicket)
|
||||
// 落地会话,再跳 redirect —— 其余页面(UserCenter.tsx)据此把它当作一次正常登录。
|
||||
//
|
||||
// 因 next.config.js 是 output:'export' 的纯静态导出,这里没有服务端 searchParams
|
||||
// 可用,票据与 redirect 目标都在挂载后从 window.location.search 读取。
|
||||
//
|
||||
// 安全要点(与 jiu 的 sso.njk 一致):
|
||||
// - URL 只携带一次性票据,从不携带真正的 access/refresh token;
|
||||
// - redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对
|
||||
// 路径,其余一律回退到 '/'(登录页),防 open redirect;
|
||||
// - 票据用后立即从地址栏抹掉(history.replaceState),不留浏览器历史;
|
||||
// - 兑换失败(票据无效/过期/已用)一律落回登录页('/'),不泄露失败原因细节。
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Mark } from '../../components/icons';
|
||||
import { ErrorLine } from '../../components/Login';
|
||||
import { card } from '../../components/shared';
|
||||
import { useUI } from '../../lib/theme';
|
||||
import { makeT } from '../../lib/i18n';
|
||||
import { getClient } from '../../lib/api/client';
|
||||
import { bilingual } from '../../lib/api/errors';
|
||||
|
||||
/** redirect 白名单:仅本站相对路径(单个 '/' 开头),其余一律回 '/'。 */
|
||||
function safeRedirect(raw: string | null): string {
|
||||
if (!raw) return '/';
|
||||
if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return '/';
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function SsoPage() {
|
||||
const { lang } = useUI();
|
||||
const t = makeT(lang);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ticket = params.get('t');
|
||||
const redirect = safeRedirect(params.get('redirect'));
|
||||
|
||||
// 票据只用一次,立刻从地址栏抹掉,不留浏览器历史(与 jiu 一致)。
|
||||
try {
|
||||
window.history.replaceState(null, '', '/sso/');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getClient();
|
||||
api
|
||||
.exchangeWebTicket(ticket)
|
||||
.then(() => {
|
||||
if (!alive) return;
|
||||
window.location.replace(redirect);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setMsg(bilingual(e, lang));
|
||||
setFailed(true);
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// 仅挂载时读取一次 URL 并发起兑换;lang 变化不应重跑网络请求。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 24 }}>
|
||||
<div style={{ ...card, width: 420, maxWidth: '100%', padding: '36px 34px', boxSizing: 'border-box', textAlign: 'center' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 18 }}>
|
||||
<Mark size={32} />
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 20, color: 'var(--fg1)' }}>
|
||||
{failed ? t('ssoFailTitle') : t('ssoTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, color: 'var(--fg3)', margin: '8px 0 0' }}>
|
||||
{failed ? t('ssoFailHint') : t('ssoMsg')}
|
||||
</div>
|
||||
{failed && msg && (
|
||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
|
||||
<ErrorLine text={msg} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,10 +100,9 @@ export default function UserCenter() {
|
||||
setView('overview');
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return <div style={{ minHeight: '100vh', background: 'var(--bg)' }} />;
|
||||
}
|
||||
if (!authed) {
|
||||
// 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白
|
||||
// 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。
|
||||
if (!ready || !authed) {
|
||||
return <Login onDone={() => { setAuthed(true); setView('overview'); }} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
|
||||
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' },
|
||||
device_not_found: { zh: '设备不存在', en: 'Device not found' },
|
||||
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' },
|
||||
'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app' },
|
||||
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
|
||||
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
|
||||
};
|
||||
|
||||
@@ -142,6 +142,21 @@ export class HttpClient implements ApiClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
// App→Web 免登录:POST /v1/auth/web-ticket/exchange(公开端点,无 Authorization 头)。
|
||||
// 服务端消费一次性票据后返回与 /v1/auth/login 相同的扁平 TokenPair(不会走 TOTP 分支——
|
||||
// 票据本身已代表 App 内已完成的完整登录),故直接映射 session 并 setSession,与
|
||||
// login()/loginTotp() 落地同一份会话状态。
|
||||
async exchangeWebTicket(ticket: string): Promise<Session> {
|
||||
const r = await this.request<RawTokenPair>('/v1/auth/web-ticket/exchange', {
|
||||
method: 'POST',
|
||||
body: { ticket },
|
||||
auth: false,
|
||||
});
|
||||
const session = mapSession(r);
|
||||
setSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
const rt = getRefreshToken();
|
||||
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
|
||||
|
||||
@@ -64,6 +64,21 @@ export class MockClient implements ApiClient {
|
||||
return s;
|
||||
}
|
||||
|
||||
// 演示态:任意非 'invalid' 票据都兑换成功;'invalid' 用于验收失败态文案。
|
||||
async exchangeWebTicket(ticket: string): Promise<Session> {
|
||||
await delay(300);
|
||||
if (ticket === 'invalid') {
|
||||
throw new ApiError({
|
||||
code: 'auth.ticket_invalid',
|
||||
message_zh: '登录票据无效或已过期,请重新从 App 打开',
|
||||
message_en: 'Login ticket is invalid or expired, please reopen from the app',
|
||||
});
|
||||
}
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
await delay(150);
|
||||
const s = makeSession();
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface ApiClient {
|
||||
login(email: string, password: string): Promise<LoginResult>;
|
||||
/** 登录二段式:提交 TOTP 动态码换取 session */
|
||||
loginTotp(pendingToken: string, code: string): Promise<Session>;
|
||||
/** App→Web 免登录:凭一次性票据兑换与 login() 同形状的会话(公开端点,无需 TOTP) */
|
||||
exchangeWebTicket(ticket: string): Promise<Session>;
|
||||
refresh(): Promise<Session>;
|
||||
getMe(): Promise<Me>;
|
||||
getSubscription(): Promise<SubscriptionInfo>;
|
||||
|
||||
@@ -27,6 +27,12 @@ export const STRINGS: Record<string, Entry> = {
|
||||
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
|
||||
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' },
|
||||
|
||||
/* sso(App→网页免登录落地页) */
|
||||
ssoTitle: { zh: '正在登录…', en: 'Signing in…' },
|
||||
ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app' },
|
||||
ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed' },
|
||||
ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually' },
|
||||
|
||||
/* overview */
|
||||
greeting: { zh: '欢迎回来', en: 'Welcome back' },
|
||||
curPlan: { zh: '当前套餐', en: 'Current plan' },
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
import { SITE } from '../config/site';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const plats = [
|
||||
// href 缺省 = 本轮未接入下载(iOS / macOS / Linux),按钮渲染为禁用态占位。
|
||||
// Android + Windows 已由客户端 CI 产出真实产物并部署到 /downloads。
|
||||
const plats: { icon: string; name: string; ver: string; href?: string }[] = [
|
||||
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
|
||||
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+' },
|
||||
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android },
|
||||
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+' },
|
||||
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11' },
|
||||
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11', href: SITE.downloads.windows },
|
||||
{ icon: 'terminal', name: 'Linux', ver: 'deb / rpm' },
|
||||
];
|
||||
---
|
||||
@@ -26,7 +29,11 @@ const plats = [
|
||||
<div class="ico"><Icon name={p.icon} /></div>
|
||||
<div class="pn">{p.name}</div>
|
||||
<div class="pv">{p.ver}</div>
|
||||
<a class="gb"><Icon name="download" /><span>{t('dl.get')}</span></a>
|
||||
{p.href ? (
|
||||
<a class="gb" href={p.href}><Icon name="download" /><span>{t('dl.get')}</span></a>
|
||||
) : (
|
||||
<span class="gb disabled" aria-disabled="true"><Icon name="download" /><span>{t('dl.soon')}</span></span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Menu } from 'lucide-react';
|
||||
import { SITE } from '../config/site';
|
||||
|
||||
function Mark() {
|
||||
return (
|
||||
@@ -60,8 +61,7 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
<a data-lang="zh" class={lang === 'zh' ? 'on' : undefined} href="/">中文</a>
|
||||
<a data-lang="en" class={lang === 'en' ? 'on' : undefined} href="/en/">EN</a>
|
||||
</div>
|
||||
{/* 「登录」暂移除:Web 用户中心(web/usercenter)未部署、无 URL;待 #30 CI/CD
|
||||
部署后接上 usercenter 登录地址再恢复。 */}
|
||||
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||
<a class="btn btn-primary" href="#download">
|
||||
<Download />
|
||||
<span>{t.get}</span>
|
||||
|
||||
@@ -19,4 +19,18 @@ export const SITE = {
|
||||
|
||||
/** 自助发卡商店 —— 占位待定(#24)。 */
|
||||
store: { label: 'shop.pangolin.vpn' },
|
||||
|
||||
/** Web 用户中心(web/usercenter,Next.js 静态导出 → CF Pages)。登录/订阅/设备管理入口。 */
|
||||
usercenter: 'https://app.yanmeiai.com',
|
||||
|
||||
/**
|
||||
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
|
||||
* /downloads/<file>(origin = 127.0.0.1:8080),文件由
|
||||
* scripts/ci/deploy-client.sh 每次构建 scp 覆盖到 pangolin1:/var/lib/pangolin/downloads/,
|
||||
* 每平台固定文件名、只保留最新一份。iOS / macOS / Linux 本轮未接入下载按钮。
|
||||
*/
|
||||
downloads: {
|
||||
android: 'https://api.yanmeiai.com/downloads/pangolin-android.apk',
|
||||
windows: 'https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -128,6 +128,7 @@ export const STRINGS: Record<string, [string, string]> = {
|
||||
'dl.h': ['全平台,随处可用', 'Every platform, everywhere'],
|
||||
'dl.sub': ['一个账户,所有设备同步。下载即用,无需配置。', 'One account syncs every device. Download and go.'],
|
||||
'dl.get': ['下载', 'Download'],
|
||||
'dl.soon': ['敬请期待', 'Coming soon'],
|
||||
|
||||
'docs.eyebrow': ['文档', 'Docs'],
|
||||
'docs.h': ['需要帮助?都在这里', 'Need help? It’s all here'],
|
||||
|
||||
@@ -189,6 +189,8 @@ section{padding:88px 0}
|
||||
.dl .pv{font-size:12px;color:var(--fg3);margin:3px 0 14px;font-family:var(--font-mono)}
|
||||
.dl .gb{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--accent)}
|
||||
.dl .gb svg{width:14px;height:14px}
|
||||
.dl .gb.disabled{color:var(--fg3);cursor:not-allowed;pointer-events:none}
|
||||
.dl .gb.disabled svg{opacity:.5}
|
||||
|
||||
/* ---------- docs ---------- */
|
||||
.docs-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-top:44px}
|
||||
|
||||
Reference in New Issue
Block a user