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