dpl/scripts/node-onboarding/verify-node.py
hellcat 6af676421b
Some checks are pending
Docker Build and Publish / build (push) Waiting to run
Add append-only batch node onboarding and recovery checks
2026-10-17 08:29:05 +08:00

241 lines
12 KiB
Python

#!/usr/bin/env python3
"""Validate private subscriptions; network execution requires explicit --run.
For a formal multi-node subscription use a private --expectations JSON array:
[{"tag": "exact outbound tag", "host": "relay.example", "port": 12345,
"sni": "node.example", "uuid": "private password", "expected_exit": "192.0.2.1",
"expected_country": "JP"}]. Never print credentials or the subscription.
"""
import argparse
import copy
import ipaddress
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import tempfile
import time
class VerificationError(Exception):
pass
def require(condition, message):
if not condition:
raise VerificationError(message)
def read_private(path):
p = Path(path)
require(p.is_file() and not p.is_symlink(), 'Private input must be a regular file')
require(p.stat().st_mode & 0o077 == 0, 'Private input must have no group/other permissions')
return json.loads(p.read_text())
def validate(config, expectations, exact=True):
require(isinstance(config, dict) and isinstance(config.get('outbounds'), list), 'Missing outbounds')
all_tags = [o.get('tag') for o in config['outbounds'] if isinstance(o, dict)]
require(len(all_tags) == len(config['outbounds']) and all(isinstance(t, str) and t for t in all_tags), 'Each outbound needs a tag')
require(len(set(all_tags)) == len(all_tags), 'Duplicate outbound tags')
nodes = {o['tag']: o for o in config['outbounds'] if o.get('type') == 'anytls'}
require(isinstance(expectations, list) and expectations, 'Nonempty expectations required')
selected = []
for spec in expectations:
require(isinstance(spec, dict), 'Invalid expectation')
for key in ['tag', 'host', 'sni', 'uuid', 'expected_exit', 'expected_country']:
require(isinstance(spec.get(key), str) and bool(spec[key]), 'Expectation missing required field')
require(isinstance(spec.get('port'), int) and not isinstance(spec['port'], bool) and 1 <= spec['port'] <= 65535, 'Invalid relay port')
try:
ipaddress.ip_address(spec['expected_exit'])
except ValueError as exc:
raise VerificationError('Invalid expected exit IP') from exc
require(len(spec['expected_country']) == 2 and spec['expected_country'].isalpha() and spec['expected_country'].isupper(), 'Expected country must be two uppercase letters')
tag = spec['tag']
require(tag in nodes and tag not in selected, 'Missing/duplicate selected node')
node = nodes[tag]
tls = node.get('tls') or {}
require(tls.get('enabled') is True and tls.get('insecure') is False, 'Strict TLS must be explicit')
require(node.get('server') == spec['host'] and node.get('server_port') == spec['port'], 'Relay endpoint mismatch')
require(tls.get('server_name') == spec['sni'], 'SNI mismatch')
require(node.get('password') == spec['uuid'], 'Subscription password mismatch')
selected.append(tag)
if exact:
require(set(selected) == set(nodes), 'Expectations must cover every AnyTLS node exactly')
return selected
def probe_config(config, spec, port):
result = copy.deepcopy(config)
result['inbounds'] = [{'type': 'mixed', 'listen': '127.0.0.1', 'listen_port': port}]
result['log'] = {'level': 'error'}
# Preserve DNS, remote rulesets and all original outbounds. Only the two
# diagnostic destinations are pinned; ordinary subscription routing remains.
route = result.setdefault('route', {})
rules = route.setdefault('rules', [])
require(isinstance(rules, list), 'Invalid route rules')
rules.insert(0, {'domain': ['api.ipify.org', 'www.cloudflare.com'],
'action': 'route', 'outbound': spec['tag']})
return result
def stop(proc):
if proc is None or proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
def run_node(config, spec, binary, port, diagnostic_dir):
require(1024 <= port <= 65535, 'Invalid local SOCKS port')
with socket.socket() as free:
try:
free.bind(('127.0.0.1', port))
except OSError as exc:
raise VerificationError('Local SOCKS port is occupied') from exc
with tempfile.TemporaryDirectory(prefix='dpl-client-', dir=diagnostic_dir) as tmp:
temporary = Path(tmp)
path = temporary / 'client.json'
path.write_text(json.dumps(probe_config(config, spec, port)))
path.chmod(0o600)
env = dict(os.environ, GOMEMLIMIT='64MiB', GOGC='75')
proc = None
log_path = temporary / 'client.log'
try:
with log_path.open('wb') as log:
subprocess.run([binary, 'check', '-c', str(path)], env=env,
cwd=tmp, stdout=log, stderr=log, check=True, timeout=30)
proc = subprocess.Popen([binary, 'run', '-c', str(path)], env=env,
cwd=tmp, stdout=log, stderr=log)
ready = False
for _ in range(100):
require(proc.poll() is None, 'Client exited during startup')
try:
with socket.create_connection(('127.0.0.1', port), 0.1):
ready = True
break
except OSError:
time.sleep(0.1)
require(ready, 'Client startup timed out')
command = ['curl', '-q', '--fail', '--silent', '--show-error', '--max-time', '30',
'--socks5-hostname', f'127.0.0.1:{port}', '--noproxy', '']
ip = subprocess.run(command + ['https://api.ipify.org'], capture_output=True,
text=True, check=True, timeout=35).stdout.strip()
require(ipaddress.ip_address(ip) == ipaddress.ip_address(spec['expected_exit']), 'Unexpected exit IP')
response = subprocess.run(command + ['https://www.cloudflare.com/cdn-cgi/trace'],
capture_output=True, text=True, check=True, timeout=35)
trace = dict(line.split('=', 1) for line in response.stdout.splitlines() if '=' in line)
require(trace.get('ip') == ip, 'Second exit check mismatch')
require(trace.get('loc') == spec['expected_country'], 'Exit country mismatch')
require(trace.get('tls', '').startswith('TLSv'), 'Target HTTPS TLS not observed')
return {'tag': spec['tag'], 'strict_tls': True, 'exit_ip': ip,
'country': trace['loc'], 'https_tls': trace['tls'], 'live': True,
'relay': spec['host'], 'relay_port': spec['port'], 'sni': spec['sni']}
except Exception:
# Logs may contain private config context; retain only in private task directory.
stop(proc)
if log_path.exists():
fd, name = tempfile.mkstemp(prefix='client-failure-', suffix='.log', dir=diagnostic_dir)
os.close(fd)
failure = Path(name)
shutil.copyfile(log_path, failure)
failure.chmod(0o600)
raise
finally:
stop(proc)
def write_report(path, report):
destination = Path(path)
require(destination.parent.is_dir() and destination.parent.stat().st_mode & 0o077 == 0,
'Report directory must be private')
require(not destination.is_symlink(), 'Refusing report symlink')
with tempfile.NamedTemporaryFile(mode='w', dir=destination.parent, delete=False) as stream:
temporary = Path(stream.name)
try:
os.chmod(temporary, 0o600)
json.dump(report, stream, ensure_ascii=False)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, destination)
finally:
if temporary.exists():
temporary.unlink()
def main():
parser = argparse.ArgumentParser(description=__doc__)
sources = parser.add_mutually_exclusive_group(required=True)
sources.add_argument('--qa')
sources.add_argument('--expectations')
parser.add_argument('--subscription', required=True)
parser.add_argument('--binary')
parser.add_argument('--expected-exit')
parser.add_argument('--expected-country')
parser.add_argument('--target-tag')
parser.add_argument('--report', help='Private JSON result file; required for --run')
parser.add_argument('--port', type=int, default=19080)
execution = parser.add_mutually_exclusive_group()
execution.add_argument('--run', action='store_true')
execution.add_argument('--validate-only', action='store_true')
args = parser.parse_args()
os.umask(0o077)
try:
require(not args.run or args.report, '--run requires a private --report destination')
if args.report:
destination = Path(args.report)
inputs = [args.subscription, args.qa, args.expectations]
require(all(destination.resolve() != Path(item).resolve() for item in inputs if item), 'Report must not overwrite a private input')
require(destination.parent.is_dir() and destination.parent.stat().st_mode & 0o077 == 0 and not destination.is_symlink(), 'Private regular report destination required')
if args.report:
write_report(args.report, {'state': 'verification_pending', 'verified_at': None})
config = read_private(args.subscription)
qa_user_id = None
source = args.expectations or args.qa
if args.expectations:
require(not any([args.expected_exit, args.expected_country, args.target_tag]), 'Put each node expectation in the private expectations file')
specs = read_private(args.expectations)
else:
qa = read_private(args.qa)
qa_user_id = qa.get('user_id')
require(args.expected_exit and args.expected_country, 'QA requires explicit expected-exit and expected-country')
candidates = [n for n in config.get('outbounds', []) if n.get('type') == 'anytls'
and n.get('server') == qa.get('host') and n.get('server_port') == qa.get('port')
and (not args.target_tag or n.get('tag') == args.target_tag)]
require(len(candidates) == 1, 'QA target ambiguous/missing; supply exact target-tag')
specs = [{'tag': candidates[0]['tag'], 'host': qa.get('host'), 'port': qa.get('port'),
'sni': qa.get('sni'), 'uuid': qa.get('uuid'), 'expected_exit': args.expected_exit,
'expected_country': args.expected_country}]
tags = validate(config, specs, exact=bool(args.expectations))
results = []
if args.run:
require(args.binary and Path(args.binary).is_file(), 'Explicit existing client binary required')
binary = str(Path(args.binary).resolve())
folder = Path(source).resolve().parent
require(folder.stat().st_mode & 0o077 == 0, 'Live diagnostics directory must be private')
for spec in specs:
results.append(run_node(config, spec, binary, args.port, str(folder)))
report = {'state': 'live_verified' if args.run else 'structure_validated',
'verified_at': int(time.time()) if args.run else None,
'qa_user_id': qa_user_id, 'selected_nodes': tags, 'results': results,
'scope': 'Full subscription retained; only diagnostic destination routes pinned. No traffic/account/GUI acceptance implied.'}
if args.report:
write_report(args.report, report)
print(json.dumps(report, ensure_ascii=False))
return 0
except (Exception, KeyboardInterrupt) as exc:
# No traceback or subprocess stderr: either can contain credentials.
message = str(exc) if isinstance(exc, VerificationError) else 'Verification failed; inspect protected diagnostics'
print(json.dumps({'state': 'failed', 'reason': message}), file=__import__('sys').stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())