From a5d980a5e9f19d05854dc50c4838894679b8d89f Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Mon, 2 Nov 2026 22:44:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20WireGuard=20?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。 完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。 --- .claude/skills/deploy/scripts/deploy.sh | 3 +- .../scripts/17-install-wireguard.sh | 112 +++++++ .claude/skills/update-db/scripts/api.sh | 2 +- .env.example | 4 + AGENTS.md | 29 +- README.md | 4 + docs/CURRENT-STATE.md | 25 ++ docs/FFSB-DIRECT-2026-10-19.md | 44 +++ docs/FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md | 106 +++++++ docs/FFSB-SURGE-AUDIT-2026-10-19.md | 146 +++++++++ docs/FFSB-SURGE-CHECK-2026-11-02.md | 55 ++++ docs/FFSB-SURGE-DESIGN-2026-10-19.md | 150 ++++++++++ docs/FFSB-SURGE-IMPLEMENTATION-2026-10-19.md | 99 +++++++ docs/FFSB-WIREGUARD-SURGE-2026-11-02.md | 89 ++++++ docs/FINANCE-RULES.md | 33 +++ docs/FLCLASH-COMPATIBILITY-2026-11-02.md | 22 ++ docs/FLCLASH-WIREGUARD-AUDIT-2026-11-02.md | 75 +++++ docs/META-MUSE-RULES.md | 26 ++ docs/MOBILE-CLIENT-AUDIT-2026-10-13.md | 77 +++++ docs/MOBILE-CLIENT-FIX-2026-10-13.md | 54 ++++ docs/SHADOWROCKET-APPLE-VNC-REPRO.md | 49 +++ .../SURGE-FFSB-WIREGUARD-REVIEW-2026-11-02.md | 23 ++ docs/SURGE-PRIVACY.md | 5 + docs/WIREGUARD-DEVICES.md | 133 +++++++++ docs/WIREGUARD-LAN.md | 119 ++++++++ docs/design/wireguard-devices/DESIGN.md | 171 +++++++++++ docs/design/wireguard-devices/HANDOFF.md | 99 +++++++ .../wg-device-management.html | 249 ++++++++++++++++ edge-nodes/README.md | 5 + pnpm-lock.yaml | 172 +++++++++++ rules/ffsb-domains.json | 1 + rules/finance.list | 92 ++++++ rules/meta-muse.list | 10 + scripts/ffsb/README.md | 40 +++ scripts/ffsb/overlay.py | 64 ++++ scripts/ffsb/reconcile.py | 96 ++++++ scripts/ffsb/test_overlay.py | 53 ++++ scripts/ffsb/test_reconcile.py | 60 ++++ scripts/wireguard/agent.py | 235 +++++++++++++++ .../wireguard/proxy-station-wg-agent.service | 19 ++ scripts/wireguard/test_agent.py | 85 ++++++ server/assets/ffsb-domains.json | 27 ++ server/src/db/index.ts | 47 ++- server/src/db/schema.ts | 47 ++- server/src/index.ts | 6 + server/src/routes/settings.ts | 7 +- server/src/routes/sub-ruleset.test.ts | 64 ++++ server/src/routes/sub.ts | 55 +++- server/src/routes/tokens.ts | 18 +- server/src/routes/wg-devices.test.ts | 280 ++++++++++++++++++ server/src/routes/wg.ts | 99 +++++++ server/src/routes/wireguard.test.ts | 137 +++++++++ .../services/generators/clash-ai-dns.test.ts | 59 ++++ .../src/services/generators/clash-ai-dns.ts | 60 ++++ server/src/services/generators/clash.ts | 22 +- .../generators/mobile-clients.test.ts | 20 +- .../generators/shadowrocket-conf.test.ts | 50 ++++ .../services/generators/shadowrocket-conf.ts | 21 +- .../services/generators/shadowrocket-lan.ts | 21 ++ .../services/generators/surge-ffsb.test.ts | 70 +++++ server/src/services/generators/surge-ffsb.ts | 67 +++++ server/src/services/generators/surge.ts | 20 +- server/src/services/generators/wireguard.ts | 19 ++ server/src/services/ir.ts | 5 +- server/src/services/rulesets/convert.test.ts | 13 +- server/src/services/rulesets/convert.ts | 17 ++ server/src/services/wg-crypto.ts | 54 ++++ server/src/services/wg-migrate.ts | 40 +++ server/src/services/wg.ts | 177 +++++++++++ shared/src/dto.ts | 17 +- shared/src/index.ts | 1 + shared/src/protocols.ts | 2 +- shared/src/wireguard.ts | 36 +++ web/package.json | 2 + web/src/App.vue | 4 + web/src/pages/DevicesPage.vue | 159 ++++++++++ web/src/pages/SettingsPage.vue | 2 +- web/src/pages/SubscriptionsPage.vue | 128 ++++++-- web/src/router.ts | 1 + web/src/utils/wireguard.ts | 19 ++ 80 files changed, 4777 insertions(+), 51 deletions(-) create mode 100644 .claude/skills/edge-node-multi-protocol/scripts/17-install-wireguard.sh create mode 100644 docs/FFSB-DIRECT-2026-10-19.md create mode 100644 docs/FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md create mode 100644 docs/FFSB-SURGE-AUDIT-2026-10-19.md create mode 100644 docs/FFSB-SURGE-CHECK-2026-11-02.md create mode 100644 docs/FFSB-SURGE-DESIGN-2026-10-19.md create mode 100644 docs/FFSB-SURGE-IMPLEMENTATION-2026-10-19.md create mode 100644 docs/FFSB-WIREGUARD-SURGE-2026-11-02.md create mode 100644 docs/FINANCE-RULES.md create mode 100644 docs/FLCLASH-COMPATIBILITY-2026-11-02.md create mode 100644 docs/FLCLASH-WIREGUARD-AUDIT-2026-11-02.md create mode 100644 docs/META-MUSE-RULES.md create mode 100644 docs/MOBILE-CLIENT-AUDIT-2026-10-13.md create mode 100644 docs/MOBILE-CLIENT-FIX-2026-10-13.md create mode 100644 docs/SHADOWROCKET-APPLE-VNC-REPRO.md create mode 100644 docs/SURGE-FFSB-WIREGUARD-REVIEW-2026-11-02.md create mode 100644 docs/WIREGUARD-DEVICES.md create mode 100644 docs/WIREGUARD-LAN.md create mode 100644 docs/design/wireguard-devices/DESIGN.md create mode 100644 docs/design/wireguard-devices/HANDOFF.md create mode 100644 docs/design/wireguard-devices/wg-device-management.html create mode 120000 rules/ffsb-domains.json create mode 100644 rules/finance.list create mode 100644 rules/meta-muse.list create mode 100644 scripts/ffsb/README.md create mode 100644 scripts/ffsb/overlay.py create mode 100644 scripts/ffsb/reconcile.py create mode 100644 scripts/ffsb/test_overlay.py create mode 100644 scripts/ffsb/test_reconcile.py create mode 100644 scripts/wireguard/agent.py create mode 100644 scripts/wireguard/proxy-station-wg-agent.service create mode 100644 scripts/wireguard/test_agent.py create mode 100644 server/assets/ffsb-domains.json create mode 100644 server/src/routes/sub-ruleset.test.ts create mode 100644 server/src/routes/wg-devices.test.ts create mode 100644 server/src/routes/wg.ts create mode 100644 server/src/routes/wireguard.test.ts create mode 100644 server/src/services/generators/clash-ai-dns.test.ts create mode 100644 server/src/services/generators/clash-ai-dns.ts create mode 100644 server/src/services/generators/shadowrocket-lan.ts create mode 100644 server/src/services/generators/surge-ffsb.test.ts create mode 100644 server/src/services/generators/surge-ffsb.ts create mode 100644 server/src/services/generators/wireguard.ts create mode 100644 server/src/services/wg-crypto.ts create mode 100644 server/src/services/wg-migrate.ts create mode 100644 server/src/services/wg.ts create mode 100644 shared/src/wireguard.ts create mode 100644 web/src/pages/DevicesPage.vue create mode 100644 web/src/utils/wireguard.ts diff --git a/.claude/skills/deploy/scripts/deploy.sh b/.claude/skills/deploy/scripts/deploy.sh index 1489a36..a07793b 100755 --- a/.claude/skills/deploy/scripts/deploy.sh +++ b/.claude/skills/deploy/scripts/deploy.sh @@ -106,7 +106,8 @@ if [ "$TARGET" != web ]; then cp .staging/web/package.json web/; rm -rf .staging" step "远端安装生产依赖(frozen lockfile)" - "${SSH[@]}" "cd '$REMOTE_DIR' && CI=true pnpm install --frozen-lockfile --prod --filter '@proxy-station/server...' 2>&1 | tail -3 && chown -R $RUN_USER:$RUN_USER '$REMOTE_DIR'" \ + # Only application paths: data/backups must retain root ownership and mode 600. + "${SSH[@]}" "cd '$REMOTE_DIR' && CI=true pnpm install --frozen-lockfile --prod --filter '@proxy-station/server...' 2>&1 | tail -3 && chown -R $RUN_USER:$RUN_USER server/src shared/src server/assets node_modules" \ || fail "pnpm install 失败。锁文件与 package.json 不一致时先在本地 pnpm install 并提交锁文件" step "重启 $SERVICE 并等待健康检查" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/17-install-wireguard.sh b/.claude/skills/edge-node-multi-protocol/scripts/17-install-wireguard.sh new file mode 100644 index 0000000..442edae --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/17-install-wireguard.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Dedicated private-LAN WireGuard hub. Does not enable an Internet exit/NAT. +# Usage: bash .../17-install-wireguard.sh +set -euo pipefail +umask 077 +unset HTTPS_PROXY HTTP_PROXY https_proxy http_proxy ALL_PROXY all_proxy 2>/dev/null || true +host="${1:?SSH host required}" +endpoint="${2:?Public endpoint required}" +output_dir="${3:?Private output directory required}" +[[ "$host" =~ ^[a-z0-9-]+$ && "$endpoint" =~ ^[a-zA-Z0-9.-]+$ ]] || exit 2 +mkdir -p "$output_dir" +chmod 700 "$output_dir" + +ssh -o BatchMode=yes -o ConnectTimeout=15 "$host" 'set -e + if ! command -v wg >/dev/null || ! command -v wg-quick >/dev/null; then + sudo -n env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l apt-get update -qq + sudo -n env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l apt-get install -y --no-install-recommends wireguard-tools + fi' + +ssh -o BatchMode=yes -o ConnectTimeout=15 "$host" sudo -n python3 - "$endpoint" <<'PY' +import datetime +import ipaddress +import json +import os +from pathlib import Path +import subprocess +import sys + +os.umask(0o077) +endpoint = sys.argv[1] +interface = 'wg-njcq' +subnet = '10.203.0.0/24' +port = 51820 +root = Path('/etc/wireguard') +config = root / (interface + '.conf') +clients = root / 'njcq-clients.json' +if config.exists(): + if not clients.exists(): + raise SystemExit('Existing interface has no managed client inventory; refusing replacement') + print('Existing managed WireGuard hub preserved') + raise SystemExit(0) +routes = json.loads(subprocess.check_output(['ip', '-j', 'route'])) +for route in routes: + dst = route.get('dst', 'default') + if dst != 'default' and ipaddress.ip_network(dst, strict=False).overlaps(ipaddress.ip_network(subnet)): + raise SystemExit('Subnet overlaps an existing route: ' + dst) +stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S') +backup = Path('/root/backups') / ('before-wireguard-config-' + stamp) +backup.mkdir(parents=True, mode=0o700) +(backup / 'iptables.v4').write_bytes(subprocess.check_output(['iptables-save'])) +(backup / 'forwarding.txt').write_bytes(subprocess.check_output(['sysctl', 'net.ipv4.ip_forward'])) +root.mkdir(mode=0o700, exist_ok=True) +root.chmod(0o700) + +def keypair(): + private = subprocess.check_output(['wg', 'genkey'], text=True).strip() + public = subprocess.check_output(['wg', 'pubkey'], input=private, text=True).strip() + return private, public + +server_private, server_public = keypair() +devices = [] +for name, address, incoming in [('iPad Pro', '10.203.0.2', False), ('Mac Studio', '10.203.0.3', True)]: + private, public = keypair() + psk = subprocess.check_output(['wg', 'genpsk'], text=True).strip() + devices.append({'name': name, 'publicKey': public, 'profile': { + 'server': endpoint, 'port': port, 'serverPublicKey': server_public, + 'privateKey': private, 'presharedKey': psk, 'address': address, + 'subnet': subnet, 'mtu': 1280, 'keepalive': 25, 'acceptIncoming': incoming, + }}) + +# Private forwarding is allowed only between peers of this interface. +# The other interfaces, Docker and Tailscale keep their existing policies. +up = [ + f'iptables -I INPUT 1 -p udp --dport {port} -m comment --comment proxy-station-wireguard -j ACCEPT', + f'iptables -I FORWARD 1 -i %i -o %i -s {subnet} -d {subnet} -j ACCEPT', + 'iptables -I FORWARD 2 -i %i -j DROP', + 'iptables -I FORWARD 3 -o %i -j DROP', +] +down = [ + f'iptables -D INPUT -p udp --dport {port} -m comment --comment proxy-station-wireguard -j ACCEPT', + f'iptables -D FORWARD -i %i -o %i -s {subnet} -d {subnet} -j ACCEPT', + 'iptables -D FORWARD -i %i -j DROP', + 'iptables -D FORWARD -o %i -j DROP', +] +lines = ['[Interface]', 'Address = 10.203.0.1/24', f'ListenPort = {port}', + f'PrivateKey = {server_private}', 'MTU = 1280'] +lines += ['PostUp = ' + rule for rule in up] +lines += ['PostDown = ' + rule for rule in down] +for device in devices: + profile = device['profile'] + lines += ['', '# ' + device['name'], '[Peer]', 'PublicKey = ' + device['publicKey'], + 'PresharedKey = ' + profile['presharedKey'], 'AllowedIPs = ' + profile['address'] + '/32'] +config.write_text('\n'.join(lines) + '\n') +config.chmod(0o600) +clients.write_text(json.dumps({'interface': interface, 'serverPublicKey': server_public, 'devices': devices}, indent=2)) +clients.chmod(0o600) +Path('/etc/sysctl.d/90-proxy-station-wireguard.conf').write_text('net.ipv4.ip_forward = 1\n') +subprocess.run(['sysctl', '-w', 'net.ipv4.ip_forward=1'], check=True) +try: + subprocess.run(['systemctl', 'enable', '--now', 'wg-quick@' + interface], check=True) +except subprocess.CalledProcessError: + subprocess.run(['systemctl', 'disable', '--now', 'wg-quick@' + interface], check=False) + raise +print('WireGuard hub installed:', interface, subnet, 'UDP', port) +print('Backup:', backup) +print('Devices:', ', '.join(d['name'] for d in devices)) +PY + +ssh -o BatchMode=yes -o ConnectTimeout=15 "$host" 'sudo -n cat /etc/wireguard/njcq-clients.json' > "$output_dir/clients.json" +test -s "$output_dir/clients.json" +chmod 600 "$output_dir/clients.json" +ssh -o BatchMode=yes -o ConnectTimeout=15 "$host" 'sudo -n wg show wg-njcq; systemctl is-active wg-quick@wg-njcq tailscaled' diff --git a/.claude/skills/update-db/scripts/api.sh b/.claude/skills/update-db/scripts/api.sh index f562130..401e608 100755 --- a/.claude/skills/update-db/scripts/api.sh +++ b/.claude/skills/update-db/scripts/api.sh @@ -56,7 +56,7 @@ case "$cmd" in CTX="${1:-}"; [ -n "$CTX" ] || die "用法:api.sh backup (小写字母/数字/连字符,如 add-jp-hy2)" case "$CTX" in *[!a-z0-9-]*) die "context 只允许小写字母、数字与连字符";; esac # .backup 是 SQLite 在线备份,WAL 下安全,产出独立完整快照(无需 wal/shm) - "${SSH[@]}" 'set -e; ts=$(date +%Y%m%d-%H%M%S); f='"$BACKUP_DIR"'/before-'"$CTX"'-$ts.db; sqlite3 '"$DB_FILE"' ".backup $f"; if [ -s "$f" ]; then ls -la "$f"; else echo "备份为空" >&2; exit 1; fi' + "${SSH[@]}" 'set -e; umask 077; ts=$(date +%Y%m%d-%H%M%S); f='"$BACKUP_DIR"'/before-'"$CTX"'-$ts.db; sqlite3 '"$DB_FILE"' ".backup $f"; if [ -s "$f" ]; then chmod 600 "$f"; chown root:root "$f"; ls -la "$f"; else echo "备份为空" >&2; exit 1; fi' ;; backups) "${SSH[@]}" "ls -la '$BACKUP_DIR' | tail -20" diff --git a/.env.example b/.env.example index 80751c3..0ba0187 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,7 @@ PORT=3000 DATA_DIR=./data # 管理界面鉴权 token。未设置时 /api/* 仅放行 localhost 与局域网私有网段 ADMIN_TOKEN= + +# 可选:WireGuard 身份加密主密钥(32 字节 Base64,由启动环境注入)。 +# 未设置时首次使用生成 DATA_DIR/wg-master.key(600);必须和数据库受控备份,勿随部署重新生成。 +# WG_MASTER_KEY= diff --git a/AGENTS.md b/AGENTS.md index 076cb08..2a0b187 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ Surge 不支持组级 `url=`;组的 `testUrl` 仅用于 Clash/ShadowRocket ### ShadowRocket 分两步导入 -ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。节点订阅按完整名称字符串升序排列,保留出口词;该序列化器对副本排序,不修改 IR/数据库,跨客户端名称排序由上述节点权重约定保证。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组动态收节点靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),显式成员也要保留。因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来;这些节点及 App 自定义排序不由本服务控制。本生成器当前保守跳过 `DOMAIN-SET`/`AND`/`OR`/`NOT`/`URL-REGEX`/`PROTOCOL`,并回报 `skippedRules`;这不是客户端不支持这些类型的断言。官方更新记录已确认存在逻辑规则与 QUIC 匹配,具体配置语法仍须以 App 内帮助和真机编译验证,详见 `docs/MOBILE-CLIENT-AUDIT-2026-10-04.md`。**ShadowRocket 的 conf 策略名不可用 Surge 引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。 +ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。节点订阅按完整名称字符串升序排列,保留出口词;该序列化器对副本排序,不修改 IR/数据库,跨客户端名称排序由上述节点权重约定保证。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组动态收节点靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),显式成员也要保留。因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来;这些节点及 App 自定义排序不由本服务控制。Shadowrocket conf 在提供转换基址时把 DOMAIN-SET 转成本服务鉴权 `.list` 端点的普通 RULE-SET(精确主机→DOMAIN,前导点→DOMAIN-SUFFIX);未提供转换基址的离线调用仍跳过。LAN 按 Surge 官方文档列出的 14 条域名/CIDR 规则展开,保留原参数;明确的 macOS `/Applications/*.app/` 进程路径不下发 iOS。AND/OR/NOT/URL-REGEX/PROTOCOL 仍保守跳过并回报 `skippedRules`;这不是客户端不支持这些类型的断言。2026-10-13 已部署并完成实际 HTTPS 订阅/转换端点验收,依据与验证见 `docs/MOBILE-CLIENT-FIX-2026-10-13.md`。官方更新记录已确认存在逻辑规则与 QUIC 匹配,具体配置语法仍须以 App 内帮助和真机编译验证,详见 `docs/MOBILE-CLIENT-AUDIT-2026-10-04.md`。**ShadowRocket 的 conf 策略名不可用 Surge 引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。 Clash 节点必须明确下发 UDP 能力:VMess/Trojan/纯 TCP SS/HY2 输出 `udp: true`,SS WS 插件仅转发 TCP,输出 false,不能假设 nginx 的同端口提供 SS UDP。VMess cipher 按 mihomo 命名映射、未知 cipher 明确排除;SS plugin 的 TLS 参数在 `plugin-opts` 内。RULE-SET 保留 no-resolve;逻辑规则逐个转换子类型,仅把 PROTOCOL TCP/UDP 映射 NETWORK,任一子条件无法表达时整条跳过。两端 url-test 保留零 tolerance,fallback 不输出 tolerance。完整体检需分别核对客户端能力与生成器范围,原生解析成功不等于设备连接或隐私验收。 @@ -179,7 +179,7 @@ schema 变更同时更新 `shared/src/schemas.ts`/DTO(涉及对外字段时 ## Surge AI DNS 与协议规则 -改 DNS/隐私行为先读 `docs/SURGE-PRIVACY.md`、`server/src/services/generators/surge-ai-dns.ts` 及对应测试。AI 域名 DNS 映射来自 IR 内启用的 AI 路由规则;不要把全局 DNS 或所有端口 53 请求改送 AI。Surge 的 `PROTOCOL` 应用协议规则不能直接复制到其他客户端:Clash 只把 TCP/UDP 转成 `NETWORK`,其他应用协议跳过;ShadowRocket 跳过 `PROTOCOL`。生成配置校验不能替代设备 DNS/WebRTC 真机验证。 +改 DNS/隐私行为先读 `docs/SURGE-PRIVACY.md`、`server/src/services/generators/surge-ai-dns.ts` 及对应测试。 Clash 的本地候选从同一 IR 域名规则按顺序派生 nameserver-policy,使用 inline classical matcher 保留早期非 AI 例外,并复用远程 provider;AI DoH 通过官方 `#策略组名` 参数绑定 AI,节点域名用独立普通 DNS 引导。该改动不作用于 Surge,未开启全局 respect-rules。既有 DoH 会话可能复用旧出口,新建连接才随 AI 选择;不要声称切组立即迁移全部 DNS 会话。Shadowrocket 的 AI DNS 写法尚缺可靠语法/真机依据,仍未实现。AI 域名 DNS 映射来自 IR 内启用的 AI 路由规则;不要把全局 DNS 或所有端口 53 请求改送 AI。Surge 的 `PROTOCOL` 应用协议规则不能直接复制到其他客户端:Clash 只把 TCP/UDP 转成 `NETWORK`,其他应用协议跳过;ShadowRocket 跳过 `PROTOCOL`。生成配置校验不能替代设备 DNS/WebRTC 真机验证。 手写 Host 项优先;生成的域名 DNS 映射按连接规则顺序保留 AI 之前的非 AI 域名/规则集例外,不把 Host 当成能表达进程、协议或 IP 条件的路由系统。宽泛 AI 域名规则仍须审阅覆盖范围。 @@ -193,6 +193,16 @@ Net.Coffee 的 Claude 页通过随机 `*.d.ip.net.coffee` 探测递归 DNS 出 ## 验证与完成标准 +WireGuard 已有实现见 `docs/WIREGUARD-LAN.md`:历史身份曾放在 `sub_tokens.wireguard`(已迁移为独立加密设备,见下段),设备使用独立 token/地址/密钥,不加入共享公网代理池。`irFor()` 只附加当前 token 身份,原共享订阅保持无 WG;历史 token PUT 省略 wireguard 时保留身份;当前通过独立设备映射兼容,token API 不再接受身份写入。标准 WG 配置保留独立授权导出;Shadowrocket WG YAML 只属于历史实现,本轮已移除生成器与 DTO 链接,旧专用路径明确返回 404。Mac Surge 入站要求 6.11+,旧版本 CLI 解析通过不等于入站验收。撤销订阅 token 不等于撤销已导入的 WireGuard peer。2026-11-01 实测 Apple VNC 成功组合为 iPad 官方 WireGuard → njcq → Mac Surge 6.11.0 build 12660,MTU 1280;同机 Shadowrocket 路径出现媒体缺包/黑屏,具体实现原因尚未确认。 + +**WireGuard 最新产品约定(2026-11-02,已部署)**:开发先读 [实现交接](docs/design/wireguard-devices/HANDOFF.md)、[完整设计](docs/design/wireguard-devices/DESIGN.md) 与同目录可点击原型。用户个人 macOS/iPadOS/iOS 已统一 Surge。设备页保留固定 IP、状态、最后活动、流量、管理操作及独立 WG 导出,删除“使用客户端”。Surge 订阅增加可选设备下拉:不选为原默认纯代理 URL,选中生成带设备分发凭证的派生 URL,只附加此设备身份;清除选择恢复默认 URL,不改变其他已生成链接。设备身份迁到独立实体,旧 token 兼容保留;不实现身份模块方案,也不把 UI 选择写成共享 token 的全局设备状态。凭证不可枚举,`#!MANAGED-CONFIG` 必须保留完整设备参数;暂停需真正撤销 peer。原型附加设备均为演示,不能拿去初始化生产。实现状态、API、网关安装与验收边界见 [WIREGUARD-DEVICES](docs/WIREGUARD-DEVICES.md)。已将实际旧 token 身份迁移到加密 `wg_devices`,旧 token 用 `wg_device_id` 兼容映射;`/api/tokens` 不再接受 wireguard 写入,普通列表不返回密钥。`wg-master.key`(或启动环境 `WG_MASTER_KEY`)必须与 SQLite 一同受控备份,丢失不得重新生成覆盖;暂停/恢复/重置/移除由持久任务等待运行与持久配置双确认,LA 与 njcq agent 已部署,原有两台设备只读核对并 confirmed,网关持久/运行 peer 身份不变、普通代理正文不变。用户随后已在 macOS/iPadOS/iOS 全面验证 Surge + WireGuard 可工作;真实新增/暂停/恢复/重置等未由本轮助手执行,不把组网验证扩写为所有生命周期、长期/睡眠/换网均已验收;FFSB 原生 WG 试验属于不同身份与网络,不合并进 njcq。 + +**同日后续:单一主订阅**:用户已明确只保留一个订阅,生产通过 API 删除两条 `NJCQ WireGuard - iPad Pro/Mac Studio` 旧订阅,仅保留原 `Proxy Station` 主订阅及原默认 URL。两台设备身份、IP、peer 和独立导出仍保留,统一从主订阅选择设备生成带凭证的派生 URL;旧设备 token URL 已 404,不再按早期兼容约定恢复这两条卡片。该要求是当前生产收拢,不是将某台设备写成主订阅的全局身份,也不限制其他部署的通用多订阅能力。详情与备份见 [单一主订阅记录](docs/WIREGUARD-DEVICES.md#2026-11-02-单一主订阅收拢)。 + +**同日后续:停止 Shadowrocket WG 兼容**:用户确认 macOS/iPadOS/iOS 的 Surge + WireGuard 已实测可工作,Shadowrocket 的 Apple VNC 失败排查仅作历史记录,不再推进该路径。Shadowrocket 节点与 conf 仅下发普通代理数据:路由不附加/读取设备 WG 身份,序列化器忽略 IR.wireguard,不生成 WG 私网策略规则或额外节点导入提示,也不再按 WG 身份改写动态组筛选。已删除 Shadowrocket 专用 WG YAML 生成器与 DTO 导出链接,`/sub/:token/wireguard-shadowrocket` 显式返回 404,防止落入 SPA。保留 Surge 可选设备、标准 WG 独立导出/二维码、设备与网关管理,普通 Shadowrocket 代理兼容仍保留。该清理当前仅完成本地代码验证,尚未重新部署。 + +**FlClash Android(2026-11-02)**:已核对官方 v0.9.2 及其固定内核,WireGuard 出站支持;当前标准 Android 实现未提供 Surge 式 `allow-peer-access` 本机服务入站桥接。不能用 allow-lan、TUN、内部栈 Listen 方法或 WG 握手成功代替双向设备验收。FlClash 可选设备身份已部署,旧 `/clash` 默认仍为纯代理,设备凭证按用途隔离;尚未进行 Android 真机测试,不恢复 Shadowrocket WG 兼容。完整依据及兼容性审查见 [FlClash 核对](docs/FLCLASH-WIREGUARD-AUDIT-2026-11-02.md) 与 [配置兼容性](docs/FLCLASH-COMPATIBILITY-2026-11-02.md)。 + | 改动 | 建议验证 | |---|---| | 服务端逻辑/解析器/生成器 | `pnpm --filter @proxy-station/server typecheck` + 相关 Vitest;新增行为用占位凭据构造 IR/分享链接用例 | @@ -213,3 +223,18 @@ Net.Coffee 的 Claude 页通过随机 `*.d.ip.net.coffee` 探测递归 DNS 出 改生成器时检查节点过滤、组成员引用、规则顺序和 FINAL 兜底;Clash 可用 mihomo 校验,Surge/ShadowRocket 的客户端语法与实际连接仍需真机验收。`excluded`/`skippedRules` 是生成器返回字段,订阅 HTTP 响应只返回配置正文。 完成后说明改动、已通过的验证与尚未完成的实际验证。新增业务约束或修正旧认识时同步更新这里或相应 `docs/`,让后续 Codex/Claude Code 会话能接续。 + + +### FFSB / Claude 手动订阅分流(2026-10-19) + +最新方案见 `docs/FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md`,已替代早先本机协调层。当前仅用正式托管 `Proxy Station`;不恢复 `Proxy Station Claude` 或 LaunchAgent。可见 `🦀 Claude` 为 select,新增 `🦁 FFSB`;仅源清单 25 项受它选择,合并的集合外地址始终经隐藏 `Claude-Surge-Internal` → `🧠 AI Nodes`。Surge-only 生成器适配读取生产 settings 的 FFSB JSON,其他客户端保持原行为。25 项唯一源 `server/assets/ffsb-domains.json`,`rules/ffsb-domains.json` 为符号链接。 + +必要设备前提:FFSB `disableDns=true` 并重连生效,本机实测仍保留内部 DNS;Surge 增强模式用 fake-IP 接住普通域名流量。不要给这 25 项添加 always-real-ip,否则真实地址可被 FFSB 系统路由提前接走,绕过手动选择。选择 FFSB 时绑定 utun100 并向 100.70.255.254 查询;选择代理时远端解析;FFSB 不运行时选它允许失败,不自动回退。其他 Mac 需一次性确认接口、DNS 和客户端设置,不能声称仅导入就通用;iOS 未验收。用户已授权本次一次性设置调整和有恢复保证的 FFSB 断开/重连测试,仍未授权重启 Surge 引擎。真实模型流、长期/睡眠/换网及完整启动顺序仍未验收。 + +### 本机 FFSB 原生 WireGuard 独立验证(2026-11-02) + +探索记录、管理连接边界与接续步骤见 `docs/FFSB-WIREGUARD-SURGE-2026-11-02.md`。用户授权导出既有 WG 身份并独立验证:测试期间 FFSB GUI/daemon 停止,27 项原生规则、隧道内 DNS、35/35 多轮请求、错误端点反向对照及 39 秒公开响应传输通过。原生方案只使用已下发配置快照,没有替代 NetBird 管理连接;长期/睡眠/换网/重新认证、真实模型业务与完整在线规则集快照未验收。真实 WG 身份和证据仅在本机权限 700/600 的私有归档中,绝不入 Git;不写入共享订阅。 + +**当前状态(同日 11:05 回退验收)**:按用户要求恢复正式托管 `Proxy Station`+FFSB,Claude → `🦁 FFSB`,FFSB GUI/daemon 和 utun100 已恢复,disableDns=true;3 轮 24/24 请求、原清单 25/25 规则、FFSB 内层 utun100/外层 DIRECT en0、集合外美国 AI 与普通日本 Proxy 分流通过。试验 `Proxy Station WG Validation` 已移出 Surge 配置列表并私有归档,原托管文件、生产数据、生成器及其他手动组选择未改,未重启引擎。FFSB 下发新增 hCaptcha 两项尚未同步生产 Surge 清单,保留原方案范围。后续若再试原生 WG,须先断开并停止 FFSB;两者复用相同身份,不可并行连接。不要依据旧试验快照擅自重新激活原生 WG。 + +Surge CLI 的 profile dump 会隐藏认证字段,不能将其输出直接复制成可用的完整配置;需保留真实凭据时在本机私有文件中使用磁盘原件并校验,输出与备份仍须脱敏、受限权限。 diff --git a/README.md b/README.md index 300cbaa..cf9b3b4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ **按订阅分配节点**——每个订阅可以只开放部分节点,用于把不同线路分给不同设备或他人。 +**WireGuard 设备组网**——设备专用订阅携带独立私网身份,提供 Surge 配置和标准 `.conf` 下载,通过固定 VPS 中转访问 Apple 屏幕共享。用户已在 macOS/iPadOS/iOS 实测 Surge + WireGuard 可工作,Shadowrocket 只保留普通代理下发;WG 清理当前已本地完成、未部署。原共享订阅保持原有范围。部署、版本要求和验收边界见 [WireGuard 组网](docs/WIREGUARD-LAN.md)。 + +**WireGuard 设备管理(2026-11-02,已部署)**——保留设备状态、固定 IP、流量和独立配置导出,去掉客户端选择;Surge 订阅可选附加一台设备,未选为原默认纯代理链接,选中生成带设备凭证的链接。开发从 [实现交接](docs/design/wireguard-devices/HANDOFF.md) 开始,配套 [完整设计](docs/design/wireguard-devices/DESIGN.md) 和 [可点击原型](docs/design/wireguard-devices/wg-device-management.html)。设备页、加密身份迁移、网关 agent 和设备绑定 API 已上线,原 Mac/iPad 只读纳管完成;运维和待验收项见 [实现记录](docs/WIREGUARD-DEVICES.md)。 + ## 客户端支持 | 客户端 | 节点 | 策略组 | 规则 | 导入方式 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eb4b83f..50fb263 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,21 @@ # 当前生产状态与接续入口 +2026-11-02 后续客户端验收:用户确认 macOS/iPadOS/iOS 的 Surge + WireGuard 已全面实测可工作。已据此在本地清理 Shadowrocket 的 WG 下发兼容(普通代理能力保留),但本轮尚未重新部署;不将旧 Apple VNC 排查当作待继续实现的方向。详情见 [WireGuard 实现记录](WIREGUARD-DEVICES.md)。 + +2026-11-02 后续订阅收拢:按用户“只保留一个”要求,生产仅保留原 `Proxy Station` 主订阅(原 token、URL 与节点范围不变),已通过管理 API 删除 `NJCQ WireGuard - iPad Pro` 和 `NJCQ WireGuard - Mac Studio` 两条旧订阅,旧 URL 返回 404。两台独立 WG 设备、固定 IP、密钥、标准导出和网关 peer 均保留,统一在主订阅的 Surge 设备下拉中附加;不要恢复两条旧订阅卡片。修改前在线备份及主密钥备份见 [实现记录](WIREGUARD-DEVICES.md#2026-11-02-单一主订阅收拢)。以下同日部署时的 3 订阅为收拢前历史数量。 + +2026-11-02 最新:WireGuard 设备管理和 Surge 可选设备订阅已部署 LA,njcq agent 已接入并只读确认原 iPad `.2`、Mac `.3`。旧身份迁入加密独立设备实体,保留旧 URL;普通共享 URL 仍无 WG,派生 URL 含订阅/设备绑定的可撤销凭证。部署前后 API 核对为 30 节点(12 启用)、14 组、277 规则、3 订阅,节点/组/规则/settings 未变;njcq 持久配置及既有 peer 身份/地址不变,无演示设备。本机活动 Surge 未切换,三端新导入/入站/实际暂停和媒体等仍待验收。备份和验证详见 [实现与生产记录](WIREGUARD-DEVICES.md)。下方 2026-10-04 表格和旧 token 模型均为历史基线。 + +2026-11-01 新增 njcq WireGuard 私网与两份设备专用订阅;原共享订阅、代理节点、组和规则不变。每台设备独立身份,Mac Surge 入站要求 6.11+。 iPad 原生 WireGuard → njcq → Mac Surge 6.11 的 Apple VNC 已由用户确认流畅;Shadowrocket 同机组网路径的普通 VNC 可用,高性能 Apple VNC 未通过。见 [WireGuard 组网记录](WIREGUARD-LAN.md)。下文历史数量不包含新增设备订阅。 + +2026-10-13 移动端生成器修复已全部部署,实际 HTTPS 验收见 [修复记录](MOBILE-CLIENT-FIX-2026-10-13.md)。 + +2026-10-13 最新:已删除旧手动 paypal.com 规则,主站仍由 Finance 的 paypal 关键词覆盖。随后按用户连接记录补充 metaaivm.com → AI;最新为 13 个组、25 个显式成员、266 条规则(264 启用),Finance 86 条;Clash/Shadowrocket 全面检查见 [移动端校验](MOBILE-CLIENT-AUDIT-2026-10-13.md)。下面同日 Finance 新增阶段的计数是历史快照,当前 266 条包含后来补充的 metaaivm.com。 + +2026-10-13 Finance 更新:新增 `💰 Finance` 手动组,成员依次为美国、日本、新加坡、DIRECT,默认美国、不包含 All Nodes。Plasma One 与 PayPal 规则已归入该组,见 [Finance 记录](FINANCE-RULES.md)。本次变更后为 13 个组、25 个显式成员、266 条规则(264 启用);下文历史组列表与数量不代表更新后状态。 + +2026-10-13 更早的定向更新:已补充 Meta Muse 四条 AI 域名规则,当时 API 为 180 条规则(178 启用),见 [Meta Muse 记录](META-MUSE-RULES.md)。下文 2026-10-04 的规则数量与产物表为历史盘点,不代表此次更新后的数量;维护前仍需 API 重读。 + 核对日期:2026-10-04(UTC+8;管理 API 盘点时间 11:46)。来源为 LA 管理 API 与四类实际 HTTPS 订阅;本次仅更新文档,未执行部署或生产业务数据变更。完整响应、token、凭据与配置仅保存在权限受限的本机临时目录。 本文件集中维护最新生产盘点;日期化体检与变更报告保留各阶段证据。后续任务先重读 API,再更新这里,不能把模板、历史数量或本机模块当作生产现状。项目协作约定见 [AGENTS.md](../AGENTS.md),技能入口见 [双工具协作](AI-COLLABORATION.md)。 @@ -103,3 +119,12 @@ HTTP 订阅只有正文,没有 excluded/skippedRules 字段;总节点数 | 独立 AI Nodes、emoji 与名称排序 | [AI Nodes](AI-NODES-2026-10-04.md) | 独立 select、同节点来源、默认零权重、保留客户端选择 | 备份文件名与原阶段验收保留在各专题记录中,不清理远端备份。新增业务约束更新 AGENTS.md;当前盘点更新本文件;操作方法更新对应专题。代码部署、生产数据更新和设备配置更新分别核对授权,纯文档整理不触发这些动作。 + + +## 2026-10-19 Claude / FFSB 分离 + +生产新增独立 `🦀 Claude` select 组,FFSB 25 项与原 Claude 规则去重合并为21条业务规则,另有1条独立 DNS 传输规则;原 AI 保留其他服务。此时14组、277规则,维护仍须 API 重读。清理旧 FFSB 三条服务器 IP /32 和宽泛 App 直连,保留真实服务进程及控制域名引导。仅本机另有 `Claude` 自动入口及协调任务,关闭 FFSB 回退、重新连接恢复已实测,启动顺序未获本轮重启许可。详见 [实施及验收](FFSB-SURGE-IMPLEMENTATION-2026-10-19.md)。 + +## 2026-10-19 后续:FFSB 手动选择 + +早先本机自动协调层已退役。正式 `Proxy Station` 订阅的 `🦀 Claude` 新增 `🦁 FFSB`;25 项精确范围可手动选择,集合外 Claude 走隐藏策略 → AI Nodes。生产 settings 增加 FFSB 适配参数,数据库组/规则数量不变,其他客户端生成保持原行为。本机仅保留原托管配置,停用 LaunchAgent,FFSB 关闭系统 DNS 接管以便 Surge fake-IP 控制入口。实施、验证及设备前提见 [最新记录](FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md)。 diff --git a/docs/FFSB-DIRECT-2026-10-19.md b/docs/FFSB-DIRECT-2026-10-19.md new file mode 100644 index 0000000..c27f1ce --- /dev/null +++ b/docs/FFSB-DIRECT-2026-10-19.md @@ -0,0 +1,44 @@ +# FFSB 专线与 Surge 直连例外(2026-10-19) + +本机 FFSB 报 Anthropic 探测等待响应头超时;检查发现 `link.fufeishangban.com` 返回 fake-IP,目标服务器路由进入 Surge utun5。当前活动配置为 Proxy Station,增强模式开启。 + +经管理 API 在生产规则最前添加以下规则,保留原有规则相对顺序和唯一末尾 FINAL: + +```ini +IP-CIDR,147.93.172.146/32,DIRECT,no-resolve +IP-CIDR,185.180.19.201/32,DIRECT,no-resolve +DOMAIN,link.fufeishangban.com,DIRECT +``` + +Surge General 的既有 `always-real-ip` 追加精确域名 `link.fufeishangban.com`,避免专线客户端收到 fake-IP;未固定域名 IP,也未扩大到所有域名。域名当前真实解析与隧道服务器 IP 不同,分别处理域名与隧道服务器,不应将两者混为一谈。 + +备份:生产 `data/backups/before-ffsb-direct-20261019-103801.db`,已确认 root/600。规则由 267 增至 270,重读确认旧规则顺序与其他设置不变。 + +实际 HTTPS Surge 订阅及 49/49 远程规则集快照通过官方 CLI 校验,静态域名遮蔽提示为 0。本机更新原托管配置后,新增直连规则命中 DIRECT,系统 DNS 已返回真实公网 IP,策略组列表与更新前一致。Clash 实际订阅已获取并确认包含新 IP 规则,但本机未安装 mihomo,未完成其原生语法校验。 + +DIRECT 表示 Surge 直连转发,并不保证系统路由不再显示 utun;不能只凭 utun 路由判定连接仍走外层代理。FFSB 首次重连后服务器改为当前解析 IP `147.93.172.146`,因此补充该 IP 的直连规则。再次刷新订阅并重连后,客户端出口检测成功,显示住宅出口及 5591 ms 延迟,原 Anthropic 超时提示消失。该结果为即时检测,未验证长期稳定性。 + +依据:[Surge always-real-ip](https://manual.nssurge.com/dns/advanced.html)。 + +## 后续:FFSB 服务 STUN 被拦截 + +Surge 请求记录确认 `ffsb-service` 的 `stun.cloudflare.com:3478` 请求命中 `PROTOCOL,STUN,REJECT`。仅豁免服务器 IP 无法覆盖该连接。经实际进程路径核对,新增高优先级规则: + +```ini +IP-CIDR,185.180.19.82/32,DIRECT,no-resolve +PROCESS-NAME,/Applications/FFSB.app/Contents/MacOS/ffsb-service,DIRECT +``` + +新 IP 来自 FFSB 设置页的当前服务器说明。进程规则使服务自身的隧道与 STUN 连接直连,不更改其他应用的 STUN 拦截或 Claude 域名路由。保留此前域名 fake-IP 豁免;进程规则不能替代 DNS 阶段的真实 IP 返回。 + +本次生产备份 `data/backups/before-ffsb-process-direct-20261019-105916.db` 已确认 root/600。规则 270 → 272,既有规则顺序保持;实际 HTTPS 订阅与规则集快照通过官方 Surge CLI。更新本机后以相同 STUN 目标和协议、不同进程路径核验:FFSB 服务 DIRECT,Chrome REJECT。FFSB 主界面随后显示住宅出口与 589 ms 延迟。该即时结果不代表长期稳定性验证;Clash 原生校验仍缺 mihomo。 + +参考:[Surge 进程规则](https://manual.nssurge.com/rules/process.html)。 + +## 修正:符号链接路径与真实进程路径 + +此前从 `ps` 取得 `/usr/local/bin/ffsb-service`,但该路径是符号链接,指向 `/Applications/FFSB.app/Contents/MacOS/ffsb-service`。Surge 原始请求 `#50934` 记录后者,导致前次完整路径例外未匹配,真实 STUN 仍被 REJECT。前次通过手工传入符号链接路径的 rule match 只验证了假设,不能作为真实流量已修复的依据;当时业务出口成功也不能证明 STUN 放行。 + +生产原规则已定向修改为上文真实路径,规则数仍为 272、顺序与其他字段保持。备份 `data/backups/before-ffsb-real-process-path-20261019-110335.db`,root/600。实际 HTTPS 订阅和规则集快照通过官方 Surge CLI,刷新托管配置并重连 FFSB 后,新请求 `#51133` 到 `stun.cloudflare.com:3478` 命中真实路径的 PROCESS-NAME DIRECT,rejected=false,发送 20 字节、收到 32 字节;隧道请求亦有双向流量。其他进程的同一 STUN 模拟请求仍 REJECT。客户端显示已连接、25/25 域名、4734 ms 延迟,出口 IP 栏为“手动查看”,未据此声称出口 IP 已重新验证。 + +后续进程规则应核对符号链接目标并从 `surge-cli --raw dump request` 的 `processPath` 取得真实路径;使用新连接的真实规则与收发数据验收,而非仅依赖 `ps` 或模拟匹配。 diff --git a/docs/FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md b/docs/FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md new file mode 100644 index 0000000..1ee2c74 --- /dev/null +++ b/docs/FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md @@ -0,0 +1,106 @@ +# FFSB 手动选择与统一 Surge 订阅 + +本方案替代同日早先的 `Proxy Station Claude` 本机协调层。最终状态、验证结果见下文;旧实现文档保留为历史记录。 + +2026-11-02 本机后续已完成 [Surge 原生 WireGuard 独立验证](FFSB-WIREGUARD-SURGE-2026-11-02.md),并于同日 11:05 按用户要求回退本文的正式 `Proxy Station`+FFSB 组合,24/24 请求验收通过。原生 WG 试验配置与身份仅保留在本机私有归档,未改生产订阅。本文的 utun100/本机 DNS 前提描述组合方案,不代表原生 WG 必须运行 FFSB;两种方式复用同一身份,不可并行连接。 + +## 原理与前提 + +业务配置由正式订阅统一生成,本机仅使用 `Proxy Station`。可见的 `🦀 Claude` 为手动 select,原有国家和 AI Nodes 成员保持不变,新增 `🦁 FFSB`。不使用自动 fallback,不运行定时探测/配置重写脚本。FFSB 不可用时选中它应失败;用户自行改选 Surge 节点。 + +FFSB 0.1.25 的 Go 构建信息表明它基于 `github.com/netbirdio/netbird`,基线 `v0.75.1-0.20260920041446-3ee1e3889f9c+dirty`。依赖包括 WireGuard userspace 实现、Pion ICE/STUN/TURN、gRPC、QUIC。这证明其技术组成,不证明每种协议当前都在使用。实测状态中有 P2P 连接、host/srflx ICE 候选及 WireGuard 握手;本机使用 utun100,地址 100.70.160.98,本地 DNS 为 100.70.255.254。不能把它当成一个已有本地 SOCKS/HTTP 监听端口的普通代理。 + +原架构的问题是 FFSB 的系统分域 DNS 和真实 IP 主机路由可在 Surge 接管前接走流量。改动前,同一 api.ipify.org 请求,普通系统入口出口为 138.75.58.11,显式 Surge 入口为 179.253.249.56,即使 Claude 选择的是 Surge。单加一个选项不足以控制所有入口。 + +新的必要前提是本机 FFSB **持久设置 `disableDns=true`**,并重连一次使其生效。它停止修改系统 DNS,但仍提供内部 DNS 服务及客户端路由。只执行 up 修改设置而不重连,本机实测旧分域 resolver 仍存在;down/up 后才消失。没有关闭客户端路由,否则接口无法承接目标地址。 + +正常系统 DNS 由 Surge 增强模式返回 fake-IP,业务连接因此先进入 Surge;不为 25 项配置 always-real-ip。Surge 完成域名规则匹配后: + +- 选 `🦁 FFSB`:direct 别名绑定 utun100,`allow-other-interface=false`;25 项的 Host 映射交给 100.70.255.254,`dns-follow-interface=true` 绑定查询接口。FFSB DNS 为域名资源解析并维护路由,随后连接走 WireGuard。 +- 选国家或 AI Nodes:由远端代理解析目标域名,不依赖 FFSB DNS。即使 FFSB 已关闭,fake-IP 接管和远端解析仍可用。 +- 25 项之外:所有原 Claude 分类规则(包括其 1.0.0.1 DoH 传输规则)转给隐藏 `Claude-Surge-Internal`,它只引用现有 `🧠 AI Nodes`。用户已明确要求这些地址始终走 Surge;这个内部组不能选择 FFSB。 +- FFSB 外层:实际 daemon 进程的 DIRECT 规则在业务规则之前,避免再套远端代理。其他应用的 STUN 防护不删除。 + +因此,连接出口和 DNS 无需靠脚本同步切换;它依赖 Surge 的 fake-IP 与代理远端解析机制,以及 FFSB 的一次性客户端设置。不是“任意设备仅导入订阅、不配置客户端即可自动就绪”:其他 Mac 须确认接口/DNS 地址及 disableDns;iOS 共存、不同 FFSB 版本未验收。 + +## 官方依据 + +- [Surge direct 别名](https://manual.nssurge.com/policies/built-in.html):允许在 Proxy 中声明带接口绑定的 direct 策略。 +- [Surge 参数](https://manual.nssurge.com/policies/parameters.html):接口须具备目标路由;不允许其他接口时不可用即失败;dns-follow-interface 只控制查询接口,不能等同于动态选择 DNS 服务器。 +- [Surge DNS](https://manual.nssurge.com/dns/advanced.html):fake-IP 将客户端连接还原为域名,再匹配出站策略。 +- [本地与远端解析](https://kb.nssurge.com/surge-knowledge-base/technotes/dns):代理请求默认交给远端解析。 +- [Host 映射](https://manual.nssurge.com/dns/local-dns-mapping.html):本地查询按首条映射选服务器;不是按组选项动态修改 Host。 +- [隐藏策略组](https://manual.nssurge.com/policy-groups/parameters.html):hidden=true 隐藏菜单中的内部组。 +- [NetBird CLI](https://docs.netbird.io/get-started/cli):disable-dns 停止配置系统 DNS;本机 FFSB help 也提供该参数。 +- [NetBird 域名资源](https://docs.netbird.io/manage/networks/how-routing-peers-work):本地 DNS 转发至路由节点,路由节点解析域名。FFSB 是修改版本,最终依据本机结果,不假定上游全部行为一致。 + +## 实现与部署 + +`server/src/services/generators/surge-ffsb.ts` 是 Surge 序列化适配,读取 IR 内生产 settings 的 `FFSB` 配置;不另行查库、不修改原 IR,也不影响 Clash/Shadowrocket。配置字段为 enabled、interface、dnsServer、extraPolicy。25 项唯一源为 `server/assets/ffsb-domains.json`;原 `rules/ffsb-domains.json` 为指向它的相对符号链接。 + +适配器保留原成员、只追加 🦁 FFSB;置顶精确 25 项规则,原合并范围继续隐藏分流;有名称冲突、无效接口或内部组循环依赖时拒绝生成。生产原有 14 组/277 规则不需要改写,适配规则由生成器下发。 + +生产写入前在线备份:`/opt/proxy-station/data/backups/before-manual-ffsb-selection-20261019-143021.db`。部署脚本会更改目录属主,因此部署完成后重新检查并恢复此备份 root:root、600。 + +本机已停用并移出 LaunchAgents 的 `com.proxystation.ffsb-overlay.plist`;`Default.conf` 和 `Proxy Station Claude.conf` 已移入私有归档,未删除。Surge 配置列表只保留 `Proxy Station`。归档位于 `~/Library/Application Support/ProxyStationFFSB/manual-migration-20261019-142442/`。 + +## 验证 + +迁移前两次可回滚试验已验证: + +| 条件 | 普通系统入口 | 显式 Surge 入口 | +|---|---|---| +| FFSB 开启、DNS 接管关闭、选 FFSB | 138.75.58.11 | 138.75.58.11 | +| FFSB 开启、DNS 接管关闭、选 AI Nodes | 179.253.249.56 | 179.253.249.56 | +| FFSB 关闭、选 AI Nodes | 成功,179.253.249.56 | 成功,179.253.249.56 | +| FFSB 关闭、选 FFSB | 超时失败 | 超时失败 | + +Surge 请求 #58762/#58763 显示两种入口绑定 utun100;#58765/#58766 显示两种入口经美国节点 en0。FFSB 关闭失败不自动改选,符合手动模式。 + +类型检查通过;全量 75 个测试通过、1 个原有用例跳过。新增 5 个测试验证精确范围、DNS/规则顺序、唯一可见组、IR 不变、其他客户端无变化、参数和循环保护、原生解析。正式候选与 HTTPS 产物均通过官方 CLI,49/49 规则集下载并通过快照校验。13 个域名遮蔽提示对应有意置顶的 25 项覆盖较宽 Claude 分类,保留集合外规则,不将它们扩进 FFSB。 + +最终正式订阅(🦁 FFSB 命名)已再次验证上述四种组合,并恢复至 FFSB 开启、🦀 Claude 选择 🦁 FFSB: + +- 当前 FFSB CLI Selected 清单恰好 25 项,与源清单集合相等;25/25 原生规则解释都进入 🦁 FFSB(通配项用匹配样本验证规则,不等同于该样本有业务服务)。 +- 18 个精确域名中 16 个完成 TLS/HTTP 传输;`claudemcpclient.com` 与 `claudemcpcontent.com` 请求 #59298/#59299 命中正确策略,但 FFSB DNS 返回空答案,未算连接通过。HTTP 403/404/426 只代表传输和根路径响应,不是登录或模型调用成功。 +- 4 个真实通配样本 www.anthropic.com、platform.claude.com、bridge.claudeusercontent.com、c.ffsb.sg 完成传输;其余通配范围没有声称穷举验收。 +- 官方 API #59292:🦁 FFSB、utun100。两种入口 #59287/#59289 均为 FFSB 出口。系统 lookup 返回 fake-IP 198.18.2.126,Surge DNS trace 则经 100.70.255.254 返回真实 160.79.104.10,说明应用接管与隧道解析分层生效。 +- 集合外 #59315 downloads、#59316 Auth0、#59317 Datadog 均经隐藏策略走 Surge;#59318 GitHub 与 #59319 ChatGPT 保持各自 Proxy/AI 分流。 +- 手动选美国、新加坡、日本,普通系统请求分别得到 179.253.249.56、54.254.42.195、104.28.243.105,全部成功;结束后恢复 🦁 FFSB。 +- FFSB 外层 #59344 UDP/51820:DIRECT、en0,双向有字节;STUN 命中 DIRECT,未被全局 STUN 阻断。单条 STUN 样本无下行不据此声称该次 STUN 成功。 +- 其他现有策略组选择与变更前一致,仅 🦀 Claude 改为 🦁 FFSB,新增隐藏内部组;FFSB 有效配置仅 disableDns 从 false 改为 true。系统代理仍关闭、增强模式仍开启,未重启 Surge 引擎。 +- 最终 emoji 更新后的 HTTPS 订阅再次通过原生校验、49/49 规则集快照校验。一次规则集拉取失败后复测成功,不将失败结果当通过。 + +私有证据归档到 `~/Library/Application Support/ProxyStationFFSB/manual-migration-20261019-142442/evidence/`,含原生请求日志、DNS trace、四象限结果、生产前后快照和部署日志;文件权限 600、目录 700,不提交 Git。实际模型业务、启动顺序及长期场景未纳入本次完成声明。 + +## 边界与恢复 + +后续故障(同日约 14:46):用户报告 GUI 检测失败,实际 curl 对 API、join、ipify 也超时。前台 `ffsb` 的检测请求命中业务策略 🦁 FFSB/utun100,但只有上行;后台 `ffsb-service` 的控制、WireGuard 和 STUN 仍命中 DIRECT/en0。直接指定真实 IP 并绑定 utun100 的请求同样失败;Go 默认 TLS 与经典曲线对照均超时,不支持仅为大 TLS 握手问题的假设。绕过的是内层业务接管,外层 UDP 仍经 Surge,因此尚未定位是 FFSB 状态、远端还是外层转发问题。管理/信令 Connected 不等于业务可用,早先验收仅代表当时状态。此次未修改配置或重连,证据归档于上述私有迁移目录的 `failure-followup-1446/`。 + +### 追加故障排查:新连接 TLS 间歇性停顿 + +同日继续排查,**尚未完成根因修复**。不能把前述迁移验收或暂时恢复视为稳定性通过。服务为第三方运营,用户确认没有服务端权限。 + +| 对照 | 实测结果与结论边界 | +|---|---| +| 外层进程与业务进程 | `ffsb-service` 的管理连接、WireGuard UDP/51820 命中 DIRECT/en0;前台 `ffsb` 的官方地址检测命中 🦁 FFSB/utun100,属于预期内层流量,不是仅凭策略名称就能判定的回环 | +| 仅排除两台外层服务器的 TUN 路由 | 临时排除 185.180.19.201/32、147.93.172.146/32;实际路由为 en0,重连后 3 个 HTTPS 目标仍超时;已恢复正式配置,没有保留排除项 | +| 系统 DNS 管理开关 | 临时启用再关闭并重连曾恢复;随后的同条件对照不能复现,之后再次失败;故不能归因为 disableDns 或 DNS 缓存 | +| 故障时直接查询 FFSB DNS | 可返回真实地址,随后 TLS 仍失败;单纯刷新 DNS 不能修复 | +| 绕过 Surge 内层接管 | curl 指定真实目标 IP 并绑定 utun100,同样失败;这不代表绕过了外层 Surge 转发 | +| TCP 抓包 | 15:00:08 的 API 新连接完成 SYN/SYN-ACK/ACK,327 字节 ClientHello 获对端 ACK,此后未收到 TLS 响应;同时其他既有 API 连接仍收发数据。未解密 TLS,也未调用计费模型 API | +| MTU 对照 | utun100 从 1280 临时降为 1200,新 SYN 的 MSS 从 1240 降到 1160,HTTPS 仍失败;已恢复 1280,未持久修改 MTU | +| TLS 实现与版本 | Go 默认/经典曲线和 curl 均出现超时;TLS 1.2 也失败。不能认定仅为 Go 的后量子 ClientHello 大小问题 | +| HTTP/HTTPS 对照 | 同一阶段 HTTP 曾成功,而 API、ipify、assets、pivot、ifconfig 多个 HTTPS 超时;HTTP 随后也有超时,不能断言故障严格只限 HTTPS | +| 延长一次观测超时 | API 最终 18.53 秒完成 TLS、18.72 秒收到 HTTP 404,超过 GUI 的 10 秒预算;这是延迟证据,不是修复,也未修改客户端检测超时 | +| TCP ECN | 仅测试 socket 上分别默认、关闭和开启 ECN,后来均成功;随后原 Go 测试也自行恢复,不能据此更改全机 ECN 设置 | + +本机证据将问题收窄到 FFSB 数据路径的新连接/TLS 响应阶段;尚不能进一步区分客户端实现、外层传输、路由节点转发或其上游。需要服务商在 `b5-primary.ffsb.internal`(100.70.254.2,对外 185.180.19.201:51820)侧关联同一时间的抓包与转发日志。TCP ACK 可能由中间转发组件生成,不能仅凭它认定最终官方服务器已收到 ClientHello。[NetBird 官方排障](https://docs.netbird.io/help/troubleshooting-resource-connectivity)同样要求把 peer 连通、资源 TCP 连接及应用响应分层检查。 + +恢复后的最后观测窗口为 15:07:54—15:09:42(UTC+8):6 轮 × 4 路径共 24/24 完成 HTTP 响应,其中 FFSB 系统入口、显式 Surge 入口、真实 IP 绑定接口各 6 次,GitHub 普通 Surge 对照 6 次。官方 API 的 404 仅证明传输成功。原生请求 #61197 为 API → 🦁 FFSB/utun100,#61196 为 ipify → 🦁 FFSB/utun100,#61198 为 GitHub → US LA VMess Direct/en0。此窗口不足两分钟,不构成长时间稳定性验收;故障窗口中的失败没有被后续成功覆盖。全部原始证据归档到上述迁移目录的 `failure-followup-1510/`,附 SHA-256 清单。 + +本轮不重启 Surge、不关闭增强模式、不改生产规则;临时 profile、路由排除、系统 DNS 试验与 MTU 都已恢复。最终 FFSB resolved config 与排查前逐字段一致,旧协调 LaunchAgent 未加载。抓包进程已停止。原始日志和诊断包只留本地,不上传第三方。GUI 自动化工具不可用,最后的界面检测状态未重新验收。 + +本机普通域名客户端和显式代理入口是验收范围。硬编码真实 IP、私有 DoH、先前缓存的真实地址或旧长连接可能绕过 fake-IP,选择不会迁移旧连接;必要时重新建立应用连接。未对真实计费模型流、全部通配子域、长期运行、睡眠换网、IPv6 数据路径或其他设备宣称验收完成。 + +恢复旧客户端 DNS 管理需 `ffsb-service down` 后 `ffsb-service up --disable-dns=false`,不能仅改配置就断言生效。恢复生产功能开关通过管理 API 将 FFSB.enabled 改为 false 并更新订阅;不能只恢复系统 DNS、却误以为仍有统一手动入口。旧脚本和配置仅用于调查/回滚,不重新自动启用。 diff --git a/docs/FFSB-SURGE-AUDIT-2026-10-19.md b/docs/FFSB-SURGE-AUDIT-2026-10-19.md new file mode 100644 index 0000000..ced4288 --- /dev/null +++ b/docs/FFSB-SURGE-AUDIT-2026-10-19.md @@ -0,0 +1,146 @@ +# 本机 FFSB / Surge / Claude Code 网络审计 + +审计日期:2026-10-19。只读采集和无凭据网络探测;本轮未修改生产数据、客户端配置、DNS、路由或开关,未重启服务。用户已明确不关闭增强模式,故未执行启动顺序切换实验。 + +## 范围与结论 + +以 FFSB 当前下发的 **25 项域名规则**为唯一业务边界,不要求缩减其中的查 IP 和 FFSB 网站;其余流量继续遵循 Surge。Claude Code 的官方业务流量属于 FFSB 范围,GitHub、npm、第三方 MCP 与工具目标继续按 Surge 规则。用户已说明会将 Claude Code 的 Base URL 恢复官方,本报告不展开现有本机 API 网关。 + +**稳态下分流已基本成立;“所有启动顺序、断线与恢复阶段都只经 FFSB”尚未具备充分保障,不能宣称完成。** 现有配置有可复现的另一条出口路径:将 Anthropic 请求显式交给 Surge,会匹配 AI 组并通过其远端代理;直接 DNS 查询也可能绕过 FFSB 分域解析而得到 fake-IP。 + +| 用户要求 | 证据与判定 | +| --- | --- | +| FFSB 接管维护的 25 项 | 25/25 均有对应系统分域 DNS 范围;18 个精确域名中 16 个有地址且实际走 utun100、完成 HTTPS;2 个无 A 记录。通配规则不能靠根域探测宣称所有动态子域已验收 | +| Claude Code 官方业务只走 FFSB | 系统解析及本机独立 Bun 默认解析/HTTPS 路径成功;尚未恢复官方 Base URL 后进行真实 Claude Code 登录、模型生成或流式请求验收 | +| Surge 保留其他分流,不将 FFSB 套进远端代理 | FFSB 服务真实请求命中进程 DIRECT;GitHub 实际请求通过 Surge 原代理成功。但外层加密隧道仍由 Surge VIF 接管再 DIRECT 转发,并非完全绕过 Surge | +| 启动顺序无关、断线不串线、长期稳定 | 两端自动启动/连接配置存在,但没有就绪协调或本次查验可确认的专属出口约束;没有进行双启动顺序、睡眠唤醒或长连接测试。当前不能保证 | + +## 本机状态及配置来源 + +- Surge 6.10.0,活动配置 Proxy Station,rule 模式;增强模式开启,系统代理关闭。`scutil --proxy` 也未显示 HTTP/HTTPS 系统代理启用。 +- Claude Code 2.1.292,通过 Homebrew 安装;本次主要证据采集时未发现正在运行的 Claude Code 主进程。独立 Bun 1.4.2 探测不等于已测试 Claude Code 内嵌运行时及实际认证业务。 +- FFSB daemon 0.1.25;有效设置为 `interfaceName=utun100`、MTU 1280、`disableAutoConnect=false`、`networkMonitor=true`、`disableDns=false`、`disableClientRoutes=false`、`disableIpv6=true`。状态中的 lazy connection 与有效配置读数曾有差异,不将其作为就绪保证。 +- FFSB 服务与 GUI、Surge 均有 enabled/allowed 的系统后台/登录项。FFSB launchd 配置 `KeepAlive=true`、`RunAtLoad=false`;不应只看后者便断言不自启,也不能由 KeepAlive 推出业务已就绪。 +- 已检查用户及本项目 Claude 设置、常用 shell 配置和 launchctl 网络环境;过滤本次 Codex 会话注入变量后,新 fish/zsh 交互 shell 未发现显式 HTTP/HTTPS/ALL/NO_PROXY。不能将此推广为所有已打开终端、IDE、任意项目或后续进程环境。 +- 生产 API 重读:272 条规则,FFSB 服务真实路径的进程例外、3 个服务器 IP 例外及 bootstrap 域名直连均在现有配置;本轮未增删。`link.fufeishangban.com` 在 always-real-ip 中。 +- Tailscale 同时 Running;FFSB 的 `100.70.0.0/16` 落在 Tailscale 常用的 `100.64.0.0/10` 内。当前已知 Tailscale peer 未落入该 /16,因此未发现现有 peer 冲突;未来新增 peer/路由仍需检查。 +- 当前 en0/en1 未发现全局 IPv6 地址,FFSB 禁用 IPv6,Surge `ipv6=false`、`ipv6-vif=auto`。这仅是当前网络状态,不能替代以后在有公网 IPv6 网络上的验收。 + +## 当前链路及其边界 + +使用系统解析器访问清单内可解析域名时: + +```text +应用 → macOS 分域 DNS → FFSB 本地 DNS 100.70.255.254 + → 按解析结果建立/使用 utun100 目标路由 + → FFSB 加密隧道 → 专线出口 → 目标服务 + +FFSB 加密隧道的外层连接 + → Surge utun5 → PROCESS-NAME /Applications/FFSB.app/Contents/MacOS/ffsb-service + → DIRECT → 物理网络 → FFSB 服务器 + +清单外普通目标 → Surge DNS/utun5 → 原有规则与策略 +``` + +`DIRECT`、系统代理绕过、VIF 路由排除是三件不同的事。当前 DIRECT 已证明避免 FFSB 外层走 Surge 远端节点,但 Surge 仍处理其包转发;所以不能说 Surge 完全不影响 FFSB。`utun5` 出现在外层服务器路由中,也不能单独证明隧道被远端代理。 + +macOS 的 `resolver(5)` 规定分域解析选择最长匹配后缀。系统查询正常时,FFSB 的 specific resolver 胜过 Surge 默认 DNS。NetBird 官方说明域名路由使用本地 DNS forwarder,并按查询结果增加目标路由;本机现象与之相符。但 FFSB 是定制客户端,不能把所有上游 NetBird 功能当作其已支持的功能。 + +证据:[NetBird DNS](https://docs.netbird.io/manage/dns)、[域名路由](https://docs.netbird.io/manage/networks/how-routing-peers-work)、[DNS 排查](https://docs.netbird.io/manage/dns/troubleshooting)、本机 `man 5 resolver`。 + +## 域名清单与实际检查 + +7 项通配规则:`*.anthropic.com`、`*.clau.de`、`*.claude.com`、`*.claudemcpclient.com`、`*.claudemcpcontent.com`、`*.claudeusercontent.com`、`*.ffsb.sg`。全部存在对应根域的系统分域 resolver。 + +以下为 18 个精确域名的无凭据 HTTPS 根路径探测。HTTP 403/404 只能证明 TLS/HTTP 可达,不代表登录、模型调用、内容权限或完整业务通过。 + +| 域名 | 解析/路由 | HTTPS 状态 | +| --- | --- | --- | +| a.claude.ai | utun100 | 404 | +| anthropic.com | utun100 | 301 | +| api.anthropic.com | utun100 | 404 | +| api.ipify.org | utun100 | 200 | +| assets.claude.ai | utun100 | 403 | +| clau.de | utun100 | 302 | +| claude.ai | utun100 | 403 | +| claude.com | utun100 | 200 | +| claudemcpclient.com | 无 A 地址 | 未执行 HTTPS | +| claudemcpcontent.com | 无 A 地址 | 未执行 HTTPS | +| claudeusercontent.com | utun100 | 301 | +| ffsb.sg | utun100 | 200 | +| icanhazip.com | utun100 | 200 | +| ifconfig.me | utun100 | 200 | +| ip.net.coffee | utun100 | 200 | +| join.fufeishangban.com | utun100 | 302 | +| pivot.claude.ai | utun100 | 200 | +| www.claude.ai | utun100 | 301 | + +两个无地址 MCP 根域:FFSB DNS 返回 NXDOMAIN,公共 DNS 返回 NOERROR/空 A 回答。二者响应语义不同,不能声称 DNS 答案完全一致;但两边都无 A 地址,不能将根域 HTTPS 无法探测认定为隧道故障。仍需有效的动态子域样本验收,未通过猜测或采集用户会话内容来制造样本。 + +已另行核对 `platform.claude.com`、`mcp-proxy.anthropic.com`、`bridge.claudeusercontent.com` 的系统解析及路由,均为真实 IPv4/utun100,验证了相应通配域的一部分范围。 + +`downloads.claude.ai` 不在这 25 项内,实测为 fake-IP/utun5,符合 FFSB 客户端界面的安装包/更新例外。此处不扩展 FFSB 清单。域名相同 IP 时,系统 IP 路由可能一并覆盖该 IP 上的其他域名,因此这种模式并非逐 HTTP Host 的严格隔离。 + +## 已证实的旁路与尚缺的保障 + +### 1. DNS 路径不同会改变结果 + +同一时段、同一域名 `api.anthropic.com`: + +- Python/system getaddrinfo、Node `dns.lookup`、独立 Bun 默认/system backend:真实 IP,目标路由 utun100。 +- Node `dns.resolve4`、Bun c-ares backend:Surge `198.18.*` fake-IP。 +- 独立 Bun 默认 fetch 两次均完成 HTTPS,约 488/370 ms。 + +这不是断言当前 Claude Code 默认使用 c-ares;它说明现有分域接管依赖解析路径,不能凭规则列表保证所有调用方式。Node 与 Bun 官方文档明确区分系统解析器和直接 DNS 后端。浏览器 Secure DNS、显式代理和其他运行时也应分别验证。 + +参考:[Node DNS](https://nodejs.org/api/dns.html)、[Bun DNS](https://bun.sh/docs/runtime/networking/dns)。 + +### 2. 显式代理可实际绕过 FFSB + +本次只对单次无凭据 curl 请求指定已有 `127.0.0.1:6152`,未修改任何系统或应用代理配置。实际 Surge 请求 `#51640` 的 `api.anthropic.com:443` 命中 `RULE-SET ai.conf`,通过 AI 组的美国 Trojan 节点,完成 HTTP 请求。不是仅靠 rule match 推测。 + +因此,如果 Claude Code 后续继承 HTTP/HTTPS_PROXY,或其他客户端使用 Surge 系统/显式代理,当前配置允许清单内目标改走 Surge。新 shell 当前没有这些变量,并不构成禁止以后使用其他出口的机制。Claude Code 官方文档确认读取代理环境变量,并支持 NO_PROXY;既有进程不会随 shell 后续修改自动更新。 + +参考:[Claude Code 网络配置](https://code.claude.com/docs/en/network-config)。 + +### 3. 没有完整的启动/断线闭锁 + +自动启动不等于以下步骤已完成:管理连接、peer 握手、分域 DNS 注册、域名路由建立、真实 HTTPS 可用。FFSB 未就绪或退出时,Surge 的 AI 域名规则仍在;缺少 FFSB 路由/解析条件时,存在切换出口的配置路径。未实际关闭 FFSB 或 Surge 来验证该场景,不能将推断写成已实测断线结果。 + +本轮没有验证“Surge 先启动”和“FFSB 先启动”的完整矩阵,也没有运行 Claude Code 真实登录/流式业务、长时负载、网络切换或睡眠唤醒。此前反复出现超时后重新检测成功,根因没有被严格隔离;不能以成功重测认定已经永久修复。 + +### 4. 其他分流及服务自身流量正常 + +FFSB 服务真实请求的完整路径与生产进程规则一致,STUN 有响应;通过相同 STUN 目标、不同进程路径模拟,Chrome 仍为 REJECT。普通 GitHub 请求 `#51650` 命中 GitHub 规则集并通过 Surge 原代理,HTTP 200。Tailscale 的既有规则和本机选择未修改。 + +## 建议实施方向:不是继续扩大服务器网段 + +以下为待实施/待验收方案,不是本次已经部署的结果。 + +1. 保留已验证的 FFSB 服务进程 DIRECT、bootstrap 域名真实解析和已有服务器例外。服务器地址变化应主要由进程匹配覆盖,不靠放行宽网段。不要把 Claude Code 全进程设置为裸 DIRECT,这既不能强制使用 FFSB,还会改变清单外工具流量。 +2. 以这 25 项维护单一清单,核对系统分域 DNS 与 Surge 内部 DNS 的一致性。若需要覆盖被 Surge 捕获的路径,候选做法是为该清单明确配置 FFSB DNS,并配合 always-real-ip;不把全局 DNS 改成 FFSB,不将 FFSB 控制服务器解析交回依赖自身隧道的 DNS。需验证解析失败时不自动落到公共 DNS。 +3. 对已进入 Surge 的清单内请求,增加“只交给 FFSB 接口、不可回落”的策略,而非普通 DIRECT。例如官方支持 `direct, interface=utun100, allow-other-interface=false`。**这是候选机制,未在本机启用或通过启动矩阵验证**;必须先保证目标路由由 FFSB DNS 建立,核对接口实际存在及路由有效。仅靠这一项不保护 Surge 自身也未启动的阶段。 +4. 若需求是“任何时刻都不可从别的出口发送”,还需要独立于应用启动顺序的约束:FFSB 未就绪则等待/拒绝,断开后停止新请求,并验证既有连接。启动前一次探测或普通脚本包装不能提供持续保障。应选择并验证本机持久流量限制或仅能通过 FFSB 出站的专用入口,而不是只依赖自动启动顺序。 +5. 该类接口/DNS 绑定属于这台 Mac 的配置,应使用本机专用配置/模块,不能未经评估就向所有共享订阅设备下发 `utun100` 和该机的 FFSB DNS 地址。 + +Surge 官方依据:[VIF 排除与系统代理](https://manual.nssurge.com/profile/general.html)、[Host 首条匹配/分域 DNS](https://manual.nssurge.com/dns/local-dns-mapping.html)、[真实 IP 例外](https://manual.nssurge.com/dns/advanced.html)、[指定接口与禁止其他接口回落](https://manual.nssurge.com/policies/parameters.html)。这些说明验证机制存在,不能代替本机端到端测试。 + +切勿向 `tun-excluded-routes` 机械添加 Claude 目标公网 IP 或整个 `100.64.0.0/10`:本项目已有物理网关排除覆盖 VPN 路由的实测教训,见 [Surge/Tailscale 共存](SURGE-TAILSCALE.md)。FFSB 外层公网服务器和 FFSB 内层业务目标必须分开处理。 + +## 完整验收矩阵 + +| 场景 | 必须核对 | +| --- | --- | +| Surge → FFSB → Claude Code | 25 项 DNS/路由就绪,真实官方认证与模型请求走 FFSB;清单外仍按 Surge | +| FFSB → Surge → Claude Code | Surge 上线不改写/绕过已建立的 FFSB 业务路径;同上 | +| Claude Code 先启动、FFSB 后上线 | 上线前等待或明确失败;不得先走 Surge/ISP,恢复后使用 FFSB | +| FFSB 断开或 daemon 崩溃 | 新请求不换出口;既有连接的行为有记录;恢复后的连接重新校验 | +| Surge 重载托管配置/重启 | 本机覆盖仍存在,FFSB DNS/路由正常、控制与 STUN 不被拦截 | +| 睡眠唤醒、切换网络、有公网 IPv6 | 无 DNS/路由残留和其他出口回落,第三方流量仍正常 | +| 长时间 Claude 流式请求 | 不仅根路径 HTTP 成功;验证实际流、持续连接、重试和出口 | + +## 证据保存与限制 + +敏感快照仅位于本机私有目录 `/private/tmp/network-audit-20261019/`,不提交 Git。这里仅记录脱敏结论及公开域名。运行时路由与 IP 可变化,后续实施前应重新采集。 + +本轮没有改网络,因此不存在本轮生产变更备份或“已部署修复”声明。先前进程路径修复与备份见 [FFSB 直连记录](FFSB-DIRECT-2026-10-19.md)。 diff --git a/docs/FFSB-SURGE-CHECK-2026-11-02.md b/docs/FFSB-SURGE-CHECK-2026-11-02.md new file mode 100644 index 0000000..c1b7568 --- /dev/null +++ b/docs/FFSB-SURGE-CHECK-2026-11-02.md @@ -0,0 +1,55 @@ +# 本机 Surge + FFSB 检查(2026-11-02) + +本次为只读检查,未修改生产数据、托管配置、策略选择或客户端设置,未重连 FFSB、未重启 Surge。依据当前本机状态,不沿用 10 月 19 日的连接验收结果。 + +## 当前状态 + +- Surge 6.11.0 build 12670,活动配置为 `Proxy Station`,规则模式、增强模式开启,系统代理关闭。 +- `🦀 Claude → 🦁 FFSB`;FFSB direct 策略绑定 utun100、启用 dns-follow-interface。有效配置省略 allow-other-interface,官方默认 false,因此省略本身不改变失败时不回落的设置。[官方参数说明](https://manual.nssurge.com/policies/parameters.html) +- 集合外 Claude 规则经 `Claude-Surge-Internal → 🧠 AI Nodes → US LA Trojan Direct`;AI 同样选择该美国节点。普通 Proxy 选择 Japan → JP Trojan Direct。 +- FFSB CLI/daemon 均为 0.1.27;有效设置为 disableDns=true、disableClientRoutes=false,utun100 MTU 1280。 +- 系统默认 resolver 是 Surge 的 198.18.0.2,没有 FFSB 的系统分域 resolver。系统查询 API 返回 fake-IP;Surge DNS trace 使用 100.70.255.254 返回真实地址。既定 25 项未加入 always-real-ip。 +- FFSB daemon 状态为 Connected,4 个 peer Connected、1 个 Connecting。资源所在的 a6 peer 为 P2P。此状态单独不能证明业务成功。 + +## 实际请求与规则 + +约 09:57(本机 UTC+8)新建 8 个无鉴权 HTTPS GET,全部完成 TLS 并收到 HTTP 响应: + +| 请求 | 结果 | 原生请求记录中的实际策略/接口 | +|---|---|---| +| api.anthropic.com 根路径 | 404,约 0.34 秒 | #358,FFSB/utun100 | +| api.ipify.org 系统入口 | 200 | #357,FFSB/utun100 | +| api.ipify.org 显式 Surge 入口 | 200,与系统入口出口相同 | #359,FFSB/utun100 | +| join.fufeishangban.com | 302 | #360,FFSB/utun100 | +| claude.ai 根路径 | 403 | #361,FFSB/utun100 | +| github.com | 200 | #362,日本代理/en0 | +| downloads.claude.ai | 403 | #363,隐藏 Claude 组 → AI Nodes → 美国代理/en0 | +| http-intake.logs.datadoghq.com | 403 | #364,Proxy → 日本代理/en0 | + +后者是普通 Datadog 地址;另外原生规则解释确认 Claude 使用的 `http-intake.logs.us5.datadoghq.com` 及 `anthropic.auth0.com` 都经隐藏 Claude 组转美国 AI 节点。现有 ChatGPT 活动请求也走 AI Nodes/美国代理。FFSB daemon 的活动控制连接 #34/#35/#37 命中实际进程路径 DIRECT/en0,均有双向字节;本次未另行抓包验证外层 UDP。 + +源清单 25/25 项通过 Surge 原生规则解释进入 FFSB;通配项使用样本名,仅验证规则匹配,不证明全部子域业务可用。有效配置通过官方 CLI 原生解析。 + +`scripts/check-surge.py --fetch-rulesets` 的两次运行均通过原生配置、唯一末尾 FINAL、组测速参数、控制口令与 MITM 检查,但均在远程规则集下载阶段失败,未通过全量在线快照校验。不能将当前缓存下的规则匹配成功表述为全部上游规则集当前可下载;本轮未进一步定位具体失败源。 + +## 新发现的范围差异 + +FFSB 当前 Selected 资源为 27 项,比本项目既定 25 项新增 `hcaptcha.com` 与 `*.hcaptcha.com`。Surge 当前没有将其加入 FFSB:根域命中 FINAL → Mainland → DIRECT,`newassets.hcaptcha.com` 命中 CDN → Proxy → 日本。这说明客户端上游资源清单已变化,不代表原有 25 项失效,也不能仅凭新增资源推断用户已授权扩展分流范围。本次保留原规则,未修改生产设置或清单。 + +## 验收边界 + +本轮证明当前观测窗口内的既定分流和新建 HTTPS 传输可用。404/403 不代表模型调用、登录或验证码业务成功。Surge 启动后的早期 GUI 请求记录曾有仅上行、无下行的连接;后续成功不覆盖这段历史,也不证明间歇性问题已修复。本次没有切组、断开 FFSB、测试计费模型流,或验证睡眠、换网与长时间稳定性。 + +含凭据的有效配置与原生请求原始记录仅存于本机 `/private/tmp/surge-ffsb-audit-20261102/`(目录 700、文件 600),不入 Git。 + +## 追加:FFSB 客户端 DNS 劫持横幅 + +用户随后提供 FFSB 0.1.27 的警告:Claude 域名解析为 fake-IP,且当前 a6 服务器的系统路由为 Surge utun5,因此界面断言 Claude 走了机场出口。 + +两项观测都属实,但不能据此推出实际出口。复查 `route -n get` 确认为默认 utun5;这描述内核首先把包交给 Surge,没有展示 Surge 的后续 DIRECT/接口绑定。本方案本来就让普通应用取得 fake-IP,再由 Surge 恢复域名并交给 FFSB direct 别名。[官方 fake-IP 机制](https://manual.nssurge.com/dns/advanced.html)、[接口绑定 direct 别名](https://manual.nssurge.com/policies/built-in.html) + +追加 8 次新建 HTTPS 探测仍全部收到响应。原生记录 #999(API)和 #1001(claude.ai)均为 FFSB/utun100、双向有字节;API 根路径约 0.33 秒返回 404。系统与显式 Surge 的 ipify 探测 #997/#998 均走 FFSB/utun100,出口一致且与上一轮相同。外层控制连接仍为真实 daemon 进程 DIRECT/en0。a6 状态为 P2P Connected、WireGuard 握手持续刷新。这些证据支持当前既定分流有效,不支持横幅所说的 Claude 已经走机场出口。未取得 sudo 抓包权限,本轮没有独立验证外层 UDP 的物理网卡包路径,不能声称它完全绕开 Surge。 + +本机应用内嵌前端确认该横幅由独立 `CheckDNSHijack` 检测结果触发,每 20 秒刷新;未取得其后端源码,不将检测名称或二进制符号当成完整算法证明。 + +不以消除横幅为目的关闭增强模式或把 Claude 业务域名加入 always-real-ip:现有手动方案需要 fake-IP 保证普通应用先由 Surge 按组选择出站,真实地址可能被 FFSB 已有系统路由提前接走。`link.fufeishangban.com` 已单独设置真实解析和 DIRECT。用户贴出的 IP-CIDR DIRECT 是 Surge 的策略规则,并不等同于 VIF 路由排除;已有实际 daemon 进程 DIRECT 规则也已覆盖该进程的服务器请求。本次没有改变这些设置。 diff --git a/docs/FFSB-SURGE-DESIGN-2026-10-19.md b/docs/FFSB-SURGE-DESIGN-2026-10-19.md new file mode 100644 index 0000000..a7dfea1 --- /dev/null +++ b/docs/FFSB-SURGE-DESIGN-2026-10-19.md @@ -0,0 +1,150 @@ +# FFSB / Surge 稳态分流设计与部署验收 + +> 后续状态:用户新增关闭回退要求后已实施新的本机协调层。本文为早期无回退方案,不代表当前配置;当前结果与未覆盖项见 [实施及验收](FFSB-SURGE-IMPLEMENTATION-2026-10-19.md)。 + +## 目标与设计状态 + +以 FFSB 实际维护的 25 项域名为集合 D。两个客户端已启动、就绪并连接后:D 内业务的 DNS 交给 FFSB,业务经 FFSB;D 外按 Surge 原规则;FFSB 的外层控制/隧道/STUN 连接不使用任何远端代理。用户不要求未就绪时可用,因此不引入开机串行调度器。 + +这是设计,不是已部署配置。本轮只完成官方资料核对、FFSB DNS 只读探测和 Surge 离线语法验证。现状证据见 [审计报告](FFSB-SURGE-AUDIT-2026-10-19.md)。 + +可验证目标是“出口确定、解析一致、入口不依赖进程名称”。无法承诺远端服务器、运营商和客户端软件永不故障。异常时明确失败比悄悄换出口更符合该目标。 + +## 第一性原理与约束 + +1. IP 路由看目标 IP,不看域名或 Claude 品牌。要按 D 分流,必须在 DNS、HTTP CONNECT 或其他仍能看到目标域名的入口完成分类。 +2. FFSB 的域名网络不仅回答 DNS,还参与建立对应 IP 的隧道路由。使用它的 DNS,才能让解析结果和路由状态保持一致;不能长期写死公网业务 IP。 +3. 应用经 macOS 系统 resolver 时,可以由 FFSB 分域 DNS 与更具体的 utun100 路由接管。显式使用 Surge 代理时,连接先到 Surge,由 Surge 重新解析/建立连接;必须为这个入口另行指定同一个最终出口。 +4. Surge 增强模式下的普通 DIRECT 不是“送入另一个 VPN”的承诺。官方给出的共存机制是 direct 类型策略绑定 VPN 接口,并要求该接口存在有效目标路由。 +5. FFSB 的外层 UDP/控制连接与其承载的业务是两层不同的流量。外层必须走物理网络,不能再次送回 FFSB,否则构成环路。 + +依据:[Surge 的 VPN 共存原理](https://manual.nssurge.com/book/understanding-surge/en/)、[接口绑定参数](https://manual.nssurge.com/policies/parameters.html)、[NetBird 域名路由机制](https://docs.netbird.io/manage/networks/how-routing-peers-work)。FFSB 是定制版本,其实际行为仍以本机测试为准。 + +## 推荐架构:多个入口,同一最终出口 + +```text +本机应用访问 D + ├─ 系统 DNS → FFSB DNS → 真实 IP / utun100 → FFSB + └─ Surge DNS、VIF 或本地显式代理 + → D 的 DNS 指向 FFSB + → FFSB-Only 策略绑定 utun100 → FFSB + +FFSB 外层服务进程 → Surge DIRECT(如被接管)→ 物理网络 → FFSB 服务器 + +本机应用访问 D 外目标 → Surge 原 DNS、规则与策略 +``` + +这里“由 FFSB 接管”指业务出口归 FFSB,不要求每个本地字节永远不经过 Surge。请求已经发给 Surge 的 HTTP 代理端口时,Surge 必须参与接收;它应只把这类请求交给 FFSB,而非自己的远端节点。 + +### A. 单一域名清单 + +以 FFSB 的 25 项生成业务规则、Host DNS 映射和 always-real-ip 列表,不独立维护三份手工清单。原集合包含 18 个精确域名、7 个通配域。 + +- 精确域名用 DOMAIN;通配域按原含义用 DOMAIN-WILDCARD。 +- 不把精确 `claude.ai` 扩成 `*.claude.ai`;`downloads.claude.ai` 仍是集合外的下载例外。 +- 根域和 `*.根域` 分别保留;Surge Host 通配符不会自动包含根域。 +- FFSB 清单更新后做差异检查和重新验收;不因看到新服务器 IP 就扩大公网网段。 + +### B. DNS 归属 + +保留 FFSB 的系统分域 DNS。对 Surge 内部 DNS,同一个 D 集合在 Host 中指定 `100.70.255.254`,其优先级高于 AI 公共 DoH 映射;D 加入 always-real-ip。示意: + +```ini +[Host] +api.anthropic.com = server:100.70.255.254 +*.anthropic.com = server:100.70.255.254 +# 实际实现由 D 生成全部映射 +``` + +这使直接问 Surge DNS 的客户端也应获得 FFSB 的真实回答,而不是依赖 fake-IP 或公共 DoH。集合外 DNS 保持原有行为。D 的 DNS 不配置公共解析器作为备用,以免 FFSB 故障时解析/路由悄悄换路。 + +FFSB 控制入口 `link.fufeishangban.com` 不放入依赖自身隧道的 DNS 路径;其 bootstrap 继续使用可独立工作的普通 DNS,并返回真实 IP。 + +**必须验证 Surge 内部 DNS 确实能到达 FFSB DNS。** `dns-follow-interface` 对连接解析有效,但官方注明“规则匹配阶段触发的 DNS”不保证指定接口,因此不能只靠该参数推断全部 DNS 路径成功。若 Surge DNS 阶段的访问验证失败,此设计尚未通过部署验收,不使用公共 DNS 兜底掩盖问题。 + +依据:[Host 映射及首条匹配](https://manual.nssurge.com/dns/local-dns-mapping.html)、[always-real-ip](https://manual.nssurge.com/dns/advanced.html)、[dns-follow-interface 的范围](https://manual.nssurge.com/policies/parameters.html)。 + +### C. 业务出口绑定 + +对已经进入 Surge 的 D 请求使用专用策略: + +```ini +[Proxy] +FFSB-Only = direct, interface=utun100, allow-other-interface=false, dns-follow-interface=true + +[Rule] +DOMAIN,api.anthropic.com,FFSB-Only +DOMAIN-WILDCARD,*.anthropic.com,FFSB-Only +# 实际实现由 D 生成全部业务规则 +``` + +- 指定 FFSB 有效配置中的接口,不依赖 Surge 的 utun 编号。 +- 不使用 fallback/url-test 让 D 落回普通代理或物理 DIRECT。 +- 必须在 FFSB DNS 返回后核对该 IP 在 utun100 上有有效路由;仅设置接口并不能创造路由。 +- 未就绪时允许明确失败,符合最新需求;此方案不声称保护所有客户端均未启动的阶段。 + +### D. 外层传输与规则顺序 + +FFSB 服务的真实可执行路径放在业务规则之前,走普通 DIRECT: + +```ini +PROCESS-NAME,/Applications/FFSB.app/Contents/MacOS/ffsb-service,DIRECT +``` + +不能误用 `/usr/local/bin/ffsb-service` 符号链接路径;不能豁免所有 Claude 应用或整个 FFSB GUI bundle,否则可能把 GUI 的业务检测也送去物理网络。 + +最终相关优先级:FFSB 外层进程例外 → D 业务专用策略 → 其他现有规则。FFSB bootstrap 和必需 DNS 的明确规则按依赖关系放在通用 IP/STUN 规则之前。外层 STUN 不被全局 STUN REJECT 截获,其他进程的 STUN 仍遵循原策略。 + +其他规则按原意保留;“从原理设计”不等于重置生产规则。该机接口绑定属于本机配置,不传播到所有共享订阅设备。 + +### E. 应用与 DNS 的可识别性 + +“任何进程”可做到不按应用名单分流,但不能包含任意绕过本机网络控制的隧道。方案的可执行前提是: + +- 浏览器使用系统 DNS,不另走自行配置的 Secure DNS/DoH 或独立 VPN/Private Relay。 +- 应用不使用外部远端 HTTP/SOCKS 代理;如果需要显式代理,只指向本机已纳入此设计的 Surge。 +- Claude Code 使用官方目标;按用户要求,本轮不处理当前 Base URL。 +- 通过当前正常 DNS 发起新连接。部署后要处理旧 DNS 缓存和已经建立的连接,不能指望它们无缝迁移。 + +原因不是规则数量不足:本机若只看到某个外部加密代理的 IP,便无法从网络层准确知道它内部正在访问哪个域名。多个域名共享 IP 时,IP-only 请求也不能被严格按域名区分。这是所有域名分流方案的边界,不能靠更宽 CIDR 或进程 DIRECT 消除。 + +当前 FFSB 为 IPv4 模式。部署须检查 D 的 A/AAAA/SVCB 结果,不能留下走其他接口的 IPv6 或地址提示旁路;不根据一次 A 查询结果声称全部记录类型均已覆盖。 + +## 已完成的部署前检查 + +1. FFSB DNS `100.70.255.254` 的实际路由为 utun100。 +2. 对 `api.anthropic.com`,UDP 与 TCP DNS 都返回有效 A 回答;本次 AAAA 回答为空。该结果仅限此域名和本次状态。 +3. 从 D 生成包含 25 条业务规则、25 条 Host 映射、always-real-ip 与 FFSB-Only 策略的独立语法样本,已通过本机 Surge 6.10.0 官方 `--check`,结果 OK。 +4. 语法样本仅在 `/private/tmp/ffsb-design/syntax-only.conf`,为离线验证而使用的最小配置,**不得作为活动配置导入**;它不包含完整的其他代理规则。 + +这四项不等于部署后的运行验证通过。尤其尚未验证 Surge 内部 DNS 到 FFSB DNS 的实际路径、Surge 出站接口绑定和真实应用长连接。 + +## 部署步骤与验收门槛 + +先保存当前活动配置、模块、规则、DNS、路由与选择。构建只针对本机的候选完整配置,审查 D 的精确/通配语义与顺序,离线校验后再原子应用。失败时恢复本机前态,不以不断添加临时宽泛规则来掩盖失败。 + +部署后按以下顺序执行;任何关键门槛失败,均不报告“已完成”。 + +| 检查 | 方法 | 通过标准 | +| --- | --- | --- | +| 就绪基线 | FFSB 状态、接口、控制连接、DNS、实际业务请求 | 两客户端在线,utun100 存在,FFSB DNS 可达,真实 HTTPS 可用 | +| 精确/通配范围 | 核对 D 与实际生成规则、有效 Host、旧模块覆盖 | 25 项无遗漏、不扩大精确域名,规则在通用分流之前 | +| 系统 DNS | getaddrinfo/dscacheutil 对有效样本查询 | 真实地址、对应 utun100 路由 | +| Surge DNS | 向当前 Surge DNS 地址查询;Node resolve4、Bun c-ares | 与 FFSB DNS 语义一致,不返回 Surge fake-IP;DNS日志/定向抓包证明上游是 FFSB | +| DNS UDP/TCP/AAAA | 两种 DNS 传输与 A/AAAA;必要时 SVCB/HTTPS | 无别的解析出口或 IPv6 旁路;无记录域名不误判为隧道故障 | +| 系统入口 | 无显式代理的 HTTPS 与出口查询 | D 业务经 utun100;观察 FFSB 隧道对应流量 | +| 显式代理入口 | 单次 curl 经 Surge HTTP 端口 | D 命中 FFSB-Only,实际 socket/流量在 utun100;不出现 AI/Proxy 节点出口 | +| 多应用 | 恢复官方目标后的 Claude Code、Desktop、浏览器 | 登录、模型请求、流式传输正常;不是只检查根路径 HTTP 404 | +| 外层隧道 | 新的真实 STUN/控制/隧道请求记录,必要时物理接口定向抓包 | FFSB 服务命中 DIRECT,有实际响应,不套远端节点 | +| 集合外回归 | GitHub、ChatGPT 和本机需保留的 Tailscale 服务 | 仍按原规则与路由,未被宽泛 FFSB 规则覆盖 | +| 两种启动顺序 | 分别启动至两端就绪后再测试 | 两种顺序的有效 DNS、接口、出口结果一致;不要求未就绪时可用 | +| 重连/托管更新 | 每次重新就绪后重复路径检查 | 本机覆盖保留,DNS和目标路由正确,无手工重加IP需求 | +| 持续使用 | 建议至少30分钟实际混合使用、连续新连接及长流 | 所有已观察D连接只有FFSB出口;无持续超时、DNS错误或非预期重连 | + +16 个当前可解析精确域名需逐个测试。两个无 A 地址的 MCP 根域只能核对 DNS 状态;通配域必须补真实有效子域样本,不能把 NXDOMAIN、根路径 403/404 或语法 OK 当作业务全部验收。浏览器、认证界面和长期流测试中涉及用户操作的部分由用户配合,不调用真实模型来偷偷产生费用。 + +流量验收应结合接口、目标路由、Surge 实际请求记录和 FFSB 流量/出口证据;只看 IP 属地、rule match 模拟或 HTTP 200 都不足以独立证明完整链路。 + +## 完成定义 + +先完成理论支持的定向部署,再按以上门槛实测。最终报告应列出通过项、失败项和未覆盖项;在核心路径或真实应用验证未通过之前,状态只能是“候选方案/待验收”,不能承诺“万无一失”。 diff --git a/docs/FFSB-SURGE-IMPLEMENTATION-2026-10-19.md b/docs/FFSB-SURGE-IMPLEMENTATION-2026-10-19.md new file mode 100644 index 0000000..77bced7 --- /dev/null +++ b/docs/FFSB-SURGE-IMPLEMENTATION-2026-10-19.md @@ -0,0 +1,99 @@ +# FFSB / Surge 实施及验收(2026-10-19) + +> 历史方案:本机自动协调层已退役,当前状态以 [FFSB 手动订阅方案](FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md) 为准。 + +## 当前结论 + +本机已启用 `Proxy Station Claude` 配置及用户级自动协调任务。核心分流、FFSB 关闭回退、重新连接恢复、浏览器与命令行入口、DNS 和外层 DIRECT 已通过实际连接验证。**尚未达到全部场景验收**:用户明确选择暂不重启 Surge,故两种完整启动顺序未测;真实 Claude 登录/模型流、长期运行、睡眠唤醒及换网也未验收。 + +此前设计文档的“不允许回退”要求已被用户最新要求替代。当前职责是: + +- `Claude`:本机 25 项自动入口,FFSB 就绪时优先 FFSB;FFSB 关闭后使用 `🦀 Claude`。 +- `🦀 Claude`:独立的 Surge 出口选择组,当前保留美国选择。旧 Claude 规则中超出 FFSB 25 项的地址始终走这里。 +- `🤖 AI`:其余 AI 服务,原有选择未改。本次真实 ChatGPT 请求仍是 AI → AI Nodes → 美国 Direct。 + +用户明确确认“25 项 FFSB,其余 Claude Surge”,随后指定回退组名称为 `🦀 Claude`。 + +## 清单来源与生产变更 + +25 项确实来自本机 `/usr/local/bin/ffsb-service networks list` 的 Selected 网络,FFSB GUI 同时显示 25/25。原始输出保存在私有证据目录,可公开复查的域名快照为 [ffsb-domains.json](../rules/ffsb-domains.json):18 个精确项、7 个通配项。 + +合并来源包括上述清单、生产显式 Claude 补充规则以及当时 Sukka AI 列表中的 `anthropic.com`、`clau.de`、`claude.ai`、`claude.com` 后缀。按集合覆盖去重得到 **21 条业务规则**,另加 **1 条 Claude DNS 传输规则**。生产最终为 14 组、277 条规则,唯一启用 FINAL 位于末尾。远端混合 AI 列表仍服务其他 AI;Claude 在其之前被独立规则接住,不修改第三方列表。 + +21 条业务规则: + +- 9 个后缀:anthropic.com、clau.de、claude.ai、claude.com、claudemcpclient.com、claudemcpcontent.com、claudeusercontent.com、ffsb.sg、ip.net.coffee。 +- 10 个精确域:api.ipify.org、icanhazip.com、ifconfig.me、join.fufeishangban.com、servd-anthropic-website.b-cdn.net、anthropic.com.cdn.cloudflare.net、anthropic.auth0.com、anthropic-com.ghost.io、browser-intake-us5-datadoghq.com、http-intake.logs.us5.datadoghq.com。 +- 2 个原有官方 inbound 范围:160.79.104.0/23、2607:6bc0::/48(no-resolve)。 + +本机 FFSB 覆盖仍严格使用原 25 项,不把合并后的宽泛后缀全部交给 FFSB。因此 `downloads.claude.ai` 使用 `🦀 Claude`,已经真实请求确认。 + +移除旧的三个服务器 IP /32 DIRECT 补丁、整个 FFSB App 路径 DIRECT,以及已被靠前 Anthropic 后缀覆盖的 statsig 冗余规则。保留真实服务进程 `/Applications/FFSB.app/Contents/MacOS/ffsb-service` DIRECT、独立 bootstrap 域名直连和真实解析。其他应用的 STUN 防护、Tailscale 规则、AI/普通代理选择保留。 + +生产 DNS:Claude 合并域名显式使用 `https://1.0.0.1/dns-query`,1.0.0.1/32 走 `🦀 Claude`;其余 AI 保持原 1.1.1.1 路径。FFSB 开启时,本机 25 项更靠前的 Host 覆盖使用 FFSB DNS;关闭时撤除覆盖。没有将所有公共 DNS 改送 FFSB。 + +生产在线备份:`before-claude-independent-ffsb-20261019-115104.db`,已核对 root 属主、600 权限、6,692,864 字节。所有写入经管理 API,未部署应用代码。 + +## 实现与依据 + +源码为 [scripts/ffsb](../scripts/ffsb/README.md)。协调任务每 10 秒检查 FFSB 管理/信令连接及 DNS 是否可回答;生成并校验完整本机配置后,原子替换并 reload/flush DNS。生产配置每小时经原 HTTPS 地址拉取、校验,缓存和凭据仅落本机私有目录;不手改原托管文件。 + +FFSB 策略为 direct,显式 `interface=utun100`、`allow-other-interface=false`、`dns-follow-interface=true`,探测 URL 属于 FFSB 25 项。Surge 增强模式的普通 DIRECT 绑定物理接口,不能用来代替业务的 utun100 绑定;这恰好用于 FFSB 外层服务避免套远端代理。[Surge 原理](https://manual.nssurge.com/book/understanding-surge/en/)、[策略参数](https://manual.nssurge.com/policies/parameters.html)。 + +DNS 与连接出口是两个独立环节;本次同步切换 Host 与策略组成员,不把“FFSB DNS + 公共 DNS 并列”误当顺序备用。Host 首条匹配,多个 DNS 并列会竞争回答。[Host 官方说明](https://manual.nssurge.com/dns/local-dns-mapping.html)。 + +使用原生 fallback 探测选择优先成员,关闭 FFSB 后本地配置只保留 `🦀 Claude` 成员;探测、协调和连接缓存都有延迟,不承诺瞬时切换或迁移旧连接。[fallback 官方说明](https://manual.nssurge.com/policy-groups/fallback.html)。 + +曾考虑使用模块加入策略,但官方模块不能调整 Proxy/Proxy Group,因此最终使用本机完整生成层,保留生产源及回滚入口。[模块限制](https://manual.nssurge.com/profile/module.html)。 + +## 测试记录 + +| Case | 结果 | 证据/范围 | +|---|---|---| +| FFSB 清单提取与去重 | 通过 | CLI Selected 25 项;GUI 25/25;21 条合并业务规则 | +| 生产 HTTPS 与规则集 | 通过 | 官方 Surge CLI OK;49/49 远程规则集下载与快照校验成功,静态遮蔽提示 0 | +| 本机开启/关闭配置 | 通过 | 两种候选官方 CLI OK;4 个离线测试覆盖精确范围、旧规则回退、改名、清单漂移拒绝 | +| Surge DNS | 通过 | api.anthropic.com → 160.79.104.10;dns trace 上游 100.70.255.254 | +| Node 系统与 c-ares 解析 | 通过 | lookup 与 resolve4 均为真实 160.79.104.10,无 198.18 fake-IP | +| IPv6/HTTPS RR | 限定通过 | 已测样本 AAAA 无结果;TYPE65 返回 NOERROR 空回答并注明 netbird 不支持该查询类型,不代表支持 HTTPS RR 或所有 IPv6 场景 | +| 18 个精确域 HTTPS | 16 个完成传输 | 另外两个 MCP 根域无可用 A;403/404 等仅算 TLS/HTTP 传输完成,不算业务认证成功 | +| 通配域样本 | 4 个有效样本完成 | www.anthropic.com、platform.claude.com、bridge.claudeusercontent.com、c.ffsb.sg;www.clau.de 无可用地址;两个 MCP 通配域缺真实业务子域 | +| 显式 Surge 代理 | 通过 | #53773 / #53780:Claude → FFSB,DNS 100.70.255.254,socket 明确绑定 utun100 | +| FFSB 域名与控制 IP 重合 | 当前样本通过 | #53782 c.ffsb.sg 绑定 utun100 完成 HTTP 200;其后管理/信令均 Connected,不据 route get 单独判定环路 | +| FFSB 外层 | 通过 | #53827 控制连接 DIRECT,socket 绑定 en0;外层 WireGuard UDP 双向有流量;#53752 STUN DIRECT 上行20/下行32字节 | +| 关闭回退(手动触发协调) | 通过 | 两种 curl 入口均 HTTP 200、出口104.28.195.192;DNS 切到1.0.0.1 DoH | +| 自动关闭/恢复(LaunchAgent) | 通过 | 不手动运行协调脚本:本次关闭约1秒回退;重连后约12.05秒恢复;前后真实请求成功,出口切回138.75.58.11 | +| 浏览器 | 出口测试通过 | Chrome api.ipify.org:关闭时104.28.195.192,恢复刷新后138.75.58.11 | +| 集合外 Claude | 通过 | #53785 downloads.claude.ai → 🦀 Claude → 美国 Warp,HTTP403仅为根路径响应 | +| 其他 Surge 流量 | 通过 | #53786 GitHub → Proxy HTTP200;#53787 ChatGPT → AI,保持原选择 | +| Tailscale | 部分回归通过 | Running、Quad100 MagicDNS 返回答案;本轮未测 tailnet HTTP/SSH 和蜂窝入站 | +| 订阅刷新 | 通过 | --refresh 拉取改名后的生产配置,保留本机覆盖与组选择,经 CLI 校验 | +| 两种完整启动顺序 | 未验证 | 用户选择“暂不重启,记录该项未验证”;没有重启 Surge 引擎 | +| Claude Code / Desktop 登录、模型流 | 未验证 | 按用户要求未调整现有 Base URL;未发送计费模型请求。Node DNS 与 curl 不能代替该验收 | +| 短时连续新连接 | 通过 | 约123秒、两种入口交替共24次,全部成功且出口均138.75.58.11;不代表长流测试 | +| 长期、睡眠、换网 | 未验证 | 短时新连接测试不能替代这些场景 | + +私有原始证据包括 `automatic-test.json`、`fresh-request-evidence.json`、`outer-active-evidence.json`、`domains-on-test.json`、生产 API 前后快照及配置校验日志,已归档到本机 `~/Library/Application Support/ProxyStationFFSB/evidence-2026-10-19/acceptance/`,原 `/private/tmp/claude-policy-implementation/` 副本保留。其中可能含节点和本机网络信息,不提交仓库、不公开上传。 + +## 失败、修正与限制 + +第一轮原生 DNS 测试超时;随后发现 FFSB 当时管理/信令断线、其 DNS 直接返回 SERVFAIL。恢复 FFSB 后,同样的原生 DNS 配置成功,不能据第一轮超时断言 Surge 不支持 FFSB DNS。曾观察控制 IP 的全局路由指向 utun100,但实际 socket 记录证明外层控制连接绑定 en0;因此“必然回路”没有得到证实。 + +自动审批曾拒绝没有同流程恢复保证的关闭测试。随后改成 try/finally 保证恢复的单一流程,通过审批并完成测试;没有绕过拒绝执行原命令。 + +FFSB 自身设置未改变,仍会安装系统分域 DNS 和真实地址路由。因此当前实现验证的是**正常就绪分流及显式关闭回退**。接口仍存在但 FFSB 内部故障时,残留的系统 resolver/路由可能影响普通客户端,不能将此方案描述为覆盖所有半故障状态的无损恢复。当前网络没有实际 IPv6 出口场景,不能宣称所有 IPv6 客户端路径已验收。 + +外部加密代理、应用私有 DoH、硬编码共享 IP 等可能隐藏目标域名;无法从外层 IP 无条件还原“任意进程的任意请求”。客户端升级、接口/DNS/范围改变后需复测。本次不承诺“万无一失”。 + +## 收尾完成(2026-10-19) + +先前归档和安装曾因自动审批服务返回“Selected model is at capacity”而未执行。用户切换 full access 并明确要求继续后,已完成以下操作,没有重启 Surge 或切换 FFSB: + +- 将 116 个原始证据文件复制到 `~/Library/Application Support/ProxyStationFFSB/evidence-2026-10-19/acceptance/`,逐文件核对 SHA-256,并保存 `sha256-manifest.json`。原临时文件保留;归档目录权限 700、文件权限 600,已检查。 +- 先用官方 CLI 确认活动配置为 `Proxy Station Claude`,再将 `FFSB-Core-Probe.conf` 移到归档的 `retired-profiles/`,校验内容哈希一致,Surge Profiles 中不再保留该测试文件。 +- 将旧协调脚本备份为归档中的 `reconcile-before-logging.py`,原子安装新脚本,并确认与仓库源码逐字节一致。 +- 真机执行 `--refresh` 返回退出码 0,输出 `Refresh succeeded; configuration unchanged; mode=FFSB; no reload`。执行前后生成配置 SHA-256 一致,活动配置仍为 `Proxy Station Claude`。结果保存在归档的 `closeout-verification.json`。 + +8 项离线测试通过(原覆盖层 4 项、刷新日志 4 项);新增测试覆盖手动成功、后台静默、下载失败、原生校验失败,并确认无变化不 reload、不输出订阅 URL。刷新失败且已有有效缓存时明确提示沿用缓存,不能只看退出码认定远端刷新成功。此次收尾未扩大上文网络验收范围,未验证项继续保留。 + +其他客户端范围:本次生产分组/规则也会影响共享订阅的其他格式;未完成 Clash 原生解析器或 iOS 真机复验,不能将 Surge 验收推广到其他客户端。 diff --git a/docs/FFSB-WIREGUARD-SURGE-2026-11-02.md b/docs/FFSB-WIREGUARD-SURGE-2026-11-02.md new file mode 100644 index 0000000..b930aa4 --- /dev/null +++ b/docs/FFSB-WIREGUARD-SURGE-2026-11-02.md @@ -0,0 +1,89 @@ +# FFSB 由 Surge 原生 WireGuard 独立承载的本机验证 + +2026-11-02,Mac Studio,Surge 6.11.0 build 12670,FFSB 0.1.27 导出的既有设备身份。用户授权提取本机参数,并在退出 FFSB 后进行独立验证;用户明确选择暂不测试登录后的真实业务。 + +**已证实:本轮测试期间,本机可以由 Surge 原生 WireGuard 承载所测专线流量,无需运行 FFSB GUI 或 daemon。** 这不等于脱离 FFSB 服务商、取得服务端管理权限,或已经验证未来授权和节点更新。生产订阅、应用代码与数据库均未修改。 + +## 证据链 + +| 验证层 | 结果 | +|---|---| +| 身份来源 | 经 macOS 管理员授权,以标准 WireGuard UAPI `get=1` 只读导出运行时参数;从私钥派生的公钥与当前 FFSB 身份一致,未读取登录令牌 | +| 排除后台依赖 | 约 10:27(本机 UTC+8)临时 `launchctl bootout system/ffsb`;之后确认 GUI/daemon 进程均不存在、utun100 不存在;未永久禁用启动项或卸载软件 | +| 独立握手 | Surge 运行时显示策略类型 WireGuard、handshakeCompleted=true;物理 en0 定向元数据抓包记录 148 字节发起和 92 字节响应,以及后续双向加密数据 | +| 物理数据 | 第二段抓包共 208 包,125 上行、83 下行,其中 80 个下行包大于握手响应长度;抓包仅保存头部摘要,未解密用户内容 | +| 独立 DNS | 直接向资源节点的隧道地址 UDP/22054 查询 API,NOERROR/有 A 记录,约 96 ms;Surge 请求日志含 WireGuard-DNS Remote DNS completed | +| 系统/显式代理入口 | 两种 ipify 入口均走原生 WG,返回相同的原 FFSB 出口;未把 fake-IP 当成最终出口 | +| 多轮新连接 | 10:32:45—10:41:18,5 轮 × 7 请求,共 35/35 收到 HTTP 响应;最后一轮 API 根路径约 0.32 秒返回 404 | +| 规则范围 | 当前服务端 Selected 清单 27/27 项原生规则解释均进入 WG;通配项的解释样本不是实际业务穷举 | +| 实际域名样本 | 19 个精确根域+5 个真实通配样本,共 24 个;22 个完成 TLS/HTTP,两个 MCP 根域无 A 记录,未计为连通通过 | +| 反向对照 | 将测试配置端点改为错误 UDP 端口,API 和 ipify 均超时,GitHub 仍为 200;恢复正确端点后重新握手,7/7 请求恢复,无普通代理兜底假成功 | +| 较长响应 | claude.com 公开页面约 489 KB,以 12 KB/s 限速,39.03 秒完整收完、HTTP 200;这是较长 TCP 响应,不能称为模型 SSE 验收 | +| 其他分流 | GitHub 仍走日本 Proxy;downloads.claude.ai 仍经隐藏 Claude 组转美国 AI Nodes;其他手动组选择一致,仅 Claude 改选新 WG 策略 | +| 原配置完整性 | 原托管文件与备份 SHA-256 一致;Surge 引擎启动时间保持 09:50:52,未重启引擎;抓包与恢复 watchdog 已停止 | + +## 测试期间的实际配置变化 + +测试期间活动配置为 **`Proxy Station WG Validation`**,由原托管文件的磁盘内容生成独立本地副本。原 `Proxy Station` 保留,未改托管 URL、未刷新或上传生产配置。这个本地副本**不自动跟随托管订阅更新**。用户随后要求回退组合方案,最新运行状态见文末的回退记录,不以测试快照推断当前活动配置。 + +- 使用独立名称 `FFSB Native WG Validation`,由原 Claude select 组选取;其他地区/AI/All Nodes 的选择不变。 +- 原生 WG 使用导出的设备身份、资源节点公网 UDP 端点、MTU 1280、keepalive 25,DNS 指向资源节点隧道地址的 22054 端口。 +- 删除旧 25 项指向本机 FFSB 虚拟 DNS `100.70.255.254` 的 Host 映射,不再依赖 utun100。 +- 保留原有普通代理与 AI DNS 设置,未保留试验中指向远端 resolver 的额外全局 Host 覆盖;原生 WG 自己完成隧道内 DNS。 +- 本地验证副本补入服务端新增的 hcaptcha.com/通配项,合计 27 项。仓库生成器源清单与生产 settings 仍是原方案,未修改。 +- 验证副本包含资源节点隧道地址的精确 WG 规则,供普通 dig 独立查询;不放宽整个 CGNAT 网段。 +- WG 内部 `allowed-ips=0.0.0.0/0` 只负责该策略内部选择 peer;业务仍按域名规则选 WG,不能据此扩大服务器许可范围。 + +**不要在此 WG 配置活动时连接 FFSB:两者复用相同私钥和隧道地址,可能触发端点漫游并抢占流量。** 恢复原方案时先切回原 `Proxy Station`,再恢复 FFSB 服务/连接。具体恢复命令与私有配置均在本机归档说明中。 + +## 失败记录与验收边界 + +初始三轮失败均保留。第一版使用 CLI dump 配置复制节点,其认证字段与磁盘原件不同,导致普通代理对照失败;随后从磁盘原件重建,保留认证字段。旧策略名下还出现旧本机 DNS 映射及状态复用现象;采用独立策略名、移除旧本机 DNS 映射并完成配置加载后恢复。没有严格分离这一阶段的缓存、配置切换及名称复用影响,不把初始失败归因为服务端,也不删掉失败数据。 + +原生配置及 FINAL 等静态检查通过。`scripts/check-surge.py --fetch-rulesets` 在远程规则集下载阶段仍失败,**全量在线规则集快照校验未通过**;这一问题在本轮改动前已存在,不以当前缓存匹配正常宣称所有上游当前可下载。 + +HTTP 403/404/426 仅证明传输和根路径响应,不等于登录、验证码完成、MCP 会话或模型对话成功。用户选择暂不测真实业务,因此未调用计费模型 API。未验证设备 DNS 泄露探测、睡眠/换网/重启、长时间运行、服务器迁移、设备重新认证或授权撤销后的行为。当前仅使用一个已验证资源节点,没有复制 NetBird 的动态节点发现、ICE、中继与故障切换机制。 + +本轮能够支持的结论是:**所测时间窗口内,本机客户端及后台可以由 Surge 原生 WG 替代。** 不能将其扩大为永久无需控制服务、可通用导入其他设备或所有业务已验收。 + +## 私有证据 + +完整归档位于本机 `~/Library/Application Support/ProxyStationFFSB/native-wireguard-validation-20261102-1042/`,含 239 个文件及 SHA-256 清单;目录 700,文件 600。包含原始失败/成功结果、原生请求和运行时状态、物理包元数据、原配置备份、实际活动配置、导出身份及复现脚本。真实凭据和节点地址不入仓库、不上传第三方。 + +其中 `independent-validation/evidence-summary.json` 为脱敏摘要;`surge-ffsb-candidate.conf` 为本机实际验证的集成副本;早期最小候选另存,不与验证完成的版本混淆。 + +## 管理连接没有被替代 + +当前实现没有 NetBird 控制客户端或同步脚本。它使用此前管理服务下发的设备身份、地址和节点参数快照,依赖服务端当时仍认可该设备及权限,直接建立 WireGuard 数据隧道。Surge 负责 WireGuard 握手、保活和会话密钥更新;没有接管邮箱登录、设备注册/重新认证、配置与权限同步、ICE、中继或节点故障切换。域名解析可以通过已知节点 DNS 完成,但域名清单与节点变更不会自动同步到此本地副本。 + +因此后续研究重点是**长期身份有效性和配置同步**,不是再次证明静态 WireGuard 能握手。不要把一次成功写成管理连接已被 Surge 实现,也不要把 WireGuard 自身的会话换钥误认为必须重新提取设备私钥。 + +## 下一次继续的步骤 + +1. 先读本文回退记录、`AGENTS.md` 的最新状态及原生产手动方案,检查实际活动配置和 FFSB 状态。历史归档只作复现材料,不直接覆盖新生产订阅。 +2. 私有归档中 `SHA256SUMS.txt` 校验历史证据,`surge-ffsb-candidate.conf` 是当时验证过的完整集成副本;`reproduction-scripts/` 保留导出、候选生成、探测、抓包和反向对照脚本。这些脚本含当时路径/PID/节点参数,需审阅后适配,不能直接盲跑。 +3. 从当前 FFSB 状态重读节点和 Selected 域名,必要时用经管理员授权的 WireGuard UAPI `get=1` 重新导出;派生公钥对比现有身份,避免把历史端点/路由误当最新。私钥只进本机受限权限文件。 +4. 保留当前原始配置与完整组选择,准备自动恢复。先断开 FFSB,再停 daemon 并确认进程和 utun100 消失,最后才加载 WG 测试配置,禁止同身份并行连接。 +5. 使用独立 WG 策略名、节点隧道内 DNS,移除旧本机虚拟 DNS 映射;保留其他分流。若基于最新订阅重建,必须从受限的磁盘原件保留认证字段,不能拿 CLI 脱敏输出直接激活。 +6. 重做握手、隧道 DNS、新建 HTTP、出口与普通代理对照、错误端点失败/恢复测试。只有新增验收授权后再测真实业务、睡眠、换网、重启与长时间控制连接缺席,不把历史成功当作新环境验收。 + +未修改生产 FFSB 功能或增加自动同步机制;后续若要把原生 WG 正式纳入托管订阅,须另行设计设备独立身份、凭据保存、更新与撤销机制,不能把本机私钥写入共享订阅。 + +## 回退至 FFSB+Surge(2026-11-02,11:05 验收) + +用户要求固化探索成果后回退日常组合方案。**当前已恢复正式托管 `Proxy Station`,Claude → `🦁 FFSB`,FFSB GUI 和 daemon 均运行。** 原生 WG 不再活动,验证配置已移出 Surge 配置列表,完整副本保存在私有归档中;不要把上面的试验状态或旧归档 README 当成当前状态。 + +回退顺序为:保存试验配置与组选择 → 切回原托管配置 → 确认试验策略不存在、活动请求为零 → 恢复 FFSB launchd 服务并连接 → 打开 FFSB GUI。没有同身份并行运行,未重启 Surge 引擎,未修改原托管文件、生产数据或其他手动组选择。 + +回退后验收: + +- FFSB management/signal Connected,资源 peer P2P Connected;utun100 恢复,MTU 1280,disableDns=true、disableClientRoutes=false。 +- 系统 DNS 仍由 Surge 接管,API 系统查询为 fake-IP;Surge DNS trace 经本机 FFSB DNS `100.70.255.254` 返回真实地址,约 95 ms。 +- 11:05:29—11:05:43,3 轮 × 8 请求 **24/24** 收到 HTTP 响应。API 根路径 404,约 0.33—0.36 秒;系统与显式 Surge 的 ipify 均为原 FFSB 出口。claude.ai/join 测试均完成传输。 +- 原生请求 #4220 API、#4221 Claude、#4218/#4219 ipify、#4222 join 均为 `🦁 FFSB / utun100` 且双向有数据。 +- #4223 GitHub 保持日本 Proxy;#4224 downloads、#4225 Auth0 保持隐藏 Claude 组 → 美国 AI Nodes;FFSB 外层控制连接 #4140/#4142/#4144 为 DIRECT/en0,双向有字节。 +- 原生产清单 25/25 项原生规则解释进入 FFSB;原托管文件字节未变,其他手动组选择不变。活动配置通过官方 CLI 与 FINAL 等静态检查;本次未宣称全量上游规则集在线快照通过。 + +这里恢复的是生产原有 25 项方案。FFSB 当前下发的 27 项中,hcaptcha.com/通配项尚未同步进生产 Surge 清单,本次未扩展生产规则;原生 WG 试验中补齐这两项的成功仍保留在历史证据中。没有测试登录、验证码完成或真实对话,不将根路径响应当成业务验收。客户端 fake-IP 警告的既有判断差异也未通过更改 DNS 来隐藏。 + +本轮回退证据另存本机 `~/Library/Application Support/ProxyStationFFSB/restore-combined-20261102-110255/`,目录 700、文件 600,包含回退前配置、组选择、24 次探测、DNS/请求日志与恢复后的状态。原探索归档保持不可变,238 项内容哈希复核通过;后续继续原生 WG 时使用前文步骤,不自动激活旧实验配置。 diff --git a/docs/FINANCE-RULES.md b/docs/FINANCE-RULES.md new file mode 100644 index 0000000..e983b00 --- /dev/null +++ b/docs/FINANCE-RULES.md @@ -0,0 +1,33 @@ +# Finance 分组与金融服务规则(2026-10-13) + +后续按用户要求删除了旧手动 `DOMAIN-SUFFIX,paypal.com`,保留 `DOMAIN-KEYWORD,paypal`;最新 Finance 为 86 条规则,生产共 265 条(263 启用)。全面检查结果见 [移动端校验](MOBILE-CLIENT-AUDIT-2026-10-13.md)。下文 87/266 条属于新增组时的历史阶段。 + +用户授权为 Plasma One、PayPal 新增英文金融分组,指定成员顺序为美国、日本、新加坡、最后 DIRECT;默认美国,不加入 All Nodes。 + +## 分组与规则来源 + +`💰 Finance` 为手动 select,成员依次为 `🇺🇸 United States`、`🇯🇵 Japan`、`🇸🇬 Singapore`、`DIRECT`,显示在 Bybit 后、Mainland 前。默认是第一项美国地区组;地区组自身仍采用现有测速机制,并非固定单一节点 IP。Bybit 保留独立组。 + +PayPal 来源为 [blackmatrix7 PayPal 清单](https://github.com/blackmatrix7/ios_rule_script/blob/master/rule/Surge/PayPal/PayPal.list)。它包含 246 条域名后缀、1 条 paypal 关键词及 1 条旧 USER-AGENT。此次保留关键词和不被该关键词覆盖的 80 条后缀,另将既有 `paypal.com → 美国` 改为 Finance;域名覆盖与上游 247 条域名规则等价。未导入 USER-AGENT,避免依赖可见 HTTP 请求头。上游包含 Braintree、Venmo、Xoom 等 PayPal 家族域名,也会交给 Finance;关键词会匹配其他含 paypal 的主机名,分流命中不代表站点可信。 + +没有找到 Plasma One 的现成专用分流清单,按官方入口与开源 SDK 整理: + +- [Plasma 官方入口](https://www.plasma.org/personal):旧 `one.plasma.to` 已跳转至该页面。收录 `plasma.to`、`plasma.org` 后缀。 +- [plasma-card-sdk constants](https://github.com/mediakov/plasma-card-sdk/blob/main/src/constants.ts):API 为 `pay-tasks.prod.plasma-one.tech`,WebSocket 为 `pay-tasks-ws.prod.plasma-one.tech`。收录 `plasma-one.tech` 后缀。 +- [SDK 认证实现](https://github.com/mediakov/plasma-card-sdk/blob/main/src/auth/email.ts) 使用 `auth.privy.io`,并引用 `recovery.plasma.org`;[Privy 官方钱包 API 文档](https://docs.privy.io/wallets/wallets/external-ids) 使用 `api.privy.io`。后两者采用精确 DOMAIN 规则;它们是共享钱包服务,其他 App 对这两个主机的请求也会进入 Finance。 + +没有加入搜索中出现的非官方同名 meme 项目,也未按伙伴名称泛收 Bridge、Rain、SumSub 等完整域名。Plasma App 的更多身份核验、第三方充值或链上 RPC 请求仍需实际连接日志补齐。 + +现有管理 API 仅能读取规则集目录,不能创建新远程规则集。因此本次通过规则 API 写入显式快照,未改数据库表或代码、未部署服务、未 reset。快照见 [rules/finance.list](../rules/finance.list),不会自动追踪上游变化。 + +## 生产操作与验证 + +在线备份:`/opt/proxy-station/data/backups/before-finance-plasma-paypal-20261013-171418.db`,写入前确认 root 属主、600 权限、非空。 + +新增 1 个组、4 个成员、86 条规则,另定向修改已有 paypal.com 的策略。新规则放在原 paypal.com 位置的业务例外区段,在 AI 后、广告和通用分流之前;Unbreak 仍先于 Finance。已有其他规则内容与相对顺序保持,唯一启用 FINAL 仍在末尾。不改节点、Surge 原文设置或客户端已保存选择。 + +管理 API 重读验证:13 个组、25 个显式成员、266 条规则(264 启用),87 条规则指向 Finance;既有规则除 paypal.com 的策略与排序序号外保持不变。实际 HTTPS Surge、Clash、Shadowrocket conf 均包含 Finance 组及 87 条规则,四个成员顺序正确且没有 All Nodes;Surge self URL 也已核对。 + +Surge 官方 CLI 与静态防护检查通过。因本机到 GitHub 的连接失败,本次通过 LA 只读下载公开外部规则集:49/49 快照下载成功,本地快照再次通过官方 CLI,静态域名遮蔽提示为 0。代表性官网、API、WebSocket、Privy、PayPal 与 Braintree 主机均已检查显式域名匹配。CLI 与域名检查不模拟设备缓存、IP/进程/协议路径,也不代表支付或登录已验收。没有可用的 mihomo,因此 Clash 完成产物结构核对,未做原生解析;移动端也未做真机验收。 + +客户端需更新订阅;未代用户登录、查询余额或执行支付/转账,也不以配置检查宣称业务连接已验收。 diff --git a/docs/FLCLASH-COMPATIBILITY-2026-11-02.md b/docs/FLCLASH-COMPATIBILITY-2026-11-02.md new file mode 100644 index 0000000..8f8e13f --- /dev/null +++ b/docs/FLCLASH-COMPATIBILITY-2026-11-02.md @@ -0,0 +1,22 @@ +# FlClash 订阅兼容性审查 + +2026-11-02;核对目标为 FlClash v0.9.2 及其固定的 Mihomo fork `c5062921054902b25215a6a933e72ccd714c9bfa`。这次更改已部署 LA,尚未在 Android 设备上验收。原有 `/clash` URL 保留,界面显示名称改为 FlClash;Surge 名称不再附系统后缀。 + +| 原 ClashMeta 配置 | 审查与处理 | +|---|---| +| mixed-port、allow-lan、rule 模式、IPv6 与 fake-IP DNS | 沿用 Mihomo 配置结构;FlClash 的 VPN、DNS 和系统私有 DNS 等设备设置仍需真机验证。 | +| VMess、Trojan、Shadowsocks、Hysteria2 | 沿用现有序列化与能力过滤。SS WebSocket 插件只下发 TCP;无法表达的 VMess cipher 会排除而非静默改协议。语法通过不等于真实节点连接通过。 | +| select、url-test、fallback,显式成员和动态筛选 | 继续沿用;原有正则组对全部节点筛选,不等同于 Surge 的受引用组约束的动态来源。新增设备 WG 时,动态公网组增加 `exclude-filter`,避免把私网隧道当公网节点。固定内核的 `adapter/outboundgroup/parser.go` 和 `groupbase.go` 已确认该字段及筛选处理。 | +| 规则及远程 rule-provider | 保留顺序、`no-resolve` 与 FINAL→MATCH。TCP/UDP PROTOCOL 转 NETWORK;不能完整表达的逻辑规则和应用协议等仍跳过,不能视为所有 Surge 规则都能等价转换。规则集需实际网络可访问。 | +| AI DNS | 沿用既有 nameserver-policy、规则集和按 AI 策略组选择的 DoH;没有开启全局 respect-rules。系统 DNS、泄漏及出口仍需按设备和应用验证。 | +| WireGuard | 新增可选设备专属出站。使用完整 `peers` 格式限定 `allowed-ips` 为 njcq 私网;私网 IP-CIDR 规则排在最前,WG 不进入动态公网代理组。仅保证生成和用途鉴权,设备连接及入站不由解析测试证明。 | + +不选设备时订阅 URL 和内容保持原来的纯代理配置。选设备后生成 `/clash?wg=…` 派生链接;凭证随机、可单独撤销,并校验订阅、设备和 `clash` 用途。Surge 凭证和 FlClash 凭证不能跨格式使用。切换或清除选择不会撤销已生成的其他链接;设备暂停时派生链接不可使用。不要让 Android 与正在运行的 Mac/iPad 同时复用同一设备身份,应为 Android 建立独立设备和网关 peer。 + +该配置支持的目标是 Android **主动**访问 njcq 私网;固定内核没有确认 Surge `allow-peer-access` 等价的 Android 本机服务入站桥接。WG 握手不代表反向入站或 Apple VNC 媒体可用,详见 [WG 与入站能力研究](FLCLASH-WIREGUARD-AUDIT-2026-11-02.md)。Android VPN 权限及网络切换、真实私网 TCP/UDP、反向连接、VNC、业务 DNS 和公网出口仍需单独验收;不要把本机测试的占位身份用于生产。 + +参考:[Mihomo WireGuard 配置](https://wiki.metacubex.one/en/config/proxies/wg/)、[FlClash v0.9.2 固定内核 WG 实现](https://github.com/chen08209/Clash.Meta/blob/c5062921054902b25215a6a933e72ccd714c9bfa/adapter/outbound/wireguard.go)、[固定内核组解析](https://github.com/chen08209/Clash.Meta/blob/c5062921054902b25215a6a933e72ccd714c9bfa/adapter/outboundgroup/parser.go)。 + +本地验证:Node 22 下 `pnpm -r typecheck`、`pnpm test`(96 项)及 Web 构建通过;其中含设备 WG 的完整示例使用官方 Mihomo v1.19.33 macOS 原生解析器通过校验,该二进制经官方 release SHA-256 核对,但它不是 FlClash 固定 fork 或 Android 客户端。隔离数据的浏览器交互验证了格式切换、设备选择和清除、两种凭证互不干扰以及 320–1024 像素布局;未连接真实网关或设备。 + +生产验证:LA 前后端部署成功,公网 HTTPS 的默认 Surge、Clash、Shadowrocket 节点及规则配置均与部署前逐字一致,唯一订阅及节点/组/规则/settings 保持不变。使用两台既有设备临时签发 Clash 凭证,核对身份隔离、跨用途拒绝及撤销后 404;两份实际 HTTPS 设备配置和默认配置均通过 Mihomo v1.19.33 解析,临时凭证已撤销。实际 Surge 及 49/49 规则集快照通过官方 CLI,13 条静态域名遮蔽提示与部署前一致。生产数据库在线备份为 `la:/opt/proxy-station/data/backups/before-flclash-deploy-validation-20261102-153706.db`(root/600)。 diff --git a/docs/FLCLASH-WIREGUARD-AUDIT-2026-11-02.md b/docs/FLCLASH-WIREGUARD-AUDIT-2026-11-02.md new file mode 100644 index 0000000..a477e67 --- /dev/null +++ b/docs/FLCLASH-WIREGUARD-AUDIT-2026-11-02.md @@ -0,0 +1,75 @@ +# FlClash Android:WireGuard 与本机入站能力核对 + +核对日期:2026-11-02。范围:官方稳定版 FlClash **v0.9.2**(2026-10-24 发布)、该 tag 固定的内核子模块,以及官方配置文档。初次研究未安装 APK、未做 Android 真机测试、未改订阅/设备/peer 或生产部署;后续本地实现见 [配置兼容性](FLCLASH-COMPATIBILITY-2026-11-02.md)。 + +## 结论 + +| 能力 | 本次结论 | +|---|---| +| WireGuard 出站节点 | 支持,FlClash 的节点模型与内核都有相应类型/字段 | +| Android 主动经 WG 访问 njcq 私网、Mac 服务 | 可作为候选;需要配置正确的节点/私网规则并用实际设备验证 | +| 已发起连接的响应数据 | 属于正常 TCP/UDP 出站能力,不是任意本机服务入站的证明 | +| 像 Surge allow-peer-access 一样,由对端主动访问 Android 本机应用 | 当前标准 Android 使用方式未提供等价配置/桥接实现;不能作为已支持的双向设备方案 | +| Apple VNC 高性能媒体 | 未验收;WG、普通 TCP/UDP 或握手成功都不足以证明该业务可用 | +| 本项目 FlClash 订阅附加设备 WG | 已部署独立用途的设备凭证及出站配置;尚未真机验收 | + +以上入站判断来自已核对版本的实现,不能扩写为所有 Mihomo 版本、root 环境或自定义补丁都永远不可实现。 + +## 版本与来源 + +- [官方站点](https://chen08209.github.io/FlClash/?lang=en) +- [官方 v0.9.2 发布](https://github.com/chen08209/FlClash/releases/tag/v0.9.2) +- [v0.9.2 模块声明](https://github.com/chen08209/FlClash/blob/v0.9.2/.gitmodules):实际使用作者的 Clash.Meta fork,而不是任意版本的上游 Mihomo。 +- [v0.9.2 core/go.mod](https://github.com/chen08209/FlClash/blob/v0.9.2/core/go.mod):Mihomo 依赖替换到本地 `./Clash.Meta`。 +- [该 tag 的 Git tree](https://api.github.com/repos/chen08209/FlClash/git/trees/v0.9.2?recursive=1) 将 `core/Clash.Meta` 固定在 **c5062921054902b25215a6a933e72ccd714c9bfa**。本文内核判断使用该提交。 + +## WireGuard 出站的依据 + +FlClash 的 [节点模型](https://github.com/chen08209/FlClash/blob/v0.9.2/lib/models/clash_config.dart) 包含 wireguard 类型与私钥、公钥、PSK、allowed-ips 等字段;[Mihomo 官方 WG 配置](https://wiki.metacubex.one/en/config/proxies/wg/) 也将其放在 `proxies` 出站节点中。 + +固定内核的 [wireguard.go](https://github.com/chen08209/Clash.Meta/blob/c5062921054902b25215a6a933e72ccd714c9bfa/adapter/outbound/wireguard.go) 实现: + +- WG 参数、peer 与 TCP/UDP 能力; +- `newIPStack` 创建内部 MIPS/gVisor 网络栈; +- `DialContext` 从内部栈发起 TCP; +- `ListenPacketContext` 为 UDP 代理流创建内部 socket。 + +因此可研究 Android 主动经 njcq 连接 Mac 的私网服务。这个源码结论不替代认证、握手、业务连接及 Android VPN 路由实测;也不说明可以直接导入 Surge `.conf`,FlClash 使用的是 Mihomo YAML。 + +## Incoming 指的是什么 + +本项目需要的是:另一设备经 njcq,向 Android 的 WG 固定 IP 发起 TCP/UDP 连接,并到达 Android 本机实际运行的服务。它不同于“支持 WG 加密”“接收已发起连接的回包”“提供 HTTP/SOCKS 代理端口”或“作为 WireGuard 服务端监听 UDP”。 + +[Surge 官方 allow-peer-access 文档](https://manual.nssurge.com/policies/wireguard.html#allow-peer-access-ios-600-mac-6110) 明确说明这种本机服务入站;旧名 `accept-incoming` 仍受支持。该文档还单列 Apple Screen Sharing 的系统栈处理、固定源地址/端口及反向 UDP 媒体行为。 + +核对 FlClash v0.9.2 固定内核后: + +- WG 选项没有 `accept-incoming` / `allow-peer-access` 对应字段; +- WG 地址属于内部用户态 WG 栈,出站适配器没有建立将这些地址的任意本机服务流量转交 Android OS 应用的桥接;内部栈有 Listen 方法不代表 Android App 已在该内部栈监听; +- Android 的 [VpnService](https://github.com/chen08209/FlClash/blob/v0.9.2/android/service/src/main/java/com/follow/clash/service/VpnService.kt) 单独建立系统 VPN 接口,使用其固定 VPN 地址/路由,不会因为 YAML 里填了一个 WG `ip` 就自动把它变为 Android 本机服务地址; +- UDP 出站 socket 使用空的本地 AddrPort,不能由“支持 UDP”推出它具有 Surge 对 Apple Screen Sharing 的源端口透传和反向流投递行为。 + +由此判断:**不能只加一项 YAML 参数,就把 FlClash 当成已具备 Surge 同等本机入站能力的客户端。** 这仍是源码判断,未通过 Android 本机服务的反向连接实验。 + +[Mihomo WireGuard inbound issue #2864](https://github.com/MetaCubeX/mihomo/issues/2864) 在核对时仍 open,但它讨论的是 WireGuard 协议入站/服务端功能,与本项目所需 peer-access 并不完全相同,只作补充,不能单凭该 issue 得出全部结论。 + +## 容易混淆的开关和业务 + +[allow-lan 官方说明](https://wiki.metacubex.one/en/config/general/#allow-lan) 是让其他设备访问 Clash 的代理端口;它不会把 Android 的全部本机服务接到 WG 地址上。TUN 开启同样不是这个桥接能力的证明。 + +若安卓仅需主动访问 Mac,WG 出站可继续做独立验证;若需要 Mac/其他设备反过来访问 Android 的服务,则当前 FlClash 方案不能按已确认支持接入。还需区分普通 VNC 与 Apple VNC 高性能媒体,不将此前 Shadowrocket 的特定失败原因移植成对 FlClash 的已证实故障结论。 + +若改用独立的 Android WG VPN 处理系统层地址,需要另行设计与 FlClash 的共存方式。[Android 官方 VPN 文档](https://developer.android.com/develop/connectivity/vpn#vpn_service) 明确每个用户/工作资料只有一个活动 VpnService,启动另一个会停止原来的服务。因此不能默认“同时打开 FlClash VPN 与官方 WireGuard”即可解决。 + +## 对 Proxy Station 的影响 + +初次研究时尚无 Clash 设备 WG 序列化。后续已在本地补充独立分发用途、WG 出站和私网规则,排除动态公网组;默认订阅不附身份。配置仍需在 Android 真机上验证,不复用正在运行的 Mac/iPad 身份,也不恢复 Shadowrocket WG。 + +## 追加隔离对照(同日) + +`adb devices -l` 未发现已连接的 Android 设备,因此没有对 FlClash APK 或 Android 真机做网络验收。为验证“栈内监听”和“系统监听”是否自动等价,在本机使用该固定内核 `go.mod` 所锁定的 `mipstack` 提交 `9a80a07eaeb9`,构造两个直接交换 IP 包的隔离内存栈: + +1. 系统 `127.0.0.1` 打开 TCP 服务,虚拟对端连接另一栈的虚拟 IP、同一端口:连接失败,系统监听不会自动成为栈内监听。 +2. 维持上述系统服务,在虚拟栈上显式创建同一端口的 TCP listener:虚拟对端连接成功。 + +该对照没有运行 FlClash、Android 或真实 WireGuard 加密,也没有检查 UDP/Apple VNC;只能验证默认 MIPS 栈的隔离现象,并与前述固定版本源码的桥接缺口相互印证。测试没有修改本机活动 Surge、生产配置或节点。临时实验代码在 `/private/tmp/flclash-wg-research.8KkNAl/lab.go`,源码依赖为上游对应提交的本机临时副本。 diff --git a/docs/META-MUSE-RULES.md b/docs/META-MUSE-RULES.md new file mode 100644 index 0000000..7032e48 --- /dev/null +++ b/docs/META-MUSE-RULES.md @@ -0,0 +1,26 @@ +# Meta Muse AI 分流补充(2026-10-13) + +用户反馈 Meta 最新 Muse 产品不能完全代理,授权查询开源规则并加入生产 AI 分组。本次经管理 API 重读后,新增四条启用的自定义 DOMAIN-SUFFIX 规则:`muse.ai`、`meta.ai`、`meta.com`、`llama.com`,均指向 `🤖 AI`。快照见 [rules/meta-muse.list](../rules/meta-muse.list)。 + +## 来源与覆盖范围 + +- [Self-Configuration](https://github.com/ClashConnectRules/Self-Configuration/blob/main/Clash.yaml) 当前公开文件未查到 Muse、meta.ai 或 llama 专用规则。 +- [Sukka AI 清单](https://github.com/SukkaW/Surge/blob/master/Source/non_ip/ai.conf) 收录 `meta.ai`,现有生产 AI RULE-SET 已引用该清单;显式补充可固定关键入口的覆盖。 +- [metamuse-rules 域名清单](https://github.com/kkykkky/metamuse-rules/blob/main/MetaMuse.domainset) 收录 Muse 主站、Meta AI、Llama、开发者站、Graph 与账号服务,记录了跨出口造成资格校验失败的社区实测。该实测不是本机的业务验收。 +- [Meta 官方 Model API 文档](https://dev.meta.ai/docs/agent-frameworks) 使用 `https://api.meta.ai/v1`。 + +`muse.ai` 同时覆盖 `auth.muse.ai`;`meta.ai` 覆盖 API 与开发者控制台;`meta.com` 覆盖 `auth.meta.com`、`accountscenter.meta.com`、`gateway.accountscenter.meta.com`、`graph.meta.com`、`ai.meta.com` 与 `ai.developer.meta.com`,也会将其他 Meta 官网/账号请求交给 AI。没有复制上游共享 Facebook/Instagram 的宽泛 IP 兜底,后续若出现裸 IP 请求需依实际连接记录定向处理。 + +## 生产变更与验证 + +备份:`/opt/proxy-station/data/backups/before-meta-muse-ai-20261013-165833.db`;写入前已收紧并确认 root 属主、600 权限、非空。 + +规则由 176 条增至 180 条,178 条启用;新增项位于 seq 50–53,在 Unbreak 与停用 DOH 规则之后、原有启用 AI 区段之前。原规则内容和相对顺序均逐项比对不变,唯一启用 FINAL 仍在末尾。未改组成员、节点、设置或客户端已保存选择。 + +实际 HTTPS Surge、Clash、Shadowrocket conf 已成功拉取并核对四条 AI 域名规则;Surge self URL 与四组根域/通配 Host 到 Cloudflare DoH 的派生映射均已核对。官方 Surge CLI 与 FINAL、测速参数、控制口令、MITM 静态检查通过。在线规则集快照校验受到下载失败影响;本机没有可用的 mihomo,Clash 未进行原生解析验收。尚未验证 Muse 登录、对话、附件、实时功能、设备 DNS 或移动端连接,不宣称所有业务请求已完全覆盖。 + +客户端须刷新各自订阅/规则配置后生效。本次没有更新或切换本机活动配置,也没有更改 AI 的出口选择。本快照不自动跟随上游更新。 + +## 后续补充(2026-10-13) + +根据用户实际观察,新增 `DOMAIN-SUFFIX,metaaivm.com,🤖 AI`,覆盖根域及子域;此条来源为用户连接记录,未冒称上游清单或官方文档已收录。放在原四条 Muse 规则之后,保留其他规则的相对顺序。写入前在线备份为 `before-metaaivm-mobile-fixes-20261013-181717.db`(root:root、600)。最新生产计数与发布验收见 [移动端修复记录](MOBILE-CLIENT-FIX-2026-10-13.md)。 diff --git a/docs/MOBILE-CLIENT-AUDIT-2026-10-13.md b/docs/MOBILE-CLIENT-AUDIT-2026-10-13.md new file mode 100644 index 0000000..2b6b651 --- /dev/null +++ b/docs/MOBILE-CLIENT-AUDIT-2026-10-13.md @@ -0,0 +1,77 @@ +# Shadowrocket/FlClash 全面校验(2026-10-13) + +后续本地修复与回归见 [移动端修复记录](MOBILE-CLIENT-FIX-2026-10-13.md)。该候选尚未部署;下文保留生产审计时的行为与数量。 + +主要业务域名与 Finance 的分流结构正确;Clash 实际配置通过官方 mihomo 原生解析及隔离匹配测试。但两端并未完全实现 Surge 的全部规则与 AI DNS 行为,iOS Shadowrocket 真机编译、Android FlClash 的活动配置与 VPN 接管尚未验收。 + +## 生产变更 + +用户要求删除旧的手动 PayPal 规则。管理 API 确认 `DOMAIN-SUFFIX,paypal.com` 仍存在,已改指向 Finance;本次删除这条记录,保留 `DOMAIN-KEYWORD,paypal → 💰 Finance`。主站及子域仍匹配 Finance,其他规则逐项比对不变,没有重置规则或部署代码。 + +在线备份:`/opt/proxy-station/data/backups/before-remove-manual-paypal-mobile-audit-20261013-172618.db`,删除前已确认 root 属主、600 权限、非空。最新数据库 265 条规则(263 启用),13 个组、25 个成员,Finance 86 条。节点仍为 30 个登记、12 个启用;节点、组成员、设置及设备保存选择未修改。 + +## 官方依据 + +Android 目标已由用户确认为 FlClash。其[官方项目](https://github.com/chen08209/FlClash)基于 ClashMeta;[内核子模块](https://github.com/chen08209/FlClash/blob/main/.gitmodules)指向作者维护的 Clash.Meta FlClash 分支。本次查询的子模块提交为 `8597778c7df410ebe88c57e59eaab32d203156df`;用户设备的 App/内核版本尚未取得,不声称临时官方内核与手机内核逐字节相同。 + +配置审阅依据 [mihomo 路由规则](https://wiki.metacubex.one/config/rules/)、[策略组](https://wiki.metacubex.one/config/proxy-groups/)、[规则资源](https://wiki.metacubex.one/config/rule-providers/)、[DNS](https://wiki.metacubex.one/config/dns/)、[VMess](https://wiki.metacubex.one/config/proxies/vmess/)和 [Trojan](https://wiki.metacubex.one/config/proxies/trojan/)。官方规定规则按顺序匹配、DOMAIN-SUFFIX 包含根域和子域、NETWORK 区分 TCP/UDP;UDP 选中不支持 UDP 的代理时会继续往下匹配。正则只过滤动态引入的节点,不过滤显式组成员;默认选择未指定时为首项。 + +iOS 目标已由用户确认为 Shadowrocket。[官方 App Store 页面](https://apps.apple.com/us/app/shadowrocket/id932747118)确认域名、CIDR、GeoIP、DNS、用户代理规则、DNS 加密等能力,更新记录也确认逻辑规则和 QUIC 匹配存在。该页面链接的[开发者站](https://shadowlaunch.com/)没有完整配置语法手册或可调用的原生校验器。本次未将第三方手册或 Surge 编译器作为 Shadowrocket 官方语法证据;`policy-regex-filter` 等细节仍须 App 内帮助及真机编译/匹配确认。 + +## 检查范围与结果 + +所有真实 token、密码、UUID、节点主机、Host 映射及配置只保存在权限受限的私有临时目录。实际 HTTPS 四类订阅均成功下载、非空、结构正确,删除的规则均不再下发;Surge 的 self URL 正确。 + +| 检查 | 结果 | +|---|---| +| 本地生成器与生产产物 | Clash、Shadowrocket 节点订阅、Shadowrocket conf 逐字节一致 | +| 节点 | 两端各 12 个,6 VMess+6 Trojan,均 WS/TLS;无排除;分享 URI 的凭据及连接字段往返一致 | +| 安全/UDP 字段 | Clash 12 个节点均 udp=true,未启用跳过证书校验;Shadowrocket 当前节点 skipCertVerify=false | +| 组与引用 | 13 组,所有引用存在;地区动态组各 4 节点,两个独立全节点组各 12;Finance 美国、日本、新加坡、DIRECT,默认美国,无 All Nodes | +| Clash 正文 | 261 条规则、49 个 provider,唯一末尾 MATCH | +| Shadowrocket conf | 257 条规则、46 个远程 RULE-SET,唯一末尾 FINAL;策略名裸写,无 Surge use= 引用 | +| 外部资源 | 95 个不同 URL 全部下载;Clash 资源 157,260 个条目;Shadowrocket 资源 24,000 个条目 | +| 官方 mihomo | v1.19.32 darwin arm64;官方发布 SHA256 核验后仅解压至临时目录。原订阅和 file-provider 快照配置均通过 -t | +| 全部 Clash 资源子规则 | 展开为 157,472 条规则,补齐官方 Country/ASN 数据后,通过原生解析 | +| 隔离内核运行 | 49 个真实 file-provider 初始化,无错误;动态组与 Finance 默认值经 API 核对 | +| 原生路由 | 145/145 个受控 TCP/域名/IP 用例匹配预期策略链;额外 googlevideo UDP 用例原生匹配 AND→REJECT | +| Shadowrocket 静态路由 | 最后一次共 143 个域名样例;按域名/后缀/关键词/通配规则及资源展开核对。不是原生 Shadowrocket 引擎;不模拟 IP 解析、进程、协议或设备缓存 | +| Surge 回归 | 官方 CLI、49/49 外部资源快照通过,静态域名遮蔽提示 0 | +| 现有移动端测试 | 2 个测试文件、8 项 Vitest 通过,包括新取得的 mihomo 原生解析用例 | + +原生路由测试使用独立 localhost 入站与 controller,不开 TUN、不改系统代理;原规则顺序、provider 内容和策略组保持,但所有节点传输临时替换成本地 SOCKS 模拟端点,DNS 为受控主机映射。没有使用真实节点连接支付服务。该结果证明受控元数据下的内核规则/策略选择,不能证明真实节点可连接、地区出口稳定、真实解析出的 IP 路由或手机 VPN 接管。少数 DIRECT 域名使用受控本机地址,不能据此推断其真实公网 IP 的 GEOIP 路径。 + +## 明确的差异与缺口 + +1. Clash 跳过 2 条:`PROTOCOL,STUN → REJECT` 和 SMTP 文本 `URL-REGEX → DIRECT`。NETWORK TCP/UDP 无法代替应用协议 STUN,不能称全局 WebRTC 防护已通过。 +2. Shadowrocket 跳过 6 条:STUN、googlevideo UDP AND、reject/download/cdn 三个 DOMAIN-SET、SMTP 文本 URL-REGEX。这是生成器的保守范围,不等于 App 不支持逻辑规则。其 googlevideo 域名静态路径是 Streaming,不能继承 Clash 的 UDP 拒绝结果。三个 DOMAIN-SET 缺失会改变部分广告、下载和 CDN 覆盖,即使相邻 RULE-SET 可能覆盖部分域名,也没有证明完全等价。 +3. Shadowrocket 仍下发 `RULE-SET,LAN,DIRECT` 及 macOS 的 FFSB 进程例外。没有取得其在 iOS 的原生语法/匹配证据;macOS 进程规则不能用于证明 iOS 分流。远程资源还含 IP-ASN、USER-AGENT、PROCESS-NAME、URL-REGEX 等类型,资源下载成功与域名模型通过均不证明这些类型已由 iOS 正确编译。 +4. Clash DNS 为国内 DoH+fake-ip,没有 nameserver-policy/respect-rules 的 AI 随出口配置。Shadowrocket 的 General 使用国内普通 DNS,未继承 Surge 派生的 Cloudflare AI Host 映射。依据 [mihomo DNS 官方说明](https://wiki.metacubex.one/config/dns/),DNS 连接遵守路由需要相应配置及 proxy-server-nameserver。因此两端业务连接进入 AI 不代表 DNS 查询也随 AI 出口,不能沿用 Surge 的 Net.Coffee 无泄漏结论。 +5. 两端 FINAL/MATCH 没有 Surge 的 dns-failed 语义;不同内核的 DNS 失败、预匹配、REJECT 变体与 UDP 行为不能由字符串相近认定等价。Finance 选择美国地区组,该组为 url-test,首项美国也不等于固定单一节点/出口 IP。 +6. Shadowrocket 动态正则会从 App 中所有订阅收节点,All Nodes/AI Nodes 可能包含本服务之外的节点;此次 12 个成员只是本订阅的预期范围。手机已保存的组选择不一定因刷新订阅变回美国。 + +以上未通过修改生产设置、泛拦 UDP、加入全局 DNS 接管或擅自扩大生成器语法来掩盖;本次范围为删除指定规则与审计。 + +## FlClash 的活动配置需另外核对 + +其[官方配置合并源码](https://github.com/chen08209/FlClash/blob/main/lib/common/task.dart)会将 App 的 mode、TUN、监听端口及其他运行设置覆盖到原配置;DNS 覆盖或追加系统 DNS 开关也能改变解析。自定义新增规则可置顶,非空的规则覆盖列表可替换订阅 rules,代理与组覆盖可替换对应列表。脚本覆盖同样需检查。由此可见“订阅写 mode: rule”不足以证明手机正在按它分流。 + +## 真机收尾矩阵 + +先更新 Shadowrocket 的节点订阅和独立 conf,并选择使用该 conf;FlClash 更新并启用 Clash 订阅,确认运行在规则模式。记录 App/内核版本、活动配置、相关组保存选择、覆盖设置和 Android 分应用接管范围。以下请求通过手机浏览器或对应 App 发起,查看其连接/请求记录中的命中规则和完整策略链;不需要支付、转账或重新注册账号。 + +| 用例 | 预期策略/关注点 | +|---|---| +| PayPal 主站与 paypalobjects.com | Finance;删除旧规则后命中 paypal 关键词 | +| Plasma 官网、plasma-one.tech API/WS、auth.privy.io | Finance;核对 App 登录/会话的其他实际请求 | +| Muse 主站/登录/Meta API | AI | +| Claude、ChatGPT、Gemini、ip.net.coffee 探测 | AI;另检查 DNS 日志与实际探测结果 | +| Telegram 域名及数据中心 IP | Proxy | +| Netflix、YouTube、Disney、TikTok、GoogleSearch | Streaming,受更早 AI/例外规则影响 | +| Google 更新/翻译、App Store 例外 | 以规则命中及 DIRECT 最终路径确认,不凭页面成功推断 | +| Bybit | 独立 Bybit 组,未迁入 Finance | +| 无已知规则的请求 | FINAL/MATCH→NoAuto→Mainland→DIRECT | +| googlevideo TCP/UDP | FlClash TCP 通常 Streaming,UDP 应拒绝;Shadowrocket 当前不下发该 UDP 防护 | +| IPv6、DNS/WebRTC、Wi-Fi/蜂窝切换 | 独立实测;不能用 IPv6 开关或 TCP 页面成功代替 | + +没有接入用户的 iOS/Android 设备,本次未读取其活动配置、记录、VPN 权限或保存选择。完成上述原生设备核对后才能将“订阅与受控内核正常”提升为“这两台手机实际按预期分流”。 diff --git a/docs/MOBILE-CLIENT-FIX-2026-10-13.md b/docs/MOBILE-CLIENT-FIX-2026-10-13.md new file mode 100644 index 0000000..94c7962 --- /dev/null +++ b/docs/MOBILE-CLIENT-FIX-2026-10-13.md @@ -0,0 +1,54 @@ +# 移动端生成器修复与验收边界(2026-10-13) + +本轮按用户要求,仅修复有官方规范或原生验证支持的项目。代码已按用户后续授权全部部署到 LA;同轮新增用户观察到的 metaaivm.com → AI,未更改节点、策略组、设置或客户端活动配置/保存选择。[先前审计](MOBILE-CLIENT-AUDIT-2026-10-13.md)描述修复前的生产下发行为;本文件保留候选验证记录并补充发布验收。 + +## 已实现的修复与依据 + +| 项目 | 实现 | 依据 | +|---|---|---| +| Clash AI DNS | 从启用的 IR 域名规则派生有序 nameserver-policy,AI 使用 Cloudflare DoH 并通过 `#策略组名` 绑定 AI;普通 DNS 保持原国内 DoH;独立 proxy-server-nameserver 防止节点解析依赖 AI 连接形成循环 | [mihomo DNS 文档](https://wiki.metacubex.one/config/dns/)、[v1.19.32 配置解析源码](https://github.com/MetaCubeX/mihomo/blob/v1.19.32/config/config.go)、[DNS 匹配源码](https://github.com/MetaCubeX/mihomo/blob/v1.19.32/dns/resolver.go) | +| DNS 优先级 | 连续同解析器的显式域名规则合成 inline classical provider;远程资源复用原 provider,均按连接规则顺序写入 DNS policy。早于 AI 的非 AI 域名/资源继续用普通 DNS;同资源首次出现优先;IP/进程/协议条件不转换为 DNS 条件 | 官方源码的 orderedmap、domainMatcherPolicy 与只匹配域名的 rule-set 实现;避免直接使用域名 trie 把“更精确”误当成“更早” | +| Shadowrocket DOMAIN-SET | 服务端将精确主机转换为 DOMAIN,前导点转换为 DOMAIN-SUFFIX;通过已使用的 RULE-SET 引用新的 token 鉴权 `.list` 端点,保留原规则位置和策略 | [Surge 域名集规范](https://manual.nssurge.com/rules/domain.html);Shadowrocket [官方 App Store](https://apps.apple.com/us/app/shadowrocket/id932747118)确认普通域名/后缀规则能力,不假定其原生 DOMAIN-SET 语法 | +| Shadowrocket LAN | 将 [Surge 官方文档当前列出的 LAN](https://manual.nssurge.com/rules/ruleset.html) 展开为 14 条普通 DOMAIN-SUFFIX/IPv4/IPv6 CIDR 规则,保留 policy、位置与原 no-resolve 参数 | 使用发布的具体条目,不猜接收客户端内置 LAN 或 GEOIP,LAN 的写法;这是文档当前版本的固定映射,未来需复核 | +| macOS App 路径 | Shadowrocket 跳过 `/Applications/*.app/…` 的 PROCESS-NAME,并回报 skippedRules;不删除数据库记录,不改 Surge/Clash | 该绝对路径属于 macOS App bundle,不能标识用户目标 iOS App;其他进程规则及远程资源保持原状 | + +新增 `.list` 仅处理已登记、behavior=domain 的资源,复用现有 24h/ETag/陈旧缓存逻辑。未知 token/资源返回 404;非法上游格式失败,不返回已转换的一部分以掩盖覆盖缺失。原 Clash `.yaml` 转换逻辑保留。 + +## 对既有功能的回归 + +使用管理 API 新近重读的生产数据(265 条规则,263 启用)构造 IR,与先前实际 HTTPS 产物比对: + +- **Surge 候选与生产配置逐字节相同**;官方 CLI、49/49 外部规则集快照通过,静态域名遮蔽提示 0。没有修改 Surge 生成器、AI DNS 辅助器、模板、数据库或原文段落。 +- Shadowrocket 节点订阅逐字节相同;General、Proxy Group、Host、URL Rewrite、MITM 段落也逐字节相同。节点范围、凭据、排序、独立 All Nodes/AI Nodes 与组选择身份保持。 +- Clash 的 12 个节点、13 个组、261 条连接规则及原 49 个 HTTP provider 全部不变;新增 3 个 inline DNS matcher,provider 总数为 52。不改变普通 DNS 的 nameserver,不开启全局 respect-rules,不增加端口 53 接管或 STUN 替代规则。 +- 三个实际域名集完整转换:reject 140,433 条、download 1,972 条、cdn 4,686 条,共 **147,091 条,零丢弃**。候选 Shadowrocket 正文为 271 条;新增 3 个资源引用、LAN 由 1 条展开为 14 条,另跳过 Tailscale 与 FFSB 两个 macOS App 路径。 +- IR 在各序列化器调用后保持不变;其他规则、显式成员、Finance 顺序和默认美国保持。 + +## 验证结果 + +- 三个包全量 typecheck 通过;11 个测试文件、**71 项 Vitest** 通过,包含 token 鉴权、HTTPS 来源、新旧转换端点兼容性、无部分转换输出、域名集精确/后缀语义、DNS 规则顺序与资源名冲突、LAN 参数以及原移动端/Surge 回归。 +- 生产快照候选通过官方 mihomo v1.19.32 原生解析。49 个远程 provider 使用既有下载快照;新增 inline matcher 原生加载通过。 +- 隔离 mihomo 使用同样的连接规则与资源、localhost 模拟节点:**145/145 个 TCP/域名/IP 用例通过**,googlevideo UDP 原生 AND→REJECT 继续有效。未使用真实节点传输、系统代理或 TUN。 +- 另用 localhost DoH 与美国/新加坡模拟 SOCKS 出口实测 **9/9 个 DNS 查询**:早期精确 DIRECT 与 Unbreak 资源例外走普通 DNS;AI 后缀、关键词、AI 资源走 AI DoH;较晚的精确 DIRECT 不抢过早期宽泛 AI;代理节点解析走独立普通 DNS;新建 DNS 连接随 AI 从美国切到新加坡。 + +DNS 原生测试确实观察到:已有 DoH HTTP 会话可能在切组后复用旧连接,新建连接才采用新选择。本实现没有杜撰“关闭复用”参数,也不主动断开用户连接。不能声称切组立即迁移所有现存 DNS 会话;新连接的选择不等于设备所有应用都使用该 DNS。 + +## 保留的待验/未修复项 + +1. Shadowrocket 的逻辑规则/UDP、按策略 DNS、STUN 仍没有取得可核实的完整语法与真机结果。官方更新记录确认能力存在,但不能据此复制 Surge/mihomo 的写法。本轮没有加入猜测配置;其 DNS General 保持现有值,**iOS AI DNS 随出口仍未实现**。 +2. STUN 在 Clash 没有 NETWORK 等价物;SMTP 的文本 URL-REGEX 不能表达邮件协议。继续保守跳过,不用宽泛 UDP 拒绝或其他条件冒充修复。 +3. Shadowrocket 当前候选 skippedRules 为 5 条:两个 macOS App 路径、STUN、googlevideo UDP AND、SMTP URL-REGEX;不再因 DOMAIN-SET 丢掉三个域名集。其他远程资源里的 PROCESS-NAME/USER-AGENT/IP-ASN 等仍须 iOS 原生确认。 +4. Shadowrocket `.list` 端点及大域名资源已经通过服务端转换和路由测试,但没有 iOS 原生编译、资源加载性能/内存或网络验收。部署后三个生产 HTTPS `.list` 端点均已验证,与完整转换内容逐字节相同。 +5. FlClash 会按自身模式、DNS 覆盖、自定义规则/组和分应用接管设置改写活动配置,见 [官方配置合并源码](https://github.com/chen08209/FlClash/blob/main/lib/common/task.dart)。用户 Android 的具体版本/覆盖设置、iOS 的活动 conf 与保存选择均未读取。 + +生产 HTTPS 订阅与转换端点已验收;iOS/Android 仍需刷新配置后复测资源加载、Finance/AI/Telegram/Streaming、DNS 与 UDP。原生解析与隔离模拟出口验证不等于真实设备业务验收。 + +## 发布与实际 HTTPS 验收 + +2026-10-13 已执行 `deploy.sh all`,版本基线 `f741aee`,包含本次未提交工作区修改(未执行 Git 提交或推送)。前后端均发布;全量类型检查、71 项测试、前端构建通过。前端指纹 `e11cf89c7ea8`、后端源码指纹 `304e7d23b862` 与远端一致,systemd active,公网 health 正常,未鉴权管理请求为 401。 + +新增 `DOMAIN-SUFFIX,metaaivm.com,🤖 AI` 位于原 Muse 四条之后,其他规则内容及相对顺序逐项核对不变;旧手动 paypal.com 条目仍不存在。生产为 30 个登记节点/12 个启用、13 个组、25 个显式成员、266 条规则/264 个启用,唯一启用 FINAL 仍末尾。备份 `before-metaaivm-mobile-fixes-20261013-181717.db` 在写入前及部署后均确认 root:root、600、非空(部署脚本递归调整应用目录属主后已恢复该备份的 root 属主)。 + +实际公网 Surge、Clash、Shadowrocket 节点与 conf 四个订阅逐字节等于本地候选。Surge 与添加新域名后、代码部署前的实际 HTTPS 版本逐字节相同;官方 CLI 与 49/49 外部资源快照再次通过,静态域名遮蔽提示 0。三个新 HTTPS `.list` 端点均与 147,091 条完整转换内容逐字节相同。Clash 当前 262 条连接规则、52 个 provider;Shadowrocket conf 272 条规则,节点与组保留。 + +实际 Clash HTTPS 配置通过官方 mihomo v1.19.32 原生解析;使用相同规则和资源快照的隔离 localhost 模拟出口复测 **146/146** 个域名/IP/TCP 用例(含 metaaivm.com),googlevideo UDP 的原生 AND→REJECT 通过。未启动系统代理/TUN,未切换客户端活动配置,未进行真实节点金融操作或手机验收。 diff --git a/docs/SHADOWROCKET-APPLE-VNC-REPRO.md b/docs/SHADOWROCKET-APPLE-VNC-REPRO.md new file mode 100644 index 0000000..7f77aab --- /dev/null +++ b/docs/SHADOWROCKET-APPLE-VNC-REPRO.md @@ -0,0 +1,49 @@ +# Shadowrocket WireGuard / Apple VNC 对照记录 + +2026-11-02 后续结论:用户已在 macOS/iPadOS/iOS 全面验证 Surge + WireGuard 可工作,停止 Shadowrocket WG 兼容。本文件保留历史复现与排查证据,不再作为继续实现的任务;相关 WG 下发路径已在本地清理,发布状态见 [实现记录](WIREGUARD-DEVICES.md)。 + +状态:2026-11-01,尚未在 Shadowrocket 上解决。本文不含真实密钥、订阅 token 或公网端点;没有向开发者发送反馈。 + +2026-11-02 接续说明:用户已将个人设备统一为 Surge,本文保留为历史对照与未决问题记录,不是当前设备管理实现的阻塞项。最新方案与代码入口见 [实现交接](design/wireguard-devices/HANDOFF.md);不据此声称 Shadowrocket 问题已经修复或新 Surge 三端功能已验收。 + +## 目标与环境 + +让 OpenScreenSharing 的 Apple VNC 高性能模式通过 iPad Shadowrocket 的 WireGuard 节点工作,并保留既有公网分流。 + +- iPad Pro,Shadowrocket 2.2.93 (3488),UDP 转发开启、禁用 STUN 关闭。 +- 同设备官方 WireGuard 使用相同身份的标准配置,Apple VNC 已成功,用户确认速度良好。 +- Mac:Surge 6.11.0 (12660),allow-peer-access/旧 accept-incoming 已生效。官方该 build 说明包含 Apple Screen Sharing 特殊处理。 +- Linux hub:内核 WireGuard,同接口转发两个 peer,仅允许私网,未做公网 NAT。 +- 私网示例为 iPad 10.203.0.2、Mac 10.203.0.3,Apple VNC 连接 TCP 5900、媒体使用 UDP 5900/5901。 +- OpenScreenSharing 与官方 WireGuard 的具体 App 版本尚未单独记录。 + +## 已验证事实 + +1. Shadowrocket 节点已导入、握手正常;普通 VNC 快速可用,账号 RSA-SRP 认证成功。 +2. Apple VNC 在 Shadowrocket 下仍加载/黑屏。服务器抓到双向 UDP,iPad 反馈源端口保留为 5900/5901;不能泛称所有 UDP 被阻断或源端口完全不保留。 +3. 一次受控样本:hub 入站 4,858 个视频 RTP 唯一序号,序号跨度 4,858、缺口 0%;4,288 组 IPv4 分片全部完整;iPad 同期报告约 200 Kbps、90% 丢包。 +4. 同一 iPad 换官方 WireGuard 后 Apple VNC 成功;服务器观察到相同公网出口、同一 peer 身份,Mac 与 hub 保留 MTU 1280。 +5. 后续完整 MTU1420 对照中,两个 token、Mac effective 配置、实际 Shadowrocket YAML 与 hub 运行 MTU均核对到1420,日志确认 iPad 重新拉取节点订阅;iPad UI 字段值尚未单独回报。99.311 秒捕获14,596个IP包、0分片,固定源端口正确;期间三次媒体 SSRC 的 hub 入站 RTP 缺口分别为0%、4.348%、60.437%,不能合并成“链路始终无丢包”。用户最后报告仍无画面、0%丢包和约200Kbps,需区分没有媒体样本与确实无缺口。 +6. 所有远端/生产临时 MTU 改动已自动恢复1280,原生客户端成功配置未覆盖。 + +## 依据与解释范围 + +- [OpenScreenSharing 作者关于固定源端口和反向接收的说明](https://github.com/nekohasekai/open-screen-sharing/issues/2)。该评论有版本/场景范围,不能据此认定当前所有代理客户端永久不兼容。 +- [Shadowrocket 官方网站](https://shadowlaunch.com/) 由 App Store sellerUrl 确认;官网链接指向公告频道。 +- [build3488说明](https://t.me/ShadowrocketNews/1665)、[2.2.93正式版](https://t.me/ShadowrocketNews/1666),未找到声明修复此 Apple VNC 症状的内容。2.2.94 build3490–3493 的公开说明也未见此修复;这不等于开发者确认存在同一bug。 +- 历史[raw socket MTU修复3215](https://t.me/ShadowrocketNews/1454)、[读取长度调整3216](https://t.me/ShadowrocketNews/1455)、[L3 UDP fast relay3399–3400](https://t.me/ShadowrocketNews/1593)表明相关实现曾变动,不能将其当作当前已确认缺少功能的证据。 +- 源码中 Network 为 TCP SRTT/2;视频丢包率来自成功 SRTP 接收的 RTP 序号跨度,画面重组需要同步帧。没有成功媒体样本时,显示0%不代表完整画面链路已通。 + +## 仍待验证 + +- Shadowrocket 设置→代理→代理类型的实际选项与当前状态。社区手册描述 None 为TUN-only、对应compatibility-mode=3;需以当前版本UI/实测验证。尚未修改此设置或在生产强加该参数。 +- 用同一网络、同一配置、同一画质与匹配时间的抓包验证 TUN-only 是否改变原始UDP/L3路径。 +- 最小规则与标准WG配置直接导入Shadowrocket,排除配置转换、其他规则或本地设置干扰。 +- HTTPS日志曾见一台iPhone下载iPad身份;已询问是否并发启用。一次25秒endpoint监测稳定,尚无并发争用证据,不能将此当作早期故障解释。 +- 未确认具体根因是片段接收/重组、反向包投递、媒体反馈还是其他核心实现问题。 + +## 复现与恢复 + +同一身份只开启一种客户端。先在原生WireGuard确认Apple VNC能工作,再关闭它并开启Shadowrocket,通过同一IP连接;记录节点导入格式、实际MTU、代理类型、Apple VNC画质与同一时段的帧率/码率/丢包。所有实验保留独立成功配置,失败后恢复原值。 + +数据库与节点备份路径、详细时间和既有验证见 [WireGuard组网记录](WIREGUARD-LAN.md)。若后续需要反馈给开发者,先补齐TUN/最小配置结果与App版本;只附脱敏配置及经核对的抓包,不附密钥或完整订阅。 diff --git a/docs/SURGE-FFSB-WIREGUARD-REVIEW-2026-11-02.md b/docs/SURGE-FFSB-WIREGUARD-REVIEW-2026-11-02.md new file mode 100644 index 0000000..c1dfca4 --- /dev/null +++ b/docs/SURGE-FFSB-WIREGUARD-REVIEW-2026-11-02.md @@ -0,0 +1,23 @@ +# Mac Surge、FFSB 与 njcq WireGuard 二次检查 + +2026-11-02 约 15:48–15:51(UTC+8),只读检查;没有改变订阅、规则、策略选择、FFSB 设置或客户端配置,没有重启或重连。私密原始配置、请求和测试记录仅留在本机权限 700/600 的 `/private/tmp/surge-wg-ffsb-audit.*`,不入仓库。 + +## 配置和规则 + +- 本机 Surge 6.11.0 build 12670,活动 `Proxy Station`,增强模式及规则模式开启。活动托管 URL 带 Mac 设备参数,HTTPS 下载内容与本地托管文件逐字一致;有效配置含设备 WireGuard 身份。`🦀 Claude → 🦁 FFSB`,`🤖 AI → 🧠 AI Nodes → US LA Trojan Direct`,`🚀 Proxy → Japan → JP Trojan Direct`。 +- 对照生产默认订阅和当前 Mac 设备订阅:General、Host 完全一致;从设备订阅移除两条 10.203 私网规则后,Rule 段逐字一致;三条地区组正则只增加排除 WG 节点的过滤。实际 All Nodes 和 AI Nodes 各有相同 12 个公网节点且不含 WG,Japan 组有 4 个日本节点,不含 WG。 +- Mac 自身 `10.203.0.3/32 → DIRECT` 位于 `10.203.0.0/24 → NJCQ WireGuard LAN` 之前;iPad 和网关地址命中 WG。Surge 报告 WG 握手完成,实时策略测试 RTT 4 ms;这不能代替各设备之间实际 VNC 测试。 +- 25/25 项 FFSB 既定域名样本由 Surge 原生规则解释命中 FFSB(通配项仅选一个代表样本),不在 always-real-ip;系统解析 API 得到 fake-IP,Surge DNS trace 使用 100.70.255.254。FFSB daemon 的真实可执行文件路径规则命中 DIRECT,先于普通 STUN 防护;Tailscale 进程例外也命中 DIRECT。针对 1.1.1.1 的既有 AI IP 特例排在 daemon 规则前,因此不能表述为该进程任何目标都必然 DIRECT;实际 FFSB 隧道 DNS 为 100.70.255.254,当前检查未发现由此引起的故障。 +- 集合外 downloads.claude.ai、anthropic.auth0.com 和 Claude 专用 Datadog 命中隐藏 Claude 组,再走美国 AI;GitHub、Google 和 Telegram 样本走普通代理,ChatGPT 走 AI;LAN 样本直连。`translate.googleapis.com` 与 `dl.google.com` 命中 Unbreak DIRECT,FINAL 为 NoAuto → Mainland → DIRECT。YouTube/Netflix 样本进入 Streaming。规则解释是抽样,不代表穷举全部域名、IP、进程与规则集。 +- 实际设备 HTTPS 配置和 49/49 个最新规则集快照通过官方 Surge CLI;13 条静态域名遮蔽提示对应既定 25 项 FFSB 优先于较宽 Claude 内部集合,与发布前默认配置提示数量和业务意图一致。当前生产节点、组、规则、settings 与本次发布前快照相同;只有一个启用订阅。 + +## 实际新连接与发现 + +FFSB 0.1.27 daemon/管理和信令 Connected,4/5 peer Connected;`disableDns=true`、`disableClientRoutes=false`,utun100 MTU 1280。FFSB 状态不是业务可用的证明。 + +- 两轮系统入口及显式本机 Surge 代理的 `api.ipify.org` 均 HTTP 200,出口一致;GitHub 200、Google 204、集合外 Claude 下载地址 403、ChatGPT 403 均收到响应。403 只证明 TLS/HTTP 传输,不能证明登录或业务成功。 +- 与此同时 `api.anthropic.com` 和 `claude.ai` 的新连接在普通系统入口及显式 Surge 入口反复失败:第一次分别在 8 秒连接预算内超时,之后两轮四种路径均在约 10 秒 TLS 连接失败。规则解释仍正确指向 FFSB。 +- 独立对照:通过 FFSB DNS 获取真实 IPv4,再将 curl 显式绑定 utun100,两个目标在 12 秒 TCP 连接预算内超时;相同方式访问 ipify 则 HTTP 200。单次用 Surge HTTP probe 显式指定 AI Nodes(不改变组选择)时,API 返回 404、claude.ai 返回 Cloudflare 403,均在约 0.8 秒收到响应。这将异常收窄至 FFSB 到这两个目标的链路或相关解析/转发,不能确定服务商、路由、目标、DNS 等更细根因,也不能凭成功的 ipify 或管理状态宣布 FFSB 整体正常。 +- FFSB 当前 Selected 清单为 27 项,比本项目既定 25 项多 `hcaptcha.com`、`*.hcaptcha.com`。根域目前命中 FINAL → DIRECT,`newassets.hcaptcha.com` 命中 CDN → Proxy,日本;本次各有一次 302/200 响应,不代表完整验证码或登录业务已通过。这一差异在此前检查已经存在,不是本次 WireGuard 改动产生;未擅自扩展既定分流范围。 + +本轮没有切组、重启 Surge 或重连 FFSB;没有测试真实计费模型请求、iPad 到 Mac 的 VNC/媒体、所有规则集流量及长时间/睡眠/换网。此前记录的 FFSB 间歇性连接问题仍不能视为已解决。若用户临时需要使用 Claude,可手动将 `🦀 Claude` 选择现有 AI/国家节点;这样只改变现有用户可见策略,FFSB 未选用期间不会自动回退。对 hCaptcha 是否应纳入 FFSB,需结合实际验证码业务及用户对范围的决定处理。 diff --git a/docs/SURGE-PRIVACY.md b/docs/SURGE-PRIVACY.md index a5e7c5c..3bdcb0e 100644 --- a/docs/SURGE-PRIVACY.md +++ b/docs/SURGE-PRIVACY.md @@ -33,3 +33,8 @@ AI 可以选择地区组或独立的 `🧠 AI Nodes`;后者与 All Nodes 的 导入时关联 General、Proxy、Proxy Group、Rule、Host,并开启增强模式。无需 MITM 证书。更新后清除 DNS 缓存并复测 Surge DNS 请求日志;生成配置验证不是设备泄漏测试。 参考:[Host DNS 映射](https://manual.nssurge.com/dns/local-dns-mapping.html)、[加密 DNS](https://manual.nssurge.com/dns/encrypted-dns.html)。 + + +## 2026-10-19 Claude 独立出口 + +Claude 已从 AI 独立为 `🦀 Claude`,其合并域名通过生产手写 Host 使用1.0.0.1 DoH,并由同组承载DNS连接;AI派生逻辑未修改。同日后续已改为 [手动订阅方案](FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md):25 项在 Surge 产物中映射至 FFSB DNS,但保持 fake-IP 接管;选远端代理时由远端解析,不依赖 FFSB。设备须关闭 FFSB 对系统 DNS 的管理并重连。集合外 Claude 及其 DoH 传输始终经隐藏组转 AI Nodes;不再运行本机协调脚本。 diff --git a/docs/WIREGUARD-DEVICES.md b/docs/WIREGUARD-DEVICES.md new file mode 100644 index 0000000..6488ecf --- /dev/null +++ b/docs/WIREGUARD-DEVICES.md @@ -0,0 +1,133 @@ +# WireGuard 设备管理:实现与生产验收 + +2026-11-02:已部署 LA 前后端及 njcq 网关 agent,两个原有 peer 已只读核对并确认纳管。**未切换本机 Surge,未对现有 peer 执行暂停、恢复或重置测试**。产品设计见 [DESIGN](design/wireguard-devices/DESIGN.md),历史设备网络验收见 [WIREGUARD-LAN](WIREGUARD-LAN.md)。本文件记录实现及后续运维边界。 + +2026-11-02 用户后续验收:已在 macOS、iPadOS、iOS 上全面验证 Surge + WireGuard 可工作。由于 Shadowrocket 的 Apple VNC 路径未通过,停止其 WG 下发兼容,仅保留普通代理能力。本轮清理已完成本地验证,尚未重新部署,详见文末。 + +## 身份、迁移和密钥 + +- `wg_networks` / `wg_gateways` 保存网段和现有网关;不初始化示例网关、设备、凭据。新建只支持 njcq `10.203.0.0/24`、MTU 1280;`.1` 为网关,`.2/.3` 为现有设备保留,新设备从 `.4` 开始。归档后地址不复用。 +- `wg_devices` 独立保存名称、系统、公钥、固定地址、期望状态和应用版本。私钥及 PSK 随完整 profile 使用 AES-256-GCM 加密。没有客户端字段。 +- 启动迁移仅纳管旧库 `sub_tokens.wireguard` 中实际存在的身份,保留 key、PSK、IP、MTU 及旧 URL 的入站设定(新派生 Surge 链接统一启用入站);将旧 token 映射到 `wg_device_id` 并清空旧 JSON。只创建只读 `adopt` 核对任务,不写远端。迁移事务幂等,冲突则失败,不替换已有身份。 +- 加密主密钥默认在 `DATA_DIR/wg-master.key`(600),也可由启动环境提供 32 字节 Base64 的 `WG_MASTER_KEY`。已经存在加密身份时,丢失/错误密钥会报错,不生成替代身份。数据库新建目录 700、数据库 600。`secure_delete` 清理迁移后的数据库旧字段,但历史备份/WAL 仍按含真实密钥处理。 +- **备份必须同时保全 SQLite 和主密钥**,主密钥建议另存受控离线备份。不能只恢复数据库,也不能在部署时重新生成/随意轮换主密钥。首次生产迁移前已按 update-db 技能在线备份,迁移后另备份加密数据库与主密钥,路径见下方生产记录。 +- `/api/tokens` 列表不返回旧 profile;POST/PUT 不再接受 `wireguard` 写入。普通订阅编辑只改名称/节点范围,不改变旧设备兼容映射。新增设备与绑定均走独立 API。 + +## 订阅与导出 + +普通共享入口逐字保持 `/sub/:token/surge`,未附加时无 WG 节点、段落、私网规则或设备密钥。历史设备专属 token 是明确的兼容例外,仍沿用原 URL 下发其原设备身份。 + +管理员在 Surge 格式下选择设备,通过 `POST /api/tokens/:id/wg-bindings`(`{deviceId}`)签发 `/sub/:token/surge?wg=<32字节随机凭证>`。数据库只存 SHA-256 哈希、订阅 ID、设备 ID、用途和创建/访问/撤销时间;每次签发独立凭证,当前页面内复用同一组合,不在浏览器持久化。没有全局当前设备或共享 token 身份切换。 + +`wg` 校验订阅绑定、Surge 用途、撤销状态和设备可下发状态;裸 ID、空参数、篡改/跨订阅凭证、暂停/归档均拒绝,不降级纯代理。未知/重复 Surge 查询参数返回 400。托管 header 保留完整的、已经验证过的 `wg` 参数及请求 origin。切换设备/清除选择/切换格式不改变其他已生成 URL。新设备身份不附加到其他格式。 + +- 设备下拉保留不可选状态;采集未知不等于暂停,已生效的离线/未知设备仍可使用。 +- 设备详情 `POST /api/wg/devices/:id/exports` 返回标准 `.conf`。二维码使用浏览器本地 `qrcode` 包生成,不调用在线服务;关闭导出框清理页面内配置。两种导出引用同一身份,不创建新 peer。 +- `DELETE /api/tokens/:id/wg-bindings/:bindingId` 单独撤销链接;网页可撤销当前领取的链接并重新签发。撤销链接不等于撤销已导入 peer,停止网络接入必须暂停设备。 +- 敏感管理/订阅响应 `private, no-store`,全站响应 `Referrer-Policy: no-referrer`,不记录配置正文。生产反代需让 `/wg-agent/` 到达 server,并关闭/脱敏 `/sub/` 的完整 URI 访问日志,避免路径 token 和查询凭证进入日志;不把凭证接入分析系统。 +- WG 不加入公网动态/静态代理池;动态 regex 额外排除 WG 策略名。只增加自己的 `/32` DIRECT(入站)和组网 `/24` 规则,原节点范围、DNS、FFSB 和 FINAL 保持原流水线。 + +## 任务和状态 + +`wg_jobs` 是持久化的每设备版本任务,`wg_events` 保存操作记录,`wg_observations` 保存最近采样、基线和累计有效增量。创建请求使用 UUID `requestId`,重复请求返回同一设备/IP/任务。暂停/恢复/重置/移除互斥执行,未完成任务先重试;重试不更换身份或任务 ID。 + +网关确认 `runtime=true` 且 `persistent=true` 后才推进 `appliedVersion`。失败显示配置异常;连接丢失保留待确认,不报完成。暂停立即阻止后续私密下载,但只有真实撤销确认后才显示“已暂停”。移除确认后归档并撤销所有绑定;重置保留 IP、先删除旧 key 再应用新 key,既有绑定在确认后可刷新取得新身份,标准配置需重新导入。 + +旧 token 在只读纳管待确认阶段仍可按原身份下载,避免迁移直接中断自动更新;新绑定/新导出等网关确认后开放。旧身份纳管失败则拒绝下载,不改用其他身份。设备暂停与归档同样约束旧 URL。停用/删除一个订阅 token 仍只撤销该订阅下载,不自动暂停其关联设备。 + +agent 和页面默认每 15 秒更新。采集超过 45 秒显示未知并隐藏速率;相对握手年龄换算到中心接收时间,180 秒内握手/有效流量增长为近期在线,无握手为待接入。设备上行对应网关 RX、下行对应 TX。计数下降/agent 进程重启会重建基线,不输出负速率;采集间隔过长不显示平均值为实时速率。累计量为纳管后的有效增量,重启或漏采期间可能少计,不是账单计费。 + +## 网关 agent 接入与重建参考(本次已获授权并部署) + +源码为 [agent.py](../scripts/wireguard/agent.py),[systemd unit](../scripts/wireguard/proxy-station-wg-agent.service)。第一版直接由单个受限 root 服务执行固定接口操作,不开放 shell/RPC,也不需要全站管理员 token。它只处理 `wg-njcq` 和上述私网,校验本机公钥与管理员登记值;不修改防火墙、路由、DNS、NAT 或重启接口,不读取 FFSB 身份。 + +本次按以下顺序部署,后续重建仍须先核对实际状态与授权: + +1. 按生产维护约定备份应用库、网关配置及客户端交付记录,核对真实 Mac/iPad key/IP/PSK 与网关;不使用原型设备。 +2. 部署应用后先检查迁移元数据。通过设备页“网关”登记实际 endpoint/publicKey,匹配已迁移的网关;签发一次性展示的独立 agent 凭证。已有配置下再次操作会轮换旧 agent 凭证,须同步 agent 配置。 +3. 将脚本放到 `/opt/proxy-station-wg/agent.py`,root 所有且不可由应用用户修改;unit 安装到 systemd。写 root/600 的 `/etc/proxy-station-wg-agent.json`,字段如下,值均为占位: + +```json +{ + "url": "https://proxy-station.example.net", + "gatewayId": "<管理 API 返回的网关 ID>", + "agentToken": "<仅用于此网关的独立凭证>", + "publicKey": "<现有 wg-njcq 公钥>" +} +``` + +4. 确认网关已有 `wg` 工具与 Python 3;`wg-njcq.conf` 不得开启 `SaveConfig=true`,避免停机覆盖管理结果。服务限制只写 `/etc/wireguard` 和运行锁,另有文件锁避免多个 agent 并行运行。确认现有运维也不要同时改此接口。 +5. 启动 agent。它主动 HTTPS GET `/wg-agent/:id/tasks`,POST `/wg-agent/:id/report`,认证只接受此网关 token。HTTP 重定向被拒绝,连接使用 TLS 验证,不继承环境代理。采集/任务响应 no-store,日志不含密钥、PSK、端点或请求 URL。 +6. 首先完成 `adopt`:只读核对运行和磁盘中 publicKey、AllowedIPs、PSK,不改写 peer。核对失败保留同一任务,排查后在设备页重试。 +7. 再以新测试设备验收新增/暂停/恢复/重置/移除。helper 每任务私有备份一次,锁内原子替换、fsync 持久文件,并用 `wg set` 只变更目标 peer;保留 Interface 指令和非受管 peer,拒绝地址/路由冲突。重试幂等,不用整接口重启或全量覆盖。失败可能已部分应用,界面持续显示异常,不能当作回滚成功。 + +持久任务未确认时不要直接删库或删除 peer 来“修好 UI”;修复原因并重试原任务。agent token 只允许该网关的任务/采样,不能访问管理页面、设备私钥或其他网关。 + +## 已完成的本地验收与剩余边界 + +隔离 DATA_DIR、占位身份,不读/写本机真实 `server/data`、私有 njcq 交付或生产 API: + +- Node 22 下三包类型检查通过、服务端全量 91 项通过 / 1 项原有跳过、前端构建通过;Python agent 5 项离线测试及语法编译通过。新增测试覆盖生命周期、凭证、节点范围、迁移、主密钥丢失、遥测与公网动态池隔离。 +- 实际本地 HTTP 默认/设备订阅验证:默认内容逐字不变,两个设备身份隔离,托管 header 包含完整凭证。Surge 官方 CLI 原配置和规则集快照解析通过。 +- 无头 Chrome 真实页面:选择/清除/切换格式、旧设备链接稳定、独立二维码、暂停等待确认、创建任务及 320/360/736/1024 窄屏布局;包括没有 `crypto.randomUUID()` 的创建路径,检查无页面 JS 异常。 + +尚未验收:真实 Linux 内核上的新增/暂停/恢复/重置/移除生命周期,暂停后的实际不可达、长期运行/睡眠/换网。用户已随后确认三端 Surge + WireGuard 组网可工作;此处不将用户结论扩写成助手逐项执行了所有生命周期、版本与网络场景的测试。生产身份迁移、跨机 agent HTTPS 与只读纳管已通过,见下。握手/CLI 通过不能替代这些验收。 + +语法依据:[Surge WireGuard](https://manual.nssurge.com/policies/wireguard.html)、[托管配置](https://manual.nssurge.com/profile/managed-profile.html)、[WireGuard 工具](https://git.zx2c4.com/wireguard-tools/about/src/man/wg.8)。 + +## 2026-11-02 生产部署记录 + +- 用户明确授权 LA 应用发布,并确认同时部署 njcq agent。发布为工作区版本 `f741aee` 加未提交改动;前端产物及最终后端源码指纹与本地一致,服务 active,公网健康检查成功,未带 token 的管理接口 401。 +- API 部署前后:30 节点(12 启用)、14 组、277 规则、3 订阅;节点/组/规则/settings 完整数据一致,订阅 token/name/nodeIds/enabled 与创建时间一致。只迁移已有 iPad `.2`、Mac `.3` 的独立身份,不初始化原型设备。 +- 两份旧 profile 与 njcq 公钥、客户端公钥、PSK、AllowedIPs 在迁移前核对一致。迁移后 peer 的磁盘配置 SHA-256 不变,运行中的公钥、PSK 和 AllowedIPs 不变;两项 `adopt` 任务均 confirmed。未重启 WG 接口,未修改防火墙、路由、Tailscale、FFSB 或本机活动 Surge。 +- `proxy-station-wg-agent` 已安装并启用,固定接口 `wg-njcq`;agent 独立认证 HTTPS 采样正常。已有设备是否近期在线以握手/流量为准,安装服务与生成链接不产生在线状态。 +- 管理 API 实际签发两个临时设备绑定,实际 HTTPS 验证各自私钥/IP 隔离、完整带 `wg` 的托管 URL、入站字段、独立导出、跨订阅/裸 ID 拒绝、先前链接保持稳定及撤销后 404。两条正式测试绑定及一次重试产生的临时绑定均已撤销,不遗留测试设备或 peer。 +- 原默认 Surge 与 Clash 正文逐字不变。旧设备 Surge 仅增加排除私网 WG 的动态筛选:`^(?!NJCQ WireGuard LAN$).*?(?:原正则)`;保留原正则在名称中段搜索及显式锚点的语义,逐个核对原有 30 个节点匹配结果不变,其他内容与身份不变。部署差异检查发现并修复了最初额外限制原表达式从名称开头匹配的问题,相关回归测试已通过。 +- aaPanel 原站点的 extension 中新增 `/sub/`、`/wg-agent/` 独立代理,关闭 URI 访问/错误日志与缓存,保留原 HTTPS 转发头;nginx 配置校验及平滑 reload 成功。新 `wg` 凭证未出现在站点最近访问日志中,敏感响应 no-store、no-referrer 验证通过。 +- 实际 HTTPS 原/派生配置均经本机官方 Surge CLI 解析通过。标准脚本的本机直接规则集下载因 skk CDN 超时未完成;独立拉取取得本机 37 项和 LA 补齐 12 项,共 49/49 个 HTTP 200、非空且非 HTML 的完整新快照,原默认/iPad 派生/Mac 派生配置替换为这些快照后均经同一官方 CLI 通过。静态域名遮蔽提示 13 项,与用同一新快照计算的部署前默认配置完全一致(原 FFSB 手动集合与内部集合的既有覆盖:🦀 Claude 优先于 Claude-Surge-Internal);未据此改规则。该结果不代表真机入站或媒体业务已验收。 + +受控备份(远端文件 root/600,本机目录 700、文件 600;均不入 Git): + +| 位置 | 作用 | +|---|---| +| `la:/opt/proxy-station/data/backups/before-wireguard-device-management-20261102-140453.db` | 迁移前在线快照,保留旧身份 | +| `la:/opt/proxy-station/data/backups/before-wireguard-device-management-deployed-20261102-141750.db` | 迁移/纳管及测试绑定撤销后的在线快照(已用主密钥在隔离 DATA_DIR 中验证恢复,两个完整 profile 与旧值逐字段一致,三个临时绑定均已撤销) | +| `la:/root/backups/proxy-station-before-wg-devices-20261102-140551/` | 原应用源码、产物及服务配置 | +| `la:/root/backups/proxy-station-wg-key-20261102-140857/` | 加密主密钥独立备份 | +| `la:/root/backups/before-proxy-station-wg-nginx-20261102-141101/` | 原站点与代理配置 | +| `njcq:/root/backups/before-proxy-station-wg-agent-20261102-140742/` | 原 WG 持久/运行配置与身份清单 | +| `edge-nodes/private/njcq-edge-node-docs/management-20261102/` | 本机私有主密钥、加密库、agent 配置与脱敏核对记录 | + +## 2026-11-02 单一主订阅收拢 + +用户随后明确只保留一个订阅。通过管理 API 删除 `NJCQ WireGuard - iPad Pro`、`NJCQ WireGuard - Mac Studio` 两条旧设备专属订阅;当前只剩原 `Proxy Station` 主订阅,保留原 token、URL、节点范围及启用状态。旧设备订阅及其标准 WG/Shadowrocket WG 下载 URL 均返回 404,不再作为当前兼容入口,也不跳转到其他设备身份。通用的旧 token 兼容代码仍保留,用于迁移其他已有库;这两条生产记录按用户最新要求退休,不恢复。 + +独立设备不依附于旧 token 的生命周期。删除两条旧订阅未删除/暂停设备,也未提交任何网关任务。Mac `.3` 与 iPad `.2` 的设备 ID、公钥、私钥、PSK、IP、网关任务状态和独立 WG 配置保持不变;两台设备都可在唯一主订阅的 Surge 下拉框选择。设备导出不再使用已删除 token 的 URL,改用设备页授权导出。 + +验证:生产列表只有一条;主订阅实际 HTTPS 配置与变更前逐字一致、不含 WG,并经官方 Surge CLI 通过;两份独立导出与删除前逐字一致;节点/组/规则/settings 全量数据不变。两个设备分别通过主订阅临时签发绑定,核对完整托管 URL 与对应身份后已撤销验证凭证,不遗留新订阅卡片或测试 peer。本次没有修改代码、重新部署或切换本机 Surge;规则集/规则顺序没有变化,完整 49 项快照验收沿用上面的本次部署记录。 + +备份: + +- `la:/opt/proxy-station/data/backups/before-single-main-subscription-20261102-144457.db` +- `la:/opt/proxy-station/data/backups/before-single-main-subscription-20261102-144457.wg-master.key` + +两份备份均为 root 所有、600 权限。旧 URL 已停止更新,仍使用它的客户端需从主订阅选择本机设备并重新导入派生配置;本轮不自动修改已安装客户端的托管地址。 + +## 2026-11-02 停止 Shadowrocket WireGuard 下发(已部署) + +用户已实际确认 macOS、iPadOS、iOS 的 Surge + WireGuard 可工作;不再继续兼容此前 Apple VNC 未通过的 Shadowrocket WG 路径。历史排查与失败证据保留,不将具体实现原因推断为已解决或已确认。 + +- Shadowrocket 普通节点与 conf 使用纯代理 IR,不再读取旧 token 对应的 WG profile;即使旧设备已暂停,普通代理下载仍可用。`wg` 查询参数不会向 Shadowrocket 附加设备身份。 +- conf 序列化器不再生成 WG 私网路由、WG 额外节点导入提示或 WG 特定的动态组筛选;即使离线调用方附带 IR.wireguard,产物也与未附加时一致。通用 LAN DIRECT 规则照常保留。 +- 移除 Shadowrocket WG YAML 生成函数及 DTO 的 `shadowrocketWireguard` URL。旧 `/sub/:token/wireguard-shadowrocket` 路径明确返回 404,不再下发身份,也不落到 SPA HTML。 +- Surge 带设备配置、标准 `.conf`/二维码导出、设备身份与 njcq peer 管理保留;不修改单一主订阅或生产数据。此前导入 Shadowrocket App 的独立 WG 节点属于设备本地数据,更新普通订阅不会自动删除这些手工导入记录。 + +本地回归验证覆盖旧设备/暂停设备、附加查询参数、普通节点下发、conf 身份完全隔离和废弃端点 404。后续已与 FlClash 更新一同部署,HTTPS 旧 WG 端点返回 404,默认 Shadowrocket 订阅与部署前逐字一致;没有操作客户端。 + +## FlClash Android 研究(2026-11-02) + +已核对官方 v0.9.2 及其固定内核:WG 出站支持,标准 Android 配置/实现未提供 Surge 式本机 peer-access,尚无 Android 真机验收;不能将出站、allow-lan 或普通代理端口当成双向 WG 本机入站。版本、源码依据和后续边界见 [FlClash 能力核对](FLCLASH-WIREGUARD-AUDIT-2026-11-02.md)。 + +## FlClash 可选设备身份(已部署) + +订阅页的 FlClash 使用原 `/clash` 纯代理入口,可选设备后签发仅用于 Clash 的独立凭证;仅该派生链接包含设备 WG 出站和优先私网规则,动态公网组排除 WG。Android 可在设备页建立独立身份,不能与其他同时在线的设备共用密钥。FlClash 的本机服务入站及真实私网连接未在 Android 真机验收。原有 Surge 链接不受设备切换或 FlClash 凭证撤销影响,Shadowrocket 不恢复 WG 下发。配置审查与验证边界见 [FlClash 兼容性](FLCLASH-COMPATIBILITY-2026-11-02.md)。 diff --git a/docs/WIREGUARD-LAN.md b/docs/WIREGUARD-LAN.md new file mode 100644 index 0000000..8183d79 --- /dev/null +++ b/docs/WIREGUARD-LAN.md @@ -0,0 +1,119 @@ +# WireGuard 设备组网订阅 + +2026-11-01 增加独立 WireGuard 私网接入。njcq 固定中转设备间流量,Apple 屏幕共享和 OpenScreenSharing 保持原有协议。 + +## 最新客户端约定(2026-11-02 后续) + +用户已在 macOS、iPadOS、iOS 实际全面验证 Surge + WireGuard 可工作,停止 Shadowrocket 的 WG 兼容。普通 Shadowrocket 节点/规则下发保留,WG 私网路由、专用 WG YAML 与导出链接已从本地代码清除,旧专用路径明确 404;尚未重新部署此清理。Surge 与标准 WG 独立导出、设备/网关管理不变,见 [清理记录](WIREGUARD-DEVICES.md#2026-11-02-停止-shadowrocket-wireguard-下发本地已清理未部署)。下文的 Shadowrocket WG 安装与排查仅是历史记录,不作为当前接入指引。 + +## 最新接续方向(2026-11-02) + +用户已将个人 macOS、iPadOS、iOS 客户端统一为 Surge,并确认支持 WireGuard 与入站。下一阶段从 [实现交接](design/wireguard-devices/HANDOFF.md) 和 [产品设计](design/wireguard-devices/DESIGN.md) 开始:设备管理独立于订阅,保留标准 WG 导出,删除客户端选择;Surge 订阅可选附加某台设备,未选使用原默认纯代理 URL,选中生成带设备凭证的派生 URL。现已实现并部署独立设备实体、加密密钥迁移、设备页、njcq agent 和设备绑定 API/查询参数,详见 [实现与生产验收](WIREGUARD-DEVICES.md)。两个原 peer 已只读确认纳管,现有身份、IP 和旧链接保留,下面的设备 token 模型仅为历史实现记录。 + +下文保留 2026-11-01 的部署、测试和恢复记录;Shadowrocket 排查是历史对照,不是新设备管理的实施前提。用户统一到 Surge 是最新使用信息,本轮已读取管理 API 和 njcq 运行/持久状态完成迁移验收;未切换本机 Surge,也未完成三端新方案实际设备验收。 + +同日后续用户要求只保留一个订阅:生产已删除两个旧设备 token,原主订阅 URL 保留;两台 WG 身份、IP、peer 和独立配置不变,改从主订阅选择对应设备生成派生链接。旧 token 下载入口已 404,下方旧入口表仅为历史记录;备份见 [单一主订阅记录](WIREGUARD-DEVICES.md#2026-11-02-单一主订阅收拢)。 + +## 服务与身份 + +- njcq:Ubuntu 内核 WireGuard,接口 `wg-njcq`,UDP 51820,`10.203.0.1/24`,开机启动。 +- iPad Pro:`10.203.0.2`;Mac Studio:`10.203.0.3`。每台设备独立私钥、公钥和预共享密钥。 +- 只允许同一 WireGuard 接口内的私网转发,不提供 Internet 出口 NAT。 +- 保留既有 Tailscale、Docker、面板和系统 IPv6 设置,新增 WireGuard 使用 IPv4。 +- 云安全组和主机防火墙均需允许 UDP 51820。服务 active 或配置解析通过不足以证明公网可用。 + +安装脚本是本机 SSH 编排器: + +```bash +bash .claude/skills/edge-node-multi-protocol/scripts/17-install-wireguard.sh \ + edge-nodes/private/-edge-node-docs +``` + +脚本保留已有受管接口,不重新生成身份;目前预置本次两台设备及网段,扩容前需审阅,不能作为任意地址的自动分配器。VPS 配置和客户端身份清单在 `/etc/wireguard/`,root/600;本机交付在 Git 忽略的 `edge-nodes/private/njcq-edge-node-docs/`。 + +## 已有订阅模型与入口 + +WireGuard 是设备私网身份,不加入公网代理 `nodes` 协议枚举。`sub_tokens.wireguard` 为可空 JSON,旧库启动补列,旧共享订阅保持 null。token POST/PUT 接受经校验的 wireguard 对象,普通编辑省略该字段时保留身份。仅允许 RFC1918 私网与有效设备地址,拒绝默认/公网路由、复用私钥和同一服务器下的重复地址。 + +`irFor()` 只给当前 token 的 ProfileIR 附加该设备身份,生成器不再读库,不向其他订阅下发此设备密钥。 + +| 路径 | 内容 | +|---|---| +| `/sub/:token/wireguard.conf` | 标准配置,可导入 WireGuard / Passepartout | +| `/sub/:token/wireguard-shadowrocket` | 单个 WG 节点的 YAML 订阅 | +| `/sub/:token/surge` | 原有公网配置加该设备 WireGuard 段落和优先私网规则 | +| `/sub/:token/shadowrocket-conf` | 原有规则加私网规则,引用额外导入的 WG 节点 | + +设备订阅为 `NJCQ WireGuard - iPad Pro` 和 `NJCQ WireGuard - Mac Studio`;原共享订阅保留,不能在多台同时在线的设备上复用同一身份。Shadowrocket 的原公网 URI 节点订阅不变,WG 节点需额外导入;动态公网策略组排除 `NJCQ WireGuard LAN`,避免误作 Internet 出口。Clash 原公网配置保持原行为,组网使用标准 WireGuard 配置,不声称已下发 Clash 私网适配。 + +Shadowrocket YAML 字段参考 [Sub-Store 的 Shadowrocket producer](https://github.com/sub-store-org/Sub-Store/blob/master/backend/src/core/proxy-utils/producers/shadowrocket.js),iPad 节点导入及普通 VNC 已通过;Apple VNC 在本次 Shadowrocket 路径未通过,原生 WireGuard 对照成功,见下文。 + +### Shadowrocket 首页缺少 WG 节点 + +若已更新“ShadowRocket iOS”的普通节点和规则,但首页看不到 `NJCQ WireGuard LAN`,还需在本设备专属订阅卡片复制 **“WG 节点 · Shadowrocket”** 链接,作为额外 Subscribe 订阅添加。普通 `/shadowrocket` 入口保持原公网 URI 节点,不包含 WG;仅刷新 `/shadowrocket-conf` 不会创建节点。保持全局路由为“配置”,OpenScreenSharing 连接 `10.203.0.3:5900`。 + +2026-11-01 实际排查:iPad 用户报告首页缺少该节点;HTTPS 日志只有该设备 `/shadowrocket` 与 `/shadowrocket-conf` 的 Shadowrocket 请求,没有 `/wireguard-shadowrocket` 请求;同时服务器没有 iPad peer 握手。此状态先补导入节点,不修改 Mac 屏幕共享或服务器防火墙。 + +## 客户端要求 + +- Mac Surge 接收入站需要 **6.11.0+**,通过 `accept-incoming = true` 把本设备虚拟 IP 的连接交给本机服务。6.10 CLI 接受语法不代表支持入站。 +- Mac 可用独立 WireGuard / Passepartout 导入标准配置;同一身份只启用一种实现。 +- iPad 使用 Shadowrocket 时需导入本设备公网节点、额外 WG 节点及规则配置;本次普通 VNC 已通过,Apple VNC 高性能模式未通过。 +- iPad 使用 Apple VNC 的已验收组合:**官方 WireGuard 客户端 → njcq → Mac Surge 6.11.0 build 12660**。导入标准 `.conf` 或离线二维码,连接 `10.203.0.3:5900`;同一身份不与 Shadowrocket 同时启用。 +- 不自动更换客户端活动配置或升级 Surge。WireGuard 规则只接管私网网段,原公网出口与 DNS 保持原有逻辑。 + +## 验证、备份和恢复 + +- 2026-11-01:全量类型检查、前端构建、80 项单测通过,1 项既有测试跳过。覆盖旧库迁移、设备密钥隔离、私网范围、重复身份和普通 token 编辑保留身份。 +- 已下载实际 HTTPS 设备产物并通过本机 Surge 官方 CLI。候选及实际 Mac 设备产物的 49 个规则快照全部下载并通过本地快照 CLI;重试期间出现过上游偶发超时。现有 FFSB 静态遮蔽提示不作为本次组网改动处理。 +- 用户放行云安全组后,临时 peer 从另一台公网 VPS 成功握手,5/5 ICMP 返回;经 njcq 到另一隔离 peer 的 TCP 转发成功。验证程序、临时 peer 和 namespace 已清理。该阶段尚未验收实际设备,后续 Mac 验收见下。 +- 本机另用官方 wireguard-go netstack 发起临时接入,跨公网访问隔离 peer 的 HTTP 成功;未创建系统隧道或替换活动 Surge 配置。 +- 同日实际 Mac 验收:Surge 6.11.0 (12660)。磁盘已是 Mac 专属订阅,但运行中的 original/effective 配置仍无 WireGuard,私网落到 LAN DIRECT;`managed-profile update` 返回 unchanged 且不安排重载。备份并保存组选择后执行 `surge-cli reload`,私网规则与 WireGuard 生效,原组选择保持一致。不能只检查磁盘配置或订阅刷新结果。 +- Mac 到 njcq 实际握手成功,Surge WireGuard RTT 约 8 ms;njcq 经 `10.203.0.3:5900` 读到 Apple `RFB 003.889` 响应,证明 Surge 入站可用。Mac 经 WireGuard 从 hub 下载 16 MiB 完整数据,单次约 0.259 秒 / 519 Mbps;只代表这一段短时下载,不代表 iPad 到 Mac 的端到端吞吐。临时监听仅绑定 hub 私网地址,测试后已退出。 +- 同日 iPad 补导入独立 WG 节点后已握手,服务器记录约 85 MiB 桌面转发,用户确认普通 VNC 连接流畅。长期运行、睡眠与换网仍未验收。 +- OpenScreenSharing 的 Apple VNC 选项对应高性能媒体模式;源码在标准 RFB 控制会话外打开 UDP 媒体 socket。用户报告该模式无法显示,Mac 日志于 16:06 显示 iPad RSA-SRP 认证成功、base UDP 5900 及媒体协商消息;普通 VNC 于 16:07 再次认证成功。这是初期排查状态,后续受控抓包及原生客户端成功对照见下文。不要因此开放公网 5900/5901。 +- 生产数据库在线备份:`data/backups/before-njcq-wireguard-device-subscriptions-20261101-151040.db`,root/600。VPS 安装前备份:`/root/backups/before-wireguard-*`。 +- 运维脚本同时修正备份权限:新备份使用 umask 077,部署只调整应用源码/依赖属主,不递归改动 data/backups。 +- 查看状态使用 `wg show wg-njcq`;不要把会输出私钥的 `wg showconf` / `wg show ... dump` 写入共享日志。 +- 停用/删除订阅 token 只撤销后续下载,**不会撤销已导入的 WireGuard peer**。撤销网络身份还需在服务器删除 peer 并更新持久配置。 +- 停用:`systemctl disable --now wg-quick@wg-njcq`,PostDown 只撤销该接口添加的规则;恢复:`systemctl enable --now wg-quick@wg-njcq`。保留私有配置和备份。 + +Surge 行为以[官方文档](https://manual.nssurge.com/policies/wireguard.html)为准。 + +## Apple VNC 高性能模式排查(2026-11-01) + +普通 VNC 已通不代表 Apple VNC 媒体已验收。协议与实现依据: + +- [Apple 高性能屏幕共享要求](https://support.apple.com/zh-cn/guide/remote-desktop/apdf8e09f5a9/mac):UDP 5900/5901/5902 双向通信;75 Mbps 是其原生单台 4K 工作流的推荐带宽,不能当作 OpenScreenSharing Internet 模式的最低门槛。 +- [OpenScreenSharing 作者 issue #2](https://github.com/nekohasekai/open-screen-sharing/issues/2):需要固定 UDP 源端口 5900/5901 与正确的反向包投递,泛称“支持 UDP”不足以保证兼容。该评论针对当时版本,不能直接推导当前 Surge 永远不支持。 +- [Surge 官方更新记录 426](https://t.me/s/SurgeTestFlightFeed/426) 与 [Mac beta appcast](https://sgupd.com/latest/appcast-signed-beta.xml):早期 beta 曾说明不兼容;后续移除 peer-access-passthrough 并内置 Apple Screen Sharing 特殊处理。已核对 appcast enclosure build **12660 / 6.11.0** 的说明包含此处理,与本机版本相符,不能只引用旧的“不支持”公告。旧 accept-incoming 仍兼容,运行配置规范化为 allow-peer-access;不要添加已移除的参数。 +- 源码 `internal/rfb/media/socket.go`:媒体 socket 使用固定本地源端口并 connect 到对端相同端口。`media/rtcp.go` / `media/stream.go`:视频丢包统计来自成功 SRTP 解密后的 RTP 序号缺口;不是 ICMP 丢包。`media/video.go`:初始等待同步帧,损坏的 access unit 不建立画面。因此有 bitrate 仍可长期黑屏。 +- `DebugInfoMonitor.swift` 的 Network 为 TCP SRTT/2,Mac 与 This iPad 指标依赖画面处理事件,不能把 Network 值单独当成端到端视频延迟。Internet 配置用 mode 5、4:2:0、单分带;Local Network 用 mode 7、4:4:4、四分带;本次降低到 Internet/30 fps 后用户仍报告黑屏、约 2 Mbps / 35% 丢包。 + +实际日志:账号 RSA-SRP 认证成功,UDP 5900/5901 双向包与 iPad 反馈均存在。视频 UDP 负载约 1386 字节,IP 总长 1414,MTU 1280 下发生分片;临时只改 hub 到 1420 无效,上游分片依旧;再将 Mac 设备订阅 MTU 改到 1420 后确认入站完整 IP 包约 1409 字节、无分片,但仍黑屏。用户报告码率提高、丢包降低,但不能将 MTU 认定为唯一原因。 + +测试已恢复:Mac 专属订阅经 API 回到 MTU 1280、Surge 已刷新;hub 运行 MTU 恢复 1280,自动恢复 timer 已停用,持久配置始终未改。iPad 订阅未改。公共节点/组/规则/settings 前后比对一致。在线备份 `data/backups/before-apple-vnc-mtu-test-20261101-161957.db` root/600;节点私有配置备份 `/root/backups/wireguard-mtu-test-20261101-161752/`。 + +受控抓包结果(16:32 左右,MTU 已恢复 1280):njcq 入站捕获 10,811 个 IP 包,媒体样本覆盖 16.451 秒;视频 RTP 单 SSRC 收到 4,858 个唯一序号,序号跨度也为 4,858,观察缺口 0%;4,288 组 IPv4 分片全部完整,无未完成分片组,tcpdump 内核丢包 0。视频 UDP 总负载约 6,335,778 字节,同期用户报告 iPad 仅约 200 Kbps、90% 丢包。此样本把媒体缺包定位到 njcq 之后至 iPad 应用接收之间,尚不能区分云公网/ISP/Wi-Fi 与 Shadowrocket 接收及重组。不要把该样本外推为所有网络时段绝无丢包。 + +### 原生 WireGuard 对照成功 + +同日用户在 iPad 官方 WireGuard 客户端导入 `njcq-ipad.conf` 后确认 Apple VNC 成功出画面、速度良好。客户端继续使用同一 iPad 地址/密钥、MTU 1280;服务器看到 iPad 公网出口 IP 与此前 Shadowrocket 相同,仅 UDP 源端口变化,两个正式 peer 均有近期握手。Mac 仍使用 Surge 6.11.0 build 12660,hub MTU 仍为 1280;短时抓包确认 UDP 5900/5901 音视频与反馈双向流动。 + +因此,本次主要故障可定位到 **iPad Shadowrocket 的 WireGuard/UDP 接入路径与 Apple VNC 的兼容性**。服务端、Mac Surge 和 MTU 1280 均在成功组合中得到保留;不应再把它们宣称为本次黑屏的已证实主因。尚未做到 Shadowrocket 实现级排查,具体是分片重组、固定源端口/反向 UDP 处理、缓冲或其他机制不能断言;也不据此宣称所有 Shadowrocket 版本都无法使用 Apple VNC。长期运行、睡眠/换网、定量吞吐与最终丢包率仍未验收。 + +2026-11-01 当时的成功使用方式为原生 WireGuard 承载 iPad 组网,Shadowrocket 普通代理按需切换;后续用户已统一 Surge,见本文顶部。配置只路由 `10.203.0.0/24`,不作为公网出口。 + + + +### Shadowrocket 后续配置排查 + +用户随后要求继续使 Shadowrocket 的 WG 连接支持 Apple VNC,原生 WireGuard 仅作为成功基线。已核实客户端 2.2.93 build 3488,UDP 转发开、禁用 STUN 关。官方站由 App Store sellerUrl 指向 https://shadowlaunch.com/,其链接指向官方 Telegram 群/公告频道;不要把搜索到的多个自称“官方手册”网站当作官方依据。 + +[官方 build 3488 更新](https://t.me/ShadowrocketNews/1665) 与 [2.2.93 正式版](https://t.me/ShadowrocketNews/1666) 未声明修复 Apple VNC;2.2.94 build 3490–3493 的公开说明也未见该项。历史 [build 3215](https://t.me/ShadowrocketNews/1454) 修复 raw socket MTU、[3216](https://t.me/ShadowrocketNews/1455) 调整读取长度、[3399–3400](https://t.me/ShadowrocketNews/1593) 涉及 L3 UDP fast relay,这些是实现曾变动的证据,不能据此宣称当前缺少基础 UDP 功能或已确认同一 bug。此前实际反馈包在服务器上保留了 5900/5901 源端口,不能简单断言 Shadowrocket 始终随机改写该端口。 + +进行了完整三端 1420 MTU 对照:两个设备 token 的 wireguard.mtu 经 API 暂改 1420,Mac effective 配置、实际 HTTPS Shadowrocket YAML 和 hub 运行 MTU 已核对;日志确认 iPad 下载过新节点订阅,iPad UI 实际字段尚未单独回报。抓包 99.311 秒、14,596 个 IP 包、0 分片,固定源端口正确。期间有多次重新连接:3 个 SSRC 的 njcq 入站序号缺口分别为 0%、4.348%、60.437%,不能混成单一成功的无丢包样本。用户最后回报仍黑屏、丢包显示 0%、bitrate 约 200 Kbps;缺少完整同步媒体样本时显示 0% 不足以证明媒体成功,需继续核对实际视频包与帧重组。 + +测试已由自动恢复程序回退两个 token 与 Mac Surge/hub 到原 MTU 1280,server 持久配置未动。备份 `data/backups/before-shadowrocket-complete-mtu-test-20261101-165515.db` root/600;服务器临时参数快照 `/root/backups/sr-complete-mtu-test-20261101-165519/`。原生客户端导入的成功配置文件/二维码从未覆盖。 + +额外干扰项:HTTPS 访问日志见一台 iPhone 也下载了 iPad 专属 WG 节点订阅,已向用户核实是否同时启用;25 秒 endpoint 观察只有一个值,尚未实证并发身份争用。不能据此解释全部早期失败。当时拟继续核对 Shadowrocket 设置→代理→代理类型,以及 TUN-only(None / compatibility-mode=3)是否影响 L3 UDP 接收路径;这一测试未完成,选项描述来自社区配置手册。用户后续统一 Surge,当前交接不要求继续此分支,也不将它写为已解决。 diff --git a/docs/design/wireguard-devices/DESIGN.md b/docs/design/wireguard-devices/DESIGN.md new file mode 100644 index 0000000..651096c --- /dev/null +++ b/docs/design/wireguard-devices/DESIGN.md @@ -0,0 +1,171 @@ +# WireGuard 设备管理与 Surge 按设备订阅 + +状态:已确认产品设计与原型,2026-11-02。已完成本地实现,具体接口、迁移和验收见 [实现记录](../../WIREGUARD-DEVICES.md);现已部署 LA 应用与 njcq agent,未切换活动客户端;原有设备只读纳管与凭证端点已通过,用户同日随后已确认 macOS/iPadOS/iOS 的 Surge + WireGuard 全面实测可工作,停止 Shadowrocket WG 下发兼容;生命周期与长期网络场景不据此外推。 + +开始开发请先读 [实现交接](HANDOFF.md),其中列出已有代码、实现缺口、真实配置位置与历史验证边界。 + +## 产品决策 + +设备页独立管理 WireGuard 身份、固定 IP、状态和网络生命周期。设备模型不记录“使用客户端”,创建和导出时也不要求选择客户端。当前个人设备统一使用支持 WireGuard 与入站的 Surge,仍保留标准 WireGuard 独立配置导出。 + +订阅页选择 Surge 格式后,提供“附加设备 WireGuard(可选)”下拉框,数据来自设备管理列表: + +| 选择 | 下发配置 | 链接行为 | +|---|---|---| +| 不附加设备 | 干净的代理节点、策略组与规则,无设备 WG 身份 | 使用原默认 Surge URL,逐字保持不变 | +| 选择某台设备 | 原代理内容 + 此设备独立 WG 身份、私网规则和入站设置 | 基于原入口生成带设备凭证的派生 URL | +| 清除选择 | 恢复纯代理配置 | 恢复原默认 URL | + +**选择状态必须体现在生成链接的凭证中,不能保存在服务端的全局“当前设备”变量。** 否则 Mac 选择设备后会改变 iPad 下次刷新所得到的身份。 + +本方案统一的是订阅入口与节点、策略、规则来源,不承诺包含不同私钥的各设备使用逐字相同的完整 URL。设备专属链接需在对应设备导入一次,之后每次更新仍包含同一设备身份。不再需要额外身份模块、客户端选择或复制私钥。 + +## 信息架构 + +主导航沿用:节点 / 策略组 / 规则 / **设备** / **订阅** / 设置。 + +设备页保留现有列表与操作:名称、系统、固定私网 IP、状态、最后活动、上行/下行、详情、暂停/恢复、重置身份与移除。显示方向以设备为准,上行为设备到网关,下行为网关到设备。窄屏沿用设备条目布局。 + +设备详情增加两个清晰入口:“用于 Surge 订阅”和“导出独立 WireGuard 配置”。前者打开订阅页并预选此设备,后者打开独立导出页。两种方式引用同一设备身份,不另分配 IP 或密钥。 + +设备信息中删除客户端名称与兼容性分支。系统字段继续用于识别设备,不作为私钥领取认证凭据。 + +## 添加设备 + +1. 填写名称、系统。网关、网络、MTU、IP 和密钥自动处理。创建前展示默认设备互访范围。 +2. 事务内预留唯一 IP 和独立 keypair/PSK,创建幂等网关任务。只有网关确认运行配置和持久配置生效后,才显示“待接入”。失败保留同一设备和 IP,重试不重复创建。 +3. 创建完成后可选“前往 Surge 订阅”或“导出独立 WireGuard 配置”。也可稍后从设备列表继续。 +4. 导入后单独检查首次握手。生成、复制链接、下载配置或点击导入均不直接变为在线。 + +第一版使用现有 njcq 网关、10.203.0.0/24、MTU 1280,保留现有 iPad .2 与 Mac .3。网关只转发个人私网互访,不新增 Internet 出口或 NAT。 + +## 订阅交互 + +订阅卡片保留当前节点范围与启用状态。选择格式为 Surge 后显示设备下拉框,默认“不附加设备 · 纯代理订阅”。选项包含名称、私网 IP 和当前状态;离线设备可以选择,首次未握手但网关已生效的设备也可以选择。 + +已暂停、正在撤销、正在恢复、配置中、配置异常的设备仍在列表显示,但不可新选择生成接入链接。采集失联不等于身份被暂停,不因遥测未知就错误撤销既有权限。 + +选择设备后,页面就地更新: + +- 明确显示所选设备和 IP,以及该链接仅供此设备使用。 +- 链接包含对应的受控设备凭证;“复制链接”和“导入 Surge”使用相同的当前 URL。 +- 保留“清除设备选择”,随时返回原默认 URL。 +- 提供配置结构预览,私钥/PSK 始终遮蔽,不把真实密钥放进普通 DOM、浏览器持久化状态或原型。 +- 提供“检查设备连接”,回到所选设备的真实握手验证。 + +切换其他订阅格式时清除设备附加选择;其他格式保持原行为,不静默继承 Surge 的个人身份。清除选择只改变当前导出的链接,不撤销设备,不修改其他设备已导入的链接,也不覆盖同一订阅 token 的默认设置。 + +如果选择的设备随后被暂停或移除,界面明确显示不可下发,禁用复制/导入。旧设备 URL 在服务端也应拒绝,不能默默返回纯代理配置,更不能换成另一台设备身份。 + +## 链接、授权与生成规则 + +示意入口: + +```text +纯代理:/sub/<原订阅 token>/surge +附加设备:/sub/<原订阅 token>/surge?wg=<不可枚举的设备分发凭证> +``` + +`wg` 不是裸设备 ID、名称或 IP。共享代理订阅 token 的持有者不能通过枚举 `?device=1/2/3` 获取任意设备私钥。 + +管理员在订阅页选择设备后,由已认证的管理 API 签发或复用绑定“订阅 + 设备 + 导出用途”的高熵分发凭证。数据库保存凭证哈希及作用域,可单独撤销。服务端校验原订阅 token 和设备凭证,确认二者绑定关系、设备仍存在且允许接入,再附加身份。 + +此凭证是能读取该设备私密配置的长期能力凭证,不能作为无敏感性的普通分享链接。网页不必展示内部 token 管理细节,但要说明链接只能用于所选设备。导入地址和凭证不写入公开日志、统计或 Referer;敏感响应 no-store,缓存不得跨设备串用。 + +首次跨设备分享可使用短期领取票据,兑换后获取该设备长期订阅链接。短期票据不直接充当托管配置的长期更新 URL,避免过期后自动刷新失效。GET 预览不消耗领取会话,由显式确认的 POST 原子绑定;并发最多一个会话成功,允许同一领取会话短时重试。 + +**带设备配置的 `#!MANAGED-CONFIG` 必须写入完整的带 `wg` URL。** 如果仍写原纯代理 URL,下次自动更新就会丢掉 WG 身份。原默认纯代理配置的 header 继续写原 URL。 + +生成流水线遵循:原订阅节点权限 → 原代理 IR → 通过授权的一个设备身份 → Surge 完整配置。用户原有公网代理选择与策略名称尽量保持不变;设备 WG 节点不进入 Automatic、All Nodes、AI 等公网代理池,只供专用私网策略引用。 + +纯代理响应不包含 `[WireGuard ...]`、WG 代理声明、WG 私网路由、设备地址、私钥或 PSK。附加响应只包含被授权的一台设备,不包含设备列表或其他设备身份。入站按用户当前 Surge 能力启用,具体字段与平台行为须通过目标版本验收。 + +## 独立 WireGuard 导出 + +设备详情继续提供二维码与 `.conf` 下载。配置引用同一 device keypair、PSK 和地址;导出不创建另一个 peer,不修改代理订阅,也不因生成配置就标记在线。 + +普通列表 DTO 不返回私钥、PSK、完整配置或长期分发凭证。导出/领取使用独立授权接口,正式二维码在本地或受控服务生成,不调用在线二维码服务。暂停或网关任务未确认时不继续签发接入配置。 + +原型二维码为无密钥的演示载荷,下载与导入仅显示模拟结果。 + +## 状态与操作 + +状态仍由期望生命周期、网关应用回执、连接遥测三部分计算。 + +| 状态 | 判定与行为 | +|---|---| +| 配置中 | 已创建身份,网关尚未确认 | +| 待接入 | peer 已生效,从未出现有效握手 | +| 近期在线 | 新鲜采样且 180 秒内有握手或流量增长 | +| 离线 | 采集正常,已连接过,但超出活跃阈值;不代表设备电源关闭 | +| 状态未知 | 采集超过 45 秒未更新,展示最后采集时间并隐藏实时速率 | +| 正在暂停 | 已请求移除 peer,尚未收到确认,可能仍可通信 | +| 已暂停 | 网关运行配置与持久配置均确认移除 peer,记录与 IP 保留 | +| 配置异常 | 网关应用失败或配置冲突,保留幂等任务重试 | + +网关每 15 秒上报,页面 15 秒刷新;使用相对握手年龄和中心接收时间处理时钟偏差。计数周期重置时重新建立速率基线,不显示负流量;累计用量按纳管后的有效增量持久记录。 + +WireGuard 数据不直接证明 VNC 健康、端到端画面延迟或媒体丢包。地址漫游也不直接判定身份被盗用。 + +- 改名只修改元数据;设备凭证绑定 ID,不随名称变化失效。 +- 暂停必须在网关移除 peer 并持久化;仅停用订阅链接不能撤销已导入的网络身份。 +- 恢复在网关确认 peer 重新生效后允许连接。 +- 重置身份先撤销旧 key 再生效新 key,IP 保留。既有未泄露的设备订阅可以继续通过刷新获取新身份,独立 `.conf` 需重新导入。若泄露的是分发链接,另行撤销并重新签发其凭证。 +- 移除先撤销 peer,确认后归档、撤销所有绑定凭证。IP 不立即复用。被移除设备的旧 URL 拒绝下发,不能指向后续设备。 + +## 数据模型与接口 + +复用上一版设备生命周期实体:`wg_networks`、`wg_gateways`、`wg_devices`、`wg_jobs`、`wg_observations`、用量聚合、`wg_events`、短期 `wg_enrollments`。 + +设备实体包含名称、系统、固定 IP、公钥、加密存储的私钥/PSK、期望状态和应用版本,**不包含客户端选择**。增加 `wg_subscription_bindings`,保存订阅 ID、设备 ID、分发凭证哈希、用途、创建/访问/撤销时间。它是订阅与设备的受控关系,不是把个人身份写回公共订阅。 + +建议管理接口: + +- `GET/POST /api/wg/devices`、`GET/PATCH /api/wg/devices/:id`。 +- `POST /api/wg/devices/:id/actions`、`GET /api/wg/jobs/:id`。 +- `GET /api/wg/gateways`。 +- `POST /api/tokens/:id/wg-bindings`:沿用现有 `/api/tokens` 资源命名,校验设备权限,签发/复用当前组合凭证;选择变化时不改默认订阅。接口已部署,生产验证见实现记录。 +- `POST /api/wg/devices/:id/exports`:授权领取标准配置或生成短期接入票据。 +- 公开受凭证保护的拉取入口为原 `/sub/:token/surge` 加可选 `wg`;签名错误、跨订阅使用、暂停或归档明确拒绝,不降级泄露或换身份。 + +默认托管模式由服务端生成独立密钥、加密保存,再通过授权的 HTTPS 分发。不能声称私钥永不离开设备;若未来增加设备端生成,需要单独实现客户端公钥登记流程。 + +网关 agent 使用独立认证主动 HTTPS 上报,不持有全站管理员 token。受限 helper 只操作固定 WG 接口,不接受任意 shell。配置更新原子持久化、幂等重试、合并受管 peer,避免重启隧道或删除非受管设备。 + +## 迁移与验收 + +先备份,再按网关公钥与客户端公钥纳管现有 Mac/iPad,保留 key、PSK、IP、MTU 和现有连接。只读采集先行,不因平台迁移重建 peer。 + +现有独立 WG token/旧导出 URL 保留明确的兼容映射,避免立即中断已安装设备;默认共享 URL 保持纯代理。迁移到新方式时,在同一个订阅页选择对应设备,导入带凭证的派生 URL。默认公共地址不失效,但需要 WG 身份的设备托管地址会增加设备参数,这是本设计的明确行为。 + +实施顺序:只读纳管与采集 → 新增/暂停/恢复/独立导出 → 订阅设备绑定与 Surge 生成 → 三端更新、入站与跨 ISP 远程桌面验收。不因原型完成而声称全部客户端媒体能力已验收。 + +关键验收: + +1. 未选设备时响应完全无 WG,原默认 URL 保持不变。 +2. Mac、iPad、iPhone 的派生 URL 各只包含本机身份;选择 B 不改变已生成的 A 链接。 +3. 没有私密设备凭证、篡改设备 ID、跨订阅复用凭证均不能读到私钥。 +4. 托管 header 保留完整设备 URL,更新/重启后不丢 WG;清除网页选择不撤销已有设备链接。 +5. 网关确认前不假成功,失败重试不重复创建,暂停后真实网络不可达,采集失联不显示旧在线/速率。 +6. 独立导出与 Surge 引用同一身份;设备暂停、移除、重置与失效链接行为一致。 +7. 现有设备不断线纳管;原公网分流行为不因附加 WG 扩大或改变。 + +## 可点击原型与验证 + +交互源文件:[wg-device-management.html](wg-device-management.html)。默认打开订阅页,可进入设备列表和详情。所有 URL、设备状态、导出、导入和握手为演示,不发生产请求。 + +主要体验路径:Surge 订阅 → 选择设备 → 查看带身份链接及结构 → 清除选择恢复纯代理。设备页可新增、详情查看、复制 IP、暂停/恢复、重置/移除及导出独立 WG 配置。 + +本次浏览器实际检查通过: + +- 未选设备、选择 Mac、选择 iPad、清除选择,以及切换非 Surge 格式后的链接与配置结构变化。 +- 选中 Mac 的结构仅包含 .3,选中 iPad 仅包含 .2;托管更新 header 保留当前带设备凭证的完整 URL。 +- 新增设备不再出现客户端选择;创建后即出现在订阅下拉中,未握手仍为待接入;模拟握手后才显示在线。 +- 从设备详情导出独立 WG 配置保留同一 IP;暂停后禁止继续生成接入链接。 +- 移除已选设备后旧链接保持明确不可用,清除选择才回到纯代理;没有自动切换身份或静默降级。 +- 订阅页与独立导出页在 320、360、736、1024 宽度均无横向溢出,深色外观可用。 +- JavaScript 语法、片段结构与无网络请求检查通过;最终浏览器检查无控制台错误。 + +以上仅验证原型交互与演示结构。真实凭证授权、网关生命周期和三端订阅结果属于实施验收。 + +参考:[Surge WireGuard](https://manual.nssurge.com/policies/wireguard.html)、[托管配置更新](https://manual.nssurge.com/profile/managed-profile.html)、[WireGuard 工具与状态](https://git.zx2c4.com/wireguard-tools/about/src/man/wg.8)、[现有组网记录](../../WIREGUARD-LAN.md)。 diff --git a/docs/design/wireguard-devices/HANDOFF.md b/docs/design/wireguard-devices/HANDOFF.md new file mode 100644 index 0000000..6c85f64 --- /dev/null +++ b/docs/design/wireguard-devices/HANDOFF.md @@ -0,0 +1,99 @@ +# WireGuard 设备管理实现交接 + +更新:2026-11-02。下述最初缺口已完成本地实现,包含设备加密存储/旧库迁移、网关 agent/回执、设备 UI 和订阅绑定。当前接续请先读 [实现与上线验收](../../WIREGUARD-DEVICES.md)。已部署 LA 应用与 njcq agent,原有两台设备只读纳管已完成;未刷新活动客户端;下方“已有能力与实现缺口”保留为实施前基线。 + +## 开始实现前先读 + +1. [完整产品与技术设计](DESIGN.md):最新需求与验收标准的唯一方案入口。 +2. [可点击原型](wg-device-management.html):设备列表、详情、独立导出,以及订阅页选择设备。可直接用浏览器打开;在对话内也可交互。数据、链接、二维码与握手均为演示。 +3. [既有 WG 实现与验证记录](../../WIREGUARD-LAN.md):2026-11-01 已落地的设备专用 token 模型、njcq 配置和历史验收,不代表新设备管理功能已实现。 +4. 根目录 [AGENTS.md](../../../AGENTS.md):仓库修改、验证、凭据、部署与线上数据操作约定。 + +## 用户最终确认的交互 + +- 个人 macOS、iPadOS、iOS 设备统一使用支持 WireGuard 与入站的 Surge。用户同日随后已在三端全面实测 Surge + WireGuard 可工作,并要求停止 Shadowrocket WG 下发兼容;实际网络/版本/生命周期的具体证据范围仍以用户回报和实现记录为准。 +- 保留设备列表、私网 IP、状态、最后活动、上下行、管理操作及独立 WireGuard `.conf` / 二维码导出。 +- 去掉“使用客户端”,不按 Surge / Shadowrocket / 原生 WG 分支创建不同设备身份。 +- 在订阅页选 Surge 后,增加可清除的设备下拉框,来源为设备列表。 +- 未选设备:原默认 Surge URL 不变,配置只含原节点、策略组和规则,无 WG 配置项。 +- 选中设备:基于同一订阅入口生成带设备分发凭证的派生 URL,原代理内容加该设备的 WG 配置。不同设备完整 URL 可以不同;它们共享节点与规则来源。 +- 清除选择:恢复原默认纯代理 URL。只影响当前导出的链接,不修改其他设备的既有链接,不撤销 peer。 +- 新方案不安装身份模块,也不再靠新建一条独立订阅卡片表达设备。初次迁移保留旧设备专用 token;用户同日后续要求仅保留一个订阅,生产已退休两条旧设备卡片,设备通过原主订阅附加。详见实现记录。 + +**选择某设备不能修改服务端全局状态或覆盖共享 token 的 wireguard 字段。** 已生成的 A 设备 URL 必须继续获取 A 身份,不能随另一个浏览器选择 B 而变化。 + +## 已有能力与实现缺口(实施前基线) + +| 部分 | 当前代码 | 后续工作 | +|---|---|---| +| 身份存储 | `sub_tokens.wireguard` 可空 JSON,真实密钥由当前管理员 DTO 返回 | 建立独立设备实体、加密密钥存储和不含私钥的列表 DTO;迁移兼容旧 token | +| 订阅生成 | `irFor()` 附加当前 token 的身份,Surge/WG 序列化器已可用 | 将身份来源改为经过授权的设备绑定;无选择保持纯代理 | +| 自更新地址 | Surge 路由的 `selfUrl` 当前没有查询参数 | 保留经过验证的设备凭证参数,否则下一次自动刷新丢 WG | +| 独立导出 | 标准 `.conf` 与 Shadowrocket WG YAML 已有生成器 | 标准导出改为设备授权入口,复用同一身份,不再额外创建 peer | +| 设备 UI | 当前只有订阅卡片附带 WG 地址/链接 | 新增设备页、详情、状态与接入流程;订阅页新增设备下拉框 | +| 网关生命周期 | 一次性安装脚本预置两台设备 | 独立网关采集、幂等 peer 应用/撤销、运行与持久配置回执;不是只操作下载 token | +| `?wg=...` | 当前订阅路由尚未解析这一参数 | 实现高熵、不可枚举、可撤销、绑定订阅和设备的分发凭证 | + +上述表格为实施前的代码基线,已由本地新实现补齐;最新路径和行为见实现记录。原“当前代码”来自本地源码核对,不是本次重新读取生产 API 或远端配置的结果。工作区有其他未提交的 FFSB、规则、生成器等变更,需保留并按文件审阅,不用整目录覆盖或回滚。 + +## 代码定位 + +| 路径 | 作用 | +|---|---| +| `shared/src/wireguard.ts` | WireguardProfile、私网/密钥/MTU 校验;当前 MTU 默认 1280、上限 1420 | +| `shared/src/dto.ts` | 当前 TokenDto 与导出 URL,后续拆分设备元数据和私密领取 | +| `server/src/db/schema.ts`、`server/src/db/index.ts` | 当前 token 身份字段与旧库补列;新增表和迁移同时更新 | +| `server/src/routes/tokens.ts` | 当前 `/api/tokens` CRUD、重复身份检查、URL 和 DTO | +| `server/src/routes/sub.ts` | token 校验、`irFor()`、标准 WG 导出、Surge 路由与 selfUrl;新增绑定鉴权的关键位置 | +| `server/src/services/ir.ts` | 原代理 IR 与可选 WG 身份类型,保留节点权限范围 | +| `server/src/services/generators/wireguard.ts` | 标准 WG、Shadowrocket WG、Surge WireGuard 段落生成 | +| `server/src/services/generators/surge.ts` | WG 代理声明、私网规则、自地址 DIRECT、托管 header 与公网组排除 | +| `server/src/routes/wireguard.test.ts` | 当前 WG token、迁移、隔离与配置行为测试,可扩展新绑定测试 | +| `web/src/pages/SubscriptionsPage.vue` | 当前 WG 仍作为 token 卡片额外导出;实现设备下拉与清除操作 | +| `web/src/App.vue`、`web/src/router.ts` | 增加“设备”导航和受鉴权页面 | +| `.claude/skills/edge-node-multi-protocol/scripts/17-install-wireguard.sh` | 既有 njcq 安装编排;目前不是通用设备地址分配器或网关 agent | + +管理资源沿用 `/api/tokens`。本地已实现 `POST /api/tokens/:id/wg-bindings`,其余设备、任务、网关与导出 API 见 [实现记录](../../WIREGUARD-DEVICES.md),已发布到生产,设备侧新导入/入站仍须验收。 + +## njcq 与设备身份基线 + +最后记录的服务为 Linux 内核 WireGuard,接口 `wg-njcq`,UDP 51820,网关 `10.203.0.1/24`。正式登记为 iPad Pro `10.203.0.2`、Mac Studio `10.203.0.3`,独立 keypair/PSK,MTU 已从临时测试恢复 1280。只提供 `10.203.0.0/24` 设备互访,不做 Internet NAT。 + +原型中 MacBook Air、iPhone、Windows/Linux 等额外设备、速率、状态、最后活动以及示例地址均不是正式登记清单,不能拿去初始化生产。iPhone 是否已有独立 peer 须在实施前核对,不能复用 iPad 身份。 + +2026-11-01 已验证:Mac Surge 6.11 入站及经 hub 访问本机 VNC;iPad 官方 WG → njcq → Mac Surge 的 Apple VNC 成功。Shadowrocket 普通 VNC 可用但 Apple VNC 黑屏,具体实现根因尚未确认。MTU 1420 不是已证实修复,临时修改与监测已恢复/结束。 + +用户后来统一三端为 Surge,因此 Shadowrocket 后续 TUN-only 等排查留作历史材料,不是本次设备管理的实现前提。Surge 三端安装后的自动更新、入站与 Apple VNC 仍需单独记录实际验收,不能用普通握手代替媒体可用性验证。 + +## 私有资料在哪里 + +仅列路径,不复制凭据。真实内容仍留在本机 Git 忽略的 `edge-nodes/private/njcq-edge-node-docs/`: + +- `README.md`:真实交付入口、订阅地址和备份说明。 +- `subscriptions.json`:既有两个设备 token、WG 身份与 URL 的交付记录,实施迁移前应与线上重新比对。 +- `import/njcq-ipad.conf`、`import/njcq-ipad-qr.png`:原生客户端的成功对照产物。 +- `import/export-summary.json`:导出摘要。 + +上述真实配置/订阅文件已核对存在、权限 600;父目录应维持 700。不得把真实订阅 URL、私钥、PSK、二维码或完整配置放入公共设计文档、普通日志或 Git。新克隆只携带公共源码和方案,不会自动携带这些私有交付记录。 + +njcq 远端配置在 `/etc/wireguard/`,备份索引见 [WG 记录](../../WIREGUARD-LAN.md)。需要操作生产时按仓库技能重新读取、备份和验证,不以历史交付数据覆盖最新配置。 + +## 实现时必须守住的边界 + +1. 设备凭证不能是裸 ID/IP;普通共享订阅 token 不得通过枚举设备获得私钥。绑定校验订阅、设备、用途和撤销状态,密钥响应 no-store,日志与缓存按敏感数据处理。 +2. `#!MANAGED-CONFIG` 使用完整带设备参数的自身 URL,仅保留受支持且已验证的参数。无设备时继续原 URL。 +3. 公网节点、策略组、DNS、FFSB 手动分流和其他组选择保持原范围;WG 不被 Automatic/All Nodes 等选成 Internet 出口。 +4. 首次握手是连接证据,点击生成/复制/导入不是在线证据。采集过期显示未知,不使用缓存速率冒充实时值。 +5. 暂停必须真正移除网关 peer 并持久化;停用分发凭证不能撤销已下载的配置。移除后旧链接拒绝,不静默降级或换身份,设备 ID/IP 不立即复用。 +6. 幂等任务确认前不报成功;采用原子配置合并,保留非受管 peer 和现有 Tailscale、Docker 等共机服务。 +7. 本仓库另有 [FFSB 原生 WG 试验](../../FFSB-WIREGUARD-SURGE-2026-11-02.md),是第三方服务的不同身份/网段,不能合并进 njcq 私网设备或自动激活试验配置。其当前恢复状态以该文档与 AGENTS.md 为准。 + +## 实施与验收顺序 + +先纳管现有两台设备并只读采集;再做新增/暂停/恢复和独立导出;最后实现订阅设备绑定、Surge 配置生成与三端实际导入。迁移保留现有 key、PSK、IP 和旧链接兼容,不为了新 UI 重建所有 peer。 + +测试应覆盖默认无 WG、各设备私钥隔离、非法/跨订阅凭证、selfUrl 保留设备参数、设备暂停/归档、旧库迁移、失败幂等重试、采集失联、真实撤销以及节点范围/FINAL 不变。含密钥的 fixture 使用占位值,数据库验证在模块加载前设置隔离 DATA_DIR,不能测试真实 server/data。 + +验证命令依改动执行根目录 `AGENTS.md` 的 typecheck、相关 Vitest 与前端构建。Surge 产物先核对下载成功/结构,再用官方 CLI;CLI 通过仍需 macOS、iPadOS、iOS 的真实连接验收。生产部署和远端写入按后续用户授权执行。 + +本次交接保留了上一轮原型验证记录:设备选择/清除与配置结构、自更新 URL、独立导出、新增/暂停/移除、320–1024 宽度和深色外观。它们是原型验收,不是待实现功能的生产验收。 diff --git a/docs/design/wireguard-devices/wg-device-management.html b/docs/design/wireguard-devices/wg-device-management.html new file mode 100644 index 0000000..33fbe3b --- /dev/null +++ b/docs/design/wireguard-devices/wg-device-management.html @@ -0,0 +1,249 @@ +
+ +
+
PProxy Station
+ + 交互原型 · 演示数据 +
+
+ + +
diff --git a/edge-nodes/README.md b/edge-nodes/README.md index cac460c..fd1ba3d 100644 --- a/edge-nodes/README.md +++ b/edge-nodes/README.md @@ -31,9 +31,14 @@ edge-nodes/ | la | DMIT/洛杉矶,承载 Proxy Station 生产服务 | `private/la-edge-node-docs/` | | rn | RackNerd/洛杉矶 | `private/rn2-edge-node-docs/` | | jp | VMISS/东京,裸 nginx | `private/jp-edge-node-docs/` | +| njcq | 腾讯云/用户命名南京,WireGuard 私网与原 Tailscale relay 共存 | `private/njcq-edge-node-docs/` | 站点信息是迁移时的工作记录;维护前仍需读取私有交付文档并核对实际机器。`rn-edge-node-docs/` 的旧链接不作为当前 RN 节点导入来源。 +njcq 使用 `17-install-wireguard.sh`,不部署公网代理出口,按设备生成身份并经订阅交付。新增 WireGuard 使用 IPv4,未改现役共机服务的系统 IPv6。见 [WireGuard 组网](../docs/WIREGUARD-LAN.md)。 + +后续设备管理与 Surge 可选设备订阅从 [实现交接](../docs/design/wireguard-devices/HANDOFF.md) 开始。安装脚本目前预置两个 peer,不是通用网关管理 agent;产品原型中的额外设备均为演示,不用于初始化生产。 + 应用内的登记/启用节点与舰队交付线路是不同统计范围。最新应用盘点为 30 个登记节点、12 个启用、18 个停用,见 [当前生产状态](../docs/CURRENT-STATE.md);停用不表示远端线路故障,维护节点或生成交付页不自动恢复应用开关。 2026-10-03 四台现役节点已统一使用 IPv4:系统接口禁用 IPv6,代理层拒绝 IPv6 目标(含 WARP),Cloudflare 不保留节点 AAAA。后续重建与扩展遵守 [IPv4-only 维护约定](../docs/EDGE-IPV4-ONLY.md);具体备份与验收在各站点的私有 `04-IPv4-only.md`。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bc0ba2..290ae21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: pinia: specifier: ^2.3.0 version: 2.3.1(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 vue: specifier: ^3.5.13 version: 3.5.41(typescript@5.9.3) @@ -92,6 +95,9 @@ importers: specifier: ^4.5.0 version: 4.6.4(vue@3.5.41(typescript@5.9.3)) devDependencies: + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@vitejs/plugin-vue': specifier: ^5.2.1 version: 5.2.4(vite@6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) @@ -613,6 +619,7 @@ packages: engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-android-arm-eabi@4.62.5': resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==} @@ -648,66 +655,79 @@ packages: resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.5': resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.5': resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.5': resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.5': resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.5': resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.5': resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.5': resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.5': resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.5': resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.5': resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.5': resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.5': resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.62.5': resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==} @@ -754,6 +774,9 @@ packages: '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@vitejs/plugin-vue@5.2.4': resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -883,6 +906,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -898,6 +925,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -943,6 +973,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -959,6 +993,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + drizzle-orm@0.38.4: resolution: {integrity: sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q==} peerDependencies: @@ -1112,6 +1149,10 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1156,6 +1197,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -1214,9 +1259,25 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1240,6 +1301,10 @@ packages: typescript: optional: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -1253,6 +1318,11 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -1265,6 +1335,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + rollup@4.62.5: resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1284,6 +1357,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + shell-quote@1.9.0: resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} engines: {node: '>= 0.4'} @@ -1541,11 +1617,18 @@ packages: peerDependencies: vue: ^3.0.11 + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1553,6 +1636,9 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1562,10 +1648,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -1932,6 +2026,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.20.1 + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3))': dependencies: vite: 6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0) @@ -2105,6 +2203,8 @@ snapshots: cac@6.7.14: {} + camelcase@5.3.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -2122,6 +2222,12 @@ snapshots: chownr@1.1.4: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -2164,6 +2270,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -2174,6 +2282,8 @@ snapshots: detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} + drizzle-orm@0.38.4(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0): optionalDependencies: '@types/better-sqlite3': 7.6.13 @@ -2293,6 +2403,11 @@ snapshots: file-uri-to-path@1.0.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + fs-constants@1.0.0: {} fsevents@2.3.3: @@ -2318,6 +2433,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash-es@4.18.1: {} lodash@4.18.1: {} @@ -2378,8 +2497,20 @@ snapshots: dependencies: wrappy: 1.0.2 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} + pathe@1.1.2: {} pathval@2.0.1: {} @@ -2398,6 +2529,8 @@ snapshots: transitivePeerDependencies: - '@vue/composition-api' + pngjs@5.0.0: {} + postcss@8.5.26: dependencies: nanoid: 3.3.18 @@ -2424,6 +2557,12 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -2439,6 +2578,8 @@ snapshots: require-directory@2.1.1: {} + require-main-filename@2.0.0: {} + rollup@4.62.5: dependencies: '@types/estree': 1.0.9 @@ -2481,6 +2622,8 @@ snapshots: semver@7.8.5: {} + set-blocking@2.0.0: {} + shell-quote@1.9.0: {} siginfo@2.0.0: {} @@ -2701,11 +2844,19 @@ snapshots: vooks: 0.2.12(vue@3.5.41(typescript@5.9.3)) vue: 3.5.41(typescript@5.9.3) + which-module@2.0.1: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -2714,12 +2865,33 @@ snapshots: wrappy@1.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/rules/ffsb-domains.json b/rules/ffsb-domains.json new file mode 120000 index 0000000..d44b005 --- /dev/null +++ b/rules/ffsb-domains.json @@ -0,0 +1 @@ +../server/assets/ffsb-domains.json \ No newline at end of file diff --git a/rules/finance.list b/rules/finance.list new file mode 100644 index 0000000..3fc0e87 --- /dev/null +++ b/rules/finance.list @@ -0,0 +1,92 @@ +# Finance domain snapshot — 2026-10-13 +# PayPal source: https://github.com/blackmatrix7/ios_rule_script/blob/master/rule/Surge/PayPal/PayPal.list +# Keyword-covered PayPal suffixes are redundant; legacy USER-AGENT is omitted. +# Includes PayPal family domains from upstream (Braintree / Venmo / Xoom). +# Plasma source: https://github.com/mediakov/plasma-card-sdk/blob/main/src/constants.ts +# Privy hosts are shared by other apps; exact hosts are used. +DOMAIN-SUFFIX,plasma.to,💰 Finance +DOMAIN-SUFFIX,plasma.org,💰 Finance +DOMAIN-SUFFIX,plasma-one.tech,💰 Finance +DOMAIN,auth.privy.io,💰 Finance +DOMAIN,api.privy.io,💰 Finance +DOMAIN-SUFFIX,anfutong.cn,💰 Finance +DOMAIN-SUFFIX,anfutong.com,💰 Finance +DOMAIN-SUFFIX,anfutong.com.cn,💰 Finance +DOMAIN-SUFFIX,beibao.cn,💰 Finance +DOMAIN-SUFFIX,beibao.com,💰 Finance +DOMAIN-SUFFIX,beibao.com.cn,💰 Finance +DOMAIN-SUFFIX,bill-safe.com,💰 Finance +DOMAIN-SUFFIX,billmelater.com,💰 Finance +DOMAIN-SUFFIX,billmelater.info,💰 Finance +DOMAIN-SUFFIX,billmelater.net,💰 Finance +DOMAIN-SUFFIX,bml.info,💰 Finance +DOMAIN-SUFFIX,braintreegateway.com,💰 Finance +DOMAIN-SUFFIX,braintreegateway.tv,💰 Finance +DOMAIN-SUFFIX,braintreepayments.com,💰 Finance +DOMAIN-SUFFIX,braintreepayments.info,💰 Finance +DOMAIN-SUFFIX,braintreepayments.org,💰 Finance +DOMAIN-SUFFIX,braintreepayments.tv,💰 Finance +DOMAIN-SUFFIX,braintreepaymentsolutions.com,💰 Finance +DOMAIN-SUFFIX,braintreeps.com,💰 Finance +DOMAIN-SUFFIX,briantreepayments.net,💰 Finance +DOMAIN-SUFFIX,briantreepayments.tv,💰 Finance +DOMAIN-SUFFIX,buyfast-paysmart.net,💰 Finance +DOMAIN-SUFFIX,card.io,💰 Finance +DOMAIN-SUFFIX,cash2.com,💰 Finance +DOMAIN-SUFFIX,cashify.com,💰 Finance +DOMAIN-SUFFIX,cashify.net,💰 Finance +DOMAIN-SUFFIX,experiencebillmelater.com,💰 Finance +DOMAIN-SUFFIX,filipino-music.net,💰 Finance +DOMAIN-SUFFIX,getbraintree.com,💰 Finance +DOMAIN-SUFFIX,gmoney.org,💰 Finance +DOMAIN-SUFFIX,i-o-u.info,💰 Finance +DOMAIN-SUFFIX,krakenjs.com,💰 Finance +DOMAIN-SUFFIX,loanbuilder.com,💰 Finance +DOMAIN-SUFFIX,mywaytopay.info,💰 Finance +DOMAIN-SUFFIX,mywaytopay.net,💰 Finance +DOMAIN-SUFFIX,pa9pal.com,💰 Finance +DOMAIN-SUFFIX,paaypal.com,💰 Finance +DOMAIN-SUFFIX,paily.net,💰 Finance +DOMAIN-SUFFIX,paily.org,💰 Finance +DOMAIN-SUFFIX,paipal.com,💰 Finance +DOMAIN-SUFFIX,pavpal.com,💰 Finance +DOMAIN-SUFFIX,paydiant.com,💰 Finance +DOMAIN-SUFFIX,paylike.com,💰 Finance +DOMAIN-SUFFIX,paypa1.com,💰 Finance +DOMAIN-SUFFIX,paypa1.org,💰 Finance +DOMAIN-SUFFIX,paypaal.com,💰 Finance +DOMAIN-SUFFIX,payppal.com,💰 Finance +DOMAIN-SUFFIX,payypal.com,💰 Finance +DOMAIN-SUFFIX,pdncommunity.com,💰 Finance +DOMAIN-SUFFIX,pp-soc.com,💰 Finance +DOMAIN-SUFFIX,pppds.com,💰 Finance +DOMAIN-SUFFIX,pypl.com,💰 Finance +DOMAIN-SUFFIX,pypl.info,💰 Finance +DOMAIN-SUFFIX,pypl.net,💰 Finance +DOMAIN-SUFFIX,pypl.tv,💰 Finance +DOMAIN-SUFFIX,s-xoom.com,💰 Finance +DOMAIN-SUFFIX,simility.com,💰 Finance +DOMAIN-SUFFIX,swiftbank.info,💰 Finance +DOMAIN-SUFFIX,swiftbank.us,💰 Finance +DOMAIN-SUFFIX,swiftcapital.com,💰 Finance +DOMAIN-SUFFIX,swiftfinancial.com,💰 Finance +DOMAIN-SUFFIX,swiftfinancial.info,💰 Finance +DOMAIN-SUFFIX,swiftfinancial.net,💰 Finance +DOMAIN-SUFFIX,theshoppingexpresslane.net,💰 Finance +DOMAIN-SUFFIX,venmo-touch.com,💰 Finance +DOMAIN-SUFFIX,venmo.com,💰 Finance +DOMAIN-SUFFIX,venmo.info,💰 Finance +DOMAIN-SUFFIX,venmo.net,💰 Finance +DOMAIN-SUFFIX,venmo.org,💰 Finance +DOMAIN-SUFFIX,venmo.s3.amazonaws.com,💰 Finance +DOMAIN-SUFFIX,webmoneyinfo.com,💰 Finance +DOMAIN-SUFFIX,wiremoneytoirelandwithxoomeasierandcheaper.com,💰 Finance +DOMAIN-SUFFIX,wwwxoom.com,💰 Finance +DOMAIN-SUFFIX,xn--bnq297cix3a.cn,💰 Finance +DOMAIN-SUFFIX,xoom-experience.com,💰 Finance +DOMAIN-SUFFIX,xoom.com,💰 Finance +DOMAIN-SUFFIX,xoom.io,💰 Finance +DOMAIN-SUFFIX,xoom.net.cn,💰 Finance +DOMAIN-SUFFIX,xoom.us,💰 Finance +DOMAIN-SUFFIX,xoomcom.com,💰 Finance +DOMAIN-KEYWORD,paypal,💰 Finance diff --git a/rules/meta-muse.list b/rules/meta-muse.list new file mode 100644 index 0000000..b41d053 --- /dev/null +++ b/rules/meta-muse.list @@ -0,0 +1,10 @@ +# Meta Muse / Model API / Muse Code — reviewed 2026-10-13 +# Source: https://github.com/kkykkky/metamuse-rules/blob/main/MetaMuse.domainset +# Explicit snapshot; not automatically synchronized with upstream. +# meta.com also routes Meta account services and its main website through AI. +DOMAIN-SUFFIX,muse.ai,🤖 AI +DOMAIN-SUFFIX,meta.ai,🤖 AI +DOMAIN-SUFFIX,meta.com,🤖 AI +DOMAIN-SUFFIX,llama.com,🤖 AI +# metaaivm.com: user-observed Muse request, 2026-10-13. +DOMAIN-SUFFIX,metaaivm.com,🤖 AI diff --git a/scripts/ffsb/README.md b/scripts/ffsb/README.md new file mode 100644 index 0000000..ca1896e --- /dev/null +++ b/scripts/ffsb/README.md @@ -0,0 +1,40 @@ +# 历史本机 FFSB / Surge 协调层 + +**已退役(2026-10-19)**:当前使用正式 `Proxy Station` 订阅内的手动 `🦁 FFSB` 选项。LaunchAgent 已停用,不要按下文重新安装;最新配置和设备前提见 [手动订阅方案](../../docs/FFSB-MANUAL-SUBSCRIPTION-2026-10-19.md)。以下仅保留为历史实现与回滚参考。 + +此工具只针对已验收的 macOS 本机。生产维护独立 `🦀 Claude` 组;本机增加自动入口 `Claude`。25 项范围来自 `ffsb-service networks list`,快照为 `rules/ffsb-domains.json`,不能把精确的 `claude.ai` 扩成通配域。实际报告见 `docs/FFSB-SURGE-IMPLEMENTATION-2026-10-19.md`。 + +- `overlay.py`:从完整生产配置生成本机候选。FFSB 就绪时,25 项 DNS 指向 `100.70.255.254`,业务策略绑定 `utun100`;其余 Claude 规则仍使用 `🦀 Claude`。FFSB 未就绪时移除这些 DNS 覆盖,自动入口只包含 `🦀 Claude`。 +- `reconcile.py`:读取 FFSB 管理/信令状态并探测 DNS;经官方 `surge-cli --check` 后原子替换本机生成文件,仅在该文件为活动配置时 reload/flush dns。失败保留最近有效源;重载失败会在下一轮重试。不会关闭/开启 FFSB 或 Surge。 +- 生产源 URL 取自原有 `Proxy Station.conf`,只允许 HTTPS。每小时重新获取、原生验证并保存到私有缓存;`--refresh` 可立即刷新。原托管文件不被写入。生成配置是普通本机配置,更新由协调任务负责,不能把普通配置的 UI 更新按钮当成此任务的刷新入口。 +- 用户级 LaunchAgent 每 10 秒运行一次;两端需要就绪后再使用,切换不承诺瞬时完成或迁移已建立的连接。 + +已安装文件位于 `~/Library/Application Support/ProxyStationFFSB/`,LaunchAgent 为 `~/Library/LaunchAgents/com.proxystation.ffsb-overlay.plist`;生成配置为 `~/Library/Application Support/Surge/Profiles/Proxy Station Claude.conf`。配置缓存与备份含节点凭据,保持私有目录和文件权限,不提交 Git。 + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/ffsb -v +PYTHONDONTWRITEBYTECODE=1 /usr/bin/python3 "$HOME/Library/Application Support/ProxyStationFFSB/reconcile.py" --refresh +launchctl print "gui/$(id -u)/com.proxystation.ffsb-overlay" +``` + +更新这些源码不会自动更新本机已安装副本;重新安装前先测试并保存副本。接口名、FFSB DNS、域名范围或服务输出格式改变时须重新验收。 + +手动 `--refresh` 成功且配置没有变化时,输出 `Refresh succeeded; configuration unchanged; mode=FFSB; no reload`(回退时 mode 为 Surge)。后台定时检查无变化时保持静默。拉取或原生校验失败但已有有效缓存时,stderr 明确提示沿用缓存,手动无变化提示为 `Refresh failed`;此时仍可正常完成缓存协调,退出码不能单独代表远端刷新成功。日志不输出订阅 URL。 + +## 回滚本机覆盖层 + +先停用协调任务,再切回原来的 `Proxy Station`,保留文件以便调查: + +```bash +launchctl bootout "gui/$(id -u)/com.proxystation.ffsb-overlay" +/Applications/Surge.app/Contents/Applications/surge-cli profile switch 'Proxy Station' +/Applications/Surge.app/Contents/Applications/surge-cli managed-profile update +``` + +这仅撤销本机覆盖,生产仍保留独立 `🦀 Claude` 组。生产规则回滚应通过管理 API 使用本次快照定向恢复,不覆盖其他人后续变更。FFSB 自身设置未修改。 + +## 已知边界 + +系统分域 DNS 和已有 host routes 仍由 FFSB 负责。FFSB 关闭后的回退已实测;FFSB 处于“接口存在但内部故障”时,系统客户端仍可能受其残留 DNS/路由影响,不能将“关闭回退”扩大解释成所有隧道故障都自动修复。 + +Surge 原生 fallback 有探测及缓存周期,本地状态协调也有周期。短时过渡可能失败,既有连接不迁移。硬编码 IP、独立远端代理/加密 DNS 等不可识别目标无法保证按域名分类。真实应用认证、长流、睡眠/网络切换及完整启动顺序的覆盖状态以验收报告为准。 diff --git a/scripts/ffsb/overlay.py b/scripts/ffsb/overlay.py new file mode 100644 index 0000000..a11fdf1 --- /dev/null +++ b/scripts/ffsb/overlay.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Build a device-local Surge profile. Never modify the managed source profile.""" +import argparse +import json +import re +from pathlib import Path + + +def sections(text): + result = {} + current = None + for line in text.splitlines(): + match = re.fullmatch(r'\[([^]]+)\]', line.strip()) + if match: + current = match[1] + result[current] = [] + elif current is not None: + result[current].append(line) + return result + + +def build(base, domains, enabled): + if len(domains) != 25 or len(set(domains)) != 25: + raise ValueError('FFSB scope changed; re-audit before applying') + s = sections(base) + for key in ['General', 'Proxy', 'Proxy Group', 'Rule', 'Host']: + if key not in s: + raise ValueError('Missing section: ' + key) + groups = s['Proxy Group'] + original = [x for x in groups if re.match(r'^(?:Claude|"?🦀 Claude"?)\s*=', x)] + if len(original) != 1: + raise ValueError('Expected one production Claude group') + if original[0].startswith('Claude'): + groups[groups.index(original[0])] = original[0].replace('Claude', '"🦀 Claude"', 1) + groups.insert(0, 'Claude = fallback, ' + ('FFSB, ' if enabled else '') + '"🦀 Claude", interval=5') + s['Proxy'].insert(0, 'FFSB = direct, interface=utun100, allow-other-interface=false, dns-follow-interface=true, test-url=https://api.ipify.org') + # The domain union goes to the Surge fallback unless overridden by the exact FFSB scope. + s['Rule'] = [re.sub(r',"?Claude"?(?=,|$)', ',"🦀 Claude"', line) for line in s['Rule']] + scope = [('DOMAIN-WILDCARD' if d.startswith('*.') else 'DOMAIN') + ',' + d + ',Claude' for d in domains] + outer = 'PROCESS-NAME,/Applications/FFSB.app/Contents/MacOS/ffsb-service,DIRECT' + s['Rule'] = [outer] + scope + [line for line in s['Rule'] if line != outer] + if enabled: + s['Host'] = [d + ' = server:100.70.255.254' for d in domains] + s['Host'] + for i, line in enumerate(s['General']): + if re.match(r'^always-real-ip\s*=', line): + s['General'][i] = line + ', ' + ', '.join(domains) + break + else: + s['General'].append('always-real-ip = ' + ', '.join(domains)) + return '# Local Claude overlay; source remains managed. Mode: ' + ('FFSB' if enabled else 'Surge') + '\n\n' + '\n\n'.join('['+key+']\n'+'\n'.join(lines) for key, lines in s.items()) + '\n' + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('base', type=Path) + parser.add_argument('domains', type=Path) + parser.add_argument('output', type=Path) + parser.add_argument('--ffsb', action='store_true') + args = parser.parse_args() + args.output.write_text(build(args.base.read_text(), json.loads(args.domains.read_text()), args.ffsb)) + + +if __name__ == '__main__': + main() diff --git a/scripts/ffsb/reconcile.py b/scripts/ffsb/reconcile.py new file mode 100644 index 0000000..1089b4e --- /dev/null +++ b/scripts/ffsb/reconcile.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Reconcile the local profile after FFSB or its managed source changes.""" +import hashlib +import json +import os +import re +import time +from pathlib import Path +import subprocess +import sys +from overlay import build + +CLI = '/Applications/Surge.app/Contents/Applications/surge-cli' +SERVICE = '/Applications/FFSB.app/Contents/MacOS/ffsb-service' +PROFILE = 'Proxy Station Claude' +ROOT = Path.home() / 'Library/Application Support/ProxyStationFFSB' +PROFILES = Path.home() / 'Library/Application Support/Surge/Profiles' + + +def run(args, timeout=8): + return subprocess.run(args, capture_output=True, text=True, timeout=timeout) + + +def main(): + os.umask(0o077) + manual_refresh = '--refresh' in sys.argv + refresh_failed = False + domains = json.loads((ROOT / 'domains.json').read_text()) + status = run([SERVICE, 'status']) + connected = status.returncode == 0 and 'Management: Connected' in status.stdout and 'Signal: Connected' in status.stdout + if connected: + answer = run(['/usr/bin/dig', '@100.70.255.254', 'api.ipify.org', 'A', '+time=1', '+tries=1', '+short']) + connected = answer.returncode == 0 and any(line.count('.') == 3 and all(x.isdigit() for x in line.split('.')) for line in answer.stdout.splitlines()) + managed = (PROFILES / 'Proxy Station.conf').read_text() + cache = ROOT / 'managed-source.conf' + if not cache.exists() or manual_refresh or time.time() - cache.stat().st_mtime > 3600: + url = re.search(r'^#!MANAGED-CONFIG\s+(\S+)', managed, re.M) + if not url or not url[1].startswith('https://'): + raise RuntimeError('Expected existing HTTPS managed source') + fetched = ROOT / 'download.conf' + env = {k: v for k, v in os.environ.items() if k.lower() not in ['http_proxy', 'https_proxy', 'all_proxy']} + result = subprocess.run(['/usr/bin/curl', '-fsSL', '--max-time', '20', url[1], '-o', str(fetched)], env=env, capture_output=True, text=True) + if result.returncode == 0 and fetched.exists() and fetched.stat().st_size > 0: + check = run([CLI, '--check', str(fetched)], timeout=30) + if check.returncode == 0: + fetched.replace(cache) + elif not cache.exists(): + raise RuntimeError('Downloaded source failed native validation') + else: + refresh_failed = True + elif not cache.exists(): + raise RuntimeError('Cannot fetch initial managed source') + else: + refresh_failed = True + if refresh_failed: + print('Warning: source refresh failed; using last validated cache', file=sys.stderr, flush=True) + source = cache.read_text() + candidate = build(source, domains, connected) + target = PROFILES / (PROFILE + '.conf') + profile_hash = hashlib.sha256(candidate.encode()).hexdigest() + state_file = ROOT / 'state.json' + try: + previous_state = json.loads(state_file.read_text()) + except (FileNotFoundError, ValueError): + previous_state = {} + if target.exists() and target.read_text() == candidate and previous_state.get('profile_sha256') == profile_hash: + if manual_refresh: + print(('Refresh failed; cached configuration unchanged' if refresh_failed else 'Refresh succeeded; configuration unchanged') + '; mode=' + ('FFSB' if connected else 'Surge') + '; no reload', flush=True) + return + temp = ROOT / 'candidate.conf' + temp.write_text(candidate) + check = run([CLI, '--check', str(temp)], timeout=30) + if check.returncode: + raise RuntimeError('Candidate rejected: ' + check.stdout + check.stderr) + # Same-volume atomic replace, and preserve last working candidate for manual recovery. + if target.exists(): + (ROOT / 'previous.conf').write_text(target.read_text()) + staged = target.with_suffix('.pending') + staged.write_text(candidate) + staged.replace(target) + current = run([CLI, 'profile', 'current']) + if current.returncode == 0 and current.stdout.strip().endswith(PROFILE): + result = run([CLI, 'reload'], timeout=30) + if result.returncode: + raise RuntimeError('Reload failed: ' + result.stdout + result.stderr) + run([CLI, 'flush', 'dns']) + (ROOT / 'state.json').write_text(json.dumps({'mode': 'FFSB' if connected else 'Surge', 'source_sha256': hashlib.sha256(source.encode()).hexdigest(), 'profile_sha256': profile_hash}, indent=2)) + print('Applied mode=' + ('FFSB' if connected else 'Surge'), flush=True) + + +if __name__ == '__main__': + try: + main() + except Exception as error: + print(type(error).__name__ + ': ' + str(error), file=sys.stderr, flush=True) + sys.exit(1) diff --git a/scripts/ffsb/test_overlay.py b/scripts/ffsb/test_overlay.py new file mode 100644 index 0000000..1220a50 --- /dev/null +++ b/scripts/ffsb/test_overlay.py @@ -0,0 +1,53 @@ +import unittest +from overlay import build, sections + +DOMAINS = ['*.anthropic.com','*.clau.de','*.claude.com','*.claudemcpclient.com','*.claudemcpcontent.com','*.claudeusercontent.com','*.ffsb.sg','a.claude.ai','anthropic.com','api.anthropic.com','api.ipify.org','assets.claude.ai','clau.de','claude.ai','claude.com','claudemcpclient.com','claudemcpcontent.com','claudeusercontent.com','ffsb.sg','icanhazip.com','ifconfig.me','ip.net.coffee','join.fufeishangban.com','pivot.claude.ai','www.claude.ai'] +BASE = '''[General] +always-real-ip = *.example.ts.net +[Proxy] +Node = http, 127.0.0.1, 9999 +[Proxy Group] +Claude = select, Node +AI = select, Node +[Rule] +DOMAIN-SUFFIX,claude.ai,Claude +DOMAIN,anthropic.auth0.com,Claude +DOMAIN-SUFFIX,openai.com,AI +FINAL,DIRECT +[Host] +*.claude.ai = server:https://1.0.0.1/dns-query +''' + +class OverlayTests(unittest.TestCase): + def test_on_preserves_exact_ffsb_scope_and_extra_fallback(self): + s = sections(build(BASE, DOMAINS, True)) + self.assertEqual(sum(x.endswith(',Claude') for x in s['Rule']), 25) + self.assertIn('DOMAIN,claude.ai,Claude', s['Rule']) + self.assertNotIn('DOMAIN-SUFFIX,claude.ai,Claude', s['Rule']) + self.assertIn('DOMAIN-SUFFIX,claude.ai,"🦀 Claude"', s['Rule']) + self.assertIn('DOMAIN,anthropic.auth0.com,"🦀 Claude"', s['Rule']) + self.assertIn('DOMAIN-SUFFIX,openai.com,AI', s['Rule']) + self.assertTrue(s['Rule'][0].startswith('PROCESS-NAME,')) + self.assertEqual([x for x in s['Rule'] if x.startswith('FINAL,')], ['FINAL,DIRECT']) + self.assertIn('*.example.ts.net', '\n'.join(s['General'])) + self.assertEqual(sum('server:100.70.255.254' in x for x in s['Host']), 25) + + def test_off_removes_tunnel_dns_and_selection(self): + s = sections(build(BASE, DOMAINS, False)) + self.assertNotIn('100.70.255.254', '\n'.join(s['Host'])) + self.assertIn('Claude = fallback, "🦀 Claude", interval=5', s['Proxy Group']) + self.assertEqual(s['General'][0], 'always-real-ip = *.example.ts.net') + self.assertIn('*.claude.ai = server:https://1.0.0.1/dns-query',s['Host']) + + def test_production_renamed_group(self): + renamed = BASE.replace('Claude = select', '🦀 Claude = select').replace(',Claude', ',"🦀 Claude"') + result = build(renamed, DOMAINS, True) + self.assertEqual(result.count('🦀 Claude = select'), 1) + self.assertIn('DOMAIN,anthropic.auth0.com,"🦀 Claude"', result) + + def test_drift_rejected(self): + with self.assertRaises(ValueError): build(BASE, DOMAINS[:-1], True) + with self.assertRaises(ValueError): build(BASE.replace('Claude = select, Node','Other = select, Node'), DOMAINS, True) + with self.assertRaises(ValueError): build(BASE, DOMAINS[:-1]+[DOMAINS[0]], True) + +if __name__ == '__main__': unittest.main() diff --git a/scripts/ffsb/test_reconcile.py b/scripts/ffsb/test_reconcile.py new file mode 100644 index 0000000..25d9fc0 --- /dev/null +++ b/scripts/ffsb/test_reconcile.py @@ -0,0 +1,60 @@ +import contextlib +import hashlib +import io +import json +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import reconcile + + +class RefreshLoggingTests(unittest.TestCase): + def check_refresh(self, manual, download_ok=True, valid=True): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'domains.json').write_text('[]') + (root / 'Proxy Station.conf').write_text('#!MANAGED-CONFIG https://example.invalid/private') + (root / 'managed-source.conf').write_text('source') + (root / 'Proxy Station Claude.conf').write_text('candidate') + (root / 'state.json').write_text(json.dumps({'profile_sha256': hashlib.sha256(b'candidate').hexdigest()})) + calls = [] + + def run(args, **kwargs): + calls.append(args) + if args[0] == '/usr/bin/curl': + (root / 'download.conf').write_text('source') + return SimpleNamespace(returncode=0 if download_ok else 1) + return SimpleNamespace(returncode=0 if valid else 1, stdout='', stderr='') + + stdout, stderr = io.StringIO(), io.StringIO() + with patch.object(reconcile, 'ROOT', root), patch.object(reconcile, 'PROFILES', root), patch.object(reconcile, 'build', return_value='candidate'), patch.object(reconcile, 'run', side_effect=run), patch.object(reconcile.subprocess, 'run', side_effect=run), patch.object(reconcile.sys, 'argv', ['reconcile.py'] + (['--refresh'] if manual else [])), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + reconcile.main() + self.assertFalse(any('reload' in call for call in calls)) + self.assertNotIn('example.invalid', stdout.getvalue() + stderr.getvalue()) + return stdout.getvalue(), stderr.getvalue() + + def test_manual_unchanged_success(self): + out, err = self.check_refresh(True) + self.assertIn('Refresh succeeded; configuration unchanged', out) + self.assertEqual(err, '') + + def test_background_unchanged_quiet(self): + self.assertEqual(self.check_refresh(False), ('', '')) + + def test_download_failure_does_not_claim_success(self): + out, err = self.check_refresh(True, download_ok=False) + self.assertIn('Refresh failed', out) + self.assertIn('using last validated cache', err) + self.assertNotIn('succeeded', out) + + def test_validation_failure_does_not_claim_success(self): + out, err = self.check_refresh(True, valid=False) + self.assertIn('Refresh failed', out) + self.assertIn('using last validated cache', err) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/wireguard/agent.py b/scripts/wireguard/agent.py new file mode 100644 index 0000000..637f074 --- /dev/null +++ b/scripts/wireguard/agent.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""njcq-only agent: outbound HTTPS, no shell, no interface restart or firewall changes.""" +import argparse +import base64 +import fcntl +import ipaddress +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time +import urllib.request +import uuid + +INTERFACE = 'wg-njcq' +CONFIG = Path('/etc/wireguard/wg-njcq.conf') +NETWORK = ipaddress.ip_network('10.203.0.0/24') + + +def command(*args): + return subprocess.check_output(args, text=True, stderr=subprocess.PIPE, timeout=20).strip() + + +def valid_key(value): + if not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9+/]{43}=', value): + raise ValueError('Invalid key') + if len(base64.b64decode(value, validate=True)) != 32: + raise ValueError('Invalid key') + return value + + +def sections(text): + # Keep all interface directives, comments and non-managed peer blocks intact. + blocks = re.split(r'(?m)(?=^\s*\[(?:Interface|Peer)\]\s*$)', text) + if not any(b.lstrip().startswith('[Interface]') for b in blocks): + raise ValueError('Missing interface configuration') + if re.search(r'(?im)^\s*SaveConfig\s*=\s*true\s*$', text): + raise ValueError('SaveConfig would overwrite managed configuration') + return blocks + + +def fields(block): + result = {} + for line in block.splitlines(): + line = line.split('#', 1)[0].strip() + if '=' in line: + key, value = line.split('=', 1) + result[key.strip().lower()] = value.strip() + return result + + +def peers(text): + result = {} + for block in sections(text): + if not block.lstrip().startswith('[Peer]'): + continue + values = fields(block) + key = values.get('publickey') + if not key or key in result: + raise ValueError('Duplicate or missing peer key') + result[key] = values + return result + + +def validate_task(task): + valid_key(task['publicKey']) + if task.get('oldPublicKey'): + valid_key(task['oldPublicKey']) + if task.get('presharedKey'): + valid_key(task['presharedKey']) + address = ipaddress.ip_address(task['address']) + if address not in NETWORK or address in (NETWORK.network_address, NETWORK.broadcast_address, NETWORK.network_address + 1): + raise ValueError('Address outside device pool') + if task['desired'] not in ('active', 'paused', 'archived') or task['action'] not in ('adopt', 'create', 'pause', 'resume', 'rotate', 'remove'): + raise ValueError('Unsupported task') + + +def matches(values, task): + return values is not None and values.get('allowedips') == task['address'] + '/32' and \ + values.get('presharedkey', None) == task.get('presharedKey', None) + + +def merge_config(text, task): + validate_task(task) + owned = {task['publicKey'], task.get('oldPublicKey')} + blocks = sections(text) + address = ipaddress.ip_address(task['address']) + for key, peer in peers(text).items(): + if key in owned and peer.get('allowedips') != task['address'] + '/32': + raise ValueError('Existing managed key has conflicting routes') + if key not in owned and any(address in ipaddress.ip_network(cidr.strip(), strict=False) + for cidr in peer.get('allowedips', '').split(',') if cidr.strip()): + raise ValueError('Address conflicts with an unmanaged peer') + kept = [b for b in blocks if not (b.lstrip().startswith('[Peer]') and fields(b).get('publickey') in owned)] + if task['desired'] == 'active': + kept.append('[Peer]\nPublicKey = ' + task['publicKey'] + '\n' + + ('PresharedKey = ' + task['presharedKey'] + '\n' if task.get('presharedKey') else '') + + 'AllowedIPs = ' + task['address'] + '/32\n') + return ''.join(kept) + + +def atomic_write(file, content): + fd, name = tempfile.mkstemp(prefix='.ps-wg-', dir=file.parent) + try: + with os.fdopen(fd, 'w') as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(name, 0o600) + os.replace(name, file) + directory = os.open(file.parent, os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(name): + os.unlink(name) + + +def runtime_peers(): + # Never send/log showconf: it contains the gateway private key. + return peers('[Interface]\n' + '\n'.join(command('wg', 'showconf', INTERFACE).splitlines()[1:])) + + +def apply_task(task): + validate_task(task) + original = CONFIG.read_text() + runtime = runtime_peers() + persisted = peers(original) + key = task['publicKey'] + old = task.get('oldPublicKey') + if task['action'] == 'adopt': + # Read-only adoption does not alter already running devices. + return matches(runtime.get(key), task), matches(persisted.get(key), task) + merged = merge_config(original, task) + # Fail closed on runtime-only address conflicts as well. + merge_config(command('wg', 'showconf', INTERFACE), task) + # Keep a single pre-task backup, including across retries. + job_id = task['id'] + if not re.fullmatch(r'[\w-]{1,128}', job_id): + raise ValueError('Invalid task id') + backup = CONFIG.parent / 'proxy-station-backups' + backup.mkdir(mode=0o700, exist_ok=True) + backup.chmod(0o700) + snapshot = backup / (job_id + '.conf') + if not snapshot.exists(): + with snapshot.open('x') as stream: + stream.write(original) + snapshot.chmod(0o600) + # Persist removal before touching runtime; rotate revokes old key before installing new. + atomic_write(CONFIG, merged) + for remove in [old, key if task['desired'] != 'active' else None]: + if remove: + command('wg', 'set', INTERFACE, 'peer', remove, 'remove') + if task['desired'] == 'active': + with tempfile.NamedTemporaryFile(mode='w', dir=CONFIG.parent) as secret: + secret.write(task.get('presharedKey') or '') + secret.flush() + command('wg', 'set', INTERFACE, 'peer', key, 'preshared-key', secret.name, + 'allowed-ips', task['address'] + '/32') + runtime, persisted = runtime_peers(), peers(CONFIG.read_text()) + if task['desired'] == 'active': + return (matches(runtime.get(key), task) and (not old or old not in runtime), + matches(persisted.get(key), task) and (not old or old not in persisted)) + return key not in runtime and (not old or old not in runtime), key not in persisted and (not old or old not in persisted) + + +def sample(): + # dump's first row and peer PSKs are consumed only in memory, never returned. + rows = command('wg', 'show', INTERFACE, 'dump').splitlines()[1:] + result = [] + for row in rows: + key, _psk, _endpoint, _allowed, handshake, rx, tx, _keepalive = row.split('\t') + result.append({'publicKey': key, 'handshakeAge': max(0, int(time.time()) - int(handshake)) if int(handshake) else None, + 'rx': int(rx), 'tx': int(tx)}) + return result + + +def request(config, path, payload=None): + url = config['url'].rstrip('/') + '/wg-agent/' + config['gatewayId'] + path + data = None if payload is None else json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, headers={ + 'Authorization': 'Bearer ' + config['agentToken'], 'Content-Type': 'application/json', + }) + # Do not forward credentials to redirects or inherit an unrelated proxy. + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + with opener.open(req, timeout=25) as response: + return json.load(response) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--config', default='/etc/proxy-station-wg-agent.json') + args = parser.parse_args() + os.umask(0o077) + if os.geteuid() != 0: + raise SystemExit('Agent requires root for the fixed wg-njcq interface') + config_file = Path(args.config) + if config_file.stat().st_mode & 0o077 or config_file.stat().st_uid != 0: + raise SystemExit('Agent config must be root-owned mode 600') + config = json.loads(config_file.read_text()) + if not re.fullmatch(r'https://[A-Za-z0-9.:-]+', config['url']) or not re.fullmatch(r'[\w-]+', config['gatewayId']): + raise SystemExit('Agent requires an HTTPS origin and valid gateway ID') + if command('wg', 'show', INTERFACE, 'public-key') != valid_key(config['publicKey']): + raise SystemExit('Gateway public key mismatch') + # Recheck the key every cycle; pin to the configured hub, never adopt FFSB. + boot_id = str(uuid.uuid4()) # counter epoch changes on every agent restart + with open('/run/proxy-station-wg-agent.lock', 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + while True: + try: + if command('wg', 'show', INTERFACE, 'public-key') != config['publicKey']: + raise ValueError('Gateway key changed') + receipts = [] + for task in request(config, '/tasks')['tasks']: + try: + runtime, persistent = apply_task(task) + receipts.append({'id': task['id'], 'version': task['version'], 'runtime': runtime, 'persistent': persistent}) + except Exception: + receipts.append({'id': task['id'], 'version': task['version'], 'runtime': False, 'persistent': False, + 'error': 'Gateway apply failed'}) + request(config, '/report', {'bootId': boot_id, 'receipts': receipts, 'peers': sample()}) + except Exception: + print('WireGuard agent cycle failed; retrying. No configuration or credentials logged.', flush=True) + time.sleep(15) + + +if __name__ == '__main__': + main() diff --git a/scripts/wireguard/proxy-station-wg-agent.service b/scripts/wireguard/proxy-station-wg-agent.service new file mode 100644 index 0000000..e6fade9 --- /dev/null +++ b/scripts/wireguard/proxy-station-wg-agent.service @@ -0,0 +1,19 @@ +[Unit] +Description=Proxy Station njcq WireGuard agent +After=network-online.target wg-quick@wg-njcq.service +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /opt/proxy-station-wg/agent.py +Restart=on-failure +RestartSec=15 +UMask=0077 +NoNewPrivileges=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/etc/wireguard /run +CapabilityBoundingSet=CAP_NET_ADMIN CAP_DAC_READ_SEARCH + +[Install] +WantedBy=multi-user.target diff --git a/scripts/wireguard/test_agent.py b/scripts/wireguard/test_agent.py new file mode 100644 index 0000000..5bf86d7 --- /dev/null +++ b/scripts/wireguard/test_agent.py @@ -0,0 +1,85 @@ +"""Offline tests; never opens a WG interface or contacts a gateway.""" +import base64 +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('wg_agent', Path(__file__).with_name('agent.py')) +agent = importlib.util.module_from_spec(spec) +spec.loader.exec_module(agent) +key = lambda n: base64.b64encode(bytes([n]) * 32).decode() + + +class AgentTests(unittest.TestCase): + def setUp(self): + self.task = dict(id='task-1', publicKey=key(2), oldPublicKey=None, presharedKey=key(3), + address='10.203.0.2', desired='active', action='create', version=1) + self.base = '[Interface]\nPrivateKey = gateway-secret\nAddress = 10.203.0.1/24\nPostUp = preserve firewall\n\n[Peer]\nPublicKey = ' + key(8) + '\nAllowedIPs = 10.203.0.8/32\n' + + def test_merge_preserves_unmanaged_peers_and_interface(self): + merged = agent.merge_config(self.base, self.task) + self.assertTrue(merged.startswith(self.base)) + self.assertEqual(agent.merge_config(merged, self.task), merged) + task = {**self.task, 'desired': 'paused', 'action': 'pause'} + removed = agent.merge_config(merged, task) + self.assertNotIn(key(2), removed) + self.assertIn(key(8), removed) + self.assertIn('PostUp = preserve firewall', removed) + + def test_conflicts_and_outside_network_are_rejected(self): + for address in ['8.8.8.8', '10.203.0.1', '10.203.0.255']: + with self.assertRaises(ValueError): + agent.merge_config(self.base, {**self.task, 'address': address}) + with self.assertRaises(ValueError): + agent.merge_config(self.base, {**self.task, 'address': '10.203.0.8'}) + with self.assertRaises(ValueError): + agent.merge_config(self.base.replace('PostUp = preserve firewall', 'SaveConfig = true'), self.task) + + def test_rotate_removes_old_identity(self): + old = agent.merge_config(self.base, self.task) + rotated = agent.merge_config(old, {**self.task, 'publicKey': key(4), 'oldPublicKey': key(2), 'action': 'rotate'}) + self.assertNotIn(key(2), rotated) + self.assertIn(key(4), rotated) + self.assertIn(key(8), rotated) + + def test_adoption_is_read_only_and_requires_matching_psk(self): + config = agent.merge_config(self.base, self.task) + with tempfile.TemporaryDirectory() as directory: + file = Path(directory) / 'wg-njcq.conf' + file.write_text(config) + with patch.object(agent, 'CONFIG', file), patch.object(agent, 'runtime_peers', return_value=agent.peers(config)), patch.object(agent, 'command') as cmd: + self.assertEqual(agent.apply_task({**self.task, 'action': 'adopt'}), (True, True)) + self.assertEqual(agent.apply_task({**self.task, 'action': 'adopt', 'presharedKey': key(9)}), (False, False)) + cmd.assert_not_called() + self.assertEqual(file.read_text(), config) + + def test_pause_persists_and_removes_runtime_peer_before_confirming(self): + config = agent.merge_config(self.base, self.task) + runtime = agent.peers(config) + calls = [] + def command(*args): + calls.append(args) + if args[:2] == ('wg', 'showconf'): + return config + if args[-1] == 'remove': + runtime.pop(args[-2], None) + return '' + raise AssertionError(args) + with tempfile.TemporaryDirectory() as directory: + file = Path(directory) / 'wg-njcq.conf' + file.write_text(config) + with patch.object(agent, 'CONFIG', file), patch.object(agent, 'runtime_peers', side_effect=lambda: runtime), patch.object(agent, 'command', side_effect=command): + task = {**self.task, 'desired': 'paused', 'action': 'pause'} + self.assertEqual(agent.apply_task(task), (True, True)) + self.assertEqual(agent.apply_task(task), (True, True)) + self.assertNotIn(key(2), file.read_text()) + self.assertIn(key(8), file.read_text()) + self.assertNotIn(key(2), runtime) + self.assertEqual(len(list((file.parent / 'proxy-station-backups').glob('*.conf'))), 1) + self.assertEqual(file.stat().st_mode & 0o777, 0o600) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/assets/ffsb-domains.json b/server/assets/ffsb-domains.json new file mode 100644 index 0000000..5cbc99b --- /dev/null +++ b/server/assets/ffsb-domains.json @@ -0,0 +1,27 @@ +[ + "*.anthropic.com", + "*.clau.de", + "*.claude.com", + "*.claudemcpclient.com", + "*.claudemcpcontent.com", + "*.claudeusercontent.com", + "*.ffsb.sg", + "a.claude.ai", + "anthropic.com", + "api.anthropic.com", + "api.ipify.org", + "assets.claude.ai", + "clau.de", + "claude.ai", + "claude.com", + "claudemcpclient.com", + "claudemcpcontent.com", + "claudeusercontent.com", + "ffsb.sg", + "icanhazip.com", + "ifconfig.me", + "ip.net.coffee", + "join.fufeishangban.com", + "pivot.claude.ai", + "www.claude.ai" +] \ No newline at end of file diff --git a/server/src/db/index.ts b/server/src/db/index.ts index 459b088..db0dcfd 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import fs from 'node:fs'; import path from 'node:path'; import { env } from '../env.js'; +import { migrateWireguard, validateWireguardSecrets } from '../services/wg-migrate.js'; import * as schema from './schema.js'; const BOOTSTRAP_SQL = ` @@ -86,11 +87,41 @@ CREATE TABLE IF NOT EXISTS sub_tokens ( token TEXT NOT NULL UNIQUE, name TEXT NOT NULL, node_ids TEXT NOT NULL DEFAULT '[]', + wireguard TEXT, + wg_device_id TEXT, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, last_access_at INTEGER, access_count INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS wg_networks (id TEXT PRIMARY KEY, subnet TEXT NOT NULL, mtu INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS wg_gateways ( + id TEXT PRIMARY KEY, network_id TEXT NOT NULL REFERENCES wg_networks(id), server TEXT NOT NULL, + port INTEGER NOT NULL, public_key TEXT NOT NULL UNIQUE, agent_hash TEXT, received_at INTEGER, boot_id TEXT +); +CREATE TABLE IF NOT EXISTS wg_devices ( + id TEXT PRIMARY KEY, gateway_id TEXT NOT NULL REFERENCES wg_gateways(id), name TEXT NOT NULL, os TEXT NOT NULL, + address TEXT NOT NULL UNIQUE, public_key TEXT NOT NULL UNIQUE, secret TEXT NOT NULL, desired TEXT NOT NULL, + version INTEGER NOT NULL, applied_version INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, archived_at INTEGER +); +CREATE TABLE IF NOT EXISTS wg_jobs ( + id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES wg_devices(id), action TEXT NOT NULL, + version INTEGER NOT NULL, state TEXT NOT NULL, old_public_key TEXT, error TEXT, created_at INTEGER NOT NULL, confirmed_at INTEGER, + UNIQUE(device_id,version) +); +CREATE TABLE IF NOT EXISTS wg_observations ( + device_id TEXT PRIMARY KEY REFERENCES wg_devices(id), received_at INTEGER NOT NULL, boot_id TEXT NOT NULL, + handshake_at INTEGER, activity_at INTEGER, rx INTEGER NOT NULL, tx INTEGER NOT NULL, upload_rate INTEGER, download_rate INTEGER, + upload_total INTEGER NOT NULL DEFAULT 0, download_total INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS wg_events ( + id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES wg_devices(id), message TEXT NOT NULL, created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS wg_subscription_bindings ( + id TEXT PRIMARY KEY, token_id TEXT NOT NULL REFERENCES sub_tokens(id) ON DELETE CASCADE, + device_id TEXT NOT NULL REFERENCES wg_devices(id), credential_hash TEXT NOT NULL UNIQUE, purpose TEXT NOT NULL, + created_at INTEGER NOT NULL, last_access_at INTEGER, revoked_at INTEGER +); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -100,9 +131,11 @@ CREATE TABLE IF NOT EXISTS settings ( export function createDb(dbPath?: string) { const file = dbPath ?? path.join(env.dataDir, 'proxy-station.db'); if (file !== ':memory:') { - fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); } const sqlite = new Database(file); + if (file !== ':memory:') fs.chmodSync(file, 0o600); + sqlite.pragma('secure_delete = ON'); sqlite.pragma('journal_mode = WAL'); sqlite.pragma('foreign_keys = ON'); sqlite.exec(BOOTSTRAP_SQL); @@ -110,6 +143,8 @@ export function createDb(dbPath?: string) { for (const stmt of [ 'ALTER TABLE policy_groups ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1', "ALTER TABLE sub_tokens ADD COLUMN node_ids TEXT NOT NULL DEFAULT '[]'", + 'ALTER TABLE sub_tokens ADD COLUMN wireguard TEXT', + 'ALTER TABLE sub_tokens ADD COLUMN wg_device_id TEXT', ]) { try { sqlite.exec(stmt); @@ -117,7 +152,15 @@ export function createDb(dbPath?: string) { // 列已存在 } } - return drizzle(sqlite, { schema }); + const database = drizzle(sqlite, { schema }); + try { + validateWireguardSecrets(database); + migrateWireguard(database); + } catch (error) { + sqlite.close(); + throw error; + } + return database; } export type Db = ReturnType; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 40a4588..aa4c6f6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -1,4 +1,4 @@ -import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { integer, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core'; export const nodes = sqliteTable('nodes', { id: text('id').primaryKey(), @@ -89,6 +89,8 @@ export const subTokens = sqliteTable('sub_tokens', { name: text('name').notNull(), /** 分配给该订阅的节点 id(JSON 数组);空数组 = 不限制,下发全部启用节点 */ nodeIds: text('node_ids').notNull().default('[]'), + wireguard: text('wireguard'), + wgDeviceId: text('wg_device_id'), enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), createdAt: integer('created_at').notNull(), lastAccessAt: integer('last_access_at'), @@ -99,3 +101,46 @@ export const settings = sqliteTable('settings', { key: text('key').primaryKey(), value: text('value').notNull(), }); + +// WireGuard identities are separate from subscriptions; archived addresses remain reserved. +export const wgNetworks = sqliteTable('wg_networks', { + id: text('id').primaryKey(), subnet: text('subnet').notNull(), mtu: integer('mtu').notNull(), +}); +export const wgGateways = sqliteTable('wg_gateways', { + id: text('id').primaryKey(), networkId: text('network_id').notNull().references(() => wgNetworks.id), + server: text('server').notNull(), port: integer('port').notNull(), publicKey: text('public_key').notNull().unique(), + agentHash: text('agent_hash'), receivedAt: integer('received_at'), bootId: text('boot_id'), +}); +export const wgDevices = sqliteTable('wg_devices', { + id: text('id').primaryKey(), gatewayId: text('gateway_id').notNull().references(() => wgGateways.id), + name: text('name').notNull(), os: text('os').notNull(), address: text('address').notNull().unique(), + publicKey: text('public_key').notNull().unique(), secret: text('secret').notNull(), + desired: text('desired', { enum: ['active', 'paused', 'archived'] }).notNull(), + version: integer('version').notNull(), appliedVersion: integer('applied_version').notNull().default(0), + createdAt: integer('created_at').notNull(), archivedAt: integer('archived_at'), +}); +export const wgJobs = sqliteTable('wg_jobs', { + id: text('id').primaryKey(), deviceId: text('device_id').notNull().references(() => wgDevices.id), + action: text('action').notNull(), version: integer('version').notNull(), + state: text('state', { enum: ['pending', 'failed', 'confirmed'] }).notNull(), + oldPublicKey: text('old_public_key'), error: text('error'), createdAt: integer('created_at').notNull(), + confirmedAt: integer('confirmed_at'), +}, t => ({ deviceVersion: unique().on(t.deviceId, t.version) })); +export const wgObservations = sqliteTable('wg_observations', { + deviceId: text('device_id').primaryKey().references(() => wgDevices.id), + receivedAt: integer('received_at').notNull(), bootId: text('boot_id').notNull(), + handshakeAt: integer('handshake_at'), activityAt: integer('activity_at'), + rx: integer('rx').notNull(), tx: integer('tx').notNull(), + uploadRate: integer('upload_rate'), downloadRate: integer('download_rate'), + uploadTotal: integer('upload_total').notNull().default(0), downloadTotal: integer('download_total').notNull().default(0), +}); +export const wgEvents = sqliteTable('wg_events', { + id: text('id').primaryKey(), deviceId: text('device_id').notNull().references(() => wgDevices.id), + message: text('message').notNull(), createdAt: integer('created_at').notNull(), +}); +export const wgSubscriptionBindings = sqliteTable('wg_subscription_bindings', { + id: text('id').primaryKey(), tokenId: text('token_id').notNull().references(() => subTokens.id, { onDelete: 'cascade' }), + deviceId: text('device_id').notNull().references(() => wgDevices.id), + credentialHash: text('credential_hash').notNull().unique(), purpose: text('purpose').notNull(), + createdAt: integer('created_at').notNull(), lastAccessAt: integer('last_access_at'), revokedAt: integer('revoked_at'), +}); diff --git a/server/src/index.ts b/server/src/index.ts index 003f59d..08df628 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,11 +13,13 @@ import { groupsRoute } from './routes/groups.js'; import { rulesRoute, rulesetsRoute } from './routes/rules.js'; import { tokensRoute } from './routes/tokens.js'; import { subRoute } from './routes/sub.js'; +import { wgRoute, wgAgentRoute, wgBindingRoute } from './routes/wg.js'; import { settingsRoute } from './routes/settings.js'; seedIfEmpty(db); const app = new Hono(); +app.use('*', async (c, next) => { c.header('referrer-policy', 'no-referrer'); await next(); }); app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' })); @@ -29,6 +31,10 @@ app.route('/api/groups', groupsRoute); app.route('/api/rules', rulesRoute); app.route('/api/rulesets', rulesetsRoute); app.route('/api/tokens', tokensRoute); +app.route('/api/tokens', wgBindingRoute); +app.route('/api/wg', wgRoute); +app.route('/wg-agent', wgAgentRoute); +app.all('/wg-agent/*', c => c.json({ error: 'not found' }, 404)); app.route('/api/settings', settingsRoute); // 公开订阅端点:token 即凭证 diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 7ee90d8..42be92a 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -2,11 +2,16 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { getSetting, setSetting } from '../services/ir.js'; +import { ffsbSettingsSchema } from '../services/generators/surge-ffsb.js'; import { db } from '../db/index.js'; -const SURGE_SECTION_KEYS = ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']; +const SURGE_SECTION_KEYS = ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script', 'FFSB']; function unsafeSettings(sections: Record): string | null { + if (sections.FFSB) { + try { ffsbSettingsSchema.parse(JSON.parse(sections.FFSB)); } + catch { return 'FFSB 设置无效'; } + } for (const line of (sections.General ?? '').split(/\r?\n/)) { const match = line.match(/^\s*(http-api|external-controller-access)\s*=\s*([^@]+)@/i); if (match && ['surge', 'clashconnectrules'].includes(match[2].trim().replace(/^["']|["']$/g, '').toLowerCase())) return '控制接口不能使用公开模板口令;请关闭接口或使用独立凭据与访问限制'; diff --git a/server/src/routes/sub-ruleset.test.ts b/server/src/routes/sub-ruleset.test.ts new file mode 100644 index 0000000..5907451 --- /dev/null +++ b/server/src/routes/sub-ruleset.test.ts @@ -0,0 +1,64 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import YAML from 'yaml'; + +const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-sub-ruleset-')); +process.env.DATA_DIR = directory; +vi.mock('../services/rulesets/fetch.js', () => ({ fetchRulesetContent: vi.fn() })); +const { db } = await import('../db/index.js'); +const { subTokens, rulesets, rules } = await import('../db/schema.js'); +const { fetchRulesetContent } = await import('../services/rulesets/fetch.js'); +const { subRoute } = await import('./sub.js'); +const fetchContent = vi.mocked(fetchRulesetContent); + +beforeEach(() => { + for (const table of [rules, rulesets, subTokens]) db.delete(table).run(); + db.insert(subTokens).values({ id: 'token-id', token: 'placeholder-token', name: 'test', createdAt: 0 }).run(); + db.insert(rulesets).values({ id: 'domains', name: 'domains', surgeUrl: 'https://example.com/domains.txt', + clashBehavior: 'domain', clashFormat: 'text', needsConvert: true }).run(); + fetchContent.mockReset(); + fetchContent.mockResolvedValue({ content: 'exact.example.com\n.suffix.example.com', fetchedAt: 0, stale: false }); +}); +afterAll(() => { db.$client.close(); rmSync(directory, { recursive: true, force: true }); }); + +describe('authenticated domain-set conversion subscriptions', () => { + it('serves an ordinary rule list and carries stale-cache diagnostics', async () => { + fetchContent.mockResolvedValueOnce({ content: 'exact.example.com\n.suffix.example.com', fetchedAt: 0, stale: true }); + const response = await subRoute.request('/placeholder-token/ruleset/domains.list'); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/plain'); + expect(response.headers.get('x-proxy-station-converted')).toBe('2/2'); + expect(response.headers.get('x-proxy-station-stale')).toBe('1'); + expect(await response.text()).toBe('DOMAIN,exact.example.com\nDOMAIN-SUFFIX,suffix.example.com\n'); + }); + + it('keeps the existing Clash YAML conversion endpoint unchanged', async () => { + const response = await subRoute.request('/placeholder-token/ruleset/domains.yaml'); + expect(response.status).toBe(200); + expect(YAML.parse(await response.text())).toEqual({ payload: ['exact.example.com', '+.suffix.example.com'] }); + }); + + it('rejects unknown tokens, resources and non-domain list conversions before fetching', async () => { + db.insert(rulesets).values({ id: 'ip', name: 'ip', surgeUrl: 'https://example.com/ip.list', clashBehavior: 'ipcidr' }).run(); + for (const url of ['/invalid/ruleset/domains.list', '/placeholder-token/ruleset/missing.list', + '/placeholder-token/ruleset/ip.list']) expect((await subRoute.request(url)).status).toBe(404); + expect(fetchContent).not.toHaveBeenCalled(); + }); + + it('fails instead of returning partial or injected rules for an invalid upstream format', async () => { + fetchContent.mockResolvedValueOnce({ content: 'valid.example.com\nexample.com,REJECT', fetchedAt: 0, stale: false }); + const response = await subRoute.request('/placeholder-token/ruleset/domains.list'); + expect(response.status).toBe(502); + expect(await response.text()).not.toContain('valid.example.com'); + }); + + it('uses the actual forwarded HTTPS origin for Shadowrocket conversion URLs', async () => { + db.insert(rules).values({ id: 'rule', seq: 0, type: 'DOMAIN-SET', value: 'domains', policy: 'REJECT', params: '[]' }).run(); + const response = await subRoute.request('/placeholder-token/shadowrocket-conf', { headers: { + 'x-forwarded-proto': 'https', 'x-forwarded-host': 'example.com', + } }); + expect(await response.text()).toContain('RULE-SET,https://example.com/sub/placeholder-token/ruleset/domains.list,REJECT'); + }); +}); diff --git a/server/src/routes/sub.ts b/server/src/routes/sub.ts index 8f2d11a..ddfc36e 100644 --- a/server/src/routes/sub.ts +++ b/server/src/routes/sub.ts @@ -5,11 +5,13 @@ import { db } from '../db/index.js'; import { rulesets, subTokens } from '../db/schema.js'; import { buildProfileIR } from '../services/ir.js'; import { fetchRulesetContent } from '../services/rulesets/fetch.js'; -import { convertSurgeList } from '../services/rulesets/convert.js'; +import { convertSurgeList, domainSetToRuleList } from '../services/rulesets/convert.js'; import { generateSurge } from '../services/generators/surge.js'; import { generateShadowrocket } from '../services/generators/shadowrocket.js'; import { generateShadowrocketConf } from '../services/generators/shadowrocket-conf.js'; import { generateClash } from '../services/generators/clash.js'; +import { authorizedProfile, bindingProfile, migrateLegacy, WgError } from '../services/wg.js'; +import { generateWireguard } from '../services/generators/wireguard.js'; function constantTimeEqual(a: string, b: string): boolean { const ha = crypto.createHash('sha256').update(a).digest(); @@ -18,15 +20,21 @@ function constantTimeEqual(a: string, b: string): boolean { } export function findToken(token: string) { + migrateLegacy(); const rows = db.select().from(subTokens).where(eq(subTokens.enabled, true)).all(); return rows.find((r) => constantTimeEqual(r.token, token)) ?? null; } /** 该订阅可见的节点范围 */ -function irFor(row: { nodeIds: string }) { +function proxyIrFor(row: { nodeIds: string }) { return buildProfileIR({ nodeIds: JSON.parse(row.nodeIds) }); } +function irFor(row: { nodeIds: string; wgDeviceId: string | null }) { + return { ...proxyIrFor(row), + ...(row.wgDeviceId ? { wireguard: authorizedProfile(row.wgDeviceId, true) } : {}) }; +} + function touchToken(id: string, count: number) { db.update(subTokens) .set({ lastAccessAt: Date.now(), accessCount: count + 1 }) @@ -49,13 +57,30 @@ function baseUrl(c: { req: { url: string; header: (name: string) => string | und } export const subRoute = new Hono() + .onError((e, c) => e instanceof WgError ? c.text('not found', 404) : c.text('subscription unavailable', 503)) + .use('*', async (c, next) => { c.header('referrer-policy', 'no-referrer'); c.header('cache-control', 'private, no-store'); await next(); }) + .get('/:token/wireguard.conf', (c) => { + const row = findToken(c.req.param('token')); + if (!row?.wgDeviceId) return c.text('not found', 404); + touchToken(row.id, row.accessCount); + c.header('cache-control', 'private, no-store'); + c.header('content-type', 'text/plain; charset=utf-8'); + c.header('content-disposition', 'attachment; filename="wireguard.conf"'); + return c.body(generateWireguard(authorizedProfile(row.wgDeviceId!, true))); + }) + // Retired endpoint: explicitly return 404 rather than falling through to the SPA. + .get('/:token/wireguard-shadowrocket', (c) => c.text('not found', 404)) .get('/:token/surge', (c) => { const row = findToken(c.req.param('token')); if (!row) return c.text('not found', 404); touchToken(row.id, row.accessCount); - const ir = irFor(row); - const selfUrl = `${baseUrl(c)}/sub/${row.token}/surge`; + const params = new URL(c.req.url).searchParams; + if ([...params.keys()].some(k => k !== 'wg') || params.getAll('wg').length > 1) return c.text('invalid parameters', 400); + const wg = params.get('wg'); + const ir = wg !== null ? { ...buildProfileIR({ nodeIds: JSON.parse(row.nodeIds) }), wireguard: bindingProfile(row.id, wg) } : irFor(row); + const selfUrl = `${baseUrl(c)}/sub/${row.token}/surge${wg !== null ? `?wg=${encodeURIComponent(wg)}` : ''}`; const { content } = generateSurge(ir, { selfUrl }); + if (row.wgDeviceId || wg) c.header('cache-control', 'private, no-store'); c.header('content-type', 'text/plain; charset=utf-8'); c.header('content-disposition', 'attachment; filename="Proxy Station.conf"; filename*=UTF-8\'\'Proxy%20Station.conf'); return c.body(content); @@ -64,7 +89,7 @@ export const subRoute = new Hono() const row = findToken(c.req.param('token')); if (!row) return c.text('not found', 404); touchToken(row.id, row.accessCount); - const { content } = generateShadowrocket(irFor(row)); + const { content } = generateShadowrocket(proxyIrFor(row)); c.header('content-type', 'text/plain; charset=utf-8'); return c.body(content); }) @@ -73,7 +98,8 @@ export const subRoute = new Hono() const row = findToken(c.req.param('token')); if (!row) return c.text('not found', 404); touchToken(row.id, row.accessCount); - const { content } = generateShadowrocketConf(irFor(row)); + const convertBaseUrl = `${baseUrl(c)}/sub/${row.token}`; + const { content } = generateShadowrocketConf(proxyIrFor(row), { convertBaseUrl }); c.header('content-type', 'text/plain; charset=utf-8'); c.header('content-disposition', 'attachment; filename="Proxy Station Rules.conf"; filename*=UTF-8\'\'Proxy%20Station%20Rules.conf'); return c.body(content); @@ -82,7 +108,10 @@ export const subRoute = new Hono() const row = findToken(c.req.param('token')); if (!row) return c.text('not found', 404); touchToken(row.id, row.accessCount); - const ir = irFor(row); + const params = new URL(c.req.url).searchParams; + if ([...params.keys()].some(k => k !== 'wg') || params.getAll('wg').length > 1) return c.text('invalid parameters', 400); + const wg = params.get('wg'); + const ir = wg !== null ? { ...proxyIrFor(row), wireguard: bindingProfile(row.id, wg, 'clash') } : proxyIrFor(row); const convertBaseUrl = `${baseUrl(c)}/sub/${row.token}`; const { content } = generateClash(ir, { convertBaseUrl }); c.header('content-type', 'text/yaml; charset=utf-8'); @@ -94,11 +123,21 @@ export const subRoute = new Hono() .get('/:token/ruleset/:file', async (c) => { const row = findToken(c.req.param('token')); if (!row) return c.text('not found', 404); - const id = c.req.param('file').replace(/\.yaml$/, ''); + const file = c.req.param('file'); + const isRuleList = file.endsWith('.list'); + const id = file.replace(/\.(yaml|list)$/, ''); const rs = db.select().from(rulesets).where(eq(rulesets.id, id)).get(); if (!rs) return c.text('not found', 404); + if (isRuleList && rs.clashBehavior !== 'domain') return c.text('not found', 404); try { const { content, stale } = await fetchRulesetContent(rs.surgeUrl); + if (isRuleList) { + const result = domainSetToRuleList(content); + c.header('content-type', 'text/plain; charset=utf-8'); + c.header('x-proxy-station-converted', `${result.total}/${result.total}`); + if (stale) c.header('x-proxy-station-stale', '1'); + return c.body(result.content); + } const result = convertSurgeList(content, rs.clashBehavior as 'classical' | 'domain' | 'ipcidr'); c.header('content-type', 'text/yaml; charset=utf-8'); c.header('x-proxy-station-converted', `${result.converted}/${result.total}`); diff --git a/server/src/routes/tokens.ts b/server/src/routes/tokens.ts index dd4705a..324d304 100644 --- a/server/src/routes/tokens.ts +++ b/server/src/routes/tokens.ts @@ -6,33 +6,38 @@ import { nanoid } from 'nanoid'; import { z } from 'zod'; import { CLIENTS, type TokenDto } from '@proxy-station/shared'; import { db } from '../db/index.js'; +import { migrateLegacy } from '../services/wg.js'; import { subTokens } from '../db/schema.js'; /** 只返回路径,前端用当前页面的 origin 拼完整 URL */ -function tokenUrls(token: string): TokenDto['urls'] { +function tokenUrls(token: string, wireguard: boolean): TokenDto['urls'] { return { ...(Object.fromEntries(CLIENTS.map((c) => [c, `/sub/${token}/${c}`])) as Record< (typeof CLIENTS)[number], string >), shadowrocketConf: `/sub/${token}/shadowrocket-conf`, + ...(wireguard ? { wireguard: `/sub/${token}/wireguard.conf` } : {}), }; } type TokenRow = typeof subTokens.$inferSelect; function toDto(row: TokenRow): TokenDto { - return { ...row, nodeIds: JSON.parse(row.nodeIds), urls: tokenUrls(row.token) }; + const { wireguard: _secret, ...safe } = row; + return { ...safe, nodeIds: JSON.parse(row.nodeIds), urls: tokenUrls(row.token, !!row.wgDeviceId) }; } const tokenInput = z.object({ name: z.string().min(1).max(64), /** 空数组 = 不限制,下发全部启用节点 */ nodeIds: z.array(z.string()).default([]), -}); +}).strict(); export const tokensRoute = new Hono() + .use('*', async (c, next) => { c.header('cache-control', 'private, no-store'); await next(); }) .get('/', (c) => { + migrateLegacy(); const rows = db.select().from(subTokens).orderBy(asc(subTokens.createdAt)).all(); return c.json(rows.map(toDto)); }) @@ -43,15 +48,19 @@ export const tokensRoute = new Hono() token: crypto.randomBytes(16).toString('hex'), name, nodeIds: JSON.stringify(nodeIds), + wireguard: null, + wgDeviceId: null, enabled: true, createdAt: Date.now(), lastAccessAt: null, accessCount: 0, }; db.insert(subTokens).values(row).run(); - return c.json(toDto(row), 201); + migrateLegacy(); + return c.json(toDto(db.select().from(subTokens).where(eq(subTokens.id, row.id)).get()!), 201); }) .put('/:id', zValidator('json', tokenInput), (c) => { + migrateLegacy(); const id = c.req.param('id'); const { name, nodeIds } = c.req.valid('json'); const res = db @@ -60,6 +69,7 @@ export const tokensRoute = new Hono() .where(eq(subTokens.id, id)) .run(); if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404); + migrateLegacy(); return c.json(toDto(db.select().from(subTokens).where(eq(subTokens.id, id)).get()!)); }) .patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => { diff --git a/server/src/routes/wg-devices.test.ts b/server/src/routes/wg-devices.test.ts new file mode 100644 index 0000000..1afc4b4 --- /dev/null +++ b/server/src/routes/wg-devices.test.ts @@ -0,0 +1,280 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, renameSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import Database from 'better-sqlite3'; +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import YAML from 'yaml'; + +const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-wg-devices-')); +process.env.DATA_DIR = directory; +const { db, createDb } = await import('../db/index.js'); +const { subTokens, wgDevices, wgEvents, wgJobs, wgNetworks, wgGateways, wgObservations, wgSubscriptionBindings, nodes } = await import('../db/schema.js'); +const { wgRoute, wgBindingRoute, wgAgentRoute } = await import('./wg.js'); +const { tokensRoute } = await import('./tokens.js'); +const { subRoute } = await import('./sub.js'); +const { publicKey, decryptSecret } = await import('../services/wg-crypto.js'); +const { listDevices, receiveReport } = await import('../services/wg.js'); +const app = new Hono().route('/api/wg', wgRoute).route('/api/tokens', tokensRoute).route('/api/tokens', wgBindingRoute) + .route('/wg-agent', wgAgentRoute).route('/sub', subRoute); +const key = (n: number) => Buffer.alloc(32, n).toString('base64'); +const legacyProfile = (address = '10.203.0.2', n = 2) => ({ server: 'wg.example.test', port: 51820, serverPublicKey: key(1), + privateKey: key(n), presharedKey: key(n + 10), address, subnet: '10.203.0.0/24', mtu: 1280, keepalive: 25, acceptIncoming: true }); +function req(url: string, body?: unknown, method = 'POST', agentToken?: string) { + return app.request(url, { method, headers: { 'content-type': 'application/json', ...(agentToken ? { authorization: `Bearer ${agentToken}` } : {}) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); +} +async function setup() { + const response = await req('/api/wg/gateways', { server: 'wg.example.test', port: 51820, publicKey: key(1) }); + expect(response.status).toBe(201); + return await response.json() as { id: string; agentToken: string }; +} +async function create(name = 'Mac', id = crypto.randomUUID()) { + const response = await req('/api/wg/devices', { name, os: 'macOS', requestId: id }); + expect(response.status).toBe(202); return await response.json() as any; +} +async function confirm(gateway: { id: string; agentToken: string }, peers: any[] = [], ok = true) { + const tasks = await (await req(`/wg-agent/${gateway.id}/tasks`, undefined, 'GET', gateway.agentToken)).json() as any; + const response = await req(`/wg-agent/${gateway.id}/report`, { bootId: 'epoch-1', peers, + receipts: tasks.tasks.map((t: any) => ({ id: t.id, version: t.version, runtime: ok, persistent: ok })) }, 'POST', gateway.agentToken); + expect(response.status).toBe(200); return tasks.tasks; +} +function token(id = 'shared', wireguard: unknown = null) { + db.insert(subTokens).values({ id, token: id + '-secret', name: id, nodeIds: '[]', wireguard: wireguard ? JSON.stringify(wireguard) : null, createdAt: 0 }).run(); +} +async function bind(deviceId: string, id = 'shared') { + const response = await req(`/api/tokens/${id}/wg-bindings`, { deviceId }); + expect(response.status).toBe(201); return await response.json() as { id: string; path: string }; +} +beforeEach(() => { + vi.useRealTimers(); + for (const table of [wgSubscriptionBindings, subTokens, wgEvents, wgObservations, wgJobs, wgDevices, wgGateways, wgNetworks, nodes]) db.delete(table).run(); +}); +afterAll(() => { vi.useRealTimers(); db.$client.close(); rmSync(directory, { recursive: true, force: true }); }); + +describe('WireGuard device lifecycle and subscription capabilities', () => { + it('allocates no demo identities, requires an explicitly configured gateway, reserves old addresses and deduplicates create requests', async () => { + expect(listDevices()).toEqual([]); + expect((await req('/api/wg/devices', { name: 'Mac', os: 'macOS', requestId: crypto.randomUUID() })).status).toBe(503); + await setup(); const device = await create(); + expect(device.address).toBe('10.203.0.4'); expect(device.status).toBe('provisioning'); + const repeated = await create('retry', device.id); + expect(repeated.id).toBe(device.id); expect(listDevices()).toHaveLength(1); + expect(db.select().from(wgJobs).all()).toHaveLength(1); + expect(JSON.stringify(device)).not.toContain('privateKey'); expect(JSON.stringify(device)).not.toContain('presharedKey'); + const stored = db.select().from(wgDevices).get()!; + const profile = decryptSecret(stored.secret); + expect(stored.secret).not.toContain(profile.privateKey); expect(stored.publicKey).toBe(publicKey(profile.privateKey)); + expect(profile.acceptIncoming).toBe(true); + }); + + it('isolates A/B identities, preserves default URL/content, node scope and the complete managed update URL', async () => { + const gateway = await setup(); const a = await create('Mac'); const b = await create('iPad'); token(); token('other'); + await confirm(gateway); + const defaultBefore = await (await app.request('/sub/shared-secret/surge')).text(); + expect(defaultBefore).not.toContain('[WireGuard'); + expect(defaultBefore).not.toContain('NJCQ WireGuard LAN'); + const first = await bind(a.id); const second = await bind(b.id); + const response = await app.request(first.path, { headers: { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'ps.example.test' } }); + const aText = await response.text(); const bText = await (await app.request(second.path)).text(); + const aProfile = decryptSecret(db.select().from(wgDevices).where(eq(wgDevices.id, a.id)).get()!.secret); + const bProfile = decryptSecret(db.select().from(wgDevices).where(eq(wgDevices.id, b.id)).get()!.secret); + expect(aText).toContain(aProfile.privateKey); expect(aText).not.toContain(bProfile.privateKey); + expect(bText).toContain(bProfile.privateKey); expect(bText).not.toContain(aProfile.privateKey); + expect(aText.split('\n')[0]).toBe(`#!MANAGED-CONFIG https://ps.example.test${first.path} interval=86400 strict=true`); + expect(response.headers.get('cache-control')).toBe('private, no-store'); expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + expect(await (await app.request(first.path)).text()).toContain(aProfile.privateKey); + expect(await (await app.request('/sub/shared-secret/surge')).text()).toBe(defaultBefore); + expect(await (await app.request('/sub/shared-secret/shadowrocket-conf')).text()).not.toContain('NJCQ WireGuard LAN'); + expect(await (await app.request('/sub/shared-secret/clash')).text()).not.toContain('wireguard'); + const exported = await (await req(`/api/wg/devices/${a.id}/exports`)).json() as any; + expect(exported.content).toContain(aProfile.privateKey); expect(exported.content).toContain(a.address); + expect(listDevices().find(d => d.id === a.id)!.status).not.toBe('online'); + expect(db.select().from(subTokens).get()!.wireguard).toBeNull(); + expect(db.select().from(wgSubscriptionBindings).get()!.credentialHash).not.toContain(new URL('http://test' + first.path).searchParams.get('wg')!); + }); + + it('retains subscription node restrictions when attaching a device and prevents revoked token access', async () => { + const gateway = await setup(); const device = await create(); await confirm(gateway); token(); + for (const id of ['allowed', 'hidden']) db.insert(nodes).values({ id, name: id, protocol: 'trojan', server: 'example.test', port: 443, + password: 'placeholder', createdAt: 0, updatedAt: 0 }).run(); + db.update(subTokens).set({ nodeIds: '["allowed"]' }).where(eq(subTokens.id, 'shared')).run(); + const binding = await bind(device.id); + const content = await (await app.request(binding.path)).text(); + expect(content).toContain('allowed = trojan'); expect(content).not.toContain('hidden = trojan'); + expect(content.match(/^FINAL,/gm)).toHaveLength(1); + db.update(subTokens).set({ enabled: false }).where(eq(subTokens.id, 'shared')).run(); + expect((await app.request(binding.path)).status).toBe(404); + expect((await req('/api/tokens/shared/wg-bindings', { deviceId: device.id })).status).toBe(404); + db.delete(subTokens).where(eq(subTokens.id, 'shared')).run(); + expect(db.select().from(wgSubscriptionBindings).all()).toHaveLength(0); + }); + + it('delivers a purpose-bound FlClash identity without changing the default or public proxy groups', async () => { + const gateway = await setup(); const device = await create(); await confirm(gateway); token(); token('other'); + const baseline = await (await app.request('/sub/shared-secret/clash')).text(); + const surge = await bind(device.id); + const response = await req('/api/tokens/shared/wg-bindings', { deviceId: device.id, purpose: 'clash' }); + expect(response.status).toBe(201); + const binding = await response.json() as { id: string; path: string }; + const delivered = await app.request(binding.path); + expect(delivered.status).toBe(200); + expect(delivered.headers.get('cache-control')).toBe('private, no-store'); + const doc = YAML.parse(await delivered.text()); + const profile = decryptSecret(db.select().from(wgDevices).where(eq(wgDevices.id, device.id)).get()!.secret); + const wg = doc.proxies.find((p: any) => p.type === 'wireguard'); + expect(wg).toMatchObject({ ip: device.address, 'private-key': profile.privateKey, mtu: 1280, udp: true, + 'persistent-keepalive': 25, peers: [{ server: profile.server, port: profile.port, 'public-key': profile.serverPublicKey, + 'pre-shared-key': profile.presharedKey, 'allowed-ips': ['10.203.0.0/24'] }] }); + expect(doc.rules[0]).toBe('IP-CIDR,10.203.0.0/24,NJCQ WireGuard LAN,no-resolve'); + expect(doc.rules.at(-1)).toMatch(/^MATCH,/); + expect(await (await app.request('/sub/shared-secret/clash')).text()).toBe(baseline); + expect((await app.request(binding.path.replace('shared-secret', 'other-secret'))).status).toBe(404); + expect((await app.request(binding.path.replace('/clash?', '/surge?'))).status).toBe(404); + expect((await app.request(surge.path.replace('/surge?', '/clash?'))).status).toBe(404); + expect((await app.request('/sub/shared-secret/clash?wg=' + device.id)).status).toBe(404); + expect((await app.request(binding.path + '&wg=extra')).status).toBe(400); + const disposable = await (await req('/api/tokens/shared/wg-bindings', { deviceId: device.id, purpose: 'clash' })).json() as { id: string; path: string }; + expect((await req(`/api/tokens/shared/wg-bindings/${disposable.id}`, undefined, 'DELETE')).status).toBe(200); + expect((await app.request(disposable.path)).status).toBe(404); + expect((await app.request(binding.path)).status).toBe(200); + expect((await app.request(surge.path)).status).toBe(200); + await req(`/api/wg/devices/${device.id}/actions`, { action: 'pause' }); + expect((await app.request(binding.path)).status).toBe(404); + expect((await app.request('/sub/shared-secret/clash')).status).toBe(200); + }); + + it('rejects enumerated, forged, cross-subscription, empty, duplicate and unknown device parameters', async () => { + const gateway = await setup(); const device = await create(); await confirm(gateway); token(); token('other'); + const binding = await bind(device.id); + for (const path of ['/sub/shared-secret/surge?wg=' + device.id, '/sub/shared-secret/surge?wg=1', + '/sub/shared-secret/surge?wg=', binding.path.replace('shared-secret', 'other-secret')]) { + expect((await app.request(path)).status).toBe(404); + } + expect((await app.request('/sub/shared-secret/surge?device=' + device.id)).status).toBe(400); + expect((await app.request(binding.path + '&wg=extra')).status).toBe(400); + expect((await req(`/api/tokens/other/wg-bindings/${binding.id}`, undefined, 'DELETE')).status).toBe(404); + expect((await req(`/api/tokens/shared/wg-bindings/${binding.id}`, undefined, 'DELETE')).status).toBe(200); + expect((await app.request(binding.path)).status).toBe(404); + }); + + it('does not mark pause/remove complete until both runtime and persistent receipts, retries the same task and never recycles IP', async () => { + const gateway = await setup(); const device = await create(); await confirm(gateway); token(); const binding = await bind(device.id); + const paused = await (await req(`/api/wg/devices/${device.id}/actions`, { action: 'pause' })).json() as any; + expect(paused.status).toBe('revoking'); + const duplicate = await (await req(`/api/wg/devices/${device.id}/actions`, { action: 'pause' })).json() as any; + expect(duplicate.job.id).toBe(paused.job.id); + expect((await app.request(binding.path)).status).toBe(404); + expect((await req(`/api/wg/devices/${device.id}/exports`)).status).toBe(404); + const jobs = await confirm(gateway, [], false); + expect(listDevices()[0].status).toBe('error'); + await req(`/api/wg/devices/${device.id}/actions`, { action: 'retry' }); + const retried = await confirm(gateway); expect(retried[0].id).toBe(jobs[0].id); + expect(listDevices()[0].status).toBe('paused'); + await req(`/api/wg/devices/${device.id}/actions`, { action: 'resume' }); + expect(listDevices()[0].status).toBe('restoring'); expect((await app.request(binding.path)).status).toBe(404); + await confirm(gateway); expect((await app.request(binding.path)).status).toBe(200); + await req(`/api/wg/devices/${device.id}/actions`, { action: 'remove' }); + expect(listDevices()[0].status).toBe('revoking'); await confirm(gateway); + expect(listDevices()).toHaveLength(0); expect((await app.request(binding.path)).status).toBe(404); + expect(db.select().from(wgSubscriptionBindings).get()!.revokedAt).not.toBeNull(); + expect((await create('new')).address).not.toBe(device.address); + }); + + it('retains the IP on rotate, removes old key via a versioned task and preserves the existing binding', async () => { + const gateway = await setup(); const device = await create(); await confirm(gateway); token(); const binding = await bind(device.id); + const original = await (await app.request(binding.path)).text(); + await req(`/api/wg/devices/${device.id}/actions`, { action: 'rotate' }); + expect((await app.request(binding.path)).status).toBe(404); + const tasks = await confirm(gateway); + expect(tasks[0].oldPublicKey).toBe(device.publicKey); expect(tasks[0].publicKey).not.toBe(device.publicKey); + expect(tasks[0]).not.toHaveProperty('privateKey'); + const updated = await (await app.request(binding.path)).text(); + expect(updated).not.toBe(original); expect(updated).toContain(`self-ip = ${device.address}`); + await req(`/api/wg/devices/${device.id}`, { name: 'Renamed', os: 'macOS' }, 'PATCH'); + expect((await app.request(binding.path)).status).toBe(200); + }); + + it('uses receiver time/handshake age, counts traffic from the device perspective, handles counter resets and stale samples', async () => { + vi.useFakeTimers(); vi.setSystemTime(new Date('2026-11-02T00:00:00Z')); + const gateway = await setup(); const device = await create(); await confirm(gateway); + const report = (rx: number, tx: number, age: number | null = 5, bootId = 'boot') => receiveReport(gateway.id, { bootId, receipts: [], + peers: [{ publicKey: device.publicKey, handshakeAge: age, rx, tx }] }); + report(100, 200, null); expect(listDevices()[0].status).toBe('pending'); + vi.advanceTimersByTime(15000); report(250, 500, null); + expect(listDevices()[0].status).toBe('online'); + expect(listDevices()[0]).toMatchObject({ uploadRate: 10, downloadRate: 20, uploadTotal: 150, downloadTotal: 300 }); + vi.advanceTimersByTime(15000); report(10, 20); + expect(listDevices()[0]).toMatchObject({ uploadRate: null, uploadTotal: 150 }); + vi.advanceTimersByTime(46000); + expect(listDevices()[0]).toMatchObject({ status: 'unknown', uploadRate: null, downloadRate: null }); + vi.advanceTimersByTime(180000); report(10, 20, 400); + expect(listDevices()[0].status).toBe('offline'); + report(5, 6, null, 'new-boot'); expect(listDevices()[0].uploadTotal).toBe(150); + }); + + it('agent auth is independent, cannot acknowledge other gateways or stale job versions, and requires both confirmations', async () => { + const gateway = await setup(); const device = await create(); + expect((await req(`/wg-agent/${gateway.id}/tasks`, undefined, 'GET')).status).toBe(401); + expect((await req(`/wg-agent/${gateway.id}/tasks`, undefined, 'GET', 'admin-token')).status).toBe(401); + const tasks = await (await req(`/wg-agent/${gateway.id}/tasks`, undefined, 'GET', gateway.agentToken)).json() as any; + const job = tasks.tasks[0]; + await req(`/wg-agent/${gateway.id}/report`, { bootId: 'boot', peers: [], receipts: [{ id: job.id, version: 999, runtime: true, persistent: true }] }, 'POST', gateway.agentToken); + expect(listDevices()[0].status).toBe('provisioning'); + await req(`/wg-agent/${gateway.id}/report`, { bootId: 'boot', peers: [], receipts: [{ id: job.id, version: job.version, runtime: true, persistent: false }] }, 'POST', gateway.agentToken); + expect(listDevices()[0].status).toBe('error'); + expect((await req(`/api/wg/devices/${device.id}/exports`)).status).toBe(404); + }); + + it('migrates old key/IP once, retains legacy URLs without peer writes, and applies device lifecycle to old downloads', async () => { + token('ipad', { ...legacyProfile(), acceptIncoming: false }); token('mac', legacyProfile('10.203.0.3', 3)); token(); + const before = await (await app.request('/sub/ipad-secret/wireguard.conf')).text(); + expect(before).toContain(key(2)); expect(before).toContain('10.203.0.2'); + expect(listDevices()).toHaveLength(2); expect(listDevices().map(d => d.address)).toEqual(['10.203.0.2', '10.203.0.3']); + expect(db.select().from(subTokens).all().every(t => t.wireguard === null)).toBe(true); + const list = await (await app.request('/api/tokens')).text(); expect(list).not.toContain(key(2)); expect(list).not.toContain(key(12)); + expect(await (await app.request('/sub/shared-secret/surge')).text()).not.toContain('[WireGuard'); + const gateway = await setup(); const tasks = await confirm(gateway); + expect(tasks.every((t: any) => t.action === 'adopt')).toBe(true); + const device = listDevices().find(d => d.address === '10.203.0.2')!; + const derived = await bind(device.id); + expect(await (await app.request(derived.path)).text()).toContain('accept-incoming = true'); + expect(await (await app.request('/sub/ipad-secret/surge')).text()).not.toContain('accept-incoming = true'); + await req(`/api/wg/devices/${device.id}/actions`, { action: 'pause' }); await confirm(gateway); + expect((await app.request('/sub/ipad-secret/wireguard.conf')).status).toBe(404); + expect((await app.request('/sub/ipad-secret/surge')).status).toBe(404); + expect((await app.request('/sub/mac-secret/wireguard.conf')).status).toBe(200); + }); + + it('fails closed if the encryption key is lost instead of creating a replacement', async () => { + await setup(); await create(); + const file = path.join(directory, 'wg-master.key'); + renameSync(file, file + '.saved'); + try { + const response = await req('/api/wg/devices', { name: 'New', os: 'iOS', requestId: crypto.randomUUID() }); + expect(response.status).toBe(500); + expect(existsSync(file)).toBe(false); + expect(db.select().from(wgDevices).all()).toHaveLength(1); + expect(() => createDb(path.join(directory, 'proxy-station.db'))).toThrow(); + expect(existsSync(file)).toBe(false); + } finally { renameSync(file + '.saved', file); } + }); + + it('upgrades a legacy SQLite file and persists encrypted identities, legacy mapping and idempotent adoption jobs', () => { + const file = path.join(directory, 'legacy-with-keys.db'); const sqlite = new Database(file); + sqlite.exec('CREATE TABLE sub_tokens(id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, name TEXT NOT NULL, wireguard TEXT, created_at INTEGER NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, last_access_at INTEGER, access_count INTEGER NOT NULL DEFAULT 0)'); + sqlite.prepare('INSERT INTO sub_tokens(id,token,name,wireguard,created_at) VALUES(?,?,?,?,?)').run('ipad', 'old-secret', 'iPad', JSON.stringify(legacyProfile()), 1); + sqlite.close(); + for (let i = 0; i < 2; i++) { + const upgraded = createDb(file); + expect(upgraded.select().from(subTokens).get()).toMatchObject({ token: 'old-secret', wireguard: null, wgDeviceId: 'legacy-ipad' }); + expect(upgraded.select().from(wgDevices).get()).toMatchObject({ address: '10.203.0.2', publicKey: publicKey(key(2)) }); + expect(upgraded.select().from(wgJobs).all()).toHaveLength(1); + expect(decryptSecret(upgraded.select().from(wgDevices).get()!.secret).presharedKey).toBe(key(12)); + upgraded.$client.close(); + } + expect(readFileSync(path.join(directory, 'wg-master.key')).length).toBe(32); + }); +}); diff --git a/server/src/routes/wg.ts b/server/src/routes/wg.ts new file mode 100644 index 0000000..3b232b2 --- /dev/null +++ b/server/src/routes/wg.ts @@ -0,0 +1,99 @@ +import crypto from 'node:crypto'; +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { and, desc, eq } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { wgDevices, wgEvents, wgGateways, wgJobs, wgNetworks, wgSubscriptionBindings } from '../db/schema.js'; +import { credential, hashCredential } from '../services/wg-crypto.js'; +import { authorizedProfile, createDevice, deviceAction, deviceDto, gatewayTasks, getDevice, issueBinding, + listDevices, migrateLegacy, receiveReport, WgError } from '../services/wg.js'; +import { generateWireguard } from '../services/generators/wireguard.js'; + +const name = z.string().trim().min(1).max(64).regex(/^[^\x00-\x1f\x7f]+$/); +const os = z.enum(['macOS', 'iPadOS', 'iOS', 'Android', 'Windows', 'Linux', '其他']); +const key = z.string().regex(/^[A-Za-z0-9+/]{43}=$/); + +export const wgRoute = new Hono(); +wgRoute.use('*', async (c, next) => { c.header('cache-control', 'private, no-store'); c.header('referrer-policy', 'no-referrer'); await next(); }); +wgRoute.onError((e, c) => e instanceof WgError ? c.json({ error: e.message }, e.status) : c.json({ error: '设备操作失败,请检查服务器配置' }, 500)); +wgRoute.get('/devices', c => c.json(listDevices())); +wgRoute.post('/devices', zValidator('json', z.object({ name, os, requestId: z.string().uuid(), gatewayId: z.string().optional() }).strict()), + c => c.json(createDevice(c.req.valid('json')), 202)); +wgRoute.get('/devices/:id', c => { + migrateLegacy(); const device = getDevice(c.req.param('id')); + const events = db.select({ message: wgEvents.message, createdAt: wgEvents.createdAt }).from(wgEvents) + .where(eq(wgEvents.deviceId, device.id)).orderBy(desc(wgEvents.createdAt)).limit(30).all(); + return c.json({ ...deviceDto(device), events }); +}); +wgRoute.patch('/devices/:id', zValidator('json', z.object({ name, os }).strict()), c => { + const device = getDevice(c.req.param('id')); + if (device.archivedAt) throw new WgError('设备已移除'); + db.update(wgDevices).set(c.req.valid('json')).where(eq(wgDevices.id, device.id)).run(); + return c.json(deviceDto(getDevice(device.id))); +}); +wgRoute.post('/devices/:id/actions', zValidator('json', z.object({ action: z.enum(['pause', 'resume', 'rotate', 'remove', 'retry']) }).strict()), + c => c.json(deviceAction(c.req.param('id'), c.req.valid('json').action), 202)); +wgRoute.get('/jobs/:id', c => { + const job = db.select().from(wgJobs).where(eq(wgJobs.id, c.req.param('id'))).get(); + return job ? c.json(job) : c.json({ error: '任务不存在' }, 404); +}); +wgRoute.post('/devices/:id/exports', c => c.json({ filename: 'wireguard.conf', content: generateWireguard(authorizedProfile(c.req.param('id'))) })); +wgRoute.get('/gateways', c => { + migrateLegacy(); + return c.json(db.select().from(wgGateways).all().map(g => { + const network = db.select().from(wgNetworks).where(eq(wgNetworks.id, g.networkId)).get()!; + return { id: g.id, server: g.server, port: g.port, publicKey: g.publicKey, subnet: network.subnet, mtu: network.mtu, + configured: !!g.agentHash, receivedAt: g.receivedAt }; + })); +}); +// Explicit administrator provisioning. Never seeds sample identities or sends remote commands. +wgRoute.post('/gateways', zValidator('json', z.object({ server: z.string().min(1).max(253).regex(/^[\w.-]+$/), + port: z.number().int().min(1).max(65535), publicKey: key }).strict()), c => { + migrateLegacy(); const input = c.req.valid('json'); + const existing = db.select().from(wgGateways).where(eq(wgGateways.publicKey, input.publicKey)).get(); + if (existing && (existing.server !== input.server || existing.port !== input.port)) throw new WgError('网关地址与已纳管身份不一致'); + if (!existing && db.select().from(wgGateways).all().length) throw new WgError('请先核对现有 njcq 网关公钥'); + const agentToken = credential(); const id = existing?.id ?? 'njcq'; + db.transaction(() => { + if (existing) db.update(wgGateways).set({ agentHash: hashCredential(agentToken) }).where(eq(wgGateways.id, id)).run(); + else { + db.insert(wgNetworks).values({ id, subnet: '10.203.0.0/24', mtu: 1280 }).run(); + db.insert(wgGateways).values({ id, networkId: id, ...input, agentHash: hashCredential(agentToken) }).run(); + } + }); + return c.json({ id, agentToken }, 201); +}); + +export const wgBindingRoute = new Hono(); +wgBindingRoute.onError((e, c) => e instanceof WgError ? c.json({ error: e.message }, e.status) : c.json({ error: '设备绑定失败' }, 500)); +wgBindingRoute.use('*', async (c, next) => { c.header('cache-control', 'private, no-store'); c.header('referrer-policy', 'no-referrer'); await next(); }); +wgBindingRoute.post('/:id/wg-bindings', zValidator('json', z.object({ deviceId: z.string().min(1), purpose: z.enum(['surge', 'clash']).default('surge') }).strict()), + c => c.json(issueBinding(c.req.param('id'), c.req.valid('json').deviceId, c.req.valid('json').purpose), 201)); +wgBindingRoute.delete('/:id/wg-bindings/:bindingId', c => { + const result = db.update(wgSubscriptionBindings).set({ revokedAt: Date.now() }) + .where(and(eq(wgSubscriptionBindings.id, c.req.param('bindingId')), eq(wgSubscriptionBindings.tokenId, c.req.param('id')))).run(); + return result.changes ? c.json({ ok: true }) : c.json({ error: '设备链接不存在' }, 404); +}); + +const count = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const reportSchema = z.object({ bootId: z.string().min(1).max(128), + receipts: z.array(z.object({ id: z.string(), version: count, runtime: z.boolean(), persistent: z.boolean(), error: z.string().max(256).optional() }).strict()).max(512), + peers: z.array(z.object({ publicKey: key, handshakeAge: count.nullable(), rx: count, tx: count }).strict()).max(1024), +}).strict().superRefine((v, ctx) => { + if (new Set(v.peers.map(p => p.publicKey)).size !== v.peers.length || new Set(v.receipts.map(p => p.id)).size !== v.receipts.length) + ctx.addIssue({ code: 'custom', message: 'Duplicate agent sample' }); +}); +// This route is outside /api and authenticates ONLY the dedicated gateway token. +export const wgAgentRoute = new Hono(); +wgAgentRoute.use('/:id/*', async (c, next) => { + c.header('cache-control', 'private, no-store'); + const token = c.req.header('authorization')?.replace(/^Bearer /, '') ?? ''; + const gateway = db.select().from(wgGateways).where(eq(wgGateways.id, c.req.param('id')!)).get(); + if (!gateway?.agentHash || !crypto.timingSafeEqual(Buffer.from(hashCredential(token)), Buffer.from(gateway.agentHash))) return c.json({ error: 'unauthorized' }, 401); + await next(); +}); +wgAgentRoute.get('/:id/tasks', c => c.json({ tasks: gatewayTasks(c.req.param('id')) })); +wgAgentRoute.post('/:id/report', zValidator('json', reportSchema), c => { + receiveReport(c.req.param('id'), c.req.valid('json')); return c.json({ ok: true }); +}); diff --git a/server/src/routes/wireguard.test.ts b/server/src/routes/wireguard.test.ts new file mode 100644 index 0000000..d66829b --- /dev/null +++ b/server/src/routes/wireguard.test.ts @@ -0,0 +1,137 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { wireguardProfileSchema } from '@proxy-station/shared'; + +const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-wireguard-')); +process.env.DATA_DIR = directory; +const { db, createDb } = await import('../db/index.js'); +const { nodes, subTokens, wgDevices, wgJobs, wgObservations, wgEvents, wgSubscriptionBindings, wgGateways, wgNetworks } = await import('../db/schema.js'); +const { tokensRoute } = await import('./tokens.js'); +const { subRoute } = await import('./sub.js'); +const { generateSurge } = await import('../services/generators/surge.js'); +const { generateShadowrocketConf } = await import('../services/generators/shadowrocket-conf.js'); + +const key = (value: number) => Buffer.alloc(32, value).toString('base64'); +const profile = (address = '10.203.0.2', value = 2) => ({ + server: 'vpn.example.com', port: 51820, serverPublicKey: key(1), privateKey: key(value), + address, subnet: '10.203.0.0/24', acceptIncoming: false, +}); +const request = (method: string, body: unknown) => ({ method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); +beforeEach(() => { + for (const table of [wgSubscriptionBindings, subTokens, wgEvents, wgObservations, wgJobs, wgDevices, wgGateways, wgNetworks, nodes]) db.delete(table).run(); +}); +afterAll(() => { db.$client.close(); rmSync(directory, { recursive: true, force: true }); }); + +describe('per-device WireGuard subscriptions', () => { + it('isolates keys between device tokens and does not enable WireGuard on shared subscriptions', async () => { + for (const [token, wireguard] of [['ipad-token', profile()], ['mac-token', profile('10.203.0.3', 3)], ['shared-token', null]] as const) { + db.insert(subTokens).values({ id: token, token, name: token, createdAt: 0, + wireguard: wireguard ? JSON.stringify(wireguard) : null }).run(); + } + const ipad = await subRoute.request('/ipad-token/wireguard.conf'); + expect(ipad.headers.get('cache-control')).toBe('private, no-store'); + const config = await ipad.text(); + expect(config).toContain(`PrivateKey = ${key(2)}`); + expect(config).not.toContain(key(3)); + expect(config).toContain('AllowedIPs = 10.203.0.0/24'); + expect(config).not.toContain('0.0.0.0/0'); + for (const token of ['shared-token', 'invalid-token']) { + expect((await subRoute.request(`/${token}/wireguard.conf`)).status).toBe(404); + } + expect(await (await subRoute.request('/shared-token/surge')).text()).not.toContain('[WireGuard'); + for (const token of ['ipad-token', 'shared-token', 'invalid-token']) { + expect((await subRoute.request(`/${token}/wireguard-shadowrocket`)).status).toBe(404); + } + db.update(subTokens).set({ enabled: false }).run(); + expect((await subRoute.request('/ipad-token/wireguard.conf')).status).toBe(404); + }); + + it('keeps Shadowrocket proxy downloads independent of legacy and paused WG identities', async () => { + for (const [token, wireguard] of [['device', { ...profile(), presharedKey: key(9) }], ['shared', null]] as const) { + db.insert(subTokens).values({ id: token, token, name: token, createdAt: 0, + wireguard: wireguard ? JSON.stringify(wireguard) : null }).run(); + } + db.insert(nodes).values({ id: 'proxy', name: 'Example Proxy Direct', protocol: 'trojan', server: 'proxy.example.test', + port: 443, password: 'placeholder-password', createdAt: 0, updatedAt: 0 }).run(); + const plainNodes = await (await subRoute.request('/shared/shadowrocket')).text(); + expect(Buffer.from(plainNodes, 'base64').toString()).toContain('trojan://'); + const plainConf = await (await subRoute.request('/shared/shadowrocket-conf')).text(); + // A paused device must not gate the ordinary proxy subscription or trigger private-key retrieval. + db.update(wgDevices).set({ desired: 'paused' }).run(); + for (const [endpoint, expected] of [['shadowrocket', plainNodes], ['shadowrocket-conf', plainConf]]) { + for (const query of ['', '?wg=not-a-device-credential']) { + const response = await subRoute.request(`/device/${endpoint}${query}`); + expect(response.status).toBe(200); + const content = await response.text(); + expect(content).toBe(expected); + expect(content).not.toContain('WireGuard'); + expect(content).not.toContain('10.203.0.'); + expect(content).not.toContain(key(2)); + expect(content).not.toContain(key(9)); + } + } + const tokens = await (await tokensRoute.request('/')).json() as any[]; + expect(tokens.every(t => t.urls.shadowrocketWireguard === undefined)).toBe(true); + expect(tokens.find(t => t.id === 'device').urls.wireguard).toBe('/sub/device/wireguard.conf'); + }); + + it('prevents token edits from switching shared identity and preserves a legacy device on rename', async () => { + db.insert(subTokens).values({ id: 'ipad', token: 'ipad-secret', name: 'iPad', createdAt: 0, wireguard: JSON.stringify(profile()) }).run(); + const created = await tokensRoute.request('/', request('POST', { name: 'shared' })); + expect(created.status).toBe(201); + const shared = await created.json() as any; + expect((await tokensRoute.request('/', request('POST', { name: 'duplicate', wireguard: profile() }))).status).toBe(400); + expect((await tokensRoute.request(`/${shared.id}`, request('PUT', { name: 'shared', wireguard: profile() }))).status).toBe(400); + const updated = await tokensRoute.request('/ipad', request('PUT', { name: 'iPad renamed', nodeIds: [] })); + const metadata = await updated.json() as any; + expect(metadata.wgDeviceId).toBeTruthy(); + expect(metadata.wireguard).toBeUndefined(); + expect(await (await subRoute.request('/ipad-secret/wireguard.conf')).text()).toContain(key(2)); + }); + + it('rejects default/public routes, invalid keys, injection and addresses outside the subnet', () => { + for (const patch of [{ subnet: '0.0.0.0/0' }, { subnet: '8.8.8.0/24' }, { address: '10.204.0.2' }, + { address: '10.203.0.0' }, { privateKey: 'invalid' }, { server: 'vpn.example.com\n[Rule]' }]) { + expect(wireguardProfileSchema.safeParse({ ...profile(), ...patch }).success).toBe(false); + } + }); + + it('routes only the LAN before broad DIRECT rules, protects Internet groups and marks incoming requirements', () => { + const ir = { nodes: [], sections: { General: '' }, + groups: [{ name: 'All', type: 'select' as const, members: [], includeAllNodes: true, + filterRegex: null, testUrl: null, interval: null, tolerance: null }], + rules: [{ type: 'IP-CIDR', value: '10.0.0.0/8', policy: 'DIRECT', params: [], ruleset: null }, + { type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null }], + wireguard: wireguardProfileSchema.parse({ ...profile(), acceptIncoming: true }), + }; + const surge = generateSurge(ir, { selfUrl: 'https://example.com/sub/placeholder/surge' }).content; + expect(surge.indexOf('IP-CIDR,10.203.0.0/24,NJCQ WireGuard LAN,no-resolve')).toBeLessThan(surge.indexOf('IP-CIDR,10.0.0.0/8')); + expect(surge).toContain('accept-incoming = true'); + expect(surge).toContain('Surge Mac 6.11.0+'); + expect(surge.split('[Proxy Group]')[1].split('[Rule]')[0]).not.toContain('NJCQ WireGuard LAN'); + const dynamic = generateSurge({ ...ir, groups: [{ ...ir.groups[0], filterRegex: 'US|LAN' }] }, { selfUrl: 'https://example.test/sub/placeholder/surge' }).content; + const filter = dynamic.match(/policy-regex-filter=([^\n]+)/)![1]; + const decodedFilter = filter.startsWith('"') ? JSON.parse(filter) : filter; + expect(new RegExp(decodedFilter).test('NJCQ WireGuard LAN')).toBe(false); + expect(new RegExp(decodedFilter).test('US Test Direct')).toBe(true); + expect(new RegExp(decodedFilter).test('prefix US Test Direct')).toBe(true); + expect(new RegExp(decodedFilter).test('prefix LAN')).toBe(true); + expect(new RegExp(decodedFilter).test('JP Direct')).toBe(false); + expect(generateShadowrocketConf(ir)).toEqual(generateShadowrocketConf({ ...ir, wireguard: undefined })); + }); + + it('upgrades an existing database without changing shared token identity', () => { + const file = path.join(directory, 'legacy.db'); + const old = new Database(file); + old.exec("CREATE TABLE sub_tokens (id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, last_access_at INTEGER, access_count INTEGER NOT NULL DEFAULT 0); INSERT INTO sub_tokens(id,token,name,created_at) VALUES ('legacy','old-token','existing',0)"); + old.close(); + const migrated = createDb(file); + const token = migrated.select().from(subTokens).get()!; + expect(token.token).toBe('old-token'); + expect(token.wireguard).toBeNull(); + migrated.$client.close(); + }); +}); diff --git a/server/src/services/generators/clash-ai-dns.test.ts b/server/src/services/generators/clash-ai-dns.test.ts new file mode 100644 index 0000000..54c5809 --- /dev/null +++ b/server/src/services/generators/clash-ai-dns.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import YAML from 'yaml'; +import { generateClash } from './clash.js'; +import type { ProfileIR, RuleIR } from '../ir.js'; + +const ai = { name: '🤖 AI', type: 'select' as const, testUrl: null, interval: null, tolerance: null, + filterRegex: null, includeAllNodes: false, members: [{ kind: 'builtin' as const, name: 'DIRECT' }] }; +const rule = (type: string, value: string, policy: string): RuleIR => ({ type, value, policy, params: [], ruleset: null }); +const set = (id: string, policy: string, behavior = 'classical'): RuleIR => ({ + ...rule('RULE-SET', id, policy), ruleset: { id, name: id, surgeUrl: `https://example.com/${id}.list`, + clashUrl: `https://example.com/${id}.yaml`, clashBehavior: behavior, clashFormat: 'yaml', needsConvert: false }, +}); +const base: ProfileIR = { nodes: [], groups: [ai], sections: {}, rules: [] }; +const doc = (rules: RuleIR[], ir = base) => YAML.parse(generateClash({ ...ir, rules }, { convertBaseUrl: 'https://example.com/sub/token' }).content); +const defaults = ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query']; +const aiDns = [`https://1.1.1.1/dns-query#${encodeURIComponent(ai.name)}`]; + +describe('Clash AI DNS derived from ordered IR domain rules', () => { + it('keeps earlier domain/resource exceptions before broader AI rules and ignores IP/process conditions', () => { + const rules = [rule('DOMAIN', 'auth.ai.example', 'DIRECT'), set('Unbreak', 'DIRECT'), + rule('DOMAIN-SUFFIX', 'ai.example', ai.name), rule('DOMAIN-KEYWORD', 'ai-keyword', ai.name), + set('AI', ai.name), rule('IP-CIDR', '192.0.2.0/24', ai.name), rule('PROCESS-NAME', 'example', ai.name), + rule('DOMAIN', 'later.example', 'DIRECT')]; + const before = structuredClone(rules); + const output = doc(rules); + expect(rules).toEqual(before); + expect(Object.keys(output.dns['nameserver-policy'])).toEqual(['rule-set:proxy-station-dns-1', 'rule-set:Unbreak', + 'rule-set:proxy-station-dns-2', 'rule-set:AI']); + expect(Object.values(output.dns['nameserver-policy'])).toEqual([defaults, defaults, aiDns, aiDns]); + expect(output['rule-providers']['proxy-station-dns-1'].payload).toEqual(['DOMAIN,auth.ai.example']); + expect(output['rule-providers']['proxy-station-dns-2'].payload).toEqual([ + 'DOMAIN-SUFFIX,ai.example', 'DOMAIN-KEYWORD,ai-keyword']); + expect(output.dns.nameserver).toEqual(defaults); + expect(output.dns['proxy-server-nameserver']).toEqual(defaults); + expect(output.dns).not.toHaveProperty('respect-rules'); + expect(output.rules).toEqual(['DOMAIN,auth.ai.example,DIRECT', 'RULE-SET,Unbreak,DIRECT', + 'DOMAIN-SUFFIX,ai.example,🤖 AI', 'DOMAIN-KEYWORD,ai-keyword,🤖 AI', 'RULE-SET,AI,🤖 AI', + 'IP-CIDR,192.0.2.0/24,🤖 AI', 'PROCESS-NAME,example,🤖 AI', 'DOMAIN,later.example,DIRECT']); + }); + + it('preserves interleaved resolvers, first resource occurrence and avoids provider name collisions', () => { + const output = doc([set('proxy-station-dns-1', 'DIRECT'), rule('DOMAIN-SUFFIX', 'overlap.example', ai.name), + rule('DOMAIN', 'specific.overlap.example', 'DIRECT'), set('proxy-station-dns-1', ai.name), + set('ip-only', ai.name, 'ipcidr'), rule('DOMAIN', 'last.example', ai.name)]); + const policy = output.dns['nameserver-policy']; + expect(policy['rule-set:proxy-station-dns-1']).toEqual(defaults); + expect(Object.keys(policy)).toEqual(['rule-set:proxy-station-dns-1', 'rule-set:proxy-station-dns-2', + 'rule-set:proxy-station-dns-3', 'rule-set:proxy-station-dns-4']); + expect(policy).not.toHaveProperty('rule-set:ip-only'); + expect(output['rule-providers']['proxy-station-dns-1'].type).toBe('http'); + }); + + it('leaves DNS unchanged without a live AI group or an AI domain rule', () => { + expect(doc([rule('DOMAIN', 'example.com', 'DIRECT')]).dns).not.toHaveProperty('nameserver-policy'); + expect(doc([rule('IP-CIDR', '192.0.2.0/24', ai.name)]).dns).not.toHaveProperty('proxy-server-nameserver'); + expect(doc([rule('DOMAIN', 'example.com', ai.name)], { ...base, groups: [] }).dns) + .not.toHaveProperty('nameserver-policy'); + }); +}); diff --git a/server/src/services/generators/clash-ai-dns.ts b/server/src/services/generators/clash-ai-dns.ts new file mode 100644 index 0000000..50221e1 --- /dev/null +++ b/server/src/services/generators/clash-ai-dns.ts @@ -0,0 +1,60 @@ +import type { ProfileIR } from '../ir.js'; + +const DOMAIN_TYPES = new Set(['DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD']); + +/** + * mihomo's ordered nameserver-policy + classical domain matchers preserve rule + * order, including an earlier exception before a broader AI domain. Plain trie + * keys alone would instead prefer the more specific domain, regardless of order. + * Sources: wiki.metacubex.one/config/dns/ and mihomo config/config.go, dns/resolver.go. + */ +export function clashAiDns( + ir: ProfileIR, + providers: Record>, + providerNames: Map, + defaultNameservers: string[], +): Record { + const ai = ir.groups.find((g) => /^[^\p{L}\p{N}]*AI$/u.test(g.name)); + if (!ai) return {}; + const lastAI = ir.rules.map((r, index) => r.policy === ai.name + && (DOMAIN_TYPES.has(r.type) || (r.ruleset && r.ruleset.clashBehavior !== 'ipcidr')) ? index : -1) + .filter((index) => index >= 0).at(-1) ?? -1; + if (lastAI < 0) return {}; + + const aiNameservers = [`https://1.1.1.1/dns-query#${encodeURIComponent(ai.name)}`]; + const policy: Record = {}; + let payload: string[] = []; + let aiBlock = false; + let index = 0; + const flush = () => { + if (!payload.length) return; + let name: string; + do { name = `proxy-station-dns-${++index}`; } while (name in providers); + providers[name] = { type: 'inline', behavior: 'classical', payload }; + policy[`rule-set:${name}`] = aiBlock ? aiNameservers : defaultNameservers; + payload = []; + }; + for (const rule of ir.rules.slice(0, lastAI + 1)) { + if (!rule.value) continue; + const isAI = rule.policy === ai.name; + if (rule.ruleset) { + if (rule.ruleset.clashBehavior === 'ipcidr') continue; + const name = providerNames.get(rule.ruleset.id); + if (!name) continue; + flush(); + // A reused resource has the same domain membership; its first occurrence wins. + const key = `rule-set:${name}`; + if (!(key in policy)) policy[key] = isAI ? aiNameservers : defaultNameservers; + } else if (DOMAIN_TYPES.has(rule.type)) { + if (payload.length && isAI !== aiBlock) flush(); + aiBlock = isAI; + payload.push(`${rule.type},${rule.value}`); + } + } + flush(); + return { + 'nameserver-policy': policy, + // Resolve node hostnames independently to prevent proxy/DNS recursion. + 'proxy-server-nameserver': [...defaultNameservers], + }; +} diff --git a/server/src/services/generators/clash.ts b/server/src/services/generators/clash.ts index d0f3843..a05071c 100644 --- a/server/src/services/generators/clash.ts +++ b/server/src/services/generators/clash.ts @@ -1,6 +1,8 @@ import YAML from 'yaml'; import { clientSupports, normalizeSsCipher, splitRuleExpression, type ExcludedNode, type NodeDto } from '@proxy-station/shared'; import type { ProfileIR } from '../ir.js'; +import { clashAiDns } from './clash-ai-dns.js'; +import { WG_POLICY } from './wireguard.js'; export function clashProxy(n: NodeDto): Record { const base: Record = { @@ -119,6 +121,9 @@ export interface ClashOutput { } export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): ClashOutput { + if (ir.wireguard && [...ir.nodes, ...ir.groups].some(n => n.name === WG_POLICY)) { + throw new Error('WireGuard 组网策略名称与现有节点或组冲突'); + } const excluded: ExcludedNode[] = []; const usable = ir.nodes.filter((n) => { const verdict = clientSupports('clash', n); @@ -128,6 +133,13 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): const usableNames = new Set(usable.map((n) => n.name)); const proxies = usable.map(clashProxy); + if (ir.wireguard) { + const wg = ir.wireguard; + proxies.push({ name: WG_POLICY, type: 'wireguard', ip: wg.address, 'private-key': wg.privateKey, + mtu: wg.mtu, udp: true, 'persistent-keepalive': wg.keepalive, + peers: [{ server: wg.server, port: wg.port, 'public-key': wg.serverPublicKey, + ...(wg.presharedKey ? { 'pre-shared-key': wg.presharedKey } : {}), 'allowed-ips': [wg.subnet] }] }); + } const proxyGroups = ir.groups.map((g) => { const group: Record = { name: g.name, type: g.type }; @@ -136,10 +148,12 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): .map((m) => m.name); if (g.filterRegex) { group['include-all-proxies'] = true; + if (ir.wireguard) group['exclude-filter'] = `^${WG_POLICY}$`; group.filter = g.filterRegex; if (members.length) group.proxies = [...new Set(members)]; } else if (g.includeAllNodes) { group['include-all-proxies'] = true; + if (ir.wireguard) group['exclude-filter'] = `^${WG_POLICY}$`; if (members.length) group.proxies = [...new Set(members)]; } else { group.proxies = members.length ? members : ['DIRECT']; @@ -154,7 +168,7 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): // rule-providers:从启用规则引用的规则集生成,名称唯一化 const providerNameByRulesetId = new Map(); - const providers: Record = {}; + const providers: Record> = {}; const takenNames = new Set(); const usedRulesets = ir.rules.flatMap((r) => (r.ruleset ? [r.ruleset] : [])); for (const rs of usedRulesets) { @@ -175,6 +189,7 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): const skippedRules: string[] = []; const ruleLines: string[] = []; + if (ir.wireguard) ruleLines.push(`IP-CIDR,${ir.wireguard.subnet},${WG_POLICY},no-resolve`); for (const r of ir.rules) { if (r.type === 'FINAL') { ruleLines.push(`MATCH,${mapPolicy(r.policy)}`); @@ -217,6 +232,8 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): ruleLines.push([r.type, r.value, policy, ...params].join(',')); } + const defaultNameservers = ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query']; + const aiDns = clashAiDns(ir, providers, providerNameByRulesetId, defaultNameservers); const doc = { 'mixed-port': 7890, 'allow-lan': false, @@ -228,7 +245,8 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): enable: true, listen: '0.0.0.0:1053', 'enhanced-mode': 'fake-ip', - nameserver: ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'], + nameserver: defaultNameservers, + ...aiDns, }, proxies, 'proxy-groups': proxyGroups, diff --git a/server/src/services/generators/mobile-clients.test.ts b/server/src/services/generators/mobile-clients.test.ts index 05da042..d91658a 100644 --- a/server/src/services/generators/mobile-clients.test.ts +++ b/server/src/services/generators/mobile-clients.test.ts @@ -10,6 +10,7 @@ import { generateClash, clashProxy } from './clash.js'; import { generateShadowrocket } from './shadowrocket.js'; import { generateShadowrocketConf } from './shadowrocket-conf.js'; import { parseShareLink } from '../sharelink/parse.js'; +import { wireguardProfileSchema } from '@proxy-station/shared'; const node: NodeDto = { id: 'placeholder', name: '🇯🇵 JP VMess Direct', protocol: 'vmess', @@ -84,6 +85,20 @@ describe('mobile client node subscriptions', () => { }); describe('mobile client groups and rule semantics', () => { + it('keeps the WG identity out of dynamic public pools while preserving explicit members', () => { + const key = (n: number) => Buffer.alloc(32, n).toString('base64'); + const wireguard = wireguardProfileSchema.parse({ server: 'wg.example.test', port: 51820, serverPublicKey: key(1), + privateKey: key(2), presharedKey: key(3), address: '10.203.0.4', subnet: '10.203.0.0/24' }); + const result = YAML.parse(generate({ ...base, wireguard, groups: [group, { ...group, name: 'All', filterRegex: null }] }).content); + for (const g of result['proxy-groups']) { + expect(g['exclude-filter']).toBe('^NJCQ WireGuard LAN$'); + expect(g.proxies).toEqual(['DIRECT', node.name, 'Source']); + } + expect(result.rules[0]).toBe('IP-CIDR,10.203.0.0/24,NJCQ WireGuard LAN,no-resolve'); + expect(result.rules.at(-1)).toBe('MATCH,DIRECT'); + expect(() => generate({ ...base, wireguard, groups: [{ ...group, name: 'NJCQ WireGuard LAN' }] })).toThrow(); + }); + it('preserves explicit members in dynamic groups, zero tolerance, and omits fallback tolerance', () => { const source = { ...group, name: 'Source', filterRegex: null, includeAllNodes: false, members: [{ kind: 'node' as const, name: node.name }] }; @@ -135,7 +150,10 @@ describe('mobile client groups and rule semantics', () => { const nodes: NodeDto[] = [node, { ...node, name: 'Trojan', protocol: 'trojan' }, { ...node, name: 'SS', protocol: 'shadowsocks' }, { ...node, name: 'HY2', protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander', obfsPassword: 'placeholder-obfs' }]; - const ir: ProfileIR = { ...base, nodes, groups: [ + const key = (n: number) => Buffer.alloc(32, n).toString('base64'); + const wireguard = wireguardProfileSchema.parse({ server: '127.0.0.1', port: 51820, serverPublicKey: key(1), + privateKey: key(2), presharedKey: key(3), address: '10.203.0.4', subnet: '10.203.0.0/24' }); + const ir: ProfileIR = { ...base, nodes, wireguard, groups: [ { ...group, name: 'Source', filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }, group, ], rules: [{ type: 'AND', value: '((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DOMAIN,example.com))', policy: 'REJECT', params: [], ruleset: null }, final] }; diff --git a/server/src/services/generators/shadowrocket-conf.test.ts b/server/src/services/generators/shadowrocket-conf.test.ts index d1a9fad..2d7b7a6 100644 --- a/server/src/services/generators/shadowrocket-conf.test.ts +++ b/server/src/services/generators/shadowrocket-conf.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { generateShadowrocketConf } from './shadowrocket-conf.js'; import type { ProfileIR } from '../ir.js'; +import { wireguardProfileSchema } from '@proxy-station/shared'; const ir: ProfileIR = { nodes: [], @@ -37,6 +38,23 @@ const ir: ProfileIR = { }; describe('generateShadowrocketConf', () => { + it('ignores optional WireGuard identity without adding private routes, sections or group filters', () => { + const profile: ProfileIR = { ...ir, groups: [...ir.groups, { + ...ir.groups[0], name: 'All Nodes', includeAllNodes: true, members: [], + }, { ...ir.groups[0], name: 'US', filterRegex: 'US|States', members: [] }] }; + const key = (n: number) => Buffer.alloc(32, n).toString('base64'); + const withWg = { ...profile, wireguard: wireguardProfileSchema.parse({ server: 'wg.example.test', port: 51820, + privateKey: key(2), serverPublicKey: key(1), presharedKey: key(3), address: '10.203.0.2', + subnet: '10.203.0.0/24', acceptIncoming: true }) }; + const output = generateShadowrocketConf(withWg); + expect(output).toEqual(generateShadowrocketConf(profile)); + expect(output.content).toContain('policy-regex-filter=.*'); + expect(output.content).toContain('policy-regex-filter=US|States'); + for (const secret of ['WireGuard', '10.203.0.', 'accept-incoming', key(1), key(2), key(3)]) { + expect(output.content).not.toContain(secret); + } + }); + // ShadowRocket 不剥引号:带引号的策略名会匹配不到策略组,整条规则失效(真机与社区配置证实) it('含空格/emoji 的策略名一律裸写,不加引号', () => { const { content } = generateShadowrocketConf(ir); @@ -45,4 +63,36 @@ describe('generateShadowrocketConf', () => { expect(content).toContain('FINAL,🎯 NoAuto'); expect(content).toContain('🎯 NoAuto = select, 🇨🇳 Mainland, ⚡️ Automatic'); }); + + it('converts a domain set at the original priority without guessing native DOMAIN-SET support', () => { + const domainSet = { type: 'DOMAIN-SET', value: 'domain-set', policy: 'REJECT', params: [], + ruleset: { id: 'domain-set', name: 'domains', surgeUrl: 'https://example.com/domains.txt', clashUrl: null, + clashBehavior: 'domain', clashFormat: 'text', needsConvert: true } }; + const profile = { ...ir, rules: [ir.rules[0], domainSet, ir.rules[1]] }; + const result = generateShadowrocketConf(profile, { convertBaseUrl: 'https://example.com/sub/placeholder' }); + expect(result.content).toContain('DOMAIN-SUFFIX,xiaohongshu.com,🇨🇳 Mainland\n' + + 'RULE-SET,https://example.com/sub/placeholder/ruleset/domain-set.list,REJECT\nFINAL,🎯 NoAuto'); + expect(result.skippedRules).toEqual([]); + expect(generateShadowrocketConf(profile).skippedRules).toHaveLength(1); + }); + + it('expands the documented LAN set with original DNS resolution flags and skips only macOS app paths', () => { + const result = generateShadowrocketConf({ ...ir, rules: [ + { type: 'RULE-SET', value: 'LAN', policy: 'DIRECT', params: ['no-resolve'], ruleset: null }, + { type: 'PROCESS-NAME', value: '/Applications/Example.app/', policy: 'DIRECT', params: [], ruleset: null }, + { type: 'PROCESS-NAME', value: 'example', policy: 'DIRECT', params: [], ruleset: null }, ir.rules[1], + ] }); + expect(result.content).not.toContain('RULE-SET,LAN'); + expect(result.content).toContain('DOMAIN-SUFFIX,local,DIRECT,no-resolve'); + expect(result.content).toContain('IP-CIDR,100.64.0.0/10,DIRECT,no-resolve'); + expect(result.content).toContain('IP-CIDR6,fc00::/7,DIRECT,no-resolve'); + expect(result.content).toContain('PROCESS-NAME,example,DIRECT'); + expect(result.content).not.toContain('/Applications/'); + expect(result.skippedRules).toEqual(['PROCESS-NAME,/Applications/Example.app/,DIRECT']); + const plain = generateShadowrocketConf({ ...ir, rules: [ + { type: 'RULE-SET', value: 'LAN', policy: 'DIRECT', params: [], ruleset: null }, ir.rules[1], + ] }).content; + expect(plain).toContain('IP-CIDR,10.0.0.0/8,DIRECT\n'); + expect(plain).not.toContain('no-resolve'); + }); }); diff --git a/server/src/services/generators/shadowrocket-conf.ts b/server/src/services/generators/shadowrocket-conf.ts index 2916bcc..0b4e821 100644 --- a/server/src/services/generators/shadowrocket-conf.ts +++ b/server/src/services/generators/shadowrocket-conf.ts @@ -1,4 +1,5 @@ import type { ProfileIR } from '../ir.js'; +import { SHADOWROCKET_LAN_RULES } from './shadowrocket-lan.js'; /** * ShadowRocket 的 conf 语法没有引号机制:策略名一律裸写,含空格/emoji 均可; @@ -58,7 +59,7 @@ export interface ShadowrocketConfOutput { * 策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛选, * 因此订阅在 App 里叫什么名字都可以。 */ -export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput { +export function generateShadowrocketConf(ir: ProfileIR, opts: { convertBaseUrl?: string } = {}): ShadowrocketConfOutput { const lines: string[] = []; lines.push('# 由 Proxy Station 生成的 ShadowRocket 配置(策略组 + 规则)'); @@ -78,8 +79,8 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`); } // policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则 - if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`); - else if (g.includeAllNodes) parts.push('policy-regex-filter=.*'); + const filter = g.filterRegex || (g.includeAllNodes ? '.*' : null); + if (filter) parts.push(`policy-regex-filter=${filter}`); lines.push(`${policyName(g.name)} = ${parts.join(', ')}`); } @@ -90,6 +91,20 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput lines.push(['FINAL', policyName(r.policy)].join(',')); continue; } + if (r.type === 'DOMAIN-SET' && r.ruleset?.clashBehavior === 'domain' && opts.convertBaseUrl) { + const url = `${opts.convertBaseUrl}/ruleset/${encodeURIComponent(r.ruleset.id)}.list`; + lines.push(['RULE-SET', url, policyName(r.policy), ...r.params].join(',')); + continue; + } + if (r.type === 'RULE-SET' && !r.ruleset && r.value === 'LAN') { + lines.push(...SHADOWROCKET_LAN_RULES.map((rule) => [rule, policyName(r.policy), ...r.params].join(','))); + continue; + } + // An absolute macOS app-bundle path cannot identify an iOS application. + if (r.type === 'PROCESS-NAME' && /^\/Applications\/.*\.app(?:\/|$)/.test(r.value ?? '')) { + skippedRules.push(`${r.type},${r.value},${r.policy}`); + continue; + } if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) { skippedRules.push(`${r.type},${r.ruleset ? r.ruleset.name : r.value},${r.policy}`); continue; diff --git a/server/src/services/generators/shadowrocket-lan.ts b/server/src/services/generators/shadowrocket-lan.ts new file mode 100644 index 0000000..26c98ad --- /dev/null +++ b/server/src/services/generators/shadowrocket-lan.ts @@ -0,0 +1,21 @@ +/** + * Surge's documented LAN set expanded to ordinary Shadowrocket domain/CIDR rules. + * No guessed GEOIP,LAN or built-in RULE-SET,LAN syntax on the receiving client. + * Source: https://manual.nssurge.com/rules/ruleset.html#lan (reviewed 2026-10-13). + */ +export const SHADOWROCKET_LAN_RULES = [ + 'DOMAIN-SUFFIX,local', + 'IP-CIDR,0.0.0.0/8', + 'IP-CIDR,10.0.0.0/8', + 'IP-CIDR,100.64.0.0/10', + 'IP-CIDR,127.0.0.0/8', + 'IP-CIDR,169.254.0.0/16', + 'IP-CIDR,172.16.0.0/12', + 'IP-CIDR,192.0.0.0/24', + 'IP-CIDR,192.0.2.0/24', + 'IP-CIDR,192.168.0.0/16', + 'IP-CIDR,224.0.0.0/4', + 'IP-CIDR6,::1/128', + 'IP-CIDR6,fc00::/7', + 'IP-CIDR6,fe80::/10', +]; diff --git a/server/src/services/generators/surge-ffsb.test.ts b/server/src/services/generators/surge-ffsb.test.ts new file mode 100644 index 0000000..7e44c69 --- /dev/null +++ b/server/src/services/generators/surge-ffsb.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import type { GroupIR, ProfileIR } from '../ir.js'; +import { surgeFfsb, ffsbSettingsSchema } from './surge-ffsb.js'; +import { generateSurge } from './surge.js'; +import { generateClash } from './clash.js'; +import { generateShadowrocketConf } from './shadowrocket-conf.js'; +import domains from '../../../assets/ffsb-domains.json'; +const group = (name: string): GroupIR => ({ name, type: 'select', members: [{ kind: 'builtin', name: 'DIRECT' }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null }); +const config = { enabled: true, interface: 'utun100', dnsServer: '100.70.255.254', extraPolicy: '🧠 AI Nodes' }; +const fixture = (): ProfileIR => ({ + nodes: [], groups: [group('🦀 Claude'), group('🧠 AI Nodes')], + rules: [ + { type: 'DOMAIN-SUFFIX', value: 'claude.ai', policy: '🦀 Claude', params: [], ruleset: null }, + { type: 'DOMAIN', value: 'anthropic.auth0.com', policy: '🦀 Claude', params: [], ruleset: null }, + { type: 'IP-CIDR', value: '1.0.0.1/32', policy: '🦀 Claude', params: ['no-resolve'], ruleset: null }, + { type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null }, + ], + sections: { FFSB: JSON.stringify(config), General: 'always-real-ip = example.ts.net, api.anthropic.com', Host: 'example.ts.net = server:100.100.100.100' }, +}); +describe('manual FFSB Surge adapter', () => { + it('limits selectable FFSB scope to the audited 25 entries and preserves the source IR', () => { + const ir = fixture(); const before = JSON.stringify(ir); + const adapted = surgeFfsb(ir).ir; + expect(domains).toHaveLength(25); + expect(new Set(domains).size).toBe(25); + expect(adapted.rules.filter(r => r.policy === '🦀 Claude').map(r => r.value)).toEqual(domains); + expect(adapted.rules.find(r => r.value === 'downloads.claude.ai' && r.policy === '🦀 Claude')).toBeUndefined(); + expect(adapted.rules.find(r => r.type === 'DOMAIN-SUFFIX' && r.value === 'claude.ai')?.policy).toBe('Claude-Surge-Internal'); + expect(adapted.rules.find(r => r.value === '1.0.0.1/32')?.policy).toBe('Claude-Surge-Internal'); + expect(JSON.stringify(ir)).toBe(before); + }); + it('emits one visible manual Claude group and keeps DNS/external process routing scoped', () => { + const text = generateSurge(fixture(), { selfUrl: 'https://example.com/sub/test/surge' }).content; + expect(text).toContain('🦀 Claude = select, DIRECT, "🦁 FFSB"'); + expect(text).toContain('Claude-Surge-Internal = select, "🧠 AI Nodes", hidden=true'); + expect(text).not.toContain('Claude = fallback'); + expect(text).toContain('api.anthropic.com = server:100.70.255.254'); + expect(text).toContain('always-real-ip = example.ts.net'); + expect(text).toContain('example.ts.net = server:100.100.100.100'); + expect(text.indexOf('PROCESS-NAME,/Applications/FFSB.app/Contents/MacOS/ffsb-service,DIRECT')).toBeLessThan(text.indexOf('DOMAIN,claude.ai,')); + expect(text.match(/^FINAL,/gm)).toHaveLength(1); + }); + it('does not alter other client serializers or profiles without the opt-in', () => { + const ir = fixture(); const off = { ...ir, sections: { ...ir.sections, FFSB: '' } }; + expect(generateClash(ir, { convertBaseUrl: 'https://example.com' })).toEqual(generateClash(off, { convertBaseUrl: 'https://example.com' })); + expect(generateShadowrocketConf(ir)).toEqual(generateShadowrocketConf(off)); + expect(generateSurge(off, { selfUrl: 'https://example.com/surge' }).content).not.toContain('🦁 FFSB'); + }); + it('rejects invalid interface/DNS, name collisions, and extra-domain loops', () => { + expect(ffsbSettingsSchema.safeParse({ ...config, interface: 'utun100,foo=true' }).success).toBe(false); + expect(ffsbSettingsSchema.safeParse({ ...config, dnsServer: 'host.invalid' }).success).toBe(false); + const ir = fixture(); ir.groups.push(group('🦁 FFSB')); + expect(() => surgeFfsb(ir)).toThrow('conflicts'); + ir.groups.pop(); ir.groups[1].members = [{ kind: 'group', name: '🦀 Claude' }]; + expect(() => surgeFfsb(ir)).toThrow('must not reference'); + }); + it.skipIf(!existsSync('/Applications/Surge.app/Contents/Applications/surge-cli'))('passes the native Surge parser', () => { + const dir = mkdtempSync(join(tmpdir(), 'ffsb-native-')); + try { + const file = join(dir, 'test.conf'); + writeFileSync(file, generateSurge(fixture(), { selfUrl: 'https://example.com/sub/test/surge' }).content, { mode: 0o600 }); + const result = spawnSync('/Applications/Surge.app/Contents/Applications/surge-cli', ['--check', file], { encoding: 'utf8' }); + expect(result.status, result.stdout + result.stderr).toBe(0); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +}); diff --git a/server/src/services/generators/surge-ffsb.ts b/server/src/services/generators/surge-ffsb.ts new file mode 100644 index 0000000..ae15b90 --- /dev/null +++ b/server/src/services/generators/surge-ffsb.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; +import domains from '../../../assets/ffsb-domains.json'; +import type { ProfileIR } from '../ir.js'; +import { surgeValue } from './surge-format.js'; + +export const FFSB_POLICY = '🦁 FFSB'; +export const CLAUDE_GROUP = '🦀 Claude'; +export const FFSB_INTERNAL = 'Claude-Surge-Internal'; +export const ffsbSettingsSchema = z.object({ + enabled: z.boolean(), + interface: z.string().regex(/^utun\d+$/), + dnsServer: z.string().ip({ version: 'v4' }), + extraPolicy: z.string().min(1), +}).strict(); + +/** Surge-only adapter; the stored cross-client IR is never mutated. */ +export function surgeFfsb(ir: ProfileIR): { + ir: ProfileIR; proxyLines: string[]; hiddenGroups: string[]; extraMembers: Record; +} { + const empty = { ir, proxyLines: [], hiddenGroups: [], extraMembers: {} }; + if (!ir.sections.FFSB) return empty; + const config = ffsbSettingsSchema.parse(JSON.parse(ir.sections.FFSB)); + if (!config.enabled) return empty; + const claude = ir.groups.find(g => g.name === CLAUDE_GROUP); + if (!claude) return empty; + if (claude.type !== 'select') throw new Error('FFSB requires a manual Claude group'); + const extra = ir.groups.find(g => g.name === config.extraPolicy); + if (!extra || extra.name === CLAUDE_GROUP) throw new Error('FFSB requires an independent extra-domain group'); + const byName = new Map(ir.groups.map(g => [g.name, g])); + const reachesClaude = (name: string, seen = new Set()): boolean => { + if (name === CLAUDE_GROUP) return true; + if (seen.has(name)) return false; + seen.add(name); + return byName.get(name)?.members.some(m => m.kind === 'group' && reachesClaude(m.name, seen)) ?? false; + }; + if (reachesClaude(extra.name)) throw new Error('FFSB extra-domain group must not reference Claude'); + if ([...ir.nodes, ...ir.groups].some(x => [FFSB_POLICY, FFSB_INTERNAL].includes(x.name))) { + throw new Error('FFSB generated policy name conflicts with stored policy'); + } + const outer = '/Applications/FFSB.app/Contents/MacOS/ffsb-service'; + const rules = ir.rules.filter(r => !(r.type === 'PROCESS-NAME' && r.value === outer)); + const scope = domains.map(d => ({ + type: d.startsWith('*.') ? 'DOMAIN-WILDCARD' : 'DOMAIN', value: d, + policy: CLAUDE_GROUP, params: [], ruleset: null, + })); + // These domains must reach Surge as fake IPs, so OS host routes cannot bypass selection. + const general = (ir.sections.General ?? '').replace(/^always-real-ip\s*=\s*(.*)$/gmi, (_, values: string) => + 'always-real-ip = ' + values.split(',').map(v => v.trim()).filter(v => !domains.includes(v)).join(', ')); + const adapted: ProfileIR = { + ...ir, + groups: [...ir.groups, { ...extra, name: FFSB_INTERNAL, type: 'select', filterRegex: null, + includeAllNodes: false, members: [{ kind: 'group', name: extra.name }] }], + rules: [ + { type: 'PROCESS-NAME', value: outer, policy: 'DIRECT', params: [], ruleset: null }, + ...scope, + ...rules.map(r => r.policy === CLAUDE_GROUP ? { ...r, policy: FFSB_INTERNAL } : r), + ], + sections: { ...ir.sections, General: general, + Host: domains.map(d => `${d} = server:${config.dnsServer}`).join('\n') + '\n' + (ir.sections.Host ?? '') }, + }; + return { + ir: adapted, + proxyLines: [`${FFSB_POLICY} = direct, interface=${config.interface}, allow-other-interface=false, dns-follow-interface=true, test-url=https://api.ipify.org`], + hiddenGroups: [FFSB_INTERNAL], + extraMembers: { [CLAUDE_GROUP]: [surgeValue(FFSB_POLICY)] }, + }; +} diff --git a/server/src/services/generators/surge.ts b/server/src/services/generators/surge.ts index 3070026..39d8332 100644 --- a/server/src/services/generators/surge.ts +++ b/server/src/services/generators/surge.ts @@ -1,7 +1,9 @@ import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared'; import type { ProfileIR } from '../ir.js'; +import { surgeFfsb } from './surge-ffsb.js'; import { surgeAiDns } from './surge-ai-dns.js'; import { surgeDeclaration, surgeParam, surgeValue as quoteName } from './surge-format.js'; +import { WG_POLICY, WG_SECTION, surgeWireguardSection } from './wireguard.js'; export function surgeNodeLine(n: NodeDto): string { const parts: string[] = []; @@ -57,6 +59,11 @@ export interface SurgeOutput { } export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOutput { + if (ir.wireguard && [...ir.nodes, ...ir.groups].some(n => n.name === WG_POLICY)) { + throw new Error('WireGuard 组网策略名称与现有节点或组冲突'); + } + const ffsb = surgeFfsb(ir); + ir = ffsb.ir; const dns = surgeAiDns(ir); const excluded: ExcludedNode[] = []; const usable = ir.nodes.filter((n) => { @@ -74,6 +81,8 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu lines.push(dns.general); lines.push('', '[Proxy]'); + lines.push(...ffsb.proxyLines); + if (ir.wireguard) lines.push(`${WG_POLICY} = wireguard, section-name=${WG_SECTION}`); for (const n of usable) lines.push(surgeNodeLine(n)); lines.push('', '[Proxy Group]'); @@ -90,7 +99,9 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu } const sources = g.members.filter((m) => m.kind === 'group').map((m) => m.name); if (sources.length) parts.push(`include-other-group=${quoteName(sources.join(','))}`); - parts.push(`policy-regex-filter=${quoteName(g.filterRegex)}`); + // Keep the original regex's unanchored search semantics while excluding only the WG policy. + const filter = ir.wireguard ? `^(?!${WG_POLICY}$).*?(?:${g.filterRegex})` : g.filterRegex; + parts.push(`policy-regex-filter=${quoteName(filter)}`); } else if (g.includeAllNodes) { // 「全部节点」组:显式展开当前启用且 Surge 支持的节点 parts.push(...[...new Set([...g.members.filter((m) => m.kind !== 'node' || usableNames.has(m.name)).map((m) => m.name), ...usable.map((n) => n.name)])].map(quoteName)); @@ -98,6 +109,8 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu } else { parts.push(...(memberNames.length ? memberNames : ['DIRECT'])); } + parts.push(...(ffsb.extraMembers[g.name] ?? [])); + if (ffsb.hiddenGroups.includes(g.name)) parts.push('hidden=true'); if (g.type !== 'select') { // Surge uses per-node test-url or General.proxy-test-url, never group url=. if (g.interval) parts.push(`interval=${g.interval}`); @@ -107,6 +120,10 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu } lines.push('', '[Rule]'); + if (ir.wireguard) { + if (ir.wireguard.acceptIncoming) lines.push(`IP-CIDR,${ir.wireguard.address}/32,DIRECT,no-resolve`); + lines.push(`IP-CIDR,${ir.wireguard.subnet},${WG_POLICY},no-resolve`); + } lines.push(...dns.rules); for (const r of ir.rules.filter((r) => r.type !== 'FINAL')) { const value = r.ruleset ? r.ruleset.surgeUrl : r.value; @@ -123,5 +140,6 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu } } + if (ir.wireguard) lines.push('', surgeWireguardSection(ir.wireguard)); return { content: lines.join('\n') + '\n', excluded }; } diff --git a/server/src/services/generators/wireguard.ts b/server/src/services/generators/wireguard.ts new file mode 100644 index 0000000..6fa0c0f --- /dev/null +++ b/server/src/services/generators/wireguard.ts @@ -0,0 +1,19 @@ +import type { WireguardProfile } from '@proxy-station/shared'; + +export const WG_POLICY = 'NJCQ WireGuard LAN'; +export const WG_SECTION = 'njcq-lan'; + +export function generateWireguard(wg: WireguardProfile): string { + return ['# 每台设备使用自己的配置;仅路由私有组网地址。', '[Interface]', + `PrivateKey = ${wg.privateKey}`, `Address = ${wg.address}/32`, `MTU = ${wg.mtu}`, + '', '[Peer]', `PublicKey = ${wg.serverPublicKey}`, + ...(wg.presharedKey ? [`PresharedKey = ${wg.presharedKey}`] : []), + `Endpoint = ${wg.server}:${wg.port}`, `AllowedIPs = ${wg.subnet}`, + `PersistentKeepalive = ${wg.keepalive}`, ''].join('\n'); +} + +export function surgeWireguardSection(wg: WireguardProfile): string { + return [`[WireGuard ${WG_SECTION}]`, `private-key = ${wg.privateKey}`, `self-ip = ${wg.address}`, + `mtu = ${wg.mtu}`, ...(wg.acceptIncoming ? ['# 接受入站连接需要 Surge Mac 6.11.0+。', 'accept-incoming = true'] : []), + `peer = (public-key = ${wg.serverPublicKey}, allowed-ips = ${wg.subnet}, endpoint = ${wg.server}:${wg.port}, keepalive = ${wg.keepalive}${wg.presharedKey ? `, preshared-key = ${wg.presharedKey}` : ''})`].join('\n'); +} diff --git a/server/src/services/ir.ts b/server/src/services/ir.ts index 226dcd2..8c76f9d 100644 --- a/server/src/services/ir.ts +++ b/server/src/services/ir.ts @@ -1,7 +1,7 @@ import { asc, eq } from 'drizzle-orm'; import { db } from '../db/index.js'; import { groupMembers, nodes, policyGroups, rules, rulesets, settings } from '../db/schema.js'; -import { BUILTIN_POLICIES, type NodeDto } from '@proxy-station/shared'; +import { BUILTIN_POLICIES, type NodeDto, type WireguardProfile } from '@proxy-station/shared'; import { rowToDto } from '../routes/nodes.js'; export interface GroupIR { @@ -33,6 +33,7 @@ export interface RuleIR { } export interface ProfileIR { + wireguard?: WireguardProfile; nodes: NodeDto[]; // 仅启用节点,按 sortOrder groups: GroupIR[]; rules: RuleIR[]; // 仅启用规则,按 seq @@ -124,7 +125,7 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR { }); const sections: Record = {}; - for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) { + for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script', 'FFSB']) { const v = getSetting(`surge:${key}`); if (v) sections[key] = v; } diff --git a/server/src/services/rulesets/convert.test.ts b/server/src/services/rulesets/convert.test.ts index 991b1db..4d1a4bb 100644 --- a/server/src/services/rulesets/convert.test.ts +++ b/server/src/services/rulesets/convert.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import YAML from 'yaml'; -import { convertSurgeList } from './convert.js'; +import { convertSurgeList, domainSetToRuleList } from './convert.js'; describe('convertSurgeList', () => { it('classical:保留支持类型与 no-resolve,丢弃 USER-AGENT', () => { @@ -26,4 +26,15 @@ describe('convertSurgeList', () => { const result = convertSurgeList('example.com\n.cdn.example.com', 'domain'); expect(YAML.parse(result.yaml).payload).toEqual(['example.com', '+.cdn.example.com']); }); + + it('converts domain sets losslessly to ordinary rule lists without broadening exact hosts', () => { + const result = domainSetToRuleList('\uFEFF# comment\nexact.example.com\n.suffix.example.com\n// comment\n; comment\n'); + expect(result).toEqual({ content: 'DOMAIN,exact.example.com\nDOMAIN-SUFFIX,suffix.example.com\n', total: 2 }); + expect(domainSetToRuleList('# empty').content).toBe(''); + }); + + it.each(['+.example.com', '*.example.com', 'example.com,REJECT', 'https://example.com', '', + 'DOMAIN,example.com', 'example.com = 192.0.2.1', '.', 'example..com'])('rejects unknown domain-set syntax: %s', (line) => { + expect(() => domainSetToRuleList(`valid.example.com\n${line}`)).toThrow('invalid domain set entry at 2'); + }); }); diff --git a/server/src/services/rulesets/convert.ts b/server/src/services/rulesets/convert.ts index 6f3f8a9..c3f4596 100644 --- a/server/src/services/rulesets/convert.ts +++ b/server/src/services/rulesets/convert.ts @@ -1,5 +1,22 @@ import YAML from 'yaml'; +/** Exact DOMAIN-SET semantics from manual.nssurge.com/rules/domain.html. */ +export function domainSetToRuleList(content: string): { content: string; total: number } { + const entries = content.split(/\r?\n/).map((line) => line.trim()) + .filter((line) => line && !/^(#|\/\/|;)/.test(line)); + const rules = entries.map((entry, index) => { + const suffix = entry.startsWith('.'); + const domain = suffix ? entry.slice(1) : entry; + // Fail closed on unrecognized formats, rather than silently dropping coverage + // or accepting policy delimiters in an upstream resource. + if (!/^[\p{L}\p{N}_-]+(?:\.[\p{L}\p{N}_-]+)*\.?$/u.test(domain)) { + throw new Error(`invalid domain set entry at ${index + 1}`); + } + return `${suffix ? 'DOMAIN-SUFFIX' : 'DOMAIN'},${domain}`; + }); + return { content: rules.join('\n') + (rules.length ? '\n' : ''), total: entries.length }; +} + /** mihomo classical provider 支持的规则类型 */ const CLASSICAL_TYPES = new Set([ 'DOMAIN', diff --git a/server/src/services/wg-crypto.ts b/server/src/services/wg-crypto.ts new file mode 100644 index 0000000..865f145 --- /dev/null +++ b/server/src/services/wg-crypto.ts @@ -0,0 +1,54 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { env } from '../env.js'; + +// Back up this key together with SQLite. Never silently replace a lost key. +const initializedFiles = new Set(); +function masterKey(create = true): Buffer { + if (process.env.WG_MASTER_KEY) { + const key = Buffer.from(process.env.WG_MASTER_KEY, 'base64'); + if (key.length !== 32) throw new Error('WG_MASTER_KEY must contain 32 bytes'); + return key; + } + const file = path.join(env.dataDir, 'wg-master.key'); + fs.mkdirSync(env.dataDir, { recursive: true, mode: 0o700 }); + try { if (create && !initializedFiles.has(file)) fs.writeFileSync(file, crypto.randomBytes(32), { flag: 'wx', mode: 0o600 }); } + catch (e: any) { if (e.code !== 'EEXIST') throw e; } + const key = fs.readFileSync(file); + if (key.length !== 32) throw new Error('Invalid WireGuard encryption key'); + fs.chmodSync(file, 0o600); + initializedFiles.add(file); + return key; +} + +export function encryptSecret(value: unknown): string { + const nonce = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', masterKey(), nonce); + cipher.setAAD(Buffer.from('proxy-station/wireguard/v1')); + const data = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()]); + return ['v1', nonce.toString('base64'), cipher.getAuthTag().toString('base64'), data.toString('base64')].join(':'); +} + +export function decryptSecret(value: string): T { + const [version, nonce, tag, data] = value.split(':'); + if (version !== 'v1') throw new Error('Unknown WireGuard encryption format'); + const cipher = crypto.createDecipheriv('aes-256-gcm', masterKey(false), Buffer.from(nonce, 'base64')); + cipher.setAAD(Buffer.from('proxy-station/wireguard/v1')); + cipher.setAuthTag(Buffer.from(tag, 'base64')); + return JSON.parse(Buffer.concat([cipher.update(Buffer.from(data, 'base64')), cipher.final()]).toString()); +} + +export const credential = () => crypto.randomBytes(32).toString('base64url'); +export const hashCredential = (value: string) => crypto.createHash('sha256').update(value).digest('hex'); +export function publicKey(privateKey: string): string { + const key = crypto.createPrivateKey({ key: Buffer.concat([ + Buffer.from('302e020100300506032b656e04220420', 'hex'), Buffer.from(privateKey, 'base64'), + ]), format: 'der', type: 'pkcs8' }); + return crypto.createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64'); +} +export function newIdentity() { + const key = crypto.randomBytes(32); + key[0] &= 248; key[31] &= 127; key[31] |= 64; + return { privateKey: key.toString('base64'), presharedKey: crypto.randomBytes(32).toString('base64') }; +} diff --git a/server/src/services/wg-migrate.ts b/server/src/services/wg-migrate.ts new file mode 100644 index 0000000..223a4ec --- /dev/null +++ b/server/src/services/wg-migrate.ts @@ -0,0 +1,40 @@ +import { eq, isNotNull } from 'drizzle-orm'; +import { wireguardProfileSchema } from '@proxy-station/shared'; +import type { Db } from '../db/index.js'; +import { subTokens, wgDevices, wgGateways, wgJobs, wgNetworks } from '../db/schema.js'; +import { decryptSecret, encryptSecret, publicKey } from './wg-crypto.js'; + +/** Adopt only identities actually present in the old DB. No demo/device seeding or peer writes. */ +export function validateWireguardSecrets(database: Db) { + for (const device of database.select().from(wgDevices).all()) decryptSecret(device.secret); +} + +export function migrateWireguard(database: Db) { + database.transaction(tx => { + for (const token of tx.select().from(subTokens).where(isNotNull(subTokens.wireguard)).all()) { + if (!token.wireguard) throw new Error('Invalid empty legacy WireGuard profile'); + const profile = wireguardProfileSchema.parse(JSON.parse(token.wireguard)); + const key = publicKey(profile.privateKey); + let gateway = tx.select().from(wgGateways).where(eq(wgGateways.publicKey, profile.serverPublicKey)).get(); + if (!gateway) { + const id = `legacy-${token.id}`; + tx.insert(wgNetworks).values({ id, subnet: profile.subnet, mtu: profile.mtu }).run(); + gateway = { id, networkId: id, server: profile.server, port: profile.port, publicKey: profile.serverPublicKey, + agentHash: null, receivedAt: null, bootId: null }; + tx.insert(wgGateways).values(gateway).run(); + } + let device = tx.select().from(wgDevices).where(eq(wgDevices.publicKey, key)).get(); + if (device && (device.address !== profile.address || device.gatewayId !== gateway.id || JSON.stringify(decryptSecret(device.secret)) !== JSON.stringify(profile))) throw new Error('Conflicting legacy WireGuard identity'); + if (!device) { + device = { id: `legacy-${token.id}`, gatewayId: gateway.id, name: token.name, + os: profile.address === '10.203.0.3' ? 'macOS' : profile.address === '10.203.0.2' ? 'iPadOS' : '其他', + address: profile.address, publicKey: key, secret: encryptSecret(profile), desired: 'active', + version: 1, appliedVersion: 0, createdAt: token.createdAt, archivedAt: null }; + tx.insert(wgDevices).values(device).run(); + tx.insert(wgJobs).values({ id: `adopt-${token.id}`, deviceId: device.id, action: 'adopt', version: 1, + state: 'pending', createdAt: Date.now() }).run(); + } + tx.update(subTokens).set({ wireguard: null, wgDeviceId: device.id }).where(eq(subTokens.id, token.id)).run(); + } + }); +} diff --git a/server/src/services/wg.ts b/server/src/services/wg.ts new file mode 100644 index 0000000..32d16b4 --- /dev/null +++ b/server/src/services/wg.ts @@ -0,0 +1,177 @@ +import { and, asc, desc, eq, isNull } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { wireguardProfileSchema, type WireguardProfile, type WgDeviceDto } from '@proxy-station/shared'; +import { db } from '../db/index.js'; +import { subTokens, wgDevices, wgEvents, wgGateways, wgJobs, wgNetworks, wgObservations, wgSubscriptionBindings } from '../db/schema.js'; +import { credential, decryptSecret, encryptSecret, hashCredential, newIdentity, publicKey } from './wg-crypto.js'; +import { migrateWireguard } from './wg-migrate.js'; + +export class WgError extends Error { constructor(message: string, public status: 404 | 409 | 503 = 409) { super(message); } } +export const migrateLegacy = () => migrateWireguard(db); +export function getDevice(id: string) { + const row = db.select().from(wgDevices).where(eq(wgDevices.id, id)).get(); + if (!row) throw new WgError('设备不存在', 404); + return row; +} +export function profileFor(device: typeof wgDevices.$inferSelect) { + return wireguardProfileSchema.parse(decryptSecret(device.secret)); +} +export function canDistribute(device: typeof wgDevices.$inferSelect, legacy = false) { + const job = db.select().from(wgJobs).where(eq(wgJobs.deviceId, device.id)).orderBy(desc(wgJobs.version)).get(); + return device.desired === 'active' && (device.appliedVersion === device.version || + (legacy && device.version === 1 && job?.action === 'adopt' && job.state === 'pending')); +} +export function authorizedProfile(id: string, legacy = false) { + const device = getDevice(id); + if (!canDistribute(device, legacy)) throw new WgError('设备尚未生效、已暂停或已移除', 404); + const profile = profileFor(device); + // New device delivery always enables Surge inbound; legacy URLs retain their original setting. + return legacy ? profile : { ...profile, acceptIncoming: true }; +} +export function deviceDto(device: typeof wgDevices.$inferSelect, now = Date.now()): WgDeviceDto { + const observation = db.select().from(wgObservations).where(eq(wgObservations.deviceId, device.id)).get(); + const job = db.select().from(wgJobs).where(eq(wgJobs.deviceId, device.id)).orderBy(desc(wgJobs.version)).get(); + const fresh = !!observation && now - observation.receivedAt <= 45_000; + let status: WgDeviceDto['status']; + if (device.appliedVersion !== device.version) status = job?.state === 'failed' ? 'error' : + device.desired === 'active' ? (job?.action === 'resume' ? 'restoring' : 'provisioning') : 'revoking'; + else if (device.desired !== 'active') status = device.desired; + else if (!fresh) status = 'unknown'; + else if (!observation.handshakeAt && !observation.activityAt) status = 'pending'; + else status = now - Math.max(observation.handshakeAt ?? 0, observation.activityAt ?? 0) <= 180_000 ? 'online' : 'offline'; + return { id: device.id, gatewayId: device.gatewayId, name: device.name, os: device.os, address: device.address, + publicKey: device.publicKey, status, canDistribute: canDistribute(device), createdAt: device.createdAt, + sampledAt: observation?.receivedAt ?? null, lastActivityAt: observation?.activityAt ?? observation?.handshakeAt ?? null, + uploadRate: fresh && status !== 'paused' ? observation.uploadRate : null, + downloadRate: fresh && status !== 'paused' ? observation.downloadRate : null, + uploadTotal: observation?.uploadTotal ?? 0, downloadTotal: observation?.downloadTotal ?? 0, + job: job ? { id: job.id, action: job.action, state: job.state, error: job.error } : null }; +} +export function listDevices() { migrateLegacy(); return db.select().from(wgDevices).where(isNull(wgDevices.archivedAt)).orderBy(asc(wgDevices.createdAt)).all().map(d => deviceDto(d)); } +function event(id: string, message: string) { db.insert(wgEvents).values({ id: nanoid(), deviceId: id, message, createdAt: Date.now() }).run(); } +export function createDevice(input: { name: string; os: string; requestId: string; gatewayId?: string }) { + migrateLegacy(); + return db.transaction(() => { + // Stable client request ID survives network timeout/double submission. + const prior = db.select().from(wgDevices).where(eq(wgDevices.id, input.requestId)).get(); + if (prior) return deviceDto(prior); + if (db.select().from(wgDevices).where(and(eq(wgDevices.name, input.name), isNull(wgDevices.archivedAt))).get()) throw new WgError('已有同名设备,请用位置或用途区分'); + const gateways = db.select().from(wgGateways).all(); + const gateway = input.gatewayId ? gateways.find(g => g.id === input.gatewayId) : gateways.length === 1 ? gateways[0] : null; + if (!gateway?.agentHash) throw new WgError('请先配置网关及独立 agent 凭证', 503); + const network = db.select().from(wgNetworks).where(eq(wgNetworks.id, gateway.networkId)).get()!; + if (network.subnet !== '10.203.0.0/24') throw new WgError('当前仅开放 njcq 设备网络'); + const used = new Set(db.select().from(wgDevices).all().map(d => d.address)); + // .2/.3 reserved for the existing iPad/Mac even before adoption; never recycle archives. + const address = Array.from({ length: 250 }, (_, i) => `10.203.0.${i + 4}`).find(ip => !used.has(ip)); + if (!address) throw new WgError('设备地址已用尽'); + const profile = wireguardProfileSchema.parse({ server: gateway.server, port: gateway.port, serverPublicKey: gateway.publicKey, + ...newIdentity(), address, subnet: network.subnet, mtu: network.mtu, acceptIncoming: true }); + const device = { id: input.requestId, gatewayId: gateway.id, name: input.name, os: input.os, address, + publicKey: publicKey(profile.privateKey), secret: encryptSecret(profile), desired: 'active' as const, + version: 1, appliedVersion: 0, createdAt: Date.now(), archivedAt: null }; + db.insert(wgDevices).values(device).run(); + db.insert(wgJobs).values({ id: nanoid(), deviceId: device.id, action: 'create', version: 1, state: 'pending', createdAt: Date.now() }).run(); + event(device.id, '已预留固定 IP 和身份,等待网关确认'); + return deviceDto(device); + }); +} +export function deviceAction(id: string, action: 'pause' | 'resume' | 'rotate' | 'remove' | 'retry') { + return db.transaction(() => { + const device = getDevice(id); + const previous = db.select().from(wgJobs).where(eq(wgJobs.deviceId, id)).orderBy(desc(wgJobs.version)).get(); + if (device.archivedAt) throw new WgError('设备已移除'); + if (device.appliedVersion !== device.version) { + if (previous?.action === action) return deviceDto(device); + if (action !== 'retry') throw new WgError('请先完成或重试当前网关任务'); + db.update(wgJobs).set({ state: 'pending', error: null }).where(eq(wgJobs.id, previous!.id)).run(); + return deviceDto(device); + } + if (action === 'retry') return deviceDto(device); + if (action === 'resume' && device.desired !== 'paused') throw new WgError('设备未暂停'); + if (action === 'pause' && device.desired === 'paused') return deviceDto(device); + if (action === 'rotate' && device.desired !== 'active') throw new WgError('请先恢复设备'); + const desired = action === 'pause' ? 'paused' : action === 'remove' ? 'archived' : 'active'; + const patch = action === 'rotate' ? (() => { + const profile = { ...profileFor(device), ...newIdentity() }; + return { publicKey: publicKey(profile.privateKey), secret: encryptSecret(profile) }; + })() : {}; + db.update(wgDevices).set({ ...patch, desired, version: device.version + 1 }).where(eq(wgDevices.id, id)).run(); + db.insert(wgJobs).values({ id: nanoid(), deviceId: id, action, version: device.version + 1, state: 'pending', + oldPublicKey: action === 'rotate' ? device.publicKey : null, createdAt: Date.now() }).run(); + event(id, `已请求 ${action},等待运行与持久配置确认`); + return deviceDto(getDevice(id)); + }); +} +export function issueBinding(tokenId: string, deviceId: string, purpose: 'surge' | 'clash' = 'surge') { + const token = db.select().from(subTokens).where(eq(subTokens.id, tokenId)).get(); + if (!token?.enabled) throw new WgError('订阅不存在或已停用', 404); + authorizedProfile(deviceId); + const secret = credential(); const id = nanoid(); + db.insert(wgSubscriptionBindings).values({ id, tokenId, deviceId, credentialHash: hashCredential(secret), + purpose, createdAt: Date.now() }).run(); + return { id, deviceId, path: `/sub/${token.token}/${purpose}?wg=${secret}` }; +} +export function bindingProfile(tokenId: string, secret: string, purpose: 'surge' | 'clash' = 'surge') { + if (!/^[\w-]{43}$/.test(secret)) throw new WgError('设备凭证无效', 404); + const binding = db.select().from(wgSubscriptionBindings).where(and(eq(wgSubscriptionBindings.credentialHash, hashCredential(secret)), + eq(wgSubscriptionBindings.tokenId, tokenId), eq(wgSubscriptionBindings.purpose, purpose), isNull(wgSubscriptionBindings.revokedAt))).get(); + if (!binding) throw new WgError('设备凭证无效', 404); + const profile = authorizedProfile(binding.deviceId); + db.update(wgSubscriptionBindings).set({ lastAccessAt: Date.now() }).where(eq(wgSubscriptionBindings.id, binding.id)).run(); + return profile; +} + +// Agent requests contain public keys/PSKs but never client private keys. +export function gatewayTasks(gatewayId: string) { + const jobs = db.select().from(wgJobs).innerJoin(wgDevices, eq(wgJobs.deviceId, wgDevices.id)) + .where(and(eq(wgDevices.gatewayId, gatewayId), eq(wgJobs.state, 'pending'))).all(); + return jobs.map(({ wg_jobs: j, wg_devices: d }) => ({ id: j.id, deviceId: d.id, action: j.action, + version: j.version, publicKey: d.publicKey, oldPublicKey: j.oldPublicKey, + address: d.address, presharedKey: profileFor(d).presharedKey ?? null, desired: d.desired })); +} +export interface AgentReport { + bootId: string; + receipts: { id: string; version: number; runtime: boolean; persistent: boolean; error?: string }[]; + peers: { publicKey: string; handshakeAge: number | null; rx: number; tx: number }[]; +} +export function receiveReport(gatewayId: string, report: AgentReport) { + const now = Date.now(); + db.transaction(() => { + db.update(wgGateways).set({ receivedAt: now, bootId: report.bootId }).where(eq(wgGateways.id, gatewayId)).run(); + for (const receipt of report.receipts) { + const job = db.select().from(wgJobs).where(eq(wgJobs.id, receipt.id)).get(); + if (!job || job.state === 'confirmed' || job.version !== receipt.version) continue; + const device = getDevice(job.deviceId); + if (device.gatewayId !== gatewayId || device.version !== receipt.version) continue; + const ok = receipt.runtime && receipt.persistent && !receipt.error; + db.update(wgJobs).set({ state: ok ? 'confirmed' : 'failed', error: ok ? null : '网关配置未确认,请重试任务', + confirmedAt: ok ? now : null }).where(eq(wgJobs.id, job.id)).run(); + if (ok) { + db.update(wgDevices).set({ appliedVersion: job.version, ...(device.desired === 'archived' ? { archivedAt: now } : {}) }) + .where(eq(wgDevices.id, device.id)).run(); + if (device.desired === 'archived') db.update(wgSubscriptionBindings).set({ revokedAt: now }).where(eq(wgSubscriptionBindings.deviceId, device.id)).run(); + if (job.action === 'rotate') db.delete(wgObservations).where(eq(wgObservations.deviceId, device.id)).run(); + } + event(device.id, ok ? '网关确认:运行配置与持久配置均已生效' : '网关未确认配置,保留当前身份与任务供重试'); + } + const peers = new Map(report.peers.map(p => [p.publicKey, p])); + for (const device of db.select().from(wgDevices).where(eq(wgDevices.gatewayId, gatewayId)).all()) { + const peer = peers.get(device.publicKey); + if (!peer || device.desired !== 'active') continue; + const old = db.select().from(wgObservations).where(eq(wgObservations.deviceId, device.id)).get(); + const elapsed = old ? (now - old.receivedAt) / 1000 : 0; + const sameEpoch = old?.bootId === report.bootId && peer.rx >= old.rx && peer.tx >= old.tx; + const up = sameEpoch ? peer.rx - old!.rx : 0; // gateway rx = device upload + const down = sameEpoch ? peer.tx - old!.tx : 0; + const fresh = elapsed > 0 && elapsed <= 45; + const handshakeAt = peer.handshakeAge === null ? old?.handshakeAt ?? null : now - peer.handshakeAge * 1000; + const activityAt = (up || down) ? now : Math.max(old?.activityAt ?? 0, handshakeAt ?? 0) || null; + const row = { deviceId: device.id, receivedAt: now, bootId: report.bootId, handshakeAt, activityAt, + rx: peer.rx, tx: peer.tx, uploadRate: fresh && sameEpoch ? Math.round(up / elapsed) : null, + downloadRate: fresh && sameEpoch ? Math.round(down / elapsed) : null, + uploadTotal: (old?.uploadTotal ?? 0) + up, downloadTotal: (old?.downloadTotal ?? 0) + down }; + db.insert(wgObservations).values(row).onConflictDoUpdate({ target: wgObservations.deviceId, set: row }).run(); + } + }); +} diff --git a/shared/src/dto.ts b/shared/src/dto.ts index 7704466..3a19fff 100644 --- a/shared/src/dto.ts +++ b/shared/src/dto.ts @@ -95,12 +95,14 @@ export interface TokenDto { name: string; /** 分配给该订阅的节点 id;空数组 = 全部节点 */ nodeIds: string[]; + /** 旧设备专属订阅兼容映射;不返回设备密钥。 */ + wgDeviceId?: string | null; enabled: boolean; createdAt: number; lastAccessAt: number | null; accessCount: number; /** 各客户端订阅路径;ShadowRocket 额外有 shadowrocketConf(策略组与规则) */ - urls: Record & { shadowrocketConf: string }; + urls: Record & { shadowrocketConf: string; wireguard?: string }; } export interface ExcludedNode { @@ -117,3 +119,16 @@ export interface PreviewResult { export interface SettingsDto { surgeSections: Record; } + +export interface WgDeviceDto { + id: string; gatewayId: string; name: string; os: string; address: string; publicKey: string; + status: 'provisioning' | 'restoring' | 'revoking' | 'error' | 'pending' | 'online' | 'offline' | 'unknown' | 'paused' | 'archived'; + canDistribute: boolean; createdAt: number; sampledAt: number | null; lastActivityAt: number | null; + uploadRate: number | null; downloadRate: number | null; uploadTotal: number; downloadTotal: number; + job: { id: string; action: string; state: string; error: string | null } | null; +} +export interface WgGatewayDto { + id: string; server: string; port: number; publicKey: string; subnet: string; mtu: number; + configured: boolean; receivedAt: number | null; +} +export interface WgBindingDto { id: string; deviceId: string; path: string } diff --git a/shared/src/index.ts b/shared/src/index.ts index 5a28e44..f1c730d 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './protocols.js'; export * from './schemas.js'; export * from './dto.js'; +export * from './wireguard.js'; export { splitRuleExpression } from './rule-syntax.js'; diff --git a/shared/src/protocols.ts b/shared/src/protocols.ts index d3e367f..575b8e2 100644 --- a/shared/src/protocols.ts +++ b/shared/src/protocols.ts @@ -7,7 +7,7 @@ export type ClientType = (typeof CLIENTS)[number]; export const CLIENT_LABELS: Record = { surge: 'Surge (macOS)', shadowrocket: 'ShadowRocket (iOS)', - clash: 'ClashMeta (Android)', + clash: 'FlClash', }; export type Egress = 'direct' | 'warp' | 'unknown'; diff --git a/shared/src/wireguard.ts b/shared/src/wireguard.ts new file mode 100644 index 0000000..ae4ebfb --- /dev/null +++ b/shared/src/wireguard.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +const key = z.string().regex(/^[A-Za-z0-9+/]{43}=$/, 'WireGuard 密钥必须为 32 字节 Base64'); +const ipv4 = z.string().ip({ version: 'v4' }); +const ipNumber = (ip: string) => ip.split('.').reduce((n, v) => n * 256 + Number(v), 0); + +/** Device-specific private-network access; never an Internet proxy node. */ +export const wireguardProfileSchema = z.object({ + server: z.string().min(1).max(253).regex(/^[a-zA-Z0-9.-]+$/), + port: z.number().int().min(1).max(65535), + serverPublicKey: key, + privateKey: key, + presharedKey: key.optional(), + address: ipv4, + subnet: z.string().regex(/^\d+\.\d+\.\d+\.\d+\/\d+$/), + mtu: z.number().int().min(576).max(1420).default(1280), + keepalive: z.number().int().min(0).max(65535).default(25), + acceptIncoming: z.boolean().default(false), +}).strict().superRefine((v, ctx) => { + const [network, bits] = v.subnet.split('/'); + const prefix = Number(bits); + if (!ipv4.safeParse(network).success || prefix < 8 || prefix > 30) { + ctx.addIssue({ code: 'custom', path: ['subnet'], message: '组网需要有效的 IPv4 私网网段(/8–/30)' }); + return; + } + const size = 2 ** (32 - prefix); + const base = ipNumber(network); + const address = ipNumber(v.address); + const privateRange = [[167772160, 184549375], [2886729728, 2887778303], [3232235520, 3232301055]] + .some(([lo, hi]) => base >= lo && base + size - 1 <= hi); + if (!privateRange || base % size !== 0 || address <= base || address >= base + size - 1) { + ctx.addIssue({ code: 'custom', path: ['subnet'], message: '仅允许 RFC1918 私网,设备地址必须是该网段的有效主机地址' }); + } +}); + +export type WireguardProfile = z.output; diff --git a/web/package.json b/web/package.json index a8de8c8..2b33e0b 100644 --- a/web/package.json +++ b/web/package.json @@ -13,10 +13,12 @@ "@proxy-station/shared": "workspace:*", "naive-ui": "^2.40.4", "pinia": "^2.3.0", + "qrcode": "^1.5.4", "vue": "^3.5.13", "vue-router": "^4.5.0" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "@vitejs/plugin-vue": "^5.2.1", "typescript": "^5.7.2", "vite": "^6.0.6", diff --git a/web/src/App.vue b/web/src/App.vue index 104c9a9..c674277 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -25,6 +25,7 @@ const navItems = [ { path: '/nodes', label: '节点' }, { path: '/groups', label: '策略组' }, { path: '/rules', label: '规则' }, + { path: '/devices', label: '设备' }, { path: '/subscriptions', label: '订阅' }, { path: '/settings', label: '设置' }, ]; @@ -132,6 +133,7 @@ const active = computed(() => route.path); display: flex; gap: 26px; flex: 1; + min-width: 0; } /* 选中 = 粗下划线(站台线条),不加底不加边 */ @@ -145,6 +147,8 @@ const active = computed(() => route.path); border-bottom: 3px solid transparent; border-top: 3px solid transparent; cursor: pointer; + white-space: nowrap; + flex-shrink: 0; transition: color 0.1s; } diff --git a/web/src/pages/DevicesPage.vue b/web/src/pages/DevicesPage.vue new file mode 100644 index 0000000..54b26d3 --- /dev/null +++ b/web/src/pages/DevicesPage.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/web/src/pages/SettingsPage.vue b/web/src/pages/SettingsPage.vue index 320bb8d..647a3e2 100644 --- a/web/src/pages/SettingsPage.vue +++ b/web/src/pages/SettingsPage.vue @@ -35,7 +35,7 @@ async function saveSections() {

- 这些段落按原文透传进 Surge 配置([General]、[MITM] 等),此处直接编辑文本。ShadowRocket/ClashMeta 不使用这些内容。 + 这些段落按原文透传进 Surge 配置([General]、[MITM] 等),此处直接编辑文本。ShadowRocket/FlClash 不使用这些内容。

diff --git a/web/src/pages/SubscriptionsPage.vue b/web/src/pages/SubscriptionsPage.vue index 19c6a52..ad401d8 100644 --- a/web/src/pages/SubscriptionsPage.vue +++ b/web/src/pages/SubscriptionsPage.vue @@ -1,7 +1,10 @@