Files
archy/scripts/public_update_runtime.py

255 lines
14 KiB
Python

"""Runtime portion of the public application transaction.
Bundled with the installed updater, before public_update_worker.py. These are
code/configuration destinations, never wallet, container or user data paths.
The public owner retains the existing host privilege model; protected D09/C01
installations are rejected by the common admission code.
"""
RUNTIME_RECORD = Path('/etc/archipelago/public-update-runtime.json')
RUNTIME_OBJECTS = {
'apps': ('/opt/archipelago/apps', 'apps', 'directory', True),
'scripts': ('/opt/archipelago/scripts', 'scripts', 'directory', True),
'docker': ('/opt/archipelago/docker', 'docker', 'directory', True),
'image_versions': ('/opt/archipelago/image-versions.sh', 'scripts/image-versions.sh', 'executable', True),
'reticulum': ('/usr/local/bin/archy-reticulum-daemon', 'radio-tools/archy-reticulum-daemon', 'executable', True),
'rnode': ('/usr/local/bin/archy-rnodeconf', 'radio-tools/archy-rnodeconf', 'executable', True),
'esptool': ('/usr/local/bin/archy-esptool', 'radio-tools/archy-esptool', 'executable', True),
'doctor_service': ('/etc/systemd/system/archipelago-doctor.service', 'image-recipe/configs/archipelago-doctor.service', 'file', True),
'doctor_timer': ('/etc/systemd/system/archipelago-doctor.timer', 'image-recipe/configs/archipelago-doctor.timer', 'file', True),
'audit_service': ('/etc/systemd/system/archipelago-host-secrets-audit.service', 'image-recipe/configs/archipelago-host-secrets-audit.service', 'file', True),
'npm_service': ('/etc/systemd/system/archipelago-npm-bridge.service', 'image-recipe/configs/archipelago-npm-bridge.service', 'file', True),
'npm_timer': ('/etc/systemd/system/archipelago-npm-bridge.timer', 'image-recipe/configs/archipelago-npm-bridge.timer', 'file', True),
'kiosk_service': ('/etc/systemd/system/archipelago-kiosk.service', 'image-recipe/configs/archipelago-kiosk.service', 'file', False),
'kiosk_launcher': ('/usr/local/bin/archipelago-kiosk-launcher', 'image-recipe/configs/archipelago-kiosk-launcher.sh', 'executable', False),
'audio_service': ('/etc/systemd/system/archipelago-audio-router.service', 'image-recipe/configs/archipelago-audio-router.service', 'file', False),
'audio_router': ('/usr/local/bin/archipelago-audio-router', 'image-recipe/configs/archipelago-audio-router.sh', 'executable', False),
'gamepad_service': ('/etc/systemd/system/archipelago-gamepad-keys.service', 'image-recipe/configs/archipelago-gamepad-keys.service', 'file', False),
'gamepad_keys': ('/usr/local/bin/archipelago-gamepad-keys', 'image-recipe/configs/archipelago-gamepad-keys.py', 'executable', False),
'routing': ('/etc/nginx/sites-available/archipelago', '', 'file', False),
'managed': (str(RUNTIME_RECORD), '', 'file', True),
}
MAINTENANCE_UNITS = ('archipelago-doctor.timer', 'archipelago-npm-bridge.timer',
'archipelago-doctor.service', 'archipelago-npm-bridge.service')
def runtime_identity(path):
try:
info = path.lstat()
except FileNotFoundError:
return None
if stat.S_ISDIR(info.st_mode):
return {'kind': 'directory', 'sha256': tree(path)}
if stat.S_ISREG(info.st_mode) and not info.st_mode & 0o6000:
return {'kind': 'file', 'sha256': digest_file(path), 'executable': bool(info.st_mode & 0o111)}
raise ValueError('unsupported runtime object; retain it for operator review')
def runtime_copy(source, target):
if not os.path.lexists(source):
return None
identity = runtime_identity(source)
if identity['kind'] == 'directory':
tree(source, target)
else:
fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
copy_file_fd(fd, target, identity['executable'])
finally:
os.close(fd)
if runtime_identity(target) != identity:
raise ValueError('runtime preimage changed while freezing')
return identity
def runtime_unit_states(units=None):
result = {}
for unit in MAINTENANCE_UNITS if units is None else units:
fields = command('/usr/bin/systemctl', 'show', unit, '-p', 'LoadState',
'-p', 'ActiveState', '-p', 'UnitFileState', '-p', 'Job', timeout=10)
result[unit] = dict(line.split('=', 1) for line in fields.splitlines() if '=' in line)
return result
def runtime_pause_units(tx, state):
timers = [unit for unit in MAINTENANCE_UNITS if unit.endswith('.timer')]
services = [unit for unit in MAINTENANCE_UNITS if unit.endswith('.service')]
before = runtime_unit_states(timers)
if any(value.get('ActiveState') not in ('active', 'inactive', 'failed')
for value in before.values()):
raise ValueError('maintenance timer transition in flight; retry after it finishes')
# Persist timer decisions before stopping anything. If draining fails, both
# recovery and boot recovery can restore exactly the previously active set.
state['maintenance_units'] = before
state = journal(tx, state, 'preparing')
runtime_hold_maintenance(state)
for unit, value in before.items():
if value['ActiveState'] == 'active':
command('/usr/bin/systemctl', 'stop', unit)
# A timer's already queued helper can begin when the starting manager is
# stopped. Its oneshot state is "activating", not "active". Let that work
# finish without killing it; the pause condition prevents further starts.
# Also wait for queued jobs, including inactive units awaiting conditions.
deadline = time.monotonic() + 360
while True:
drained = runtime_unit_states(services)
if all(value.get('ActiveState') in ('inactive', 'failed')
and value.get('Job', '0') in ('', '0') for value in drained.values()):
state['maintenance_units'].update(drained)
return journal(tx, state, 'preparing')
if time.monotonic() >= deadline:
raise ValueError('maintenance helpers did not drain before the update deadline')
time.sleep(0.5)
def runtime_hold_maintenance(state):
if not state.get('runtime_startup_guard'):
return
marker = TRANSACTIONS / 'maintenance-paused'
if os.path.lexists(marker) and secure_read(marker) != state['id'].encode():
raise ValueError('different public transaction owns maintenance pause')
atomic(marker, state['id'].encode())
def runtime_resume_units(state):
for unit, before in state.get('maintenance_units', {}).items():
enabled = command('/usr/bin/systemctl', 'show', unit, '-p', 'UnitFileState', '--value').strip()
expected = before.get('UnitFileState', '')
if (state.get('runtime_startup_guard') and state.get('phase') in ('restored-pending-start', 'aborted')
and unit.endswith('.timer') and expected == 'disabled' and enabled == 'enabled'):
# The authenticated older binary enables these two timers during
# startup. The pause condition prevents execution; remove only its
# exact standard link to restore a recorded disabled decision.
link = UNIT_DIR / 'timers.target.wants' / unit
info = link.lstat()
if (not stat.S_ISLNK(info.st_mode) or info.st_uid
or os.readlink(link) != str(UNIT_DIR / unit)):
raise ValueError('foreign maintenance enablement link')
link.unlink(); sync_directory(link.parent)
command('/usr/bin/systemctl', 'daemon-reload')
enabled = command('/usr/bin/systemctl', 'show', unit, '-p', 'UnitFileState', '--value').strip()
if before.get('LoadState') == 'not-found' and enabled in ('disabled', 'static'):
expected = enabled
# Adding/removing an [Install] section changes static/disabled while
# both still mean no enablement link. Preserve that operator decision.
if enabled != expected and {enabled, expected} != {'static', 'disabled'}:
raise ValueError('maintenance enablement changed during update')
marker = TRANSACTIONS / 'maintenance-paused'
if state.get('runtime_startup_guard') and os.path.lexists(marker):
if secure_read(marker) != state['id'].encode():
raise ValueError('different public transaction owns maintenance pause')
marker.unlink(); sync_directory(marker.parent)
for unit, before in state.get('maintenance_units', {}).items():
if before['ActiveState'] == 'active':
command('/usr/bin/systemctl', 'start', unit)
def runtime_prepare(tx, state):
"""Freeze exact old and new images before allowing any runtime replacement."""
payload = tx / 'new-frontend/archipelago-runtime'
if not payload.is_dir():
raise ValueError('public application archive lacks its runtime payload')
old_dir, new_dir = tx / 'old-runtime', tx / 'new-runtime'
old_dir.mkdir(mode=0o700); new_dir.mkdir(mode=0o700)
# Preserve accepted routing inside the incoming frontend, so ordinary
# runtime reads do not see a generic template replacing local settings.
routing = Path(RUNTIME_OBJECTS['routing'][0])
if routing.exists():
if state.get('adoption'):
routing_raw = legacy_routing_bytes(routing)
else:
os.close(trusted_open(routing))
routing_raw = secure_read(routing, 1024**2)
command('/usr/sbin/nginx', '-t')
template = payload / 'image-recipe/configs/nginx-archipelago.conf'
atomic(template, routing_raw, 0o444)
record = {'schema': 1, 'version': state['new_version'],
'backend_sha256': state['new']['binary'], 'transaction': state['id']}
result = {}
for name, (destination, relative, kind, required) in RUNTIME_OBJECTS.items():
target = Path(destination)
previous = runtime_copy(target, old_dir / name)
if name == 'managed':
atomic(new_dir / name, json.dumps(record, sort_keys=True).encode(), 0o444)
elif name == 'routing' or (not required and previous is None):
runtime_copy(old_dir / name, new_dir / name)
else:
incoming = payload / relative
if not incoming.exists():
raise ValueError('public runtime payload missing: ' + name)
runtime_copy(incoming, new_dir / name)
if name in ('apps', 'docker') and previous is not None:
# Public payloads intentionally omit private/custom apps. Keep
# those installed source directories locally; absence from the
# catalog must not become an uninstall or lose their settings.
for retained in (old_dir / name).iterdir():
destination = new_dir / name / retained.name
if not os.path.lexists(destination):
runtime_copy(retained, destination)
if kind == 'executable':
durable_mode(new_dir / name, 0o555)
if name == 'scripts':
for script in (new_dir / name).rglob('*.sh'):
durable_mode(script, 0o555)
result[name] = {'old': previous, 'new': runtime_identity(new_dir / name)}
sync_directory(old_dir); sync_directory(new_dir)
state['runtime'] = result
state['new'] = pair_identity(tx / 'new-binary', tx / 'new-frontend')
return state
def runtime_verify_snapshots(tx, state):
if set(state['runtime']) != set(RUNTIME_OBJECTS):
raise ValueError('runtime journal domain differs')
for name, identities in state['runtime'].items():
for label in ('old', 'new'):
if runtime_identity(tx / (label + '-runtime') / name) != identities[label]:
raise ValueError('protected runtime snapshot changed')
def runtime_replace(tx, state, label):
runtime_verify_snapshots(tx, state)
# Validate the whole installed domain first. Never hide a foreign edit by
# overwriting it, including when resuming a partial directory rename.
for name, (destination, _, _, _) in RUNTIME_OBJECTS.items():
actual = runtime_identity(Path(destination))
if actual is not None and actual not in state['runtime'][name].values():
raise ValueError('foreign runtime/configuration change: ' + name)
for name, (destination, _, _, _) in RUNTIME_OBJECTS.items():
target = Path(destination)
wanted = state['runtime'][name][label]
if runtime_identity(target) == wanted:
continue
if not target.parent.exists():
if target.parent != RUNTIME_RECORD.parent:
raise ValueError('missing standard runtime destination parent')
os.close(trusted_open(target.parent.parent, directory=True))
target.parent.mkdir(mode=0o755)
sync_directory(target.parent.parent)
os.close(trusted_open(target.parent, directory=True))
temporary = target.with_name('.public-runtime-new-' + uuid.uuid4().hex)
if wanted is not None:
runtime_copy(tx / (label + '-runtime') / name, temporary)
if wanted['kind'] == 'directory':
for directory, _, files in os.walk(temporary):
for file in files:
path = Path(directory) / file
durable_mode(path, 0o755 if path.stat().st_mode & 0o111 else 0o644)
durable_mode(Path(directory), 0o755)
else:
durable_mode(temporary, 0o755 if wanted['executable'] else 0o644)
if os.path.lexists(target):
target.rename(target.with_name('.public-runtime-retired-' + uuid.uuid4().hex))
sync_directory(target.parent)
if wanted is not None:
temporary.rename(target)
sync_directory(target.parent)
command('/usr/bin/systemctl', 'daemon-reload')
runtime_verify_installed(state, label)
def runtime_verify_installed(state, label):
for name, (destination, _, _, _) in RUNTIME_OBJECTS.items():
if runtime_identity(Path(destination)) != state['runtime'][name][label]:
raise ValueError('installed runtime/configuration differs: ' + name)