Files
apex.ermnvldmr.com/tests/test_manual_host_setup.py
T

277 lines
16 KiB
Python

import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from engine import overlay
def action(name):
return overlay.load_module(str(Path(__file__).resolve().parents[1] / f"actions/{name}.py"),
"test_manual_" + name.replace("/", "_"))
class HostSetup(unittest.TestCase):
def test_packages_preserve_pve_firewall(self):
packages = action("config/packages")
ctx = SimpleNamespace(sys=Mock())
with (patch.object(Path, "is_file", return_value=True),
patch.object(Path, "exists", return_value=True),
patch.object(packages, "_install_missing") as install):
packages.run(ctx, None)
requested = install.call_args.args[1]
self.assertIn("sudo", requested)
self.assertNotIn("ufw", requested)
def test_fresh_key_proof_gates_each_destructive_phase(self):
hardening = action("config/hardening")
for phase, operation in (("transition", "finalize"), ("final", "commit")):
state = {"status": phase, "authenticated_after_us": 123,
"bundle": "/usr/local/lib/apex-provisioner/" + "a" * 64, "token": "a" * 32}
system = Mock()
with (patch.object(hardening, "_state", return_value=state),
patch.object(hardening, "_connection", side_effect=RuntimeError("fresh login required")) as proof):
with self.assertRaisesRegex(RuntimeError, "fresh login required"):
hardening.run(SimpleNamespace(sys=system), SimpleNamespace(operation=operation))
proof.assert_called_once_with(unittest.mock.ANY, after=123)
self.assertFalse(system.mock_calls)
def test_begin_installs_bundle_before_dispatch_with_real_endpoint_and_profile(self):
hardening = action("config/hardening")
ctx = SimpleNamespace(sys=Mock())
bundle = "/usr/local/lib/apex-provisioner/" + "a" * 64
ctx.sys.sudo.return_value.stdout = bundle
with (patch.object(hardening, "_operator_access"),
patch.object(hardening, "_state", return_value={}),
patch.object(Path, "is_file", return_value=True),
patch.object(hardening, "_original_endpoint", return_value=(2022, "192.0.2.8"))):
hardening.run(ctx, None)
install, begin = [call.args[0] for call in ctx.sys.sudo.call_args_list]
self.assertTrue(install[-1].endswith("/manual_bundle.py"))
self.assertIn(bundle, begin)
self.assertEqual(begin[begin.index("begin"): -2],
["begin", "--profile", "proxmox", "--original-port", "2022", "--client-address", "192.0.2.8"])
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"),
"test_manual_bundle")
with tempfile.TemporaryDirectory() as temp, patch.object(sys, "path", list(sys.path)):
destination = installer.install(Path(temp).resolve())
self.assertEqual(installer.install(Path(temp).resolve()), destination)
state = {"status": "final", "authenticated_after_us": 120, "profile": "debian",
"bundle": "/usr/local/lib/apex-provisioner/" + destination.name, "token": "a" * 32}
system = Mock()
with (patch.object(hardening, "_state", return_value=state),
patch.object(hardening, "_connection")):
hardening.run(SimpleNamespace(sys=system), SimpleNamespace(operation="commit"))
# Run the generated CLI in isolation. Stop at its root check after argument parsing.
command = system.sudo.call_args.args[0]
command[0] = sys.executable
command[3] = "import os; os.geteuid = lambda: 1000; " + command[3]
command[4] = str(destination)
result = subprocess.run(command, cwd=temp, capture_output=True, text=True, timeout=30)
self.assertIn("Access handover requires root", result.stderr)
source = destination / "provisioning/access.py"
source.chmod(0o666)
with self.assertRaisesRegex(RuntimeError, "Untrusted installed"):
installer.install(Path(temp).resolve())
source.chmod(0o644)
source.write_text("changed")
with self.assertRaisesRegex(RuntimeError, "differs from the checkout"):
installer.install(Path(temp).resolve())
def test_other_linux_distributions_are_rejected_before_installing_the_watchdog(self):
hardening = action("config/hardening")
ctx = SimpleNamespace(sys=Mock())
with (patch.object(hardening, "_operator_access"),
patch.object(hardening, "_state", return_value={}),
patch.object(Path, "is_file", return_value=False),
patch.object(hardening.platform, "freedesktop_os_release", return_value={"ID": "ubuntu"})):
with self.assertRaisesRegex(RuntimeError, "supports Debian"):
hardening.run(ctx, None)
self.assertFalse(ctx.sys.mock_calls)
def test_current_endpoint_must_have_authenticated_with_a_key(self):
hardening = action("config/hardening")
ctx = SimpleNamespace(sys=Mock())
sockets = SimpleNamespace(stdout='0 0 192.0.2.9:22 192.0.2.8:3002 users:(("sshd",pid=60,fd=4))')
accepted = {"_PID": "60", "__MONOTONIC_TIMESTAMP": "100",
"MESSAGE": "Accepted publickey for adam from 192.0.2.8 port 3002 ssh2: ED25519 SHA256:example"}
with (patch.dict(os.environ, {"SSH_CONNECTION": "192.0.2.100 4321 192.0.2.9 1234",
"SSH_USER_AUTH": "/arbitrary/file"}),
patch.object(hardening, "_ancestors", return_value={60: 50}),
patch.object(hardening.pwd, "getpwuid", return_value=SimpleNamespace(pw_name="adam"))):
for changes in ({"_PID": "42"}, {"__MONOTONIC_TIMESTAMP": "49"},
{"MESSAGE": accepted["MESSAGE"].replace("3002", "4002")}):
with self.subTest(changes=changes):
ctx.sys.sudo.side_effect = [sockets, SimpleNamespace(
returncode=0, stdout=json.dumps({**accepted, **changes}))]
with self.assertRaisesRegex(RuntimeError, "Cannot verify"):
hardening._connection(ctx)
ctx.sys.sudo.side_effect = [sockets, SimpleNamespace(returncode=0, stdout=json.dumps(accepted))]
self.assertEqual(hardening._connection(ctx),
"user=adam,host=192.0.2.8,addr=192.0.2.8,laddr=192.0.2.9,lport=22")
def test_reused_transport_and_wrong_port_cannot_finalize(self):
hardening = action("config/hardening")
ctx = SimpleNamespace(sys=Mock())
entry = {"_PID": "60", "__MONOTONIC_TIMESTAMP": "100",
"MESSAGE": "Accepted publickey for adam from 192.0.2.8 port 3002 ssh2: ED25519"}
ctx.sys.sudo.return_value = SimpleNamespace(returncode=0, stdout=json.dumps(entry))
with (patch.object(hardening.pwd, "getpwuid", return_value=SimpleNamespace(pw_name="adam")),
patch.object(hardening, "_ancestors", return_value={60: 50})):
for port, after in ((22, 49), (2222, 50), (2222, 99), (2222, 101)):
with (self.subTest(port=port, after=after),
patch.object(hardening, "_socket", return_value=("192.0.2.9", port, "192.0.2.8", 3002)),
self.assertRaises(RuntimeError)):
hardening._connection(ctx, after=after)
with patch.object(hardening, "_socket", return_value=("192.0.2.9", 2222, "192.0.2.8", 3002)):
self.assertIn("lport=2222", hardening._connection(ctx, after=49))
def test_journald_restart_failure_restores_previous_config(self):
base = action("config/base")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
target = root / "99-apex.conf"
legacy = root / "maxuse.conf"
target.write_text("previous\n")
legacy.write_text("legacy\n")
backup = root / "legacy-backup"
host = Mock()
host.backup.side_effect = lambda path, **kwargs: (backup.write_bytes(Path(path).read_bytes())
and str(backup))
host.write_file.side_effect = lambda path, content, **kwargs: Path(path).write_text(content)
def sudo(command):
if command[0] == "systemctl":
raise RuntimeError("restart failed")
if command[0] == "cp":
Path(command[-1]).write_bytes(Path(command[-2]).read_bytes())
else:
Path(command[-1]).unlink()
ctx = SimpleNamespace(sys=SimpleNamespace(sudo=sudo), host=host)
with self.assertRaisesRegex(RuntimeError, "restart failed"):
base._configure(ctx, target, legacy, "updated\n", restart=True)
self.assertEqual(target.read_text(), "previous\n")
self.assertEqual(legacy.read_text(), "legacy\n")
def test_unverified_session_makes_no_host_changes(self):
hardening = action("config/hardening")
ctx = SimpleNamespace(sys=Mock(), host=Mock())
with (patch.object(hardening.pwd, "getpwnam", return_value=SimpleNamespace(
pw_uid=1001, pw_dir="/home/adam")),
patch.object(hardening.pwd, "getpwuid", return_value=SimpleNamespace(pw_name="root")),
patch.object(Path, "read_text", return_value="1001")):
with self.assertRaisesRegex(RuntimeError, "independent administrator"):
hardening.run(ctx, None)
self.assertFalse(ctx.host.mock_calls)
self.assertFalse(ctx.sys.mock_calls)
def test_independent_administrator_can_harden_only_with_an_operator_key(self):
hardening = action("config/hardening")
account = SimpleNamespace(pw_uid=1001, pw_dir="/home/adam")
system = Mock()
def privileged(command, **kwargs):
output = ""
if "stat" in command:
output = "1001 600 regular file" if command[-1].endswith("authorized_keys") else "1001 700 directory"
elif "cat" in command:
output = "ssh-ed25519 fixture"
elif "-T" in command:
output = "authorizedkeysfile .ssh/authorized_keys"
return SimpleNamespace(stdout=output, returncode=0)
system.sudo.side_effect = privileged
ctx = SimpleNamespace(sys=system)
with (patch.object(hardening.pwd, "getpwnam", return_value=account),
patch.object(hardening.pwd, "getpwuid", return_value=SimpleNamespace(pw_name="root")),
patch.object(hardening.os, "getuid", return_value=0),
patch.object(Path, "read_text", return_value="0")):
system.run.return_value.returncode = 1
with self.assertRaisesRegex(RuntimeError, "valid unrestricted operator public key"):
hardening._operator_access(ctx)
system.run.return_value.returncode = 0
self.assertEqual(hardening._operator_access(ctx), "user=adam,host=localhost,addr=127.0.0.1")
def test_chown_limits_changes_to_checkout_and_skips_mounts_and_hardlinks(self):
chown = action("repository/chown")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
for name in (".git", "configs", "volumes"):
(root / name).mkdir()
for name in ("configs/node", ".git/HEAD", "private", "volumes/data"):
(root / name).write_text("fixture")
os.link(root / "private", root / "configs/key")
(root / "configs/other").symlink_to(root / "volumes", target_is_directory=True)
with patch("os.chown") as ownership, patch("os.fchown"):
chown._repair(str(root), {"configs/node", "configs/key", "configs/other", "volumes/data"},
{str(root / "volumes")}, os.getuid(), os.getgid())
self.assertEqual({call.args[0] for call in ownership.call_args_list},
{"HEAD", "node", "other"})
self.assertTrue(all(not call.kwargs["follow_symlinks"] for call in ownership.call_args_list))
def test_operator_provisioning_creates_account_then_preserves_keys_on_rerun(self):
adam = action("config/adam")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
home = root / "adam"
account = SimpleNamespace(pw_dir=str(home), pw_uid=1001)
key = root / "operator"
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True)
public = key.with_suffix(".pub")
ctx = SimpleNamespace(sys=Mock(), host=Mock())
ctx.sys.run.side_effect = lambda cmd, **kw: subprocess.run(cmd, input=kw["input"], text=True,
capture_output=True, check=True)
def privileged(command, **kwargs):
if command[0] == "useradd":
home.mkdir()
if command[:6] == ["runuser", "-u", "adam", "--", "sh", "-ec"]:
subprocess.run(command[4:], text=True, input=kwargs["input"], check=True)
ctx.sys.sudo.side_effect = privileged
actual_stat = Path.stat
def metadata(path, *args, **kwargs):
stat = actual_stat(path, *args, **kwargs)
if path == home:
fields = list(stat)
fields[4] = account.pw_uid
return os.stat_result(fields)
return stat
with (patch.object(adam, "HOME", home), patch.object(Path, "stat", metadata),
patch.object(adam.grp, "getgrnam", side_effect=KeyError),
patch.object(adam.pwd, "getpwnam", side_effect=[KeyError, account, account])):
args = SimpleNamespace(public_key_file=str(public))
adam.run(ctx, args)
authorized = home / ".ssh/authorized_keys"
with authorized.open("a") as stream:
stream.write("existing operator entry\n")
adam.run(ctx, args)
self.assertEqual(authorized.read_text().splitlines().count(public.read_text().strip()), 1)
self.assertIn("existing operator entry", authorized.read_text())
commands = [call.args[0] for call in ctx.sys.sudo.call_args_list]
self.assertEqual(sum(cmd[0] == "useradd" for cmd in commands), 1)
self.assertIn(["runuser", "-u", "adam", "--", "sudo", "-n", "true"], commands)
def test_existing_system_account_cannot_be_promoted(self):
adam = action("config/adam")
ctx = SimpleNamespace(sys=Mock())
account = SimpleNamespace(pw_dir="/home/adam", pw_uid=999)
with patch.object(adam.pwd, "getpwnam", return_value=account):
with self.assertRaisesRegex(RuntimeError, "incompatible UID"):
adam.run(ctx, SimpleNamespace(public_key_file=None))
self.assertFalse(ctx.sys.mock_calls)
def test_invalid_operator_key_is_rejected_before_provisioning(self):
adam = action("config/adam")
with tempfile.NamedTemporaryFile(mode="w") as key:
key.write("private key fixture")
key.flush()
ctx = SimpleNamespace(sys=Mock())
with self.assertRaisesRegex(ValueError, "OpenSSH public key"):
adam.run(ctx, SimpleNamespace(public_key_file=key.name))
self.assertFalse(ctx.sys.mock_calls)