Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4561ee4cc3 | |||
| a53bbe743e | |||
| 7b1f49c182 | |||
| 8c8486f9af | |||
| 7d0f4c8065 | |||
| 3d521973ab | |||
| cb8e4bddd8 | |||
| 4cfe4d0ccb | |||
| 366695ffd6 | |||
| 2e631ed3b5 | |||
| 2698116696 |
@@ -55,7 +55,11 @@ jobs:
|
||||
/mnt/scripts/ci/release-server.sh \
|
||||
/mnt/scripts/ci/deploy-server.sh \
|
||||
/mnt/scripts/ci/test.sh \
|
||||
/mnt/scripts/ci/backup-db.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: |
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ GRPC_CERT_PATH=$ETC/grpc.crt
|
||||
GRPC_KEY_PATH=$ETC/grpc.key
|
||||
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
|
||||
PANGOLIN_RULES_DIR=$DATA_DIR/rules
|
||||
DOWNLOADS_DIR=$DATA_DIR/downloads
|
||||
EOF
|
||||
if [ -n "${SMTP_HOST:-}" ]; then
|
||||
cat >> "$ETC/server.env" <<EOF
|
||||
@@ -201,6 +202,15 @@ 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"
|
||||
|
||||
# ── 7. 迁移 + seed(SQLite)────────────────────────────────────────────────────
|
||||
log "执行迁移(sqlite)..."
|
||||
DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up
|
||||
|
||||
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"
|
||||
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
|
||||
@@ -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,12 @@ 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)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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 / Windows / Linux),按钮渲染为禁用态占位。
|
||||
// 本轮仅 Android 有真实产物;Windows 待其 CI(需 windows runner)产出 exe 后接
|
||||
// SITE.downloads.windows(已在 site.ts 备好)。
|
||||
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: 'terminal', name: 'Linux', ver: 'deb / rpm' },
|
||||
@@ -26,7 +30,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