fix(apex): preserve environments and restore secure standalone operations

This commit is contained in:
deytenit committed 2026-10-26 12:26:09 +03:00
1 parent 6463bad5ee
commit 995ea9bdf9
44 files changed
+1399 -429

No files matched your search

+2 -2
View File
@@ -319,12 +319,12 @@ class Host:
self.command(['ufw', '--force', 'delete', 'allow', '2222/tcp', 'comment', 'apex-init-access'])
def arm(self):
bundle = Path(__file__).resolve().parents[2]
bundle = Path(__file__).resolve().parents[1]
if not re.fullmatch(r'/usr/local/lib/apex-provisioner/[a-f0-9]{64}', str(bundle)):
raise AccessError('Access runner must execute from the installed root-owned bundle')
service = ('[Unit]\nDescription=APEX pending access rollback\nAfter=local-fs.target\n'
'[Service]\nType=oneshot\nWorkingDirectory=' + str(bundle) + '\n'
'ExecStart=/usr/bin/python3 -m engine.provisioning.access tick\n')
'ExecStart=/usr/bin/python3 -m provisioning.access tick\n')
timer = ('[Unit]\nDescription=APEX access rollback deadline and boot recovery\n'
'[Timer]\nOnBootSec=5s\nOnUnitActiveSec=10s\nAccuracySec=1s\n'
'[Install]\nWantedBy=timers.target\n')
@@ -325,7 +325,8 @@ def configure_sources():
key = Path(f"/etc/apt/keyrings/apex-{name}.asc")
if not key.exists():
try:
with urllib.request.urlopen(key_url, timeout=30) as response:
# Both repository key URLs above are fixed HTTPS endpoints.
with urllib.request.urlopen(key_url, timeout=30) as response: # noqa: S310
data = response.read()
except OSError as exc:
raise BaselineError(f"Cannot download {name} repository signing key.") from exc
+5 -5
View File
@@ -16,9 +16,9 @@ from .target import preflight, run
def install():
from .transport import build_bundle
source = Path(__file__).resolve().parents[2]
data, digest, _ = build_bundle(source)
from .transport import _bundle_from_source
source = Path(__file__).resolve().parents[1]
data, digest = _bundle_from_source(source)
destination = safe_path(Path('/usr/local/lib/apex-provisioner') / digest)
destination.mkdir(mode=0o755, parents=True, exist_ok=False)
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
@@ -29,7 +29,7 @@ def install():
atomic_write('/etc/apex/provisioner.json', json.dumps(metadata) + '\n')
for executable, operation in (('apex-provision-image', ''), ('apex-bootstrap', 'cloud-enroll ')):
content = ('#!/bin/sh\nset -eu\ncd ' + str(destination) + '\n'
'exec /usr/bin/python3 -m engine.provisioning.image ' + operation + '"$@"\n')
'exec /usr/bin/python3 -m provisioning.image ' + operation + '"$@"\n')
atomic_write('/usr/local/bin/' + executable, content, 0o755)
return metadata
@@ -84,7 +84,7 @@ def sanitize_files(root=Path('/')):
dbus.unlink()
dbus.parent.mkdir(parents=True, exist_ok=True)
dbus.symlink_to('/etc/machine-id')
for name in ('var/lib/apex-init', 'tmp/engine', 'var/lib/cloud'):
for name in ('var/lib/apex-init', 'tmp/provisioning', 'var/lib/cloud'):
path = root / name
if path.is_symlink():
path.unlink()
@@ -45,7 +45,7 @@ def _runtime_rule(line):
if len(tokens) % 2:
raise SecurityError('Unsupported runtime firewall rule')
pairs = []
for key, value in zip(tokens[::2], tokens[1::2]):
for key, value in zip(tokens[::2], tokens[1::2], strict=True):
if key in ('-s', '-d'):
value = str(ipaddress.ip_network(value, strict=False))
elif key == '--ctstate':
@@ -77,7 +77,7 @@ class Security:
if not active:
output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}', 'ufw']).stdout
conffiles = dict(re.findall(r'^\s*(/\S+)\s+([a-f0-9]{32})(?:\s|$)', output, re.M))
pristine = (hashlib.md5(base64.b64decode(files['/etc/default/ufw']['data'])).hexdigest()
pristine = (hashlib.md5(base64.b64decode(files['/etc/default/ufw']['data']), usedforsecurity=False).hexdigest()
== conffiles.get('/etc/default/ufw'))
manifest = dict((name, digest) for digest, name in re.findall(
r'^([a-f0-9]{32})\s+(\S+)\s*$', self._text('/var/lib/dpkg/info/ufw.md5sums'), re.M))
@@ -86,7 +86,7 @@ class Security:
filename = name.rsplit('/', 1)[1]
template = '/usr/share/ufw/' + ('' if filename == 'ufw.conf' else 'iptables/') + filename
data = self._path(template).read_bytes()
pristine = pristine and (hashlib.md5(data).hexdigest() == manifest.get(template.lstrip('/'))
pristine = pristine and (hashlib.md5(data, usedforsecurity=False).hexdigest() == manifest.get(template.lstrip('/'))
and data == base64.b64decode(files[name]['data']))
if not pristine:
raise SecurityError('Inactive UFW is not provably pristine; reconcile its existing policy first')
@@ -111,7 +111,7 @@ class Security:
output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}',
'crowdsec-firewall-bouncer-iptables']).stdout
conffiles = dict(re.findall(r'^\s*(/\S+)\s+([a-f0-9]{32})(?:\s|$)', output, re.M))
return hashlib.md5(self._path(path).read_bytes()).hexdigest() == conffiles.get(path)
return hashlib.md5(self._path(path).read_bytes(), usedforsecurity=False).hexdigest() == conffiles.get(path)
def _path(self, name):
path = self.host.path(name)
+28 -19
View File
@@ -129,17 +129,19 @@ def destination(value, port=None):
return match[1], hostname, int(port) if port is not None else None
def build_bundle(commons):
root = Path(commons)
files = sorted((root / 'engine').rglob('*.py'))
seed = root / 'engine/provisioning/seed.sh'
if not seed.is_file():
raise TransportError('Provisioning seed is missing')
def _bundle_from_source(root):
root = Path(root).resolve()
package = root / 'provisioning'
files = sorted(package.rglob('*.py'))
seed = package / 'seed.sh'
if not seed.is_file() or not (package / '__init__.py').is_file():
raise TransportError('Provisioning package or seed is missing')
files.append(seed)
output = io.BytesIO()
with tarfile.open(fileobj=output, mode='w') as archive:
for path in files:
if path.is_symlink() or '__pycache__' in path.parts:
if (not path.is_file() or '__pycache__' in path.parts
or any(part.is_symlink() for part in (path, *path.parents))):
raise TransportError('Provisioning bundle contains an unsafe source path')
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(root).as_posix())
@@ -147,15 +149,21 @@ def build_bundle(commons):
info.uid = info.gid = 0
archive.addfile(info, io.BytesIO(data))
data = output.getvalue()
return data, hashlib.sha256(data).hexdigest()
def build_bundle(commons):
root = Path(commons).resolve()
data, digest = _bundle_from_source(root / 'ci/scripts/init-node')
revision = 'unversioned-modified'
if shutil.which('git'):
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30)
if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
revision = result.stdout.strip()
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'engine'], capture_output=True, text=True, timeout=30)
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'ci/scripts/init-node', 'ci/scripts/init-node.py'], capture_output=True, text=True, timeout=30)
if dirty.returncode or dirty.stdout.strip():
revision += '-modified'
return data, hashlib.sha256(data).hexdigest(), revision
return data, digest, revision
class SSH:
@@ -240,7 +248,7 @@ def protected_handover(ssh, invoke, token, port, client_address):
except Exception:
try:
invoke('rollback', token, None, None, False)
except Exception:
except Exception: # noqa: S110 — diagnostics may contain secrets.
pass # Persistent timer is authoritative if both connections are gone.
raise
@@ -297,7 +305,7 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
if active.returncode == 0:
raise TransportError('An enrollment unit is still running: ' + previous_unit + '; wait for it to settle before resuming')
stage = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_dir /var/tmp; mktemp -d /var/tmp/apex-init.XXXXXXXXXX').stdout.decode().strip()
if not re.fullmatch(r'/var/tmp/apex-init\.[A-Za-z0-9]{10}', stage):
if not re.fullmatch(r'/var/tmp/apex-init\.[A-Za-z0-9]{10}', stage): # noqa: S108 — validates remote mktemp output.
raise TransportError('Target returned an invalid staging path')
# Hard reboot can bypass the shell trap; boot-time tmpfiles removes opaque inputs.
cleanup = ('r ' + stage + '/user-data - - - -\n' +
@@ -308,13 +316,13 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
install = remote_bundle_install(stage, installed, digest)
progress('Installing the versioned target runtime and validating enrollment input.')
ssh.run(install)
base = ['python3', '-m', 'engine.provisioning.target']
base = ['python3', '-m', 'provisioning.target']
options = ['--user-data', stage + '/user-data', '--revision', revision, '--bundle-digest', digest]
# State remains inspectable after disconnect; user-data is removed by the durable unit.
enrollment = shlex.join(base + ['enroll'] + options + ['--heartbeat', stage + '/heartbeat'])
validation = shlex.join(base + ['validate'] + options)
access_preflight = shlex.join(['python3', '-m', 'engine.provisioning.access', 'preflight'])
seed = shlex.join(['bash', installed + '/engine/provisioning/seed.sh'])
access_preflight = shlex.join(['python3', '-m', 'provisioning.access', 'preflight'])
seed = shlex.join(['bash', installed + '/provisioning/seed.sh'])
wrapper = ('umask 077; cd ' + installed + '; '
'trap ' + shlex.quote('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + cleanup_policy) + ' EXIT; '
'{ ' + seed + ' && ' + validation + ' && ' + access_preflight + ' && ' + enrollment + '; } >' + stage + '/runner.log 2>&1; rc=$?; '
@@ -344,7 +352,7 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
result = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_file /var/lib/apex-init/access/state.json; '
'cat /var/lib/apex-init/access/state.json', fresh=True)
return json.loads(result.stdout)
args = ['python3', '-m', 'engine.provisioning.access', operation, '--token', token]
args = ['python3', '-m', 'provisioning.access', operation, '--token', token]
if original_port:
args += ['--original-port', str(original_port), '--client-address', address]
result = ssh.run('cd ' + installed + ' && ' + shlex.join(args), fresh=fresh)
@@ -368,7 +376,7 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
try:
ssh.run('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + stage + '/bundle.tar ' + cleanup_policy, fresh=fresh)
break
except Exception:
except Exception: # noqa: S110 — cleanup tries both verified endpoints.
pass
if launched and not finished:
progress('Coordinator stopped. The current target stage may finish; later stages stop after heartbeat expiry.')
@@ -379,7 +387,8 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
label = 'Safe resume' + ('' if access_committed else ' after any pending rollback')
endpoints = [(label, 'adam' if access_committed else ssh.user, 2222 if access_committed else ssh.resolved_port)]
for label, resume_user, resume_port in endpoints:
resume = './apex init ' + resume_user + '@' + ssh.host + ' --port ' + str(resume_port) + ' --user-data ' + shlex.quote(str(source))
resume = ['python3', 'ci/scripts/init-node.py', resume_user + '@' + ssh.host,
'--port', str(resume_port), '--user-data', str(source)]
if ssh.identity:
resume += ' --identity-file ' + shlex.quote(str(ssh.identity))
progress(label + ': ' + resume)
resume += ['--identity-file', str(ssh.identity)]
progress(label + ': ' + shlex.join(resume))
@@ -23,7 +23,8 @@ def decode_user_data(content):
if len(content) > 1024 * 1024:
raise ContractError('user-data exceeds the supported size')
try:
document = yaml.load(content, Loader=Loader)
# Loader extends SafeLoader only to reject aliases and duplicate keys.
document = yaml.load(content, Loader=Loader) # noqa: S506
except (yaml.YAMLError, UnicodeError, RecursionError):
raise ContractError('invalid or unsupported YAML user-data') from None
return normalize_user_data(document)