Files
proxy-station/scripts/ffsb/reconcile.py
YANG JIANKUAN a5d980a5e9 feat: 添加 WireGuard 设备管理并完善客户端分流
实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。

完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
2026-11-02 22:44:23 +08:00

97 lines
4.6 KiB
Python

#!/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)