#!/usr/bin/python3 """Read-only, bounded pre-Podman IPv4 uplink gate; never repairs networking. Run in the SAME network namespace as the future runtime. network-online.target is only an ordering hint. No probes send packets or invoke Podman (even info can initialize its context). A retained rootless namespace is inspected via pinned nsfs descriptors in a child, never recreated, unmounted or reconfigured. """ import argparse import ctypes import fcntl import ipaddress import json import os from pathlib import Path import stat import subprocess import sys import time NS_GET_USERNS = 0xb701 NS_GET_NSTYPE = 0xb703 NEWNET = 0x40000000 NEWUSER = 0x10000000 def ip(*args): result = subprocess.run(['/usr/bin/ip', '-j', '-4', *args], check=True, capture_output=True, timeout=2, env={'LANG': 'C', 'PATH': '/usr/bin:/bin'}) return json.loads(result.stdout) def ready(): routes = [r for r in ip('route', 'show', 'table', 'main', 'default') if r.get('type', 'unicast') == 'unicast' and r.get('gateway') and r.get('dev') and not set(r.get('flags', ())) & {'dead', 'linkdown'}] if not routes: raise ValueError('no usable main-table IPv4 default') metric = min(r.get('metric', 0) for r in routes) routes = [r for r in routes if r.get('metric', 0) == metric] if len(routes) != 1: raise ValueError('ambiguous main-table default') route = routes[0] links = ip('address', 'show', 'dev', route['dev']) if len(links) != 1 or not {'UP', 'LOWER_UP'} <= set(links[0]['flags']) or 'LOOPBACK' in links[0]['flags']: raise ValueError('default link not ready') addresses = {a['local'] for a in links[0]['addr_info'] if a.get('scope') == 'global' and a.get('family') == 'inet' and not a.get('tentative') and not a.get('dadfailed')} # Kernel lookup only: no DNS, TCP, ARP or public-service dependency. Policy # routing that disagrees with pasta's main-table choice is not qualified. selected = ip('route', 'get', '1.1.1.1') if len(selected) != 1 or selected[0].get('dev') != route['dev'] or selected[0].get('gateway') != route['gateway'] or selected[0].get('prefsrc') not in addresses: raise ValueError('effective route/source differs from default') source = ipaddress.ip_address(selected[0]['prefsrc']) if source.is_loopback or source.is_link_local or source.is_unspecified: raise ValueError('unusable uplink source') return route['dev'], route['gateway'], str(source) def namespace_id(fd): if fcntl.ioctl(fd, NS_GET_NSTYPE) != NEWNET: raise ValueError('not a kernel network namespace') meta = os.fstat(fd) return meta.st_dev, meta.st_ino def appointed(): path = Path('/run/netns/archipelago-runtime') for parent in path.parents: meta = parent.lstat() if not stat.S_ISDIR(meta.st_mode) or meta.st_uid or meta.st_mode & 0o022: raise ValueError('mutable runtime namespace ancestry') fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) try: meta = os.fstat(fd) if not stat.S_ISREG(meta.st_mode) or meta.st_mode & 0o022: raise ValueError('mutable appointed namespace') identity = namespace_id(fd) owner = fcntl.ioctl(fd, NS_GET_USERNS) try: actual, expected = os.fstat(owner), os.stat('/proc/self/ns/user') if (actual.st_dev, actual.st_ino) != (expected.st_dev, expected.st_ino): raise ValueError('unapproved runtime namespace owner') finally: os.close(owner) with open('/proc/self/ns/net') as current: if identity != namespace_id(current.fileno()): raise ValueError('gate is outside the appointed private runtime namespace') # Unprivileged USER units cannot read root PID1's namespace descriptor. # They authenticate the protected appointment and their own membership; # the existing root C01 broker independently excludes the host namespace # before admission. Do not add privilege to the user gate to read PID1. if os.geteuid() == 0: with open('/proc/1/ns/net') as host: if identity == namespace_id(host.fileno()): raise ValueError('host namespace cannot be appointed as runtime') return identity finally: os.close(fd) def metadata(path): fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) with os.fdopen(fd, 'rb') as stream: if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): raise ValueError('retained rootless metadata is not a regular file') raw = stream.read(16385) if len(raw) > 16384: raise ValueError('retained rootless metadata exceeds bound') return raw def retained(runtime_dir, uplink): path = Path(runtime_dir) / 'containers/networks/rootless-netns/rootless-netns' try: fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) except FileNotFoundError: # Partial retained state is uncertain, not permission to initialize it. if path.parent.exists(): raise ValueError('partial retained rootless namespace requires owner reconciliation') return try: identity = namespace_id(fd) info_path = path.parent / 'info.json' raw = metadata(info_path) info = json.loads(raw) if uplink[2] not in info.get('IPAddresses', []): raise ValueError('retained rootless address cache differs from uplink; preserved') user = fcntl.ioctl(fd, NS_GET_USERNS) try: pid = os.fork() if pid == 0: try: libc = ctypes.CDLL(None, use_errno=True) with open('/proc/self/ns/user') as current: a, b = os.fstat(user), os.fstat(current.fileno()) if (a.st_dev, a.st_ino) != (b.st_dev, b.st_ino) and libc.setns(user, NEWUSER): raise ValueError('retained user namespace inaccessible') if libc.setns(fd, NEWNET): raise ValueError('retained network namespace inaccessible') if ready()[1:] != uplink[1:]: raise ValueError('retained rootless route/source differs from uplink') os._exit(0) except Exception as error: print('retained namespace refused: ' + str(error), file=sys.stderr, flush=True) os._exit(1) _, status = os.waitpid(pid, 0) if status: raise ValueError('retained rootless namespace requires owner reconciliation; preserved') finally: os.close(user) named = path.stat() if identity != (named.st_dev, named.st_ino) or metadata(info_path) != raw: raise ValueError('retained rootless namespace replaced during inspection') finally: os.close(fd) def wait(runtime_dir, timeout, c01=False): expected = appointed() if c01 else None deadline = time.monotonic() + timeout stable = None since = 0 while True: try: if c01 and appointed() != expected: raise RuntimeError('appointed namespace changed') current = ready() if current != stable: stable, since = current, time.monotonic() elif time.monotonic() - since >= 1: # Retained failures are terminal: waiting for the host cannot # repair a namespace already pinned to an obsolete uplink. retained(runtime_dir, current) if ready() != current: raise RuntimeError('uplink changed during retained inspection') print('runtime uplink ready: ' + json.dumps(current), flush=True) return except (ValueError, subprocess.SubprocessError) as error: if stable is not None and time.monotonic() - since >= 1: raise stable = None if time.monotonic() >= deadline: raise ValueError('uplink readiness deadline: ' + str(error)) from error if time.monotonic() >= deadline: raise ValueError('uplink did not stabilize before deadline') time.sleep(.2) if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--runtime-dir', required=True) parser.add_argument('--timeout', type=float, default=60) parser.add_argument('--c01', action='store_true') args = parser.parse_args() try: if not 0 < args.timeout <= 120 or not Path(args.runtime_dir).is_absolute(): raise ValueError('invalid fixed startup parameters') wait(args.runtime_dir, args.timeout, args.c01) except Exception as error: print('runtime uplink blocked: ' + str(error), file=sys.stderr) sys.exit(1)