feat: 添加 WireGuard 设备管理并完善客户端分流

实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。

完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
This commit is contained in:
YANG JIANKUAN
2026-11-02 22:44:23 +08:00
parent f741aeeccd
commit a5d980a5e9
80 changed files with 4777 additions and 51 deletions

235
scripts/wireguard/agent.py Normal file
View 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()

View 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

View 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()