Remove the image build and consolidate operational code under scripts
This commit is contained in:
77 files changed
+586
-260607
No files matched your search
@@ -0,0 +1,120 @@
|
||||
from contextlib import nullcontext
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from subprocess import CompletedProcess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from provisioning import docker
|
||||
|
||||
|
||||
class ManualDockerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name).resolve()
|
||||
(self.root / "etc").mkdir()
|
||||
(self.root / "etc/os-release").write_text("ID=debian\nVERSION_ID=13\n")
|
||||
self.commands = []
|
||||
self.arch = "arm64"
|
||||
self.running = False
|
||||
self.data_root = "/custom/data"
|
||||
self.missing_buildx = False
|
||||
self.versions = {}
|
||||
self.installations = []
|
||||
|
||||
def run_command(self, cmd, **kwargs):
|
||||
self.commands.append(cmd)
|
||||
if cmd == ["dpkg", "--print-architecture"]:
|
||||
return CompletedProcess(cmd, 0, self.arch + "\n", "")
|
||||
if cmd[-1] == "{{.DockerRootDir}}":
|
||||
return CompletedProcess(cmd, 0 if self.running else 1, self.data_root + "\n", "")
|
||||
if cmd[-2:] in (["version", "--short"], ["buildx", "version"]):
|
||||
if self.missing_buildx and cmd[-2:] == ["buildx", "version"]:
|
||||
if ["docker-buildx-plugin"] not in self.installations:
|
||||
return CompletedProcess(cmd, 1, "", "plugin missing")
|
||||
return CompletedProcess(cmd, 0, "installed\n", "")
|
||||
raise AssertionError(cmd)
|
||||
|
||||
def apply(self):
|
||||
def target(path):
|
||||
path = Path(path)
|
||||
return path if self.root in path.parents else self.root / str(path).lstrip("/")
|
||||
|
||||
with (patch.object(docker, "Path", side_effect=target),
|
||||
patch.object(docker, "run", side_effect=self.run_command),
|
||||
patch.object(docker, "package_versions", return_value=self.versions),
|
||||
patch.object(docker.shutil, "which", return_value="/usr/bin/docker" if self.running else None),
|
||||
patch.object(docker, "install_missing", side_effect=self.installations.append)):
|
||||
return docker.apply_docker_policy()
|
||||
|
||||
def test_working_engine_keeps_local_data_root_without_writes(self):
|
||||
self.running = True
|
||||
self.versions = {"docker.io": "1"}
|
||||
self.assertEqual(self.apply()["docker_data_root"], "/custom/data")
|
||||
self.assertEqual(self.installations, [])
|
||||
self.assertFalse((self.root / "etc/docker").exists())
|
||||
|
||||
def test_pve_refused_before_packages_or_daemon(self):
|
||||
binary = self.root / "usr/bin/pveversion"
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.touch()
|
||||
with self.assertRaisesRegex(docker.DockerError, "Proxmox"):
|
||||
self.apply()
|
||||
self.assertEqual(self.commands, [])
|
||||
|
||||
def test_unsupported_architecture_refused(self):
|
||||
self.arch = "i386"
|
||||
with self.assertRaisesRegex(docker.DockerError, "amd64|arm64"):
|
||||
self.apply()
|
||||
self.assertEqual(self.installations, [])
|
||||
|
||||
def test_existing_runtime_packages_not_replaced(self):
|
||||
self.versions = {"containerd": "1.7"}
|
||||
with self.assertRaisesRegex(docker.DockerError, "containerd"):
|
||||
self.apply()
|
||||
self.assertEqual(self.installations, [])
|
||||
|
||||
def test_fresh_arm64_install_verifies_the_pinned_key_and_starts_local_engine(self):
|
||||
key = b"test repository signing key"
|
||||
self.data_root = "/srv/docker"
|
||||
|
||||
def start(service):
|
||||
if service == "docker":
|
||||
self.running = True
|
||||
|
||||
with (patch.object(docker, "service_start_guard", return_value=nullcontext()),
|
||||
patch.object(docker.urllib.request, "urlopen", return_value=io.BytesIO(key)),
|
||||
patch.object(docker, "DOCKER_KEY_SHA256", hashlib.sha256(key).hexdigest()),
|
||||
patch.object(docker, "ensure_service", side_effect=start)):
|
||||
self.assertEqual(self.apply()["docker_data_root"], "/srv/docker")
|
||||
source = (self.root / "etc/apt/sources.list.d/apex-docker.sources").read_text()
|
||||
self.assertIn("Architectures: arm64", source)
|
||||
self.assertIn("Signed-By: ", source)
|
||||
self.assertEqual((self.root / "etc/apt/keyrings/apex-docker.asc").read_bytes(), key)
|
||||
self.assertEqual(json.loads((self.root / "etc/docker/daemon.json").read_text())["data-root"],
|
||||
"/srv/docker")
|
||||
self.assertIn("docker-compose-plugin", self.installations[-1])
|
||||
self.assertIn("docker-buildx-plugin", self.installations[-1])
|
||||
self.assertNotIn("ufw", self.installations[-1])
|
||||
|
||||
def test_untrusted_signing_key_cannot_publish_apt_source_or_install_docker(self):
|
||||
with (patch.object(docker, "service_start_guard", return_value=nullcontext()),
|
||||
patch.object(docker.urllib.request, "urlopen", return_value=io.BytesIO(b"wrong key"))):
|
||||
with self.assertRaisesRegex(docker.DockerError, "signing key"):
|
||||
self.apply()
|
||||
self.assertFalse((self.root / "etc/apt/sources.list.d/apex-docker.sources").exists())
|
||||
self.assertFalse((self.root / "etc/docker/daemon.json").exists())
|
||||
self.assertEqual(self.installations, [("ca-certificates",)])
|
||||
|
||||
def test_working_ce_engine_only_installs_the_missing_plugin(self):
|
||||
self.running = True
|
||||
self.missing_buildx = True
|
||||
self.versions = {"docker-ce": "1"}
|
||||
with patch.object(docker, "service_start_guard", return_value=nullcontext()):
|
||||
self.assertEqual(self.apply()["docker_data_root"], "/custom/data")
|
||||
self.assertEqual(self.installations, [["docker-buildx-plugin"]])
|
||||
self.assertFalse((self.root / "etc/docker").exists())
|
||||
@@ -0,0 +1,138 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from provisioning import docker
|
||||
|
||||
|
||||
class Host:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.commands = []
|
||||
self.installed = {}
|
||||
self.partial = {}
|
||||
self.plan = ''
|
||||
self.path('/usr/sbin').mkdir(parents=True)
|
||||
|
||||
def path(self, path):
|
||||
path = Path(path)
|
||||
if str(path).startswith(str(self.root)):
|
||||
return path
|
||||
return self.root / str(path).lstrip('/')
|
||||
|
||||
def command(self, args, **kwargs):
|
||||
self.commands.append(args)
|
||||
code, output = 0, ''
|
||||
if args[:2] == ['dpkg', '--print-architecture']:
|
||||
output = 'amd64\n'
|
||||
elif args[0] == 'dpkg-query':
|
||||
output = ''.join(f'{name}\tinstall ok installed\t{version}\n' for name, version in self.installed.items())
|
||||
output += ''.join(f'{name}\t{status}\t1.0\n' for name, status in self.partial.items())
|
||||
elif args[0] == 'apt-get' and 'install' in args:
|
||||
if '--simulate' in args:
|
||||
output = self.plan
|
||||
else:
|
||||
for name in args[args.index('--no-remove') + 1:]:
|
||||
self.installed[name] = '1.0'
|
||||
self.partial.pop(name, None)
|
||||
return subprocess.CompletedProcess(args, code, output, '')
|
||||
|
||||
|
||||
class DockerPackageTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.host = Host(Path(self.temp.name).resolve())
|
||||
for item in (
|
||||
patch.object(docker, 'Path', self.host.path),
|
||||
patch.object(docker, 'POLICY', self.host.path('/usr/sbin/policy-rc.d')),
|
||||
patch.object(docker, 'POLICY_STATE', self.host.path('/usr/sbin/.apex-policy-rc.d')),
|
||||
patch.object(docker.subprocess, 'run', side_effect=self.host.command),
|
||||
):
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
def test_package_database_drives_installation_and_repeat_does_not_upgrade(self):
|
||||
self.host.installed['curl'] = '7.0'
|
||||
self.host.partial['rsyslog'] = 'deinstall ok config-files'
|
||||
self.assertEqual(docker.install_missing(['curl', 'rsyslog']), ['rsyslog'])
|
||||
self.assertEqual(self.host.installed['curl'], '7.0')
|
||||
self.assertEqual(self.host.installed['rsyslog'], '1.0')
|
||||
count = len(self.host.commands)
|
||||
self.assertEqual(docker.install_missing(['curl', 'rsyslog']), [])
|
||||
self.assertFalse(any(command[0] == 'apt-get' for command in self.host.commands[count:]))
|
||||
|
||||
def test_dependency_upgrade_is_rejected_before_install(self):
|
||||
self.host.plan = 'Inst libc6 [2.0] (3.0 Debian:13)\n'
|
||||
with self.assertRaisesRegex(docker.DockerError, 'upgrade/removal'):
|
||||
docker.install_missing(['git'])
|
||||
self.assertNotIn('git', self.host.installed)
|
||||
|
||||
def test_held_installed_packages_are_preserved(self):
|
||||
self.host.partial['git'] = 'hold ok installed'
|
||||
self.assertEqual(docker.install_missing(['git']), [])
|
||||
self.assertFalse(any(command[0] == 'apt-get' for command in self.host.commands))
|
||||
|
||||
def test_conflicting_docker_config_is_rejected_without_host_mutation(self):
|
||||
path = self.host.path('/etc/docker/daemon.json')
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text('{"data-root": "/data/docker"}')
|
||||
before = sorted(str(path.relative_to(self.host.root)) for path in self.host.root.rglob('*'))
|
||||
with self.assertRaisesRegex(docker.DockerError, 'Conflicting Docker'):
|
||||
docker.docker_preflight(self.host.installed)
|
||||
self.assertEqual(before, sorted(str(path.relative_to(self.host.root)) for path in self.host.root.rglob('*')))
|
||||
self.assertFalse(any(command[0] in ('apt-get', 'systemctl') for command in self.host.commands))
|
||||
|
||||
def test_existing_docker_data_is_preserved(self):
|
||||
data = self.host.path('/var/lib/docker/volumes/user-data')
|
||||
data.parent.mkdir(parents=True)
|
||||
data.write_text('valuable')
|
||||
with self.assertRaisesRegex(docker.DockerError, 'Existing /var/lib/docker'):
|
||||
docker.docker_preflight(self.host.installed)
|
||||
self.assertEqual(data.read_text(), 'valuable')
|
||||
|
||||
def test_conflicting_distribution_docker_package_is_never_removed(self):
|
||||
self.host.installed['docker.io'] = '26.1.5'
|
||||
with self.assertRaisesRegex(docker.DockerError, 'Conflicting Docker packages'):
|
||||
docker.docker_preflight(self.host.installed)
|
||||
self.assertEqual(self.host.installed['docker.io'], '26.1.5')
|
||||
self.assertFalse(any(command[0] == 'apt-get' for command in self.host.commands))
|
||||
|
||||
def test_unmanaged_destination_data_is_not_adopted(self):
|
||||
data = self.host.path('/srv/docker/private-data')
|
||||
data.parent.mkdir(parents=True)
|
||||
data.write_text('preserve me')
|
||||
with self.assertRaisesRegex(docker.DockerError, 'Unmanaged /srv/docker'):
|
||||
docker.docker_preflight(self.host.installed)
|
||||
self.assertEqual(data.read_text(), 'preserve me')
|
||||
|
||||
def test_service_policy_symlink_is_restored_after_failure(self):
|
||||
policy = self.host.path('/usr/sbin/policy-rc.d')
|
||||
original = self.host.path('/usr/sbin/provider-policy')
|
||||
original.write_text('provider policy')
|
||||
policy.symlink_to(original)
|
||||
with self.assertRaisesRegex(RuntimeError, 'boom'):
|
||||
with docker.service_start_guard():
|
||||
self.assertFalse(policy.is_symlink())
|
||||
raise RuntimeError('boom')
|
||||
self.assertTrue(policy.is_symlink())
|
||||
self.assertEqual(original.read_text(), 'provider policy')
|
||||
|
||||
def test_interrupted_guard_is_recovered_before_next_attempt(self):
|
||||
state = self.host.path('/usr/sbin/.apex-policy-rc.d')
|
||||
state.mkdir()
|
||||
(state / 'original').write_text('original policy')
|
||||
(state / 'ready').touch()
|
||||
self.host.path('/usr/sbin/policy-rc.d').write_text('#!/bin/sh\nexit 101\n')
|
||||
with docker.service_start_guard():
|
||||
pass
|
||||
self.assertEqual(self.host.path('/usr/sbin/policy-rc.d').read_text(), 'original policy')
|
||||
|
||||
def test_external_policy_change_is_not_overwritten(self):
|
||||
policy = self.host.path('/usr/sbin/policy-rc.d')
|
||||
with self.assertRaisesRegex(docker.DockerError, 'changed outside APEX'):
|
||||
with docker.service_start_guard():
|
||||
policy.write_text('external policy')
|
||||
self.assertEqual(policy.read_text(), 'external policy')
|
||||
@@ -0,0 +1,539 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from provisioning.access import AccessError, Host, Transition, atomic
|
||||
|
||||
TOKEN = 'a' * 32
|
||||
|
||||
|
||||
class FakeHost(Host):
|
||||
"""Replace system service effects; keep snapshots and transition files real."""
|
||||
def __init__(self, root):
|
||||
super().__init__(root)
|
||||
self.boot = 'first-boot'
|
||||
self.armed = False
|
||||
self.restored = 0
|
||||
self.ports = [22]
|
||||
self.bad_sudo = False
|
||||
self.fail_final = False
|
||||
atomic(self.path('/etc/ssh/sshd_config'), b'Port 22\n')
|
||||
|
||||
def boot_id(self):
|
||||
return self.boot
|
||||
|
||||
def preflight(self):
|
||||
if self.bad_sudo:
|
||||
raise AccessError('sudo failed')
|
||||
|
||||
def arm(self):
|
||||
self.armed = True
|
||||
|
||||
def disarm(self):
|
||||
self.armed = False
|
||||
|
||||
def policy(self, ports, final, address):
|
||||
if final and self.fail_final:
|
||||
raise AccessError('policy failed')
|
||||
|
||||
def verify_active(self, ports, final, address):
|
||||
self.policy(ports, final, address)
|
||||
if self.ports != sorted(set(ports)):
|
||||
raise AccessError('Live listener ports changed')
|
||||
|
||||
def install_policy(self, ports, final, address):
|
||||
self.policy(ports, final, address)
|
||||
self.ports = sorted(set(ports))
|
||||
atomic(self.path('/etc/systemd/system/ssh.service.d/apex-init.conf'),
|
||||
json.dumps({'ports': self.ports, 'final': final}).encode())
|
||||
|
||||
def restore(self, state):
|
||||
self.restored += 1
|
||||
self.ports = [state['original_port']]
|
||||
path = self.path('/etc/systemd/system/ssh.service.d/apex-init.conf')
|
||||
if state['previous'] is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
import base64
|
||||
atomic(path, base64.b64decode(state['previous']))
|
||||
|
||||
|
||||
class AccessTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.host = FakeHost(Path(self.temp.name))
|
||||
self.clock = 1000
|
||||
self.transition = self.manual('debian')
|
||||
|
||||
def begin(self):
|
||||
return self.transition.execute('begin', TOKEN, 22, '192.0.2.10')
|
||||
|
||||
def test_transition_keeps_original_until_final_and_commit_stops_rollback(self):
|
||||
self.begin()
|
||||
self.assertEqual(self.host.ports, [22, 2222])
|
||||
self.assertTrue(self.host.armed)
|
||||
self.transition.execute('finalize', TOKEN)
|
||||
self.assertEqual(self.host.ports, [2222])
|
||||
self.assertTrue(self.host.armed)
|
||||
self.transition.execute('commit', TOKEN)
|
||||
self.clock += 400
|
||||
self.host.boot = 'new-boot'
|
||||
self.transition.execute('tick')
|
||||
self.assertEqual(self.host.ports, [2222])
|
||||
self.assertEqual(self.host.restored, 0)
|
||||
self.assertFalse(self.host.armed)
|
||||
|
||||
def test_expiry_restores_original_even_after_final_policy(self):
|
||||
self.begin()
|
||||
self.transition.execute('finalize', TOKEN)
|
||||
self.clock += 301
|
||||
self.transition.execute('tick')
|
||||
self.assertEqual(self.host.ports, [22])
|
||||
self.assertEqual(self.transition.load()['status'], 'rolled_back')
|
||||
|
||||
def test_watchdog_restores_access_while_enrollment_lock_is_held(self):
|
||||
from provisioning.state import exclusive_lock
|
||||
self.begin()
|
||||
self.clock += 301
|
||||
with exclusive_lock(self.host.path('/var/lib/apex-init/init.lock')):
|
||||
self.transition.execute('tick')
|
||||
self.assertEqual(self.host.ports, [22])
|
||||
self.assertEqual(self.host.restored, 1)
|
||||
|
||||
def test_external_edit_prevents_finalize_and_watchdog_preserves_edit(self):
|
||||
self.begin()
|
||||
path = self.host.path('/etc/ssh/sshd_config')
|
||||
path.write_text('Port 22\nBanner /etc/banner\n')
|
||||
with self.assertRaises(AccessError):
|
||||
self.transition.execute('finalize', TOKEN)
|
||||
self.assertTrue(self.host.armed)
|
||||
self.clock += 301
|
||||
self.transition.execute('tick')
|
||||
self.assertIn('Banner', path.read_text())
|
||||
|
||||
def test_invalid_sudo_never_changes_listeners(self):
|
||||
self.host.bad_sudo = True
|
||||
with self.assertRaises(AccessError):
|
||||
self.begin()
|
||||
self.assertEqual(self.host.ports, [22])
|
||||
self.assertFalse(self.host.armed)
|
||||
|
||||
def test_failed_final_policy_restores_original(self):
|
||||
self.begin()
|
||||
self.host.fail_final = True
|
||||
with self.assertRaises(AccessError):
|
||||
self.transition.execute('finalize', TOKEN)
|
||||
self.assertEqual(self.host.ports, [22])
|
||||
self.assertFalse(self.host.armed)
|
||||
|
||||
def test_other_coordinator_cannot_commit(self):
|
||||
self.begin()
|
||||
self.transition.execute('finalize', TOKEN)
|
||||
with self.assertRaises(AccessError):
|
||||
self.transition.execute('commit', 'b' * 32)
|
||||
self.assertTrue(self.host.armed)
|
||||
|
||||
def manual(self, profile):
|
||||
self.host.live_arguments = lambda: (['/usr/sbin/sshd', '-D'], '755')
|
||||
self.host.listener_ports = lambda pid: set(self.host.ports)
|
||||
return Transition(self.host, now=lambda: self.clock, profile=profile)
|
||||
|
||||
def test_manual_debian_keeps_all_old_ports_then_limits_to_operator_port(self):
|
||||
self.host.ports = [22, 2200]
|
||||
manual = self.manual('debian')
|
||||
manual.execute('begin', TOKEN, 2200, '192.0.2.1')
|
||||
self.assertEqual(self.host.ports, [22, 2200, 2222])
|
||||
boundary = manual.load()['authenticated_after_us']
|
||||
manual.execute('finalize', TOKEN)
|
||||
self.assertEqual(self.host.ports, [2222])
|
||||
self.assertGreaterEqual(manual.load()['authenticated_after_us'], boundary)
|
||||
manual.execute('commit', TOKEN)
|
||||
self.assertEqual(manual.load()['status'], 'committed')
|
||||
|
||||
def test_manual_proxmox_installed_policy_never_restarts_and_preserves_image_state(self):
|
||||
self.host.ports = [22, 2222]
|
||||
legacy = self.host.path('/var/lib/apex-init/access/state.json')
|
||||
manual = self.manual('proxmox')
|
||||
with patch.object(self.host, 'install_policy', side_effect=AssertionError('restarted sshd')):
|
||||
for round_number, status in enumerate(('committed', 'existing')):
|
||||
record = {'status': status}
|
||||
atomic(legacy, json.dumps(record).encode())
|
||||
token = str(round_number) * 32
|
||||
manual.execute('begin', token, 22, '192.0.2.1')
|
||||
manual.execute('finalize', token)
|
||||
manual.execute('commit', token)
|
||||
self.assertEqual(json.loads(legacy.read_text()), record)
|
||||
self.assertEqual(self.host.ports, [22, 2222])
|
||||
|
||||
def test_inflight_legacy_handover_retains_its_timer_and_state(self):
|
||||
legacy = self.host.path('/var/lib/apex-init/access/state.json')
|
||||
for record in ({'status': 'final'}, {'status': 'committed', 'disarm_pending': True}):
|
||||
atomic(legacy, json.dumps(record).encode())
|
||||
for operation in ('begin', 'tick'):
|
||||
with self.assertRaisesRegex(AccessError, 'workflow is pending'):
|
||||
self.transition.execute(operation, TOKEN, 22, '192.0.2.1')
|
||||
self.assertEqual(json.loads(legacy.read_text()), record)
|
||||
self.assertIsNone(self.transition.load())
|
||||
|
||||
def test_plain_watchdog_restores_manual_override_on_reboot(self):
|
||||
override = self.host.path('/etc/systemd/system/ssh.service.d/apex-init.conf')
|
||||
atomic(override, b'original managed invocation')
|
||||
manual = self.manual('proxmox')
|
||||
manual.execute('begin', TOKEN, 22, '192.0.2.1')
|
||||
manual.execute('finalize', TOKEN)
|
||||
self.assertEqual(self.host.ports, [22, 2222])
|
||||
self.host.boot = 'second-boot'
|
||||
Transition(self.host, now=lambda: 0).execute('tick')
|
||||
self.assertEqual(override.read_bytes(), b'original managed invocation')
|
||||
self.assertEqual(manual.load()['status'], 'rolled_back')
|
||||
self.assertFalse(self.host.armed)
|
||||
|
||||
def test_failed_disarm_is_retried_after_durable_commit_without_rollback(self):
|
||||
self.begin()
|
||||
self.transition.execute('finalize', TOKEN)
|
||||
with patch.object(self.host, 'disarm', side_effect=AccessError('unavailable')), self.assertRaises(AccessError):
|
||||
self.transition.execute('commit', TOKEN)
|
||||
self.clock += 400
|
||||
self.transition.execute('tick')
|
||||
self.assertEqual(self.transition.load()['status'], 'committed')
|
||||
self.assertFalse(self.host.armed)
|
||||
self.assertEqual(self.host.restored, 0)
|
||||
|
||||
def test_reject_symlink_state_ancestor(self):
|
||||
outside = self.host.path('/outside')
|
||||
outside.mkdir()
|
||||
self.host.path('/var').mkdir()
|
||||
self.host.path('/var/lib').symlink_to(outside)
|
||||
with self.assertRaises(AccessError):
|
||||
self.begin()
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
|
||||
def test_policy_rejects_additive_ports(self):
|
||||
def runner(args, **kwargs):
|
||||
text = 'port 22\nport 2222\n' if '-T' in args else ''
|
||||
return subprocess.CompletedProcess(args, 0, text, '')
|
||||
with self.assertRaises(AccessError):
|
||||
Host(self.host.root, runner).policy([2222], True, '192.0.2.10')
|
||||
|
||||
def test_atomic_snapshot_does_not_follow_predictable_temporary_link(self):
|
||||
destination = self.host.root / 'snapshot'
|
||||
victim = self.host.root / 'victim'
|
||||
victim.write_bytes(b'preserved')
|
||||
destination.with_name('snapshot.new').symlink_to(victim)
|
||||
atomic(destination, b'new snapshot')
|
||||
self.assertEqual(destination.read_bytes(), b'new snapshot')
|
||||
self.assertEqual(victim.read_bytes(), b'preserved')
|
||||
|
||||
|
||||
class LivePolicyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name).resolve()
|
||||
self.arguments = ['/usr/sbin/sshd', '-D', '-p', '2222']
|
||||
self.running_arguments = self.arguments[:]
|
||||
self.effective = ('port 2222\npermitrootlogin no\npasswordauthentication no\n'
|
||||
'kbdinteractiveauthentication no\npubkeyauthentication yes\n'
|
||||
'authenticationmethods publickey\nallowusers adam\n'
|
||||
'x11forwarding no\npermitemptypasswords no\n')
|
||||
self.listeners = 'LISTEN 0 128 0.0.0.0:2222 0.0.0.0:* users:(("sshd",pid=755,fd=6))\n'
|
||||
self.calls = []
|
||||
self.host = Host(self.root, self.runner)
|
||||
atomic(self.host.path('/etc/ssh/sshd_config'), b'# fixture\n')
|
||||
process = self.root / 'proc/755'
|
||||
process.mkdir(parents=True)
|
||||
(process / 'exe').symlink_to('/usr/sbin/sshd')
|
||||
self.cmdline()
|
||||
|
||||
def cmdline(self, title=False):
|
||||
data = ('sshd: ' + ' '.join(self.running_arguments) + ' [listener] 0 of 10-100 startups\0'
|
||||
if title else '\0'.join(self.running_arguments) + '\0')
|
||||
(self.root / 'proc/755/cmdline').write_bytes(data.encode())
|
||||
|
||||
def runner(self, args, **kwargs):
|
||||
self.calls.append(args)
|
||||
if args[:2] in (['systemctl', 'is-active'], ['systemctl', 'is-enabled']) and args[-1] == 'ssh.socket':
|
||||
return subprocess.CompletedProcess(args, 3, '', '')
|
||||
if 'ExecStart' in args:
|
||||
output = '{ path=/usr/sbin/sshd ; argv[]=' + ' '.join(self.arguments) + ' ; ignore_errors=no ; }'
|
||||
elif 'MainPID' in args:
|
||||
output = '755\n'
|
||||
elif 'KillMode' in args:
|
||||
output = 'process\n'
|
||||
elif args[0] == 'ss':
|
||||
output = self.listeners
|
||||
elif '-T' in args:
|
||||
output = self.effective
|
||||
else:
|
||||
output = ''
|
||||
return subprocess.CompletedProcess(args, 0, output, '')
|
||||
|
||||
def test_verifies_exact_live_arguments_without_inventing_auth_options(self):
|
||||
self.cmdline(title=True)
|
||||
self.host.verify_active([2222], True, '192.0.2.1')
|
||||
effective_call = next(args for args in self.calls if '-T' in args)
|
||||
self.assertEqual(effective_call[:3], ['/usr/sbin/sshd', '-p', '2222'])
|
||||
self.assertNotIn('PermitRootLogin=no', effective_call)
|
||||
|
||||
def test_password_or_root_enabled_on_live_daemon_is_rejected(self):
|
||||
for setting in ('passwordauthentication', 'permitrootlogin'):
|
||||
with self.subTest(setting=setting):
|
||||
previous = self.effective
|
||||
self.effective = previous.replace(setting + ' no', setting + ' yes')
|
||||
with self.assertRaises(AccessError):
|
||||
self.host.verify_active([2222], True, '192.0.2.1')
|
||||
self.effective = previous
|
||||
|
||||
def test_inherited_final_restrictions_cannot_silently_allow_other_accounts(self):
|
||||
for previous, conflicting in (('allowusers adam', 'allowusers adam\nallowusers provider'),
|
||||
('x11forwarding no', 'x11forwarding yes'),
|
||||
('permitemptypasswords no', 'permitemptypasswords yes')):
|
||||
with self.subTest(conflicting=conflicting):
|
||||
original = self.effective
|
||||
self.effective = original.replace(previous, conflicting)
|
||||
with self.assertRaises(AccessError):
|
||||
self.host.verify_active([2222], True, '192.0.2.1')
|
||||
self.effective = original
|
||||
|
||||
def test_bootstrap_prerequisites_reject_socket_and_live_invocation_drift(self):
|
||||
self.arguments = self.running_arguments = ['/usr/sbin/sshd', '-D']
|
||||
self.cmdline()
|
||||
for failure in ('socket', 'invocation'):
|
||||
with self.subTest(failure=failure):
|
||||
def incompatible(args, failure=failure, **kwargs):
|
||||
if failure == 'socket' and args == ['systemctl', 'is-enabled', '--quiet', 'ssh.socket']:
|
||||
return subprocess.CompletedProcess(args, 0, '', '')
|
||||
return self.runner(args, **kwargs)
|
||||
self.host.runner = incompatible
|
||||
self.running_arguments = ['/usr/sbin/sshd', '-D', '-p', '22'] if failure == 'invocation' else self.arguments
|
||||
self.cmdline()
|
||||
with self.assertRaises(AccessError):
|
||||
self.host.bootstrap_preflight()
|
||||
|
||||
def test_later_preflight_still_checks_administrator_sudo(self):
|
||||
self.arguments = self.running_arguments = ['/usr/sbin/sshd', '-D']
|
||||
self.cmdline()
|
||||
def bad_sudo(args, **kwargs):
|
||||
if args[:3] == ['runuser', '-u', 'adam']:
|
||||
return subprocess.CompletedProcess(args, 1, '', 'sudo denied')
|
||||
return self.runner(args, **kwargs)
|
||||
self.host.runner = bad_sudo
|
||||
with self.assertRaises(AccessError):
|
||||
self.host.preflight()
|
||||
|
||||
def test_bootstrap_follows_recursive_relative_and_quoted_include_paths(self):
|
||||
self.arguments = self.running_arguments = ['/usr/sbin/sshd', '-D']
|
||||
self.cmdline()
|
||||
main_config = self.host.path('/etc/ssh/sshd_config')
|
||||
first = self.host.path('/etc/ssh/sshd_config.d/10-first.conf')
|
||||
second = self.host.path('/etc/ssh/provider # policy.conf')
|
||||
atomic(main_config, b'Include sshd_config.*/[0-9]*.conf /etc/ssh/missing/*.conf\n')
|
||||
atomic(first, b'iNcLuDe = "provider # policy.conf" # relative to /etc/ssh\n')
|
||||
atomic(second, b'# Match Address 203.0.113.0/24\nBanner "/etc/Match banner"\n')
|
||||
self.host.bootstrap_preflight()
|
||||
second.write_text('"mAtCh" Address 203.0.113.0/24\n PasswordAuthentication yes\n')
|
||||
before = {path: path.read_bytes() for path in (main_config, first, second)}
|
||||
with self.assertRaisesRegex(AccessError, 'Match.*manual reconciliation'):
|
||||
self.host.bootstrap_preflight()
|
||||
self.assertEqual({path: path.read_bytes() for path in before}, before)
|
||||
|
||||
def test_bootstrap_rejects_recursive_or_oversized_config_without_mutation(self):
|
||||
self.arguments = self.running_arguments = ['/usr/sbin/sshd', '-D']
|
||||
self.cmdline()
|
||||
configuration = self.host.path('/etc/ssh/sshd_config')
|
||||
for content in ('Include sshd_config\n', '#' + 'x' * (1024 * 1024 + 1)):
|
||||
with self.subTest(recursive=content.startswith('Include')):
|
||||
configuration.write_text(content)
|
||||
with self.assertRaisesRegex(AccessError, 'manual reconciliation'):
|
||||
self.host.bootstrap_preflight()
|
||||
self.assertEqual(configuration.read_text(), content)
|
||||
|
||||
def test_extended_include_glob_tokens_are_rejected_even_without_matches(self):
|
||||
self.arguments = self.running_arguments = ['/usr/sbin/sshd', '-D']
|
||||
self.cmdline()
|
||||
configuration = self.host.path('/etc/ssh/sshd_config')
|
||||
for pattern in ('provider[a[:digit:]].conf', 'provider[a[.x.]].conf',
|
||||
'provider[a[=x=]].conf', 'provider[:digit:].conf'):
|
||||
with self.subTest(pattern=pattern):
|
||||
content = 'Include ' + pattern + '\n'
|
||||
configuration.write_text(content)
|
||||
with self.assertRaisesRegex(AccessError, 'glob syntax.*manual reconciliation'):
|
||||
self.host.bootstrap_preflight()
|
||||
self.assertEqual(configuration.read_text(), content)
|
||||
|
||||
def test_unrestarted_daemon_with_old_arguments_is_rejected(self):
|
||||
self.running_arguments = ['/usr/sbin/sshd', '-D', '-p', '22']
|
||||
self.cmdline()
|
||||
with self.assertRaises(AccessError):
|
||||
self.host.verify_active([2222], True, '192.0.2.1')
|
||||
|
||||
def test_extra_live_listener_port_is_rejected(self):
|
||||
self.listeners += 'LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=755,fd=7))\n'
|
||||
with self.assertRaises(AccessError):
|
||||
self.host.verify_active([2222], True, '192.0.2.1')
|
||||
|
||||
def test_additional_sshd_service_listener_is_rejected(self):
|
||||
self.listeners += 'LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=999,fd=7))\n'
|
||||
with self.assertRaises(AccessError):
|
||||
self.host.verify_active([2222], True, '192.0.2.1')
|
||||
|
||||
def test_runtime_override_changes_fingerprint(self):
|
||||
before = self.host.fingerprint()
|
||||
atomic(self.host.path('/run/systemd/system/ssh.service.d/override.conf'), b'[Service]\nExecStart=/usr/sbin/sshd -D\n')
|
||||
self.assertNotEqual(before, self.host.fingerprint())
|
||||
|
||||
def test_managed_file_does_not_exempt_drifted_service_from_preflight(self):
|
||||
atomic(self.host.path('/etc/systemd/system/ssh.service.d/apex-init.conf'),
|
||||
b'[Service]\nExecStart=\nExecStart=/usr/sbin/sshd -p 2222 -o PermitRootLogin=no -D\n')
|
||||
with self.assertRaisesRegex(AccessError, 'differs from its managed invocation'):
|
||||
self.host.preflight()
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('sshd') and shutil.which('ssh-keygen'), 'OpenSSH tools unavailable')
|
||||
class OpenSSHPolicyTests(unittest.TestCase):
|
||||
"""Evaluate the managed options with a real daemon without opening sockets."""
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.key = self.root / 'host-key'
|
||||
self.config = self.root / 'etc/ssh/sshd_config'
|
||||
self.config.parent.mkdir(parents=True)
|
||||
self.config.write_text('PasswordAuthentication yes\nPermitRootLogin yes\n'
|
||||
'X11Forwarding yes\nPermitEmptyPasswords yes\n')
|
||||
subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(self.key)],
|
||||
check=True, capture_output=True, timeout=30)
|
||||
self.host = Host(self.root, self.runner)
|
||||
original_path = self.host.path
|
||||
self.host.path = lambda path: Path(path) if Path(path).is_relative_to(self.root) else original_path(path)
|
||||
|
||||
def runner(self, args, **kwargs):
|
||||
if args[0] != '/usr/sbin/sshd':
|
||||
code = 3 if args[-1] == 'ssh.socket' else 0
|
||||
output = 'process\n' if 'KillMode' in args else ''
|
||||
return subprocess.CompletedProcess(args, code, output, '')
|
||||
return subprocess.run([shutil.which('sshd'), '-f', str(self.config), '-h', str(self.key), *args[1:]],
|
||||
**kwargs)
|
||||
|
||||
def effective(self, arguments, user):
|
||||
result = self.runner(arguments + ['-T', '-C', 'user=' + user + ',host=localhost,addr=192.0.2.1'],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return result.stdout.splitlines()
|
||||
|
||||
def test_transition_preserves_provider_access_and_only_final_policy_excludes_it(self):
|
||||
transition = self.host.policy([22, 2222], False, '192.0.2.1')
|
||||
transitional = self.effective(transition, 'provider')
|
||||
self.assertFalse(any(line.startswith('allowusers ') for line in transitional))
|
||||
self.assertIn('passwordauthentication yes', transitional)
|
||||
final = self.host.policy([2222], True, '192.0.2.1')
|
||||
for user in ('adam', 'provider'):
|
||||
with self.subTest(user=user):
|
||||
effective = self.effective(final, user)
|
||||
allowed = [line.removeprefix('allowusers ') for line in effective if line.startswith('allowusers ')]
|
||||
self.assertEqual(allowed, ['adam'])
|
||||
self.assertNotIn('provider', allowed)
|
||||
self.assertIn('x11forwarding no', effective)
|
||||
self.assertIn('permitemptypasswords no', effective)
|
||||
|
||||
def test_proxmox_effective_root_key_auth_and_both_ports_with_legacy_operator_block(self):
|
||||
self.host.profile = 'proxmox'
|
||||
with self.config.open('a') as stream:
|
||||
stream.write('# APEX operator SSH policy\nMatch User adam\n'
|
||||
' AuthenticationMethods publickey\n PubkeyAuthentication yes\n'
|
||||
' PasswordAuthentication no\n KbdInteractiveAuthentication no\n'
|
||||
' PermitEmptyPasswords no\n X11Forwarding no\n')
|
||||
final = self.host.policy([22, 2222], True, '192.0.2.1')
|
||||
for user in ('root', 'adam'):
|
||||
effective = self.effective(final, user)
|
||||
self.assertEqual({item for item in effective if item.startswith('port ')}, {'port 22', 'port 2222'})
|
||||
self.assertTrue({'permitrootlogin without-password', 'permitrootlogin prohibit-password'}.intersection(effective))
|
||||
self.assertIn('authenticationmethods publickey', effective)
|
||||
self.assertFalse(any(item.startswith('allowusers ') for item in effective))
|
||||
with self.config.open('a') as stream:
|
||||
stream.write(' PasswordAuthentication yes\n')
|
||||
with self.assertRaisesRegex(AccessError, 'Match'):
|
||||
self.host.policy([22, 2222], True, '192.0.2.1')
|
||||
|
||||
def test_adding_probe_port_preserves_authentication_from_the_existing_service(self):
|
||||
self.host.profile = 'debian'
|
||||
args = ['/usr/sbin/sshd', '-p', '2200', '-o', 'PermitRootLogin=no', '-o', 'PasswordAuthentication=no', '-D']
|
||||
with patch.object(self.host, 'service_arguments', return_value=args):
|
||||
transition = self.host.policy([2200, 2222], False, '192.0.2.1')
|
||||
effective = self.effective(transition, 'root')
|
||||
self.assertIn('permitrootlogin no', effective)
|
||||
self.assertIn('passwordauthentication no', effective)
|
||||
self.assertIn('port 2200', effective)
|
||||
|
||||
def test_additive_inherited_allowusers_stops_finalization(self):
|
||||
with self.config.open('a') as stream:
|
||||
stream.write('AllowUsers provider\n')
|
||||
self.host.policy([22, 2222], False, '192.0.2.1')
|
||||
with self.assertRaises(AccessError):
|
||||
self.host.policy([2222], True, '192.0.2.1')
|
||||
|
||||
def test_provider_account_match_block_cannot_bypass_final_restrictions(self):
|
||||
provider = pwd.struct_passwd(('provider', 'x', 1001, 1001, '', '/home/provider', '/bin/bash'))
|
||||
for setting in ('AllowUsers provider', 'X11Forwarding yes', 'PermitEmptyPasswords yes'):
|
||||
with self.subTest(setting=setting):
|
||||
self.config.write_text('Match User provider\n ' + setting + '\n')
|
||||
with patch('pwd.getpwall', return_value=[provider]), self.assertRaises(AccessError):
|
||||
self.host.policy([2222], True, '192.0.2.1')
|
||||
|
||||
def test_bootstrap_rejects_match_address_in_main_or_recursively_included_file(self):
|
||||
conditional = ('\n AllowUsers root provider\n PasswordAuthentication yes\n'
|
||||
' PermitRootLogin yes\n AuthenticationMethods any\n'
|
||||
' X11Forwarding yes\n PermitEmptyPasswords yes\n')
|
||||
first = self.root / 'first # include.conf'
|
||||
second = self.root / 'second include.conf'
|
||||
for directive, included in (('Match Address', False), ('mAtCh=Address', False),
|
||||
('"Match" Address', False), ('"mAtCh" Address', True)):
|
||||
with self.subTest(directive=directive, included=included):
|
||||
payload = directive + ' 203.0.113.0/24' + conditional
|
||||
if included:
|
||||
second.write_text(payload)
|
||||
first.write_text('iNcLuDe "' + str(second) + '"\n')
|
||||
self.config.write_text('"Include" "' + str(first) + '" # provider policy\n')
|
||||
else:
|
||||
self.config.write_text(payload)
|
||||
arguments = ['/usr/sbin/sshd', '-T', '-p', '2222', '-o', 'AllowUsers=adam',
|
||||
'-o', 'PermitRootLogin=no', '-o', 'PasswordAuthentication=no',
|
||||
'-o', 'AuthenticationMethods=publickey', '-o', 'X11Forwarding=no',
|
||||
'-o', 'PermitEmptyPasswords=no']
|
||||
other_address = self.runner(arguments + ['-C', 'user=root,host=localhost,addr=203.0.113.25'],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(other_address.returncode, 0, other_address.stderr)
|
||||
self.assertIn('permitrootlogin yes', other_address.stdout.splitlines())
|
||||
self.assertIn('passwordauthentication yes', other_address.stdout.splitlines())
|
||||
before = self.config.read_bytes()
|
||||
with patch.object(self.host, 'service_arguments', return_value=['/usr/sbin/sshd', '-D']), \
|
||||
patch.object(self.host, 'live_arguments'), \
|
||||
self.assertRaisesRegex(AccessError, 'Match.*manual reconciliation'):
|
||||
self.host.bootstrap_preflight()
|
||||
self.assertEqual(self.config.read_bytes(), before)
|
||||
|
||||
def test_posix_class_include_cannot_hide_a_match_from_preflight(self):
|
||||
included = self.root / 'provider1.conf'
|
||||
included.write_text('Match Address 203.0.113.0/24\n PermitRootLogin yes\n')
|
||||
for pattern in ('provider[[:digit:]].conf', 'provider[a[:digit:]].conf'):
|
||||
with self.subTest(pattern=pattern):
|
||||
self.config.write_text('Include ' + str(self.root / pattern) + '\n')
|
||||
effective = self.runner(['/usr/sbin/sshd', '-T', '-o', 'PermitRootLogin=no',
|
||||
'-C', 'user=root,host=localhost,addr=203.0.113.25'],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(effective.returncode, 0, effective.stderr)
|
||||
self.assertIn('permitrootlogin yes', effective.stdout.splitlines())
|
||||
with patch.object(self.host, 'service_arguments', return_value=['/usr/sbin/sshd', '-D']), \
|
||||
patch.object(self.host, 'live_arguments'), \
|
||||
self.assertRaisesRegex(AccessError, 'manual reconciliation'):
|
||||
self.host.bootstrap_preflight()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the core and standalone provisioning test suites (stdlib unittest — no pytest).
|
||||
# Run CLI, bootstrap, and host provisioning checks with the project modules on the import path.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
export PYTHONPATH="$PWD/scripts${PYTHONPATH:+:$PYTHONPATH}"
|
||||
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
PYTHONPATH=ci/scripts/init-node${PYTHONPATH:+:$PYTHONPATH} python3 -m unittest discover -s ci/scripts/init-node/tests -p 'test_*.py' -v
|
||||
python3 -m unittest discover -s tests/provisioning -p 'test_*.py' -v
|
||||
@@ -60,7 +60,7 @@ class HostSetup(unittest.TestCase):
|
||||
def test_installed_watchdog_survives_checkout_and_dispatches_cli_arguments(self):
|
||||
hardening = action("config/hardening")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
installer = overlay.load_module(str(root / "ci/scripts/init-node/provisioning/manual_bundle.py"),
|
||||
installer = overlay.load_module(str(root / "scripts/provisioning/manual_bundle.py"),
|
||||
"test_manual_bundle")
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(sys, "path", list(sys.path)):
|
||||
destination = installer.install(Path(temp).resolve())
|
||||
|
||||
@@ -26,7 +26,7 @@ class ToolingTests(unittest.TestCase):
|
||||
sources = []
|
||||
(root / 'scripts').mkdir()
|
||||
(root / 'scripts/bootstrap-node').write_text('print(1)\n')
|
||||
for name in ('engine', 'actions', 'tests', 'ci/scripts', 'scripts'):
|
||||
for name in ('actions', 'tests', 'scripts'):
|
||||
source = root / name / 'nested' / 'case.py'
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text('value = 1\n')
|
||||
@@ -49,7 +49,7 @@ class ToolingTests(unittest.TestCase):
|
||||
def test_suite_runner_discovers_both_suites_and_propagates_failures(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
suites = (root / 'tests', root / 'ci/scripts/init-node/tests')
|
||||
suites = (root / 'tests', root / 'tests/provisioning')
|
||||
for suite in suites:
|
||||
suite.mkdir(parents=True)
|
||||
shutil.copyfile(ROOT / 'tests/run.sh', suites[0] / 'run.sh')
|
||||
|
||||
Reference in new issue
Block a user