Files
archy/scripts/runtime_uplink.py

203 lines
8.8 KiB
Python

#!/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)