dpl/scripts/node-onboarding/acceptance.py
hellcat 5c9157f5d7
Some checks are pending
Docker Build and Publish / build (push) Waiting to run
feat: add reusable jump-host node onboarding runtime
2026-10-17 15:22:30 +08:00

94 lines
5.5 KiB
Python

"""Token-free dynamic evidence validation shared by the reusable site adapter."""
import hashlib
import ipaddress
import re
import json
import time
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest()
def snapshot(nodes):
if not isinstance(nodes, list) or not nodes:
raise ValueError('Empty authorized node set')
fields = ('id', 'tag', 'relay', 'relay_port', 'sni', 'expected_exit', 'country')
result = []
for node in nodes:
if node.get('type') != 'anytls' or any(node.get(k) in (None, '') for k in fields):
raise ValueError('Unsupported protocol or incomplete independently registered expectations')
if type(node['id']) is not int or node['id']<1 or type(node['relay_port']) is not int or not 1<=node['relay_port']<=65535 or not isinstance(node['country'],str) or not re.fullmatch('[A-Z]{2}',node['country']):
raise ValueError('Invalid authorized node identity or expectation')
address=ipaddress.ip_address(node['expected_exit'])
if getattr(address,'scope_id',None):raise ValueError('Scoped exit expectation unsupported')
result.append({k: node[k] for k in fields})
for key in ('id', 'tag'):
if len({n[key] for n in result}) != len(result):
raise ValueError('Duplicate authorized node identity')
if len({(n['relay'], n['relay_port'], n['sni']) for n in result}) != len(result):
raise ValueError('Ambiguous authorized endpoint')
return sorted(result, key=lambda n: n['id'])
def audit(before, after, report_bytes, qa_bytes, binding, plan_sha, expected_qas, now=None, ttl=900):
now = time.time() if now is None else now
before, after = snapshot(before), snapshot(after)
if before != after or binding.get('snapshot_sha256') != digest(before):
raise ValueError('Authorized node set changed; regenerate and verify the complete set')
if binding.get('plan_sha256') != plan_sha:
raise ValueError('Wrong plan binding')
for label, raw in [('report', report_bytes), ('qa_rejections', qa_bytes)]:
if binding.get(label + '_sha256') != hashlib.sha256(raw).hexdigest():
raise ValueError('Evidence bytes changed')
report, qas = json.loads(report_bytes), json.loads(qa_bytes)
for stamp in (binding.get('verified_at'), report.get('verified_at'), qas.get('captured_at')):
if type(stamp) not in (int, float) or not 0 <= now-stamp <= ttl:
raise ValueError('Evidence expired or future dated')
if report.get('state') != 'live_verified' or len(report.get('results', [])) != len(before):
raise ValueError('Incomplete live verification')
expected = {(n['tag'], n['relay'], n['relay_port'], n['sni'], n['expected_exit'], n['country']) for n in before}
actual = set()
for n in report['results']:
if n.get('live') is not True or n.get('strict_tls') is not True:
raise ValueError('Live strict TLS required for every node')
actual.add(tuple(n.get(k) for k in ('tag', 'relay', 'relay_port', 'sni', 'exit_ip', 'country')))
if actual != expected:
raise ValueError('Node/endpoint/exit/country mismatch')
if qas.get('plan_sha256') != plan_sha or set(qas.get('users', {})) != set(expected_qas):
raise ValueError('QA evidence identity mismatch')
for key, identity in expected_qas.items():
q = qas['users'][key]
if q.get('id') != identity or q.get('http_status') != 403 or q.get('banned') is not True or q.get('excluded') is not True:
raise ValueError('QA account not fully revoked')
return {'nodes': len(before), 'snapshot_sha256': digest(before), 'verified_at': now}
def service_change(before, current):
"""Process movement is investigatory; never silently accept/rebase it."""
required = ('unit_sha256', 'binary_sha256', 'config_sha256', 'listeners')
if any(not before.get(k) or not current.get(k) for k in required):
return 'unknown'
if any(before[k] != current[k] for k in required) or current.get('active') is not True:
return 'conflict'
if any(before.get(k) != current.get(k) for k in ('pid', 'started', 'restarts')):
return 'investigate_restart'
return 'ready'
def provider_state(observed, node, now=None):
"""Canonical observations from the registered provider UI/API, never HTML substrings."""
now=time.time() if now is None else now
stamp=observed.get('captured_at')
if type(stamp) not in (int,float) or not 0<=now-stamp<=900:
raise ValueError('Provider observation expired')
if not isinstance(observed.get('dns'),list) or not isinstance(observed.get('relays'),list):
raise ValueError('Canonical DNS and relay observations required')
dns=[d for d in observed['dns'] if d.get('name')==node['tls_domain']]
relays=[r for r in observed['relays'] if r.get('host')==node['relay_host'] and r.get('port')==node['relay_port']]
if dns and (len(dns)!=1 or dns[0].get('type')!='A' or dns[0].get('address')!=node['node_address'] or dns[0].get('proxy') is not False):
raise ValueError('DNS provider resource conflict')
if relays and (len(relays)!=1 or relays[0].get('target')!=node['node_address'] or relays[0].get('listen_port')!=node['listen_port'] or relays[0].get('name')!='dpl-'+node['key']+'-anytls' or relays[0].get('proxy_protocol_in') is not False or relays[0].get('proxy_protocol_out') is not False):
raise ValueError('Relay provider resource conflict')
return {'dns':dns,'relays':relays}