98 lines
6.6 KiB
Python
98 lines
6.6 KiB
Python
#!/usr/bin/python3
|
|
"""Non-admitting structural preflight. Never opens namespaces or binds sockets."""
|
|
import argparse
|
|
import hashlib
|
|
import ipaddress
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
|
|
from c01_endpoints import validate_route, source_addresses, source_binding_conflict
|
|
from c01_uplink import validate as validate_uplink
|
|
import c01_network_mdns
|
|
import c01_retained_producer
|
|
|
|
|
|
def exact(pairs):
|
|
result={}
|
|
for key,value in pairs:
|
|
if key in result:raise ValueError('duplicate JSON field')
|
|
result[key]=value
|
|
return result
|
|
|
|
|
|
def validate(policy,runtime):
|
|
if type(policy) is not dict or set(policy) not in ({'version','namespaces','manifests','routes','uplink'},{'version','namespaces','manifests','routes','uplink','mdns'}) or type(policy['version']) is not int or policy['version']!=4:
|
|
raise ValueError('version 4 endpoint policy required')
|
|
ns=policy['namespaces']
|
|
if type(ns) is not dict or set(ns)!={'host','managed','runtime'}:raise ValueError('namespace fields')
|
|
for pair in ns.values():
|
|
if type(pair) is not list or len(pair)!=2 or any(type(v) is not int or v<=0 for v in pair):raise ValueError('measured namespace identity required')
|
|
if len({tuple(pair) for pair in ns.values()})!=3:raise ValueError('namespaces must be distinct')
|
|
validate_uplink(policy['uplink'])
|
|
if 'mdns' in policy:c01_network_mdns.validate(policy['mdns'],policy['uplink'])
|
|
if type(policy['manifests']) is not list or len(policy['manifests'])>128:raise ValueError('projection bound')
|
|
manifests={}
|
|
base={'app_id','manifest_sha256','loopback_ports','target_cgroup'}
|
|
for doc in policy['manifests']:
|
|
if type(doc) is not dict or set(doc) not in (base,base|{'source_bindings','target_addresses','producer'}):raise ValueError('projection exact fields')
|
|
if not re.fullmatch('[a-z0-9][a-z0-9-]{0,63}',doc['app_id']) or not re.fullmatch('[0-9a-f]{64}',doc['manifest_sha256']):raise ValueError('projection identity')
|
|
if type(doc['loopback_ports']) is not list or not 1<=len(doc['loopback_ports'])<=128:raise ValueError('projection ports bound')
|
|
for port in doc['loopback_ports']:
|
|
if type(port) is not list or len(port)!=2 or port[0] not in ('tcp','udp') or type(port[1]) is not int or not 1<=port[1]<=65535:raise ValueError('projection port')
|
|
if 'producer' in doc:
|
|
producer=doc['producer']
|
|
if type(producer) is dict and producer.get('kind')=='retained-container-v1':
|
|
c01_retained_producer.validate(producer,doc['target_cgroup'])
|
|
elif producer!={'kind':'exact'}:
|
|
fields={'kind','container_name','image_id'}
|
|
if type(producer) is not dict or set(producer) not in (fields,fields|{'payload_principal'}) or producer['kind']!='container-payload' or not re.fullmatch('[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}',producer['container_name']) or not re.fullmatch('[0-9a-f]{64}',producer['image_id']):raise ValueError('runtime producer selector')
|
|
if 'payload_principal' in producer:
|
|
principal=producer['payload_principal']
|
|
if type(principal) is not list or len(principal)!=2 or any(type(n) is not int or not 0<n<2**32-1 for n in principal):raise ValueError('mapped payload principal')
|
|
if not doc['target_cgroup'].endswith('.service'):raise ValueError('payload requires stable service cgroup')
|
|
if type(doc['source_bindings']) is not list or not 1<=len(doc['source_bindings'])<=512:raise ValueError('source binding bound')
|
|
for binding in doc['source_bindings']:
|
|
if type(binding) is not list or len(binding)!=4 or binding[0] not in ('host','managed') or binding[1] not in ('tcp','udp') or type(binding[2]) is not int or not 1<=binding[2]<=65535:raise ValueError('source binding')
|
|
ip=ipaddress.ip_address(binding[3])
|
|
if str(ip)!=binding[3] or ip.is_multicast or ip.is_link_local or '%' in binding[3]:raise ValueError('source binding address')
|
|
if type(doc['target_addresses']) is not list or not 1<=len(doc['target_addresses'])<=16:raise ValueError('target address bound')
|
|
for address in doc['target_addresses']:
|
|
ip=ipaddress.ip_address(address)
|
|
if str(ip)!=address or ip.is_unspecified or ip.is_multicast or ip.is_link_local or '%' in address:raise ValueError('target address')
|
|
raw=json.dumps(doc,sort_keys=True,separators=(',',':'),ensure_ascii=False,allow_nan=False).encode()
|
|
digest=hashlib.sha256(raw).hexdigest()
|
|
if digest in manifests:raise ValueError('duplicate projection')
|
|
manifests[digest]=doc
|
|
if type(policy['routes']) is not list or not 1<=len(policy['routes'])<=128:raise ValueError('route bound')
|
|
names=set();bindings=set()
|
|
for route in policy['routes']:
|
|
validate_route(route,manifests,runtime,address_version=4)
|
|
if route['name'] in names:raise ValueError('duplicate route')
|
|
names.add(route['name'])
|
|
for address in source_addresses(route):
|
|
key=(route['source'],route['protocol'],route['port'],address)
|
|
for prior in bindings:
|
|
if source_binding_conflict(key,prior):raise ValueError('overlapping source binding')
|
|
bindings.add(key)
|
|
if len(bindings)>512:raise ValueError('listener capability bound')
|
|
gated_ports={r['target_port'] for r in policy['routes'] if r['name'].startswith('gate/')}
|
|
for route in policy['routes']:
|
|
if route['target']=='runtime' and route['source']=='host' and route['protocol']=='tcp' and route['target_port'] in gated_ports and any(not ipaddress.ip_address(address).is_loopback for address in route['source_addresses']):
|
|
raise ValueError('public runtime route would bypass an appointed authentication gate')
|
|
mdns_names={name for name in names if name.startswith('native/mdns')}
|
|
if mdns_names != ({'native/mdns4/udp/5353/host','native/mdns6/udp/5353/host'} if 'mdns' in policy else set()):raise ValueError('complete mDNS scope/routes required')
|
|
return {'structural_validation':True,'routes':len(names),'listeners':len(bindings),'live_admission':False}
|
|
|
|
|
|
if __name__=='__main__':
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--policy',required=True);parser.add_argument('--runtime',required=True)
|
|
args=parser.parse_args()
|
|
def load(path):
|
|
with open(path,'rb') as stream:raw=stream.read(65537)
|
|
if len(raw)>65536:raise ValueError('plan exceeds size bound')
|
|
return json.loads(raw,object_pairs_hook=exact)
|
|
print(json.dumps(validate(load(args.policy),load(args.runtime))))
|