Files
wangjia 2c804d4ebf fix(scripts): local_test.sh 对齐当前 macOS 架构(sysext 标识符名 + 静态 libbox)
发版脚本停留在旧「嵌入 Libbox.framework」架构,与现工程漂移两处、导致 sign 失败:
- sysext bundle 名 = 标识符 com.pangolin.pangolin.PacketTunnel.systemextension
  (PRODUCT_NAME,见 CLAUDE.md),非短名 PacketTunnel.systemextension。
- libbox 现「只 Link 不 Embed」静态进 sysext 二进制,sysext 内已无独立
  Libbox.framework;删掉对不存在 framework 的签名,签 sysext bundle 即覆盖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:11:05 +08:00

251 lines
11 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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'
<?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>com.apple.application-identifier</key><string>BYL4KQHMTN.com.pangolin.pangolin</string>
<key>com.apple.developer.team-identifier</key><string>BYL4KQHMTN</string>
<key>com.apple.developer.system-extension.install</key><true/>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><false/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.network.server</key><true/>
<key>keychain-access-groups</key>
<array><string>BYL4KQHMTN.com.pangolin.pangolin</string></array>
</dict></plist>
PLIST
cat > "$WORK/sysext.entitlements" <<'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>com.apple.application-identifier</key><string>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</string>
<key>com.apple.developer.team-identifier</key><string>BYL4KQHMTN</string>
<key>com.apple.developer.networking.networkextension</key>
<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>group.com.pangolin.pangolin</string></array>
</dict></plist>
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