- Purge misplaced RFCs, scratch tokens, and node-proprietary assets - Decouple remote provisioning to ci/scripts/init-node.py - Add targeted composition orchestration in actions/compose.py - Add host-side Xray stale socket cleanup in /dev/shm - Add actions/env.py for shell exports and .env synchronization - Harden UFW, backup retention, host tempfiles, and notify dispatcher - Add pyproject.toml and Makefile with 78 passing unit tests
171 lines
7.5 KiB
Python
171 lines
7.5 KiB
Python
"""Shared root runner for remote and cloud-init enrollment."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import pwd
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
from .state import Journal, ProvisionError, atomic_write, exclusive_lock, safe_path
|
|
from .userdata import decode_user_data
|
|
|
|
STATE = Path('/var/lib/apex-init')
|
|
|
|
|
|
def run(args, **kwargs):
|
|
result = subprocess.run(args, text=True, capture_output=True, timeout=300, **kwargs)
|
|
if result.returncode:
|
|
raise ProvisionError('required target operation failed: ' + args[0])
|
|
return result.stdout.strip()
|
|
|
|
|
|
def preflight():
|
|
if os.geteuid() != 0:
|
|
raise ProvisionError('target enrollment requires root privileges')
|
|
release = {}
|
|
for line in Path('/etc/os-release').read_text().splitlines():
|
|
key, separator, value = line.partition('=')
|
|
if separator:
|
|
release[key] = value.strip('"')
|
|
if release.get('ID') != 'debian' or release.get('VERSION_ID') != '13' or platform.machine() != 'x86_64':
|
|
raise ProvisionError('initialization supports Debian 13 amd64 only')
|
|
|
|
|
|
def validate_keys(spec):
|
|
for key in spec.authorized_keys:
|
|
with tempfile.NamedTemporaryFile(mode='w') as stream:
|
|
stream.write(key + '\n')
|
|
stream.flush()
|
|
try:
|
|
run(['ssh-keygen', '-l', '-f', stream.name])
|
|
except ProvisionError:
|
|
raise ProvisionError('administrator public key failed cryptographic validation') from None
|
|
|
|
|
|
def administrator(spec):
|
|
try:
|
|
account = pwd.getpwnam('adam')
|
|
except KeyError:
|
|
run(['useradd', '--create-home', '--shell', '/bin/bash', '--groups', 'sudo,docker', 'adam'])
|
|
account = pwd.getpwnam('adam')
|
|
if account.pw_dir != '/home/adam' or account.pw_uid == 0:
|
|
raise ProvisionError('existing administrator has incompatible home or UID')
|
|
safe_path(account.pw_dir)
|
|
if account.pw_shell != '/bin/bash':
|
|
run(['usermod', '--shell', '/bin/bash', 'adam'])
|
|
run(['usermod', '--append', '--groups', 'sudo,docker', 'adam'])
|
|
directory = safe_path(Path(account.pw_dir) / '.ssh')
|
|
directory.mkdir(mode=0o700, exist_ok=True)
|
|
os.chown(directory, account.pw_uid, account.pw_gid)
|
|
os.chmod(directory, 0o700)
|
|
authorized = safe_path(directory / 'authorized_keys')
|
|
existing = authorized.read_text().splitlines() if authorized.exists() else []
|
|
keys = existing + [key for key in spec.authorized_keys if key not in existing]
|
|
atomic_write(authorized, '\n'.join(keys) + '\n', uid=account.pw_uid, gid=account.pw_gid)
|
|
policy = 'adam ALL=(ALL:ALL) NOPASSWD: ALL\n'
|
|
with tempfile.NamedTemporaryFile(mode='w') as stream:
|
|
stream.write(policy)
|
|
stream.flush()
|
|
run(['visudo', '-cf', stream.name])
|
|
atomic_write('/etc/sudoers.d/99-apex-adam', policy, 0o440)
|
|
run(['visudo', '-c'])
|
|
run(['runuser', '-u', 'adam', '--', 'sudo', '-n', 'true'])
|
|
for item in spec.files:
|
|
uid, gid = (0, 0) if item.owner == 'root:root' else (account.pw_uid, account.pw_gid)
|
|
atomic_write(item.path, item.content, item.mode, uid=uid, gid=gid)
|
|
if run(['hostname']) != spec.hostname:
|
|
run(['hostnamectl', 'set-hostname', spec.hostname])
|
|
groups = run(['id', '-nG', 'adam']).split()
|
|
if not {'sudo', 'docker'} <= set(groups):
|
|
raise ProvisionError('administrator groups did not converge')
|
|
return {'administrator': 'adam', 'hostname': spec.hostname, 'sudo': 'verified'}
|
|
|
|
|
|
def enroll(spec, revision, bundle_digest, heartbeat=None):
|
|
from .baseline import apply_baseline
|
|
from .storage import acknowledge_storage_ownership, reconcile_storage
|
|
from .repository import reconcile_repository
|
|
|
|
preflight()
|
|
validate_keys(spec)
|
|
safe_path(STATE).mkdir(mode=0o700, exist_ok=True)
|
|
if STATE.stat().st_uid != 0:
|
|
raise ProvisionError('enrollment state directory must be root-owned')
|
|
os.chmod(STATE, 0o700)
|
|
with exclusive_lock(STATE / 'init.lock'):
|
|
journal = Journal(STATE / 'enrollment.json', spec.identity(), revision, bundle_digest)
|
|
journal.save()
|
|
journal.stage('baseline', apply_baseline, heartbeat)
|
|
journal.stage('administrator', lambda: administrator(spec), heartbeat)
|
|
result = journal.stage('storage', lambda: reconcile_storage(spec, journal.data, journal.save), heartbeat)
|
|
account = pwd.getpwnam('adam')
|
|
for path in result['ownership_pending']:
|
|
os.chown(safe_path(path), account.pw_uid, account.pw_gid)
|
|
acknowledge_storage_ownership(spec, journal.data, [path], journal.save)
|
|
journal.stage('repository', lambda: reconcile_repository(spec, journal.data, journal.save), heartbeat)
|
|
journal.data['status'] = 'enrolled'
|
|
journal.data['external_access'] = 'pending'
|
|
journal.save()
|
|
return journal.data
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('operation', choices=('validate', 'enroll', 'status', 'complete'))
|
|
parser.add_argument('--user-data')
|
|
parser.add_argument('--revision', default='unknown')
|
|
parser.add_argument('--bundle-digest', default='unknown')
|
|
parser.add_argument('--heartbeat')
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
preflight()
|
|
if args.operation == 'status':
|
|
path = safe_path(STATE / 'enrollment.json')
|
|
print(path.read_text() if path.exists() else json.dumps({'status': 'not-started'}))
|
|
return 0
|
|
if args.operation == 'complete':
|
|
with exclusive_lock(STATE / 'init.lock'):
|
|
access = json.loads(safe_path(STATE / 'access/state.json').read_text())
|
|
path = safe_path(STATE / 'enrollment.json')
|
|
record = json.loads(path.read_text())
|
|
if access.get('status') != 'committed' or record.get('status') not in ('enrolled', 'complete'):
|
|
raise ProvisionError('enrollment and access must be verified before completion')
|
|
record['external_access'] = 'verified'
|
|
record['status'] = 'complete'
|
|
atomic_write(path, json.dumps(record, sort_keys=True) + '\n')
|
|
print(json.dumps(record, sort_keys=True))
|
|
return 0
|
|
if not args.user_data:
|
|
raise ProvisionError('--user-data is required')
|
|
path = safe_path(args.user_data)
|
|
if path.stat().st_uid != 0 or path.stat().st_mode & 0o077:
|
|
raise ProvisionError('user-data must be root-owned and private')
|
|
if not path.is_file() or path.stat().st_size > 1024 * 1024:
|
|
raise ProvisionError('user-data must be a regular file no larger than 1 MiB')
|
|
spec = decode_user_data(path.read_bytes())
|
|
validate_keys(spec)
|
|
if args.operation == 'validate':
|
|
print(json.dumps({'status': 'valid', 'hostname': spec.hostname}))
|
|
else:
|
|
print(json.dumps(enroll(spec, args.revision, args.bundle_digest, args.heartbeat), sort_keys=True))
|
|
return 0
|
|
except Exception as error:
|
|
from .contract import ContractError
|
|
from .baseline import BaselineError
|
|
from .storage import StorageError
|
|
if isinstance(error, (ProvisionError, ContractError, BaselineError, StorageError)):
|
|
print(str(error), file=sys.stderr)
|
|
else:
|
|
# Unexpected dependency failures must not render raw credential input.
|
|
print('initialization failed (' + type(error).__name__ + '); inspect stage status', file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|