fix(oci): preserve private access to stack frontends across lifecycle operations

This commit is contained in:
Jonatan
2026-10-28 11:26:59 +01:00
parent a7786a6874
commit 05790b494e
16 changed files with 892 additions and 10 deletions
+6
View File
@@ -242,6 +242,12 @@ variables, such as `NEXTAUTH_URL` or `ROOT_URL`, must contain the intended URL i
the supplied Compose or environment file. Changing a frontend IP later also
requires updating its application URLs.
Private members receive specific routes to their own stack's static frontend
addresses before the application starts. These routes stay inside the container's
network namespace and use the frontend's private address as the next hop. Native
update and recovery preserve their ownership records and rebuild the required
host configuration, including when a restore changes container IDs.
Repeated identical volume sources share one host directory, selected once;
separate configuration volumes stay independent. Host directories are not part
of a normal Proxmox container backup. Newly created private data volumes use the
+41
View File
@@ -26,6 +26,7 @@ import xml.etree.ElementTree as ET
from allocate_private_network import allocate_network, existing_bridges, existing_networks
import oci_instances
import oci_nested_mounts
import oci_stack_public_routes
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2, stop_spinner, log
HERE = Path(__file__).resolve().parent
@@ -306,6 +307,45 @@ def prepare_public_environment(services):
_set_public_value(service, key, binding['value'].replace('@STACK_LAN_IP@', str(address)))
def install_public_routes(services, primary_id, bridge):
"""Private members can reach only this stack's own static frontend IPs."""
frontends = [service for service in services if service.get('main') or service.get('frontend')]
targets = {}
for service in frontends:
address = service.get('frontend_ipv4')
if not address or address == 'dhcp':
continue
destination = str(ipaddress.IPv4Interface(address).ip)
if destination in targets:
raise RuntimeError('Duplicate stack frontend address')
targets[destination] = service
if not targets:
return
records = {service['vmid']: oci_instances.read(oci_instances.ROOT, service['vmid']) for service in services}
stack_id = records[primary_id]['installation_id']
for service in services:
if service.get('main') or service.get('frontend'):
continue
vmid = service['vmid']
identity = records[vmid]['installation_id']
spec = {'schema': 1, 'stack_id': stack_id, 'primary_vmid': primary_id,
'vmid': vmid, 'installation_id': identity, 'private_bridge': bridge,
'private_address': service['ip'] + '/24', 'routes': [
{'destination': destination, 'via': target['ip'], 'target_vmid': target['vmid'],
'target_installation_id': records[target['vmid']]['installation_id']}
for destination, target in sorted(targets.items())]}
plan = service['deployment']
plan['stack_public_routes'] = spec
oci_stack_public_routes.validate(plan, vmid, identity)
config = Path(f'/etc/pve/lxc/{vmid}.conf')
text = config.read_text()
# Never replace another include or accept a stale route contract.
oci_stack_public_routes.check(text, {}, vmid, require_host_assets=False)
line = oci_stack_public_routes.install(plan, vmid, identity)
config.write_text(text.rstrip('\n') + '\n' + line + '\n')
oci_stack_public_routes.check(config.read_text(), plan, vmid, identity)
def bind_completed_jobs(services):
"""All members and private aliases exist before a job can be authorized."""
from oci_compose_jobs import job_spec_from_record
@@ -1022,6 +1062,7 @@ def main():
result.update(completion_notes=list(deployment.get('completion_notes',[])), log=LOG)
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
return
install_public_routes(services, primary_id, bridge)
bind_completed_jobs(services)
hook_spec = Path(tmp)/'lifecycle.json'
write_json(hook_spec,{'schema':1,'stack':deployment['stack_name'],'dependencies':[
+10
View File
@@ -2107,6 +2107,16 @@ apply_rlimits
apply_host_monitor
apply_kept_settings
# Recreated stack members retain their owned pre-init routes. Append rather
# than replacing lxc.include: other managed runtime includes must survive.
if jq -e '.stack_public_routes != null' "$DEPLOYMENT_FILE" >/dev/null; then
ROUTE_INCLUDE=$(python3 "${SCRIPT_DIR}/oci_stack_public_routes.py" install "$VMID" \
--deployment "$DEPLOYMENT_FILE" --installation-id "$INSTANCE_ID") \
|| die "Managed public route installation failed"
[[ -n "$ROUTE_INCLUDE" ]] || die "Managed public route include is missing"
grep -Fxq "$ROUTE_INCLUDE" "$CONF" || printf '%s\n' "$ROUTE_INCLUDE" >>"$CONF"
fi
while IFS= read -r REPAIR_ENCODED; do
[[ -n $REPAIR_ENCODED ]] || continue
run_pre_start_repair "$(printf '%s' "$REPAIR_ENCODED" | base64 -d)"
+15 -4
View File
@@ -33,6 +33,7 @@ import oci_host_mounts as host_mounts
import oci_nested_mounts as nested_mounts
import oci_accelerators as gpu_devices
import oci_runtime_settings as runtime_settings
import oci_stack_public_routes as public_routes
import oci_image_cache as image_cache
import oci_work_backup as work_backup
import oci_ui
@@ -52,7 +53,7 @@ BASIC = {'arch', 'cmode', 'console', 'tty', 'cores', 'cpulimit', 'cpuunits', 'de
# Their output is data (and may hold saved secrets); it is never logged.
DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get'), ('pct', 'df')}
# Kept across a modification: the watchdog and where the backups are made.
HOST_PREFERENCES = ('watchdog', 'work_storage')
HOST_PREFERENCES = ('watchdog', 'work_storage', 'stack_public_routes')
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]')
SPINNER_FRAME = re.compile('^ ?[' + ''.join(oci_ui.FRAMES) + ']')
@@ -476,7 +477,7 @@ def candidate_contract(record, operation, proposal=None):
# of what is being edited: it stays as the record has it now.
for key in HOST_PREFERENCES:
if key in record['deployment']:
result['deployment'][key] = record['deployment'][key]
result['deployment'][key] = copy.deepcopy(record['deployment'][key])
else:
result['deployment'].pop(key, None)
return with_default_healthcheck(result)
@@ -519,6 +520,11 @@ def external_changes(record, config, adopt=True):
return values
def without_public_routes(config, vmid):
"""The managed include is validated separately from sysctl includes."""
return b'\n'.join(line for line in config.splitlines() if not public_routes.own(line, vmid)) + b'\n'
def preflight(record, candidate, config, coordinated=None, in_place=False, baseline=True):
deployment = record['deployment']
desired = candidate['deployment']
@@ -555,7 +561,10 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel
files = runtime_settings.file_binds(config)
if files:
runtime_keys.add('lxc.mount.entry')
runtime_settings.check(config, deployment, record['vmid'])
public_routes.check(config, deployment, record['vmid'], record['installation_id'])
if deployment.get('stack_public_routes'):
runtime_keys.add('lxc.include')
runtime_settings.check(without_public_routes(config, record['vmid']), deployment, record['vmid'])
coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS)
preserved_keys = {'nameserver', 'searchdomain', 'timezone', 'protection'} if in_place else set()
detached = unused_volumes(config)
@@ -680,7 +689,8 @@ def freeze_host_sources(record, candidate, acknowledge_external_data, files=()):
def check_runtime_mounts(config, deployment):
runtime_settings.check(config, deployment, deployment['vmid'])
public_routes.check(config, deployment, deployment['vmid'])
runtime_settings.check(without_public_routes(config, deployment['vmid']), deployment, deployment['vmid'])
nested_mounts.check(config)
actual = mounts(config)
files = runtime_settings.file_binds(config)
@@ -1303,6 +1313,7 @@ def complete_recovery(root, journal, state):
raise ValueError(translate('The configuration changed after the backup was restored; the recovery is not confirmed'))
cleanup_restored_format_dirs(vmid, state['record']['deployment'], state['record']['installation_id'])
runtime_settings.restore(state['record']['deployment'], vmid)
public_routes.restore(state['record']['deployment'], vmid, state['record']['installation_id'])
check_runtime_mounts(config, state['record']['deployment'])
gpu_devices.check(config, state['record']['deployment'])
if show:
+13 -2
View File
@@ -20,6 +20,7 @@ import oci_image_cache as image_cache
import oci_work_backup as work_backup
import oci_instances as instances
import oci_compose_jobs as compose_jobs
import oci_stack_public_routes as public_routes
from oci_installation_state import parse_config
import oci_console
import oci_runtime_settings as runtime_settings
@@ -198,7 +199,7 @@ def _leftovers(vmid):
"""Whether anything of the container is still on the host."""
paths = [runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid),
runtime_settings.seccomp_path(vmid), SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh', MONITOR_APPS / f'{int(vmid)}.json',
CLUSTER_RECORDS / f'{int(vmid)}.json',
CLUSTER_RECORDS / f'{int(vmid)}.json', public_routes.include_path(vmid), public_routes.contract_path(vmid),
*oci_console.LOG_DIR.glob(f'{int(vmid)}.console.log*')]
return any(path.exists() for path in paths)
@@ -214,7 +215,8 @@ def sweep_orphans(root):
(runtime_settings.seccomp_path(0).parent, r'([0-9]+)\.proxmenux-seccomp'),
(oci_console.LOG_DIR, r'([0-9]+)\.console\.log.*'),
(SNIPPETS, r'proxmenux-rclone-([0-9]+)-fuse-hook\.sh'),
(CLUSTER_RECORDS, r'([0-9]+)\.json')):
(CLUSTER_RECORDS, r'([0-9]+)\.json'),
(public_routes.DIRECTORY, r'([0-9]+)\.(?:conf|json)')):
if directory.is_dir():
found.update(int(m.group(1)) for m in (re.fullmatch(pattern, p.name) for p in directory.iterdir()) if m)
# Only the App tab registrations of OCI installs; the other ones belong to
@@ -231,10 +233,17 @@ def sweep_orphans(root):
if instances.guest_exists(vmid):
continue
record = instances.has_contract(root, vmid)
route_assets = public_routes.contract_path(vmid).exists() or public_routes.include_path(vmid).exists()
route_identity = instances.read(root, vmid)['installation_id'] if record and route_assets else None
if record and not instances.release_orphan(root, vmid):
continue
if not (record or _leftovers(vmid)):
continue
try:
public_routes.remove(vmid, route_identity)
except (ValueError, OSError):
# Changed or unowned assets remain for inspection, never guessed.
continue
oci_console.remove_log(vmid)
remove_host_state(vmid)
cleaned.append(vmid)
@@ -340,6 +349,7 @@ def remove(root, vmid):
msg_warn(f"{translate('The container no longer exists:')} CT {member}")
incomplete = True
oci_console.remove_log(member)
public_routes.remove(member, record['installation_id'])
remove_host_state(member)
elif instances.identity(config) != record['installation_id']:
msg_warn(f"{translate('The VMID belongs to another container now and is not touched:')} CT {member}")
@@ -349,6 +359,7 @@ def remove(root, vmid):
subprocess.run(['pct', 'stop', str(member), '--skiplock', '1'], check=False, capture_output=True)
run('pct', 'destroy', str(member), '--purge', '1', '--destroy-unreferenced-disks', '1')
oci_console.remove_log(member)
public_routes.remove(member, record['installation_id'])
remove_host_state(member)
msg_ok(f"{translate('Container removed:')} CT {member}")
if bridge and not bridge_in_use(bridge, set(members)):
+27 -2
View File
@@ -31,6 +31,7 @@ import tempfile
import oci_carried_record as carried
import oci_console
import oci_stack_public_routes as public_routes
import oci_instances as instances
import oci_compose_jobs as compose_jobs
import oci_stack_plan
@@ -59,7 +60,7 @@ HOST_MONITOR_CONTENT = 'lxc.namespace.share.pid = 1\nlxc.namespace.share.net = 1
# them from, so they are not offered again.
UNRECOVERABLE = '.unrecoverable.json'
# The fields of a record that name a container of the application.
VMID_KEYS = ('vmid', 'primary_vmid', 'base_vmid')
VMID_KEYS = ('vmid', 'primary_vmid', 'base_vmid', 'target_vmid')
def run(*args):
@@ -287,7 +288,7 @@ def renumbered_lines(text, old, new):
f'lxc.include: {runtime_settings.legacy_include_path(old)}': f'lxc.include: {runtime_settings.include_path(new)}'}
hook = re.compile(rf'(hookscript: \S+:snippets/proxmenux-rclone-){int(old)}(-fuse-hook\.sh)')
lines = [hook.sub(rf'\g<1>{int(new)}\g<2>', swaps.get(line, line)) if line.startswith('hookscript: ')
else swaps.get(line, line) for line in text.split('\n')]
else swaps.get(line, public_routes.renumber_line(line, old, new)) for line in text.split('\n')]
return '\n'.join(lines)
@@ -441,6 +442,11 @@ def check_host_resources(plan):
vmid, label = entry['vmid'], f"CT {entry['vmid']}"
values = parse_config(entry['config'].encode())
deployment = entry['copy']['record']['deployment']
projected = renumbered_lines(entry['config'], entry.get('recorded', vmid), vmid)
try:
public_routes.check(projected, deployment, vmid, entry.get('installation_id'), require_host_assets=False)
except ValueError as error:
plan['blockers'].append(f"{label}: {error}")
directories = [value.split(',', 1)[0] for key, value in values.items()
if re.fullmatch(r'mp[0-9]+', key) and value.startswith('/')]
devices = [oci_stack_modify.device_path(value) for key, value in values.items()
@@ -482,6 +488,9 @@ def check_host_resources(plan):
if key != 'lxc.include':
continue
path = Path(value)
if any(public_routes.own(f'lxc.include: {path}', number) for number in {vmid, entry.get('recorded', vmid)}):
# Canonical assets are emitted from the validated deployment before start.
continue
if path in HOST_MONITOR:
if path.exists() and path.read_text() != HOST_MONITOR_CONTENT:
plan['blockers'].append(f"{label}: {translate('a different host monitor include already exists:')} {path}")
@@ -708,12 +717,27 @@ def check_records(root, plan):
plan['blockers'].append(f"CT {entry['vmid']}: {translate('this host keeps a record of another installation with an operation pending')}")
def check_public_route_members(root, plan):
"""Guest-carried routes can target only members validated in this recovery."""
restored = {entry['vmid']: entry['copy']['record'] for entry in plan['restored'] if entry.get('copy')}
try:
records = {member['vmid']: restored.get(member['vmid']) or instances.read(root, member['vmid'])
for member in plan['members']}
if not any(record.get('deployment', {}).get('stack_public_routes') for record in records.values()):
return
oci_stack_plan.validate_public_route_members(plan['primary'], records)
except (ValueError, KeyError, OSError) as error:
plan['blockers'].append(translate('Invalid stack contract') + ': ' + str(error))
def prepare(root):
"""Every restored application of this node, with what prevents its
recovery and what the user has to know."""
plans = applications(root, [examine(entry) for entry in pending(root)])
for plan in plans:
check_records(root, plan)
if not plan['blockers']:
check_public_route_members(root, plan)
if not plan['blockers']:
check_networks(plan)
check_host_resources(plan)
@@ -740,6 +764,7 @@ def restore_host_files(entry):
vmid = entry['vmid']
deployment = entry['copy']['record']['deployment']
renumber_config(entry)
public_routes.restore(deployment, vmid, entry.get('installation_id'))
for line in entry['config'].splitlines():
key, _, value = line.partition(': ')
if key == 'lxc.include':
+2
View File
@@ -15,6 +15,7 @@ import uuid
import oci_image_cache as image_cache
import oci_carried_record as carried
import oci_stack_public_routes as public_routes
import oci_compose_jobs as compose_jobs
import oci_instances as instances
import oci_instance_transaction as member_tx
@@ -227,6 +228,7 @@ class NativeAdapter:
if row['type'] != 'lxc' or row.get('node') != socket.gethostname().split('.')[0]:
raise ValueError(translate('A member VMID is in use by another guest or is on another node'))
config = instances.command('pct', 'config', str(vmid))
public_routes.check(config, record['deployment'], vmid, record['installation_id'])
if instances.identity(config) != record['installation_id']:
raise ValueError(translate('The identity of a member was replaced'))
if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config, ignore_tags=True):
+23
View File
@@ -1,5 +1,6 @@
"""Read-only validation of a coordinated stack operation, before host changes."""
import copy
import oci_stack_public_routes as public_routes
import re
import uuid
@@ -32,6 +33,27 @@ def validate_job_check(check, record=None):
return job
def validate_public_route_members(primary_id, records):
"""A route may name only the frontends of this exact recorded stack."""
for vmid, record in records.items():
spec = public_routes.validate(record.get('deployment', {}), vmid, record['installation_id'])
if spec is None:
continue
primary = records.get(primary_id)
if (primary is None or spec['primary_vmid'] != primary_id
or spec['stack_id'] != primary['installation_id']):
raise ValueError(translate('Invalid stack contract'))
members = {member['vmid']: member['installation_id']
for member in primary.get('stack', {}).get('members', [])}
if members.get(vmid) != record['installation_id']:
raise ValueError(translate('Invalid stack contract'))
for route in spec['routes']:
target = records.get(route['target_vmid'])
if (target is None or target['installation_id'] != route['target_installation_id']
or members.get(route['target_vmid']) != route['target_installation_id']):
raise ValueError(translate('Invalid stack contract'))
def build(primary, records, inventory, operation):
"""Inventory maps VMIDs to installation UUIDs, including unrelated guests.
@@ -43,6 +65,7 @@ def build(primary, records, inventory, operation):
stack = primary.get('stack')
if not stack or not stack.get('members'):
raise ValueError(translate('This is not a coordinated stack'))
validate_public_route_members(primary['vmid'], records)
snapshots = stack['members']
ids = [member['vmid'] for member in snapshots]
if any(type(vmid) is not int or vmid < 100 for vmid in ids) or len(set(ids)) != len(ids):
+351
View File
@@ -0,0 +1,351 @@
#!/usr/bin/env python3
"""Owned /32 routes to a stack's own frontends, installed before OCI init."""
import argparse
import copy
import fcntl
import ipaddress
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import tempfile
import uuid
DIRECTORY = Path('/etc/pve/priv/proxmenux-stack-public-routes')
HELPER = Path('/usr/local/share/proxmenux/oci/engine/remote/oci_stack_public_routes.py')
CONFIGS = Path('/etc/pve/lxc')
ROOT_UID = 0
PRIVATE = ipaddress.ip_network('10.77.0.0/16')
IP = '/usr/sbin/ip'
NSENTER = '/usr/bin/nsenter'
NS_GET_NSTYPE = 0xb703
CLONE_NEWNET = 0x40000000
class RouteError(ValueError):
pass
def _vmid(value):
if type(value) is not int or not 100 <= value < 1000000000:
raise RouteError('Invalid public route VMID')
return value
def _identity(value):
try:
result = str(uuid.UUID(value))
except (ValueError, TypeError, AttributeError) as error:
raise RouteError('Invalid public route identity') from error
if result != value:
raise RouteError('Invalid public route identity')
return result
def include_path(vmid):
return DIRECTORY / f'{_vmid(vmid)}.conf'
def contract_path(vmid):
return DIRECTORY / f'{_vmid(vmid)}.json'
def _address(value):
if not isinstance(value, str):
raise RouteError('Invalid public route address')
try:
address = ipaddress.IPv4Address(value)
except (ValueError, TypeError) as error:
raise RouteError('Invalid public route address') from error
if address.is_loopback or address.is_multicast or address.is_unspecified or address.is_link_local:
raise RouteError('Invalid public route address')
return address
def validate(deployment, vmid, identity=None):
spec = deployment.get('stack_public_routes')
if spec is None:
return None
required = {'schema', 'stack_id', 'primary_vmid', 'vmid', 'installation_id',
'private_bridge', 'private_address', 'routes'}
if not isinstance(spec, dict) or set(spec) != required or type(spec['schema']) is not int or spec['schema'] != 1:
raise RouteError('Invalid public route contract')
if _vmid(spec['vmid']) != _vmid(vmid):
raise RouteError('Public route owner differs')
_vmid(spec['primary_vmid'])
_identity(spec['stack_id'])
if _identity(spec['installation_id']) != (identity or spec['installation_id']):
raise RouteError('Public route owner identity differs')
if not isinstance(spec['private_bridge'], str) or not re.fullmatch(r'vmbr[0-9]+', spec['private_bridge']):
raise RouteError('Invalid public route bridge')
if not isinstance(spec['private_address'], str):
raise RouteError('Invalid public route private address')
try:
source = ipaddress.IPv4Interface(spec['private_address'])
except (ValueError, TypeError) as error:
raise RouteError('Invalid public route private address') from error
if source.network.prefixlen != 24 or source.ip not in PRIVATE or source.ip in (source.network.network_address, source.network.broadcast_address):
raise RouteError('Invalid public route private address')
routes = spec['routes']
if not isinstance(routes, list) or not routes or len(routes) > 64:
raise RouteError('Invalid public route list')
destinations = set()
for route in routes:
if not isinstance(route, dict) or set(route) != {'destination', 'via', 'target_vmid', 'target_installation_id'}:
raise RouteError('Invalid public route entry')
destination, via = _address(route['destination']), _address(route['via'])
if (destination in source.network or destination in destinations or via not in source.network
or via in (source.ip, source.network.network_address, source.network.broadcast_address)
or _vmid(route['target_vmid']) == vmid):
raise RouteError('Invalid public route destination or next hop')
_identity(route['target_installation_id'])
destinations.add(destination)
return copy.deepcopy(spec)
def include_content():
return f'lxc.hook.start-host = /usr/bin/python3 -I {HELPER} start\n'
def own(line, vmid):
if isinstance(line, bytes):
line = line.decode()
return line.rstrip('\n') == f'lxc.include: {include_path(vmid)}'
def renumber_line(line, old, new):
binary = isinstance(line, bytes)
text = line.decode() if binary else line
if own(text, old):
text = f'lxc.include: {include_path(new)}' + ('\n' if text.endswith('\n') else '')
return text.encode() if binary else text
def _read(path):
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != ROOT_UID or info.st_mode & 0o022:
raise RouteError('Unsafe managed public route file')
if info.st_size > 262144:
raise RouteError('Managed public route file is too large')
return path.read_bytes()
def _managed_directory(path, create=False):
if create:
path.mkdir(parents=True, exist_ok=True)
info = path.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != ROOT_UID or info.st_mode & 0o022:
raise RouteError('Unsafe managed public route directory')
def _write(path, data, mode=0o600):
_managed_directory(path.parent, create=True)
if path.is_symlink():
raise RouteError('Managed public route path is a symbolic link')
fd, temporary = tempfile.mkstemp(prefix='.public-routes-', dir=path.parent)
try:
with os.fdopen(fd, 'wb') as output:
output.write(data)
output.flush()
os.fsync(output.fileno())
# pmxcfs supplies the permissions of files below its private directory.
if Path('/etc/pve') not in path.parents:
os.chmod(temporary, mode)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def install(deployment, vmid, identity=None):
spec = validate(deployment, vmid, identity)
if spec is None:
return
# The installed engine is the authority; upgrades replace it normally.
_managed_directory(HELPER.parent)
_read(HELPER)
_write(contract_path(vmid), (json.dumps(spec, sort_keys=True) + '\n').encode())
_write(include_path(vmid), include_content().encode())
return f'lxc.include: {include_path(vmid)}'
restore = install
publish = install
def check(config, deployment, vmid, identity=None, require_host_assets=True):
spec = validate(deployment, vmid, identity)
text = config.decode() if isinstance(config, bytes) else config
includes = [line for line in text.splitlines() if line.startswith('lxc.include: ')
and str(DIRECTORY) + '/' in line]
expected = [f'lxc.include: {include_path(vmid)}'] if spec else []
if includes != expected:
raise RouteError('Managed public route include differs from the contract')
if spec and require_host_assets:
_managed_directory(DIRECTORY)
_managed_directory(HELPER.parent)
if _read(include_path(vmid)) != include_content().encode():
raise RouteError('Managed public route include was changed')
if json.loads(_read(contract_path(vmid))) != spec:
raise RouteError('Managed public route contract was changed')
_read(HELPER)
return spec
def remove(vmid, identity=None):
path = contract_path(vmid)
if not path.exists() and not path.is_symlink():
if include_path(vmid).exists() or include_path(vmid).is_symlink():
raise RouteError('Public route include has no ownership contract')
return False
_managed_directory(DIRECTORY)
spec = json.loads(_read(path))
validate({'stack_public_routes': spec}, vmid, identity)
include = include_path(vmid)
if include.exists() or include.is_symlink():
if _read(include) != include_content().encode():
raise RouteError('Managed public route include was changed')
include.unlink()
path.unlink()
return True
def _config(vmid):
text = _read(CONFIGS / f'{_vmid(vmid)}.conf').decode()
matches = re.findall(r'proxmenux-instance=([0-9a-f-]{36})(?![0-9a-f-])', text)
if len(set(matches)) != 1:
raise RouteError('Public route container identity is unknown')
values = {}
for line in text.splitlines():
if line.startswith('#') or ': ' not in line:
continue
key, value = line.split(': ', 1)
if key in ('net0', 'net1'):
if key in values:
raise RouteError('Ambiguous public route network')
parts = [part.split('=', 1) for part in value.split(',') if '=' in part]
if len({key for key, _ in parts}) != len(parts):
raise RouteError('Ambiguous public route network')
values[key] = dict(parts)
return matches[0], values, text
def live_contract(spec):
"""Validate actual PVE ownership and both ends of every generated route."""
vmid = spec['vmid']
identity, networks, text = _config(vmid)
check(text, {'stack_public_routes': spec}, vmid, identity)
source = networks.get('net0', {})
if source.get('bridge') != spec['private_bridge'] or source.get('ip') != spec['private_address'] or source.get('name') != 'eth0':
raise RouteError('Public route private interface differs')
primary_identity, primary_networks, _ = _config(spec['primary_vmid'])
if primary_identity != spec['stack_id'] or primary_networks.get('net0', {}).get('bridge') != spec['private_bridge']:
raise RouteError('Public route stack ownership differs')
for route in spec['routes']:
target_identity, target_networks, _ = _config(route['target_vmid'])
private, frontend = target_networks.get('net0', {}), target_networks.get('net1', {})
try:
private_ip = ipaddress.IPv4Interface(private.get('ip'))
public_ip = ipaddress.IPv4Interface(frontend.get('ip'))
except (ValueError, TypeError) as error:
raise RouteError('Public route target is not a static frontend') from error
if (target_identity != route['target_installation_id'] or private.get('bridge') != spec['private_bridge']
or private_ip.network != ipaddress.IPv4Interface(spec['private_address']).network
or str(private_ip.ip) != route['via'] or str(public_ip.ip) != route['destination']):
raise RouteError('Public route target ownership or addresses differ')
mac = source.get('hwaddr', '').lower()
if not re.fullmatch(r'(?:[0-9a-f]{2}:){5}[0-9a-f]{2}', mac):
raise RouteError('Public route private hardware address is unknown')
return mac
def namespace_fd(path):
if not isinstance(path, str) or not re.fullmatch(r'/proc/[1-9][0-9]*/(?:fd/[0-9]+|ns/net)', path):
raise RouteError('LXC did not provide a network namespace')
fd = os.open(path, os.O_RDONLY)
try:
if fcntl.ioctl(fd, NS_GET_NSTYPE) != CLONE_NEWNET:
raise RouteError('LXC namespace is not a network namespace')
info, host = os.fstat(fd), os.stat('/proc/self/ns/net')
if (info.st_dev, info.st_ino) == (host.st_dev, host.st_ino):
raise RouteError('Refusing to configure the host network namespace')
return fd
except BaseException:
os.close(fd)
raise
def _namespace_command(fd, *args):
process = subprocess.run([NSENTER, f'--net=/proc/self/fd/{fd}', '--', IP, *args],
pass_fds=(fd,), capture_output=True, check=True, timeout=15)
return process.stdout
def apply(spec, fd, mac):
source = ipaddress.IPv4Interface(spec['private_address'])
interfaces = json.loads(_namespace_command(fd, '-j', 'address', 'show', 'dev', 'eth0'))
if (len(interfaces) != 1 or interfaces[0].get('address', '').lower() != mac
or not any(item.get('family') == 'inet' and item.get('local') == str(source.ip)
and item.get('prefixlen') == source.network.prefixlen
for item in interfaces[0].get('addr_info', []))):
raise RouteError('Network namespace does not belong to the public route owner')
for route in spec['routes']:
_namespace_command(fd, '-4', 'route', 'replace', route['destination'] + '/32',
'via', route['via'], 'dev', 'eth0', 'src', str(source.ip))
def start():
if os.geteuid() != 0:
raise RouteError('Public routes require root')
name = os.environ.get('LXC_NAME', '')
if not re.fullmatch(r'[1-9][0-9]*', name):
raise RouteError('LXC did not provide its container identity')
vmid = _vmid(int(name))
spec = json.loads(_read(contract_path(vmid)))
validate({'stack_public_routes': spec}, vmid)
mac = live_contract(spec)
pid = os.environ.get('LXC_PID', '')
if not re.fullmatch(r'[1-9][0-9]*', pid):
raise RouteError('LXC did not provide its init process identity')
fd = namespace_fd(f'/proc/{pid}/ns/net')
try:
apply(spec, fd, mac)
finally:
os.close(fd)
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest='operation', required=True)
start_parser = commands.add_parser('start')
start_parser.add_argument('hook_arguments', nargs='*')
install_parser = commands.add_parser('install')
install_parser.add_argument('vmid', type=int)
install_parser.add_argument('--deployment', required=True)
install_parser.add_argument('--installation-id', required=True)
args = parser.parse_args()
if os.geteuid() != 0:
raise RouteError('Public routes require root')
if args.operation == 'start':
# Proxmox's network scripts use the default LXC version-0 hook ABI.
expected = [os.environ.get('LXC_NAME', ''), 'lxc', 'start-host']
if args.hook_arguments != expected:
raise RouteError('Unexpected public route hook invocation')
start()
else:
line = install(json.loads(Path(args.deployment).read_text()), args.vmid, args.installation_id)
if line:
print(line)
if __name__ == '__main__':
try:
main()
except (ValueError, OSError, subprocess.SubprocessError) as error:
detail = str(error)
if isinstance(error, subprocess.CalledProcessError) and error.stderr:
output = error.stderr.decode(errors='replace') if isinstance(error.stderr, bytes) else error.stderr
detail += ': ' + output.strip()[:1000]
raise SystemExit('Managed public routes failed: ' + detail)
+2 -2
View File
@@ -1598,7 +1598,7 @@ def _run_remote_install(
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
shutil.copy2(project_root / 'remote' / 'oci_image_mountpoints.py', temporary / 'oci_image_mountpoints.py')
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
if deployment_kind == 'generic-multi-lxc-stack':
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
@@ -1635,7 +1635,7 @@ def _run_remote_install(
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
archive.add(project_root / 'remote' / helper, arcname=helper)
if deployment_kind == 'generic-multi-lxc-stack':
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
@@ -42,6 +42,7 @@ class JobInstallIntegrationTests(unittest.TestCase):
staged=Path(command[1]).parent/'oci_compose_jobs.py'
self.assertEqual(staged.read_bytes(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
self.assertEqual((staged.parent/'oci_carried_record.py').read_bytes(),(ROOT/'remote/oci_carried_record.py').read_bytes())
self.assertEqual((staged.parent/'oci_stack_public_routes.py').read_bytes(),(ROOT/'remote/oci_stack_public_routes.py').read_bytes())
return None
with patch.object(installer,'_stream_process',side_effect=inspect):
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'local',True)
@@ -54,6 +55,7 @@ class JobInstallIntegrationTests(unittest.TestCase):
with tarfile.open(fileobj=io.BytesIO(data),mode='r:gz') as archive:
self.assertEqual(archive.extractfile('oci_compose_jobs.py').read(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
self.assertEqual(archive.extractfile('oci_carried_record.py').read(),(ROOT/'remote/oci_carried_record.py').read_bytes())
self.assertEqual(archive.extractfile('oci_stack_public_routes.py').read(),(ROOT/'remote/oci_stack_public_routes.py').read_bytes())
return None
with patch.object(installer,'_stream_process',side_effect=inspect):
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'test-node',True)
@@ -0,0 +1,68 @@
"""Persist route contracts before startup without losing other LXC includes."""
import copy
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
import uuid
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
import install_generic_stack as runtime
import oci_stack_public_routes as routes
class PublicRouteInstallTests(unittest.TestCase):
def test_private_members_route_to_all_static_frontends_with_real_identities(self):
services = [
{'name':'web','vmid':4810,'main':True,'ip':'10.77.0.30','frontend_ipv4':'10.0.1.246/24','deployment':{}},
{'name':'mail','vmid':4811,'main':False,'frontend':True,'ip':'10.77.0.31','frontend_ipv4':'10.0.1.245/24','deployment':{}},
{'name':'worker','vmid':4812,'main':False,'ip':'10.77.0.32','deployment':{}},
]
identities = {service['vmid']: {'installation_id':str(uuid.uuid4())} for service in services}
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / '4812.conf'
config.write_text('hostname: worker\nlxc.include: /existing/sysctl.conf\n')
def path(value):
return config if str(value) == '/etc/pve/lxc/4812.conf' else Path(value)
def install(deployment, vmid, identity):
routes.validate(deployment,vmid,identity)
return f'lxc.include: {routes.include_path(vmid)}'
with patch.object(runtime,'Path',side_effect=path), patch.object(runtime.oci_instances,'read',side_effect=lambda root,vmid:identities[vmid]), patch.object(routes,'install',side_effect=install), patch.object(routes,'check',wraps=lambda text,plan,vmid,identity=None,**kw:routes.validate(plan,vmid,identity)):
runtime.install_public_routes(services,4810,'vmbr10')
self.assertIn('lxc.include: /existing/sysctl.conf\n',config.read_text())
spec=services[2]['deployment']['stack_public_routes']
self.assertEqual(spec['installation_id'],identities[4812]['installation_id'])
self.assertEqual(spec['stack_id'],identities[4810]['installation_id'])
self.assertEqual(spec['primary_vmid'],4810)
self.assertEqual(spec['routes'],[
{'destination':'10.0.1.245','via':'10.77.0.31','target_vmid':4811,'target_installation_id':identities[4811]['installation_id']},
{'destination':'10.0.1.246','via':'10.77.0.30','target_vmid':4810,'target_installation_id':identities[4810]['installation_id']}])
self.assertNotIn('stack_public_routes',services[0]['deployment'])
self.assertNotIn('stack_public_routes',services[1]['deployment'])
def test_dhcp_without_static_targets_does_not_invent_routes(self):
services=[{'main':True,'frontend_ipv4':'dhcp','deployment':{}}]
with patch.object(runtime.oci_instances,'read') as read:
runtime.install_public_routes(services,4810,'vmbr10')
read.assert_not_called()
def test_duplicate_static_ip_rejected_before_read_or_write(self):
services=[{'main':True,'frontend_ipv4':'10.0.1.246/24'}, {'frontend':True,'frontend_ipv4':'10.0.1.246/24'}]
with patch.object(runtime.oci_instances,'read') as read:
with self.assertRaisesRegex(RuntimeError,'Duplicate'):runtime.install_public_routes(services,4810,'vmbr10')
read.assert_not_called()
def test_recreate_route_include_is_last_after_other_managed_includes(self):
source=(ROOT/'remote/install_oci.sh').read_text()
flow=source[source.index('prepare_managed_format_directories "/var/lib/lxc/'):]
self.assertLess(flow.index('apply_host_monitor\n'),flow.index('ROUTE_INCLUDE=$(python3'))
self.assertLess(flow.index('apply_kept_settings\n'),flow.index('ROUTE_INCLUDE=$(python3'))
def test_routes_installed_before_jobs_and_main_start(self):
source=(ROOT/'remote/install_generic_stack.py').read_text()
main=source[source.index('def main('):]
self.assertLess(main.index('install_public_routes(services, primary_id, bridge)'),main.index('bind_completed_jobs(services)'))
self.assertLess(main.index('install_public_routes(services, primary_id, bridge)'),main.index("run('pct','start',primary_id"))
if __name__ == '__main__':unittest.main()
+199
View File
@@ -0,0 +1,199 @@
"""Managed routes cannot configure an unowned guest or the host namespace."""
import copy
from contextlib import ExitStack
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import oci_stack_public_routes as routes
OWNER = '11111111-1111-4111-8111-111111111111'
PRIMARY = '22222222-2222-4222-8222-222222222222'
MAC = '02:11:22:33:44:55'
def fixture():
return {'stack_public_routes': {'schema': 1, 'stack_id': PRIMARY, 'primary_vmid': 4900,
'vmid': 4901, 'installation_id': OWNER, 'private_bridge': 'vmbr15',
'private_address': '10.77.5.31/24', 'routes': [
{'destination': '172.30.249.90', 'via': '10.77.5.30',
'target_vmid': 4900, 'target_installation_id': PRIMARY}]}}
class PublicRouteTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
root = Path(self.temp.name)
self.stack = ExitStack()
self.addCleanup(self.stack.close)
for key, value in {'DIRECTORY': root/'owned', 'HELPER': root/'helper.py',
'CONFIGS': root/'configs', 'ROOT_UID': os.getuid()}.items():
self.stack.enter_context(patch.object(routes, key, value))
routes.CONFIGS.mkdir()
routes.HELPER.write_text(Path(routes.__file__).read_text())
routes.HELPER.chmod(0o644)
self.deployment = fixture()
self.spec = self.deployment['stack_public_routes']
def config(self, vmid, identity, private, public=None, mac=MAC):
text = f'# proxmenux-instance={identity}\nnet0: name=eth0,bridge=vmbr15,ip={private},hwaddr={mac}\n'
if public:
text += f'net1: name=eth1,bridge=vmbr909,ip={public}\n'
if vmid == 4901:
text += f'lxc.include: {routes.include_path(vmid)}\n'
path = routes.CONFIGS/f'{vmid}.conf'
path.write_text(text)
path.chmod(0o600)
return text
def install(self):
return routes.install(self.deployment, 4901, OWNER)
def test_install_check_canonical_include_and_root_owned_contract(self):
line = self.install()
self.assertEqual(line, f'lxc.include: {routes.include_path(4901)}')
self.assertNotIn('lxc.hook.version', routes.include_path(4901).read_text())
self.assertIn(' -I ', routes.include_content())
self.assertEqual(routes.check(line, self.deployment, 4901, OWNER), self.spec)
self.assertEqual(routes.contract_path(4901).stat().st_mode & 0o777, 0o600)
self.assertEqual(routes.HELPER.stat().st_mode & 0o777, 0o644)
def test_restore_can_validate_without_assets_but_not_a_foreign_include(self):
line = f'lxc.include: {routes.include_path(4901)}\n'
self.assertEqual(routes.check(line, self.deployment, 4901, OWNER, require_host_assets=False), self.spec)
with self.assertRaises(routes.RouteError):
routes.check(line.replace('4901.conf','4902.conf'), self.deployment, 4901, OWNER, require_host_assets=False)
self.assertEqual(routes.renumber_line(line.encode(),4901,5901), f'lxc.include: {routes.include_path(5901)}\n'.encode())
def test_reused_vmid_cannot_remove_another_installation_contract(self):
self.install()
with self.assertRaises(routes.RouteError):routes.remove(4901, PRIMARY)
self.assertTrue(routes.contract_path(4901).exists())
self.assertTrue(routes.remove(4901, OWNER))
self.assertFalse(routes.include_path(4901).exists())
def test_bad_identity_and_unsafe_routes_fail_before_commands(self):
changes = [lambda s:s.update(vmid=4902), lambda s:s.update(installation_id=PRIMARY),
lambda s:s['routes'][0].update(destination='0.0.0.0/0'),
lambda s:s['routes'][0].update(destination='127.0.0.1'),
lambda s:s['routes'][0].update(via='172.30.249.1'),
lambda s:s['routes'][0].update(destination=True),
lambda s:s['routes'].append(copy.deepcopy(s['routes'][0]))]
with patch.object(routes.subprocess,'run') as run:
for change in changes:
value=copy.deepcopy(self.deployment);change(value['stack_public_routes'])
with self.subTest(value=value),self.assertRaises(routes.RouteError):routes.validate(value,4901,OWNER)
run.assert_not_called()
def test_symlink_and_unsafe_helper_fail_closed(self):
routes.DIRECTORY.mkdir()
target=Path(self.temp.name)/'other';target.write_text('do not touch')
routes.include_path(4901).symlink_to(target)
with self.assertRaises(routes.RouteError):self.install()
self.assertEqual(target.read_text(),'do not touch')
routes.include_path(4901).unlink()
routes.HELPER.chmod(0o666)
with self.assertRaises(routes.RouteError):self.install()
def test_symlink_directory_and_foreign_file_owner_are_rejected(self):
outside=Path(self.temp.name)/'outside';outside.mkdir()
routes.DIRECTORY.symlink_to(outside, target_is_directory=True)
with self.assertRaises(routes.RouteError):self.install()
unsafe=SimpleNamespace(lstat=lambda:SimpleNamespace(st_mode=stat.S_IFREG|0o600,st_uid=routes.ROOT_UID+1,st_size=10))
with self.assertRaises(routes.RouteError):routes._read(unsafe)
def test_live_identity_and_frontend_addresses_are_verified(self):
self.install()
self.config(4901,OWNER,'10.77.5.31/24')
self.config(4900,PRIMARY,'10.77.5.30/24','172.30.249.90/24')
self.assertEqual(routes.live_contract(self.spec),MAC)
self.config(4900,OWNER,'10.77.5.30/24','172.30.249.90/24')
with self.assertRaises(routes.RouteError):routes.live_contract(self.spec)
self.config(4900,PRIMARY,'10.77.5.30/24','172.30.249.91/24')
with self.assertRaises(routes.RouteError):routes.live_contract(self.spec)
def test_tampered_or_world_writable_contract_is_rejected(self):
self.install();routes.contract_path(4901).chmod(0o666)
with self.assertRaises(routes.RouteError):routes.check(f'lxc.include: {routes.include_path(4901)}',self.deployment,4901,OWNER)
def test_namespace_is_a_net_namespace_and_never_the_host(self):
with patch.object(routes.os,'open',return_value=17),patch.object(routes.os,'close') as close, \
patch.object(routes.fcntl,'ioctl',return_value=routes.CLONE_NEWNET), \
patch.object(routes.os,'fstat',return_value=SimpleNamespace(st_dev=1,st_ino=9)), \
patch.object(routes.os,'stat',return_value=SimpleNamespace(st_dev=1,st_ino=9)):
with self.assertRaisesRegex(routes.RouteError,'host network'):routes.namespace_fd('/proc/123/fd/4')
close.assert_called_once_with(17)
with patch.object(routes.os,'open',return_value=17),patch.object(routes.os,'close'), \
patch.object(routes.fcntl,'ioctl',return_value=0):
with self.assertRaisesRegex(routes.RouteError,'not a network'):routes.namespace_fd('/proc/123/fd/4')
with patch.object(routes.os,'open') as opened:
for value in (None,'/proc/self/ns/net','/etc/passwd','/proc/123/ns/mnt'):
with self.assertRaises(routes.RouteError):routes.namespace_fd(value)
opened.assert_not_called()
def test_routes_are_only_32_in_verified_namespace_with_no_shell(self):
interface=[{'address':MAC,'addr_info':[{'family':'inet','local':'10.77.5.31','prefixlen':24}]}]
with patch.object(routes,'_namespace_command',side_effect=[json.dumps(interface).encode(),b'']) as command:
routes.apply(self.spec,17,MAC)
self.assertEqual(command.call_args_list[0].args,(17,'-j','address','show','dev','eth0'))
self.assertEqual(command.call_args.args,(17,'-4','route','replace','172.30.249.90/32','via','10.77.5.30','dev','eth0','src','10.77.5.31'))
with patch.object(routes.subprocess,'run',return_value=SimpleNamespace(stdout=b'ok')) as run:
routes._namespace_command(17,'-4','route','show')
self.assertEqual(run.call_args.args[0][:3],[routes.NSENTER,'--net=/proc/self/fd/17','--'])
self.assertEqual(run.call_args.kwargs['pass_fds'],(17,));self.assertNotIn('shell',run.call_args.kwargs)
def test_engine_helper_can_change_normally_without_stale_snapshot_adoption(self):
routes.HELPER.write_text('# trusted engine update, same stable path\n')
self.install()
self.assertEqual(routes.HELPER.read_text(),'# trusted engine update, same stable path\n')
def test_namespace_with_wrong_owner_address_cannot_receive_routes(self):
for value in ([], [{'address':'02:aa:bb:cc:dd:ee','addr_info':[{'family':'inet','local':'10.77.5.31','prefixlen':24}]}]):
with patch.object(routes,'_namespace_command',return_value=json.dumps(value).encode()) as command:
with self.assertRaises(routes.RouteError):routes.apply(self.spec,17,MAC)
self.assertEqual(command.call_count,1)
def test_ip_failure_propagates_so_startup_cannot_report_success(self):
interface=[{'address':MAC,'addr_info':[{'family':'inet','local':'10.77.5.31','prefixlen':24}]}]
with patch.object(routes,'_namespace_command',side_effect=[json.dumps(interface).encode(),subprocess.CalledProcessError(2,['ip'])]):
with self.assertRaises(subprocess.CalledProcessError):routes.apply(self.spec,17,MAC)
def test_start_uses_only_the_runtime_pid_namespace(self):
self.install()
with patch.dict(os.environ,{'LXC_NAME':'4901','LXC_PID':'12345'},clear=True), \
patch.object(routes.os,'geteuid',return_value=0), \
patch.object(routes,'live_contract',return_value=MAC), \
patch.object(routes,'namespace_fd',return_value=17) as namespace, \
patch.object(routes,'apply') as apply, patch.object(routes.os,'close') as close:
routes.start()
namespace.assert_called_once_with('/proc/12345/ns/net')
apply.assert_called_once_with(self.spec,17,MAC)
close.assert_called_once_with(17)
def test_default_hook_arguments_must_match_owner_and_phase(self):
with patch.dict(os.environ,{'LXC_NAME':'4901'},clear=True), \
patch.object(routes.os,'geteuid',return_value=0),patch.object(routes,'start') as start:
with patch.object(sys,'argv',['helper','start','4901','lxc','start-host']):routes.main()
start.assert_called_once()
start.reset_mock()
with patch.object(sys,'argv',['helper','start','4902','lxc','start-host']), self.assertRaises(routes.RouteError):routes.main()
with patch.object(sys,'argv',['helper','start','4901','lxc','post-stop']), self.assertRaises(routes.RouteError):routes.main()
start.assert_not_called()
def test_missing_lxc_pid_is_not_replaced_by_a_host_fallback(self):
self.install()
with patch.dict(os.environ,{'LXC_NAME':'4901'},clear=True),patch.object(routes.os,'geteuid',return_value=0),patch.object(routes,'live_contract',return_value=MAC),patch.object(routes,'apply') as apply:
with self.assertRaises(routes.RouteError):routes.start()
apply.assert_not_called()
if __name__=='__main__':unittest.main()
@@ -0,0 +1,131 @@
"""Managed frontend /32 routes remain bounded and portable across lifecycle APIs."""
import copy
import contextlib
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'remote'))
import oci_instance_transaction as tx
import oci_restore_recovery as recovery
import oci_stack_plan as plans
import oci_stack_public_routes as routes
import oci_remove as removal
MAIN='11111111-1111-4111-8111-111111111111'
WORKER='22222222-2222-4222-8222-222222222222'
FOREIGN='33333333-3333-4333-8333-333333333333'
def deployment():
return {'vmid':4901,'mounts':[],'stack_public_routes':{'schema':1,'stack_id':MAIN,
'primary_vmid':4900,'vmid':4901,'installation_id':WORKER,'private_bridge':'vmbr15',
'private_address':'10.77.5.31/24','routes':[{'destination':'192.0.2.10','via':'10.77.5.30',
'target_vmid':4900,'target_installation_id':MAIN}]}}
def records():
return {4900:{'vmid':4900,'installation_id':MAIN,'deployment':{},'stack':{'members':[
{'vmid':4900,'installation_id':MAIN},{'vmid':4901,'installation_id':WORKER}]}},
4901:{'vmid':4901,'installation_id':WORKER,'deployment':deployment()}}
class ManagedRouteLifecycleTests(unittest.TestCase):
@contextlib.contextmanager
def assets(self,folder):
with patch.multiple(routes,DIRECTORY=Path(folder)/'private',HELPER=Path(folder)/'canonical.py',ROOT_UID=os.geteuid()):
# The installed engine is trusted; guest-carried code is never used.
routes.HELPER.write_bytes(Path(routes.__file__).read_bytes())
routes.HELPER.chmod(0o755)
yield
def test_managed_include_coexists_with_sysctl_validation_and_rejects_unknown_include(self):
with tempfile.TemporaryDirectory() as folder,self.assets(folder):
plan=deployment();routes.install(plan,4901,WORKER)
config=(f'description: proxmenux-instance={WORKER}\nrootfs: local-zfs:subvol-4901-disk-0,size=8G\n'
f'lxc.include: {routes.include_path(4901)}\n').encode()
tx.check_runtime_mounts(config,plan)
with self.assertRaisesRegex(ValueError,'sysctl include'):
tx.check_runtime_mounts(config+b'lxc.include: /unknown/profile.conf\n',plan)
with self.assertRaisesRegex(ValueError,'differs from the contract'):
tx.check_runtime_mounts(config,{'vmid':4901,'mounts':[]})
def test_plan_rejects_foreign_target_even_if_supplied_in_record_inventory(self):
good=records();plans.validate_public_route_members(4900,good)
bad=copy.deepcopy(good);bad[4999]={'vmid':4999,'installation_id':FOREIGN,'deployment':{}}
route=bad[4901]['deployment']['stack_public_routes']['routes'][0]
route.update(target_vmid=4999,target_installation_id=FOREIGN)
with self.assertRaisesRegex(ValueError,'Invalid stack contract'):
plans.validate_public_route_members(4900,bad)
def test_plan_rejects_changed_target_and_stack_identities(self):
for field in ('target_installation_id','stack_id'):
bad=records();spec=bad[4901]['deployment']['stack_public_routes']
if field=='stack_id':spec[field]=FOREIGN
else:spec['routes'][0][field]=FOREIGN
with self.subTest(field=field),self.assertRaisesRegex(ValueError,'Invalid stack contract'):
plans.validate_public_route_members(4900,bad)
def test_restore_renumbers_only_managed_paths_and_all_route_member_ids(self):
with tempfile.TemporaryDirectory() as folder,self.assets(folder):
mapped=recovery.renumber(records(),{4900:4800,4901:4801})
# Mapping dictionary keys are not VMID-valued fields: select records by their new vmid.
mapped={value['vmid']:value for value in mapped.values()}
plans.validate_public_route_members(4800,mapped)
spec=mapped[4801]['deployment']['stack_public_routes']
self.assertEqual((spec['vmid'],spec['primary_vmid'],spec['routes'][0]['target_vmid']),(4801,4800,4800))
self.assertEqual(spec['installation_id'],WORKER)
original=f'lxc.include: {routes.include_path(4901)}\nlxc.include: /unrelated/4901.conf\n'
changed=recovery.renumbered_lines(original,4901,4801)
self.assertIn(f'lxc.include: {routes.include_path(4801)}',changed)
self.assertIn('lxc.include: /unrelated/4901.conf',changed)
routes.check(changed.splitlines()[0]+'\n',mapped[4801]['deployment'],4801,WORKER,require_host_assets=False)
def test_restore_republishes_only_canonical_host_code_before_start(self):
with tempfile.TemporaryDirectory() as folder,self.assets(folder):
plan=recovery.renumber(deployment(),{4900:4800,4901:4801})
entry={'vmid':4801,'installation_id':WORKER,'config':f'lxc.include: {routes.include_path(4801)}\n',
'copy':{'record':{'deployment':plan},'untrusted_script':'do not execute'}}
with patch.object(recovery,'renumber_config'):
recovery.restore_host_files(entry)
self.assertEqual(routes.HELPER.read_bytes(),Path(routes.__file__).read_bytes())
self.assertEqual(json.loads(routes.contract_path(4801).read_text())['routes'][0]['target_vmid'],4800)
routes.check(entry['config'],plan,4801,WORKER)
def test_restore_plan_refuses_foreign_route_before_host_assets_are_created(self):
bad=records();bad[4901]['deployment']['stack_public_routes']['routes'][0]['target_vmid']=4999
plan={'primary':4900,'members':[{'vmid':v} for v in bad],'restored':[
{'vmid':v,'copy':{'record':record}} for v,record in bad.items()],'blockers':[]}
recovery.check_public_route_members(Path('/unused'),plan)
self.assertTrue(any('Invalid stack contract' in value for value in plan['blockers']))
def test_recreate_keeps_managed_routes_outside_editable_desired_state(self):
old={'vmid':4901,'installation_id':WORKER,'status':'installed',
'template':{'id':'fixture','proxmox':{'installer_profile':{}},
'first_run':{'endpoints':[{'scheme':'http','port':80,'path':'/'}]}},
'deployment':deployment()}
candidate=copy.deepcopy(old);candidate['deployment'].pop('stack_public_routes')
candidate['deployment']['resources']={'cores':2,'memory_mb':1024}
result=tx.candidate_contract(old,'recreate',{'operation':'recreate','candidate':candidate})
self.assertEqual(result['deployment']['stack_public_routes'],old['deployment']['stack_public_routes'])
result['deployment']['stack_public_routes']['routes'][0]['destination']='192.0.2.11'
self.assertEqual(old['deployment']['stack_public_routes']['routes'][0]['destination'],'192.0.2.10')
def test_orphan_sweep_cleans_only_absent_owned_routes_and_keeps_pending_or_reused_ids(self):
for case in ('absent','pending','reused','changed'):
with self.subTest(case=case),tempfile.TemporaryDirectory() as folder,self.assets(folder):
root=Path(folder)/'records';root.mkdir()
routes.install(deployment(),4901,WORKER)
if case=='changed':routes.include_path(4901).write_text('foreign hook')
with patch.object(removal.instances,'guest_exists',return_value=case=='reused'), \
patch.object(removal.instances,'has_contract',return_value=case=='pending'), \
patch.object(removal.instances,'read',return_value={'installation_id':WORKER}), \
patch.object(removal.instances,'release_orphan',return_value=False), \
patch.object(removal,'remove_host_state'), \
patch.object(removal.oci_console,'remove_log'), \
patch.object(removal,'release_shared_host_files'):
cleaned=removal.sweep_orphans(root)
self.assertEqual(cleaned,[4901] if case=='absent' else [])
self.assertEqual(routes.contract_path(4901).exists(),case!='absent')
self.assertEqual(routes.include_path(4901).exists(),case!='absent')