实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。 完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
97 lines
4.6 KiB
Python
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)
|