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()
|
||||
235
scripts/wireguard/agent.py
Normal file
235
scripts/wireguard/agent.py
Normal file
@@ -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()
|
||||
19
scripts/wireguard/proxy-station-wg-agent.service
Normal file
19
scripts/wireguard/proxy-station-wg-agent.service
Normal file
@@ -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
|
||||
85
scripts/wireguard/test_agent.py
Normal file
85
scripts/wireguard/test_agent.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user