566 lines
31 KiB
Python
566 lines
31 KiB
Python
import contextlib
|
|
import fcntl
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
spec = importlib.util.spec_from_file_location("ec_agent_update", Path(__file__).resolve().parents[1] / "ec_agent_update.py")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
PREVIOUS = b'VERSION = "1.0.0"\n# previous agent\n'
|
|
CANDIDATE = b'VERSION = "1.1.0"\n# next agent\n'
|
|
|
|
|
|
class PowerLoss(BaseException):
|
|
pass
|
|
|
|
|
|
class Response(io.BytesIO):
|
|
def __init__(self, value, headers=None):
|
|
super().__init__(value)
|
|
self.headers = headers or {}
|
|
self.status = 200
|
|
|
|
|
|
class UpdateTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.base = Path(self.temp.name).resolve()
|
|
self.config_dir = self.base / "etc" / "electric-cherry"
|
|
self.bin_dir = self.base / "libexec"
|
|
self.root = self.base / "data"
|
|
for directory in (self.config_dir, self.bin_dir, self.root / "agent"):
|
|
directory.mkdir(parents=True)
|
|
paths = {
|
|
"AGENT_CONFIG": self.config_dir / "agent.json",
|
|
"RELEASE_KEYRING": self.config_dir / "release-signing.gpg",
|
|
"AGENT_EXECUTABLE": self.bin_dir / "electric-cherry-agent.py",
|
|
"UNINSTALL_EXECUTABLE": self.bin_dir / "electric-cherry-uninstall.py",
|
|
"UNINSTALL_REQUEST": self.config_dir / "decommission.json",
|
|
"UPDATE_LOCK": self.config_dir / "update.lock",
|
|
"UPDATE_DIR": self.config_dir / "update",
|
|
"UPDATE_JOURNAL": self.config_dir / "update" / "journal.json",
|
|
"UPDATE_STATUS": self.config_dir / "update-status.json",
|
|
"AGENT_HEALTH": self.config_dir / "agent-health.json",
|
|
}
|
|
self.patches = [patch.object(module, name, value) for name, value in paths.items()]
|
|
def trusted_for_test(value, missing=False, directory=False):
|
|
path = Path(value)
|
|
if any(item.is_symlink() for item in (path, *path.parents)):
|
|
raise module.UpdateError("Unsafe symbolic link")
|
|
if not path.exists():
|
|
if missing:
|
|
return path
|
|
raise module.UpdateError("File unavailable")
|
|
if directory and not path.is_dir() or not directory and not path.is_file():
|
|
raise module.UpdateError("Wrong file type")
|
|
return path
|
|
self.signature_patch = patch.object(module, "verify_release_signature")
|
|
self.patches.extend([patch.object(module, "trusted", side_effect=trusted_for_test), patch.object(module, "sync_parent"), patch.object(module, "systemctl", side_effect=self.systemctl), self.signature_patch])
|
|
for item in self.patches:
|
|
mocked = item.start()
|
|
if item is self.signature_patch:
|
|
self.signature_verifier = mocked
|
|
self.config = {"hostId": "host-one", "root": str(self.root), "controller": "https://controller.example", "credential": "secret-enrollment-credential"}
|
|
self.config_bytes = json.dumps(self.config).encode()
|
|
module.AGENT_CONFIG.write_bytes(self.config_bytes)
|
|
(self.root / ".electric-cherry.json").write_text(json.dumps({"version": 1, "root": str(self.root), "hostId": "host-one", "controller": "https://controller.example"}))
|
|
module.AGENT_EXECUTABLE.write_bytes(PREVIOUS)
|
|
module.UNINSTALL_EXECUTABLE.write_bytes(PREVIOUS)
|
|
(self.root / "games.ff").write_bytes(b"large game files unchanged")
|
|
self.commands = []
|
|
self.active = True
|
|
self.pid = 123
|
|
self.write_health = True
|
|
self.downloads = []
|
|
self.source = CANDIDATE
|
|
self.signature = b"detached signature"
|
|
self.release = {"version": "1.1.0", "sha256": module.sha256(CANDIDATE), "protocolVersion": 1, "updaterProtocolVersion": 2, "platform": "debian-amd64"}
|
|
|
|
def tearDown(self):
|
|
for item in reversed(self.patches):
|
|
item.stop()
|
|
self.temp.cleanup()
|
|
|
|
def systemctl(self, *args):
|
|
self.commands.append(args)
|
|
if args == ("stop", module.AGENT_SERVICE):
|
|
self.active = False
|
|
return ""
|
|
if args == ("start", module.AGENT_SERVICE):
|
|
# The candidate must be able to take the operation lock at startup.
|
|
with module.exclusive_lock(self.root / "agent" / "activity.lock") as activity:
|
|
self.assertIsNotNone(activity, "Updater held the operation lock while starting the agent")
|
|
self.active = True
|
|
self.pid += 1
|
|
if self.write_health and module.UPDATE_JOURNAL.exists():
|
|
journal = json.loads(module.UPDATE_JOURNAL.read_text())
|
|
module.AGENT_HEALTH.write_text(json.dumps({"version": module.source_version(module.AGENT_EXECUTABLE.read_bytes()), "pid": self.pid, "checkedAt": time.time(), "updateId": journal["id"]}))
|
|
return ""
|
|
if args == ("show", module.AGENT_SERVICE, "--property=ActiveState", "--property=MainPID"):
|
|
return "ActiveState=" + ("active" if self.active else "inactive") + "\nMainPID=" + str(self.pid if self.active else 0) + "\n"
|
|
self.fail("Unexpected service command " + str(args))
|
|
|
|
def fetch(self, path, limit):
|
|
self.downloads.append(path)
|
|
if path.endswith("manifest.json"):
|
|
return json.dumps(self.release).encode()
|
|
if path.endswith("manifest.json.sig"):
|
|
if isinstance(self.signature, Exception):
|
|
raise self.signature
|
|
return self.signature
|
|
if path.endswith("ec-agent.py"):
|
|
return self.source
|
|
self.fail("Unexpected download " + path)
|
|
|
|
def updater(self):
|
|
updater = module.Updater()
|
|
updater.fetch = self.fetch
|
|
return updater
|
|
|
|
def status(self):
|
|
return json.loads(module.UPDATE_STATUS.read_text())
|
|
|
|
def assert_unchanged(self):
|
|
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), PREVIOUS)
|
|
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), PREVIOUS)
|
|
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
|
|
self.assertEqual((self.root / "games.ff").read_bytes(), b"large game files unchanged")
|
|
|
|
def test_verified_upgrade_waits_for_authenticated_candidate_health(self):
|
|
self.assertEqual(self.updater().run(), "updated")
|
|
self.signature_verifier.assert_called_once_with(json.dumps(self.release).encode(), self.signature)
|
|
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
|
|
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE)
|
|
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
|
|
self.assertEqual((self.root / "games.ff").read_bytes(), b"large game files unchanged")
|
|
self.assertEqual(self.status()["currentVersion"], "1.1.0")
|
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
|
self.assertFalse((module.UPDATE_DIR / "agent.backup.py").exists())
|
|
self.assertEqual(next(command for command in self.commands if command[0] != "show"), ("stop", module.AGENT_SERVICE))
|
|
|
|
def test_invalid_signature_rejects_attacker_source_and_matching_manifest_checksum(self):
|
|
self.source = b'VERSION = "1.1.0"\nraise RuntimeError("attacker code")\n'
|
|
self.release["sha256"] = module.sha256(self.source)
|
|
self.signature_verifier.side_effect = module.IntegrityError("Invalid release signature")
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assert_unchanged()
|
|
self.assertEqual(self.commands, [])
|
|
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
|
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
|
self.assertFalse((module.UPDATE_DIR / "failed-release.json").exists())
|
|
self.assertNotIn("targetVersion", self.status())
|
|
|
|
def test_signature_is_verified_before_manifest_json_is_parsed(self):
|
|
updater = self.updater()
|
|
updater.fetch = lambda path, limit: b"untrusted invalid JSON" if path.endswith("manifest.json") else self.signature
|
|
self.signature_verifier.side_effect = module.IntegrityError("Invalid release signature")
|
|
with patch.object(module.json, "loads", side_effect=AssertionError("Parsed unsigned metadata")):
|
|
with self.assertRaisesRegex(module.IntegrityError, "Invalid release signature"):
|
|
updater.manifest()
|
|
self.signature_verifier.assert_called_once_with(b"untrusted invalid JSON", self.signature)
|
|
|
|
def test_missing_signature_never_downloads_code_or_stops_agent(self):
|
|
self.signature = module.ContactError("No signature is published")
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assertIn("signature", self.status()["error"])
|
|
self.assert_unchanged()
|
|
self.assertEqual(self.commands, [])
|
|
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
|
|
self.signature_verifier.assert_not_called()
|
|
|
|
def test_equal_or_older_versions_do_not_download_or_restart(self):
|
|
for target in ("1.0.0", "0.9.9"):
|
|
with self.subTest(target=target):
|
|
self.release["version"] = target
|
|
self.assertEqual(self.updater().run(), "current")
|
|
self.assert_unchanged()
|
|
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
|
|
self.assertEqual(self.commands, [])
|
|
|
|
def test_busy_operation_defers_update_without_stopping_agent(self):
|
|
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
|
|
self.assertEqual(self.updater().run(), "deferred")
|
|
self.assertIn("executing an operation", self.status()["error"])
|
|
self.assert_unchanged()
|
|
self.assertEqual(self.commands, [])
|
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
|
|
|
def test_deliberately_stopped_agent_is_not_updated_or_started(self):
|
|
self.active = False
|
|
self.assertEqual(self.updater().run(), "deferred")
|
|
self.assertIn("Agent service is not running", self.status()["error"])
|
|
self.assert_unchanged()
|
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
|
self.assertFalse(self.active)
|
|
self.assertFalse(any(command[0] in ("start", "stop") for command in self.commands))
|
|
|
|
def test_concurrent_worker_does_not_overwrite_active_worker_status(self):
|
|
module.UPDATE_STATUS.write_text('{"state":"updating"}')
|
|
with module.exclusive_lock(module.UPDATE_LOCK):
|
|
self.assertEqual(self.updater().run(), "deferred")
|
|
self.assertEqual(self.status(), {"state": "updating"})
|
|
self.assertEqual(self.downloads, [])
|
|
|
|
def test_uninstall_request_prevents_any_download_or_mutation(self):
|
|
module.UNINSTALL_REQUEST.write_text("{}")
|
|
self.assertEqual(self.updater().run(), "deferred")
|
|
self.assertIn("deletion", self.status()["error"])
|
|
self.assertEqual(self.downloads, [])
|
|
self.assertEqual(self.commands, [])
|
|
self.assert_unchanged()
|
|
|
|
def test_uninstall_starting_during_download_wins_before_code_changes(self):
|
|
updater = self.updater()
|
|
def fetch(path, limit):
|
|
data = self.fetch(path, limit)
|
|
if path.endswith("ec-agent.py"):
|
|
module.UNINSTALL_REQUEST.write_text("{}")
|
|
return data
|
|
updater.fetch = fetch
|
|
self.assertEqual(updater.run(), "deferred")
|
|
self.assert_unchanged()
|
|
self.assertEqual(self.commands, [])
|
|
|
|
def test_controller_unavailable_keeps_running_agent(self):
|
|
updater = self.updater()
|
|
updater.fetch = lambda *_: (_ for _ in ()).throw(module.ContactError("Controller unavailable"))
|
|
self.assertEqual(updater.run(), "deferred")
|
|
self.assert_unchanged()
|
|
self.assertTrue(self.active)
|
|
self.assertEqual(self.commands, [])
|
|
|
|
def test_checksum_failure_holds_artifact_without_executing_it(self):
|
|
self.source = CANDIDATE + b"# changed in transit"
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assertIn("checksum", self.status()["error"])
|
|
self.assert_unchanged()
|
|
self.assertEqual(self.commands, [])
|
|
self.downloads.clear()
|
|
self.source = CANDIDATE
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
|
|
|
|
def test_invalid_or_mismatched_python_version_is_never_installed(self):
|
|
for source in (b'VERSION = "1.1.0"\ninvalid syntax ?\n', b'VERSION = "5.0.0"\n'):
|
|
with self.subTest(source=source):
|
|
self.source = source
|
|
self.release["sha256"] = module.sha256(source)
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assert_unchanged()
|
|
self.assertEqual(self.commands, [])
|
|
|
|
def test_incompatible_protocol_platform_or_version_never_downloads_code(self):
|
|
original = dict(self.release)
|
|
for key, value in (("protocolVersion", 2), ("protocolVersion", True), ("platform", "linux-arm64"), ("version", "1.1.0-rc1"), ("version", "01.1.0"), ("updaterProtocolVersion", 1), ("updaterProtocolVersion", 3)):
|
|
with self.subTest(key=key, value=value):
|
|
self.release = {**original, key: value}
|
|
self.assertIn(self.updater().run(), ("failed", "deferred"))
|
|
self.assert_unchanged()
|
|
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
|
|
self.assertEqual(self.commands, [])
|
|
|
|
def test_failed_health_rolls_back_both_files_then_holds_release(self):
|
|
updater = self.updater()
|
|
updater.wait_healthy = lambda journal: journal["targetVersion"] == "1.0.0"
|
|
self.assertEqual(updater.run(), "failed")
|
|
self.assert_unchanged()
|
|
self.assertTrue(self.active)
|
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
|
self.assertEqual(self.status()["currentVersion"], "1.0.0")
|
|
self.assertEqual(sum(command == ("start", module.AGENT_SERVICE) for command in self.commands), 2)
|
|
self.downloads.clear()
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
|
|
|
|
def test_changed_artifact_of_failed_target_can_be_retried(self):
|
|
updater = self.updater()
|
|
updater.wait_healthy = lambda journal: journal["targetVersion"] == "1.0.0"
|
|
self.assertEqual(updater.run(), "failed")
|
|
self.source = CANDIDATE + b"# corrected release\n"
|
|
self.release["sha256"] = module.sha256(self.source)
|
|
self.assertEqual(self.updater().run(), "updated")
|
|
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), self.source)
|
|
|
|
def crash_during_second_replacement(self):
|
|
original = module.atomic_bytes
|
|
def interrupt(path, value, mode=0o600):
|
|
if Path(path) == module.UNINSTALL_EXECUTABLE and value == CANDIDATE:
|
|
raise PowerLoss()
|
|
return original(path, value, mode)
|
|
with patch.object(module, "atomic_bytes", side_effect=interrupt):
|
|
with self.assertRaises(PowerLoss):
|
|
self.updater().run()
|
|
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
|
|
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), PREVIOUS)
|
|
self.assertTrue(module.UPDATE_JOURNAL.exists())
|
|
|
|
def test_crash_between_executable_replacements_recovers_from_backups(self):
|
|
self.crash_during_second_replacement()
|
|
self.downloads.clear()
|
|
self.signature_verifier.reset_mock()
|
|
self.signature_verifier.side_effect = module.IntegrityError("Signing keyring unavailable during recovery")
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assert_unchanged()
|
|
self.assertEqual(self.downloads, [])
|
|
self.signature_verifier.assert_not_called()
|
|
self.assertTrue(self.active)
|
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
|
|
|
def test_crash_after_verification_still_recovers_conservatively(self):
|
|
updater = self.updater()
|
|
updater.finish_journal = lambda: (_ for _ in ()).throw(PowerLoss())
|
|
with self.assertRaises(PowerLoss):
|
|
updater.run()
|
|
self.assertEqual(json.loads(module.UPDATE_JOURNAL.read_text())["phase"], "verifying")
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assert_unchanged()
|
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
|
|
|
def test_corrupt_backup_keeps_journal_and_never_overwrites_code(self):
|
|
self.crash_during_second_replacement()
|
|
(module.UPDATE_DIR / "agent.backup.py").write_bytes(b"corrupt")
|
|
before = len(self.commands)
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assertTrue(module.UPDATE_JOURNAL.exists())
|
|
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
|
|
self.assertEqual(len(self.commands), before)
|
|
|
|
def test_changed_enrollment_prevents_update_and_rollback(self):
|
|
self.crash_during_second_replacement()
|
|
changed = {**self.config, "hostId": "different-host"}
|
|
module.AGENT_CONFIG.write_text(json.dumps(changed))
|
|
(self.root / ".electric-cherry.json").write_text(json.dumps({"version": 1, "root": str(self.root), "hostId": "different-host", "controller": "https://controller.example"}))
|
|
before = len(self.commands)
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assertTrue(module.UPDATE_JOURNAL.exists())
|
|
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
|
|
self.assertEqual(len(self.commands), before)
|
|
|
|
def test_rollback_busy_lock_keeps_recovery_journal_for_next_tick(self):
|
|
self.crash_during_second_replacement()
|
|
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
|
|
self.assertEqual(self.updater().run(), "deferred")
|
|
self.assertEqual(json.loads(module.UPDATE_JOURNAL.read_text())["phase"], "rollback")
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assert_unchanged()
|
|
|
|
def test_unconfirmed_rollback_health_retains_backups_and_journal(self):
|
|
updater = self.updater()
|
|
updater.wait_healthy = lambda _: False
|
|
self.assertEqual(updater.run(), "failed")
|
|
self.assert_unchanged()
|
|
self.assertEqual(json.loads(module.UPDATE_JOURNAL.read_text())["phase"], "rollback")
|
|
self.assertTrue((module.UPDATE_DIR / "agent.backup.py").exists())
|
|
self.assertIn("recovery is pending", self.status()["error"])
|
|
# A subsequent worker can safely confirm the restored release.
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
|
|
|
def test_stale_wrong_pid_wrong_release_or_wrong_update_health_is_rejected(self):
|
|
updater = self.updater()
|
|
journal = {"id": "this-update", "targetVersion": "1.1.0", "switchedAt": time.time()}
|
|
valid = {"version": "1.1.0", "pid": self.pid, "checkedAt": time.time(), "updateId": "this-update"}
|
|
for key, value in (("pid", self.pid + 1), ("version", "1.0.0"), ("updateId", "previous-update"), ("checkedAt", journal["switchedAt"] - 1), ("checkedAt", float("nan")), ("checkedAt", time.time() + 100)):
|
|
with self.subTest(key=key, value=value):
|
|
module.AGENT_HEALTH.write_text(json.dumps({**valid, key: value}))
|
|
with patch.object(module, "HEALTH_TIMEOUT", 1), patch.object(module.time, "monotonic", side_effect=[0, 0, 2]), patch.object(module.time, "sleep"):
|
|
self.assertFalse(updater.wait_healthy(journal))
|
|
module.AGENT_HEALTH.write_text(json.dumps(valid))
|
|
self.assertTrue(updater.wait_healthy(journal))
|
|
|
|
def test_symlinked_executable_is_rejected_without_touching_target(self):
|
|
external = self.base / "external.py"
|
|
external.write_bytes(PREVIOUS)
|
|
module.AGENT_EXECUTABLE.unlink()
|
|
module.AGENT_EXECUTABLE.symlink_to(external)
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assertEqual(external.read_bytes(), PREVIOUS)
|
|
self.assertEqual(self.commands, [])
|
|
|
|
def test_network_reader_enforces_header_and_stream_bounds(self):
|
|
updater = module.Updater()
|
|
updater.load_installation()
|
|
for response in (Response(b"abc", {"Content-Length": "100"}), Response(b"abcdef")):
|
|
with self.subTest(response=response), patch.object(module.urllib.request, "build_opener") as opener:
|
|
opener.return_value.open.return_value = response
|
|
with self.assertRaises(module.IntegrityError):
|
|
updater.fetch("/api/agent/release/ec-agent.py", 5)
|
|
|
|
def test_trickling_or_truncated_download_defers_without_holding_release(self):
|
|
updater = module.Updater()
|
|
updater.load_installation()
|
|
with patch.object(module.urllib.request, "build_opener") as opener:
|
|
opener.return_value.open.return_value = Response(b"source")
|
|
with patch.object(module.time, "monotonic", side_effect=[0, 0, 121]):
|
|
with self.assertRaisesRegex(module.ContactError, "timed out"):
|
|
updater.fetch("/api/agent/release/ec-agent.py", 100)
|
|
with patch.object(module.urllib.request, "build_opener") as opener:
|
|
response = Response(b"source")
|
|
response.read1 = lambda *_: (_ for _ in ()).throw(module.http.client.IncompleteRead(b"partial"))
|
|
opener.return_value.open.return_value = response
|
|
with self.assertRaises(module.ContactError):
|
|
updater.fetch("/api/agent/release/ec-agent.py", 100)
|
|
|
|
def test_stale_code_staging_is_recovered_without_leaving_extra_executables(self):
|
|
temporary = module.AGENT_EXECUTABLE.with_name(module.AGENT_EXECUTABLE.name + ".update")
|
|
temporary.write_bytes(b"partial previous download")
|
|
self.assertEqual(self.updater().run(), "updated")
|
|
self.assertFalse(temporary.exists())
|
|
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
|
|
|
|
def test_download_uses_fixed_https_controller_and_no_enrollment_credentials(self):
|
|
updater = module.Updater()
|
|
updater.load_installation()
|
|
with patch.object(module.urllib.request, "build_opener") as opener:
|
|
opener.return_value.open.return_value = Response(b"source", {"Content-Length": "6"})
|
|
self.assertEqual(updater.fetch("/api/agent/release/ec-agent.py", 100), b"source")
|
|
request = opener.return_value.open.call_args.args[0]
|
|
self.assertEqual(request.full_url, "https://controller.example/api/agent/release/ec-agent.py")
|
|
self.assertIsNone(request.get_header("Authorization"))
|
|
redirect = opener.call_args.args[0]
|
|
with self.assertRaises(module.ContactError):
|
|
redirect.redirect_request(None, None, 302, "redirect", {}, "https://other.example")
|
|
|
|
def test_insecure_or_credential_embedded_controller_is_rejected(self):
|
|
for controller in ("http://controller.example", "https://token:secret@controller.example"):
|
|
with self.subTest(controller=controller):
|
|
module.AGENT_CONFIG.write_text(json.dumps({**self.config, "controller": controller}))
|
|
self.assertEqual(self.updater().run(), "failed")
|
|
self.assertEqual(self.downloads, [])
|
|
self.assertEqual(self.commands, [])
|
|
|
|
|
|
class UpdateSecurityTests(unittest.TestCase):
|
|
def test_untrusted_ownership_writable_paths_and_special_files_are_rejected(self):
|
|
for mode, uid in ((stat.S_IFREG | 0o666, 0), (stat.S_IFDIR | 0o755, 1000), (stat.S_IFLNK | 0o777, 0), (stat.S_IFIFO | 0o600, 0)):
|
|
with self.subTest(mode=mode, uid=uid), patch.object(Path, "lstat", return_value=os.stat_result((mode, 1, 1, 1, uid, 0, 0, 0, 0, 0))):
|
|
with self.assertRaises(module.UpdateError):
|
|
module.trusted("/etc/electric-cherry/update/journal.json")
|
|
|
|
def test_source_is_parsed_and_compiled_without_execution(self):
|
|
source = b'VERSION="1.2.3"\nraise RuntimeError("Never execute downloaded code during verification")\n'
|
|
self.assertEqual(module.source_version(source), "1.2.3")
|
|
|
|
def test_numeric_version_order_and_strict_format(self):
|
|
self.assertGreater(module.version("1.10.0"), module.version("1.9.9"))
|
|
for value in ("v1.0.0", "1.0", "1.2.03", "1.0.0+local", "1.0.0-rc1", True, None, "1.0.000000000000"):
|
|
with self.subTest(value=value), self.assertRaises(module.IntegrityError):
|
|
module.version(value)
|
|
|
|
def test_release_keyring_rejects_untrusted_ownership_permissions_and_file_types(self):
|
|
for mode, uid in ((stat.S_IFREG | 0o644, 1000), (stat.S_IFREG | 0o664, 0), (stat.S_IFREG | 0o646, 0), (stat.S_IFLNK | 0o777, 0), (stat.S_IFDIR | 0o755, 0)):
|
|
def file_info(path):
|
|
selected_mode, selected_uid = (mode, uid) if path == module.RELEASE_KEYRING else (stat.S_IFDIR | 0o755, 0)
|
|
return os.stat_result((selected_mode, 1, 1, 1, selected_uid, 0, 0, 0, 0, 0))
|
|
with self.subTest(mode=mode, uid=uid), patch.object(Path, "lstat", autospec=True, side_effect=file_info), patch.object(module.subprocess, "run") as run:
|
|
with self.assertRaisesRegex(module.IntegrityError, "keyring is missing or unsafe"):
|
|
module.verify_release_signature(b"manifest", b"signature")
|
|
run.assert_not_called()
|
|
|
|
|
|
class SignatureVerificationFixture:
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.base = Path(self.temporary.name)
|
|
self.keyring = self.base / "release-signing.gpg"
|
|
self.keyring.write_bytes(b"trusted host public keys")
|
|
self.update_dir = self.base / "update"
|
|
self.update_dir.mkdir(mode=0o700)
|
|
for name, value in (("RELEASE_KEYRING", self.keyring), ("UPDATE_DIR", self.update_dir)):
|
|
entry = patch.object(module, name, value)
|
|
entry.start()
|
|
self.addCleanup(entry.stop)
|
|
# Test fixtures are owned by the test runner rather than system root.
|
|
# Root ownership and unsafe modes are checked independently above.
|
|
entry = patch.object(module, "trusted", side_effect=lambda path, **_: Path(path))
|
|
self.trusted = entry.start()
|
|
self.addCleanup(entry.stop)
|
|
|
|
|
|
class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
|
|
def test_gpgv_uses_only_host_keyring_and_exact_bytes_with_an_isolated_home(self):
|
|
manifest = b'{"version": "1.2.3"}\n'
|
|
signature = b"binary detached signature\x00\xff"
|
|
def verify(command, **kwargs):
|
|
home = Path(command[2])
|
|
self.assertEqual(command[:2], ["gpgv", "--homedir"])
|
|
self.assertEqual(command[3:5], ["--keyring", str(self.keyring)])
|
|
self.assertEqual(home.parent, self.update_dir)
|
|
self.assertEqual(stat.S_IMODE(home.stat().st_mode), 0o700)
|
|
self.assertEqual(Path(command[5]).read_bytes(), signature)
|
|
self.assertEqual(Path(command[6]).read_bytes(), manifest)
|
|
self.assertEqual(kwargs["timeout"], 30)
|
|
self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)
|
|
return subprocess.CompletedProcess(command, 0)
|
|
with patch.object(module.subprocess, "run", side_effect=verify) as run:
|
|
module.verify_release_signature(manifest, signature)
|
|
run.assert_called_once()
|
|
self.assertEqual(self.trusted.call_args_list[0].args, (self.keyring,))
|
|
self.assertEqual(list(self.update_dir.iterdir()), [])
|
|
|
|
def test_missing_keyring_or_signature_never_runs_a_verifier(self):
|
|
with patch.object(module.subprocess, "run") as run:
|
|
self.trusted.side_effect = module.UpdateError("Missing keyring")
|
|
with self.assertRaisesRegex(module.IntegrityError, "keyring"):
|
|
module.verify_release_signature(b"manifest", b"signature")
|
|
self.trusted.side_effect = lambda path, **_: Path(path)
|
|
with self.assertRaisesRegex(module.IntegrityError, "detached manifest signature"):
|
|
module.verify_release_signature(b"manifest", b"")
|
|
run.assert_not_called()
|
|
|
|
def test_bad_unknown_or_unavailable_verifier_fails_closed_and_cleans_temporary_files(self):
|
|
for result in (subprocess.CompletedProcess([], 1), subprocess.CompletedProcess([], 2), FileNotFoundError("gpgv missing"), subprocess.TimeoutExpired("gpgv", 30)):
|
|
with self.subTest(result=result):
|
|
kwargs = {"side_effect": result} if isinstance(result, Exception) else {"return_value": result}
|
|
with patch.object(module.subprocess, "run", **kwargs):
|
|
with self.assertRaises(module.IntegrityError):
|
|
module.verify_release_signature(b"manifest", b"signature")
|
|
self.assertEqual(list(self.update_dir.iterdir()), [])
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("gpg") and shutil.which("gpgv"), "GnuPG signing and verification tools are required")
|
|
class RealSignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.keys = tempfile.TemporaryDirectory()
|
|
cls.addClassCleanup(cls.keys.cleanup)
|
|
cls.homes = []
|
|
for index in range(2):
|
|
home = Path(cls.keys.name) / str(index)
|
|
home.mkdir(mode=0o700)
|
|
cls.homes.append(home)
|
|
subprocess.run(["gpg", "--homedir", str(home), "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", f"Release Test {index} <release-{index}@example.test>", "ed25519", "sign", "0"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
if shutil.which("gpgconf"):
|
|
cls.addClassCleanup(subprocess.run, ["gpgconf", "--homedir", str(home), "--kill", "gpg-agent"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
|
cls.public_key = subprocess.run(["gpg", "--homedir", str(cls.homes[0]), "--batch", "--export"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
|
|
|
|
def sign(self, manifest, index=0):
|
|
return subprocess.run(["gpg", "--homedir", str(self.homes[index]), "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--digest-algo", "SHA512", "--detach-sign"], input=manifest, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True).stdout
|
|
|
|
def test_real_signatures_authenticate_exact_manifest_and_reject_unknown_keys(self):
|
|
self.keyring.write_bytes(self.public_key)
|
|
manifest = b'{"version":"1.1.0","sha256":"trusted-code-checksum"}\n'
|
|
signature = self.sign(manifest)
|
|
module.verify_release_signature(manifest, signature)
|
|
for changed_manifest, changed_signature in ((manifest + b" ", signature), (manifest.replace(b"trusted-code-checksum", b"attacker-code-checksum"), signature), (manifest, signature[:-1]), (manifest, self.sign(manifest, 1))):
|
|
with self.subTest(manifest=changed_manifest, signature=changed_signature):
|
|
with self.assertRaises(module.IntegrityError):
|
|
module.verify_release_signature(changed_manifest, changed_signature)
|
|
self.assertEqual(list(self.update_dir.iterdir()), [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|