255 lines
14 KiB
Python
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)
|