Refresh agent signing keys from repository (Refs #64)

This commit is contained in:
2026-10-09 21:39:03 -04:00
parent 6f71822d41
commit a93ddb5ef9
14 changed files with 420 additions and 214 deletions
+52 -38
View File
@@ -27,7 +27,8 @@ import uuid
UPDATER_PROTOCOL_VERSION = 2 UPDATER_PROTOCOL_VERSION = 2
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
RELEASE_KEYRING = Path("/etc/electric-cherry/release-signing.gpg") # This repository is the trust authority, independent of controller configuration.
RELEASE_KEY_URL = "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg"
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py") AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py") UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json") UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
@@ -39,6 +40,7 @@ AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
AGENT_SERVICE = "electric-cherry-agent.service" AGENT_SERVICE = "electric-cherry-agent.service"
MAX_MANIFEST = 16 * 1024 MAX_MANIFEST = 16 * 1024
MAX_SIGNATURE = 16 * 1024 MAX_SIGNATURE = 16 * 1024
MAX_KEYRING = 256 * 1024
MAX_SOURCE = 8 * 1024 * 1024 MAX_SOURCE = 8 * 1024 * 1024
HEALTH_TIMEOUT = 90 HEALTH_TIMEOUT = 90
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$") VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
@@ -153,13 +155,51 @@ def source_version(source):
return declarations[0] return declarations[0]
def verify_release_signature(manifest, signature): def fetch_url(url, limit, source):
# Trust is provisioned by the host operator, never fetched from the request = urllib.request.Request(url, headers={"User-Agent": "electric-cherry-updater/2", "Cache-Control": "no-cache"})
# controller. Check every path component before exposing it to gpgv. class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
try: try:
keyring = trusted(RELEASE_KEYRING) with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
except (UpdateError, OSError): if response.status != 200:
raise IntegrityError("The host release-signing keyring is missing or unsafe; provision a trusted root-owned keyring") from None raise ContactError(f"The {source} did not return a complete download")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError(f"The {source} download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError(f"The {source} download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError(f"The {source} is unavailable; the running agent was left unchanged") from None
def fetch_release_key():
# Fetch afresh before every release check, including checks with no new
# version. There is deliberately no persistent keyring or offline fallback.
key = fetch_url(RELEASE_KEY_URL, MAX_KEYRING, "release-signing repository")
if not key:
raise IntegrityError("The release-signing repository returned an empty keyring")
return key
def verify_release_signature(manifest, signature, public_key):
if not public_key or len(public_key) > MAX_KEYRING:
raise IntegrityError("The repository release-signing keyring is empty or too large")
if not signature: if not signature:
raise IntegrityError("The controller release requires a detached manifest signature") raise IntegrityError("The controller release requires a detached manifest signature")
trusted(UPDATE_DIR, directory=True) trusted(UPDATE_DIR, directory=True)
@@ -167,6 +207,8 @@ def verify_release_signature(manifest, signature):
# The exact downloaded bytes are verified before manifest JSON is parsed. # The exact downloaded bytes are verified before manifest JSON is parsed.
with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary: with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary:
home = Path(temporary) home = Path(temporary)
keyring = home / "release-signing.gpg"
keyring.write_bytes(public_key)
manifest_path = home / "manifest.json" manifest_path = home / "manifest.json"
signature_path = home / "manifest.json.sig" signature_path = home / "manifest.json.sig"
manifest_path.write_bytes(manifest) manifest_path.write_bytes(manifest)
@@ -271,45 +313,17 @@ class Updater:
return state return state
def fetch(self, path, limit): def fetch(self, path, limit):
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/2"}) return fetch_url(self.installation["controller"] + path, limit, "controller release")
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
if response.status != 200:
raise ContactError("The controller did not return a complete agent release")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError("The controller release exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError("The controller release download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError("The controller release exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError("The controller release download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
def manifest(self): def manifest(self):
public_key = fetch_release_key()
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST) manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
try: try:
signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE) signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE)
except ContactError: except ContactError:
raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None
self.ensure_directory() self.ensure_directory()
verify_release_signature(manifest, signature) verify_release_signature(manifest, signature, public_key)
try: try:
value = json.loads(manifest) value = json.loads(manifest)
except (ValueError, UnicodeError): except (ValueError, UnicodeError):
+19 -8
View File
@@ -96,11 +96,6 @@ def trusted_json(path):
trusted_directories(root) trusted_directories(root)
trusted_directories(config.parent) trusted_directories(config.parent)
keyring = config.with_name('release-signing.gpg')
try:
trusted_file(keyring)
except FileNotFoundError:
raise SystemExit('Provision the publisher public key at /etc/electric-cherry/release-signing.gpg through a trusted channel before installing. Never obtain the trust key from the controller.') from None
mountinfo = pathlib.Path('/proc/1/mountinfo') mountinfo = pathlib.Path('/proc/1/mountinfo')
if mountinfo.exists(): if mountinfo.exists():
for line in mountinfo.read_text().splitlines(): for line in mountinfo.read_text().splitlines():
@@ -147,7 +142,7 @@ PY
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
if [ -d "$data_dir" ]; then existing_data_root=true; fi if [ -d "$data_dir" ]; then existing_data_root=true; fi
installation_preflight installation_preflight
temporary_dir=$(mktemp -d) temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
updater_timer_was_active=false updater_timer_was_active=false
update_lock_held=false update_lock_held=false
activity_lock_held=false activity_lock_held=false
@@ -172,23 +167,39 @@ installation_cleanup() {
exit "$ec_install_status" exit "$ec_install_status"
} }
trap installation_cleanup EXIT trap installation_cleanup EXIT
# INSTALLATION_TRUST: always fetch directly from the repository, never the controller.
key_status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
--connect-timeout 25 --max-time 120 --max-filesize 262144 \
--header 'Cache-Control: no-cache' --write-out '%{http_code}' \
'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg' \
-o "$temporary_dir/release-signing.gpg")
[ "$key_status" = 200 ] && [ -s "$temporary_dir/release-signing.gpg" ] || {
echo 'The release-signing repository did not return a public key. Installation stopped.' >&2
exit 1
}
# INSTALLATION_TRUST_END
controller="${controller%/}" controller="${controller%/}"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" /etc/electric-cherry/release-signing.gpg <<'PY' python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" "$temporary_dir/release-signing.gpg" <<'PY'
# INSTALLATION_RELEASE: check both downloads before installing or executing either. # INSTALLATION_RELEASE: check both downloads before installing or executing either.
import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
manifest_path = pathlib.Path(sys.argv[1]) manifest_path = pathlib.Path(sys.argv[1])
manifest_bytes = manifest_path.read_bytes() manifest_bytes = manifest_path.read_bytes()
signature = manifest_path.with_name('manifest.json.sig') signature = manifest_path.with_name('manifest.json.sig')
keyring = pathlib.Path(sys.argv[4]) keyring = pathlib.Path(sys.argv[4])
for path in (*reversed(keyring.parents), keyring): # The key lives in our private root-owned mktemp directory under sticky /tmp.
# /tmp itself is intentionally writable; require protection of the directory
# and file we created, rather than accepting a pre-existing host keyring.
for path in (keyring.parent, keyring):
info = path.lstat() info = path.lstat()
expected_type = stat.S_ISREG if path == keyring else stat.S_ISDIR expected_type = stat.S_ISREG if path == keyring else stat.S_ISDIR
if not expected_type(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: if not expected_type(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise SystemExit('Release signing key must be a regular root-owned file in protected directories.') raise SystemExit('Release signing key must be a regular root-owned file in protected directories.')
if not 0 < keyring.stat().st_size <= 256 * 1024:
raise SystemExit('Repository public key is empty or exceeds its size limit.')
if len(manifest_bytes) > 16 * 1024 or signature.stat().st_size > 16 * 1024: if len(manifest_bytes) > 16 * 1024 or signature.stat().st_size > 16 * 1024:
raise SystemExit('Release signing metadata exceeds its size limit.') raise SystemExit('Release signing metadata exceeds its size limit.')
try: try:
+49 -9
View File
@@ -82,15 +82,8 @@ class InstallerTests(unittest.TestCase):
self.preflight() self.preflight()
self.assertEqual((self.root / "unrelated.txt").read_text(), "keep") self.assertEqual((self.root / "unrelated.txt").read_text(), "keep")
def test_trust_key_must_be_preprovisioned_and_protected(self): def test_no_preprovisioned_signing_key_is_required(self):
self.keyring.unlink() self.keyring.unlink()
with self.assertRaisesRegex(SystemExit, 'Provision the publisher public key'):
self.preflight()
self.keyring.write_bytes(b'publisher public key')
for override in ({'uid': 1000}, {'mode': stat.S_IFREG | 0o666}, {'mode': stat.S_IFLNK | 0o777}):
with self.subTest(override=override):
self.overrides[str(self.keyring)] = override
with self.assertRaisesRegex(SystemExit, 'regular, root-owned'):
self.preflight() self.preflight()
def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self): def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self):
@@ -347,6 +340,46 @@ docker() {
self.assertNotIn("Docker is installed but unavailable", result.stderr) self.assertNotIn("Docker is installed but unavailable", result.stderr)
class RepositoryTrustTests(unittest.TestCase):
def fetch(self, status="200", body="public key", curl_exit=0):
block = SOURCE[SOURCE.index("# INSTALLATION_TRUST:"):SOURCE.index("# INSTALLATION_TRUST_END")]
with tempfile.TemporaryDirectory() as temporary:
script = r'''set -euo pipefail
curl() {
printf '%s\n' "$@" > "$temporary_dir/curl-args"
printf '%s' "$EC_TEST_BODY" > "$temporary_dir/release-signing.gpg"
printf '%s' "$EC_TEST_STATUS"
return "$EC_TEST_EXIT"
}
''' + block + "\nprintf 'verified-fetch'"
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
env={**os.environ, "temporary_dir": temporary, "EC_TEST_BODY": body,
"EC_TEST_STATUS": status, "EC_TEST_EXIT": str(curl_exit)})
args = (Path(temporary) / "curl-args").read_text().splitlines()
return result, args
def test_installer_fetches_fixed_repository_key_with_bounds_and_tls(self):
result, args = self.fetch()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "verified-fetch")
self.assertIn("https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg", args)
for option, value in (("--proto", "=https"), ("--max-filesize", "262144"),
("--max-time", "120"), ("--connect-timeout", "25"),
("--header", "Cache-Control: no-cache")):
self.assertEqual(args[args.index(option) + 1], value)
self.assertNotIn("--location", args)
self.assertNotIn("--insecure", args)
self.assertLess(SOURCE.index("# INSTALLATION_TRUST:"), SOURCE.index('$controller/api/agent/release/manifest.json'))
def test_repository_failure_redirect_or_empty_key_stops_installation(self):
for status, body, code in (("302", "redirect", 0), ("404", "missing", 22),
("200", "", 0), ("200", "partial", 28), ("200", "oversized", 63)):
with self.subTest(status=status, body=body):
result, _ = self.fetch(status, body, code)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("verified-fetch", result.stdout)
class ReleaseTests(unittest.TestCase): class ReleaseTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.temporary = tempfile.TemporaryDirectory() self.temporary = tempfile.TemporaryDirectory()
@@ -409,7 +442,14 @@ class ReleaseTests(unittest.TestCase):
with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"): with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"):
self.verify() self.verify()
def test_signature_is_verified_against_pinned_key_before_manifest_parsing(self): def test_empty_or_oversized_downloaded_key_cannot_authorize_installation(self):
for value in (b"", b"x" * (256 * 1024 + 1)):
self.keyring.write_bytes(value)
with self.subTest(size=len(value)), self.assertRaisesRegex(SystemExit, "public key is empty or exceeds"):
self.verify()
self.gpgv.assert_not_called()
def test_signature_is_verified_against_downloaded_key_before_manifest_parsing(self):
self.verify() self.verify()
args, kwargs = self.gpgv.call_args args, kwargs = self.gpgv.call_args
self.assertEqual(args[0][0], 'gpgv') self.assertEqual(args[0][0], 'gpgv')
+2 -3
View File
@@ -176,11 +176,10 @@ class OfflineSigningTests(ReleaseFixture, unittest.TestCase):
exec(compile(release, "installer-release", "exec"), {}) exec(compile(release, "installer-release", "exec"), {})
def verify_updater(self): def verify_updater(self):
with patch.object(UPDATER, "RELEASE_KEYRING", self.keyring), \ with patch.object(UPDATER, "UPDATE_DIR", self.update_dir), \
patch.object(UPDATER, "UPDATE_DIR", self.update_dir), \
patch.object(Path, "lstat", self.root_owned): patch.object(Path, "lstat", self.root_owned):
UPDATER.verify_release_signature((self.target / "manifest.json").read_bytes(), UPDATER.verify_release_signature((self.target / "manifest.json").read_bytes(),
(self.target / "manifest.json.sig").read_bytes()) (self.target / "manifest.json.sig").read_bytes(), self.public_key)
def verify(self): def verify(self):
return subprocess.run([ return subprocess.run([
+115 -27
View File
@@ -43,7 +43,6 @@ class UpdateTests(unittest.TestCase):
directory.mkdir(parents=True) directory.mkdir(parents=True)
paths = { paths = {
"AGENT_CONFIG": self.config_dir / "agent.json", "AGENT_CONFIG": self.config_dir / "agent.json",
"RELEASE_KEYRING": self.config_dir / "release-signing.gpg",
"AGENT_EXECUTABLE": self.bin_dir / "electric-cherry-agent.py", "AGENT_EXECUTABLE": self.bin_dir / "electric-cherry-agent.py",
"UNINSTALL_EXECUTABLE": self.bin_dir / "electric-cherry-uninstall.py", "UNINSTALL_EXECUTABLE": self.bin_dir / "electric-cherry-uninstall.py",
"UNINSTALL_REQUEST": self.config_dir / "decommission.json", "UNINSTALL_REQUEST": self.config_dir / "decommission.json",
@@ -65,6 +64,10 @@ class UpdateTests(unittest.TestCase):
if directory and not path.is_dir() or not directory and not path.is_file(): if directory and not path.is_dir() or not directory and not path.is_file():
raise module.UpdateError("Wrong file type") raise module.UpdateError("Wrong file type")
return path return path
self.public_key = b"repository public key"
key_patch = patch.object(module, "fetch_release_key", return_value=self.public_key)
self.fetch_key = key_patch.start()
self.addCleanup(key_patch.stop)
self.signature_patch = patch.object(module, "verify_release_signature") 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]) 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: for item in self.patches:
@@ -139,7 +142,7 @@ class UpdateTests(unittest.TestCase):
def test_verified_upgrade_waits_for_authenticated_candidate_health(self): def test_verified_upgrade_waits_for_authenticated_candidate_health(self):
self.assertEqual(self.updater().run(), "updated") self.assertEqual(self.updater().run(), "updated")
self.signature_verifier.assert_called_once_with(json.dumps(self.release).encode(), self.signature) self.signature_verifier.assert_called_once_with(json.dumps(self.release).encode(), self.signature, self.public_key)
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE) self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE) self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes) self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
@@ -168,7 +171,7 @@ class UpdateTests(unittest.TestCase):
with patch.object(module.json, "loads", side_effect=AssertionError("Parsed unsigned metadata")): with patch.object(module.json, "loads", side_effect=AssertionError("Parsed unsigned metadata")):
with self.assertRaisesRegex(module.IntegrityError, "Invalid release signature"): with self.assertRaisesRegex(module.IntegrityError, "Invalid release signature"):
updater.manifest() updater.manifest()
self.signature_verifier.assert_called_once_with(b"untrusted invalid JSON", self.signature) self.signature_verifier.assert_called_once_with(b"untrusted invalid JSON", self.signature, self.public_key)
def test_missing_signature_never_downloads_code_or_stops_agent(self): def test_missing_signature_never_downloads_code_or_stops_agent(self):
self.signature = module.ContactError("No signature is published") self.signature = module.ContactError("No signature is published")
@@ -188,6 +191,32 @@ class UpdateTests(unittest.TestCase):
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads)) self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
self.assertEqual(self.commands, []) self.assertEqual(self.commands, [])
def test_every_check_fetches_key_before_controller_even_without_a_new_version(self):
self.release["version"] = "1.0.0"
keys = [b"first public key", b"replacement public key"]
self.fetch_key.side_effect = keys
updater = self.updater()
def controller_fetch(path, limit):
self.assertGreater(self.fetch_key.call_count, self.signature_verifier.call_count)
return self.fetch(path, limit)
updater.fetch = controller_fetch
for _ in keys:
self.assertEqual(updater.run(), "current")
self.assertEqual(self.fetch_key.call_count, 2)
self.assertEqual([call.args[2] for call in self.signature_verifier.call_args_list], keys)
self.assert_unchanged()
self.assertEqual(self.commands, [])
def test_repository_failure_never_uses_old_host_key_or_contacts_controller(self):
(self.config_dir / "release-signing.gpg").write_bytes(b"old trusted key")
self.fetch_key.side_effect = module.ContactError("Repository unavailable")
self.assertEqual(self.updater().run(), "deferred")
self.assert_unchanged()
self.assertEqual(self.downloads, [])
self.assertEqual(self.commands, [])
self.assertTrue(self.active)
self.signature_verifier.assert_not_called()
def test_busy_operation_defers_update_without_stopping_agent(self): def test_busy_operation_defers_update_without_stopping_agent(self):
with module.exclusive_lock(self.root / "agent" / "activity.lock"): with module.exclusive_lock(self.root / "agent" / "activity.lock"):
self.assertEqual(self.updater().run(), "deferred") self.assertEqual(self.updater().run(), "deferred")
@@ -309,11 +338,14 @@ class UpdateTests(unittest.TestCase):
self.crash_during_second_replacement() self.crash_during_second_replacement()
self.downloads.clear() self.downloads.clear()
self.signature_verifier.reset_mock() self.signature_verifier.reset_mock()
self.fetch_key.reset_mock()
self.fetch_key.side_effect = module.ContactError("Repository offline during recovery")
self.signature_verifier.side_effect = module.IntegrityError("Signing keyring unavailable during recovery") self.signature_verifier.side_effect = module.IntegrityError("Signing keyring unavailable during recovery")
self.assertEqual(self.updater().run(), "failed") self.assertEqual(self.updater().run(), "failed")
self.assert_unchanged() self.assert_unchanged()
self.assertEqual(self.downloads, []) self.assertEqual(self.downloads, [])
self.signature_verifier.assert_not_called() self.signature_verifier.assert_not_called()
self.fetch_key.assert_not_called()
self.assertTrue(self.active) self.assertTrue(self.active)
self.assertFalse(module.UPDATE_JOURNAL.exists()) self.assertFalse(module.UPDATE_JOURNAL.exists())
@@ -458,15 +490,48 @@ class UpdateSecurityTests(unittest.TestCase):
with self.subTest(value=value), self.assertRaises(module.IntegrityError): with self.subTest(value=value), self.assertRaises(module.IntegrityError):
module.version(value) 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)): class RepositoryKeyDownloadTests(unittest.TestCase):
def file_info(path): def test_fixed_https_url_no_credentials_no_cache_and_no_redirects(self):
selected_mode, selected_uid = (mode, uid) if path == module.RELEASE_KEYRING else (stat.S_IFDIR | 0o755, 0) with patch.object(module.urllib.request, "build_opener") as opener:
return os.stat_result((selected_mode, 1, 1, 1, selected_uid, 0, 0, 0, 0, 0)) opener.return_value.open.return_value = Response(b"public keys")
with self.subTest(mode=mode, uid=uid), patch.object(Path, "lstat", autospec=True, side_effect=file_info), patch.object(module.subprocess, "run") as run: self.assertEqual(module.fetch_release_key(), b"public keys")
with self.assertRaisesRegex(module.IntegrityError, "keyring is missing or unsafe"): request = opener.return_value.open.call_args.args[0]
module.verify_release_signature(b"manifest", b"signature") self.assertEqual(request.full_url, "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg")
run.assert_not_called() self.assertIsNone(request.get_header("Authorization"))
self.assertIsNone(request.get_header("Cookie"))
self.assertEqual(request.get_header("Cache-control"), "no-cache")
self.assertEqual(opener.return_value.open.call_args.kwargs["timeout"], 25)
redirect = opener.call_args.args[0]
for destination in ("https://attacker.example/key.gpg", "http://gitea.thewrightserver.net/key.gpg"):
with self.subTest(destination=destination), self.assertRaises(module.ContactError):
redirect.redirect_request(None, None, 302, "redirect", {}, destination)
def test_missing_empty_oversized_and_truncated_keys_fail_closed(self):
cases = [Response(b""), Response(b"x", {"Content-Length": str(module.MAX_KEYRING + 1)}),
Response(b"x" * (module.MAX_KEYRING + 1)),
Response(b"partial", {"Content-Length": "100"}),
Response(b"key", {"Content-Length": "invalid"})]
for status in (301, 304, 404, 500):
response = Response(b"public key")
response.status = status
cases.append(response)
for response in cases:
with self.subTest(status=response.status, headers=response.headers), patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = response
with self.assertRaises(module.UpdateError):
module.fetch_release_key()
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.side_effect = OSError("TLS or network failure")
with self.assertRaisesRegex(module.ContactError, "release-signing repository is unavailable"):
module.fetch_release_key()
def test_trickling_repository_download_has_a_deadline(self):
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = Response(b"public key")
with patch.object(module.time, "monotonic", side_effect=[0, 0, 121]):
with self.assertRaisesRegex(module.ContactError, "timed out"):
module.fetch_release_key()
class SignatureVerificationFixture: class SignatureVerificationFixture:
@@ -478,8 +543,7 @@ class SignatureVerificationFixture:
self.keyring.write_bytes(b"trusted host public keys") self.keyring.write_bytes(b"trusted host public keys")
self.update_dir = self.base / "update" self.update_dir = self.base / "update"
self.update_dir.mkdir(mode=0o700) self.update_dir.mkdir(mode=0o700)
for name, value in (("RELEASE_KEYRING", self.keyring), ("UPDATE_DIR", self.update_dir)): entry = patch.object(module, "UPDATE_DIR", self.update_dir)
entry = patch.object(module, name, value)
entry.start() entry.start()
self.addCleanup(entry.stop) self.addCleanup(entry.stop)
# Test fixtures are owned by the test runner rather than system root. # Test fixtures are owned by the test runner rather than system root.
@@ -490,13 +554,14 @@ class SignatureVerificationFixture:
class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase): class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
def test_gpgv_uses_only_host_keyring_and_exact_bytes_with_an_isolated_home(self): def test_gpgv_uses_only_downloaded_keyring_and_exact_bytes_with_an_isolated_home(self):
manifest = b'{"version": "1.2.3"}\n' manifest = b'{"version": "1.2.3"}\n'
signature = b"binary detached signature\x00\xff" signature = b"binary detached signature\x00\xff"
def verify(command, **kwargs): def verify(command, **kwargs):
home = Path(command[2]) home = Path(command[2])
self.assertEqual(command[:2], ["gpgv", "--homedir"]) self.assertEqual(command[:2], ["gpgv", "--homedir"])
self.assertEqual(command[3:5], ["--keyring", str(self.keyring)]) self.assertEqual(command[3:5], ["--keyring", str(home / "release-signing.gpg")])
self.assertEqual(Path(command[4]).read_bytes(), self.keyring.read_bytes())
self.assertEqual(home.parent, self.update_dir) self.assertEqual(home.parent, self.update_dir)
self.assertEqual(stat.S_IMODE(home.stat().st_mode), 0o700) self.assertEqual(stat.S_IMODE(home.stat().st_mode), 0o700)
self.assertEqual(Path(command[5]).read_bytes(), signature) self.assertEqual(Path(command[5]).read_bytes(), signature)
@@ -505,19 +570,18 @@ class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase
self.assertEqual(kwargs["stdin"], subprocess.DEVNULL) self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)
return subprocess.CompletedProcess(command, 0) return subprocess.CompletedProcess(command, 0)
with patch.object(module.subprocess, "run", side_effect=verify) as run: with patch.object(module.subprocess, "run", side_effect=verify) as run:
module.verify_release_signature(manifest, signature) module.verify_release_signature(manifest, signature, self.keyring.read_bytes())
run.assert_called_once() run.assert_called_once()
self.assertEqual(self.trusted.call_args_list[0].args, (self.keyring,)) self.assertEqual(self.trusted.call_args_list[0].args, (self.update_dir,))
self.assertEqual(list(self.update_dir.iterdir()), []) self.assertEqual(list(self.update_dir.iterdir()), [])
def test_missing_keyring_or_signature_never_runs_a_verifier(self): def test_empty_or_oversized_keyring_or_missing_signature_never_runs_a_verifier(self):
with patch.object(module.subprocess, "run") as run: with patch.object(module.subprocess, "run") as run:
self.trusted.side_effect = module.UpdateError("Missing keyring") for key in (b"", b"x" * (module.MAX_KEYRING + 1)):
with self.assertRaisesRegex(module.IntegrityError, "keyring"): with self.subTest(size=len(key)), self.assertRaisesRegex(module.IntegrityError, "keyring"):
module.verify_release_signature(b"manifest", b"signature") module.verify_release_signature(b"manifest", b"signature", key)
self.trusted.side_effect = lambda path, **_: Path(path)
with self.assertRaisesRegex(module.IntegrityError, "detached manifest signature"): with self.assertRaisesRegex(module.IntegrityError, "detached manifest signature"):
module.verify_release_signature(b"manifest", b"") module.verify_release_signature(b"manifest", b"", self.keyring.read_bytes())
run.assert_not_called() run.assert_not_called()
def test_bad_unknown_or_unavailable_verifier_fails_closed_and_cleans_temporary_files(self): def test_bad_unknown_or_unavailable_verifier_fails_closed_and_cleans_temporary_files(self):
@@ -526,7 +590,7 @@ class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase
kwargs = {"side_effect": result} if isinstance(result, Exception) else {"return_value": result} kwargs = {"side_effect": result} if isinstance(result, Exception) else {"return_value": result}
with patch.object(module.subprocess, "run", **kwargs): with patch.object(module.subprocess, "run", **kwargs):
with self.assertRaises(module.IntegrityError): with self.assertRaises(module.IntegrityError):
module.verify_release_signature(b"manifest", b"signature") module.verify_release_signature(b"manifest", b"signature", self.keyring.read_bytes())
self.assertEqual(list(self.update_dir.iterdir()), []) self.assertEqual(list(self.update_dir.iterdir()), [])
@@ -549,15 +613,39 @@ class RealSignatureVerificationTests(SignatureVerificationFixture, unittest.Test
def sign(self, manifest, index=0): 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 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_repository_key_rotation_removes_old_signer_on_the_next_check(self):
replacement = subprocess.run(["gpg", "--homedir", str(self.homes[1]), "--batch", "--export"],
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
manifest = json.dumps({"version": "1.1.0", "sha256": module.sha256(CANDIDATE),
"protocolVersion": 1, "updaterProtocolVersion": 2,
"platform": "debian-amd64",
"keyUrl": "https://controller.example/attacker-key"}).encode()
old_signature, new_signature = self.sign(manifest), self.sign(manifest, 1)
updater = module.Updater()
updater.installation = {"controller": "https://controller.example"}
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.side_effect = [Response(value) for value in (
self.public_key, manifest, old_signature,
replacement, manifest, old_signature,
replacement, manifest, new_signature)]
self.assertEqual(updater.manifest()["version"], "1.1.0")
with self.assertRaisesRegex(module.IntegrityError, "not signed by a trusted release key"):
updater.manifest()
self.assertEqual(updater.manifest()["version"], "1.1.0")
urls = [call.args[0].full_url for call in opener.return_value.open.call_args_list]
self.assertEqual(urls[::3], [module.RELEASE_KEY_URL] * 3)
self.assertNotIn("https://controller.example/attacker-key", urls)
self.assertEqual(list(self.update_dir.iterdir()), [])
def test_real_signatures_authenticate_exact_manifest_and_reject_unknown_keys(self): def test_real_signatures_authenticate_exact_manifest_and_reject_unknown_keys(self):
self.keyring.write_bytes(self.public_key) self.keyring.write_bytes(self.public_key)
manifest = b'{"version":"1.1.0","sha256":"trusted-code-checksum"}\n' manifest = b'{"version":"1.1.0","sha256":"trusted-code-checksum"}\n'
signature = self.sign(manifest) signature = self.sign(manifest)
module.verify_release_signature(manifest, signature) module.verify_release_signature(manifest, signature, self.keyring.read_bytes())
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))): 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.subTest(manifest=changed_manifest, signature=changed_signature):
with self.assertRaises(module.IntegrityError): with self.assertRaises(module.IntegrityError):
module.verify_release_signature(changed_manifest, changed_signature) module.verify_release_signature(changed_manifest, changed_signature, self.keyring.read_bytes())
self.assertEqual(list(self.update_dir.iterdir()), []) self.assertEqual(list(self.update_dir.iterdir()), [])
+8 -9
View File
@@ -9,8 +9,8 @@ HTTPS. Running game containers continue through controller or agent outages.
- A controller machine with Docker Compose and an HTTPS reverse proxy. - A controller machine with Docker Compose and an HTTPS reverse proxy.
- Debian 12 or 13 amd64 game hosts with storage for game files and instances. - Debian 12 or 13 amd64 game hosts with storage for game files and instances.
- A signed agent release, plus its publisher's public key and reviewed installer - A signed agent release and its public key published in the trusted repository.
obtained through a trusted channel independent of the controller. - Outbound HTTPS access from game hosts to the controller and public repository.
- Your complete T5 or T6 game folders, including the DLC you intend to host. - Your complete T5 or T6 game folders, including the DLC you intend to host.
- Plutonium server keys for each game, with one unused key per new concurrent lobby. - Plutonium server keys for each game, with one unused key per new concurrent lobby.
@@ -69,14 +69,13 @@ password recovery.
1. Open **Releases → Game files** and upload the complete local folder for T5 or 1. Open **Releases → Game files** and upload the complete local folder for T5 or
T6, including DLC. Uploads can run before any host is enrolled. The latest T6, including DLC. Uploads can run before any host is enrolled. The latest
verified upload becomes that game's default files. verified upload becomes that game's default files.
2. [Provision release trust](native-hosts.md#provision-release-trust): independently 2. Ensure the release publisher has [published its public key and signed release](native-hosts.md#publishing-signed-releases).
verify the publisher key's full fingerprint, install its binary public key at Hosts fetch the key automatically during installation and before every update
`/etc/electric-cherry/release-signing.gpg`, and copy the reviewed installer to check; there is no manual key-copy step.
`./electric-cherry-install.sh` on the host. Both come from the trusted publisher.
3. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and 3. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and
bind address, and run the generated enrollment command from the directory bind address, and run the generated enrollment command. It downloads the
containing that local installer. Do not pipe controller-downloaded code into installer from the fixed public repository URL before running it. Check that
a root shell. the installer source is the known repository, independent of the controller.
4. Wait for automatic setup to prepare the Plutainer runtime and available game 4. Wait for automatic setup to prepare the Plutainer runtime and available game
files. The host page reports download, verification, and readiness progress. files. The host page reports download, verification, and readiness progress.
+88 -58
View File
@@ -12,9 +12,9 @@ the agent owns local execution and its durable operation journal.
and roles; agent endpoints authenticate their own host credentials. See and roles; agent endpoints authenticate their own host credentials. See
[Users and access](authentication.md) for setup and password recovery. [Users and access](authentication.md) for setup and password recovery.
2. Open **Hosts → Add Host**, choose a name, local game-port range and bind address, 2. Open **Hosts → Add Host**, choose a name, local game-port range and bind address,
and generate an enrollment. First [provision release trust](#provision-release-trust) and generate an enrollment. Run the displayed command on the Debian 12/13
and copy the reviewed installer to `./electric-cherry-install.sh` on the Debian amd64 host. It downloads the installer directly from the public repository;
12/13 amd64 host. Then run the displayed command from that directory. [release trust](#provision-release-trust) is fetched automatically.
3. The installer validates the platform, installs Python 3, `gpgv` and an absent Docker 3. The installer validates the platform, installs Python 3, `gpgv` and an absent Docker
installation, verifies the release signature and agent/updater SHA-256 checksums, installation, verifies the release signature and agent/updater SHA-256 checksums,
enrolls it and enables `electric-cherry-agent.service` and its automatic update enrolls it and enables `electric-cherry-agent.service` and its automatic update
@@ -45,34 +45,44 @@ copying or per-container setup. EC does not supply the game content itself.
### Provision release trust ### Provision release trust
Obtain the release publisher's public key and reviewed installer through a The public Electric Cherry repository is the authority for both the installer
trusted channel independent of the controller. Verify the full public-key and release-signing public keys. The enrollment command downloads `agent/install.sh`
fingerprint with the publisher through that independent channel. On a trusted from that repository over HTTPS, saves it locally, and runs it only after the
administrator workstation with GnuPG, inspect the supplied binary public key: download succeeds. No manual key transfer is required.
```sh During installation and **before every update check**, the host downloads the
gpg --show-keys --with-fingerprint ./release-signing.gpg binary OpenPGP public keyring directly from this fixed URL:
```text
https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg
``` ```
Transfer that verified public key and installer to the host, with the installer The URL is built into the trusted installer and updater. Controller configuration,
named `electric-cherry-install.sh`. Provision the key before running enrollment: release metadata and enrollment credentials cannot change it. The host verifies
HTTPS certificates, rejects redirects and incomplete, empty or oversized key
responses, and requests revalidation with `Cache-Control: no-cache`. It uses the
fresh keyring only for that check and deletes its temporary copy afterward.
There is no fallback to a previous host keyring, cached download or controller key.
If the repository is unavailable, new installations stop and automatic updates
wait for the next check; the installed agent and game containers keep running.
Local recovery of an interrupted update does not require the repository.
```sh This deliberately trusts the repository account, its `main` branch and HTTPS
sudo install -d -o root -g root -m 0700 /etc/electric-cherry hosting as authorities for root code on game hosts. They must be outside the
sudo install -o root -g root -m 0644 ./release-signing.gpg /etc/electric-cherry/release-signing.gpg controller's write access: a compromised controller must not also have a token,
``` mount or server access that can change these repository files. A repository
compromise can replace the key or installer. Before initial enrollment, check that
the command downloads from
`https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/install.sh`,
since a compromised controller UI could display a different command. Host provisioning systems can use that known URL directly.
The keyring and every parent directory must be owned by root, must not be Before deploying the first signed release, the publisher must export and commit
symlinks, and must not be writable by other users. Install only the publisher its **public** key as `agent/release-signing.gpg` on `main`, following the
keys you intend to trust. The generated enrollment command runs the local [signing workflow below](#publishing-signed-releases). No production key is
installer with `sudo bash ./electric-cherry-install.sh`; never replace this with created by packaging. Until the public key and signed release are published,
a controller download piped into a root shell. An installer supplied by a installation and updates fail closed. Forks must change the fixed repository URLs
compromised controller could bypass its own verification. The host never obtains in the installer, updater and enrollment command to their own trusted repository;
or replaces its trust key through controller APIs. there is intentionally no controller setting to override them.
If you build your own controller releases, act as the publisher using the
[signing workflow below](#publishing-signed-releases). Hosts reject unsigned
releases, including ordinary development builds that have not been signed.
### Docker installation ### Docker installation
@@ -200,8 +210,9 @@ directory, while file writes reject symlinks and paths outside that directory.
## Automatic agent updates ## Automatic agent updates
New installations enable automatic agent updates by default. Each host checks New installations enable automatic agent updates by default. Each host checks
its controller's release over HTTPS about every five minutes, beginning about the repository public key and then its controller's release over HTTPS about
two minutes after boot, with up to 30 seconds of jitter. Updating the controller every five minutes, beginning about two minutes after boot, with up to 30 seconds
of jitter. Updating the controller
therefore makes its compatible, newer signed agent release available to enrolled hosts. therefore makes its compatible, newer signed agent release available to enrolled hosts.
There is no controller command to schedule and no update button to press. There is no controller command to schedule and no update button to press.
@@ -211,27 +222,30 @@ controller-forged updates until this migration is complete. Publishing a signed
release does not upgrade those bootstraps: they request an installer rerun when release does not upgrade those bootstraps: they request an installer rerun when
they see protocol 2, but a compromised controller could still serve protocol 1. they see protocol 2, but a compromised controller could still serve protocol 1.
First provision release trust as described above. From the host's **Agent From the host's **Agent access**, generate and run the command, which fetches
access**, generate a command and run it using the reviewed local installer, with the repository installer and public key automatically. Use the same controller
the same controller and data directory. Preserve a custom root with `--data-dir`; and data directory. Preserve a custom root with `--data-dir`;
do not use `--reenroll` for this upgrade. To pause old automatic checks during do not use `--reenroll` for this upgrade. To pause old automatic checks during
migration, run `sudo systemctl stop electric-cherry-update.timer`. This does not migration, run `sudo systemctl stop electric-cherry-update.timer`. This does not
interrupt a running update worker; let any active update or journal recovery interrupt a running update worker; let any active update or journal recovery
finish before installing. The installer preserves host identity, credentials, finish before installing. The installer preserves host identity, credentials,
game files and running containers, replaces the bootstrap, and enables its timer game files and running containers, replaces the bootstrap, and enables its timer
on success. Hosts without an updater show **Setup required** until a heartbeat on success. Hosts without an updater show **Setup required** until a heartbeat
confirms installation. confirms installation. Hosts using an earlier protocol 2 bootstrap with a
manually provisioned keyring also need this installer rerun to adopt repository
refresh: the updater does not replace itself automatically. An old local
`/etc/electric-cherry/release-signing.gpg` file is ignored by the new bootstrap.
The **Automatic agent updates** panel reports the running version, target The **Automatic agent updates** panel reports the running version, target
version, last check, progress and any error. Offline hosts show their last version, last check, progress and any error. Offline hosts show their last
reported status. Deferred updates report why they are waiting: for example an reported status. Deferred updates report why they are waiting: for example an
active agent operation, an unavailable controller or a newer updater bootstrap. active agent operation, an unavailable repository/controller or a newer updater bootstrap.
Checks also defer while the agent service is stopped; an update does not start a Checks also defer while the agent service is stopped; an update does not start a
service that was stopped outside the update workflow. service that was stopped outside the update workflow.
The independent updater holds the same execution lock, authenticates the exact The independent updater holds the same execution lock, authenticates the exact
manifest bytes with the pinned keyring before parsing them, verifies the release manifest bytes with the freshly fetched repository keyring before parsing them,
checksum, version and supported protocol, replaces agent code, and restarts the verifies the release checksum, version and supported protocol, replaces agent
agent service. It never restarts Docker or game containers. It confirms an code, and restarts the agent service. It never restarts Docker or game containers. It confirms an
authenticated heartbeat from the new agent process before accepting the update; authenticated heartbeat from the new agent process before accepting the update;
failure restores the previous agent code. Its durable journal permits recovery failure restores the previous agent code. Its durable journal permits recovery
after a host reboot or interrupted update. after a host reboot or interrupted update.
@@ -266,13 +280,21 @@ gpg --homedir "$ec_signing_home" --list-keys --with-fingerprint
Protect the key with a passphrase and keep its backup and revocation certificate Protect the key with a passphrase and keep its backup and revocation certificate
outside release artifacts. Set the full fingerprint of the signing key, then outside release artifacts. Set the full fingerprint of the signing key, then
export only its public key for independent host provisioning: export only its public key into the reviewed checkout:
```sh ```sh
ec_signing_fingerprint=REPLACE_WITH_FULL_SIGNING_KEY_FINGERPRINT ec_signing_fingerprint=REPLACE_WITH_FULL_SIGNING_KEY_FINGERPRINT
gpg --homedir "$ec_signing_home" --export "$ec_signing_fingerprint" > release-signing.gpg gpg --homedir "$ec_signing_home" --export "$ec_signing_fingerprint" > agent/release-signing.gpg
``` ```
Inspect the export with `gpg --show-keys --with-fingerprint agent/release-signing.gpg`
and confirm the expected publisher fingerprint. Commit and publish that public
file to the repository's `main` branch before enrolling hosts or releasing code
signed by this key. Never export secret keys into the repository. A YubiKey-held
signing key works with the same workflow: export its public certificate from your
GnuPG home and use the full signing key/subkey fingerprint with the signer. Keep
the YubiKey on the publisher workstation, outside controller and CI access.
From the reviewed checkout on the publisher machine, package and explicitly sign: From the reviewed checkout on the publisher machine, package and explicitly sign:
```sh ```sh
@@ -299,18 +321,22 @@ run the controller, but hosts fail closed and leave their installed code intact.
### Signing-key rotation and removal ### Signing-key rotation and removal
Verify new fingerprints independently and replace each host's root-owned keyring Review new fingerprints on the publisher workstation, then replace
through trusted administration before publishing releases signed by a new key. `agent/release-signing.gpg` on the repository's `main` branch. Every host uses the
For a planned transition, provision both approved public keys temporarily, then new keyring on its next update check; no host visits or controller changes are
remove the retired key once every host can verify the new signer. The controller needed. For a planned transition, export both approved public keys into this file,
cannot authorize key rotation. publish the combined keyring, then publish releases signed by the new key. Remove
the retired key once the controller serves the new signatures. Checks already in
progress use the keyring they fetched at the start of that check.
Remove compromised or retired keys from every host's keyring. Publishing a For a compromised key, remove it from the repository keyring immediately and
revocation certificate or allowing a key to expire is insufficient: [GnuPG's publish releases signed by a replacement key. Hosts with the refreshed keyring
reject the old signer even if the controller still serves its release. Publishing
a revocation certificate or allowing a key to expire is insufficient: [GnuPG's
`gpgv` documentation](https://www.gnupg.org/documentation/manuals/gnupg/gpgv.html) `gpgv` documentation](https://www.gnupg.org/documentation/manuals/gnupg/gpgv.html)
states that it does not check key expiration or revocation. Host deletion removes states that it does not check key expiration or revocation. Keep the keyring's
the pinned keyring with `/etc/electric-cherry`; provision trust again before contents restricted to currently trusted keys. Removing all keys blocks new
reenrolling that machine. installations and updates; it does not stop or remove already installed agents.
### Inspecting updates ### Inspecting updates
@@ -478,9 +504,9 @@ and polling; Docker has no exposed remote API. Agent endpoints reject HTTP unles
`EC_AGENT_ALLOW_HTTP=true` is explicitly enabled for development. The agent's `EC_AGENT_ALLOW_HTTP=true` is explicitly enabled for development. The agent's
insecure development option is limited to loopback. Public release assets contain insecure development option is limited to loopback. Public release assets contain
no credentials. HTTPS protects transport; an offline publisher signature over the no credentials. HTTPS protects transport; an offline publisher signature over the
release manifest authenticates agent and updater code using the independently release manifest authenticates agent and updater code using the public keyring
provisioned host keyring. Controller credentials do not grant release-signing fetched directly from the trusted repository before each check. Controller
authority. credentials do not grant release-signing authority or control the key source.
Browser sessions do not authorize agents. Similarly, host credentials never grant Browser sessions do not authorize agents. Similarly, host credentials never grant
access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`, access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`,
@@ -687,11 +713,11 @@ and invalidates unused installer links. A separate cleanup service removes the
host's downloaded game libraries, instance files, agent journal and configuration, host's downloaded game libraries, instance files, agent journal and configuration,
owned containers, and recorded runtime images unused by other containers. It owned containers, and recorded runtime images unused by other containers. It
stops and removes the agent service and executable, automatic update timer, stops and removes the agent service and executable, automatic update timer,
update service, updater executable, update state and pinned signing keyring, then update service, updater executable and update state (including any legacy local
removes its own files. signing keyring), then removes its own files.
The controller hides the host and revokes its ordinary credentials only after The controller hides the host and revokes its ordinary credentials only after
receiving the final uninstall receipt. Adding the machine again requires a new receiving the final uninstall receipt. Adding the machine again requires a new
host enrollment and independently reprovisioned release trust. host enrollment; the installer fetches release trust from the repository again.
Game folders uploaded through Electric Cherry belong to the controller. Those Game folders uploaded through Electric Cherry belong to the controller. Those
original uploads remain available for other hosts and future deployments; only original uploads remain available for other hosts and future deployments; only
@@ -956,7 +982,8 @@ also requires the host's `gpgv` executable. Real signing tests require `gpg` and
Automated tests cover enrollment/authentication, rotation/revocation, host Automated tests cover enrollment/authentication, rotation/revocation, host
readiness, scoped ports, immutable library publication, streaming/ranges, readiness, scoped ports, immutable library publication, streaming/ranges,
signed manifests, forged checksums, unknown signing keys, interrupted downloads, signed manifests, forged checksums, unknown signing keys, repository key refresh
and rotation, repository failures, interrupted downloads,
instance generation ownership, local occupancy instance generation ownership, local occupancy
holds, controller restart/reconnect, ambiguous results, occupied campaign staging, holds, controller restart/reconnect, ambiguous results, occupied campaign staging,
activation before images, collections and draining. Host deletion tests cover activation before images, collections and draining. Host deletion tests cover
@@ -968,15 +995,18 @@ A real Debian/Docker/Plutonium acceptance run is still required before productio
rollout. Automated simulations do not establish successful deployment on real rollout. Automated simulations do not establish successful deployment on real
game hosts. Run: game hosts. Run:
- Independently provision release trust and run the reviewed installer on Debian - Publish the public key in the repository and run the generated enrollment
12 and 13 amd64, then rerun without changing identity/data. command on Debian 12 and 13 amd64 without a local keyring, then rerun without
changing identity/data.
- Migrate a protocol 1 host with the trusted installer, then publish a newer - Migrate a protocol 1 host with the trusted installer, then publish a newer
compatible signed agent release. Verify a timer-driven update, its authenticated compatible signed agent release. Verify a timer-driven update, its authenticated
heartbeat and unchanged game containers. Exercise an active-operation hold, heartbeat and unchanged game containers. Exercise an active-operation hold,
missing/invalid signatures, an unknown key, a forged manifest matching changed missing/invalid signatures, an unknown key, a forged manifest matching changed
code, bad checksum, failed startup/heartbeat rollback, controller downgrade, code, bad checksum, failed startup/heartbeat rollback, controller downgrade,
network loss and host reboot during update. Verify updater protocol mismatch network loss and host reboot during update. Verify updater protocol mismatch
requests an installer rerun and test trusted keyring replacement for rotation. requests an installer rerun. Replace the repository keyring and confirm the
next check rejects the removed signer and accepts the new signer; block access
to the repository and confirm no cached key is used and local rollback still works.
- T5 and T6 deployments and a mixed collection from a fresh destination host. - T5 and T6 deployments and a mixed collection from a fresh destination host.
- Warm the Plutonium runtime cache, then create and rebuild T5 and T6 lobbies; - Warm the Plutonium runtime cache, then create and rebuild T5 and T6 lobbies;
verify the running revision and compare first-start downloads and launch times verify the running revision and compare first-start downloads and launch times
+3 -1
View File
@@ -70,5 +70,7 @@ export function hostApiError(cause: unknown): Response {
export function installerCommand(controller: string, token: string): string { export function installerCommand(controller: string, token: string): string {
const origin = new URL(controller).origin; const origin = new URL(controller).origin;
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
return `sudo bash ./electric-cherry-install.sh --controller ${quote(origin)} --token ${quote(token)}`; const installer =
'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/install.sh';
return `curl --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 25 --max-time 120 ${quote(installer)} -o electric-cherry-install.sh && sudo bash ./electric-cherry-install.sh --controller ${quote(origin)} --token ${quote(token)}`;
} }
+3 -4
View File
@@ -53,10 +53,9 @@
{name} created. Run this command with administrator access on that machine. {name} created. Run this command with administrator access on that machine.
</p> </p>
<p> <p>
First copy the reviewed installer from your release publisher to This command downloads the installer from the public Electric Cherry repository. The
<code>electric-cherry-install.sh</code> and provision the publisher's public signing host automatically fetches the publisher's public signing key from that repository
key at <code>/etc/electric-cherry/release-signing.gpg</code>. Obtain both through a during installation and before every update check.
trusted channel independent of this controller.
</p> </p>
<textarea <textarea
readonly readonly
+3 -4
View File
@@ -609,10 +609,9 @@
</div> </div>
{#if enrollment}<p>Expires {$timeFormat.dateTime(enrollment.expiresAt)}</p> {#if enrollment}<p>Expires {$timeFormat.dateTime(enrollment.expiresAt)}</p>
<p> <p>
Use a reviewed <code>electric-cherry-install.sh</code> from your This command downloads the installer from the public Electric Cherry
release publisher and provision its public signing key at repository. The host fetches its public signing key from that
<code>/etc/electric-cherry/release-signing.gpg</code>. Obtain both repository during installation and before every update check.
through a trusted channel independent of this controller.
</p> </p>
<pre>{enrollment.command}</pre>{/if}{:else}<p> <pre>{enrollment.command}</pre>{/if}{:else}<p>
Administrators manage enrollment and agent credentials. Administrators manage enrollment and agent credentials.
+52 -38
View File
@@ -27,7 +27,8 @@ import uuid
UPDATER_PROTOCOL_VERSION = 2 UPDATER_PROTOCOL_VERSION = 2
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
RELEASE_KEYRING = Path("/etc/electric-cherry/release-signing.gpg") # This repository is the trust authority, independent of controller configuration.
RELEASE_KEY_URL = "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg"
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py") AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py") UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json") UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
@@ -39,6 +40,7 @@ AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
AGENT_SERVICE = "electric-cherry-agent.service" AGENT_SERVICE = "electric-cherry-agent.service"
MAX_MANIFEST = 16 * 1024 MAX_MANIFEST = 16 * 1024
MAX_SIGNATURE = 16 * 1024 MAX_SIGNATURE = 16 * 1024
MAX_KEYRING = 256 * 1024
MAX_SOURCE = 8 * 1024 * 1024 MAX_SOURCE = 8 * 1024 * 1024
HEALTH_TIMEOUT = 90 HEALTH_TIMEOUT = 90
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$") VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
@@ -153,13 +155,51 @@ def source_version(source):
return declarations[0] return declarations[0]
def verify_release_signature(manifest, signature): def fetch_url(url, limit, source):
# Trust is provisioned by the host operator, never fetched from the request = urllib.request.Request(url, headers={"User-Agent": "electric-cherry-updater/2", "Cache-Control": "no-cache"})
# controller. Check every path component before exposing it to gpgv. class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
try: try:
keyring = trusted(RELEASE_KEYRING) with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
except (UpdateError, OSError): if response.status != 200:
raise IntegrityError("The host release-signing keyring is missing or unsafe; provision a trusted root-owned keyring") from None raise ContactError(f"The {source} did not return a complete download")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError(f"The {source} download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError(f"The {source} download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError(f"The {source} is unavailable; the running agent was left unchanged") from None
def fetch_release_key():
# Fetch afresh before every release check, including checks with no new
# version. There is deliberately no persistent keyring or offline fallback.
key = fetch_url(RELEASE_KEY_URL, MAX_KEYRING, "release-signing repository")
if not key:
raise IntegrityError("The release-signing repository returned an empty keyring")
return key
def verify_release_signature(manifest, signature, public_key):
if not public_key or len(public_key) > MAX_KEYRING:
raise IntegrityError("The repository release-signing keyring is empty or too large")
if not signature: if not signature:
raise IntegrityError("The controller release requires a detached manifest signature") raise IntegrityError("The controller release requires a detached manifest signature")
trusted(UPDATE_DIR, directory=True) trusted(UPDATE_DIR, directory=True)
@@ -167,6 +207,8 @@ def verify_release_signature(manifest, signature):
# The exact downloaded bytes are verified before manifest JSON is parsed. # The exact downloaded bytes are verified before manifest JSON is parsed.
with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary: with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary:
home = Path(temporary) home = Path(temporary)
keyring = home / "release-signing.gpg"
keyring.write_bytes(public_key)
manifest_path = home / "manifest.json" manifest_path = home / "manifest.json"
signature_path = home / "manifest.json.sig" signature_path = home / "manifest.json.sig"
manifest_path.write_bytes(manifest) manifest_path.write_bytes(manifest)
@@ -271,45 +313,17 @@ class Updater:
return state return state
def fetch(self, path, limit): def fetch(self, path, limit):
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/2"}) return fetch_url(self.installation["controller"] + path, limit, "controller release")
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
if response.status != 200:
raise ContactError("The controller did not return a complete agent release")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError("The controller release exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError("The controller release download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError("The controller release exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError("The controller release download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
def manifest(self): def manifest(self):
public_key = fetch_release_key()
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST) manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
try: try:
signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE) signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE)
except ContactError: except ContactError:
raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None
self.ensure_directory() self.ensure_directory()
verify_release_signature(manifest, signature) verify_release_signature(manifest, signature, public_key)
try: try:
value = json.loads(manifest) value = json.loads(manifest)
except (ValueError, UnicodeError): except (ValueError, UnicodeError):
+19 -8
View File
@@ -96,11 +96,6 @@ def trusted_json(path):
trusted_directories(root) trusted_directories(root)
trusted_directories(config.parent) trusted_directories(config.parent)
keyring = config.with_name('release-signing.gpg')
try:
trusted_file(keyring)
except FileNotFoundError:
raise SystemExit('Provision the publisher public key at /etc/electric-cherry/release-signing.gpg through a trusted channel before installing. Never obtain the trust key from the controller.') from None
mountinfo = pathlib.Path('/proc/1/mountinfo') mountinfo = pathlib.Path('/proc/1/mountinfo')
if mountinfo.exists(): if mountinfo.exists():
for line in mountinfo.read_text().splitlines(): for line in mountinfo.read_text().splitlines():
@@ -147,7 +142,7 @@ PY
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
if [ -d "$data_dir" ]; then existing_data_root=true; fi if [ -d "$data_dir" ]; then existing_data_root=true; fi
installation_preflight installation_preflight
temporary_dir=$(mktemp -d) temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
updater_timer_was_active=false updater_timer_was_active=false
update_lock_held=false update_lock_held=false
activity_lock_held=false activity_lock_held=false
@@ -172,23 +167,39 @@ installation_cleanup() {
exit "$ec_install_status" exit "$ec_install_status"
} }
trap installation_cleanup EXIT trap installation_cleanup EXIT
# INSTALLATION_TRUST: always fetch directly from the repository, never the controller.
key_status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
--connect-timeout 25 --max-time 120 --max-filesize 262144 \
--header 'Cache-Control: no-cache' --write-out '%{http_code}' \
'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg' \
-o "$temporary_dir/release-signing.gpg")
[ "$key_status" = 200 ] && [ -s "$temporary_dir/release-signing.gpg" ] || {
echo 'The release-signing repository did not return a public key. Installation stopped.' >&2
exit 1
}
# INSTALLATION_TRUST_END
controller="${controller%/}" controller="${controller%/}"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" /etc/electric-cherry/release-signing.gpg <<'PY' python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" "$temporary_dir/release-signing.gpg" <<'PY'
# INSTALLATION_RELEASE: check both downloads before installing or executing either. # INSTALLATION_RELEASE: check both downloads before installing or executing either.
import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
manifest_path = pathlib.Path(sys.argv[1]) manifest_path = pathlib.Path(sys.argv[1])
manifest_bytes = manifest_path.read_bytes() manifest_bytes = manifest_path.read_bytes()
signature = manifest_path.with_name('manifest.json.sig') signature = manifest_path.with_name('manifest.json.sig')
keyring = pathlib.Path(sys.argv[4]) keyring = pathlib.Path(sys.argv[4])
for path in (*reversed(keyring.parents), keyring): # The key lives in our private root-owned mktemp directory under sticky /tmp.
# /tmp itself is intentionally writable; require protection of the directory
# and file we created, rather than accepting a pre-existing host keyring.
for path in (keyring.parent, keyring):
info = path.lstat() info = path.lstat()
expected_type = stat.S_ISREG if path == keyring else stat.S_ISDIR expected_type = stat.S_ISREG if path == keyring else stat.S_ISDIR
if not expected_type(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: if not expected_type(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise SystemExit('Release signing key must be a regular root-owned file in protected directories.') raise SystemExit('Release signing key must be a regular root-owned file in protected directories.')
if not 0 < keyring.stat().st_size <= 256 * 1024:
raise SystemExit('Repository public key is empty or exceeds its size limit.')
if len(manifest_bytes) > 16 * 1024 or signature.stat().st_size > 16 * 1024: if len(manifest_bytes) > 16 * 1024 or signature.stat().st_size > 16 * 1024:
raise SystemExit('Release signing metadata exceeds its size limit.') raise SystemExit('Release signing metadata exceeds its size limit.')
try: try:
+2 -2
View File
@@ -4,6 +4,6 @@
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701", "sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701",
"updaterProtocolVersion": 2, "updaterProtocolVersion": 2,
"updaterSha256": "e4ea1857dcadd9b3e7861bb44cf20d06216047ffa81053ce3ccd7278470013fe", "updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
"installerSha256": "b7f680577c8e0ee0f892a2c43211bd0f1ca64b6c92ba51389d2abca9cfb2c26c" "installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
} }
+2 -2
View File
@@ -38,10 +38,10 @@ async function enrolled() {
} }
describe('native host enrollment and scoped authentication', () => { describe('native host enrollment and scoped authentication', () => {
it('enrolls with an independently obtained local installer and quotes the token', async () => { it('downloads the installer from the fixed repository and quotes the enrollment token', async () => {
const { installerCommand } = await import('../../src/lib/server/hosts/http.js'); const { installerCommand } = await import('../../src/lib/server/hosts/http.js');
expect(installerCommand('https://controller.example/hosts/new', "token'quote")).toBe( expect(installerCommand('https://controller.example/hosts/new', "token'quote")).toBe(
"sudo bash ./electric-cherry-install.sh --controller 'https://controller.example' --token 'token'\\''quote'" "curl --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 25 --max-time 120 'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/install.sh' -o electric-cherry-install.sh && sudo bash ./electric-cherry-install.sh --controller 'https://controller.example' --token 'token'\\''quote'"
); );
}); });