"""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', 'type', '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 candidate_eligibility(total_memory_bytes, xrayr_installed): """Use physical MemTotal, excluding swap and currently free memory. 1 GB = 10**9 bytes.""" if type(total_memory_bytes) is not int or total_memory_bytes <= 0 or type(xrayr_installed) is not bool: raise ValueError('Unknown candidate memory or XrayR installation') return not (total_memory_bytes < 1_000_000_000 and xrayr_installed) def validate_impact(impact): if impact is None: return if not isinstance(impact, dict) or set(impact) != {'shared_changes', 'affected_snis', 'review'}: raise ValueError('Explicit reviewed impact required') if not isinstance(impact['review'], str) or not impact['review'].strip(): raise ValueError('Impact review reference required') for key in ('shared_changes', 'affected_snis'): if not isinstance(impact[key], list) or any(not isinstance(x, str) or not x.strip() for x in impact[key]) or len(set(impact[key])) != len(impact[key]): raise ValueError('Invalid impact inventory') def verification_scope(nodes, impact=None): """Unknown/shared impact keeps full tests; reviewed append uses light TLS probes.""" nodes = snapshot(nodes) if impact is None: return {n['tag']: 'full' for n in nodes} validate_impact(impact) if not set(impact['affected_snis']).issubset({n['sni'] for n in nodes}): raise ValueError('Affected node absent from current authorization set') return {n['tag']: 'full' if impact['shared_changes'] or n['sni'] in impact['affected_snis'] else 'tls' for n in nodes} def audit(before, after, report_bytes, qa_bytes, binding, plan_sha, expected_qas, now=None, ttl=900, scope=None): 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') modes = {n['tag']: 'full' for n in before} if scope is None else scope if set(modes) != {n['tag'] for n in before} or any(v not in ('full', 'tls') for v in modes.values()): raise ValueError('Incomplete verification scope') if scope is not None and binding.get('scope_sha256') != digest(modes): raise ValueError('Verification scope binding changed') if report.get('state') != 'live_verified' or len(report.get('results', [])) != len(before): raise ValueError('Incomplete live verification') actual = {} for result in report['results']: tag = result.get('tag') if tag in actual or tag not in modes: raise ValueError('Unexpected or duplicate verification result') actual[tag] = result for node in before: result = actual.get(node['tag'], {}) if result.get('live') is not True or result.get('strict_tls') is not True or result.get('verification', 'full') != modes[node['tag']]: raise ValueError('Required verification mode or strict TLS missing') if any(result.get(k) != node[k] for k in ('relay', 'relay_port', 'sni')): raise ValueError('Node endpoint mismatch') if modes[node['tag']] == 'full' and (result.get('exit_ip') != node['expected_exit'] or result.get('country') != node['country']): raise ValueError('Node 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}