203 lines
8.8 KiB
Python
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)
|