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