Compare commits

..

4 Commits

Author SHA1 Message Date
wangjia dd721fafc8 fix(scripts): local_test.sh ipad 改走公司分发证书(ad-hoc),装机前核验签名主体
原 `ipad` 子命令用 `flutter run --release` 装真机,走的是 Xcode 工程的**开发**
签名。Apple 只把 Development 证书签发给团队里的**个人成员**(不存在组织版),
于是装到机上「设置→通用→VPN 与设备管理」里开发者显示成个人姓名,而不是
Yanmei (beijing) Technology Co., Ltd。

改为 ad-hoc 分发(Xcode 15+ 的 method=release-testing)+ 自动签名:
- 新增 write_ios_export_options() 生成 ExportOptions.plist(teamID=BYL4KQHMTN);
- `flutter build ipa --release --export-options-plist=…` 导出时由 Xcode 用
  **Apple Distribution: Yanmei…** 重签;
- 装机前解包 IPA 逐字比对签名主体(IOS_EXPECT_SIGNER),不符即中止 —— 防止
  以后无声退回个人证书;
- 用 devicectl 安装;仅当覆盖安装失败(旧包签名主体不同)才卸载重装,不无条件
  先卸(卸载会清空 app 数据要重新登录);
- 构建失败时提示新设备注册命令:光给 -allowProvisioningUpdates 不够,必须同时
  给 -allowProvisioningDeviceRegistration。

另外两处顺手修:
- 取 IPA 路径原写 `ls … | head -1`,在 set -o pipefail 下 head 提前退出会让 ls
  吃 SIGPIPE、管道返回 141,被 set -e 当失败终止脚本(实测复现,退出码 141)。
  改用数组 glob。签名主体提取的 `codesign | grep -m1` 同一个坑,改成整段捕获
  后用 awk 读完全部输入。
- API_URL 支持 env 覆盖(默认值不变),便于装生产地址的包做验证。

验证:bash -n 通过;shellcheck 无新增告警;
API_URL=https://api.yanmeiai.com bash scripts/local_test.sh ipad 无字天书
端到端 exit=0,日志确认"签名核验通过: Apple Distribution: Yanmei…"并装机成功。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-22 11:59:08 +08:00
wangjia e38c7e7c1c chore: gitignore 用全局通配兜住签名私钥/证书
签名私钥 pangolin-release.jks 实际躺在**仓库根目录**,而原规则只写了
client/android/*.jks 一层,根目录那份既未被 git 跟踪、也未被忽略 ——
一次 `git add -A` 就会把 release 私钥提交进库。

改为全局通配 *.jks / *.keystore / key.properties,并一并兜住 Apple 侧的
*.p12 / *.mobileprovision / *.provisionprofile(CI 与 local_test.sh 都会
在工作区落盘这些材料)。

已核对:仓库当前没有任何**已跟踪**文件匹配这些模式,不会把在库文件变成
untracked。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-22 10:57:10 +08:00
wangjia d5fc9c1c07 fix(client): 补 es/ja/ko/ru 支付文案 + 去掉 pubspec 重复依赖键
HEAD 上客户端编译不过,两处:

1. pay-v2 给 AppText 加了 28 个支付相关 getter,只补了 zh/en,
   es/ja/ko/ru 四个 Strings* 实现类没跟上 → non_abstract_class_
   inherits_abstract_member,flutter analyze/test/build 全挂。
   本次按 zh/en 原文补齐四语(购买/支付方式/USDT 收款/支付宝跳转/
   订单态等 28 条),用词沿用生命周期中文词的对应译法。

2. pubspec.yaml 里 url_launcher 声明了两次(line 23 与 line 30),
   yaml Duplicate mapping key 直接让 flutter 工具链抛异常。删掉
   后加的那条重复声明,保留带原注释的首条。

验证:flutter analyze 仅剩 3 条既有 info;flutter build ios --release
成功并已装到 iPad Pro 真机。(test/golden 34 个失败是既有 UI 漂移,
与本次改动无关——goldens 不涉及 es/ja/ko/ru。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-22 10:38:27 +08:00
wangjia 74d8c857be feat(server): 私有服务域名分流(PANGOLIN_PRIVATE_SPLIT_DOMAINS)
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Failing after 11s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Failing after 9s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Failing after 9s
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Has been cancelled
家庭内网穿透域名(nas/git/win.yanmeiai.com)的客户端配置渲染:
- DNS 面: 系统解析器(type=local,底层网络)+私有域名规则置顶+reverse_mapping
- 路由面: 私有域名→强制走隧道,钉在 LAN 直连之后、国内分流(geoip-cn)之前
在家: 局域网 DNS 覆盖→私网IP→LAN直连零绕行; 在外: 锚点(frps@ali,国内IP)
不再被 smartRoute 分流成直连、避开安全组限源。env 不配置=行为零变化。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 07:22:16 +08:00
12 changed files with 532 additions and 24 deletions
+8 -3
View File
@@ -57,9 +57,14 @@ client/ios/Flutter/ephemeral/
client/pubspec.lock
# Android release 签名材料(本地或 CI 落盘,绝不入库)
client/android/key.properties
client/android/*.jks
client/android/*.keystore
# 用全局通配:签名私钥曾以 pangolin-release.jks 出现在**仓库根**,而旧规则只盖
# client/android/ 一层,根目录那份不被忽略 → 一次 `git add -A` 就会把私钥提交进库。
key.properties
*.jks
*.keystore
*.p12
*.mobileprovision
*.provisionprofile
# CI 客户端产物暂存目录(scripts/ci/compile-{android,windows}.sh 输出,构建期生成)
/dist/
+58
View File
@@ -370,4 +370,62 @@ class StringsEs extends AppText {
String get ob3Title => 'Privado por diseño';
@override
String get ob3Sub => 'Cifrado de extremo a extremo. No guardamos ningún registro de navegación.';
// ── 支付 / 购买(pay-v2)──
@override
String get purchaseTitle => 'Comprar plan';
@override
String get paymentTitle => 'Pago';
@override
String get buyNow => 'Comprar ahora';
@override
String get payMethodAlipay => 'Alipay';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => 'Elige un método de pago';
@override
String get proMonthly => 'Pro · Mensual';
@override
String get proQuarterly => 'Pro · Trimestral';
@override
String get proYearly => 'Pro · Anual';
@override
String get perQuarter => '/trimestre';
@override
String get perYear => '/año';
@override
String get payAmountLabel => 'Importe';
@override
String get payAddressLabel => 'Dirección';
@override
String get payNetworkLabel => 'Red';
@override
String get payExactAmountHint => 'Envía el importe exacto; se activa automáticamente';
@override
String get copied => 'Copiado';
@override
String get openAlipay => 'Pagar con Alipay';
@override
String get openAlipayHint => 'Vuelve tras pagar; esta página se actualiza sola';
@override
String get openAlipayFailed => 'No se pudo abrir Alipay; comprueba que esté instalado o inténtalo de nuevo';
@override
String get awaitingPayment => 'Esperando el pago';
@override
String get paySucceeded => 'Activado';
@override
String get payExpiresAt => 'Válido hasta';
@override
String get payDone => 'Hecho';
@override
String get payFailed => 'Pago fallido';
@override
String get payRetry => 'Reintentar';
@override
String get switchPayMethod => 'Cambiar de método de pago';
@override
String get cancelOrder => 'Cancelar pedido';
@override
String get qrNotSupported => 'Copia el contenido y ábrelo en Alipay';
}
+58
View File
@@ -370,4 +370,62 @@ class StringsJa extends AppText {
String get ob3Title => '設計段階からプライバシー重視';
@override
String get ob3Sub => 'エンドツーエンドで暗号化。閲覧ログは一切保持しません。';
// ── 支付 / 购买(pay-v2)──
@override
String get purchaseTitle => 'プランを購入';
@override
String get paymentTitle => 'お支払い';
@override
String get buyNow => '今すぐ購入';
@override
String get payMethodAlipay => 'Alipay';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => 'お支払い方法を選択';
@override
String get proMonthly => 'Pro・月額';
@override
String get proQuarterly => 'Pro・3か月';
@override
String get proYearly => 'Pro・年額';
@override
String get perQuarter => '/3か月';
@override
String get perYear => '/年';
@override
String get payAmountLabel => '送金額';
@override
String get payAddressLabel => '送金先アドレス';
@override
String get payNetworkLabel => 'ネットワーク';
@override
String get payExactAmountHint => '表示どおりの金額を送金してください。着金後に自動で有効化されます';
@override
String get copied => 'コピーしました';
@override
String get openAlipay => 'Alipay で支払う';
@override
String get openAlipayHint => 'お支払い後に戻ってください。このページは自動更新されます';
@override
String get openAlipayFailed => 'Alipay を開けませんでした。インストール状況を確認するか、後でもう一度お試しください';
@override
String get awaitingPayment => 'お支払い待ち';
@override
String get paySucceeded => '有効化されました';
@override
String get payExpiresAt => '有効期限';
@override
String get payDone => '完了';
@override
String get payFailed => 'お支払いに失敗しました';
@override
String get payRetry => '再試行';
@override
String get switchPayMethod => '別のお支払い方法にする';
@override
String get cancelOrder => '注文をキャンセル';
@override
String get qrNotSupported => '内容をコピーして Alipay で開いてください';
}
+58
View File
@@ -370,4 +370,62 @@ class StringsKo extends AppText {
String get ob3Title => '설계부터 프라이버시';
@override
String get ob3Sub => '종단 간 암호화. 브라우징 기록을 전혀 저장하지 않습니다.';
// ── 支付 / 购买(pay-v2)──
@override
String get purchaseTitle => '요금제 구매';
@override
String get paymentTitle => '결제';
@override
String get buyNow => '지금 구매';
@override
String get payMethodAlipay => 'Alipay';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => '결제 수단 선택';
@override
String get proMonthly => 'Pro · 월간';
@override
String get proQuarterly => 'Pro · 분기';
@override
String get proYearly => 'Pro · 연간';
@override
String get perQuarter => '/분기';
@override
String get perYear => '/년';
@override
String get payAmountLabel => '송금 금액';
@override
String get payAddressLabel => '받는 주소';
@override
String get payNetworkLabel => '네트워크';
@override
String get payExactAmountHint => '표시된 금액과 정확히 일치하게 보내주세요. 입금 후 자동으로 활성화됩니다';
@override
String get copied => '복사됨';
@override
String get openAlipay => 'Alipay로 결제';
@override
String get openAlipayHint => '결제 후 돌아오세요. 이 페이지는 자동으로 새로고침됩니다';
@override
String get openAlipayFailed => 'Alipay를 열 수 없습니다. 설치 여부를 확인하거나 잠시 후 다시 시도해 주세요';
@override
String get awaitingPayment => '결제 대기 중';
@override
String get paySucceeded => '활성화됨';
@override
String get payExpiresAt => '유효 기간';
@override
String get payDone => '완료';
@override
String get payFailed => '결제 실패';
@override
String get payRetry => '다시 시도';
@override
String get switchPayMethod => '다른 결제 수단 선택';
@override
String get cancelOrder => '주문 취소';
@override
String get qrNotSupported => '내용을 복사한 뒤 Alipay에서 열어주세요';
}
+58
View File
@@ -370,4 +370,62 @@ class StringsRu extends AppText {
String get ob3Title => 'Приватность по умолчанию';
@override
String get ob3Sub => 'Сквозное шифрование. Мы не храним журналы просмотров.';
// ── 支付 / 购买(pay-v2)──
@override
String get purchaseTitle => 'Купить тариф';
@override
String get paymentTitle => 'Оплата';
@override
String get buyNow => 'Купить';
@override
String get payMethodAlipay => 'Alipay';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => 'Выберите способ оплаты';
@override
String get proMonthly => 'Pro · Помесячно';
@override
String get proQuarterly => 'Pro · Ежеквартально';
@override
String get proYearly => 'Pro · Ежегодно';
@override
String get perQuarter => '/квартал';
@override
String get perYear => '/год';
@override
String get payAmountLabel => 'Сумма';
@override
String get payAddressLabel => 'Адрес';
@override
String get payNetworkLabel => 'Сеть';
@override
String get payExactAmountHint => 'Отправьте точную сумму — доступ откроется автоматически';
@override
String get copied => 'Скопировано';
@override
String get openAlipay => 'Оплатить через Alipay';
@override
String get openAlipayHint => 'Вернитесь после оплаты — страница обновится сама';
@override
String get openAlipayFailed => 'Не удалось открыть Alipay. Проверьте, установлен ли он, или повторите позже';
@override
String get awaitingPayment => 'Ожидание оплаты';
@override
String get paySucceeded => 'Активировано';
@override
String get payExpiresAt => 'Действует до';
@override
String get payDone => 'Готово';
@override
String get payFailed => 'Оплата не прошла';
@override
String get payRetry => 'Повторить';
@override
String get switchPayMethod => 'Сменить способ оплаты';
@override
String get cancelOrder => 'Отменить заказ';
@override
String get qrNotSupported => 'Скопируйте данные и откройте в Alipay';
}
-1
View File
@@ -27,7 +27,6 @@ dependencies:
launch_at_startup: ^0.5.1
tray_manager: ^0.5.3
window_manager: ^0.5.1
url_launcher: ^6.3.0 # 支付 redirect(如支付宝)拉起外部浏览器/App
dev_dependencies:
flutter_test:
+149 -4
View File
@@ -12,11 +12,20 @@
# run 直跑 /Applications 版本(绕 Gatekeeper,实时输出日志,并打印测试账号)
# all 依次执行 build → sign → copy → run
#
# 用法: scripts/local_test.sh all | scripts/local_test.sh build ...
# ── iOS / iPad 真机(USB 连接;与上面 macOS 流程完全独立)──
# ios-devices 列出已连接的 iOS 物理设备(拿 device id)
# ipad [id|名字] iOS/iPad 真机装机:ad-hoc 导出(**公司**分发证书 Apple
# Distribution: Yanmei…)→ 核验签名主体 → devicectl 装。
# 仅一台 iOS 设备时可省参数;iPhone/iPad 同连时须指定 id 或名字子串。
# 注:不用 `flutter run --release` —— 那走开发签名,是**个人**证书。
#
# 用法: scripts/local_test.sh all | scripts/local_test.sh ipad ...
set -euo pipefail
# ─────────── 配置(按需改)───────────
API_URL="http://103.119.13.48:8080" # 联调控制面;发版改这里或走默认
# 联调控制面。默认指向联调节点;要装生产地址的包用 env 覆盖:
# API_URL=https://api.yanmeiai.com scripts/local_test.sh ipad
API_URL="${API_URL:-http://103.119.13.48:8080}"
SIGN_ID="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)"
APP_PROFILE_NAME="Pangolin App DevID" # 主 app 的 Developer ID 描述文件名
SE_PROFILE_NAME="Pangolin PacketTunnel DevID" # PacketTunnel 的描述文件名
@@ -29,6 +38,12 @@ IP_SVC="https://api.ipify.org" # 返回纯文本公网 IP
# 注:DevID profile 已含 system-extension.install + NE(-systemextension 变体);
# app/sysext entitlements 与之对齐(见 write_entitlements)。
# ── iOS/iPad 真机分发(ad-hoc,**公司**证书)────────────────────────────────
TEAM_ID="BYL4KQHMTN" # Yanmei (beijing) Technology Co., Ltd
IOS_BUNDLE_ID="com.pangolin.pangolinVpn"
# 期望的签名主体。装机前逐字核对,不符即中止 —— 防止悄悄退回个人开发证书。
IOS_EXPECT_SIGNER="Apple Distribution: Yanmei (beijing) Technology Co., Ltd (${TEAM_ID})"
# ─────────── 路径推导 ───────────
SRC="${BASH_SOURCE[0]}"
DIR="${SRC%/*}"; [ "$DIR" = "$SRC" ] && DIR="."
@@ -85,7 +100,7 @@ PLIST
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.application-groups</key>
<array><string>BYL4KQHMTN.com.pangolin.pangolin</string></array>
<array><string>group.com.pangolin.pangolin</string></array>
</dict></plist>
PLIST
}
@@ -235,6 +250,134 @@ cmd_ks_status(){
2>/dev/null | tail -20 || echo " (无,确认已连接过一次)"
}
# ─────────── iOS / iPad 真机安装 ───────────
# 与 macOS 流程独立:iOS 用 Network Extension(非 System Extension),不需要 Developer ID
# 重签 / 公证;flutter build ios --release 由 Xcode 工程配置签名(Team=BYL4KQHMTN,自动
# 签名会把已连接设备注册进描述文件),再 flutter install 走 USB 直接装。
# 列出已连接的物理 iOS 设备(输出 "id<TAB>name" 每行一台;模拟器/无线设备排除)。
list_ios_devices(){
flutter devices --machine 2>/dev/null | python3 -c '
import sys, json
try:
devs = json.load(sys.stdin)
except Exception:
devs = []
for d in devs:
if str(d.get("targetPlatform", "")).startswith("ios") and not d.get("emulator", False):
print((d.get("id", "") or "") + "\t" + (d.get("name", "") or ""))
'
}
cmd_ios_devices(){
log "已连接的 iOS 物理设备"
local list; list="$(list_ios_devices)"
[ -n "$list" ] || die "没检测到 iOS 设备。用数据线连上 iPad、解锁并在弹窗点「信任此电脑」后重试。"
printf '%s\n' "$list" | while IFS=$'\t' read -r id name; do printf ' %-42s %s\n' "$id" "$name"; done
}
# 解析目标设备到全局 DEVICE_ID:优先用传入的 id/名字子串;否则仅一台 iOS 设备时自动选它,
# 多台(如 iPhone+iPad 同连)则列出并要求显式指定,绝不瞎猜。
resolve_ios_device(){
local want="${1:-}" list
list="$(list_ios_devices)"
[ -n "$list" ] || die "没检测到 iOS 设备。用数据线连上 iPad、解锁并在弹窗点「信任此电脑」后重试。"
if [ -n "$want" ]; then
DEVICE_ID="$(printf '%s\n' "$list" | grep -iF "$want" | head -1 | cut -f1)"
[ -n "$DEVICE_ID" ] || die "找不到匹配「$want」的 iOS 设备。先跑 ios-devices 看可用列表。"
return 0
fi
local n; n="$(printf '%s\n' "$list" | grep -c . || true)"
if [ "$n" = 1 ]; then
DEVICE_ID="$(printf '%s\n' "$list" | head -1 | cut -f1)"
else
printf '%s\n' "$list" | while IFS=$'\t' read -r id name; do printf ' %-42s %s\n' "$id" "$name" >&2; done
die "检测到多台 iOS 设备(如上)。请指定:scripts/local_test.sh ipad <设备id或名字子串>"
fi
}
# ad-hoc 导出选项。**不要**改回 development/automatic:
# Apple 只把 Development 证书签发给团队里的**个人成员**(没有组织版),走开发签名
# 装机后 iPad「设置→通用→VPN 与设备管理」里开发者会显示成个人姓名。要显示公司名
# 必须用分发证书,而能装到自有真机的分发方式就是 ad-hoc(Xcode 15+ 叫 release-testing)。
write_ios_export_options(){
cat > "$WORK/ExportOptions-adhoc.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>release-testing</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>automatic</string>
<key>stripSwiftSymbols</key><true/>
<key>uploadSymbols</key><false/>
<key>compileBitcode</key><false/>
</dict>
</plist>
PLIST
}
cmd_ipad(){
command -v flutter >/dev/null || die "找不到 flutter。"
local DEVICE_ID=""
resolve_ios_device "${1:-}"
write_ios_export_options
cd "$CLIENT"
echo ""
echo " 目标设备 : $DEVICE_ID"
echo " 签名主体 : $IOS_EXPECT_SIGNER"
echo " 联调 API : $API_URL"
echo " 测试账号 : $TEST_EMAIL / $TEST_PASSWORD"
echo " ad-hoc 分发签名无需在「VPN 与设备管理」信任;首次启动要联网让 iOS 校验证书。"
echo " VPN 配置在系统弹窗点「允许」。"
echo ""
log "flutter build ipa --release(ad-hoc / 公司分发证书)"
# 新设备第一次装会因描述文件不含其 UDID 失败(0xe8008012)。自动签名要真正去
# **注册设备**,光给 -allowProvisioningUpdates 不够,必须同时给
# -allowProvisioningDeviceRegistration —— 由 Xcode 在 archive 阶段完成。
flutter build ipa --release \
--export-options-plist="$WORK/ExportOptions-adhoc.plist" \
--dart-define="PANGOLIN_API_URL=$API_URL" \
|| die "IPA 构建失败。若报设备未注册,跑一次:
cd $CLIENT/ios && xcodebuild -workspace Runner.xcworkspace -scheme Runner \\
-configuration Release -destination \"id=$DEVICE_ID\" \\
-allowProvisioningUpdates -allowProvisioningDeviceRegistration build"
# 用 glob 取,不要 `ls | head` —— set -o pipefail 下 head 先退出会让 ls 吃 SIGPIPE,
# 管道返回 141,再被 set -e 当失败直接终止脚本(实测踩过)。
local IPA IPAS=()
IPAS=("$CLIENT"/build/ios/ipa/*.ipa)
IPA="${IPAS[0]}"
[ -f "$IPA" ] || die "没找到 IPA(build/ios/ipa/*.ipa)"
# ── 装机前核验签名主体,防止无声退回个人开发证书 ──
local VDIR="$WORK/ipa_verify"; rm -rf "$VDIR"; mkdir -p "$VDIR"
( cd "$VDIR" && unzip -q "$IPA" ) || die "IPA 解包失败。"
# 同理避开提前退出的管道消费者(grep -m1 / head):先整段捕获,再在内存里取第一条
# Authority(叶子证书)。awk 读完全部输入,不会给上游制造 SIGPIPE。
local AUTH CS_OUT
CS_OUT="$(codesign -dv --verbose=4 "$VDIR/Payload/Runner.app" 2>&1 || true)"
AUTH="$(printf '%s\n' "$CS_OUT" | awk -F'Authority=' '/^Authority=/ && !seen {print $2; seen=1}')"
[ "$AUTH" = "$IOS_EXPECT_SIGNER" ] \
|| die "签名主体不符,拒绝安装。
期望: $IOS_EXPECT_SIGNER
实际: ${AUTH:-<空>}"
log "签名核验通过:$AUTH"
log "装到设备 $DEVICE_ID"
# 同 bundle id 的旧包若是**别的签名主体**(如早先的 Apple Development 个人证书),
# iOS 拒绝覆盖安装。仅在这种情况下才卸载重装 —— 卸载会清空 app 数据(需重新登录),
# 所以不无条件先卸。
if ! xcrun devicectl device install app --device "$DEVICE_ID" "$IPA"; then
log "覆盖安装失败(多半是旧包签名主体不同),卸载旧包后重装 —— app 数据会清空,需重新登录"
xcrun devicectl device uninstall app --device "$DEVICE_ID" "$IOS_BUNDLE_ID" || true
xcrun devicectl device install app --device "$DEVICE_ID" "$IPA" \
|| die "安装失败。"
fi
log "已安装 $IOS_BUNDLE_ID$DEVICE_ID;在设备上点开「穿山甲」即可(首次需联网校验证书)"
}
case "${1:-}" in
build) cmd_build ;;
sign) cmd_sign ;; # 旧:手动 Developer ID 重签(现已由 Xcode 工程配置直接签,通常不需要)
@@ -246,5 +389,7 @@ case "${1:-}" in
ks-baseline) cmd_ks_baseline ;; # ① VPN 断开下记录真实公网 IP
ks-test) cmd_ks_test ;; # ② VPN 连接(killswitch 开)下杀扩展做漏测
ks-status) cmd_ks_status ;; # 看 VPN 状态 + killswitch 配置打点日志
*) echo "用法: $0 {all|build|sign|notarize|copy|run|ks-baseline|ks-test|ks-status}"; exit 2 ;;
ios-devices) cmd_ios_devices ;; # 列出已连接的 iOS 物理设备(拿 device id)
ipad|ios) cmd_ipad "${2:-}" ;; # 构建 iOS release 并装到 iPad(可传设备 id/名字子串)
*) echo "用法: $0 {all|build|sign|notarize|copy|run|ks-baseline|ks-test|ks-status|ios-devices|ipad [id]}"; exit 2 ;;
esac
+9 -1
View File
@@ -376,7 +376,15 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
slog.Warn("PANGOLIN_PUBLIC_URL 未设置:国内分流(split_cn)将被静默跳过,客户端全量走隧道。" +
"如需国内直连,设为控制面对外公网基址(如 http://<公网IP>:8080)")
}
nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), nodeSvc.Load(), os.Getenv("NODE_DERIVE_KEY"), publicURL)
// 私有服务域名分流(家庭内网穿透,如 nas/git/win.yanmeiai.com):
// 逗号分隔;这些域名用系统 DNS 解析、在外强制走隧道(排在国内分流前)。
var privateSplitDomains []string
for _, d := range strings.Split(os.Getenv("PANGOLIN_PRIVATE_SPLIT_DOMAINS"), ",") {
if d = strings.TrimSpace(d); d != "" {
privateSplitDomains = append(privateSplitDomains, d)
}
}
nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), nodeSvc.Load(), os.Getenv("NODE_DERIVE_KEY"), publicURL, privateSplitDomains)
}
// 国内分流(#5)的 rule-set 静态服务:GET /v1/rules/{name}.srs(自托管,
+45 -10
View File
@@ -15,6 +15,13 @@ type ClientConfigOpts struct {
// RulesBaseURL 是控制面对外公网基址(如 "http://node:8080"),rule_set 的 .srs
// 从 <base>/v1/rules/*.srs 下载。SplitCN 生效需此项非空(否则分流静默跳过)。
RulesBaseURL string
// PrivateSplitDomains 私有服务域名(家庭内网穿透,如 nas/git/win.yanmeiai.com,
// 服务端 env PANGOLIN_PRIVATE_SPLIT_DOMAINS 配置;空=行为完全不变):
// - DNS 改用系统解析器(在家吃到局域网 DNS 覆盖→私网 IP;在外解析出公网锚点)
// - 路由上私网结果命中 LAN 直连(在家零绕行),公网结果强制走隧道——该规则
// 必须排在国内分流(geoip-cn)之前:锚点(frps@ali)是国内 IP,否则 smartRoute
// 会把它分流成直连,被 frps 侧安全组限源(仅节点出口)拦截。
PrivateSplitDomains []string
}
// BuildClientConfig renders a complete sing-box CLIENT configuration JSON that
@@ -126,7 +133,8 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
"tolerance": 50,
}
// Route: DNS 劫持 → LAN direct →(可选)国内直连 → 其余 via auto。
// Route: DNS 劫持 → LAN direct →(可选)私有域名走隧道 →(可选)国内直连 → 其余 via auto。
privateSplit := len(opts.PrivateSplitDomains) > 0
routeRules := []any{
// DNS 劫持(sing-box 1.13 action=hijack-dns,按目的端口 53 匹配,不依赖 sniff):
// 把发往隧道 DNS(172.19.0.2:53)的查询交给 sing-box DNS 模块解析。必须排在 LAN
@@ -138,6 +146,15 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
"outbound": "direct",
},
}
if privateSplit {
// 私有域名强制走隧道。在家不受此规则影响:系统 DNS(局域网覆盖)解析出私网 IP,
// 上面的 LAN 直连规则先命中。域名元数据靠 dns.reverse_mapping 补回(应用自行
// 解析后按 IP 连接,无回映射则此规则永不匹配、在外会掉进国内分流被限源拦截)。
routeRules = append(routeRules, map[string]any{
"domain": opts.PrivateSplitDomains,
"outbound": "auto",
})
}
route := map[string]any{
"final": "auto",
"auto_detect_interface": true,
@@ -166,24 +183,42 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
// DNS: remote over tunnel, local for domestic.
// sing-box 1.12+ DNS server format(type+server);旧的 address 串格式在
// 1.13 已 FATAL 拒绝(legacy DNS servers deprecated)。
dnsServers := []any{
map[string]any{"tag": "remote", "type": "tls", "server": "8.8.8.8", "detour": "auto"},
// local 不带 detour:sing-box 1.12 拒绝 DNS detour 到空 direct 出站
// (FATAL: detour to an empty direct outbound makes no sense)。
map[string]any{"tag": "local", "type": "udp", "server": "223.5.5.5"},
}
if privateSplit {
// 系统解析器(type=local,经底层物理网络):在家=路由器 DHCP 下发的局域网 DNS
// (含私有域名覆盖→私网 IP),在外=所在网络 DNS(公网记录→锚点 IP)。
// 不能用 223.5.5.5/8.8.8.8——公共 DNS 不知道家里的覆盖记录。
dnsServers = append(dnsServers, map[string]any{"tag": "dns-system", "type": "local"})
}
dns := map[string]any{
"servers": []any{
map[string]any{"tag": "remote", "type": "tls", "server": "8.8.8.8", "detour": "auto"},
// local 不带 detour:sing-box 1.12 拒绝 DNS detour 到空 direct 出站
// (FATAL: detour to an empty direct outbound makes no sense)。
map[string]any{"tag": "local", "type": "udp", "server": "223.5.5.5"},
},
"servers": dnsServers,
"final": "remote",
"strategy": "ipv4_only",
}
dnsRules := []any{}
if privateSplit {
// 私有域名规则须排在 geosite-cn 之前(优先级最高)。
dnsRules = append(dnsRules, map[string]any{
"domain": opts.PrivateSplitDomains, "server": "dns-system",
})
// 回映射:记住"哪个 IP 是哪个域名解析出来的",给后续按 IP 发起的连接补回
// 域名元数据——路由层的 domain 规则(私有域名→隧道)靠它才会命中。
dns["reverse_mapping"] = true
}
// 国内分流的 DNS 面(补 #5 数据面之外的 DNS 面):开分流时,命中 geosite-cn 的
// 国内域名用 local(223.5.5.5)直连解析,不走 remote(8.8.8.8 经隧道)。否则即便数据
// 直连,域名解析仍绕道出海(实测国内 DNS 段 200-600ms),首连凭空多一个出海 RTT。
// 复用 route.rule_set 里已定义的 geosite-cn 标签。
if splitActive {
dns["rules"] = []any{
map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"},
}
dnsRules = append(dnsRules, map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"})
}
if len(dnsRules) > 0 {
dns["rules"] = dnsRules
}
cfg := map[string]any{
@@ -91,6 +91,84 @@ func TestBuildClientConfigSplitCN(t *testing.T) {
}
}
func TestBuildClientConfigPrivateSplit(t *testing.T) {
domains := []string{"nas.yanmeiai.com", "git.yanmeiai.com", "win.yanmeiai.com"}
// 私有分流 + 国内分流同时开:验证规则齐全且顺序正确
// (LAN 直连 → 私有域名强制走隧道 → 国内直连;私有规则必须在国内直连之前,
// 否则锚点是国内 IP 会被分流成直连、被 frps 侧安全组限源拦截)。
cfg, err := BuildClientConfig(testNode(), "uuid-1", "k",
ClientConfigOpts{SplitCN: true, RulesBaseURL: "http://node:8080",
PrivateSplitDomains: domains})
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(cfg, &m); err != nil {
t.Fatal(err)
}
// ① dns.servers 含系统解析器(type=local):在家吃到局域网 DNS 覆盖(私网IP),
// 在外用所在网络 DNS 解析出公网锚点。
dnsm := m["dns"].(map[string]any)
foundSystem := false
for _, s := range dnsm["servers"].([]any) {
sm := s.(map[string]any)
if sm["tag"] == "dns-system" && sm["type"] == "local" {
foundSystem = true
}
}
if !foundSystem {
t.Error("missing dns-system (type=local) dns server")
}
// ② dns.rules 首条 = 私有域名→dns-system(须排在 geosite-cn→local 之前)。
dnsRules := dnsm["rules"].([]any)
dr := dnsRules[0].(map[string]any)
if dr["server"] != "dns-system" || dr["domain"] == nil {
t.Errorf("dns.rules[0] should be private domains → dns-system, got %v", dr)
}
// ③ reverse_mapping 开启:应用自行解析后按 IP 连接,回映射补回域名元数据,
// 路由的 domain 规则才有效。
if dnsm["reverse_mapping"] != true {
t.Error("reverse_mapping should be true when private split is on")
}
// ④ 路由顺序:LAN 直连 < 私有域名→auto < 国内 rule_set→direct。
rules := m["route"].(map[string]any)["rules"].([]any)
lanIdx, privIdx, cnIdx := -1, -1, -1
for i, r := range rules {
rm := r.(map[string]any)
if rm["ip_cidr"] != nil && rm["outbound"] == "direct" {
lanIdx = i
}
if rm["domain"] != nil && rm["outbound"] == "auto" {
privIdx = i
}
if rm["rule_set"] != nil && rm["outbound"] == "direct" {
cnIdx = i
}
}
if lanIdx < 0 || privIdx < 0 || cnIdx < 0 {
t.Fatalf("missing rules: lan=%d priv=%d cn=%d", lanIdx, privIdx, cnIdx)
}
if !(lanIdx < privIdx && privIdx < cnIdx) {
t.Errorf("rule order wrong: lan=%d < priv=%d < cn=%d expected", lanIdx, privIdx, cnIdx)
}
// ⑤ 不配置 → 全部不出现(行为与旧版完全一致)。
cfg2, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{})
var m2 map[string]any
_ = json.Unmarshal(cfg2, &m2)
dnsm2 := m2["dns"].(map[string]any)
if _, ok := dnsm2["reverse_mapping"]; ok {
t.Error("private split off: reverse_mapping should be absent")
}
if strings.Contains(string(cfg2), "dns-system") {
t.Error("private split off: dns-system should be absent")
}
}
func TestRulesHandler(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "geoip-cn.srs"), []byte("SRS"), 0o644); err != nil {
+10 -4
View File
@@ -42,12 +42,16 @@ type NodeAPI struct {
// rulesBaseURL 是控制面对外公网基址(PANGOLIN_PUBLIC_URL),供国内分流的
// rule_set .srs 下载用;空则分流不生效。
rulesBaseURL string
// privateSplitDomains 私有服务域名(PANGOLIN_PRIVATE_SPLIT_DOMAINS,逗号分隔),
// 见 ClientConfigOpts.PrivateSplitDomains;空则不渲染相关规则。
privateSplitDomains []string
}
// NewNodeAPI creates a NodeAPI. load may be nil (then all nodes are treated as
// data-plane healthy — agent gRPC liveness still gates status).
func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, load nodeLoadReader, deriveKey, rulesBaseURL string) *NodeAPI {
return &NodeAPI{store: store, hub: hub, load: load, deriveKey: deriveKey, rulesBaseURL: rulesBaseURL}
func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, load nodeLoadReader, deriveKey, rulesBaseURL string, privateSplitDomains []string) *NodeAPI {
return &NodeAPI{store: store, hub: hub, load: load, deriveKey: deriveKey,
rulesBaseURL: rulesBaseURL, privateSplitDomains: privateSplitDomains}
}
// dataPlaneHealthy reports the node's last sing-box health (default true when
@@ -315,7 +319,8 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
// split_cn=1/true → 国内 IP/域名直连(#5);客户端按 smartRoute 偏好传。
splitCN := r.URL.Query().Get("split_cn") == "1" || r.URL.Query().Get("split_cn") == "true"
cfgJSON, renderErr := BuildClientConfig(node, dpUUID, a.deriveKey,
ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL})
ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL,
PrivateSplitDomains: a.privateSplitDomains})
if renderErr != nil {
slog.Error("connect: build client config failed", "node", nodeUUID, "err", renderErr)
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
@@ -324,7 +329,8 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
// 可观测:国内分流是否真正生效(split_active=两个条件都满足才渲染 rule_set)。
slog.Info("client config rendered", "node", nodeUUID, "split_cn", splitCN,
"rules_base_set", a.rulesBaseURL != "",
"split_active", splitCN && a.rulesBaseURL != "", "bytes", len(cfgJSON))
"split_active", splitCN && a.rulesBaseURL != "",
"private_split", len(a.privateSplitDomains), "bytes", len(cfgJSON))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(cfgJSON)
@@ -51,7 +51,7 @@ func (f *fakeDisconnectStore) PersistCredential(context.Context, int64, *agentv1
func doDisconnect(t *testing.T, store *fakeDisconnectStore, body string) int {
t.Helper()
api := NewNodeAPI(store, nil, nil, "", "") // nil hub:跳过 Push,只验证凭证删除
api := NewNodeAPI(store, nil, nil, "", "", nil) // nil hub:跳过 Push,只验证凭证删除
req := httptest.NewRequest("POST", "/v1/nodes/node-1/disconnect", strings.NewReader(body))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "node-1")