feat: 添加 WireGuard 设备管理并完善客户端分流
实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。 完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user