#!/usr/bin/env bash
#
# local_test.sh — macOS 客户端本地联调一条龙(Developer ID 重签 + 装 + 跑)。
#
# 背景:flutter build 出来的包是 Apple Development 签名;要让 System Extension
# 能加载(且不依赖 SIP/dev mode),需重签成 Developer ID。本脚本封装这套流程。
#
# 子命令:
# build flutter build macos --release(注入联调节点地址)
# sign Developer ID 内向外重签整个 app + PacketTunnel sysext(嵌 DevID profile)
# copy 覆盖安装到 /Applications
# run 直跑 /Applications 版本(绕 Gatekeeper,实时输出日志,并打印测试账号)
# all 依次执行 build → sign → copy → run
#
# 用法: scripts/local_test.sh all | scripts/local_test.sh build ...
set -euo pipefail
# ─────────── 配置(按需改)───────────
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 的描述文件名
TEST_EMAIL="wang880812@gmail.com" # 联调测试账号
TEST_PASSWORD="pangolin2026"
NOTARY_PROFILE="pangolin-notary" # notarytool 凭据(已存入钥匙串)
EXT_NAME="PacketTunnel" # System Extension 可执行名(killswitch 测试用)
VPN_NAME="Pangolin" # NETunnelProviderManager.localizedDescription
IP_SVC="https://api.ipify.org" # 返回纯文本公网 IP
# 注:DevID profile 已含 system-extension.install + NE(-systemextension 变体);
# app/sysext entitlements 与之对齐(见 write_entitlements)。
# ─────────── 路径推导 ───────────
SRC="${BASH_SOURCE[0]}"
DIR="${SRC%/*}"; [ "$DIR" = "$SRC" ] && DIR="."
cd "$DIR/.."; REPO_ROOT="$PWD"
CLIENT="$REPO_ROOT/client"
APP="$CLIENT/build/macos/Build/Products/Release/pangolin_vpn.app"
# sysext bundle 名 = 标识符(PRODUCT_NAME=com.pangolin.pangolin.PacketTunnel,见 CLAUDE.md),
# 不是短名 PacketTunnel.systemextension。
SE="$APP/Contents/Library/SystemExtensions/com.pangolin.pangolin.PacketTunnel.systemextension"
LIBFW="$SE/Contents/Frameworks/Libbox.framework"
PROF_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
WORK="${TMPDIR:-/tmp}/pangolin_local_test"; mkdir -p "$WORK"
log(){ printf '\033[1;33m== %s ==\033[0m\n' "$*"; }
die(){ printf '\033[1;31m❌ %s\033[0m\n' "$*" >&2; exit 1; }
# 按 Name 在描述文件目录里定位 .provisionprofile(结果写入全局 FOUND)。
FOUND=""
find_profile(){
local want="$1" f
for f in "$PROF_DIR"/*.provisionprofile; do
[ -e "$f" ] || continue
if security cms -D -i "$f" 2>/dev/null | grep -q ">$want<"; then
FOUND="$f"; return 0
fi
done
return 1
}
write_entitlements(){
cat > "$WORK/app.entitlements" <<'PLIST'
com.apple.application-identifierBYL4KQHMTN.com.pangolin.pangolin
com.apple.developer.team-identifierBYL4KQHMTN
com.apple.developer.system-extension.install
com.apple.developer.networking.networkextension
packet-tunnel-provider-systemextension
com.apple.security.app-sandbox
com.apple.security.network.client
com.apple.security.network.server
keychain-access-groups
BYL4KQHMTN.com.pangolin.pangolin
PLIST
cat > "$WORK/sysext.entitlements" <<'PLIST'
com.apple.application-identifierBYL4KQHMTN.com.pangolin.pangolin.PacketTunnel
com.apple.developer.team-identifierBYL4KQHMTN
com.apple.developer.networking.networkextension
packet-tunnel-provider-systemextension
com.apple.security.app-sandbox
com.apple.security.application-groups
group.com.pangolin.pangolin
PLIST
}
cs(){ codesign --force --options runtime --timestamp "$@"; }
cmd_build(){
log "构建 release(API=$API_URL)"
cd "$CLIENT"
flutter build macos --release --dart-define="PANGOLIN_API_URL=$API_URL"
[ -d "$APP" ] || die "构建产物不存在: $APP"
}
cmd_sign(){
[ -d "$APP" ] || die "先 build:找不到 $APP"
log "Developer ID 重签"
write_entitlements
find_profile "$APP_PROFILE_NAME" || die "找不到描述文件: $APP_PROFILE_NAME"
local app_prof="$FOUND"
find_profile "$SE_PROFILE_NAME" || die "找不到描述文件: $SE_PROFILE_NAME"
local se_prof="$FOUND"
echo " app profile : $app_prof"
echo " sysext prof : $se_prof"
xattr -cr "$APP"
cp "$app_prof" "$APP/Contents/embedded.provisionprofile"
cp "$se_prof" "$SE/Contents/embedded.provisionprofile"
# 内向外:sysext → app 各 framework → app 主体。
# libbox 现为「只 Link 不 Embed」静态进 sysext 二进制(见 CLAUDE.md),sysext 内已无
# 独立 Libbox.framework;签 sysext bundle 即覆盖其静态链接的 libbox,无需单独签 framework。
cs --entitlements "$WORK/sysext.entitlements" -s "$SIGN_ID" "$SE"
local item
for item in "$APP/Contents/Frameworks/"*; do
[ -e "$item" ] && cs -s "$SIGN_ID" "$item"
done
cs --entitlements "$WORK/app.entitlements" -s "$SIGN_ID" "$APP"
codesign --verify --deep --strict "$APP" || die "深度校验失败"
echo " ✅ 重签 + 校验通过($SIGN_ID)"
}
cmd_notarize(){
[ -d "$APP" ] || die "先 build/sign:找不到 $APP"
log "公证(notarytool submit --wait,通常 1-5 分钟)"
local zip="$WORK/pangolin_vpn.zip"
rm -f "$zip"
ditto -c -k --keepParent "$APP" "$zip"
xcrun notarytool submit "$zip" --keychain-profile "$NOTARY_PROFILE" --wait
log "staple 票据到 app"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP" && echo " ✅ 已公证 + staple"
}
cmd_copy(){
[ -d "$APP" ] || die "先 build/sign:找不到 $APP"
log "安装到 /Applications"
osascript -e 'quit app "pangolin_vpn"' 2>/dev/null || true
pkill -x pangolin_vpn 2>/dev/null || true
sleep 1
rm -rf /Applications/pangolin_vpn.app
cp -R "$APP" /Applications/
echo " ✅ /Applications/pangolin_vpn.app"
}
cmd_run(){
log "启动 /Applications/pangolin_vpn.app(直跑,日志见下)"
echo ""
echo " 测试账号: $TEST_EMAIL"
echo " 测试密码: $TEST_PASSWORD"
echo ""
exec /Applications/pangolin_vpn.app/Contents/MacOS/pangolin_vpn
}
# ─────────── KillSwitch fail-closed 验证 ───────────
# 原理:killswitch 开 → NE 设了 includeAllNetworks + enforceRoutes(L2,OS 级强制)
# + NEOnDemandRule 常开(L3)。验证点:隧道数据进程(sysext)被杀的瞬间,流量必须
# 被 OS 挡住、真实 IP 不泄漏;随后 on-demand 把隧道自动拉回。
get_ip(){ curl -fsS --max-time "${2:-5}" "$IP_SVC" 2>/dev/null; }
vpn_status(){ scutil --nc status "$VPN_NAME" 2>/dev/null | head -1; }
ext_pid(){ pgrep -x "$EXT_NAME" 2>/dev/null || pgrep -f "$EXT_NAME.systemextension" 2>/dev/null || true; }
# 第 1 步:VPN 断开下记录「真实公网 IP」基线(后续用来判断是否泄漏)。
cmd_ks_baseline(){
log "KillSwitch 基线:记录真实公网 IP(请确保此刻 VPN 已断开)"
local real; real="$(get_ip real 6)"
[ -n "$real" ] || die "拿不到公网 IP(断网?)。请连上普通网络、断开 VPN 再跑。"
echo "$real" > "$WORK/ks_real_ip"
echo " 真实公网 IP = $real(已存 $WORK/ks_real_ip)"
echo " 默认路由接口:"; route -n get default 2>/dev/null | grep -E 'interface:' || true
}
# 第 2 步:VPN 连接(killswitch 开)下做漏测 —— 杀扩展进程,在重连窗口内狂探 IP。
cmd_ks_test(){
[ -f "$WORK/ks_real_ip" ] || die "先跑 ks-baseline 记录真实 IP。"
local real; real="$(cat "$WORK/ks_real_ip")"
log "KillSwitch 漏测(真实 IP=$real)"
echo " 当前 VPN 状态: $(vpn_status)"
vpn_status | grep -qi Connected || die "VPN 未连接。请在 app 内连接(killswitch 保持开)后再跑。"
local vpnip; vpnip="$(get_ip vpn 6)"
echo " 连接中出口 IP = ${vpnip:-<取不到>}"
[ -n "$vpnip" ] && [ "$vpnip" = "$real" ] && die "出口 IP 等于真实 IP —— VPN 没真正接管,测试无意义。"
local pid; pid="$(ext_pid)"
[ -n "$pid" ] || die "找不到扩展进程 $EXT_NAME(它以 root 运行,确认隧道在跑)。"
echo " 扩展进程 PID = $pid,准备 sudo 杀掉模拟崩溃…(可能要输密码)"
sudo kill -9 $pid 2>/dev/null || sudo pkill -x "$EXT_NAME" 2>/dev/null || true
# 杀掉后立刻在重连窗口内反复探测:任何一次拿到「真实 IP」即判定泄漏。
log "重连窗口漏测(8 次 × ~1s)"
local leaked=0 reachable=0 i ip
for i in $(seq 1 8); do
ip="$(get_ip probe 1)"
if [ -z "$ip" ]; then
echo " #$i 流量被阻断(无响应)✓ fail-closed"
elif [ "$ip" = "$real" ]; then
echo " #$i ❌ 泄漏!拿到真实 IP=$ip"
leaked=1
else
echo " #$i 仍走隧道 IP=$ip(on-demand 已拉回)"
reachable=1
fi
done
echo ""
log "on-demand 自愈检查(等 5s)"
sleep 5
echo " VPN 状态: $(vpn_status)"
echo " 此刻出口 IP = $(get_ip after 6)(应为隧道 IP、且 != $real)"
echo ""
if [ "$leaked" = 1 ]; then
die "FAIL —— 检测到真实 IP 泄漏,fail-closed 未生效(检查 includeAllNetworks/enforceRoutes)。"
fi
printf '\033[1;32m✅ PASS —— 扩展被杀期间真实 IP 未泄漏(fail-closed 生效)。\033[0m\n'
echo " 补充手测:在 app 内点「断开」→ 跑 ks-status,应稳定 Disconnected 不被 on-demand 拉回(验 stop() 处理)。"
}
# 辅助:看 NE 是否真把 killswitch 配置写进去了(读统一日志我加的打点)。
cmd_ks_status(){
log "VPN 状态"; vpn_status
echo ""; log "最近 5 分钟 killswitch 相关日志(app + 扩展打点)"
log show --last 5m --style compact \
--predicate 'eventMessage CONTAINS[c] "killSwitch" OR eventMessage CONTAINS "includeAllNetworks"' \
2>/dev/null | tail -20 || echo " (无,确认已连接过一次)"
}
case "${1:-}" in
build) cmd_build ;;
sign) cmd_sign ;; # 旧:手动 Developer ID 重签(现已由 Xcode 工程配置直接签,通常不需要)
notarize) cmd_notarize ;;
copy) cmd_copy ;;
run) cmd_run ;;
# Xcode 工程已配 Developer ID 手动签名(Release 配置),build 即出 DevID 包,无需 sign。
all) cmd_build; cmd_notarize; cmd_copy; cmd_run ;;
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 ;;
esac