Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ca21f9ee9 | |||
| caaed88298 | |||
| fc1160ecb3 | |||
| ee16cd3266 | |||
| e1e43adc80 | |||
| a3ad50aa75 | |||
| 5bc5ed1e62 | |||
| 398e6e61cb | |||
| 7e357ced0a | |||
| ea1a875607 | |||
| 1d154bd627 | |||
| f819a77d83 | |||
| 344dfe9a82 | |||
| 2abe4d0816 | |||
| e4b527b2c9 | |||
| aaa3384e3b | |||
| f147ffbe9e | |||
| 8e24a157e1 | |||
| dbc787ac2e | |||
| 63f4325e51 | |||
| 60ff7cc586 | |||
| 1cb494daa6 | |||
| 3324c1d687 | |||
| 6f183ff0df | |||
| d216e157c3 | |||
| 87d5afa703 | |||
| 4155fc8542 | |||
| 597d6ab68b | |||
| d4156b81e2 | |||
| 6df7c9de44 | |||
| e4d014ba99 | |||
| b71f6038ae | |||
| 9cbffbb727 | |||
| dd721fafc8 | |||
| e38c7e7c1c | |||
| d5fc9c1c07 | |||
| 74d8c857be |
+10
-10
@@ -20,7 +20,7 @@ on:
|
||||
# · runs-on: ubuntu-latest —— 在 catthehacker 容器里跑,仅用于纯 bash 扫描
|
||||
# (redline/cleartext/portable-sql);容器内**不能**嵌套 `docker run`(DinD 挂载
|
||||
# 失败,$PWD 在宿主不存在),故套 docker 的 job 不能用它。
|
||||
# · runs-on: nas —— host 模式(mac-pangolin-2 直接在宿主跑),`docker run` 是宿主
|
||||
# · runs-on: mac —— host 模式(mac-pangolin-2 直接在宿主跑),`docker run` 是宿主
|
||||
# 真 docker(非嵌套),可正常拉/跑 node/golang/flutter/python/shellcheck 镜像。
|
||||
# golden 保留 ghcr.io/cirruslabs/flutter Linux 容器 → 与入库基线渲染一致。
|
||||
jobs:
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
# ── Job 1: Lint (shellcheck) ─────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint — shellcheck
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
# ── Job 2: OpenAPI Sync Check ────────────────────────────────────────────
|
||||
openapi-check:
|
||||
name: OpenAPI Sync Check
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
# ── Job 4: Flutter 客户端(分析 + 单测/组件测试)────────────────────────
|
||||
flutter-client:
|
||||
name: Flutter — analyze + test
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
# (改了 design/colors_and_type.css 没重生成,或手改了生成物)。
|
||||
codegen-drift:
|
||||
name: Codegen Drift — token 生成物未漂移
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
ds-flow:
|
||||
name: DS-flow — 原型/跨端同源/代码色单源闸
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
# -tags integration(需 docker 起 mysql/redis),见 go-integration job。
|
||||
go-server:
|
||||
name: Go — build + test
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
# 详见 scripts/e2e-smoke.sh + server/test/e2e/。
|
||||
e2e-smoke:
|
||||
name: E2E Smoke — L4 进程级端到端
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
# · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。
|
||||
go-integration:
|
||||
name: Go — integration (mysql/redis testcontainers)
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
# tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。
|
||||
golden:
|
||||
name: Golden — 视觉回归 (全量:components/auth/desktop/tablet)
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -34,11 +34,15 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
# sing-box 源码经 NAS gitea 公网镜像取(mac runner 连不上 github)。
|
||||
# 用稳定公网域名 git.51yanmei.com(ali frps ← 家里 NAS frpc,nginx 443 反代
|
||||
# gitea)—— 外网/内网都通,不依赖本地 relay/tailscale。见 scripts/build-libbox.sh。
|
||||
SINGBOX_GIT: https://git.51yanmei.com/wangjia/sing-box.git
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -106,11 +110,14 @@ jobs:
|
||||
# simply picks up whatever artifacts DO exist — an absent "macos"
|
||||
# artifact is not an error there.
|
||||
build-macos:
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
continue-on-error: true
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
# macOS 同样内嵌 libbox → 同走 NAS gitea 公网镜像取 sing-box 源码(见 build-android)。
|
||||
SINGBOX_GIT: https://git.51yanmei.com/wangjia/sing-box.git
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -137,11 +144,14 @@ jobs:
|
||||
path: dist/
|
||||
|
||||
build-ios:
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
continue-on-error: true
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
# iOS 同样内嵌 libbox → 同走 NAS gitea 公网镜像取 sing-box 源码(见 build-android)。
|
||||
SINGBOX_GIT: https://git.51yanmei.com/wangjia/sing-box.git
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -168,7 +178,7 @@ jobs:
|
||||
# (对应平台下载保留 pangolin1 上一版,待可用时下个 client-v* 追上)。
|
||||
release-deploy:
|
||||
needs: [build-android]
|
||||
runs-on: nas
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
+11
-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/
|
||||
@@ -90,3 +95,6 @@ client/windows/installer/Output/
|
||||
/server/cover.out
|
||||
/server/coverage.txt
|
||||
/client/coverage/
|
||||
|
||||
# superpowers 执行进度暂存(本地,不入库)
|
||||
.superpowers/
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
// 国内镜像优先(见 settings.gradle 同注);原仓库保留作 fallback。
|
||||
maven { url "https://maven.aliyun.com/repository/google" }
|
||||
maven { url "https://maven.aliyun.com/repository/public" }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
+3
-1
@@ -2,4 +2,6 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
# 国内镜像:services.gradle.org 被 GFW 墙(SSL 重置),CI runner 在墙内下载发行版会失败。
|
||||
# 腾讯云 gradle 镜像提供同一份 zip。见 client/android/{settings,build}.gradle 的 maven 镜像同理。
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip
|
||||
|
||||
@@ -10,6 +10,11 @@ pluginManagement {
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
// 国内镜像优先(CI runner 在墙内,直连 google/central/gradlePluginPortal 会被 GFW 重置);
|
||||
// 原仓库保留作 fallback。与 deploy-client.yml 的 GOPROXY/PUB 镜像同理。
|
||||
maven { url "https://maven.aliyun.com/repository/google" }
|
||||
maven { url "https://maven.aliyun.com/repository/public" }
|
||||
maven { url "https://maven.aliyun.com/repository/gradle-plugin" }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
|
||||
@@ -206,6 +206,7 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
|
||||
let ipv4 = NEIPv4Settings(addresses: v4addrs.map(\.address),
|
||||
subnetMasks: v4addrs.map(\.mask))
|
||||
ipv4.includedRoutes = includedRoutes4(options)
|
||||
ipv4.excludedRoutes = excludedRoutes4(options)
|
||||
settings.ipv4Settings = ipv4
|
||||
}
|
||||
let v6addrs = routePrefixes(options.getInet6Address())
|
||||
@@ -213,6 +214,7 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
|
||||
let ipv6 = NEIPv6Settings(addresses: v6addrs.map(\.address),
|
||||
networkPrefixLengths: v6addrs.map { NSNumber(value: $0.prefix) })
|
||||
ipv6.includedRoutes = includedRoutes6(options)
|
||||
ipv6.excludedRoutes = excludedRoutes6(options)
|
||||
settings.ipv6Settings = ipv6
|
||||
}
|
||||
if let dns = try? options.getDNSServerAddress(), !dns.value.isEmpty {
|
||||
@@ -311,6 +313,19 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
|
||||
networkPrefixLength: NSNumber(value: $0.prefix)) }
|
||||
}
|
||||
|
||||
// 排除路由:sing-box config 的 route_exclude_address(私有 LAN 网段)经 libbox
|
||||
// 暴露在此。之前只设 includedRoutes、丢了 excludedRoutes → strict_route 把 LAN 也
|
||||
// 抓进隧道,VPN 开着连不上局域网/NAS/家里机器。设上 excludedRoutes 后 LAN 直连。
|
||||
private func excludedRoutes4(_ o: any LibboxTunOptionsProtocol) -> [NEIPv4Route] {
|
||||
routePrefixes(o.getInet4RouteExcludeAddress())
|
||||
.map { NEIPv4Route(destinationAddress: $0.address, subnetMask: $0.mask) }
|
||||
}
|
||||
private func excludedRoutes6(_ o: any LibboxTunOptionsProtocol) -> [NEIPv6Route] {
|
||||
routePrefixes(o.getInet6RouteExcludeAddress())
|
||||
.map { NEIPv6Route(destinationAddress: $0.address,
|
||||
networkPrefixLength: NSNumber(value: $0.prefix)) }
|
||||
}
|
||||
|
||||
// utun fd:getpeername 得 sockaddr_ctl,匹配 utun 控制 id(WireGuard 技法)
|
||||
private static func tunFd() -> Int32? {
|
||||
var ctlInfo = ctl_info()
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
|
||||
let ipv4 = NEIPv4Settings(addresses: v4addrs.map(\.address),
|
||||
subnetMasks: v4addrs.map(\.mask))
|
||||
ipv4.includedRoutes = includedRoutes4(options)
|
||||
ipv4.excludedRoutes = excludedRoutes4(options)
|
||||
settings.ipv4Settings = ipv4
|
||||
}
|
||||
let v6addrs = routePrefixes(options.getInet6Address())
|
||||
@@ -208,6 +209,7 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
|
||||
let ipv6 = NEIPv6Settings(addresses: v6addrs.map(\.address),
|
||||
networkPrefixLengths: v6addrs.map { NSNumber(value: $0.prefix) })
|
||||
ipv6.includedRoutes = includedRoutes6(options)
|
||||
ipv6.excludedRoutes = excludedRoutes6(options)
|
||||
settings.ipv6Settings = ipv6
|
||||
}
|
||||
if let dns = try? options.getDNSServerAddress(), !dns.value.isEmpty {
|
||||
@@ -301,6 +303,19 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
|
||||
networkPrefixLength: NSNumber(value: $0.prefix)) }
|
||||
}
|
||||
|
||||
// 排除路由:sing-box config 的 route_exclude_address(私有 LAN 网段)经 libbox
|
||||
// 暴露在此。之前只设 includedRoutes、丢了 excludedRoutes → strict_route 把 LAN 也
|
||||
// 抓进隧道,VPN 开着连不上局域网/NAS/家里机器。设上 excludedRoutes 后 LAN 直连。
|
||||
private func excludedRoutes4(_ o: any LibboxTunOptionsProtocol) -> [NEIPv4Route] {
|
||||
routePrefixes(o.getInet4RouteExcludeAddress())
|
||||
.map { NEIPv4Route(destinationAddress: $0.address, subnetMask: $0.mask) }
|
||||
}
|
||||
private func excludedRoutes6(_ o: any LibboxTunOptionsProtocol) -> [NEIPv6Route] {
|
||||
routePrefixes(o.getInet6RouteExcludeAddress())
|
||||
.map { NEIPv6Route(destinationAddress: $0.address,
|
||||
networkPrefixLength: NSNumber(value: $0.prefix)) }
|
||||
}
|
||||
|
||||
// utun fd:getpeername 得 sockaddr_ctl,匹配 utun 控制 id(WireGuard 技法)
|
||||
private static func tunFd() -> Int32? {
|
||||
var ctlInfo = ctl_info()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"_comment": "私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restart,restart 会冷启 sing-box 踢掉全部在线用户)。",
|
||||
"enabled": true,
|
||||
"allow_dp_uuids": [
|
||||
"TODO-设备级-dp-uuid",
|
||||
"TODO-账户级-dp-uuid-供-sub-订阅链接用"
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"_comment": "与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分",
|
||||
"domain": ["brain.51yanmei.com", "git.51yanmei.com"]
|
||||
},
|
||||
{
|
||||
"_comment": "独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023",
|
||||
"ip_cidr": ["182.92.213.171/32"],
|
||||
"port": [5001, 3389, 10022, 10023]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,10 @@ StartLimitIntervalSec=0
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/pangolin-agent/agent.env
|
||||
ExecStart=/usr/local/bin/pangolin-agent
|
||||
# SIGHUP → agent 重读节点本地配置(acl.json/warp.json)并热重渲染,不冷启动
|
||||
# sing-box、不踢在线用户。没有这行,`systemctl reload` 会报「Job type reload is
|
||||
# not applicable」,运维只能退回 restart(整进程重启,sing-box 冷启动瞬断全员)。
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
# 始终自愈(崩溃/被依赖停后都拉起);RestartSec 做退避。
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
+3
-3
@@ -10,13 +10,13 @@
|
||||
| 级别 | **项目级**(绑 `wangjia/pangolin`,只服务本仓库) |
|
||||
| 机器 | mac 本机(`/Users/wangjia/bin/act_runner` v0.6.1) |
|
||||
| 执行模式 | **host**(非容器) |
|
||||
| label | `nas`(即 `runs-on: nas`) |
|
||||
| label | `mac`(即 `runs-on: mac`) |
|
||||
| 连接 | 经 jiu 的 TCP relay `127.0.0.1:13000` → NAS gitea `192.168.3.200:3000` |
|
||||
|
||||
### 为什么是 host 模式 + mac
|
||||
- ci.yml 的 9 个 job **全是 docker-in-docker**(每个 job 内部 `docker run …` 拉 golang/flutter/redis 等镜像)。container 模式(像 jiu 的 `ubuntu-latest:docker://catthehacker`)在非特权容器里跑不了 DinD,所以必须 **host 模式**。
|
||||
- mac 的 Docker Desktop 能跑 Linux 容器,host 模式下 `docker run <linux-image>` 可用。
|
||||
- 理想归宿其实是 **NAS(Linux)host runner**(给 NAS 现有 runner 加 `nas:host` label),mac 只是当前落点。
|
||||
- 理想归宿其实是 **NAS(Linux)host runner**(给 NAS 现有 runner 加 `mac:host` label),mac 只是当前落点。
|
||||
|
||||
### 为什么要 relay(13000)
|
||||
mac 上的 Shadowrocket 网络扩展会拦截 act_runner 这个 Go 二进制的流量,直连 NAS gitea 失败。jiu 已有一个常驻 python TCP relay(`~/bin/tcp_relay.py`,`127.0.0.1:13000 → 192.168.3.200:3000`),act_runner 连本地 relay 绕过。pangolin runner **复用**这个 relay。
|
||||
@@ -30,7 +30,7 @@ runner:
|
||||
capacity: 1
|
||||
timeout: 3h
|
||||
labels:
|
||||
- "nas:host"
|
||||
- "mac:host"
|
||||
cache:
|
||||
enabled: true
|
||||
host:
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
</div>
|
||||
|
||||
<h2>设计方案 / Specs</h2>
|
||||
<a class="doc" href="private-dest-acl-design.html">
|
||||
<div class="t">私有目的地访问控制(节点侧 ACL)<span class="tag html">HTML</span></div>
|
||||
<div class="d">pangolin 出口 IP 曾等价于「只有我」,多用户后退化成「所有 pangolin 用户」——家庭内网服务(brain/nas/git/win.51yanmei.com)因此对全体用户敞开。在节点 sing-box 加一道按 dp_uuid 的闸:节点本地 acl.json(照 warp.json 骨架)→ 每个目的地渲染「放行白名单 + 兜底拒绝」一对规则,与 WARP 规则合并而非覆写。控制面/DB/proto/客户端/管理后台一律不动,改动收敛在 internal/agentd/。关键点:失效方向必须 fail-closed(与 WARP 的 fail-open 先例相反)、白名单须同时含设备级与账户级 dp_uuid(否则 /sub 订阅链接把自己锁在外面)、生效走 agent SIGHUP 以免重启踢掉全部在线用户。含四态渲染矩阵、规则顺序三条硬约束、名单维护 runbook、验收清单。</div>
|
||||
<div class="path">docs/private-dest-acl-design.html</div>
|
||||
</a>
|
||||
<a class="doc" href="cicd-design.html">
|
||||
<div class="t">CI/CD 全流程(tag 触发编译/发版/部署)<span class="tag html">HTML</span></div>
|
||||
<div class="d">#30。参考 jiu 的 scripts/ci + .gitea/workflows:tag 触发(site-v*/server-v*/client-v*)→ 编译 → 测试 → Gitea release → 部署。runner 混合(nas=官网+服务端容器化 / mac=Android+macOS / windows=Windows)。服务端部署固化 F3/F4「备份→migrate→换二进制→重启→健康检查+回滚」;官网部署 pangolin.yanmeiai.com;客户端 apk/dmg/exe 挂 release 喂官网下载链接。密钥作用域:Apple/token 账户级、部署 key/Android keystore 仓库级。范围 A~F(排除 iOS/#26/#25)。</div>
|
||||
@@ -86,6 +91,11 @@
|
||||
</a>
|
||||
|
||||
<h2>实现计划 / Plans</h2>
|
||||
<a class="doc" href="private-dest-acl-plan.html">
|
||||
<div class="t">私有目的地 ACL(节点侧)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-23-private-dest-acl.md</code>(含 checkbox)。设计见 <code>docs/private-dest-acl-design.html</code>。6 个 TDD 任务:①ACL 配置类型 + fail-closed 的 active()(空白名单=拒绝所有人,非「未启用」)→ ②rules() 产出「全部放行→全部拒绝」规则对,两侧目的地条件逐字相同 → ③buildRoute 合并 ACL 与 WARP(四态矩阵;现状 <code>cfg["route"]</code> 是整块覆盖,直接赋值会干掉 WARP)→ ④渲染时读 ACL + 内存/磁盘双层 last-good 兜底(fail-closed 须跨重启成立)→ ⑤SIGHUP 触发热重渲染(重启 agent 会冷启 sing-box 踢掉全部在线用户)→ ⑥配置样例与 runbook。改动收敛在 <code>internal/agentd/</code> + <code>cmd/agent</code>,零 migration/零 proto/零客户端改动。</div>
|
||||
<div class="path">docs/private-dest-acl-plan.html · 真相源 docs/superpowers/plans/2026-07-23-private-dest-acl.md</div>
|
||||
</a>
|
||||
<a class="doc" href="frontend-ds-refactor-plan.html">
|
||||
<div class="t">前端设计系统治理重构(ds-flow 全端)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。<b>非从零 bootstrap(已约 65% 达标)</b>:补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。</div>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>私有目的地访问控制(设计 · 节点侧 ACL)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
pre code{background:none;padding:0;color:inherit}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.root{border-left:3px solid var(--accent)}
|
||||
.card.bad{border-left:3px solid var(--bad)}
|
||||
.card.ok{border-left:3px solid var(--ok)}
|
||||
.card h3{margin-top:0}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;display:block;overflow-x:auto}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px;white-space:nowrap}
|
||||
td code{font-size:.85em}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
.flow{font-family:var(--mono);font-size:12.5px;line-height:1.9;color:#cdd3df;background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:16px;overflow-x:auto}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 文档索引</a>
|
||||
<h1>私有目的地访问控制(设计 · 节点侧 ACL)</h1>
|
||||
<p class="sub">只有指定 dp_uuid 才能经 pangolin 出口访问家庭内网服务 · 2026-07-23</p>
|
||||
|
||||
<div class="lead">
|
||||
<p>pangolin 出口 IP(<code>103.119.13.48</code>)是家庭内网服务在 ali 侧的唯一准入凭据——<code>brain</code>/<code>nas</code>/<code>git</code>/<code>win.51yanmei.com</code> 全靠「来自这个 IP」放行。单用户时它等价于「只有我」;<b>现在 pangolin 有 5 个账号,它退化成了「所有 pangolin 用户」</b>。本设计在节点 sing-box 上补一道按 dp_uuid 的闸,把语义拉回「只有我」。</p>
|
||||
</div>
|
||||
|
||||
<h2>1. 范围</h2>
|
||||
<p><b>只做「私有服务白名单」一件事。</b>不做通用租户黑白名单、不做上网管控、不做管理后台、不要求实时生效。这些在评审中被显式砍掉——它们的复杂度大半来自「多策略 × 多组 × 实时下发」,而本需求三个都不需要。</p>
|
||||
|
||||
<h3>问题的真实边界</h3>
|
||||
<p>经 pangolin 出口能摸到的家里服务,各自本来就有鉴权,<b>唯一裸奔的是 brain</b>:</p>
|
||||
<table>
|
||||
<tr><th>服务</th><th>入口</th><th>自带鉴权</th></tr>
|
||||
<tr><td>git.51yanmei.com</td><td>ali 443 → Gitea</td><td>Gitea 登录 <span class="tag ok">有</span></td></tr>
|
||||
<tr><td>nas.51yanmei.com:5001</td><td>frp → DSM</td><td>账号 + 2FA + 自动封锁 <span class="tag ok">有</span></td></tr>
|
||||
<tr><td>nas/win SSH (10022/10023)</td><td>frp</td><td>密钥登录 <span class="tag ok">有</span></td></tr>
|
||||
<tr><td>win.51yanmei.com:3389</td><td>frp → RDP</td><td>NLA + 密码 <span class="tag ok">有</span></td></tr>
|
||||
<tr><td><b>brain.51yanmei.com</b></td><td>ali 443 → 静态站</td><td><span class="tag bad">无</span></td></tr>
|
||||
</table>
|
||||
<p>所以本设计提供的是<b>网络层前置闸</b>(别人连登录页都摸不到),而不是唯一防线。</p>
|
||||
|
||||
<h2>2. 关键决策</h2>
|
||||
<table>
|
||||
<tr><th>维度</th><th>选择</th><th>理由</th></tr>
|
||||
<tr><td>执行点</td><td><b>节点 sing-box route.rules</b></td><td>控制面不在数据路径上;sing-box 无外部授权钩子(实测 <code>external_controller</code>/<code>auth_request</code>/<code>external_acl</code>/<code>authenticator</code>/<code>webhook</code>/<code>http_provider</code>/<code>external_rule_provider</code> 七个字段名全部 <code>unknown field</code>)。客户端执行会被用户自行修改,且 <code>/sub/{token}</code> 路径天然绕过。</td></tr>
|
||||
<tr><td>名单来源</td><td><b>节点本地 <code>acl.json</code></b></td><td>照 <code>warp.json</code> 现成骨架。控制面/DB/proto/客户端/管理后台<b>一律不动</b>,改动收敛在 <code>internal/agentd/</code> 一个包。</td></tr>
|
||||
<tr><td>主体粒度</td><td><b>dp_uuid 列表</b></td><td>dp_uuid 每设备一个、懒生成、<b>从不轮换</b>(<code>RotateCredential</code> 协议在但控制面无调用方),写死后基本一劳永逸。当前我的账号名下仅 1 台设备有 dp_uuid。</td></tr>
|
||||
<tr><td>目的地表达</td><td><b>域名 + IP:端口 两类</b></td><td>brain/git 与公开的 jiu/travel/sudoku 共用 ali 的 443,只能靠 SNI 区分;DSM/RDP/SSH 用独立端口,直接 IP+端口匹配。</td></tr>
|
||||
<tr><td>sniff</td><td><b>复用现有</b></td><td>WARP 已在节点启用,线上 route 块本来就是 <code>[{"action":"sniff"},{...warp}]</code>——域名匹配零额外成本。</td></tr>
|
||||
<tr><td>失效方向</td><td><b>fail-closed</b></td><td>与 WARP 相反。见 §5,这是本设计唯一容易写错的地方。</td></tr>
|
||||
<tr><td>生效方式</td><td><b>agent SIGHUP 触发重渲染</b></td><td>不要求延时,但重启 agent 会让 sing-box 走冷启动、踢掉全部在线用户(见 §6),故加一个轻量信号处理。</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>3. 现状事实(设计所依赖的)</h2>
|
||||
<ul>
|
||||
<li><b>WARP 已启用</b>:<code>/etc/pangolin-agent/warp.json</code> 存在;线上 <code>/etc/sing-box/config.json</code> 的 route 块实测为 <code>{"final":"direct","rules":[{"action":"sniff"},{"domain_suffix":["reddit.com",…],"outbound":"warp"}]}</code>。</li>
|
||||
<li><b>route 块是整块覆盖</b>:<code>internal/agentd/render.go:50-55</code> —— <code>if warp.active() { cfg["endpoints"]=…; cfg["route"] = warp.warpRoute() }</code>。<span class="tag warn">关键</span> ACL 必须与 WARP <b>合并</b>,直接赋值会把 WARP 分流干掉。</li>
|
||||
<li><b>节点本地文件每次渲染重读</b>:<code>internal/agentd/singbox.go:319-326</code>(<code>LoadWarpConfig</code>),编辑后任一渲染即生效。</li>
|
||||
<li><b>热重载语义</b>:<code>internal/agentd/singbox.go:340-359</code> —— 进程内首次渲染走 <code>Restart()</code>(<code>systemctl restart sing-box</code>),之后走 <code>Reload()</code>(SIGHUP,取不到 PID 时自动回退 Restart)。</li>
|
||||
<li><b>控制面 DB</b>:<code>DB_DRIVER=sqlite</code>,<code>DB_DSN=/var/lib/pangolin/pangolin.db</code>(pangolin1 内存紧,刻意用 SQLite)。</li>
|
||||
<li><b>两层 dp_uuid 并存</b>:设备级 <code>devices.dp_uuid</code>(connect 用)与账户级 <code>users.dp_uuid</code>(<code>/sub/{token}</code> 订阅链接仍在用,<code>internal/httpapi/subscription.go:104</code>)。<span class="tag warn">关键</span> 白名单必须<b>两个都包含</b>,否则自己用订阅链接反而进不去。</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. 设计</h2>
|
||||
|
||||
<h3>4.1 配置文件 <code>/etc/pangolin-agent/acl.json</code></h3>
|
||||
<pre><code>{
|
||||
"enabled": true,
|
||||
"allow_dp_uuids": [
|
||||
"<我的设备级 dp_uuid>",
|
||||
"<我的账户级 dp_uuid(/sub 用)>"
|
||||
],
|
||||
"targets": [
|
||||
{ "domain": ["brain.51yanmei.com", "git.51yanmei.com"] },
|
||||
{ "ip_cidr": ["182.92.213.171/32"], "port": [5001, 3389, 10022, 10023] }
|
||||
]
|
||||
}</code></pre>
|
||||
<p><code>targets</code> 每项是一个「目的地形状」,字段直接对应 sing-box route rule 的匹配字段(同一项内多字段是 AND,字段内多值是 OR)。刻意不做自研 DSL——形状即 sing-box 语义,减少一层翻译和一类 bug。</p>
|
||||
|
||||
<h3>4.2 渲染出的 route 块</h3>
|
||||
<p>每个 target 产出<b>一对</b>规则:先放行白名单、再兜底拒绝。顺序即优先级,首条命中即返回。</p>
|
||||
<pre><code>"route": {
|
||||
"rules": [
|
||||
{"action": "sniff"},
|
||||
{"action": "resolve"},
|
||||
|
||||
{"auth_user": ["<我的uuid…>"], "domain": ["brain.51yanmei.com","git.51yanmei.com"],
|
||||
"outbound": "direct"},
|
||||
{"auth_user": ["<我的uuid…>"], "ip_cidr": ["182.92.213.171/32"],
|
||||
"port": [5001,3389,10022,10023], "outbound": "direct"},
|
||||
|
||||
{"domain": ["brain.51yanmei.com","git.51yanmei.com"], "action": "reject"},
|
||||
{"ip_cidr": ["182.92.213.171/32"], "port": [5001,3389,10022,10023],
|
||||
"action": "reject"},
|
||||
|
||||
{"domain_suffix": ["reddit.com","redd.it",…], "outbound": "warp"}
|
||||
],
|
||||
"final": "direct"
|
||||
}</code></pre>
|
||||
<div class="card bad">
|
||||
<h3>血泪教训:VLESS 只认 <code>auth_user</code>,不认 <code>user</code>(2026-07-23 生产验证)</h3>
|
||||
<p>放行规则匹配凭证必须用 <b><code>auth_user</code></b> 而非 <code>user</code>。<code>sing-box check</code> 对两者<b>都语法通过</b>,但 <code>user</code> 字段对 VLESS/REALITY 入站<b>运行时根本不匹配</b>——放行规则永不命中,结果<b>连白名单用户也被兜底拒绝</b>(全员进不去)。上线时正是踩了这个:节点端 git 一直 000,把本机 uuid 换着法加进白名单都没用。本地起一对真 VLESS 实例实测才定位:<code>auth_user:["good"]</code> 生效(good 通/bad 被 block),<code>user</code> 不生效。<b>只跑 <code>sing-box check</code> 不足以验收访问控制,必须真连接跑一次。</b></p>
|
||||
</div>
|
||||
<p class="small">已用本机 sing-box 1.13.13 <b>真 VLESS 连接</b>验证 <code>auth_user</code> 匹配 + <code>resolve</code> 后 ip_cidr/domain 仍匹配;节点端以 git 做「白名单一进一出」验证 per-user 放行/拒绝生效。</p>
|
||||
|
||||
<div class="card root">
|
||||
<h3>规则顺序的三条硬约束</h3>
|
||||
<ol>
|
||||
<li><code>{"action":"sniff"}</code> <b>必须最先且只出现一次</b>——域名匹配依赖它取 TLS SNI。合并时若 WARP 已产出 sniff,不得重复追加。</li>
|
||||
<li><b>全部 ACL 规则(放行 + 拒绝)排在 WARP 规则之前</b>。当前两者目的地不重叠(reddit vs 我的域名),但被拒绝的目的地永远不该有机会被路由到 warp 出口。</li>
|
||||
<li><b>放行必须排在拒绝之前</b>,且两者目的地条件<b>逐字相同</b>。任何不对称都会造成「我自己也被拒」或「有人漏网」。</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h3>4.3 与 WARP 的合并</h3>
|
||||
<p>把 <code>render.go</code> 里 <code>cfg["route"] = warp.warpRoute()</code> 的直接赋值,改为由一个 <code>buildRoute(acl, warp)</code> 统一产出:</p>
|
||||
<table>
|
||||
<tr><th>ACL</th><th>WARP</th><th>route 块</th></tr>
|
||||
<tr><td>关</td><td>关</td><td><b>不产出</b>(与现有配置逐字节一致,保持向后兼容)</td></tr>
|
||||
<tr><td>关</td><td>开</td><td>现状不变:<code>sniff + warp 规则</code></td></tr>
|
||||
<tr><td>开</td><td>关</td><td><code>sniff + ACL 规则</code>,<code>final:"direct"</code></td></tr>
|
||||
<tr><td>开</td><td>开</td><td><code>sniff + ACL 规则 + warp 规则</code>,<code>final:"direct"</code>;<code>endpoints</code> 照旧由 WARP 注入</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>5. 失效语义 <span class="tag bad">最易写错</span></h2>
|
||||
<div class="card bad">
|
||||
<p><b>WARP 的先例是 fail-open,绝不能照抄。</b><code>singbox.go:321-324</code> 加载失败即 <code>warp = nil</code> 放弃分流;<code>warp.go:47-58</code> 的 <code>active()</code> 任一字段缺失即返回 false,注释写着「宁可不分流(全直连)」。对分流这是对的,<b>对访问控制方向正好反了</b>——同样的写法会让一次手抖的编辑把私有服务对全体 pangolin 用户敞开,而且是静默的。</p>
|
||||
<p>本设计的规则:</p>
|
||||
<ul>
|
||||
<li><b>解析失败 / 文件读不出</b> → <b>保留上一次成功加载的 ACL</b>(agent 内存中持有 last-good),<b>不清空规则</b>,打 ERROR 日志。</li>
|
||||
<li><b>last-good 必须落盘</b>:每次成功加载后把规范化结果写入 <code><StateDir>/acl.last-good.json</code>(照 <code>state.json</code> 的持久化套路)。否则 agent 一重启,内存里的 last-good 就没了,fail-closed 只在进程生命周期内成立——而进程重启恰恰是最可能撞上坏配置的时刻。</li>
|
||||
<li><b>agent 冷启动</b>:先读 <code>acl.json</code>;失败则回退 <code>acl.last-good.json</code>;<b>两者都失败才不产出 ACL 规则</b>,同时打 ERROR 并告警。<span class="tag warn">注意</span> 这最后一档的实际后果是 fail-<b>open</b>(私有服务对全体 pangolin 用户敞开)——之所以只能如此,是因为白名单与<b>目的地清单同在一个文件</b>,文件全丢时连「该拒绝哪些目的地」都无从得知,无法凭空拒起。这也是为什么 last-good 落盘是必需项而非优化项。</li>
|
||||
<li><b><code>allow_dp_uuids</code> 为空数组</b> → <b>仍产出拒绝规则</b>,即「谁都不许进」。空名单的语义是「没有人」,不是「所有人」。</li>
|
||||
<li><b>关闭 ACL 只有一条合法途径</b>:显式写 <code>"enabled": false</code>。删文件不算(走 last-good 分支)。</li>
|
||||
<li><b>agent 长期离线</b> → 节点保留旧配置,被移除的 uuid 在 agent 恢复前仍可进入。这是可接受的(不要求延时),但要在 runbook 里写明:<b>撤销某人的访问后,须确认 agent 已重新渲染</b>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>6. 生效方式</h2>
|
||||
<p>编辑 <code>acl.json</code> 本身<b>不会</b>触发渲染——<code>markDirty()</code> 只在凭证变更时被调用。可选做法与代价:</p>
|
||||
<table>
|
||||
<tr><th>做法</th><th>代价</th></tr>
|
||||
<tr><td><code>systemctl restart pangolin-agent</code></td><td><span class="tag bad">会踢人</span> 新进程内 <code>s.started=false</code>,首次渲染走 <code>Restart()</code> 即 <code>systemctl restart sing-box</code>,<b>全部在线用户瞬断</b></td></tr>
|
||||
<tr><td>等下一次凭证变更顺带生效</td><td>时机不可控,可能几小时不生效</td></tr>
|
||||
<tr><td><b>给 agent 加 SIGHUP 处理 → <code>markDirty()</code></b> <span class="tag ok">采用</span></td><td>约 10 行;走 sing-box SIGHUP 热重载,<b>不断线</b>;<code>systemctl reload pangolin-agent</code> 即可</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>7. 代码改动清单</h2>
|
||||
<table>
|
||||
<tr><th>文件</th><th>改动</th></tr>
|
||||
<tr><td><code>internal/agentd/acl.go</code> <span class="tag info">新增</span></td><td><code>ACLConfig</code> 结构、<code>LoadACLConfig</code>、<code>active()</code>(fail-closed 语义)、<code>aclRules()</code> 产出规则对。镜像 <code>warp.go</code> 骨架。</td></tr>
|
||||
<tr><td><code>internal/agentd/render.go</code></td><td><code>renderSingboxConfig</code> 增 <code>acl</code> 入参;抽出 <code>buildRoute(acl, warp)</code> 取代 <code>cfg["route"] = warp.warpRoute()</code>(§4.3 四态表)</td></tr>
|
||||
<tr><td><code>internal/agentd/singbox.go</code></td><td><code>RenderConfig</code> 每次重读 <code>acl.json</code>(紧邻现有 warp 重读);持有 last-good ACL 字段,成功加载后落盘 <code>acl.last-good.json</code>,冷启动按 <code>acl.json → last-good → 无</code> 顺序回退</td></tr>
|
||||
<tr><td><code>internal/agentd/config.go</code></td><td>新增 <code>ACLConfigPath</code>,默认 <code><StateDir>/acl.json</code></td></tr>
|
||||
<tr><td><code>internal/agentd/agent.go</code> 或 <code>cmd/agent</code></td><td>SIGHUP → <code>markDirty()</code></td></tr>
|
||||
<tr><td><code>internal/agentd/acl_test.go</code> <span class="tag info">新增</span></td><td>见 §9</td></tr>
|
||||
</table>
|
||||
<p><b>不动</b>:控制面、数据库(零 migration)、proto、客户端、管理后台、CI。改动全部收敛在 <code>internal/agentd/</code>。</p>
|
||||
|
||||
<h2>8. 名单维护 Runbook</h2>
|
||||
<p>新增/更换设备后,重新生成白名单(<b>UNION 那一半是账户级 dp_uuid,供 <code>/sub</code> 订阅链接用,别漏</b>)。样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。</p>
|
||||
<pre><code>sqlite3 /var/lib/pangolin/pangolin.db \
|
||||
"SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id
|
||||
WHERE u.email = '<我的邮箱>' AND d.dp_uuid IS NOT NULL
|
||||
UNION
|
||||
SELECT dp_uuid FROM users WHERE email = '<我的邮箱>';"
|
||||
|
||||
# 填进 acl.json 的 allow_dp_uuids 后
|
||||
systemctl reload pangolin-agent # 不断线
|
||||
journalctl -u pangolin-agent -n 20 # 确认已重渲染、无 ACL ERROR</code></pre>
|
||||
<p class="small">设备的 dp_uuid 是<b>首次 connect 时懒生成</b>的:新设备装好后要先连一次,才查得到 dp_uuid。</p>
|
||||
|
||||
<h2>9. 测试与验收</h2>
|
||||
<h3>单元测试(<code>acl_test.go</code>,表驱动,镜像 <code>warp_test.go</code>)</h3>
|
||||
<ul>
|
||||
<li>解析:合法配置 / 缺字段 / 坏 JSON / 文件不存在</li>
|
||||
<li><b>fail-closed</b>:坏 JSON 时保留 last-good(<b>规则不消失</b>);<code>allow_dp_uuids: []</code> 时<b>仍产出拒绝规则</b>;<code>enabled:false</code> 才真正关闭</li>
|
||||
<li><b>last-good 跨重启</b>:成功加载后 <code>acl.last-good.json</code> 已落盘;<b>新建 agent 实例</b>(模拟重启)+ 坏 <code>acl.json</code> → 仍产出规则;两文件皆坏 → 无规则且有 ERROR</li>
|
||||
<li>渲染:四态矩阵(ACL×WARP 开关)逐一断言 route 结构</li>
|
||||
<li>顺序:<code>sniff</code> 唯一且最先;放行先于拒绝;ACL 全部先于 WARP</li>
|
||||
<li>对称性:同一 target 的放行与拒绝规则,目的地条件逐字相同</li>
|
||||
<li>产物合法性:渲染结果喂 <code>sing-box check</code> 通过(<b>注意:check 只验语法,不验 <code>auth_user</code> 运行时是否真匹配 VLESS——见 §4.2 血泪教训</b>)</li>
|
||||
<li><b>运行时匹配(不可省)</b>:起真 VLESS 连接实测 <code>auth_user</code> 放行/拒绝生效;或节点端以某个白名单域名做「本机 uuid 一进一出」验证 per-user 生效。仅 <code>sing-box check</code> 绿=未验收。</li>
|
||||
</ul>
|
||||
<h3>上线验收</h3>
|
||||
<ol>
|
||||
<li>我的设备:brain 首页 200、DSM 5001 可登录、<code>ssh nas-r</code> 通</li>
|
||||
<li>另一个账号的设备:以上全部被拒(连接被 reject,非超时)</li>
|
||||
<li>公开站不受影响:jiu / travel / sudoku / pay 在两个账号下均正常</li>
|
||||
<li>WARP 未被破坏:reddit 仍走 warp 出口</li>
|
||||
<li>把 <code>acl.json</code> 改坏 → 规则仍在、日志有 ERROR(fail-closed 实证)</li>
|
||||
<li><code>systemctl reload pangolin-agent</code> 期间在线用户不掉线</li>
|
||||
</ol>
|
||||
|
||||
<h2>10. 刻意不做(YAGNI)</h2>
|
||||
<ul>
|
||||
<li>用户组 / 租户表、多策略、优先级引擎——只有一个白名单,一张表都不建</li>
|
||||
<li>管理后台页面——编辑一个 JSON 文件即可,加页面要动 9 处</li>
|
||||
<li>控制面下发 / 增量推送——名单近乎不变,节点本地文件足够,还顺带避开了「策略必须进 ConfigSnapshot 否则重连被整表替换抹掉」这个坑</li>
|
||||
<li>上网管控黑名单(B 场景)——本轮砍掉</li>
|
||||
<li>remote <code>rule_set</code>——目的地就几条,内联即可;远端规则集会引入以 <code>update_interval</code> 计的第二条延时链(小时级),且「节点只有不透明 blob」是假收益(节点自己下载的内容当然读得到,还落 cache)</li>
|
||||
</ul>
|
||||
|
||||
<h2>11. 演进路径</h2>
|
||||
<p>若将来真要做多租户策略,<b>渲染层可原样保留</b>,只把名单来源换掉:给 <code>Credential</code>(<code>internal/pb/agentv1/types.go</code> + <code>agentd/singbox.go</code> 的 <code>Cred</code>)加一个 <code>PrivateAccess bool</code>。该字段<b>同时走增量推送与 Register 全量快照两条通道</b>,天然免疫「重连被 <code>ApplyConfig(snap,true)</code> 整表替换」的问题。届时 <code>acl.json</code> 退化为只保留 <code>targets</code>,<code>allow_dp_uuids</code> 由控制面下发。</p>
|
||||
|
||||
<h2>12. 已知残留</h2>
|
||||
<ul>
|
||||
<li><span class="tag warn">泄露</span> 节点配置里我的几台设备 uuid 会出现在同一条规则中(自我关联),且目的地明文可见。目的地本就在公网 DNS 里,泄露面小;但这确实<b>弱化了 <code>render.go:14</code> 的「节点只见不透明 dp_uuid」不变式</b>,属于知情接受,需在该注释处补一行说明。</li>
|
||||
<li><span class="tag warn">缺闸</span> 守红线的 <code>assertNoIdentityFields</code>(<code>agentd/singbox_test.go</code>)只作用于 <code>state.json</code>,<b>从不检查渲染出的 sing-box 配置</b>。本设计不扩大这个缺口,但也没有补上——补闸另开。</li>
|
||||
<li><span class="tag bad">架构限制</span> <b>443 vhost 的判定依赖 SNI,可被绕过。</b>对 <code>brain</code>/<code>git</code>,节点侧唯一的区分手段是 sniff 出的 SNI。攻击者(持有效 dp_uuid 的其他 pangolin 用户)向 <code>182.92.213.171:443</code> 发起<b>不带 SNI</b> 的 TLS、握手后用 <code>Host: brain.51yanmei.com</code> 头访问,则不匹配任何 <code>domain</code> 规则、也不匹配 <code>ip_cidr</code>(443 不在端口清单里)→ 落 <code>final:direct</code> → nginx 按 Host 路由放行。这在 sing-box 层<b>无法闭合</b>——不能整封 <code>:443</code>,否则 jiu/travel/sudoku/pay 一起死。因此下面这条 brain 鉴权不是「可选纵深」,而是本闸对 brain 的<b>前置条件</b>。</li>
|
||||
<li><span class="tag warn">客户端前置</span> <b>私有域名必须走隧道,否则本闸无从谈起。</b>节点 ACL 只对经隧道进入 sing-box 的流量生效;若客户端把 <code>brain</code>/<code>nas</code> 直连(smartRoute 把国内 IP 分流成直连),则流量根本不到节点、直接打 ali,brain 得 403(nginx deny 非白名单源 IP)、nas 超时。故 <code>brain/nas/git/win.51yanmei.com</code> 必须在控制面 <code>PANGOLIN_PRIVATE_SPLIT_DOMAINS</code> 里(已配),且<b>客户端改动后要重连一次</b>才拿到新分流规则。上线验证时 git 走隧道正常、brain/nas 因客户端未重连仍直连——排查时先确认「域名是否真走了隧道」(curl -v 看连的是不是 pangolin 出口),再判 ACL。</li>
|
||||
<li><span class="tag ok">已做(2026-07-23)</span> <b>brain 已加 nginx basic auth</b> 作纵深防御:ali 的 <code>/etc/nginx/conf.d/brain.conf</code> 在原有 <code>allow 103.119.13.48; deny all</code> 之上叠加 <code>auth_basic</code>(<code>satisfy</code> 默认 <code>all</code> → 源 IP 白名单 <b>与</b> 口令二者都需满足),口令存 Bitwarden「brain basic auth」,htpasswd 仅存 apr1 哈希(明文不落 ali)。acme 通道(:80)与 <code>robots.txt</code> 免密。这样即便本节点 ACL 闸失效(agent 挂了、配置手抖)或被上面的 SNI 手法绕过,brain——唯一无自带鉴权的私有服务——仍不裸奔。</li>
|
||||
</ul>
|
||||
|
||||
<p class="small" style="margin-top:40px">相关:<code>~/code/brain/docs/remote-access-brain-domain.html</code>(brain 外网入口与出口 IP 白名单)· baize 台账 <code>~/code/baize/data/{hosts,domains}.yaml</code></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,10 @@ gomobile init
|
||||
echo "==> [2/3] 拉 sing-box $SINGBOX_VERSION 源码"
|
||||
mkdir -p "$WORK"
|
||||
rm -rf "$WORK/sing-box"
|
||||
git clone --depth 1 --branch "$SINGBOX_VERSION" https://github.com/SagerNet/sing-box.git "$WORK/sing-box"
|
||||
# sing-box 源码:默认 github,CI 里国内 runner 连不上 github → 经 SINGBOX_GIT 指向
|
||||
# NAS gitea 镜像(wangjia/sing-box,gitea migrate 自 github)。见 deploy-client.yml。
|
||||
SINGBOX_GIT="${SINGBOX_GIT:-https://github.com/SagerNet/sing-box.git}"
|
||||
git clone --depth 1 --branch "$SINGBOX_VERSION" "$SINGBOX_GIT" "$WORK/sing-box"
|
||||
cd "$WORK/sing-box"
|
||||
|
||||
echo "==> [3/3] 用官方 build_libbox 编核心库"
|
||||
|
||||
+148
-3
@@ -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="."
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,23 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// SIGHUP 重读节点本地配置(acl.json / warp.json)并重渲染。走 sing-box 的 SIGHUP
|
||||
// 热重载路径,在线用户不掉线;重启 agent 则会冷启 sing-box,把所有人踢下线。
|
||||
hup := make(chan os.Signal, 1)
|
||||
signal.Notify(hup, syscall.SIGHUP)
|
||||
defer signal.Stop(hup)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-hup:
|
||||
log.Printf("[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)")
|
||||
agent.SingBox().Refresh()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("[pangolin-agent] starting (control-plane=%s, version=%s)", cfg.ControlPlaneAddr, cfg.AgentVersion)
|
||||
start := time.Now()
|
||||
if err := agent.Run(ctx); err != nil {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package agentd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ACLTarget 描述一组「私有目的地」的匹配条件。字段名与取值直接对应 sing-box
|
||||
// route rule 的同名字段:同一项内多字段是 AND,字段内多值是 OR。刻意不做自研 DSL
|
||||
// —— 形状即 sing-box 语义,少一层翻译就少一类 bug。
|
||||
//
|
||||
// 典型两类:
|
||||
// - 与公开站共用 443 的私有 vhost(brain/git) → 用 domain,依赖 sniff 取 SNI
|
||||
// - 独占端口的服务(DSM 5001 / RDP 3389 / SSH 10022-10023) → 用 ip_cidr + port
|
||||
type ACLTarget struct {
|
||||
Domain []string `json:"domain,omitempty"`
|
||||
DomainSuffix []string `json:"domain_suffix,omitempty"`
|
||||
IPCIDR []string `json:"ip_cidr,omitempty"`
|
||||
Port []int `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
// ACLConfig 是节点本地的私有目的地访问控制表(默认 <StateDir>/acl.json)。
|
||||
// 只有 AllowDpUUIDs 里的凭证能访问 Targets 描述的目的地,其余一律 reject。
|
||||
//
|
||||
// 与 WarpConfig 的关键区别是失效方向:WARP 读不出来就不分流(fail-open)是安全的,
|
||||
// ACL 读不出来就不拦截等于把私有服务对全体用户敞开。故本类型的 active() 语义为
|
||||
// fail-closed —— 空白名单意味着「没有人」,不是「所有人」。
|
||||
type ACLConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowDpUUIDs []string `json:"allow_dp_uuids"`
|
||||
Targets []ACLTarget `json:"targets"`
|
||||
}
|
||||
|
||||
// LoadACLConfig 读取并解析 acl.json。文件不存在 → (nil, nil)(未配置该功能,
|
||||
// 不是错误)。解析失败返回 error,由调用方决定回退到 last-good 还是告警。
|
||||
func LoadACLConfig(path string) (*ACLConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agentd: read acl config %q: %w", path, err)
|
||||
}
|
||||
var ac ACLConfig
|
||||
if err := json.Unmarshal(data, &ac); err != nil {
|
||||
return nil, fmt.Errorf("agentd: parse acl config %q: %w", path, err)
|
||||
}
|
||||
if err := ac.validate(); err != nil {
|
||||
return nil, fmt.Errorf("agentd: acl config %q: %w", path, err)
|
||||
}
|
||||
return &ac, nil
|
||||
}
|
||||
|
||||
// validate 校验每个 target 的 ip_cidr/port 取值本身是否合法(与 cleanTargets 只做
|
||||
// trim/lower、完全不校验取值的定位不同)。一个语法正确但语义非法的值(如
|
||||
// port:70000、ip_cidr:"not-a-cidr")会被 encoding/json 顺利接受,一路渲染进
|
||||
// sing-box 配置——sing-box 自己在 reload/restart 时会拒绝它并保留旧实例,但那时
|
||||
// agent 早已把这份坏配置当"渲染成功"写盘,运维靠 journalctl/看 config.json 内容
|
||||
// 完全看不出线上 gate 其实没生效。故必须在加载阶段就把这类值当加载失败处理,
|
||||
// 使其走 fail-closed 的 last-good 回退路径,而不是让它成为渲染产物。
|
||||
func (ac *ACLConfig) validate() error {
|
||||
for i, t := range ac.Targets {
|
||||
for _, c := range t.IPCIDR {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := netip.ParsePrefix(c); err != nil {
|
||||
return fmt.Errorf("target[%d] invalid ip_cidr %q: %w", i, c, err)
|
||||
}
|
||||
}
|
||||
for _, p := range t.Port {
|
||||
if p < 1 || p > 65535 {
|
||||
return fmt.Errorf("target[%d] invalid port %d (must be 1-65535)", i, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// empty 报告该 target 是否没有任何匹配条件(没有条件的规则会匹配一切,危险)。
|
||||
func (t ACLTarget) empty() bool {
|
||||
return len(t.Domain) == 0 && len(t.DomainSuffix) == 0 &&
|
||||
len(t.IPCIDR) == 0 && len(t.Port) == 0
|
||||
}
|
||||
|
||||
// matchFields 把 target 转成 sing-box route rule 的匹配字段。
|
||||
// 每次调用返回全新 map —— 放行与拒绝两条规则各自在其上追加 user/outbound/action,
|
||||
// 共享同一对象会互相污染。
|
||||
func (t ACLTarget) matchFields() map[string]any {
|
||||
m := make(map[string]any, 4)
|
||||
if len(t.Domain) > 0 {
|
||||
m["domain"] = t.Domain
|
||||
}
|
||||
if len(t.DomainSuffix) > 0 {
|
||||
m["domain_suffix"] = t.DomainSuffix
|
||||
}
|
||||
if len(t.IPCIDR) > 0 {
|
||||
m["ip_cidr"] = t.IPCIDR
|
||||
}
|
||||
if len(t.Port) > 0 {
|
||||
m["port"] = t.Port
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// active 报告本 ACL 是否应真正注入规则。
|
||||
//
|
||||
// 注意与 WarpConfig.active() 的语义差别:此处 AllowDpUUIDs 为空**不影响**返回值。
|
||||
// 空白名单是一个合法且有意义的状态 ——「谁都不许访问这些目的地」。把它当作未启用
|
||||
// 会造成 fail-open。唯一的关闭途径是显式 "enabled": false。
|
||||
func (ac *ACLConfig) active() bool {
|
||||
if ac == nil || !ac.Enabled {
|
||||
return false
|
||||
}
|
||||
return len(ac.cleanTargets()) > 0
|
||||
}
|
||||
|
||||
// cleanUUIDs 去空白/空项后返回白名单。
|
||||
func (ac *ACLConfig) cleanUUIDs() []string {
|
||||
if ac == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(ac.AllowDpUUIDs))
|
||||
for _, u := range ac.AllowDpUUIDs {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cleanTargets 规范化域名(小写去空白)并丢弃无任何条件的 target。
|
||||
func (ac *ACLConfig) cleanTargets() []ACLTarget {
|
||||
if ac == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]ACLTarget, 0, len(ac.Targets))
|
||||
for _, t := range ac.Targets {
|
||||
c := ACLTarget{
|
||||
Domain: cleanHosts(t.Domain),
|
||||
DomainSuffix: cleanHosts(t.DomainSuffix),
|
||||
IPCIDR: cleanStrings(t.IPCIDR),
|
||||
Port: t.Port,
|
||||
}
|
||||
if !c.empty() {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanHosts(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s = strings.TrimSpace(strings.ToLower(s)); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanStrings(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rules 产出 ACL 的 sing-box route 规则:每个 target 一对 —— 先放行白名单、再兜底拒绝。
|
||||
//
|
||||
// 顺序是安全性的一部分,不可重排:
|
||||
// 1. 全部放行规则排在全部拒绝规则之前。不能按 target 交错(放行A/拒绝A/放行B/拒绝B),
|
||||
// 因为 target 之间可能重叠,交错会让 B 的成员被 A 的拒绝规则先命中。
|
||||
// 2. 拒绝规则不带 user 维度 —— 它要对「白名单之外的所有人」生效。
|
||||
// 3. 同一 target 的放行与拒绝,目的地条件由同一个 matchFields() 生成,保证逐字相同。
|
||||
// 任何不对称都会造成「我自己也被拒」或「有人漏网」。
|
||||
//
|
||||
// 白名单为空时只产出拒绝规则(谁都不许进),这是 fail-closed 的核心:空名单的语义是
|
||||
// 「没有人」而非「所有人」。
|
||||
func (ac *ACLConfig) rules() []any {
|
||||
if !ac.active() {
|
||||
return nil
|
||||
}
|
||||
uuids := ac.cleanUUIDs()
|
||||
targets := ac.cleanTargets()
|
||||
|
||||
out := make([]any, 0, len(targets)*2)
|
||||
if len(uuids) > 0 {
|
||||
for _, t := range targets {
|
||||
r := t.matchFields()
|
||||
// auth_user(非 user):sing-box 1.13 的 route rule 里,VLESS/REALITY 入站的
|
||||
// 认证用户要用 auth_user 匹配 inbound user 的 name;user 字段对 VLESS 运行时
|
||||
// 不生效(仅 sing-box check 语法通过),会导致放行规则永不命中、白名单用户也
|
||||
// 被兜底拒绝。已用本地真 VLESS 连接实测确认(good→通, bad→被 block)。
|
||||
r["auth_user"] = uuids
|
||||
r["outbound"] = directOutboundTag
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
for _, t := range targets {
|
||||
r := t.matchFields()
|
||||
r["action"] = "reject"
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// persistACL 把成功加载的 ACL 快照原子写到 path,供 agent 冷启动兜底。
|
||||
func persistACL(path string, ac *ACLConfig) error {
|
||||
data, err := json.MarshalIndent(ac, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentd: marshal acl snapshot: %w", err)
|
||||
}
|
||||
return atomicWrite(path, data, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
package agentd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
|
||||
)
|
||||
|
||||
// captureLog 把标准库 log 包(logf 的底层输出)重定向到一个 buffer,测试结束
|
||||
// (t.Cleanup)后自动还原到原输出。agentd 包内没有自己的 logger 抽象——logf 直接
|
||||
// 调 log.Printf——但标准库 log 本身就是一个可重定向的现成缝隙,不需要为了可测试性
|
||||
// 另外引入 logger 接口。包内测试从不并发跑(无 t.Parallel),重定向全局 log 输出
|
||||
// 是安全的。
|
||||
func captureLog(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
orig := log.Writer()
|
||||
log.SetOutput(&buf)
|
||||
t.Cleanup(func() { log.SetOutput(orig) })
|
||||
return &buf
|
||||
}
|
||||
|
||||
// writeACL 把 acl.json 写到指定路径。
|
||||
func writeACL(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const validACL = `{
|
||||
"enabled": true,
|
||||
"allow_dp_uuids": ["uuid-me-1", "uuid-me-sub"],
|
||||
"targets": [
|
||||
{ "domain": ["brain.51yanmei.com", "git.51yanmei.com"] },
|
||||
{ "ip_cidr": ["182.92.213.171/32"], "port": [5001, 3389, 10022, 10023] }
|
||||
]
|
||||
}`
|
||||
|
||||
func TestLoadACLConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
t.Run("文件不存在返回 nil,nil(未配置,不是错误)", func(t *testing.T) {
|
||||
ac, err := LoadACLConfig(filepath.Join(dir, "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("want nil error, got %v", err)
|
||||
}
|
||||
if ac != nil {
|
||||
t.Fatalf("want nil config, got %+v", ac)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("坏 JSON 返回 error(绝不静默降级)", func(t *testing.T) {
|
||||
p := filepath.Join(dir, "bad.json")
|
||||
writeACL(t, p, `{"enabled": true,`)
|
||||
if _, err := LoadACLConfig(p); err == nil {
|
||||
t.Fatal("want error for malformed JSON, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("合法配置解析出全部字段", func(t *testing.T) {
|
||||
p := filepath.Join(dir, "acl.json")
|
||||
writeACL(t, p, validACL)
|
||||
ac, err := LoadACLConfig(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ac.Enabled {
|
||||
t.Error("Enabled = false, want true")
|
||||
}
|
||||
if len(ac.AllowDpUUIDs) != 2 {
|
||||
t.Errorf("AllowDpUUIDs len = %d, want 2", len(ac.AllowDpUUIDs))
|
||||
}
|
||||
if len(ac.Targets) != 2 {
|
||||
t.Fatalf("Targets len = %d, want 2", len(ac.Targets))
|
||||
}
|
||||
if len(ac.Targets[0].Domain) != 2 {
|
||||
t.Errorf("Targets[0].Domain len = %d, want 2", len(ac.Targets[0].Domain))
|
||||
}
|
||||
if len(ac.Targets[1].Port) != 4 {
|
||||
t.Errorf("Targets[1].Port len = %d, want 4", len(ac.Targets[1].Port))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// active() 的语义与 WARP 相反:空白名单不等于「关闭」,而等于「谁都不许进」。
|
||||
func TestACLActive_FailClosed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ac *ACLConfig
|
||||
want bool
|
||||
}{
|
||||
{"nil 配置 → 未启用", nil, false},
|
||||
{"enabled=false → 未启用(唯一的合法关闭途径)", &ACLConfig{
|
||||
Enabled: false,
|
||||
AllowDpUUIDs: []string{"u"},
|
||||
Targets: []ACLTarget{{Domain: []string{"a.com"}}},
|
||||
}, false},
|
||||
{"无 target → 未启用(无从拒起)", &ACLConfig{
|
||||
Enabled: true,
|
||||
AllowDpUUIDs: []string{"u"},
|
||||
}, false},
|
||||
{"target 全为空条件 → 未启用", &ACLConfig{
|
||||
Enabled: true,
|
||||
Targets: []ACLTarget{{}},
|
||||
}, false},
|
||||
{"白名单为空但有 target → 仍启用(拒绝所有人)", &ACLConfig{
|
||||
Enabled: true,
|
||||
AllowDpUUIDs: nil,
|
||||
Targets: []ACLTarget{{Domain: []string{"a.com"}}},
|
||||
}, true},
|
||||
{"完整配置 → 启用", &ACLConfig{
|
||||
Enabled: true,
|
||||
AllowDpUUIDs: []string{"u"},
|
||||
Targets: []ACLTarget{{IPCIDR: []string{"1.2.3.4/32"}, Port: []int{443}}},
|
||||
}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.ac.active(); got != tc.want {
|
||||
t.Errorf("active() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestACLCleanHelpers(t *testing.T) {
|
||||
ac := &ACLConfig{
|
||||
Enabled: true,
|
||||
AllowDpUUIDs: []string{" uuid-a ", "", "uuid-b"},
|
||||
Targets: []ACLTarget{
|
||||
{Domain: []string{" BRAIN.51yanmei.com ", ""}},
|
||||
{},
|
||||
{IPCIDR: []string{"1.2.3.4/32"}},
|
||||
},
|
||||
}
|
||||
uuids := ac.cleanUUIDs()
|
||||
if len(uuids) != 2 || uuids[0] != "uuid-a" || uuids[1] != "uuid-b" {
|
||||
t.Errorf("cleanUUIDs() = %v, want [uuid-a uuid-b]", uuids)
|
||||
}
|
||||
targets := ac.cleanTargets()
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("cleanTargets() len = %d, want 2 (空 target 应被丢弃)", len(targets))
|
||||
}
|
||||
if targets[0].Domain[0] != "brain.51yanmei.com" {
|
||||
t.Errorf("域名未规范化为小写去空白: %q", targets[0].Domain[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACLTargetMatchFields(t *testing.T) {
|
||||
tgt := ACLTarget{
|
||||
Domain: []string{"a.com"},
|
||||
IPCIDR: []string{"1.2.3.4/32"},
|
||||
Port: []int{443, 5001},
|
||||
}
|
||||
m := tgt.matchFields()
|
||||
if _, ok := m["domain"]; !ok {
|
||||
t.Error("缺 domain 字段")
|
||||
}
|
||||
if _, ok := m["ip_cidr"]; !ok {
|
||||
t.Error("缺 ip_cidr 字段")
|
||||
}
|
||||
if _, ok := m["port"]; !ok {
|
||||
t.Error("缺 port 字段")
|
||||
}
|
||||
if _, ok := m["domain_suffix"]; ok {
|
||||
t.Error("空的 domain_suffix 不应出现在输出里")
|
||||
}
|
||||
// matchFields 必须每次返回新 map,否则放行/拒绝两条规则会共享同一对象,
|
||||
// 给其中一条加 "user"/"action" 会污染另一条。
|
||||
m2 := tgt.matchFields()
|
||||
m2["user"] = []string{"x"}
|
||||
if _, ok := m["user"]; ok {
|
||||
t.Error("matchFields 返回了共享 map,放行与拒绝规则会互相污染")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigACLPaths(t *testing.T) {
|
||||
c := Config{StateDir: "/etc/pangolin-agent"}.withDefaults()
|
||||
if want := "/etc/pangolin-agent/acl.json"; c.ACLConfigPath != want {
|
||||
t.Errorf("ACLConfigPath = %q, want %q", c.ACLConfigPath, want)
|
||||
}
|
||||
if want := "/etc/pangolin-agent/acl.last-good.json"; c.ACLLastGoodPath() != want {
|
||||
t.Errorf("ACLLastGoodPath() = %q, want %q", c.ACLLastGoodPath(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// rules() 必须产出「先全部放行、再全部拒绝」,且同一 target 两侧目的地条件逐字相同。
|
||||
func TestACLRules_AllowThenDeny(t *testing.T) {
|
||||
ac := &ACLConfig{
|
||||
Enabled: true,
|
||||
AllowDpUUIDs: []string{"uuid-me"},
|
||||
Targets: []ACLTarget{
|
||||
{Domain: []string{"brain.51yanmei.com"}},
|
||||
{IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}},
|
||||
},
|
||||
}
|
||||
rules := ac.rules()
|
||||
if len(rules) != 4 {
|
||||
t.Fatalf("规则数 = %d, want 4 (2 target × 放行+拒绝)", len(rules))
|
||||
}
|
||||
|
||||
// 前两条是放行:带 auth_user + outbound,不带 action
|
||||
// (auth_user 而非 user:VLESS 入站运行时只认 auth_user,见 acl.go rules() 注释)
|
||||
for i := 0; i < 2; i++ {
|
||||
r := rules[i].(map[string]any)
|
||||
if _, ok := r["auth_user"]; !ok {
|
||||
t.Errorf("rules[%d] 放行规则缺 auth_user", i)
|
||||
}
|
||||
if _, ok := r["user"]; ok {
|
||||
t.Errorf("rules[%d] 放行规则不应带 user(VLESS 不生效),应用 auth_user", i)
|
||||
}
|
||||
if r["outbound"] != directOutboundTag {
|
||||
t.Errorf("rules[%d] outbound = %v, want %q", i, r["outbound"], directOutboundTag)
|
||||
}
|
||||
if _, ok := r["action"]; ok {
|
||||
t.Errorf("rules[%d] 放行规则不应带 action", i)
|
||||
}
|
||||
}
|
||||
// 后两条是拒绝:带 action=reject,不带 auth_user(对所有人生效)
|
||||
for i := 2; i < 4; i++ {
|
||||
r := rules[i].(map[string]any)
|
||||
if r["action"] != "reject" {
|
||||
t.Errorf("rules[%d] action = %v, want reject", i, r["action"])
|
||||
}
|
||||
if _, ok := r["auth_user"]; ok {
|
||||
t.Errorf("rules[%d] 拒绝规则不应带 auth_user,否则会漏掉名单外的人", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 对称性:target[0] 的放行(rules[0])与拒绝(rules[2])目的地条件必须逐字相同
|
||||
allow0 := rules[0].(map[string]any)
|
||||
deny0 := rules[2].(map[string]any)
|
||||
if fmt.Sprint(allow0["domain"]) != fmt.Sprint(deny0["domain"]) {
|
||||
t.Errorf("target0 放行/拒绝的 domain 不一致: %v vs %v", allow0["domain"], deny0["domain"])
|
||||
}
|
||||
allow1 := rules[1].(map[string]any)
|
||||
deny1 := rules[3].(map[string]any)
|
||||
if fmt.Sprint(allow1["ip_cidr"]) != fmt.Sprint(deny1["ip_cidr"]) ||
|
||||
fmt.Sprint(allow1["port"]) != fmt.Sprint(deny1["port"]) {
|
||||
t.Error("target1 放行/拒绝的 ip_cidr/port 不一致")
|
||||
}
|
||||
}
|
||||
|
||||
// 空白名单 → 不产出放行规则,但拒绝规则照出(fail-closed 的核心断言)。
|
||||
func TestACLRules_EmptyAllowlistStillDenies(t *testing.T) {
|
||||
ac := &ACLConfig{
|
||||
Enabled: true,
|
||||
AllowDpUUIDs: nil,
|
||||
Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}},
|
||||
}
|
||||
rules := ac.rules()
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("规则数 = %d, want 1 (仅拒绝)", len(rules))
|
||||
}
|
||||
r := rules[0].(map[string]any)
|
||||
if r["action"] != "reject" {
|
||||
t.Errorf("action = %v, want reject", r["action"])
|
||||
}
|
||||
}
|
||||
|
||||
// 未 active(含 nil / enabled=false)→ 无规则。
|
||||
func TestACLRules_InactiveYieldsNil(t *testing.T) {
|
||||
var nilACL *ACLConfig
|
||||
if got := nilACL.rules(); got != nil {
|
||||
t.Errorf("nil ACL rules() = %v, want nil", got)
|
||||
}
|
||||
off := &ACLConfig{Enabled: false, Targets: []ACLTarget{{Domain: []string{"a.com"}}}}
|
||||
if got := off.rules(); got != nil {
|
||||
t.Errorf("enabled=false rules() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// buildRoute 四态矩阵:ACL×WARP 开关的四种组合。
|
||||
func TestBuildRoute_Matrix(t *testing.T) {
|
||||
acl := &ACLConfig{
|
||||
Enabled: true,
|
||||
AllowDpUUIDs: []string{"uuid-me"},
|
||||
Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}},
|
||||
}
|
||||
warp := &WarpConfig{
|
||||
Enabled: true, PrivateKey: "k", PeerPublicKey: "pk",
|
||||
Endpoint: "162.159.192.1:2408", Address: []string{"172.16.0.2/32"},
|
||||
Domains: []string{"reddit.com"},
|
||||
}
|
||||
|
||||
t.Run("都关 → 不产出 route(向后兼容)", func(t *testing.T) {
|
||||
if got := buildRoute(nil, nil); got != nil {
|
||||
t.Errorf("buildRoute(nil,nil) = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("仅 WARP → sniff + resolve + warp 规则", func(t *testing.T) {
|
||||
r := buildRoute(nil, warp)
|
||||
rules := r["rules"].([]any)
|
||||
if len(rules) != 3 {
|
||||
t.Fatalf("规则数 = %d, want 3", len(rules))
|
||||
}
|
||||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||||
t.Error("首条不是 sniff")
|
||||
}
|
||||
if rules[1].(map[string]any)["action"] != "resolve" {
|
||||
t.Error("第二条不是 resolve(域名形式的目的地需要先解析才能命中后续 ip_cidr 规则)")
|
||||
}
|
||||
if rules[2].(map[string]any)["outbound"] != warpOutboundTag {
|
||||
t.Error("第三条不是 warp 分流")
|
||||
}
|
||||
if r["final"] != directOutboundTag {
|
||||
t.Errorf("final = %v, want %q", r["final"], directOutboundTag)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("仅 ACL → sniff + resolve + 放行 + 拒绝", func(t *testing.T) {
|
||||
r := buildRoute(acl, nil)
|
||||
rules := r["rules"].([]any)
|
||||
if len(rules) != 4 {
|
||||
t.Fatalf("规则数 = %d, want 4", len(rules))
|
||||
}
|
||||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||||
t.Error("首条不是 sniff")
|
||||
}
|
||||
if rules[1].(map[string]any)["action"] != "resolve" {
|
||||
t.Error("第二条不是 resolve")
|
||||
}
|
||||
if _, ok := rules[2].(map[string]any)["auth_user"]; !ok {
|
||||
t.Error("第三条不是放行规则")
|
||||
}
|
||||
if rules[3].(map[string]any)["action"] != "reject" {
|
||||
t.Error("第四条不是拒绝规则")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("都开 → sniff + resolve + ACL(放行,拒绝) + warp,且 sniff/resolve 各只出现一次", func(t *testing.T) {
|
||||
r := buildRoute(acl, warp)
|
||||
rules := r["rules"].([]any)
|
||||
if len(rules) != 5 {
|
||||
t.Fatalf("规则数 = %d, want 5", len(rules))
|
||||
}
|
||||
sniffs, resolves := 0, 0
|
||||
for _, x := range rules {
|
||||
switch x.(map[string]any)["action"] {
|
||||
case "sniff":
|
||||
sniffs++
|
||||
case "resolve":
|
||||
resolves++
|
||||
}
|
||||
}
|
||||
if sniffs != 1 {
|
||||
t.Errorf("sniff 出现 %d 次, want 1", sniffs)
|
||||
}
|
||||
if resolves != 1 {
|
||||
t.Errorf("resolve 出现 %d 次, want 1", resolves)
|
||||
}
|
||||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||||
t.Error("sniff 必须最先")
|
||||
}
|
||||
if rules[1].(map[string]any)["action"] != "resolve" {
|
||||
t.Error("resolve 必须紧跟 sniff 之后")
|
||||
}
|
||||
// ACL 全部规则必须排在 warp 之前:被拒绝的目的地不该有机会走 warp 出口
|
||||
if rules[4].(map[string]any)["outbound"] != warpOutboundTag {
|
||||
t.Error("warp 规则必须排在最后")
|
||||
}
|
||||
if rules[3].(map[string]any)["action"] != "reject" {
|
||||
t.Error("ACL 拒绝规则必须排在 warp 之前")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// I3:ip_cidr 目的地能被"域名形式"的请求绕过——ip_cidr 匹配的是已解析的目的地
|
||||
// 地址,客户端若发送域名形式(如 nas.51yanmei.com:5001)而不先解析,ip_cidr 规则
|
||||
// 不命中,请求穿透到 final:direct。route 块加 {"action":"resolve"} 让节点自己
|
||||
// 解析目的地,使域名形式收敛到 ip_cidr 规则上。resolve 必须紧跟 sniff 之后、且
|
||||
// 只在产出 route 块时才出现(与 sniff 同条件)。
|
||||
func TestBuildRoute_ResolvePositionedRightAfterSniff(t *testing.T) {
|
||||
acl := &ACLConfig{
|
||||
Enabled: true,
|
||||
AllowDpUUIDs: []string{"uuid-me"},
|
||||
Targets: []ACLTarget{{IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}}},
|
||||
}
|
||||
r := buildRoute(acl, nil)
|
||||
rules := r["rules"].([]any)
|
||||
if len(rules) < 2 {
|
||||
t.Fatalf("规则数 = %d, 至少要有 sniff+resolve", len(rules))
|
||||
}
|
||||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||||
t.Fatal("sniff 必须最先")
|
||||
}
|
||||
if rules[1].(map[string]any)["action"] != "resolve" {
|
||||
t.Fatal("resolve 必须紧跟 sniff 之后")
|
||||
}
|
||||
}
|
||||
|
||||
// 未配置 ACL 也未启用 WARP → 仍不产出 route 块(resolve 不该单独出现)。
|
||||
func TestBuildRoute_NeverConfigured_NoResolve(t *testing.T) {
|
||||
if got := buildRoute(nil, nil); got != nil {
|
||||
t.Errorf("buildRoute(nil,nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。
|
||||
func TestACL_PersistsLastGoodOnLoad(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
|
||||
t.Fatalf("last-good 未落盘: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// acl.json 被改坏 → 规则不能消失(内存 last-good 兜底)。
|
||||
func TestACL_BrokenFileKeepsInMemoryLastGood(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeACL(t, cfg.ACLConfigPath, `{"enabled": true,`) // 手抖写坏
|
||||
data, err := sb.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "reject") {
|
||||
t.Fatal("acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")
|
||||
}
|
||||
}
|
||||
|
||||
// 新 agent 实例(模拟进程重启)+ 坏 acl.json → 磁盘 last-good 兜底,规则仍在。
|
||||
func TestACL_ColdStartFallsBackToDiskLastGood(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb1 := NewSingBox(cfg, nil)
|
||||
sb1.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb1.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeACL(t, cfg.ACLConfigPath, `not json at all`)
|
||||
sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空
|
||||
sb2.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
data, err := sb2.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "reject") {
|
||||
t.Fatal("冷启动未回退到磁盘 last-good,私有服务已敞开")
|
||||
}
|
||||
}
|
||||
|
||||
// 从未配置过(无 acl.json 也无 last-good)→ 不产出 route,且不误报。
|
||||
func TestACL_NeverConfiguredYieldsNoRoute(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
data, err := sb.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := m["route"]; ok {
|
||||
t.Error("未配置 ACL 也未启用 WARP,不应产出 route 块")
|
||||
}
|
||||
}
|
||||
|
||||
// acl.json 被删除 → 规则不能消失(内存 last-good 兜底)。
|
||||
func TestACL_DeletedFileKeepsInMemoryLastGood(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.Remove(cfg.ACLConfigPath); err != nil {
|
||||
t.Fatalf("failed to remove acl.json: %v", err)
|
||||
}
|
||||
data, err := sb.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "reject") {
|
||||
t.Fatal("acl.json 被删除后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh() 必须能触发一次重渲染(经 debounce 循环),用于「编辑 acl.json 后
|
||||
// systemctl reload pangolin-agent」而不必重启 agent(重启会冷启 sing-box 踢人)。
|
||||
func TestSingBoxRefresh_TriggersRender(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
fr := &fakeRestarter{}
|
||||
sb := NewSingBox(cfg, fr)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go sb.Run(ctx)
|
||||
|
||||
// 等首次渲染落地(ApplyConfig 已 markDirty)
|
||||
eventually(t, 2*time.Second, func() bool {
|
||||
_, err := os.Stat(cfg.SingboxConfigPath)
|
||||
return err == nil
|
||||
}, "首次渲染写出配置")
|
||||
|
||||
// 断言 reloadCount 而非 count:首次渲染已冷启动过(started=true),此后的重渲染
|
||||
// 一律走 Reload(SIGHUP 热重载),Restart 计数不会再增加。断言错计数器会假失败。
|
||||
before := fr.reloadCount()
|
||||
sb.Refresh()
|
||||
eventually(t, 2*time.Second, func() bool { return fr.reloadCount() > before }, "Refresh 触发了热重载")
|
||||
}
|
||||
|
||||
// 新 agent 实例(模拟进程重启) + 被删的 acl.json → 磁盘 last-good 兜底,规则仍在。
|
||||
func TestACL_ColdStartAfterDeletedFileFallsBackToDisk(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb1 := NewSingBox(cfg, nil)
|
||||
sb1.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb1.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.Remove(cfg.ACLConfigPath); err != nil {
|
||||
t.Fatalf("failed to remove acl.json: %v", err)
|
||||
}
|
||||
sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空
|
||||
sb2.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
data, err := sb2.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "reject") {
|
||||
t.Fatal("冷启动(acl.json 删除)未回退到磁盘 last-good,私有服务已敞开")
|
||||
}
|
||||
}
|
||||
|
||||
// 样例文件必须始终可被解析且产出预期规则,防止改了 ACLTarget 形状却忘了同步样例。
|
||||
func TestACLExampleFileStaysValid(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "..", "deploy", "single-node", "acl.json.example")
|
||||
ac, err := LoadACLConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("样例文件解析失败 %s: %v", path, err)
|
||||
}
|
||||
if ac == nil {
|
||||
t.Fatalf("样例文件不存在: %s", path)
|
||||
}
|
||||
if !ac.active() {
|
||||
t.Error("样例文件应当是一份 active 的 ACL")
|
||||
}
|
||||
if len(ac.Targets) != 2 {
|
||||
t.Errorf("样例 targets = %d, want 2", len(ac.Targets))
|
||||
}
|
||||
// 样例里的 uuid 是占位符,不该是真实 uuid(真实 uuid 属标识信息,不入 git)
|
||||
for _, u := range ac.AllowDpUUIDs {
|
||||
if !strings.HasPrefix(u, "TODO-") {
|
||||
t.Errorf("样例文件混入了非占位 uuid %q —— 真实 dp_uuid 不得入 git", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── C2: 手抖字段名(如 targets → targetz)不得静默关闸,也不得覆盖 last-good ──────
|
||||
|
||||
// 字段名手抖(targetz)→ encoding/json 忽略未知字段,Targets 变 nil,active()=false。
|
||||
// 若不做特殊处理,旧代码会把这份"看似合法"的空配置当成新的 last-good 落盘,
|
||||
// 把恢复用的快照也一起冲掉。正确行为:当作加载失败处理,回退 last-good,不覆盖磁盘。
|
||||
func TestACL_TypoFieldWithEnabledTrue_FallsBackWithoutOverwritingLastGood(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadFile(cfg.ACLLastGoodPath())
|
||||
if err != nil {
|
||||
t.Fatalf("last-good 未在健康加载后落盘: %v", err)
|
||||
}
|
||||
|
||||
// "targets" 手抖成 "targetz":JSON 仍能解析,但 Targets 字段收不到值。
|
||||
writeACL(t, cfg.ACLConfigPath, `{"enabled":true,"allow_dp_uuids":["x"],"targetz":[{"domain":["a.com"]}]}`)
|
||||
data, err := sb.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "reject") {
|
||||
t.Fatal("字段名手抖(targetz)后拒绝规则消失了 —— 这是静默关闸,私有服务已敞开")
|
||||
}
|
||||
after, err := os.ReadFile(cfg.ACLLastGoodPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("手抖配置覆盖了 acl.last-good.json —— 恢复用的快照被销毁了")
|
||||
}
|
||||
}
|
||||
|
||||
// 显式 enabled:false 是唯一合法的关闭方式,渲染结果必须真的没有 reject 规则;
|
||||
// 但它不该覆盖磁盘上已有的 last-good 快照——否则日后 acl.json 意外损坏/丢失时,
|
||||
// 回退到的将是这份"已关闭"的快照而不是最近一次真正 active 的配置。
|
||||
func TestACL_ExplicitDisableDoesNotOverwriteLastGood(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadFile(cfg.ACLLastGoodPath())
|
||||
if err != nil {
|
||||
t.Fatalf("last-good 未在健康加载后落盘: %v", err)
|
||||
}
|
||||
|
||||
writeACL(t, cfg.ACLConfigPath, `{"enabled": false, "allow_dp_uuids": ["x"], "targets": [{"domain":["a.com"]}]}`)
|
||||
data, err := sb.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "reject") {
|
||||
t.Fatal("enabled=false 应真正关闭 ACL,不应再产出 reject 规则")
|
||||
}
|
||||
after, err := os.ReadFile(cfg.ACLLastGoodPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("显式 enabled=false 不该覆盖 last-good 快照,否则日后 acl.json 损坏就回不到真正 active 的配置了")
|
||||
}
|
||||
}
|
||||
|
||||
// 白名单为空但 target 有效 → 是合法的"拒绝所有人"配置,不是手抖,必须仍被当作
|
||||
// last-good 持久化,且仍产出拒绝规则。防止 C2 修复对"零 target"的特判过度矫正到
|
||||
// "零白名单"上。
|
||||
func TestACL_EnabledWithEmptyAllowlistStillPersistsAndDenies(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, `{"enabled": true, "allow_dp_uuids": [], "targets": [{"domain": ["brain.51yanmei.com"]}]}`)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
data, err := sb.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "reject") {
|
||||
t.Fatal("空白名单+有效 target 应仍产出拒绝规则(拒所有人),不该被当成手抖")
|
||||
}
|
||||
if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
|
||||
t.Fatalf("空白名单(但有效 target)的配置应被当作合法 active 配置持久化为 last-good: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── I2: last-good 双失败必须大声告警,读盘错误不能被吞掉 ─────────────────────
|
||||
|
||||
// acl.json 缺失 + 磁盘 last-good 损坏(存在但解析不了,模拟卷挂载异常/写坏)→
|
||||
// 两级兜底都失败,gate 实质消失,必须在 loadACL 的终点打 ALERT——这正是设计 §5
|
||||
// "两者都失败才不产出 ACL 规则,同时打 ERROR 并告警" 要求的那一档。
|
||||
//
|
||||
// 注:没有采用"把两个文件都整个删掉"来复现,因为那种状态在文件系统层面与
|
||||
// "这台节点从没配置过 ACL"完全无法区分(两次 os.Stat 都是 not-exist),任何仅凭
|
||||
// 现有文件状态判断的实现都做不出区分;若真要区分,需要额外的持久化标记,超出本
|
||||
// finding 的范围。"last-good 文件存在但损坏"则是一个可观察、可被 os.Stat 命中
|
||||
// 的信号,且更贴近 §5 描述的真实故障("last-good 损坏"),故以此复现。
|
||||
func TestACL_BothLastGoodFailuresAlert(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb1 := NewSingBox(cfg, nil)
|
||||
sb1.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb1.RenderConfig(); err != nil {
|
||||
t.Fatal(err) // acl.last-good.json 现已落盘
|
||||
}
|
||||
|
||||
if err := os.Remove(cfg.ACLConfigPath); err != nil {
|
||||
t.Fatalf("failed to remove acl.json: %v", err)
|
||||
}
|
||||
writeACL(t, cfg.ACLLastGoodPath(), `{"enabled": true,`) // 磁盘 last-good 也损坏
|
||||
|
||||
buf := captureLog(t)
|
||||
sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空(模拟进程重启)
|
||||
sb2.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
data, err := sb2.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "reject") {
|
||||
t.Fatal("两级 last-good 都失败,不应该还能产出规则(此断言只是确认前提)")
|
||||
}
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "ALERT") {
|
||||
t.Fatalf("两级 last-good 都失败(acl.json 缺失 + 磁盘 last-good 损坏)必须打 ALERT,实际日志:\n%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, "ERROR reading last-good") {
|
||||
t.Fatalf("读磁盘 last-good 失败的 derr 必须落日志,实际日志:\n%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// 从未配置过 ACL(acl.json 和 last-good 都从来没存在过)→ 终点应保持安静,不误报
|
||||
// ALERT。这条守着"没用这个功能的节点不该被日志刷屏"。
|
||||
func TestACL_NeverConfiguredNoAlert(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
buf := captureLog(t)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if logged := buf.String(); strings.Contains(logged, "ALERT") {
|
||||
t.Fatalf("从未配置过 ACL 的节点不该收到 ALERT(会造成告警刷屏),实际日志:\n%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// 降级路径(内存/磁盘 last-good 兜底成功)的日志必须带 WARN 级别标签,便于
|
||||
// journalctl | grep -E 'ERROR|WARN|ALERT' 抓到"已恢复但曾经降级"的状态。
|
||||
func TestACL_FallbackLogsCarryWarnLevel(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeACL(t, cfg.ACLConfigPath, `{"enabled": true,`) // 手抖写坏,触发内存 last-good 兜底
|
||||
buf := captureLog(t)
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if logged := buf.String(); !strings.Contains(logged, "WARN falling back to in-memory") {
|
||||
t.Fatalf("内存 last-good 兜底日志缺 WARN 级别标签,实际日志:\n%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── I1: 非法 ip_cidr/port 必须在加载阶段被拒绝,走 fail-closed last-good ──────
|
||||
|
||||
// port 超出 1-65535、ip_cidr 不是合法 CIDR,cleanTargets 此前只做 trim/lower,
|
||||
// 完全不校验取值——这类配置会被当作"合法"渲染进 sing-box 配置,而 sing-box 自己
|
||||
// 会在运行时(reload/restart)拒绝它、保留旧实例,导致"渲染产物落盘看起来正常,
|
||||
// 但线上 gate 其实没生效"这种难排查的静默失败。加载阶段直接拒绝,让它走
|
||||
// last-good 兜底(与解析失败同一条路径),而不是把坏值一路渲染出去。
|
||||
func TestLoadACLConfig_RejectsInvalidPort(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "acl.json")
|
||||
writeACL(t, p, `{
|
||||
"enabled": true,
|
||||
"allow_dp_uuids": ["u"],
|
||||
"targets": [{ "ip_cidr": ["182.92.213.171/32"], "port": [70000] }]
|
||||
}`)
|
||||
if _, err := LoadACLConfig(p); err == nil {
|
||||
t.Fatal("port 70000 超出合法范围(1-65535),LoadACLConfig 应返回 error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadACLConfig_RejectsInvalidCIDR(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "acl.json")
|
||||
writeACL(t, p, `{
|
||||
"enabled": true,
|
||||
"allow_dp_uuids": ["u"],
|
||||
"targets": [{ "ip_cidr": ["not-a-cidr"], "port": [443] }]
|
||||
}`)
|
||||
if _, err := LoadACLConfig(p); err == nil {
|
||||
t.Fatal("ip_cidr \"not-a-cidr\" 不是合法 CIDR,LoadACLConfig 应返回 error")
|
||||
}
|
||||
}
|
||||
|
||||
// 非法配置在 loadACL 层面必须走 last-good 兜底(与解析失败同一条路径),而不是
|
||||
// 被渲染进 sing-box 配置。
|
||||
func TestACL_InvalidPortFallsBackToLastGood(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
writeACL(t, cfg.ACLConfigPath, validACL)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeACL(t, cfg.ACLConfigPath, `{
|
||||
"enabled": true,
|
||||
"allow_dp_uuids": ["u"],
|
||||
"targets": [{ "ip_cidr": ["182.92.213.171/32"], "port": [70000] }]
|
||||
}`)
|
||||
data, err := sb.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "70000") {
|
||||
t.Fatal("非法 port 70000 不应该被渲染进 sing-box 配置,应走 last-good 兜底")
|
||||
}
|
||||
if !strings.Contains(string(data), "reject") {
|
||||
t.Fatal("非法配置应走 last-good 兜底,拒绝规则不应消失")
|
||||
}
|
||||
}
|
||||
|
||||
// 产物合法性:渲染结果喂给真实 sing-box 二进制的 `check` 子命令,必须 exit 0。
|
||||
// 这把设计 §9 "产物合法性" 验收项变成一条常驻测试,而不是只在上线前手工跑一次。
|
||||
//
|
||||
// 刻意不经 SingBox/ApplyConfig 走完整 reality/hy2 inbound(sampleSnapshot 里的
|
||||
// PrivateKey/CertPath 都是占位假值,sing-box 会因证书/密钥不合法而拒绝——那是
|
||||
// 另一类问题,不是本测试要盯的 ACL route 合法性)。直接调用 renderSingboxConfig,
|
||||
// reality/hy2 传 nil,只产出 outbounds + ACL route,聚焦 §9 要验的东西。
|
||||
func TestRenderedConfig_PassesSingBoxCheck(t *testing.T) {
|
||||
singboxBin, err := exec.LookPath("sing-box")
|
||||
if err != nil {
|
||||
t.Skip("sing-box not installed, skipping resident config-validity check")
|
||||
}
|
||||
|
||||
ac, err := LoadACLConfig(writeTempACL(t, validACL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := renderSingboxConfig(nil, nil, nil, "test-derive-key", nil, ac)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// homebrew 装的本机 sing-box 二进制没有编译 with_v2ray_api tag(生产节点上的
|
||||
// 那份是),`v2ray_api` 这段实验性配置在本机会导致 check 因为"功能未编译进来"
|
||||
// 而 FATAL——这与本测试要验的 ACL/route 语法合法性无关,故只为本次校验剥离它。
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exp, ok := m["experimental"].(map[string]any); ok {
|
||||
delete(exp, "v2ray_api")
|
||||
}
|
||||
data, err = json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(cfgPath, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(singboxBin, "check", "-c", cfgPath).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("sing-box check 未通过(exit != 0): %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTempACL 把 body 写到临时目录下的 acl.json 并返回路径。
|
||||
func writeTempACL(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "acl.json")
|
||||
writeACL(t, p, body)
|
||||
return p
|
||||
}
|
||||
@@ -5,9 +5,15 @@
|
||||
// command feed by managing the local sing-box user table.
|
||||
//
|
||||
// No-state invariant (doc/04 §2, doc/06 §3): the agent persists ONLY the
|
||||
// credential table (dp_uuid + expires_at) to disk. It keeps zero user identities,
|
||||
// zero destination/DNS data and writes no access logs. A seized node leaks only
|
||||
// opaque dp_uuids, never accounts.
|
||||
// credential table (dp_uuid + expires_at) to disk. It keeps zero user identities
|
||||
// and writes no access logs. A seized node leaks only opaque dp_uuids, never
|
||||
// accounts.
|
||||
//
|
||||
// 例外(私有目的地 ACL):节点本地 acl.json 含一份 dp_uuid 白名单与目的地清单,
|
||||
// 由运营手工维护、不经控制面。它确实让节点知道「这几个 dp_uuid 享有私有访问权」
|
||||
// 以及那几个私有域名/端口 —— 这是知情接受的不变式弱化,范围仅限该文件与渲染出的
|
||||
// route 规则,不涉及账户身份,也不产生任何访问日志。设计见
|
||||
// docs/private-dest-acl-design.html §12。
|
||||
package agentd
|
||||
|
||||
import (
|
||||
@@ -58,6 +64,10 @@ type Config struct {
|
||||
// 文件不存在 = WARP 未启用。渲染时读取,支持编辑后重启 agent 生效(#29)。
|
||||
WarpConfigPath string
|
||||
|
||||
// ACLConfigPath 指向节点本地的私有目的地访问控制表(默认 <StateDir>/acl.json)。
|
||||
// 文件不存在 = 该功能未配置。渲染时读取,SIGHUP agent 即可生效。
|
||||
ACLConfigPath string
|
||||
|
||||
// DeriveKey keys the Hy2 password derivation (see DeriveHy2Password).
|
||||
DeriveKey string
|
||||
|
||||
@@ -86,6 +96,9 @@ func (c Config) withDefaults() Config {
|
||||
if c.WarpConfigPath == "" {
|
||||
c.WarpConfigPath = filepath.Join(c.StateDir, "warp.json")
|
||||
}
|
||||
if c.ACLConfigPath == "" {
|
||||
c.ACLConfigPath = filepath.Join(c.StateDir, "acl.json")
|
||||
}
|
||||
if c.HeartbeatInterval == 0 {
|
||||
c.HeartbeatInterval = DefaultHeartbeatInterval
|
||||
}
|
||||
@@ -118,3 +131,7 @@ func (c Config) KeyPath() string { return filepath.Join(c.StateDir, "node.key"
|
||||
func (c Config) CertPath() string { return filepath.Join(c.StateDir, "node.crt") }
|
||||
func (c Config) CAPath() string { return filepath.Join(c.StateDir, "ca.crt") }
|
||||
func (c Config) StatePath() string { return filepath.Join(c.StateDir, "state.json") }
|
||||
|
||||
// ACLLastGoodPath 是最近一次成功加载的 ACL 快照,供 agent 冷启动时在 acl.json
|
||||
// 损坏的情况下兜底(fail-closed 跨重启成立的前提)。
|
||||
func (c Config) ACLLastGoodPath() string { return filepath.Join(c.StateDir, "acl.last-good.json") }
|
||||
|
||||
@@ -13,6 +13,11 @@ import (
|
||||
//
|
||||
// Only the opaque dp_uuid is ever written; no account identity touches the node.
|
||||
//
|
||||
// 例外(私有目的地 ACL):若节点配置了 acl.json,渲染出的 route 规则会含一份享有私有
|
||||
// 访问权的 dp_uuid 白名单与对应的私有域名/端口。它仍不含任何账户身份(email/user_id),
|
||||
// 但确实让节点知道「这几个 dp_uuid 属于同一组权限」—— 知情接受的不变式弱化,
|
||||
// 设计与权衡见 docs/private-dest-acl-design.html §12。
|
||||
//
|
||||
// 用量统计走 v2ray_api StatsService(loopback gRPC):节点 sing-box 编入
|
||||
// with_v2ray_api,stats.users 列出全部 dp_uuid → 每个用户独立的
|
||||
// user>>>{dp_uuid}>>>traffic>>>uplink|downlink 计数器,agent 按用户精确读取
|
||||
@@ -27,7 +32,7 @@ const (
|
||||
warpOutboundTag = "warp"
|
||||
)
|
||||
|
||||
func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig) ([]byte, error) {
|
||||
func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error) {
|
||||
cfg := map[string]any{
|
||||
"log": map[string]any{"level": "warn", "timestamp": true},
|
||||
"inbounds": buildInbounds(creds, reality, hy2, deriveKey),
|
||||
@@ -47,16 +52,55 @@ func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *age
|
||||
},
|
||||
}
|
||||
|
||||
// WARP 分流(#29):命中配置域名的流量走 Cloudflare WARP 干净出口,其余直连。
|
||||
// warp 为 nil 或未 active 时完全不加 endpoints/route → 与旧配置逐字节一致(向后兼容)。
|
||||
// WARP 分流(#29)只贡献 endpoints;route 块由 buildRoute 统一产出,因为它现在要
|
||||
// 同时容纳 ACL 规则 —— 原先 cfg["route"] = warp.warpRoute() 是整块覆盖,直接赋值
|
||||
// 会把对方的规则干掉。
|
||||
if warp.active() {
|
||||
cfg["endpoints"] = []any{warp.warpEndpoint()}
|
||||
cfg["route"] = warp.warpRoute()
|
||||
}
|
||||
if route := buildRoute(acl, warp); route != nil {
|
||||
cfg["route"] = route
|
||||
}
|
||||
|
||||
return json.MarshalIndent(cfg, "", " ")
|
||||
}
|
||||
|
||||
// buildRoute 合并私有目的地 ACL 与 WARP 分流,产出单一 route 块。
|
||||
// 两者都未激活时返回 nil —— 不产出 route 字段,与旧配置逐字节一致(向后兼容)。
|
||||
//
|
||||
// 规则顺序是安全语义的一部分:
|
||||
// 1. {"action":"sniff"} 唯一且最先。域名匹配依赖它取 TLS SNI(客户端多半发的是
|
||||
// 已解析 IP),WARP 与 ACL 都需要,故在此统一产出一次,不由各自重复追加。
|
||||
// 2. {"action":"resolve"} 紧跟 sniff 之后,同样唯一且只在产出 route 块时才出现。
|
||||
// ACL 的 ip_cidr 目的地(DSM/RDP/SSH 等独占端口服务)匹配的是已解析的连接
|
||||
// 目的地地址——若客户端发的是域名形式(如 nas.51yanmei.com:5001)而节点不主动
|
||||
// 解析,ip_cidr 规则不命中,请求会绕过 ACL 直接落到 final:direct。resolve 让
|
||||
// 节点自己解析目的地,使域名形式收敛到 ip_cidr 规则上,堵住这个绕过口子
|
||||
// (I3;这也是唯一一处从"节点不知道目的地"这条不变式主动后退的地方,是知情
|
||||
// 接受的取舍,见 docs/private-dest-acl-design.html §12)。
|
||||
// 3. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远
|
||||
// 不该还有机会被路由到 warp 出口。
|
||||
// 4. final 恒为 direct。
|
||||
func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any {
|
||||
aclRules := acl.rules()
|
||||
warpActive := warp.active()
|
||||
if len(aclRules) == 0 && !warpActive {
|
||||
return nil
|
||||
}
|
||||
|
||||
rules := make([]any, 0, len(aclRules)+3)
|
||||
rules = append(rules, map[string]any{"action": "sniff"})
|
||||
rules = append(rules, map[string]any{"action": "resolve"})
|
||||
rules = append(rules, aclRules...)
|
||||
if warpActive {
|
||||
rules = append(rules, map[string]any{
|
||||
"domain_suffix": warp.cleanDomains(),
|
||||
"outbound": warpOutboundTag,
|
||||
})
|
||||
}
|
||||
return map[string]any{"rules": rules, "final": directOutboundTag}
|
||||
}
|
||||
|
||||
// statsUsers 收集所有去重 dp_uuid,供 v2ray_api stats.users 按用户开启流量计数器。
|
||||
func statsUsers(creds []Cred) []string {
|
||||
seen := make(map[string]struct{}, len(creds))
|
||||
|
||||
@@ -61,6 +61,10 @@ type SingBox struct {
|
||||
hy2 *agentv1.Hy2Inbound
|
||||
configVersion int64
|
||||
|
||||
// lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
|
||||
// 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
|
||||
lastGoodACL *ACLConfig
|
||||
|
||||
// started 标记 sing-box 是否已被本 agent 冷启动过:首次走 Restart(冷启动),
|
||||
// 之后配置变更走 Reload(SIGHUP 热重载)。agent 进程重启后复位为 false,
|
||||
// 下次渲染做一次冷启动以确保与渲染配置一致。
|
||||
@@ -323,7 +327,80 @@ func (s *SingBox) RenderConfig() ([]byte, error) {
|
||||
logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err)
|
||||
warp = nil
|
||||
}
|
||||
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp)
|
||||
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, s.loadACL())
|
||||
}
|
||||
|
||||
// loadACL 读取私有目的地 ACL,并维护 last-good 兜底。
|
||||
//
|
||||
// 语义刻意与 WARP 相反:WARP 读失败静默降级为「不分流」是安全的,ACL 读失败若也
|
||||
// 降级为「不启用」,等于把私有服务对全体 pangolin 用户敞开,而且是静默的。故:
|
||||
// - 成功 → 更新内存 last-good 并落盘,供本进程后续与下次冷启动使用
|
||||
// - 失败/文件消失 → 回退内存 last-good,再回退磁盘 last-good,规则不消失
|
||||
// - 两级 last-good 都没有 → 只能不产出规则(白名单与目的地清单同在一个文件,
|
||||
// 文件全丢时连「该拒绝哪些目的地」都无从得知),此时必须大声告警
|
||||
func (s *SingBox) loadACL() *ACLConfig {
|
||||
acl, err := LoadACLConfig(s.cfg.ACLConfigPath)
|
||||
switch {
|
||||
case err == nil && acl != nil:
|
||||
if acl.Enabled && len(acl.cleanTargets()) == 0 {
|
||||
// enabled 却零 target = 几乎必然是字段名手抖(如 targets 打成 targetz)。
|
||||
// encoding/json 对未知字段静默无视,这份配置"看似合法"但毫无内容——当作
|
||||
// 加载失败,走下面的 last-good 兜底,绝不静默敞开、更不能拿它覆盖兜底快照。
|
||||
logf("[acl] ERROR %s parsed but enabled=true yields zero targets — treating as broken, falling back", s.cfg.ACLConfigPath)
|
||||
break // 落到下方 last-good 兜底(Go 的 switch 内 break 只退出 switch)
|
||||
}
|
||||
if acl.active() {
|
||||
// 只有 active 的配置才配当 last-good:否则一次手抖既关闸又冲掉兜底。
|
||||
s.mu.Lock()
|
||||
s.lastGoodACL = acl
|
||||
s.mu.Unlock()
|
||||
if perr := persistACL(s.cfg.ACLLastGoodPath(), acl); perr != nil {
|
||||
logf("[acl] persist last-good to %s failed: %v", s.cfg.ACLLastGoodPath(), perr)
|
||||
}
|
||||
} else {
|
||||
// active() 为 false 但走到了这里,只可能是显式 enabled:false —— 合法关闭。
|
||||
// 直接返回这份(空规则的)配置,不 persist,保留此前的恢复能力。
|
||||
logf("[acl] gate explicitly disabled (enabled=false)")
|
||||
}
|
||||
return acl
|
||||
case err != nil:
|
||||
logf("[acl] ERROR load %s failed: %v", s.cfg.ACLConfigPath, err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
lg := s.lastGoodACL
|
||||
s.mu.Unlock()
|
||||
if lg != nil {
|
||||
logf("[acl] WARN falling back to in-memory last-good ACL")
|
||||
return lg
|
||||
}
|
||||
|
||||
disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())
|
||||
if derr != nil {
|
||||
logf("[acl] ERROR reading last-good %s: %v", s.cfg.ACLLastGoodPath(), derr)
|
||||
}
|
||||
if derr == nil && disk != nil {
|
||||
logf("[acl] WARN falling back to on-disk last-good %s", s.cfg.ACLLastGoodPath())
|
||||
s.mu.Lock()
|
||||
s.lastGoodACL = disk
|
||||
s.mu.Unlock()
|
||||
return disk
|
||||
}
|
||||
|
||||
// 两级 last-good 都没有可用配置。区分两种终态:
|
||||
// - 这台节点从未配置过 ACL(acl.json 与 last-good 均从未存在过)→ 安静返回,
|
||||
// 不刷屏告警。
|
||||
// - 除此之外的任何情况(acl.json 存在但损坏/last-good 存在但损坏等)→ 私有
|
||||
// 服务的访问闸实质已消失,必须大声告警(§5 "两者都失败才不产出 ACL 规则,
|
||||
// 同时打 ERROR 并告警")。
|
||||
_, aclStatErr := os.Stat(s.cfg.ACLConfigPath)
|
||||
_, lgStatErr := os.Stat(s.cfg.ACLLastGoodPath())
|
||||
neverConfigured := os.IsNotExist(aclStatErr) && os.IsNotExist(lgStatErr)
|
||||
if !neverConfigured {
|
||||
logf("[acl] ALERT acl.json is broken and no last-good snapshot exists — "+
|
||||
"private destinations are UNPROTECTED (path=%s)", s.cfg.ACLConfigPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeAndRestart renders, writes the config file and restarts sing-box.
|
||||
@@ -395,6 +472,11 @@ func (s *SingBox) markDirty() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件
|
||||
// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走
|
||||
// 冷启动(Restart),把全部在线用户踢下线。
|
||||
func (s *SingBox) Refresh() { s.markDirty() }
|
||||
|
||||
// Run drives the debounce loop: it coalesces bursts of changes within
|
||||
// DebounceWindow into a single write+restart. It blocks until ctx is cancelled.
|
||||
func (s *SingBox) Run(ctx context.Context) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WarpConfig 描述节点上「部分域名走 Cloudflare WARP 干净出口」的分流配置(#29)。
|
||||
@@ -66,15 +65,9 @@ func (wc *WarpConfig) mtu() int {
|
||||
}
|
||||
|
||||
// cleanDomains 去空白/空项后返回域名清单(用于 domain_suffix)。
|
||||
// 与 ACL 的目的地域名规范化共用 cleanHosts,避免两处逻辑漂移。
|
||||
func (wc *WarpConfig) cleanDomains() []string {
|
||||
out := make([]string, 0, len(wc.Domains))
|
||||
for _, d := range wc.Domains {
|
||||
d = strings.TrimSpace(strings.ToLower(d))
|
||||
if d != "" {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return cleanHosts(wc.Domains)
|
||||
}
|
||||
|
||||
// endpointHostPort 拆 Endpoint 为 host + port(active() 已校验可拆)。
|
||||
@@ -107,15 +100,3 @@ func (wc *WarpConfig) warpEndpoint() map[string]any {
|
||||
"peers": []any{peer},
|
||||
}
|
||||
}
|
||||
|
||||
// warpRoute 构造分流 route:先 sniff 取出 SNI/Host(客户端多半发的是已解析 IP,
|
||||
// 不 sniff 域名规则无从命中),命中域名后缀走 warp,其余 final=direct。
|
||||
func (wc *WarpConfig) warpRoute() map[string]any {
|
||||
return map[string]any{
|
||||
"rules": []any{
|
||||
map[string]any{"action": "sniff"},
|
||||
map[string]any{"domain_suffix": wc.cleanDomains(), "outbound": warpOutboundTag},
|
||||
},
|
||||
"final": directOutboundTag,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -106,6 +113,12 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
|
||||
"auto_route": true,
|
||||
"strict_route": true,
|
||||
"stack": "system",
|
||||
// 私有 LAN 直连:strict_route 会在 OS 层把所有流量(含 LAN)强抓进隧道,
|
||||
// 光靠 route.rules 的 ip_cidr→direct 在 macOS 不生效(direct 出站的包被
|
||||
// strict_route 重新捕回隧道)。route_exclude_address 在 auto_route 层就把这些
|
||||
// 网段排除出隧道,LAN 走系统直连(修「隧道开着连不上局域网/NAS/家里机器」)。
|
||||
// **不含 172.16.0.0/12**:隧道自身地址与 DNS(172.19.x)在此段,排除会断 DNS。
|
||||
"route_exclude_address": []string{"192.168.0.0/16", "10.0.0.0/8"},
|
||||
}
|
||||
|
||||
// 代理出站集合:REALITY 必有;Hy2 仅在启用时加入(否则不进配置/探测组)。
|
||||
@@ -126,7 +139,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 +152,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 +189,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 {
|
||||
@@ -115,3 +193,45 @@ func TestRulesHandler(t *testing.T) {
|
||||
t.Errorf("allowed but missing file: code=%d, want 404", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TUN 入站必须把私有 LAN 网段从隧道排除(route_exclude_address),否则 strict_route
|
||||
// 会在 macOS 把 LAN 强抓进隧道 → 隧道开着连不上局域网/NAS。不得含 172.16/12
|
||||
// (隧道自身 172.19.x 在此段,排除会断 DNS)。
|
||||
func TestBuildClientConfigLANExclude(t *testing.T) {
|
||||
cfg, err := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(cfg, &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
var tun map[string]any
|
||||
for _, in := range m["inbounds"].([]any) {
|
||||
im := in.(map[string]any)
|
||||
if im["type"] == "tun" {
|
||||
tun = im
|
||||
break
|
||||
}
|
||||
}
|
||||
if tun == nil {
|
||||
t.Fatal("no tun inbound")
|
||||
}
|
||||
exRaw, ok := tun["route_exclude_address"]
|
||||
if !ok {
|
||||
t.Fatal("tun inbound missing route_exclude_address (LAN would be captured by strict_route)")
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, v := range exRaw.([]any) {
|
||||
got[v.(string)] = true
|
||||
}
|
||||
if !got["192.168.0.0/16"] {
|
||||
t.Error("route_exclude_address must contain 192.168.0.0/16")
|
||||
}
|
||||
if !got["10.0.0.0/8"] {
|
||||
t.Error("route_exclude_address must contain 10.0.0.0/8")
|
||||
}
|
||||
if got["172.16.0.0/12"] {
|
||||
t.Error("route_exclude_address must NOT contain 172.16.0.0/12 (tunnel DNS 172.19.x lives there)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
+132
-9
@@ -327,12 +327,12 @@ header .header-meta{color:var(--fg-soft)}
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<h1>feature+windows — 项目 TODO</h1>
|
||||
<div class="header-meta">生成于 2026-07-08 · 真相源 todo/todo.json</div>
|
||||
<div class="header-meta">生成于 2026-07-23 · 真相源 todo/todo.json</div>
|
||||
<div class="stats">
|
||||
<div class="stat-pill"><strong>18</strong>全部</div>
|
||||
<div class="stat-pill"><strong>8</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>0</strong>开发中</div>
|
||||
<div class="stat-pill"><strong>2</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>22</strong>全部</div>
|
||||
<div class="stat-pill"><strong>9</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>1</strong>开发中</div>
|
||||
<div class="stat-pill"><strong>4</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>8</strong>已验收</div>
|
||||
|
||||
</div>
|
||||
@@ -376,7 +376,7 @@ header .header-meta{color:var(--fg-soft)}
|
||||
|
||||
<div class="section-block" id="section-open">
|
||||
<div class="section-title st-open" data-toggle="open">
|
||||
📋 待开始 <span class="s-count">8</span>
|
||||
📋 待开始 <span class="s-count">9</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-open">
|
||||
@@ -599,6 +599,37 @@ header .header-meta{color:var(--fg-soft)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="21"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
data-tier=""
|
||||
data-tags="">
|
||||
<div class="card-header">
|
||||
<span class="item-id">#21</span>
|
||||
<span class="item-title">邀请/任务奖励明细列表 — GET /v1/invite/events(读 audit_log:action/target/days/时间)+ App 邀请页明细区(逐行:日期·事件·+N天)+ 六语;审计数据已齐(audit_log target 现带对方id),纯展示层活</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-07-13</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="3"
|
||||
data-level="low"
|
||||
@@ -634,18 +665,48 @@ header .header-meta{color:var(--fg-soft)}
|
||||
</div>
|
||||
<div class="section-block" id="section-doing">
|
||||
<div class="section-title st-doing" data-toggle="doing">
|
||||
🔨 开发中 <span class="s-count">0</span>
|
||||
🔨 开发中 <span class="s-count">1</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-doing">
|
||||
<ul class="todo-list" id="list-doing">
|
||||
<p class="empty-tip">暂无条目</p>
|
||||
|
||||
<li class="todo-card s-doing"
|
||||
data-id="23"
|
||||
data-level="mid"
|
||||
data-status="doing"
|
||||
data-tier=""
|
||||
data-tags="">
|
||||
<div class="card-header">
|
||||
<span class="item-id">#23</span>
|
||||
<span class="item-title">私有目的地 ACL(节点侧):只有白名单 dp_uuid 能经 pangolin 出口访问 brain/nas/git/win.51yanmei.com。节点本地 acl.json + agent 渲染放行/拒绝规则对,fail-closed,SIGHUP 热生效。设计 docs/private-dest-acl-design.html,计划 docs/superpowers/plans/2026-07-23-private-dest-acl.md</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-doing">开发中</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-07-23</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-block" id="section-done">
|
||||
<div class="section-title st-done" data-toggle="done">
|
||||
🔍 待验收 <span class="s-count">2</span>
|
||||
🔍 待验收 <span class="s-count">4</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-done">
|
||||
@@ -714,6 +775,68 @@ header .header-meta{color:var(--fg-soft)}
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-07-07</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="20"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier=""
|
||||
data-tags="">
|
||||
<div class="card-header">
|
||||
<span class="item-id">#20</span>
|
||||
<span class="item-title">Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常)</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
|
||||
|
||||
<button class="reject-btn" data-id="20" data-title="Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常)">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-07-12</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="22"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier=""
|
||||
data-tags="">
|
||||
<div class="card-header">
|
||||
<span class="item-id">#22</span>
|
||||
<span class="item-title">客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
|
||||
|
||||
<button class="reject-btn" data-id="22" data-title="客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-07-16</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
+58
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "feature+windows — 项目 TODO",
|
||||
"updated_at": "2026-07-07T17:45:18.036Z"
|
||||
"updated_at": "2026-07-22T16:59:57.735Z"
|
||||
},
|
||||
"seq": 19,
|
||||
"seq": 23,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -317,6 +317,62 @@
|
||||
"proposed_at": "2026-07-07T15:41:36.536Z",
|
||||
"approved_at": "2026-07-07T15:54:54.386Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"title": "Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常)",
|
||||
"desc": null,
|
||||
"level": "mid",
|
||||
"tier": null,
|
||||
"tags": [],
|
||||
"owner": "agent",
|
||||
"status": "done",
|
||||
"created_at": "2026-07-12T10:48:49.013Z",
|
||||
"done": false,
|
||||
"completed_at": "2026-07-13T02:20:58.685Z",
|
||||
"version": "site-v0.0.13"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"title": "邀请/任务奖励明细列表 — GET /v1/invite/events(读 audit_log:action/target/days/时间)+ App 邀请页明细区(逐行:日期·事件·+N天)+ 六语;审计数据已齐(audit_log target 现带对方id),纯展示层活",
|
||||
"desc": null,
|
||||
"level": "mid",
|
||||
"tier": null,
|
||||
"tags": [],
|
||||
"owner": "agent",
|
||||
"status": "open",
|
||||
"created_at": "2026-07-13T04:29:47.253Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"title": "客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开",
|
||||
"desc": null,
|
||||
"level": "mid",
|
||||
"tier": null,
|
||||
"tags": [],
|
||||
"owner": "agent",
|
||||
"status": "done",
|
||||
"created_at": "2026-07-15T21:40:35.427Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"title": "私有目的地 ACL(节点侧):只有白名单 dp_uuid 能经 pangolin 出口访问 brain/nas/git/win.51yanmei.com。节点本地 acl.json + agent 渲染放行/拒绝规则对,fail-closed,SIGHUP 热生效。设计 docs/private-dest-acl-design.html,计划 docs/superpowers/plans/2026-07-23-private-dest-acl.md",
|
||||
"desc": null,
|
||||
"level": "mid",
|
||||
"tier": null,
|
||||
"tags": [],
|
||||
"owner": "agent",
|
||||
"status": "doing",
|
||||
"created_at": "2026-07-22T16:59:41.945Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user