241 lines
12 KiB
Python
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())
|