Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d154bd627 | |||
| f819a77d83 | |||
| dd721fafc8 | |||
| e38c7e7c1c | |||
| d5fc9c1c07 | |||
| 74d8c857be |
+8
-3
@@ -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/
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 で開いてください';
|
||||
}
|
||||
|
||||
|
||||
@@ -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에서 열어주세요';
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(自托管,
|
||||
|
||||
@@ -31,11 +31,14 @@ type Authenticator struct {
|
||||
failMax int
|
||||
lockDur time.Duration
|
||||
sec *SecurityLog
|
||||
trusted *TrustedStore
|
||||
trustTTL time.Duration
|
||||
// now is overridable in tests.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewAuthenticator wires an Authenticator.
|
||||
// NewAuthenticator wires an Authenticator. Device-trust ("记住此设备") is
|
||||
// enabled when cfg.TrustedDeviceTTL > 0.
|
||||
func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cfg *Config, sec *SecurityLog) *Authenticator {
|
||||
return &Authenticator{
|
||||
store: store,
|
||||
@@ -45,58 +48,105 @@ func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cf
|
||||
failMax: cfg.LoginFailMax,
|
||||
lockDur: cfg.LoginLockDuration,
|
||||
sec: sec,
|
||||
trusted: NewTrustedStore(rdb, cfg.TrustedDeviceTTL),
|
||||
trustTTL: cfg.TrustedDeviceTTL,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
// LoginResult is the outcome of a device-aware login.
|
||||
type LoginResult struct {
|
||||
SID string
|
||||
Session *Session
|
||||
// NewTrustToken is non-empty when the caller ticked "记住此设备" and a fresh
|
||||
// device-trust token was issued — the handler sets it as the trust cookie.
|
||||
NewTrustToken string
|
||||
// Persistent is true when this login should use a long-lived (trust-TTL)
|
||||
// session + cookie instead of the short idle default.
|
||||
Persistent bool
|
||||
}
|
||||
|
||||
// Login validates username + password + TOTP and, on success, creates a
|
||||
// session and returns its id. Every failure is rate-limited and recorded as a
|
||||
// security event in the audit log (red line: admin records only security
|
||||
// events, never routine access).
|
||||
func (a *Authenticator) Login(ctx context.Context, username, password, code, remoteIP string) (sid string, sess *Session, err error) {
|
||||
res, lerr := a.LoginDevice(ctx, username, password, code, remoteIP, "", false)
|
||||
return res.SID, res.Session, lerr
|
||||
}
|
||||
|
||||
// LoginDevice is the device-aware login: username + password are ALWAYS
|
||||
// required; the TOTP second factor is skipped only when trustToken is a live
|
||||
// trust token bound to this admin ("记住此设备"). When remember is set, a fresh
|
||||
// trust token is issued and the session is made persistent (trust-TTL long).
|
||||
//
|
||||
// Security invariant: a trust token never substitutes for the password — a
|
||||
// wrong password fails regardless of trust.
|
||||
func (a *Authenticator) LoginDevice(ctx context.Context, username, password, code, remoteIP, trustToken string, remember bool) (LoginResult, error) {
|
||||
locked, lerr := a.isLocked(ctx, username)
|
||||
if lerr != nil {
|
||||
return "", nil, fmt.Errorf("admin.Login lock check: %w", lerr)
|
||||
return LoginResult{}, fmt.Errorf("admin.Login lock check: %w", lerr)
|
||||
}
|
||||
if locked {
|
||||
a.sec.LoginLocked(ctx, username, remoteIP)
|
||||
return "", nil, ErrLockedOut
|
||||
return LoginResult{}, ErrLockedOut
|
||||
}
|
||||
|
||||
admin, gerr := a.store.GetAdminByUsername(ctx, username)
|
||||
if gerr != nil && !errors.Is(gerr, ErrAdminNotFound) {
|
||||
return "", nil, fmt.Errorf("admin.Login lookup: %w", gerr)
|
||||
return LoginResult{}, fmt.Errorf("admin.Login lookup: %w", gerr)
|
||||
}
|
||||
|
||||
if admin == nil || admin.Status != "active" || !VerifyPassword(admin.PwHash, password) {
|
||||
a.recordFail(ctx, username)
|
||||
a.sec.LoginFail(ctx, username, remoteIP, "bad_password")
|
||||
return "", nil, ErrInvalidCredentials
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc)
|
||||
if derr != nil {
|
||||
a.recordFail(ctx, username)
|
||||
a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt")
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
if !totp.Validate(secret, code, a.now(), 1) {
|
||||
a.recordFail(ctx, username)
|
||||
a.sec.LoginFail(ctx, username, remoteIP, "bad_totp")
|
||||
return "", nil, ErrInvalidCredentials
|
||||
// Second factor: skip only for a device already trusted by THIS admin.
|
||||
if !a.trusted.Check(ctx, trustToken, admin.ID) {
|
||||
secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc)
|
||||
if derr != nil {
|
||||
a.recordFail(ctx, username)
|
||||
a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt")
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
if !totp.Validate(secret, code, a.now(), 1) {
|
||||
a.recordFail(ctx, username)
|
||||
a.sec.LoginFail(ctx, username, remoteIP, "bad_totp")
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
}
|
||||
|
||||
// Success: clear counter, stamp login, create session.
|
||||
a.clearFail(ctx, username)
|
||||
if uerr := a.store.UpdateLastLogin(ctx, admin.ID, a.now()); uerr != nil {
|
||||
return "", nil, fmt.Errorf("admin.Login update: %w", uerr)
|
||||
return LoginResult{}, fmt.Errorf("admin.Login update: %w", uerr)
|
||||
}
|
||||
|
||||
persistent := remember && a.trusted.Enabled()
|
||||
var (
|
||||
sid string
|
||||
sess *Session
|
||||
serr error
|
||||
)
|
||||
if persistent {
|
||||
sid, sess, serr = a.sessions.CreateWithTTL(ctx, admin.ID, admin.Username, a.trustTTL)
|
||||
} else {
|
||||
sid, sess, serr = a.sessions.Create(ctx, admin.ID, admin.Username)
|
||||
}
|
||||
sid, sess, serr := a.sessions.Create(ctx, admin.ID, admin.Username)
|
||||
if serr != nil {
|
||||
return "", nil, serr
|
||||
return LoginResult{}, serr
|
||||
}
|
||||
|
||||
res := LoginResult{SID: sid, Session: sess, Persistent: persistent}
|
||||
if remember {
|
||||
if tok, terr := a.trusted.Issue(ctx, admin.ID); terr == nil {
|
||||
res.NewTrustToken = tok
|
||||
}
|
||||
}
|
||||
a.sec.LoginOK(ctx, username, remoteIP)
|
||||
return sid, sess, nil
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) isLocked(ctx context.Context, username string) (bool, error) {
|
||||
|
||||
@@ -44,6 +44,12 @@ type Config struct {
|
||||
// CookieSecure controls the Secure attribute on the session cookie.
|
||||
// Defaults to true; only disabled explicitly for local/dev over plain HTTP.
|
||||
CookieSecure bool
|
||||
|
||||
// TrustedDeviceTTL is how long a "记住此设备" trust lasts. Within this window
|
||||
// the device (a) skips the TOTP second factor on re-login and (b) keeps a
|
||||
// persistent session of the same length. Password is ALWAYS still required.
|
||||
// Zero disables the feature (checkbox has no effect).
|
||||
TrustedDeviceTTL time.Duration
|
||||
}
|
||||
|
||||
// defaultInternalCIDRs are the loopback and private/ULA ranges allowed by
|
||||
@@ -66,6 +72,7 @@ var defaultInternalCIDRs = []string{
|
||||
// ADMIN_LOGIN_FAIL_MAX int (default 5)
|
||||
// ADMIN_LOGIN_LOCK Go duration (default 15m)
|
||||
// ADMIN_COOKIE_INSECURE "1" disables Secure flag (dev only)
|
||||
// ADMIN_TRUSTED_DEVICE_TTL Go duration (default 720h = 30d; 0 disables)
|
||||
func FromEnv() (*Config, error) {
|
||||
c := &Config{
|
||||
Listen: getEnvDefault("ADMIN_LISTEN", "127.0.0.1:9443"),
|
||||
@@ -73,6 +80,14 @@ func FromEnv() (*Config, error) {
|
||||
LoginFailMax: 5,
|
||||
LoginLockDuration: 15 * time.Minute,
|
||||
CookieSecure: os.Getenv("ADMIN_COOKIE_INSECURE") != "1",
|
||||
TrustedDeviceTTL: 30 * 24 * time.Hour,
|
||||
}
|
||||
if v := os.Getenv("ADMIN_TRUSTED_DEVICE_TTL"); v != "" {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: ADMIN_TRUSTED_DEVICE_TTL %q invalid: %w", v, err)
|
||||
}
|
||||
c.TrustedDeviceTTL = d
|
||||
}
|
||||
|
||||
if err := validateListen(c.Listen); err != nil {
|
||||
|
||||
@@ -61,9 +61,15 @@ func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
code := strings.TrimSpace(r.PostFormValue("totp"))
|
||||
ip := hostOnly(r.RemoteAddr)
|
||||
remember := r.PostFormValue("remember") != ""
|
||||
ip := realIP(r) // 经本机 caddy 反代时取 XFF 末跳,否则 TCP 对端(见 mw_ipallow.go)
|
||||
|
||||
sid, _, err := h.auth.Login(r.Context(), username, password, code, ip)
|
||||
var trustToken string
|
||||
if c, cerr := r.Cookie(TrustedDeviceCookieName); cerr == nil {
|
||||
trustToken = c.Value
|
||||
}
|
||||
|
||||
res, err := h.auth.LoginDevice(r.Context(), username, password, code, ip, trustToken, remember)
|
||||
if err != nil {
|
||||
flash := "用户名、密码或动态验证码有误"
|
||||
if err == ErrLockedOut {
|
||||
@@ -73,7 +79,14 @@ func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
h.render.render(w, "login", pageData{Flash: flash})
|
||||
return
|
||||
}
|
||||
h.setSessionCookie(w, sid)
|
||||
if res.Persistent {
|
||||
h.setSessionCookieTTL(w, res.SID, h.cfg.TrustedDeviceTTL)
|
||||
} else {
|
||||
h.setSessionCookie(w, res.SID)
|
||||
}
|
||||
if res.NewTrustToken != "" {
|
||||
h.setTrustedCookie(w, res.NewTrustToken)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -394,6 +407,14 @@ func (h *Handlers) writeAudit(ctx context.Context, actor, action, target, metaJS
|
||||
}
|
||||
|
||||
func (h *Handlers) setSessionCookie(w http.ResponseWriter, sid string) {
|
||||
h.setSessionCookieTTL(w, sid, h.cfg.SessionTTL)
|
||||
}
|
||||
|
||||
// setSessionCookieTTL sets the session cookie with an explicit Max-Age. For a
|
||||
// persistent ("记住此设备") session ttl is the long trust-TTL; otherwise the
|
||||
// short idle default. Max-Age <= 0 would make it a session cookie, so ttl must
|
||||
// be positive here.
|
||||
func (h *Handlers) setSessionCookieTTL(w http.ResponseWriter, sid string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: sid,
|
||||
@@ -401,7 +422,21 @@ func (h *Handlers) setSessionCookie(w http.ResponseWriter, sid string) {
|
||||
HttpOnly: true,
|
||||
Secure: h.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(h.cfg.SessionTTL.Seconds()),
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// setTrustedCookie stores the device-trust token (HttpOnly, Secure, Strict) so
|
||||
// this device can skip TOTP on future logins for the trust TTL.
|
||||
func (h *Handlers) setTrustedCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: TrustedDeviceCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: h.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(h.cfg.TrustedDeviceTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/wangjia/pangolin/server/internal/totp"
|
||||
)
|
||||
|
||||
// newTrustEnv wires a full admin router with device-trust ENABLED and seeds
|
||||
// one admin, returning the router and that admin's TOTP secret.
|
||||
func newTrustEnv(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allow, _ := ParseCIDRs([]string{"127.0.0.0/8"})
|
||||
cfg := &Config{
|
||||
Listen: "127.0.0.1:9443", AllowCIDRs: allow, SecretKey: key,
|
||||
SessionTTL: 30 * time.Minute, LoginFailMax: 3, LoginLockDuration: time.Minute,
|
||||
CookieSecure: false, TrustedDeviceTTL: 30 * 24 * time.Hour,
|
||||
}
|
||||
store := newFakeStore()
|
||||
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
|
||||
|
||||
sessions := NewSessionStore(rdb, cfg.SessionTTL)
|
||||
sec := NewSecurityLog(store, nil)
|
||||
auth := NewAuthenticator(store, sessions, rdb, cfg, sec)
|
||||
svc := Services{Codes: &fakeCodes{}, Lifecycle: &recordingLifecycle{ready: true}, Provision: &recordingProvision{ready: true}}
|
||||
h, err := NewHandlers(cfg, store, sessions, auth, svc, sec, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewRouter(h, sessions, cfg, sec), secret
|
||||
}
|
||||
|
||||
func cookieByName(resp *http.Response, name string) *http.Cookie {
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postLogin(t *testing.T, router http.Handler, form url.Values, cookies ...*http.Cookie) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.RemoteAddr = "127.0.0.1:5000"
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr.Result()
|
||||
}
|
||||
|
||||
// 端到端:勾选记住 → 登录成功 → 同时下发会话 cookie 与信任 cookie;
|
||||
// 随后仅凭信任 cookie + 空 TOTP 再次登录成功(免二次验证)。
|
||||
func TestLoginHandler_RememberThenSkipTOTP(t *testing.T) {
|
||||
router, secret := newTrustEnv(t)
|
||||
code, _ := totp.Code(secret, time.Now().UTC())
|
||||
|
||||
resp := postLogin(t, router, url.Values{
|
||||
"username": {"alice"}, "password": {"s3cret-pass"},
|
||||
"totp": {code}, "remember": {"1"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("remember login: status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
trust := cookieByName(resp, TrustedDeviceCookieName)
|
||||
if trust == nil || trust.Value == "" {
|
||||
t.Fatal("remember login should set a non-empty trusted cookie")
|
||||
}
|
||||
if trust.MaxAge <= 0 {
|
||||
t.Errorf("trusted cookie MaxAge = %d, want > 0 (persistent)", trust.MaxAge)
|
||||
}
|
||||
sessCookie := cookieByName(resp, SessionCookieName)
|
||||
if sessCookie == nil || sessCookie.MaxAge <= int((30 * time.Minute).Seconds()) {
|
||||
t.Error("remember login should set a long-lived (persistent) session cookie")
|
||||
}
|
||||
|
||||
// 第二次:只带信任 cookie,TOTP 留空 → 成功
|
||||
resp2 := postLogin(t, router, url.Values{
|
||||
"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {""},
|
||||
}, &http.Cookie{Name: TrustedDeviceCookieName, Value: trust.Value})
|
||||
if resp2.StatusCode != http.StatusFound {
|
||||
t.Fatalf("trusted re-login: status = %d, want 302 (TOTP skipped)", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 无信任 cookie + 空 TOTP → 401(二次验证仍强制)。
|
||||
func TestLoginHandler_NoTrustEmptyTOTPRejected(t *testing.T) {
|
||||
router, _ := newTrustEnv(t)
|
||||
resp := postLogin(t, router, url.Values{
|
||||
"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {""},
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("empty TOTP without trust: status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/totp"
|
||||
)
|
||||
|
||||
// newTrustAuth builds an Authenticator with device-trust ENABLED (30d).
|
||||
func newTrustAuth(t *testing.T) (*Authenticator, *fakeStore, []byte) {
|
||||
t.Helper()
|
||||
rdb, _ := newTestRedis(t)
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := newFakeStore()
|
||||
cfg := &Config{
|
||||
SecretKey: key, LoginFailMax: 3, LoginLockDuration: time.Minute,
|
||||
SessionTTL: 30 * time.Minute, TrustedDeviceTTL: 30 * 24 * time.Hour,
|
||||
}
|
||||
sessions := NewSessionStore(rdb, cfg.SessionTTL)
|
||||
sec := NewSecurityLog(store, nil)
|
||||
return NewAuthenticator(store, sessions, rdb, cfg, sec), store, key
|
||||
}
|
||||
|
||||
// 勾选「记住此设备」成功登录 → 返回可用于下次跳过 TOTP 的信任令牌。
|
||||
func TestLoginDevice_RememberIssuesTrust(t *testing.T) {
|
||||
auth, store, key := newTrustAuth(t)
|
||||
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
|
||||
code, _ := totp.Code(secret, time.Now().UTC())
|
||||
ctx := context.Background()
|
||||
|
||||
res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if res.NewTrustToken == "" {
|
||||
t.Fatal("remember=true should issue a trust token")
|
||||
}
|
||||
if !res.Persistent {
|
||||
t.Error("remember=true should mark session persistent")
|
||||
}
|
||||
// 下次:带该令牌 + 空 TOTP 也能登录(跳过二次验证)
|
||||
res2, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", res.NewTrustToken, false)
|
||||
if err != nil {
|
||||
t.Fatalf("trusted re-login should skip TOTP: %v", err)
|
||||
}
|
||||
if res2.SID == "" {
|
||||
t.Fatal("no session on trusted re-login")
|
||||
}
|
||||
}
|
||||
|
||||
// 不勾选:不签发令牌,且 TOTP 仍必填。
|
||||
func TestLoginDevice_NoRememberRequiresTOTP(t *testing.T) {
|
||||
auth, store, key := newTrustAuth(t)
|
||||
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
|
||||
code, _ := totp.Code(secret, time.Now().UTC())
|
||||
ctx := context.Background()
|
||||
|
||||
res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if res.NewTrustToken != "" {
|
||||
t.Error("remember=false must not issue a trust token")
|
||||
}
|
||||
// 无令牌 + 空 TOTP → 拒
|
||||
if _, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", "", false); err != ErrInvalidCredentials {
|
||||
t.Errorf("untrusted device with empty TOTP should fail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 铁律:即便持有效信任令牌,密码错误一律拒(只跳过 TOTP,不跳过密码)。
|
||||
func TestLoginDevice_TrustNeverSkipsPassword(t *testing.T) {
|
||||
auth, store, key := newTrustAuth(t)
|
||||
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
|
||||
code, _ := totp.Code(secret, time.Now().UTC())
|
||||
ctx := context.Background()
|
||||
|
||||
res, _ := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true)
|
||||
if res.NewTrustToken == "" {
|
||||
t.Fatal("precondition: expected trust token")
|
||||
}
|
||||
if _, err := auth.LoginDevice(ctx, "alice", "WRONG", "", "127.0.0.1", res.NewTrustToken, false); err != ErrInvalidCredentials {
|
||||
t.Errorf("trusted device must still require correct password, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 信任令牌绑定 admin:换个用户名不认(令牌属 alice,拿去登 bob 无效)。
|
||||
func TestLoginDevice_TrustBoundToAdmin(t *testing.T) {
|
||||
auth, store, key := newTrustAuth(t)
|
||||
sa := newTestAdmin(t, store, key, "alice", "alice-pass")
|
||||
_ = sa
|
||||
// bob:另一个 admin,不同 ID
|
||||
hash, _ := HashPassword("bob-pass")
|
||||
bsecret, _ := totp.GenerateSecret()
|
||||
benc, _ := EncryptSecret(key, bsecret)
|
||||
store.admins["bob"] = &Admin{ID: 2, Username: "bob", PwHash: hash, TOTPSecretEnc: benc, Status: "active"}
|
||||
ctx := context.Background()
|
||||
|
||||
acode, _ := totp.Code(sa, time.Now().UTC())
|
||||
ares, _ := auth.LoginDevice(ctx, "alice", "alice-pass", acode, "127.0.0.1", "", true)
|
||||
if ares.NewTrustToken == "" {
|
||||
t.Fatal("precondition: alice trust token")
|
||||
}
|
||||
// 用 alice 的令牌 + 空 TOTP 登 bob → 应要求 TOTP(令牌对 bob 无效)
|
||||
if _, err := auth.LoginDevice(ctx, "bob", "bob-pass", "", "127.0.0.1", ares.NewTrustToken, false); err != ErrInvalidCredentials {
|
||||
t.Errorf("alice's trust token must not skip TOTP for bob, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IPAllow is middleware that rejects any request whose source IP is not within
|
||||
@@ -58,3 +59,28 @@ func hostOnly(remoteAddr string) string {
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// realIP returns the client IP for **audit logging** (admin_login_ok 等)。
|
||||
//
|
||||
// 与上面 IPAllow 的取址原则刻意不同:白名单闸继续只看 TCP 对端(不可伪造);
|
||||
// 而审计日志要的是"人从哪来"——经本机反代(caddy mTLS 网关在同机回环上反代
|
||||
// 127.0.0.1:9444)时 RemoteAddr 恒为 loopback,没有溯源价值。仅当 TCP 对端是
|
||||
// loopback(即请求确实来自本机可信反代)才信 X-Forwarded-For,且取**最后一跳**
|
||||
// (Caddy 把真实 TCP 对端追加在末位;更早的段可被客户端伪造预置,不可信)。
|
||||
func realIP(r *http.Request) string {
|
||||
peer := hostOnly(r.RemoteAddr)
|
||||
ip := net.ParseIP(peer)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return peer
|
||||
}
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff == "" {
|
||||
return peer
|
||||
}
|
||||
parts := strings.Split(xff, ",")
|
||||
last := strings.TrimSpace(parts[len(parts)-1])
|
||||
if net.ParseIP(last) == nil {
|
||||
return peer
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realIP:仅当 TCP 对端为 loopback(本机可信反代,如 caddy mTLS 网关)时才信
|
||||
// X-Forwarded-For 的最后一跳;其余情况一律用 TCP 对端,防止伪造头污染审计。
|
||||
func TestRealIP(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xff string
|
||||
want string
|
||||
}{
|
||||
{"直连无代理", "203.0.113.7:52011", "", "203.0.113.7"},
|
||||
{"直连时伪造 XFF 不可信", "203.0.113.7:52011", "1.2.3.4", "203.0.113.7"},
|
||||
{"经本机反代取 XFF 最后一跳", "127.0.0.1:38200", "198.51.100.23", "198.51.100.23"},
|
||||
{"多跳取最后一跳(前段可伪造)", "127.0.0.1:38200", "6.6.6.6, 198.51.100.23", "198.51.100.23"},
|
||||
{"本机反代但无 XFF 回退 loopback", "127.0.0.1:38200", "", "127.0.0.1"},
|
||||
{"XFF 非法值回退", "127.0.0.1:38200", "not-an-ip", "127.0.0.1"},
|
||||
{"IPv6 loopback 反代", "[::1]:38200", "198.51.100.23", "198.51.100.23"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/login", nil)
|
||||
r.RemoteAddr = c.remoteAddr
|
||||
if c.xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", c.xff)
|
||||
}
|
||||
if got := realIP(r); got != c.want {
|
||||
t.Errorf("realIP(remote=%s, xff=%q) = %q, want %q", c.remoteAddr, c.xff, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type Session struct {
|
||||
Username string `json:"username"`
|
||||
CSRFToken string `json:"csrf"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// TTLSeconds, when > 0, overrides the store's default sliding idle TTL for
|
||||
// this session (used by "记住此设备" persistent sessions). 0 = use default.
|
||||
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// SessionStore persists admin sessions in Redis with a sliding idle TTL.
|
||||
@@ -41,9 +44,15 @@ func NewSessionStore(rdb *redis.Client, ttl time.Duration) *SessionStore {
|
||||
return &SessionStore{rdb: rdb, ttl: ttl}
|
||||
}
|
||||
|
||||
// Create starts a new session for the given admin and returns the opaque
|
||||
// session id (to be set as the cookie value).
|
||||
// Create starts a new session with the store's default sliding idle TTL.
|
||||
func (s *SessionStore) Create(ctx context.Context, adminID int64, username string) (string, *Session, error) {
|
||||
return s.CreateWithTTL(ctx, adminID, username, 0)
|
||||
}
|
||||
|
||||
// CreateWithTTL starts a new session. When ttl > 0 the session uses that TTL
|
||||
// (both the initial expiry and the per-request slide) instead of the store
|
||||
// default — this is how "记住此设备" keeps a device logged in for e.g. 30 days.
|
||||
func (s *SessionStore) CreateWithTTL(ctx context.Context, adminID int64, username string, ttl time.Duration) (string, *Session, error) {
|
||||
sid, err := randToken(32)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
@@ -58,12 +67,24 @@ func (s *SessionStore) Create(ctx context.Context, adminID int64, username strin
|
||||
CSRFToken: csrf,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if ttl > 0 {
|
||||
sess.TTLSeconds = int64(ttl / time.Second)
|
||||
}
|
||||
if err := s.save(ctx, sid, sess); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return sid, sess, nil
|
||||
}
|
||||
|
||||
// slideTTL is the TTL to (re)apply to a session: its own override if set,
|
||||
// otherwise the store default.
|
||||
func (s *SessionStore) slideTTL(sess *Session) time.Duration {
|
||||
if sess != nil && sess.TTLSeconds > 0 {
|
||||
return time.Duration(sess.TTLSeconds) * time.Second
|
||||
}
|
||||
return s.ttl
|
||||
}
|
||||
|
||||
// Get loads a session and slides its TTL forward. Returns (nil, nil) when the
|
||||
// session is absent or expired.
|
||||
func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) {
|
||||
@@ -81,8 +102,8 @@ func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) {
|
||||
if err := json.Unmarshal(val, &sess); err != nil {
|
||||
return nil, fmt.Errorf("admin.SessionStore.Get unmarshal: %w", err)
|
||||
}
|
||||
// Slide the idle timeout.
|
||||
if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.ttl).Err(); err != nil {
|
||||
// Slide the idle timeout (persistent sessions slide by their own TTL).
|
||||
if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.slideTTL(&sess)).Err(); err != nil {
|
||||
return nil, fmt.Errorf("admin.SessionStore.Get expire: %w", err)
|
||||
}
|
||||
return &sess, nil
|
||||
@@ -101,7 +122,7 @@ func (s *SessionStore) save(ctx context.Context, sid string, sess *Session) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin.SessionStore.save: %w", err)
|
||||
}
|
||||
if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.ttl).Err(); err != nil {
|
||||
if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.slideTTL(sess)).Err(); err != nil {
|
||||
return fmt.Errorf("admin.SessionStore.save set: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
{{if .Flash}}<div class="error">{{.Flash}}</div>{{end}}
|
||||
<label>用户名<input type="text" name="username" autocomplete="username" required autofocus></label>
|
||||
<label>密码<input type="password" name="password" autocomplete="current-password" required></label>
|
||||
<label>动态验证码 (TOTP)<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6" required></label>
|
||||
<label>动态验证码 (TOTP)<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6"></label>
|
||||
<label class="checkbox"><input type="checkbox" name="remember" value="1"> 记住此设备(30 天内免动态码、保持登录)</label>
|
||||
<button type="submit">登录</button>
|
||||
<p class="hint">仅限内网 / SSH 隧道访问。</p>
|
||||
<p class="hint">仅限白名单设备(客户端证书)访问。已记住的设备可留空动态码。</p>
|
||||
</form>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// TrustedDeviceCookieName is the cookie holding a device-trust token.
|
||||
const TrustedDeviceCookieName = "admin_trusted"
|
||||
|
||||
// trustedKeyPrefix namespaces device-trust tokens in Redis.
|
||||
const trustedKeyPrefix = "admin:trusted:"
|
||||
|
||||
// TrustedStore records "记住此设备" device-trust tokens in Redis.
|
||||
//
|
||||
// A trusted token lets a device SKIP THE TOTP SECOND FACTOR on re-login
|
||||
// (password is still always required). Each token is opaque (32 bytes of
|
||||
// entropy), bound to one admin id, and expires after ttl — so a flushed
|
||||
// Redis, an expired token, or a mismatched admin all fail closed to
|
||||
// "TOTP required".
|
||||
type TrustedStore struct {
|
||||
rdb *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewTrustedStore wires a TrustedStore. A ttl <= 0 disables the feature:
|
||||
// Issue returns an empty token and Check always returns false.
|
||||
func NewTrustedStore(rdb *redis.Client, ttl time.Duration) *TrustedStore {
|
||||
return &TrustedStore{rdb: rdb, ttl: ttl}
|
||||
}
|
||||
|
||||
// Enabled reports whether device-trust is active.
|
||||
func (s *TrustedStore) Enabled() bool { return s != nil && s.rdb != nil && s.ttl > 0 }
|
||||
|
||||
// Issue mints a new trust token bound to adminID and stores it with the TTL.
|
||||
// Returns "" (no error) when the feature is disabled.
|
||||
func (s *TrustedStore) Issue(ctx context.Context, adminID int64) (string, error) {
|
||||
if !s.Enabled() {
|
||||
return "", nil
|
||||
}
|
||||
tok, err := randToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.rdb.Set(ctx, trustedKeyPrefix+tok, strconv.FormatInt(adminID, 10), s.ttl).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// Check reports whether tok is a live trust token bound to adminID.
|
||||
// Any miss (disabled, empty, unknown, expired, wrong admin) returns false.
|
||||
func (s *TrustedStore) Check(ctx context.Context, tok string, adminID int64) bool {
|
||||
if !s.Enabled() || tok == "" {
|
||||
return false
|
||||
}
|
||||
v, err := s.rdb.Get(ctx, trustedKeyPrefix+tok).Result()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return v == strconv.FormatInt(adminID, 10)
|
||||
}
|
||||
|
||||
// Revoke deletes a trust token (e.g. on explicit logout-all). A no-op when
|
||||
// disabled or empty.
|
||||
func (s *TrustedStore) Revoke(ctx context.Context, tok string) error {
|
||||
if !s.Enabled() || tok == "" {
|
||||
return nil
|
||||
}
|
||||
return s.rdb.Del(ctx, trustedKeyPrefix+tok).Err()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func newTestTrustedStore(t *testing.T) (*TrustedStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis: %v", err)
|
||||
}
|
||||
t.Cleanup(mr.Close)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
return NewTrustedStore(rdb, 30*24*time.Hour), mr
|
||||
}
|
||||
|
||||
func TestTrustedStore_IssueThenCheck(t *testing.T) {
|
||||
ts, _ := newTestTrustedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok, err := ts.Issue(ctx, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
if tok == "" {
|
||||
t.Fatal("Issue returned empty token")
|
||||
}
|
||||
// 正确 admin 命中
|
||||
if !ts.Check(ctx, tok, 7) {
|
||||
t.Error("Check should accept the token for the issuing admin")
|
||||
}
|
||||
// 令牌绑定 admin:换个 admin id 不认
|
||||
if ts.Check(ctx, tok, 8) {
|
||||
t.Error("Check must reject a token bound to a different admin")
|
||||
}
|
||||
// 未知令牌不认
|
||||
if ts.Check(ctx, "bogus-token", 7) {
|
||||
t.Error("Check must reject an unknown token")
|
||||
}
|
||||
// 空令牌不认
|
||||
if ts.Check(ctx, "", 7) {
|
||||
t.Error("Check must reject an empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedStore_Revoke(t *testing.T) {
|
||||
ts, _ := newTestTrustedStore(t)
|
||||
ctx := context.Background()
|
||||
tok, _ := ts.Issue(ctx, 7)
|
||||
if !ts.Check(ctx, tok, 7) {
|
||||
t.Fatal("precondition: token should be valid")
|
||||
}
|
||||
if err := ts.Revoke(ctx, tok); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
if ts.Check(ctx, tok, 7) {
|
||||
t.Error("Check must reject a revoked token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedStore_Expires(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis: %v", err)
|
||||
}
|
||||
defer mr.Close()
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
ts := NewTrustedStore(rdb, time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
tok, _ := ts.Issue(ctx, 7)
|
||||
if !ts.Check(ctx, tok, 7) {
|
||||
t.Fatal("precondition: token valid before expiry")
|
||||
}
|
||||
mr.FastForward(2 * time.Hour) // 超过 TTL
|
||||
if ts.Check(ctx, tok, 7) {
|
||||
t.Error("Check must reject an expired token (fail-closed)")
|
||||
}
|
||||
}
|
||||
|
||||
// TTL<=0 视为功能关闭:Issue 返回空、Check 恒 false。
|
||||
func TestTrustedStore_Disabled(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis: %v", err)
|
||||
}
|
||||
defer mr.Close()
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
ts := NewTrustedStore(rdb, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
tok, err := ts.Issue(ctx, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
if tok != "" {
|
||||
t.Error("disabled store should issue empty token")
|
||||
}
|
||||
if ts.Check(ctx, "anything", 7) {
|
||||
t.Error("disabled store should never trust")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user