feat: 添加 WireGuard 设备管理并完善客户端分流
实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。 完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
This commit is contained in:
40
scripts/ffsb/README.md
Normal file
40
scripts/ffsb/README.md
Normal file
@@ -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 等不可识别目标无法保证按域名分类。真实应用认证、长流、睡眠/网络切换及完整启动顺序的覆盖状态以验收报告为准。
|
||||
64
scripts/ffsb/overlay.py
Normal file
64
scripts/ffsb/overlay.py
Normal file
@@ -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()
|
||||
96
scripts/ffsb/reconcile.py
Normal file
96
scripts/ffsb/reconcile.py
Normal file
@@ -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)
|
||||
53
scripts/ffsb/test_overlay.py
Normal file
53
scripts/ffsb/test_overlay.py
Normal file
@@ -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()
|
||||
60
scripts/ffsb/test_reconcile.py
Normal file
60
scripts/ffsb/test_reconcile.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user