fix(apex): preserve environments and restore secure standalone operations
This commit is contained in:
1 parent
6463bad5ee
commit
995ea9bdf9
44 files changed
+1399
-429
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user