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

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

236 lines
9.8 KiB
Python

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