164 lines
11 KiB
Python
164 lines
11 KiB
Python
"""Finite native loopback interfaces. A URL override is never rewritten here.
|
|
|
|
The table is an interface inventory, not evidence a service is installed/ready.
|
|
App-port policies additionally require a root-qualified manifest projection.
|
|
"""
|
|
NATIVE = {
|
|
'management': ('host', 'managed', 'tcp', 5678),
|
|
'kiosk-http': ('runtime', 'host', 'tcp', 80),
|
|
'kiosk-https': ('runtime', 'host', 'tcp', 443),
|
|
'bitcoin-rpc': ('managed', 'runtime', 'tcp', 8332),
|
|
'lnd-rest': ('managed', 'runtime', 'tcp', 18080),
|
|
'lnd-grpc': ('managed', 'runtime', 'tcp', 10009),
|
|
'dwn': ('managed', 'runtime', 'tcp', 3100),
|
|
'tor-socks': ('managed', 'host', 'tcp', 9050),
|
|
'fips-dns': ('managed', 'host', 'udp', 5354),
|
|
'fips-tcp6': ('host', 'host', 'tcp', 8443),
|
|
'indeehub': ('managed', 'runtime', 'tcp', 7778),
|
|
'ollama': ('managed', 'runtime', 'tcp', 11434),
|
|
'routstr': ('managed', 'runtime', 'tcp', 8200),
|
|
'fedimint': ('managed', 'runtime', 'tcp', 8175),
|
|
'searxng': ('managed', 'runtime', 'tcp', 8888),
|
|
}
|
|
NATIVE_APPS={'bitcoin-rpc':{'bitcoin-core','bitcoin-knots'},'lnd-rest':{'lnd'},'lnd-grpc':{'lnd'},
|
|
'dwn':{'dwn'},'indeehub':{'indeedhub-api'},'ollama':{'ollama'},'routstr':{'routstr'},
|
|
'fedimint':{'fedimint'},'searxng':{'searxng'}}
|
|
|
|
|
|
def source_addresses(route):
|
|
return route.get('source_addresses',['127.0.0.1'])
|
|
|
|
|
|
def target_address(route):
|
|
return route.get('target_address','127.0.0.1')
|
|
|
|
|
|
def target_port(route):
|
|
return route.get('target_port',route['port'])
|
|
|
|
|
|
def source_binding_conflict(left,right):
|
|
a,b=left[3],right[3]
|
|
return left[:3]==right[:3] and (a==b or ((':' in a)==(':' in b) and
|
|
(a in ('0.0.0.0','::') or b in ('0.0.0.0','::'))))
|
|
|
|
|
|
def validate_route(route, manifests, runtime=None, address_version=1):
|
|
import ipaddress
|
|
import re
|
|
fields={'name','source','target','protocol','port','app_id','projection_sha256','target_cgroup','target_uid'}
|
|
if address_version in (3,4):fields.update(('source_addresses','target_address'))
|
|
elif address_version!=1:raise ValueError('endpoint address schema version')
|
|
if address_version==4:fields.add('target_port')
|
|
if type(route) is not dict or set(route)!=fields:
|
|
raise ValueError('endpoint exact fields')
|
|
name=route['name']
|
|
if type(name) is not str:raise ValueError('endpoint name type')
|
|
gate=address_version==4 and name.startswith('gate/')
|
|
native_relay=address_version==4 and name.startswith('native/')
|
|
publication=address_version==4 and route['target']=='runtime'
|
|
if type(target_port(route)) is not int or not 1<=target_port(route)<=65535:raise ValueError('target port bound')
|
|
if address_version in (3,4):
|
|
addresses=route['source_addresses']
|
|
allowed=('127.0.0.1','127.0.0.2')
|
|
target_allowed=(*allowed,'::1') if native_relay else allowed
|
|
ingress=gate or native_relay or publication or name=='fips-tcp6'
|
|
if (type(addresses) is not list or not 1<=len(addresses)<=(16 if ingress else 2) or
|
|
any(type(a) is not str or (not ingress and a not in allowed) for a in addresses) or len(set(addresses))!=len(addresses) or
|
|
type(route['target_address']) is not str or (not publication and route['target_address'] not in target_allowed)):
|
|
raise ValueError('only explicitly appointed local loopback addresses are supported')
|
|
if ingress:
|
|
for address in addresses:
|
|
ip=ipaddress.ip_address(address)
|
|
wildcard=publication or name.startswith('native/mdns') or name=='fips-tcp6'
|
|
if str(ip)!=address or (ip.is_unspecified and not wildcard) or ip.is_multicast or ip.is_link_local or '%' in address:
|
|
raise ValueError('ingress requires exact unicast host addresses')
|
|
if publication:
|
|
ip=ipaddress.ip_address(route['target_address'])
|
|
if str(ip)!=route['target_address'] or ip.is_unspecified or ip.is_multicast or ip.is_link_local or '%' in route['target_address']:
|
|
raise ValueError('runtime target requires an exact address')
|
|
if name in NATIVE and addresses!=(['::'] if name=='fips-tcp6' else ['127.0.0.1']):
|
|
raise ValueError('native client interface address changed')
|
|
if route['target']!='runtime' and not (gate or native_relay) and route['target_address']!='127.0.0.1':
|
|
raise ValueError('host/management native endpoint address changed')
|
|
if name in NATIVE:
|
|
if tuple(route[k] for k in ('source','target','protocol','port'))!=NATIVE[name]:
|
|
raise ValueError('native endpoint interface changed')
|
|
if name in NATIVE_APPS and route['app_id'] not in NATIVE_APPS[name]:raise ValueError('native endpoint application differs')
|
|
if target_port(route)!=route['port']:raise ValueError('native endpoint port changed')
|
|
elif native_relay:
|
|
match=re.fullmatch(r'native/(peer|website-fips|website-tor|mdns4|mdns6)/(tcp|udp)/([0-9]{1,5})/host',name)
|
|
if not match or not 1<=int(match[3])<=65535:raise ValueError('fixed native relay name')
|
|
protocol='udp' if match[1].startswith('mdns') else 'tcp'
|
|
if match[2]!=protocol:raise ValueError('native relay protocol')
|
|
if match[1].startswith('website-'):
|
|
port=int(match[3]);start=32000 if match[1]=='website-fips' else 32100
|
|
if not start<=port<start+32:raise ValueError('website slot bound')
|
|
endpoint=('127.0.0.1',port)
|
|
if match[1]=='website-tor' and addresses!=['127.0.0.1']:raise ValueError('Tor website must remain host-local')
|
|
if match[1]=='website-fips' and any(ipaddress.ip_address(a).version!=6 or not a.startswith(('fc','fd')) for a in addresses):raise ValueError('website FIPS ingress must be ULA')
|
|
else:endpoint={'peer':('127.0.0.1',5679),'mdns4':('127.0.0.1',5353),'mdns6':('::1',5353)}[match[1]]
|
|
if match[1]=='peer' and (int(match[3])!=5679 or any(ipaddress.ip_address(a).version!=6 or not a.startswith(('fc','fd')) for a in addresses)):raise ValueError('peer ingress must retain the FIPS-only surface')
|
|
if match[1].startswith('mdns') and (addresses!=(['0.0.0.0'] if match[1]=='mdns4' else ['::']) or int(match[3])!=5353):raise ValueError('fixed mDNS binding')
|
|
if (route['source'],route['target'],route['protocol'],route['port'])!=('host','managed',protocol,int(match[3])) or (target_address(route),target_port(route))!=endpoint:
|
|
raise ValueError('fixed native relay interface')
|
|
elif gate:
|
|
match=re.fullmatch(r'gate/([a-z0-9][a-z0-9-]{0,63})/tcp/([0-9]{1,5})/host',name)
|
|
if not match or not 1<=int(match[2])<=65535:raise ValueError('gate relay name')
|
|
if (route['app_id'],route['source'],route['target'],route['protocol'],route['port'],target_address(route))!=(match[1],'host','managed','tcp',int(match[2]),'127.0.0.2'):
|
|
raise ValueError('gate relay must target the authenticated application gate')
|
|
else:
|
|
if not re.fullmatch(r'app/[a-z0-9][a-z0-9-]{0,63}/(?:tcp|udp)/[0-9]{1,5}/(?:host|managed)',name):
|
|
raise ValueError('unknown endpoint name')
|
|
_,app,protocol,port,source=name.split('/')
|
|
if (route['app_id'],route['protocol'],route['port'],route['source'],route['target'])!=(app,protocol,int(port),source,'runtime'):
|
|
raise ValueError('manifest endpoint identity')
|
|
if type(route['port']) is not int or not 1<=route['port']<=65535 or type(route['target_uid']) is not int or route['target_uid']<0:
|
|
raise ValueError('endpoint numeric bounds')
|
|
kiosk_web = name in ('kiosk-http', 'kiosk-https')
|
|
if (route['source']==route['target'] and name!='fips-tcp6') or (route['source'] not in ('host','managed') and not kiosk_web) or route['target'] not in ('host','managed','runtime'):
|
|
raise ValueError('endpoint realm')
|
|
if kiosk_web:
|
|
if runtime is None or route['target_uid'] != 0 or route['target_cgroup'] != '/system.slice/nginx.service':
|
|
raise ValueError('kiosk web requires appointed runtime and root nginx producer')
|
|
if route['target']=='runtime' or gate:
|
|
doc=manifests.get(route['projection_sha256'])
|
|
if not doc or doc['app_id']!=route['app_id'] or [route['protocol'],target_port(route)] not in doc['loopback_ports']:
|
|
raise ValueError('endpoint not in qualified manifest/config projection')
|
|
if gate:
|
|
if runtime is None or route['target_uid']!=runtime['manager_uid'] or route['target_cgroup']!='/system.slice/archipelago.service/c01-application':
|
|
raise ValueError('gate application principal')
|
|
else:
|
|
allowed_uids={runtime['runtime_uid']} if runtime is not None else set()
|
|
selector=doc.get('producer',{})
|
|
if selector.get('kind')=='retained-container-v1':allowed_uids.add(selector['payload_principal'][0])
|
|
if selector.get('kind')=='container-payload' and 'payload_principal' in selector:
|
|
allowed_uids={selector['payload_principal'][0]}
|
|
if runtime is None or route['target_uid'] not in allowed_uids or not route['target_cgroup'].startswith(runtime['user_cgroup'].rstrip('/')+'/'):
|
|
raise ValueError('runtime endpoint principal/hierarchy')
|
|
if route['target_cgroup'] != doc['target_cgroup']:
|
|
raise ValueError('runtime endpoint differs from qualified producer')
|
|
if publication:
|
|
extended=set(doc)>={'source_bindings','target_addresses','producer'}
|
|
external=any(a not in ('127.0.0.1','127.0.0.2') for a in addresses) or route['target_address'] not in ('127.0.0.1','127.0.0.2')
|
|
if external and not extended:raise ValueError('runtime external publication needs qualified bindings')
|
|
if extended:
|
|
if any([route['source'],route['protocol'],route['port'],a] not in doc['source_bindings'] for a in addresses) or route['target_address'] not in doc['target_addresses']:
|
|
raise ValueError('runtime route exceeds qualified address bindings')
|
|
elif route['app_id'] is not None or route['projection_sha256'] is not None:
|
|
raise ValueError('non-runtime route has application projection')
|
|
elif route['name']=='management' or native_relay:
|
|
if route['target_cgroup']!='/system.slice/archipelago.service/c01-application' or route['target_uid']==0:
|
|
raise ValueError('management listener appointment')
|
|
if native_relay and (runtime is None or route['target_uid']!=runtime['manager_uid']):
|
|
raise ValueError('native relay application identity')
|
|
elif not route['target_cgroup'].startswith('/system.slice/'):
|
|
raise ValueError('host service hierarchy')
|
|
if name=='tor-socks' and route['target_cgroup'].split('/')[-1] not in ('tor.service','tor@default.service'):
|
|
raise ValueError('Tor service identity')
|
|
if name=='fips-dns' and route['target_cgroup']!='/system.slice/archipelago-fips.service':raise ValueError('FIPS service identity')
|
|
if name=='fips-tcp6' and (address_version!=4 or route['target_cgroup']!='/system.slice/archipelago-fips.service' or route['target_uid']!=0):raise ValueError('FIPS transport producer identity')
|
|
if not re.fullmatch(r'/[A-Za-z0-9_.@/-]{1,240}',route['target_cgroup']) or '..' in route['target_cgroup'].split('/'):
|
|
raise ValueError('endpoint cgroup path')
|
|
return route
|