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
+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)