336 lines
17 KiB
Python
336 lines
17 KiB
Python
#!/usr/bin/python3 -I
|
|
"""One-shot independent-admin bootstrap into the actual D09 lifecycle owner.
|
|
|
|
No application entrypoint, unsigned mode, arbitrary command, ledger reset or
|
|
financial restore. A failed/interrupted activation always requires external
|
|
reconciliation. The owner alone subsequently consumes APPLY authority.
|
|
"""
|
|
import argparse
|
|
import ast
|
|
import fcntl
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import pwd
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import os_updater_bootstrap as bootstrap
|
|
|
|
BASE = Path('/var/lib/archipelago-os-bootstrap')
|
|
OS_ROOT = Path('/var/lib/archipelago-os-update')
|
|
LEGACY_OS_ROOT = Path('/var/lib/archipelago/os-update')
|
|
D09 = Path('/var/lib/archipelago-d09')
|
|
ADMISSION = D09 / 'incoming/admission.json'
|
|
VERIFIER = Path('/usr/libexec/archipelago-os-release-verifier')
|
|
TIMERS = ('apt-daily.timer', 'apt-daily-upgrade.timer', 'apt-daily.service',
|
|
'apt-daily-upgrade.service', 'unattended-upgrades.service')
|
|
OWNER_DROPIN = Path('/etc/systemd/system/archipelago-d09-lifecycle.service.d/55-os-bootstrap.conf')
|
|
OWNER_POLICY = (b'[Service]\n'
|
|
b'ReadWritePaths=/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock '
|
|
b'/var/lib/archipelago-os-update/transactions '
|
|
b'/var/lib/archipelago-os-update/executor.lock\n')
|
|
OS_ROOTS = (Path('/var/lib/archipelago-os-update'),)
|
|
|
|
|
|
def owner_policy(root):
|
|
if root not in OS_ROOTS:
|
|
raise ValueError('unsupported protected OS state location')
|
|
return (b'[Service]\nReadWritePaths=/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock '
|
|
+ str(root / 'transactions').encode() + b' '
|
|
+ str(root / 'executor.lock').encode() + b'\n')
|
|
|
|
|
|
def declared_root(package):
|
|
"""Read the pinned executor's fixed storage ABI without executing its code."""
|
|
tree = ast.parse(bootstrap.read(package / 'scripts/os_update_executor.py'))
|
|
roots = [node.value for node in tree.body if isinstance(node, ast.Assign)
|
|
and any(isinstance(t, ast.Name) and t.id == 'ROOT' for t in node.targets)]
|
|
if len(roots) != 1 or not isinstance(roots[0], ast.Call):
|
|
raise ValueError('executor ROOT declaration')
|
|
call = roots[0]
|
|
if (not isinstance(call.func, ast.Attribute) or call.func.attr != 'Path'
|
|
or not isinstance(call.func.value, ast.Name) or call.func.value.id != 'pathlib'
|
|
or len(call.args) != 1 or call.keywords):
|
|
raise ValueError('executor fixed ROOT declaration')
|
|
root = Path(ast.literal_eval(call.args[0]))
|
|
owner_policy(root)
|
|
return root
|
|
|
|
|
|
def command(*args):
|
|
result = subprocess.run(args, check=True, capture_output=True, text=True,
|
|
timeout=120, env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin',
|
|
'LC_ALL': 'C', 'PYTHONDONTWRITEBYTECODE': '1'})
|
|
return result.stdout
|
|
|
|
|
|
def protected_dir(path):
|
|
fd = bootstrap.secure(path, directory=True)
|
|
os.close(fd)
|
|
|
|
|
|
def ensure_dir(path):
|
|
if not path.exists():
|
|
ensure_dir(path.parent)
|
|
path.mkdir(mode=0o700)
|
|
bootstrap.sync(path.parent)
|
|
protected_dir(path)
|
|
|
|
|
|
def destination_custody(path):
|
|
"""Reject an application-owned ancestor before consuming activation intent."""
|
|
while not path.exists() and not path.is_symlink():
|
|
path = path.parent
|
|
protected_dir(path)
|
|
|
|
|
|
def same(path, expected):
|
|
raw = bootstrap.read(path)
|
|
if bootstrap.digest(raw) != bootstrap.pin(expected):
|
|
raise ValueError('activation pin drift: ' + str(path))
|
|
return raw
|
|
|
|
|
|
def package_inventory(root):
|
|
protected_dir(root)
|
|
result = {}
|
|
for directory, dirs, files in os.walk(root, followlinks=False):
|
|
protected_dir(Path(directory))
|
|
for name in dirs:
|
|
protected_dir(Path(directory) / name)
|
|
for name in files:
|
|
path = Path(directory) / name
|
|
result[str(path.relative_to(root))] = bootstrap.digest(bootstrap.read(path))
|
|
return result
|
|
|
|
|
|
def untouched_authority():
|
|
# Never adopt/reset state from the retired application-owned ancestry.
|
|
try:
|
|
parent = os.open(LEGACY_OS_ROOT.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
except FileNotFoundError:
|
|
parent = None
|
|
if parent is not None:
|
|
try:
|
|
try:
|
|
os.stat(LEGACY_OS_ROOT.name, dir_fd=parent, follow_symlinks=False)
|
|
except FileNotFoundError:
|
|
pass
|
|
else:
|
|
raise ValueError('legacy OS state requires explicit root migration/reconciliation')
|
|
finally:
|
|
os.close(parent)
|
|
for path in (D09 / 'ledger.json', Path('/etc/archipelago-d09/binding.json')):
|
|
if path.exists() or path.is_symlink():
|
|
raise ValueError('existing D09 authority requires reconciliation')
|
|
for root in set((*OS_ROOTS, OS_ROOT)):
|
|
transactions = root / 'transactions'
|
|
if transactions.exists() or transactions.is_symlink():
|
|
protected_dir(transactions)
|
|
if any(transactions.iterdir()):
|
|
raise ValueError('existing OS transaction requires reconciliation')
|
|
|
|
|
|
def current_exclusion():
|
|
# External administrator establishes exclusion; bootstrap only verifies it.
|
|
# Leave existing timers/accounts/keys/access policy unchanged.
|
|
for unit in TIMERS:
|
|
raw = command('/usr/bin/systemctl', 'show', unit, '--property=ActiveState,UnitFileState')
|
|
value = dict(line.split('=', 1) for line in raw.splitlines() if '=' in line)
|
|
if value.get('ActiveState') not in ('inactive', 'failed') or value.get('UnitFileState') not in ('masked', 'masked-runtime'):
|
|
raise ValueError('package writer exclusion not established: ' + unit)
|
|
raw = command('/usr/bin/systemctl', 'show', 'archipelago.service', '--property=MainPID,ActiveState')
|
|
value = dict(line.split('=', 1) for line in raw.splitlines() if '=' in line)
|
|
if value.get('MainPID') != '0' or value.get('ActiveState') not in ('inactive', 'failed'):
|
|
raise ValueError('old manager must be externally drained')
|
|
|
|
|
|
def activate(approval_path, expected):
|
|
global OS_ROOT
|
|
if os.geteuid() != 0:
|
|
raise ValueError('independent root administrator required')
|
|
approval_raw = same(approval_path, expected)
|
|
approval = bootstrap.decode(approval_raw)
|
|
if set(approval) != {'version', 'prepared_sha256', 'package_root', 'package_files',
|
|
'admission_sha256', 'os_inputs', 'helper_sha256', 'owner_dropin_sha256'} or approval['version'] != 1:
|
|
raise ValueError('activation approval fields')
|
|
# Pin the implementation as well as the package it will execute.
|
|
same(Path(__file__), approval['helper_sha256'])
|
|
protected_dir(BASE)
|
|
lock = os.open(BASE / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
fd = bootstrap.secure(BASE / 'lock'); os.close(fd)
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
if any(BASE.glob('activation-*')):
|
|
raise ValueError('existing activation intent; external reconciliation required')
|
|
untouched_authority()
|
|
prepared = BASE / 'prepared'
|
|
raw = same(prepared / 'approval.json', approval['prepared_sha256'])
|
|
plan = bootstrap.decode(raw)
|
|
bootstrap.validate(plan)
|
|
control = bootstrap.decode(bootstrap.read(prepared / 'control.json'))
|
|
if control.get('state') != 'PREPARED_NOT_ACTIVATED' or control.get('approval_sha256') != approval['prepared_sha256']:
|
|
raise ValueError('prepared control differs')
|
|
same(Path('/etc/machine-id'), plan['machine_id_sha256'])
|
|
same(Path('/usr/local/bin/archipelago'), plan['current']['backend_sha256'])
|
|
for path, sha in plan['observations'].items():
|
|
same(Path('/') / path, sha)
|
|
for name, sha in plan['files'].items():
|
|
same(prepared / name, sha)
|
|
admin = pwd.getpwuid(plan['admin']['uid'])
|
|
service = pwd.getpwnam('archipelago')
|
|
if service.pw_uid != plan['admin']['service_uid'] or admin.pw_uid == service.pw_uid:
|
|
raise ValueError('administrator/service principal changed')
|
|
package = Path(approval['package_root'])
|
|
if not package.is_absolute() or package_inventory(package) != approval['package_files']:
|
|
raise ValueError('protected package inventory differs')
|
|
OS_ROOT = declared_root(package)
|
|
policy = owner_policy(OS_ROOT)
|
|
if approval['owner_dropin_sha256'] != bootstrap.digest(policy):
|
|
raise ValueError('owner bootstrap policy pin differs')
|
|
provision = package / 'scripts/provision-d09-lifecycle.py'
|
|
if 'scripts/provision-d09-lifecycle.py' not in approval['package_files']:
|
|
raise ValueError('missing pinned provisioner')
|
|
admission_raw = same(ADMISSION, approval['admission_sha256'])
|
|
admission = bootstrap.decode(admission_raw)
|
|
if admission['release_identity'][0] != plan['candidate']['version']:
|
|
raise ValueError('candidate version differs from authenticated D09 release')
|
|
if admission['prerequisite']['backend_sha256'] != plan['current']['backend_sha256'] or admission['artifacts']['backend'] != plan['candidate']['backend_sha256']:
|
|
raise ValueError('old/new native admission differs')
|
|
same(D09 / 'incoming/new-backend', plan['candidate']['backend_sha256'])
|
|
same(package / 'image-recipe/configs/prepare-terminal-workspace.sh', admission['helper_digest'])
|
|
os_binding = admission['os_update']
|
|
if set(os_binding) != {'version', 'plan_sha256', 'manifest_sha256', 'authority_digest', 'verifier_sha256'} or os_binding['version'] != 1:
|
|
raise ValueError('OS contract binding fields')
|
|
for field in ('plan_sha256', 'manifest_sha256', 'authority_digest', 'verifier_sha256'):
|
|
bootstrap.pin(os_binding[field])
|
|
inputs = approval['os_inputs']
|
|
if set(inputs) != {'plan.json', 'manifest.json', 'preservation.json'}:
|
|
raise ValueError('OS input set')
|
|
data = {name: same(prepared / name, sha) for name, sha in inputs.items()}
|
|
if bootstrap.digest(data['plan.json']) != os_binding['plan_sha256'] or bootstrap.digest(data['manifest.json']) != os_binding['manifest_sha256']:
|
|
raise ValueError('OS plan/manifest admission differs')
|
|
os_plan = bootstrap.decode(data['plan.json'])
|
|
preservation = bootstrap.decode(data['preservation.json'])
|
|
if bootstrap.digest(data['preservation.json']) != os_plan['preservation_sha256'] or preservation.get('authority_digest') != os_binding['authority_digest']:
|
|
raise ValueError('OS preservation authority differs')
|
|
verifier = same(VERIFIER, os_binding['verifier_sha256'])
|
|
if not verifier.startswith(b'\x7fELF'):
|
|
raise ValueError('native release verifier required')
|
|
custody = OS_ROOT / 'verifier-custody.json'
|
|
if custody.exists() or custody.is_symlink():
|
|
if bootstrap.decode(bootstrap.read(custody)) != dict(version=1,
|
|
verifier_sha256=os_binding['verifier_sha256'],
|
|
prerequisite_backend_sha256=os_binding['verifier_sha256']):
|
|
raise ValueError('existing release verifier custody differs')
|
|
elif os_binding['verifier_sha256'] != plan['current']['backend_sha256']:
|
|
raise ValueError('initial verifier must be the appointed old native artifact')
|
|
# Run the pinned production verifier before installing any owner code.
|
|
command(str(VERIFIER), 'ceremony', 'verify', str(prepared / 'manifest.json'))
|
|
current_exclusion()
|
|
destination_custody(OS_ROOT)
|
|
destination_custody(OWNER_DROPIN.parent)
|
|
# An existing plan directory, even identical, is an external reconciliation
|
|
# case. No overwrite permits stale one-use authority to be resurrected.
|
|
destination = OS_ROOT / 'plans' / os_binding['plan_sha256']
|
|
if destination.exists() or destination.is_symlink():
|
|
raise ValueError('existing protected OS plan')
|
|
if OWNER_DROPIN.exists() or OWNER_DROPIN.is_symlink():
|
|
raise ValueError('existing bootstrap owner policy')
|
|
intent = BASE / ('activation-' + expected)
|
|
intent.mkdir(mode=0o700)
|
|
bootstrap.sync(BASE)
|
|
bootstrap.write(intent / 'approval.json', approval_raw)
|
|
bootstrap.write(intent / 'INTENT', b'one-shot owner activation; no automatic retry\n')
|
|
bootstrap.sync(intent)
|
|
ensure_dir(OS_ROOT / 'plans')
|
|
ensure_dir(OS_ROOT / 'transactions')
|
|
# Same executor lock as OS stage; no D09 authority exists yet.
|
|
executor_lock = os.open(OS_ROOT / 'executor.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
fd = bootstrap.secure(OS_ROOT / 'executor.lock'); os.close(fd)
|
|
fcntl.flock(executor_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
untouched_authority()
|
|
staging = OS_ROOT / 'plans' / ('.bootstrap-' + expected)
|
|
staging.mkdir(mode=0o700)
|
|
for name, raw in data.items():
|
|
bootstrap.write(staging / name, raw)
|
|
bootstrap.sync(staging)
|
|
os.rename(staging, destination)
|
|
bootstrap.sync(destination.parent)
|
|
# Actual D09 provisioner consumes the same fixed incoming contract.
|
|
incoming = D09 / 'incoming/os-update'
|
|
if incoming.exists() or incoming.is_symlink():
|
|
raise ValueError('existing OS incoming activation requires reconciliation')
|
|
incoming.mkdir(mode=0o700)
|
|
for name, raw in data.items():
|
|
bootstrap.write(incoming / name, raw)
|
|
bootstrap.write(incoming / 'verifier', same(VERIFIER, os_binding['verifier_sha256']))
|
|
bootstrap.sync(incoming)
|
|
bootstrap.sync(incoming.parent)
|
|
python = str(Path(sys.executable).resolve())
|
|
command(python, '-I', '-B', str(provision), '--install-package')
|
|
# Bootstrap-owned additive drop-in: permit actual lock probing and
|
|
# owner postboot status only, not writable OS packages or plan inputs.
|
|
ensure_dir(OWNER_DROPIN.parent)
|
|
bootstrap.write(OWNER_DROPIN, policy)
|
|
bootstrap.sync(OWNER_DROPIN.parent)
|
|
command('/usr/bin/systemctl', 'daemon-reload')
|
|
# Recheck source and admission after installation, before granting.
|
|
if package_inventory(package) != approval['package_files']:
|
|
raise ValueError('package changed during installation')
|
|
same(ADMISSION, approval['admission_sha256'])
|
|
command(python, '-I', '-B', str(provision), '--admission', str(ADMISSION))
|
|
installed = bootstrap.decode(bootstrap.read(Path('/etc/archipelago-d09/binding.json')))
|
|
if installed.get('os_update') != os_binding:
|
|
raise ValueError('provisioner did not bind actual OS owner')
|
|
bootstrap.write(intent / 'PROVISIONED', bootstrap.read(D09 / 'ledger.json'))
|
|
finally:
|
|
os.close(executor_lock)
|
|
command('/usr/bin/systemctl', 'start', 'archipelago-d09-lifecycle.service')
|
|
# Wait for socket creation only. Never retry grant-A submission after an
|
|
# uncertain reply; the owner retains its consumed authority.
|
|
deadline = time.monotonic() + 30
|
|
while not Path('/run/archipelago-d09/owner.sock').exists():
|
|
if time.monotonic() > deadline:
|
|
raise TimeoutError('owner socket not ready')
|
|
time.sleep(.1)
|
|
started = bootstrap.decode(command(python, '-I', '-B', str(provision), '--activate-prerequisite'))
|
|
if started == {'prerequisite_started': False, 'invocation_id': None}:
|
|
# The owner submits a no-block systemd start and independently polls
|
|
# it. Observe its protected record; never submit grant A a second time.
|
|
first = bootstrap.decode(bootstrap.read(D09 / 'ledger.json'))['prerequisite']
|
|
deadline = time.monotonic() + 30
|
|
while time.monotonic() < deadline:
|
|
prior = bootstrap.decode(bootstrap.read(D09 / 'ledger.json'))['prerequisite']
|
|
if any(prior[k] != first[k] for k in ('owner_incarnation', 'boot_id', 'grant_digest')):
|
|
raise ValueError('prerequisite observation continuity changed')
|
|
if prior['state'] == 'STARTED':
|
|
started = {'prerequisite_started': True, 'invocation_id': prior['observation']['invocation_id']}
|
|
break
|
|
if prior['state'] != 'START_INTENT':
|
|
raise ValueError('prerequisite start outcome requires reconciliation')
|
|
time.sleep(.1)
|
|
if started.get('prerequisite_started') is not True or not started.get('invocation_id'):
|
|
raise ValueError('owner did not confirm prerequisite activation')
|
|
result = {'version': 1, 'state': 'D09_PREREQUISITE_ACTIVATED',
|
|
'activation_sha256': expected, 'os_update': os_binding,
|
|
'invocation_id': started['invocation_id'],
|
|
'os_transaction_applied': False}
|
|
bootstrap.write(intent / 'result.json', json.dumps(result, sort_keys=True).encode())
|
|
bootstrap.sync(intent)
|
|
return result
|
|
finally:
|
|
os.close(lock)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--approval', type=Path, required=True)
|
|
parser.add_argument('--approval-sha256', required=True)
|
|
args = parser.parse_args()
|
|
print(json.dumps(activate(args.approval, args.approval_sha256), sort_keys=True))
|