Compare commits

..
3 Commits
Author SHA1 Message Date
josh 44e755a166 Publish signed agent release (Refs #64)
CI / checks (push) Successful in 4m51s
2026-10-09 23:08:12 -04:00
josh a93ddb5ef9 Refresh agent signing keys from repository (Refs #64) 2026-10-09 21:39:03 -04:00
josh 6f71822d41 Require signed agent releases (Refs #64) 2026-10-09 20:36:18 -04:00
26 changed files with 1263 additions and 172 deletions
+4
View File
@@ -10,3 +10,7 @@ archive
*.log
test-fixtures
data
# Offline release signing material must never enter the build context.
**/.gnupg
**/private-keys-v1.d
**/secring.gpg
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Install Python and native build tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 make g++
apt-get install -y --no-install-recommends python3 make g++ gpg gpg-agent gpgv
- name: Install dependencies
run: npm ci
+82 -32
View File
@@ -19,13 +19,16 @@ import re
import stat
import subprocess
import sys
import tempfile
import time
import urllib.parse
import urllib.request
import uuid
UPDATER_PROTOCOL_VERSION = 1
UPDATER_PROTOCOL_VERSION = 2
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
# 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")
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
@@ -36,6 +39,8 @@ UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
AGENT_SERVICE = "electric-cherry-agent.service"
MAX_MANIFEST = 16 * 1024
MAX_SIGNATURE = 16 * 1024
MAX_KEYRING = 256 * 1024
MAX_SOURCE = 8 * 1024 * 1024
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})$")
@@ -150,6 +155,72 @@ def source_version(source):
return declarations[0]
def fetch_url(url, limit, source):
request = urllib.request.Request(url, headers={"User-Agent": "electric-cherry-updater/2", "Cache-Control": "no-cache"})
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(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:
raise IntegrityError("The controller release requires a detached manifest signature")
trusted(UPDATE_DIR, directory=True)
# An isolated home excludes any ambient GnuPG keyring or configuration.
# The exact downloaded bytes are verified before manifest JSON is parsed.
with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary:
home = Path(temporary)
keyring = home / "release-signing.gpg"
keyring.write_bytes(public_key)
manifest_path = home / "manifest.json"
signature_path = home / "manifest.json.sig"
manifest_path.write_bytes(manifest)
signature_path.write_bytes(signature)
try:
result = subprocess.run(["gpgv", "--homedir", str(home), "--keyring", str(keyring), str(signature_path), str(manifest_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, check=False)
except (OSError, subprocess.TimeoutExpired):
raise IntegrityError("Release signature verification is unavailable; install or repair gpgv on this host") from None
if result.returncode:
raise IntegrityError("The release manifest signature is invalid or is not signed by a trusted release key")
@contextlib.contextmanager
def exclusive_lock(path):
trusted(path, missing=True)
@@ -242,40 +313,19 @@ class Updater:
return state
def fetch(self, path, limit):
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/1"})
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
return fetch_url(self.installation["controller"] + path, limit, "controller release")
def manifest(self):
public_key = fetch_release_key()
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
try:
value = json.loads(self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST))
signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE)
except ContactError:
raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None
self.ensure_directory()
verify_release_signature(manifest, signature, public_key)
try:
value = json.loads(manifest)
except (ValueError, UnicodeError):
raise IntegrityError("The controller release manifest is invalid") from None
if not isinstance(value, dict) or type(value.get("protocolVersion")) is not int or value["protocolVersion"] != 1 or value.get("platform") != "debian-amd64":
+46 -6
View File
@@ -33,7 +33,7 @@ command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux gpgv
installation_preflight() {
if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then
echo 'The existing enrollment disappeared during installation. Stop and inspect host deletion before trying again.' >&2
@@ -142,7 +142,7 @@ PY
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
if [ -d "$data_dir" ]; then existing_data_root=true; fi
installation_preflight
temporary_dir=$(mktemp -d)
temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
updater_timer_was_active=false
update_lock_held=false
activity_lock_held=false
@@ -167,21 +167,61 @@ installation_cleanup() {
exit "$ec_install_status"
}
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%/}"
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/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"
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" <<'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.
import ast, hashlib, json, pathlib, re, sys
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
manifest_path = pathlib.Path(sys.argv[1])
manifest_bytes = manifest_path.read_bytes()
signature = manifest_path.with_name('manifest.json.sig')
keyring = pathlib.Path(sys.argv[4])
# 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()
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:
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:
raise SystemExit('Release signing metadata exceeds its size limit.')
try:
with tempfile.TemporaryDirectory(prefix='gpgv-', dir=manifest_path.parent) as home:
verified = subprocess.run(['gpgv', '--homedir', home, '--keyring', str(keyring),
str(signature), '-'], input=manifest_bytes,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=30, check=False)
except (OSError, subprocess.TimeoutExpired):
raise SystemExit('Release signature verification requires a working gpgv installation.') from None
if verified.returncode:
raise SystemExit('Release signature verification failed. The installed agent was left unchanged.')
manifest = json.loads(manifest_bytes)
if not isinstance(manifest, dict):
raise SystemExit('Unsupported agent or updater release manifest.')
agent = pathlib.Path(sys.argv[2]).read_bytes()
updater = pathlib.Path(sys.argv[3]).read_bytes()
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
raise SystemExit('Agent release checksum verification failed.')
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
raise SystemExit('Updater release checksum verification failed.')
if manifest.get('platform') != 'debian-amd64' or manifest.get('protocolVersion') != 1 or manifest.get('updaterProtocolVersion') != 1:
if manifest.get('platform') != 'debian-amd64' or type(manifest.get('protocolVersion')) is not int or manifest['protocolVersion'] != 1 or type(manifest.get('updaterProtocolVersion')) is not int or manifest['updaterProtocolVersion'] != 2:
raise SystemExit('Unsupported agent or updater release manifest.')
versions = [node.value.value for node in ast.parse(agent).body
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets)
+26 -10
View File
@@ -5,14 +5,11 @@ import hashlib
import json
from pathlib import Path
import re
import shutil
def package(source, target):
"""Read version metadata without importing or executing release code."""
source, target = Path(source), Path(target)
agent = (source / "ec_agent.py").read_bytes()
updater = (source / "ec_agent_update.py").read_bytes()
def manifest_bytes(agent, updater, installer):
"""Return the deterministic bytes signed by the offline release signer."""
# Inspect source without importing or executing release code.
versions = [node.value.value for node in ast.parse(agent).body
if isinstance(node, ast.Assign) and any(isinstance(name, ast.Name) and name.id == "VERSION" for name in node.targets)
and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)]
@@ -21,14 +18,33 @@ def package(source, target):
compile(agent, "ec_agent.py", "exec")
compile(updater, "ec_agent_update.py", "exec")
manifest = {"version": versions[0], "protocolVersion": 1, "platform": "debian-amd64",
"sha256": hashlib.sha256(agent).hexdigest(), "updaterProtocolVersion": 1,
"updaterSha256": hashlib.sha256(updater).hexdigest()}
"sha256": hashlib.sha256(agent).hexdigest(), "updaterProtocolVersion": 2,
"updaterSha256": hashlib.sha256(updater).hexdigest(),
"installerSha256": hashlib.sha256(installer).hexdigest()}
return (json.dumps(manifest, indent=2) + "\n").encode("utf-8")
def package(source, target):
"""Read version metadata without importing or executing release code."""
source, target = Path(source), Path(target)
agent = (source / "ec_agent.py").read_bytes()
updater = (source / "ec_agent_update.py").read_bytes()
installer = (source / "install.sh").read_bytes()
manifest = manifest_bytes(agent, updater, installer)
target.mkdir(parents=True, exist_ok=True)
try:
previous_manifest = (target / "manifest.json").read_bytes()
except FileNotFoundError:
previous_manifest = None
if previous_manifest != manifest:
# Builds never sign. Keep an offline signature only across identical
# rebuilds; changed releases remain unsigned until reviewed and signed.
(target / "manifest.json.sig").unlink(missing_ok=True)
(target / "ec-agent.py").write_bytes(agent)
(target / "ec-agent-update.py").write_bytes(updater)
shutil.copyfile(source / "install.sh", target / "install.sh")
(target / "install.sh").write_bytes(installer)
temporary_manifest = target / ".manifest.json.tmp"
temporary_manifest.write_text(json.dumps(manifest, indent=2) + "\n")
temporary_manifest.write_bytes(manifest)
temporary_manifest.replace(target / "manifest.json")
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Sign a reviewed, packaged release using a separate offline GnuPG home.
This command is never invoked by controller builds or runtime. Keep
the signing key and its GnuPG home outside this checkout and Docker context.
"""
import argparse
from pathlib import Path
import re
import subprocess
import tempfile
from package_release import manifest_bytes
def reviewed_manifest(release):
manifest = (release / "manifest.json").read_bytes()
expected = manifest_bytes((release / "ec-agent.py").read_bytes(),
(release / "ec-agent-update.py").read_bytes(),
(release / "install.sh").read_bytes())
if manifest != expected:
raise ValueError("Release manifest does not match packaged artifacts; package and review the release again")
return manifest
def sign_release(release, gnupghome, fingerprint):
"""Sign exact manifest bytes, without importing any private key material."""
release, gnupghome = Path(release).resolve(), Path(gnupghome).resolve()
repository = Path(__file__).resolve().parent.parent
if not re.fullmatch(r"(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})", fingerprint):
raise ValueError("Signing requires a full OpenPGP key fingerprint (40 or 64 hexadecimal characters)")
if gnupghome.is_relative_to(repository) or gnupghome.is_relative_to(release):
raise ValueError("Keep the signing GnuPG home outside the repository and release directory")
if not gnupghome.is_dir():
raise ValueError("The offline signing GnuPG home must already exist")
manifest = reviewed_manifest(release)
with tempfile.TemporaryDirectory(prefix=".sign-release-", dir=release) as directory:
staged_manifest = Path(directory) / "manifest.json"
signature = Path(directory) / "manifest.json.sig"
staged_manifest.write_bytes(manifest)
subprocess.run([
"gpg", "--no-options", "--homedir", str(gnupghome), "--batch", "--yes",
"--armor", "--detach-sign", "--digest-algo", "SHA512",
"--local-user", fingerprint.upper() + "!", "--output", str(signature),
str(staged_manifest),
], check=True)
if not signature.read_bytes().startswith(b"-----BEGIN PGP SIGNATURE-----\n"):
raise ValueError("GnuPG did not produce an ASCII-armored detached signature")
if reviewed_manifest(release) != manifest:
raise ValueError("Release changed during signing; review and sign the release again")
signature.chmod(0o644)
signature.replace(release / "manifest.json.sig")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--release-dir", type=Path,
default=Path(__file__).resolve().parent.parent / "static" / "agent")
parser.add_argument("--gnupghome", required=True, type=Path,
help="Existing offline GnuPG home outside the checkout/build context")
parser.add_argument("--fingerprint", required=True,
help="Full fingerprint of the exact signing key (not a key ID or email)")
args = parser.parse_args()
try:
sign_release(args.release_dir, args.gnupghome, args.fingerprint)
except (OSError, ValueError, SyntaxError, subprocess.CalledProcessError) as cause:
parser.exit(1, f"Release signing failed: {cause}\n")
print(f"Signed {args.release_dir / 'manifest.json'}")
if __name__ == "__main__":
main()
+98 -7
View File
@@ -11,7 +11,7 @@ import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
from unittest.mock import Mock, patch
SOURCE = (Path(__file__).resolve().parents[1] / "install.sh").read_text()
@@ -33,6 +33,8 @@ class InstallerTests(unittest.TestCase):
self.root = self.base / "managed"
self.config = self.base / "configuration" / "agent.json"
self.config.parent.mkdir()
self.keyring = self.config.with_name('release-signing.gpg')
self.keyring.write_bytes(b'publisher public key')
self.controller = "https://controller.example"
self.saved = {"root": str(self.root), "controller": self.controller,
"hostId": "host-one", "credential": "private"}
@@ -80,6 +82,10 @@ class InstallerTests(unittest.TestCase):
self.preflight()
self.assertEqual((self.root / "unrelated.txt").read_text(), "keep")
def test_no_preprovisioned_signing_key_is_required(self):
self.keyring.unlink()
self.preflight()
def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self):
self.legacy_installation()
original_config = self.config.read_bytes()
@@ -334,23 +340,74 @@ docker() {
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):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.source = Path(self.temporary.name) / "source"
self.target = Path(self.temporary.name) / "release"
self.source = Path(self.temporary.name).resolve() / "source"
self.target = Path(self.temporary.name).resolve() / "release"
self.source.mkdir()
(self.source / "ec_agent.py").write_text('VERSION = "3.12.7"\nraise RuntimeError("must never execute during packaging")\n')
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("must never execute during verification")\n')
(self.source / "install.sh").write_text("#!/bin/bash\n")
PACKAGER.package(self.source, self.target)
self.keyring = self.source.parent / 'release-signing.gpg'
self.keyring.write_bytes(b'publisher public key')
(self.target / 'manifest.json.sig').write_bytes(b'detached signature')
self.gpgv = Mock(return_value=subprocess.CompletedProcess([], 0))
def tearDown(self):
self.temporary.cleanup()
def verify(self):
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py")]
with patch.object(sys, "argv", argv):
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py"), str(self.keyring)]
original_lstat = Path.lstat
def root_owned(path):
fields = list(original_lstat(path))
fields[4] = 0
if stat.S_ISDIR(fields[0]):
fields[0] &= ~0o022
return os.stat_result(fields)
with patch.object(sys, "argv", argv), patch.object(Path, 'lstat', root_owned), patch.object(subprocess, 'run', self.gpgv):
exec(compile(RELEASE, "installer-release", "exec"), {})
def manifest(self, **changes):
@@ -363,7 +420,7 @@ class ReleaseTests(unittest.TestCase):
def test_package_derives_version_and_hashes_both_sources_without_executing_them(self):
manifest = self.manifest()
self.assertEqual(manifest["version"], "3.12.7")
self.assertEqual(manifest["updaterProtocolVersion"], 1)
self.assertEqual(manifest["updaterProtocolVersion"], 2)
self.assertEqual(manifest["sha256"], hashlib.sha256((self.source / "ec_agent.py").read_bytes()).hexdigest())
self.assertEqual(manifest["updaterSha256"], hashlib.sha256((self.source / "ec_agent_update.py").read_bytes()).hexdigest())
self.assertEqual((self.target / "ec-agent-update.py").read_bytes(), (self.source / "ec_agent_update.py").read_bytes())
@@ -380,11 +437,45 @@ class ReleaseTests(unittest.TestCase):
path.write_bytes(original)
def test_installer_rejects_missing_or_unsupported_updater_protocol(self):
for protocol in (None, 0, 2, "1"):
for protocol in (None, 0, 1, True, "2"):
self.manifest(updaterProtocolVersion=protocol)
with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"):
self.verify()
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()
args, kwargs = self.gpgv.call_args
self.assertEqual(args[0][0], 'gpgv')
self.assertEqual(args[0][-4:], ['--keyring', str(self.keyring), str(self.target / 'manifest.json.sig'), '-'])
self.assertEqual(kwargs['input'], (self.target / 'manifest.json').read_bytes())
self.assertEqual(kwargs['timeout'], 30)
self.assertFalse(Path(args[0][2]).exists())
(self.target / 'manifest.json').write_bytes(b'not even JSON')
self.gpgv.return_value = subprocess.CompletedProcess([], 1)
with self.assertRaisesRegex(SystemExit, 'signature verification failed'):
self.verify()
def test_missing_signature_key_or_gpgv_prevents_installation(self):
for path in (self.keyring, self.target / 'manifest.json.sig'):
original = path.read_bytes()
path.unlink()
self.gpgv.reset_mock()
with self.subTest(path=path), self.assertRaises(FileNotFoundError):
self.verify()
self.gpgv.assert_not_called()
path.write_bytes(original)
for failure in (FileNotFoundError(), subprocess.TimeoutExpired('gpgv', 30)):
self.gpgv.side_effect = failure
with self.subTest(failure=failure), self.assertRaisesRegex(SystemExit, 'working gpgv'):
self.verify()
def test_installer_rejects_manifest_version_mismatch_and_invalid_code(self):
self.manifest(version="3.12.8")
with self.assertRaisesRegex(SystemExit, "version does not match"):
+243
View File
@@ -0,0 +1,243 @@
"""Release packaging is unsigned; only the explicit offline command may sign."""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
AGENT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("package_release", AGENT / "package_release.py")
PACKAGER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(PACKAGER)
SPEC = importlib.util.spec_from_file_location("sign_release", AGENT / "sign_release.py")
SIGNER = importlib.util.module_from_spec(SPEC)
with patch.dict(sys.modules, {"package_release": PACKAGER}):
SPEC.loader.exec_module(SIGNER)
SPEC = importlib.util.spec_from_file_location("signed_pipeline_updater", AGENT / "ec_agent_update.py")
UPDATER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(UPDATER)
class ReleaseFixture:
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.base = Path(self.temporary.name).resolve()
self.source = self.base / "source"
self.source.mkdir()
self.target = self.base / "release"
self.gnupghome = self.base / "offline-keyring"
self.gnupghome.mkdir(mode=0o700)
self.fingerprint = "A" * 40
(self.source / "ec_agent.py").write_text('VERSION = "4.2.0"\nraise RuntimeError("Do not run")\n')
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("Do not run")\n')
(self.source / "install.sh").write_text("#!/bin/bash\nexit 1\n")
PACKAGER.package(self.source, self.target)
def tearDown(self):
self.temporary.cleanup()
class PackagedReleaseTests(ReleaseFixture, unittest.TestCase):
def test_manifest_is_deterministic_and_covers_every_executable(self):
first = (self.target / "manifest.json").read_bytes()
PACKAGER.package(self.source, self.target)
self.assertEqual(first, (self.target / "manifest.json").read_bytes())
manifest = json.loads(first)
self.assertEqual(manifest["updaterProtocolVersion"], 2)
for field, filename in (("sha256", "ec-agent.py"),
("updaterSha256", "ec-agent-update.py"),
("installerSha256", "install.sh")):
self.assertEqual(manifest[field], hashlib.sha256((self.target / filename).read_bytes()).hexdigest())
self.assertFalse((self.target / "manifest.json.sig").exists())
def test_packaging_preserves_signature_bytes_only_for_identical_manifest(self):
signature = self.target / "manifest.json.sig"
for filename in ("ec_agent.py", "ec_agent_update.py", "install.sh"):
with self.subTest(filename=filename):
signature.write_bytes(b"offline signature")
with patch.object(subprocess, "run", side_effect=AssertionError("Builds must never sign")):
PACKAGER.package(self.source, self.target)
self.assertEqual(signature.read_bytes(), b"offline signature")
source = self.source / filename
source.write_bytes(source.read_bytes() + b"# reviewed change\n")
PACKAGER.package(self.source, self.target)
self.assertFalse(signature.exists())
def test_orphaned_signature_is_removed_when_manifest_was_missing(self):
(self.target / "manifest.json").unlink()
(self.target / "manifest.json.sig").write_bytes(b"orphaned signature")
PACKAGER.package(self.source, self.target)
self.assertFalse((self.target / "manifest.json.sig").exists())
def test_signer_requires_full_fingerprint_before_invoking_gpg(self):
for fingerprint in ("", "release@example.com", "A" * 16, "Z" * 40, "A" * 41):
with self.subTest(fingerprint=fingerprint), patch.object(subprocess, "run") as run:
with self.assertRaisesRegex(ValueError, "full OpenPGP key fingerprint"):
SIGNER.sign_release(self.target, self.gnupghome, fingerprint)
run.assert_not_called()
def test_signer_rejects_private_key_homes_in_repository_or_release(self):
for home in (AGENT, self.target, self.target / "private"):
with self.subTest(home=home), patch.object(subprocess, "run") as run:
with self.assertRaisesRegex(ValueError, "outside the repository and release"):
SIGNER.sign_release(self.target, home, self.fingerprint)
run.assert_not_called()
def test_signer_refuses_unreviewed_or_inconsistent_release_bytes(self):
for filename in ("ec-agent.py", "ec-agent-update.py", "install.sh", "manifest.json"):
with self.subTest(filename=filename), patch.object(subprocess, "run") as run:
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"\n")
with self.assertRaisesRegex(ValueError, "does not match packaged artifacts"):
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
run.assert_not_called()
path.write_bytes(original)
def test_failed_signing_does_not_replace_existing_signature(self):
signature = self.target / "manifest.json.sig"
signature.write_bytes(b"previous valid signature")
with patch.object(subprocess, "run", side_effect=subprocess.CalledProcessError(2, "gpg")):
with self.assertRaises(subprocess.CalledProcessError):
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
self.assertEqual(signature.read_bytes(), b"previous valid signature")
self.assertEqual(list(self.target.glob(".sign-release-*")), [])
def test_release_mutation_during_signing_never_publishes_a_signature(self):
def mutate(command, **kwargs):
Path(command[command.index("--output") + 1]).write_bytes(b"-----BEGIN PGP SIGNATURE-----\nstaged\n")
(self.target / "ec-agent.py").write_bytes(b'VERSION = "9.0.0"\n')
with patch.object(subprocess, "run", side_effect=mutate):
with self.assertRaisesRegex(ValueError, "does not match packaged artifacts"):
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
self.assertFalse((self.target / "manifest.json.sig").exists())
@unittest.skipUnless(shutil.which("gpg") and shutil.which("gpgv"), "GnuPG required for real offline signing tests")
class OfflineSigningTests(ReleaseFixture, unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.keys = tempfile.TemporaryDirectory(prefix="ec-sign-")
cls.keyhome = Path(cls.keys.name)
cls.keyhome.chmod(0o700)
command = ["gpg", "--no-options", "--homedir", str(cls.keyhome), "--batch"]
subprocess.run(command + ["--pinentry-mode", "loopback", "--passphrase", "",
"--quick-generate-key", "Ephemeral release test <release@example.invalid>",
"ed25519", "sign", "0"], check=True, capture_output=True)
listing = subprocess.run(command + ["--with-colons", "--list-secret-keys"],
check=True, capture_output=True, text=True).stdout
cls.key_fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:"))
cls.public_key = subprocess.run(command + ["--export", cls.key_fingerprint],
check=True, capture_output=True).stdout
@classmethod
def tearDownClass(cls):
if shutil.which("gpgconf"):
subprocess.run(["gpgconf", "--homedir", str(cls.keyhome), "--kill", "gpg-agent"],
check=False, capture_output=True)
cls.keys.cleanup()
def setUp(self):
super().setUp()
self.keyring = self.base / "release-signing.gpg"
self.keyring.write_bytes(self.public_key)
self.update_dir = self.base / "update"
self.update_dir.mkdir(mode=0o700)
original_lstat = Path.lstat
protected_paths = {self.keyring, *self.keyring.parents, self.update_dir}
def root_owned(path):
info = original_lstat(path)
if path not in protected_paths:
return info
fields = list(info)
fields[4] = 0
if stat.S_ISDIR(fields[0]):
fields[0] &= ~0o022
return os.stat_result(fields)
self.root_owned = root_owned
def verify_installer(self):
blocks = re.findall(r"<<'PY'\n(.*?)\nPY", (AGENT / "install.sh").read_text(), re.DOTALL)
release = next(block for block in blocks if "# INSTALLATION_RELEASE:" in block)
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"),
str(self.target / "ec-agent-update.py"), str(self.keyring)]
# Only filesystem ownership is simulated; real GnuPG verifies the
# separately exported public key and the offline signer's exact bytes.
with patch.object(sys, "argv", argv), patch.object(Path, "lstat", self.root_owned):
exec(compile(release, "installer-release", "exec"), {})
def verify_updater(self):
with patch.object(UPDATER, "UPDATE_DIR", self.update_dir), \
patch.object(Path, "lstat", self.root_owned):
UPDATER.verify_release_signature((self.target / "manifest.json").read_bytes(),
(self.target / "manifest.json.sig").read_bytes(), self.public_key)
def verify(self):
return subprocess.run([
"gpg", "--no-options", "--homedir", str(self.keyhome), "--batch", "--verify",
str(self.target / "manifest.json.sig"), str(self.target / "manifest.json"),
], capture_output=True)
def test_offline_signature_verifies_exact_bytes_and_survives_identical_build(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
signature = (self.target / "manifest.json.sig").read_bytes()
self.assertTrue(signature.startswith(b"-----BEGIN PGP SIGNATURE-----\n"))
self.assertEqual(self.verify().returncode, 0)
PACKAGER.package(self.source, self.target)
self.assertEqual((self.target / "manifest.json.sig").read_bytes(), signature)
self.assertEqual(self.verify().returncode, 0)
with (self.target / "manifest.json").open("ab") as manifest:
manifest.write(b" ")
self.assertNotEqual(self.verify().returncode, 0)
def test_unknown_signing_key_fails_without_publishing_a_signature(self):
with self.assertRaises(subprocess.CalledProcessError):
SIGNER.sign_release(self.target, self.keyhome, "F" * 40)
self.assertFalse((self.target / "manifest.json.sig").exists())
def test_offline_signed_release_is_accepted_by_installer_and_updater(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
self.verify_installer()
self.verify_updater()
def test_controller_cannot_authorize_changed_code_by_rehashing_manifest(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
for filename in ("ec-agent.py", "ec-agent-update.py"):
with self.subTest(filename=filename):
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"# controller's unauthorized change\n")
(self.target / "manifest.json").write_bytes(PACKAGER.manifest_bytes(
(self.target / "ec-agent.py").read_bytes(),
(self.target / "ec-agent-update.py").read_bytes(),
(self.target / "install.sh").read_bytes()))
with self.assertRaisesRegex(SystemExit, "signature verification failed"):
self.verify_installer()
with self.assertRaisesRegex(UPDATER.IntegrityError, "signature is invalid"):
self.verify_updater()
path.write_bytes(original)
def test_signed_manifest_rejects_changed_agent_and_updater_downloads(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
for filename, message in (("ec-agent.py", "Agent release checksum"),
("ec-agent-update.py", "Updater release checksum")):
with self.subTest(filename=filename):
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"# controller's unauthorized change\n")
with self.assertRaisesRegex(SystemExit, message):
self.verify_installer()
path.write_bytes(original)
if __name__ == "__main__":
unittest.main()
+241 -6
View File
@@ -5,7 +5,9 @@ import io
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import tempfile
import time
import unittest
@@ -62,9 +64,16 @@ class UpdateTests(unittest.TestCase):
if directory and not path.is_dir() or not directory and not path.is_file():
raise module.UpdateError("Wrong file type")
return path
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.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.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:
item.start()
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)
@@ -78,7 +87,8 @@ class UpdateTests(unittest.TestCase):
self.write_health = True
self.downloads = []
self.source = CANDIDATE
self.release = {"version": "1.1.0", "sha256": module.sha256(CANDIDATE), "protocolVersion": 1, "updaterProtocolVersion": 1, "platform": "debian-amd64"}
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):
@@ -108,6 +118,10 @@ class UpdateTests(unittest.TestCase):
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)
@@ -128,6 +142,7 @@ class UpdateTests(unittest.TestCase):
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.public_key)
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)
@@ -137,6 +152,36 @@ class UpdateTests(unittest.TestCase):
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, self.public_key)
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):
@@ -146,6 +191,32 @@ class UpdateTests(unittest.TestCase):
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
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):
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
self.assertEqual(self.updater().run(), "deferred")
@@ -207,7 +278,7 @@ class UpdateTests(unittest.TestCase):
self.downloads.clear()
self.source = CANDIDATE
self.assertEqual(self.updater().run(), "failed")
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json"])
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'):
@@ -220,7 +291,7 @@ class UpdateTests(unittest.TestCase):
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", 2)):
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"))
@@ -239,7 +310,7 @@ class UpdateTests(unittest.TestCase):
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"])
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()
@@ -266,9 +337,15 @@ class UpdateTests(unittest.TestCase):
def test_crash_between_executable_replacements_recovers_from_backups(self):
self.crash_during_second_replacement()
self.downloads.clear()
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.assertEqual(self.updater().run(), "failed")
self.assert_unchanged()
self.assertEqual(self.downloads, [])
self.signature_verifier.assert_not_called()
self.fetch_key.assert_not_called()
self.assertTrue(self.active)
self.assertFalse(module.UPDATE_JOURNAL.exists())
@@ -414,5 +491,163 @@ class UpdateSecurityTests(unittest.TestCase):
module.version(value)
class RepositoryKeyDownloadTests(unittest.TestCase):
def test_fixed_https_url_no_credentials_no_cache_and_no_redirects(self):
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = Response(b"public keys")
self.assertEqual(module.fetch_release_key(), b"public keys")
request = opener.return_value.open.call_args.args[0]
self.assertEqual(request.full_url, "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg")
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:
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)
entry = patch.object(module, "UPDATE_DIR", self.update_dir)
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_downloaded_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(home / "release-signing.gpg")])
self.assertEqual(Path(command[4]).read_bytes(), self.keyring.read_bytes())
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, self.keyring.read_bytes())
run.assert_called_once()
self.assertEqual(self.trusted.call_args_list[0].args, (self.update_dir,))
self.assertEqual(list(self.update_dir.iterdir()), [])
def test_empty_or_oversized_keyring_or_missing_signature_never_runs_a_verifier(self):
with patch.object(module.subprocess, "run") as run:
for key in (b"", b"x" * (module.MAX_KEYRING + 1)):
with self.subTest(size=len(key)), self.assertRaisesRegex(module.IntegrityError, "keyring"):
module.verify_release_signature(b"manifest", b"signature", key)
with self.assertRaisesRegex(module.IntegrityError, "detached manifest signature"):
module.verify_release_signature(b"manifest", b"", self.keyring.read_bytes())
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.keyring.read_bytes())
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_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):
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, 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))):
with self.subTest(manifest=changed_manifest, signature=changed_signature):
with self.assertRaises(module.IntegrityError):
module.verify_release_signature(changed_manifest, changed_signature, self.keyring.read_bytes())
self.assertEqual(list(self.update_dir.iterdir()), [])
if __name__ == "__main__":
unittest.main()
+26 -5
View File
@@ -2,7 +2,8 @@
The controller uses SvelteKit, TypeScript and SQLite (`better-sqlite3`), with the
Node adapter and server-sent events. The host agent uses Python 3's standard
library and runs as a systemd service; hosts do not need Node.js.
library and runs as a systemd service; release verification calls `gpgv`.
Hosts do not need Node.js.
Agents connect outbound to the controller over HTTPS and manage Docker/Plutainer
and local RCON on each host. The controller owns fleet metadata and queues; it
@@ -10,7 +11,9 @@ does not require a Docker socket or direct access to host filesystems.
## Run locally
Use Node.js 22.13+ (the container uses Node.js 22), npm and Python 3.
Use Node.js 22.13+ (the container uses Node.js 22), npm and Python 3. Install
GnuPG's `gpg`, `gpg-agent` and `gpgv` to run real release-signing integration tests; local test
runs skip those tests when either executable is absent.
```sh
npm ci
@@ -89,8 +92,10 @@ npm run build
The [Gitea Actions workflow](../../.gitea/workflows/ci.yml) runs these checks on
every push and pull request after `npm ci`. It uses a Node.js 22 Debian container
with Python 3 and native build tools for `better-sqlite3`. The runner must support
Docker jobs with the `ubuntu-latest` label. `npm run check` runs before the tests
with Python 3, `gpg`, `gpg-agent`, `gpgv` and native build tools for `better-sqlite3`. Signing
tests create disposable keys in temporary directories; CI never receives release
private keys. The runner must support Docker jobs with the `ubuntu-latest` label.
`npm run check` runs before the tests
to generate the SvelteKit configuration needed in a fresh checkout. Application
tests use two workers in CI to bound resource use for database-heavy fixtures.
The build step runs the same `npm run build` command used by the Dockerfile,
@@ -118,7 +123,23 @@ Dependency security checks must include development dependencies: use `npm audit
without `--omit=dev`.
`npm run dev` and `npm run build` regenerate the downloadable agent, installer,
standalone updater and checksum manifest. Builds also bundle report workers
standalone updater and checksum manifest. Packaging preserves an existing
`static/agent/manifest.json.sig` only when its manifest bytes remain identical;
changes to the agent, updater or installer remove the stale signature. Builds
never sign, and hosts reject unsigned releases while retaining installed code.
Finalize and review release code, run `npm run agent:package`, and explicitly
sign it on the offline publisher machine:
```sh
python3 agent/sign_release.py --gnupghome /secure/electric-cherry-signing --fingerprint FULL_SIGNING_KEY_FINGERPRINT
```
Commit or distribute the signed public artifacts before building the controller
image from the same source revision. Keep the signing home outside the repository, Docker context, controller
and CI. Follow [Publishing signed releases](../guides/native-hosts.md#publishing-signed-releases)
for key creation, host provisioning and rotation.
Builds also bundle report workers
under `build/reports` and launch preparation under `build/launch`. Development
and test commands compile those workers automatically. Restart `npm run dev`
after changing agent or worker code, or run `npm run agent:package` to refresh
+20 -3
View File
@@ -9,6 +9,8 @@ HTTPS. Running game containers continue through controller or agent outages.
- 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.
- A signed agent release and its public key published in the trusted repository.
- 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.
- Plutonium server keys for each game, with one unused key per new concurrent lobby.
@@ -37,6 +39,13 @@ For an existing installation, retain its original data directory. Compose
defaults to `/root/zombies/.electric-cherry`; choose a dedicated directory for a
new controller. See [.env.example](../../.env.example) for the other settings.
Before building for game hosts, obtain the publisher's signed `static/agent`
artifacts and matching source revision, or follow
[Publishing signed releases](native-hosts.md#publishing-signed-releases)
for your own build. Normal builds preserve signatures for identical artifacts;
source changes require a new offline signature. An unsigned controller can run,
but hosts reject its agent installation and updates.
```sh
docker compose up -d --build
```
@@ -60,15 +69,23 @@ password recovery.
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
verified upload becomes that game's default files.
2. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and
bind address, and run the generated enrollment command on the host.
3. Wait for automatic setup to prepare the Plutainer runtime and available game
2. Ensure the release publisher has [published its public key and signed release](native-hosts.md#publishing-signed-releases).
Hosts fetch the key automatically during installation and before every update
check; there is no manual key-copy step.
3. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and
bind address, and run the generated enrollment command. It downloads the
installer from the fixed public repository URL before running it. Check that
the installer source is the known repository, independent of the controller.
4. Wait for automatic setup to prepare the Plutainer runtime and available game
files. The host page reports download, verification, and readiness progress.
The installer sets up the Python agent as a systemd service and enables automatic
agent updates. It installs Docker if needed. Multiple instances share read-only
base game files; each instance has its own writable data. See
[Native hosts](native-hosts.md) for storage layout, network access, and recovery.
Existing hosts using updater protocol 1 need a
[trusted installer migration](native-hosts.md#automatic-agent-updates) before
controller-independent release verification takes effect.
## Launch your first server
+187 -39
View File
@@ -12,9 +12,11 @@ the agent owns local execution and its durable operation journal.
and roles; agent endpoints authenticate their own host credentials. See
[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,
and generate an enrollment. Run the displayed command on Debian 12/13 amd64.
3. The installer validates the platform, installs Python 3 and an absent Docker
installation, verifies the downloaded agent and updater SHA-256 over HTTPS,
and generate an enrollment. Run the displayed command on the Debian 12/13
amd64 host. It downloads the installer directly from the public repository;
[release trust](#provision-release-trust) is fetched automatically.
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,
enrolls it and enables `electric-cherry-agent.service` and its automatic update
timer. It needs no Git, Node.js or local build.
4. In **Game Files**, upload your complete local folder under T5 or T6. Include
@@ -41,6 +43,47 @@ the agent owns local execution and its durable operation journal.
A fresh destination host downloads centrally stored game files without manual
copying or per-container setup. EC does not supply the game content itself.
### Provision release trust
The public Electric Cherry repository is the authority for both the installer
and release-signing public keys. The enrollment command downloads `agent/install.sh`
from that repository over HTTPS, saves it locally, and runs it only after the
download succeeds. No manual key transfer is required.
During installation and **before every update check**, the host downloads the
binary OpenPGP public keyring directly from this fixed URL:
```text
https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg
```
The URL is built into the trusted installer and updater. Controller configuration,
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.
This deliberately trusts the repository account, its `main` branch and HTTPS
hosting as authorities for root code on game hosts. They must be outside the
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.
Before deploying the first signed release, the publisher must export and commit
its **public** key as `agent/release-signing.gpg` on `main`, following the
[signing workflow below](#publishing-signed-releases). No production key is
created by packaging. Until the public key and signed release are published,
installation and updates fail closed. Forks must change the fixed repository URLs
in the installer, updater and enrollment command to their own trusted repository;
there is intentionally no controller setting to override them.
### Docker installation
The installer uses Debian's Docker packages: `docker.io` on Debian 12, plus
@@ -167,45 +210,135 @@ directory, while file writes reject symlinks and paths outside that directory.
## Automatic agent updates
New installations enable automatic agent updates by default. Each host checks
its controller's release over HTTPS about every five minutes, beginning about
two minutes after boot, with up to 30 seconds of jitter. Updating the controller
therefore makes its compatible, newer agent release available to enrolled hosts.
the repository public key and then its controller's release over HTTPS about
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.
There is no controller command to schedule and no update button to press.
**Existing hosts need the current installer once.** From the host's **Agent
access**, generate an installer command and rerun it on the same machine, with
the same controller and data directory. Do not use `--reenroll` for this upgrade.
The installer preserves host identity, credentials, game files and running
containers. Agent version 1.2.0 includes automatic update support; the host page
shows **Setup required** until a fresh heartbeat confirms the updater is installed.
**Existing hosts must migrate to updater protocol 2 with the trusted installer.**
Protocol 1 bootstraps trust controller-provided checksums and remain exposed to
controller-forged updates until this migration is complete. Publishing a signed
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.
From the host's **Agent access**, generate and run the command, which fetches
the repository installer and public key automatically. Use the same controller
and data directory. Preserve a custom root with `--data-dir`;
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
interrupt a running update worker; let any active update or journal recovery
finish before installing. The installer preserves host identity, credentials,
game files and running containers, replaces the bootstrap, and enables its timer
on success. Hosts without an updater show **Setup required** until a heartbeat
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
version, last check, progress and any error. Offline hosts show their last
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
service that was stopped outside the update workflow.
The independent updater holds the same execution lock, verifies the release
checksum, version and supported protocol, replaces agent code, and restarts the
agent service. It never restarts Docker or game containers. It confirms an
The independent updater holds the same execution lock, authenticates the exact
manifest bytes with the freshly fetched repository keyring before parsing them,
verifies the release checksum, version and supported protocol, replaces agent
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;
failure restores the previous agent code. Its durable journal permits recovery
after a host reboot or interrupted update.
The updater only installs newer compatible versions; rolling the controller back
does not downgrade agents. A rejected version/checksum pair stays blocked until
The updater only installs newer compatible versions; replaying an older signed
release or rolling the controller back does not downgrade agents. Signing cannot
force a controller to publish newer releases or prevent it withholding updates.
A rejected version/checksum pair stays blocked until
the controller publishes a different pair that is still newer than the installed
agent. Inspect the reported error when a host
shows **Needs attention**. The updater itself is a stable bootstrap component:
it changes only when the installer is rerun. If a future release requires an
unsupported updater protocol, rerun that release's installer on the host.
### Publishing releases and inspecting updates
### Publishing signed releases
When publishing changed agent code, bump its numeric `VERSION` in
`agent/ec_agent.py` and rebuild the controller. Packaging derives the manifest
version and checksums from the sources; an already installed version is never
replaced just because its bytes changed.
`agent/ec_agent.py`. Finalize and review all source changes before packaging and
signing. An already installed version is never replaced just because its bytes
changed.
Use a separate offline publisher machine with GnuPG. Keep the private key and its
GnuPG home outside the checkout, Docker build context, controller, and CI. For
example, create a dedicated signing key and inspect its full fingerprint:
```sh
ec_signing_home=/secure/electric-cherry-signing
install -d -m 0700 "$ec_signing_home"
gpg --homedir "$ec_signing_home" --quick-generate-key 'Electric Cherry Release Signing' ed25519 sign 2y
gpg --homedir "$ec_signing_home" --list-keys --with-fingerprint
```
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
export only its public key into the reviewed checkout:
```sh
ec_signing_fingerprint=REPLACE_WITH_FULL_SIGNING_KEY_FINGERPRINT
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:
```sh
npm run agent:package
python3 agent/sign_release.py --gnupghome "$ec_signing_home" --fingerprint "$ec_signing_fingerprint"
```
The signer defaults to `static/agent`; `--release-dir` selects another packaged
release directory. It checks that the manifest matches the agent, updater and
installer bytes, then produces the ASCII-armored detached `manifest.json.sig`.
The manifest records their SHA-256 hashes as `sha256`, `updaterSha256`, and
`installerSha256`, along with version, platform and protocol metadata. The
installer hash lets recipients check a publisher-distributed installer against
the authenticated manifest before running it.
Commit or distribute the finalized `static/agent` public artifacts together,
including the signature, before building the controller image from the same
reviewed source revision. Never include private keys or the signing home.
Normal `npm run build`, development startup,
and Docker builds never sign: packaging preserves a signature only when the
manifest remains byte-for-byte identical and removes it after any covered source
change. Repackage, review and sign again after such a change. Unsigned builds can
run the controller, but hosts fail closed and leave their installed code intact.
### Signing-key rotation and removal
Review new fingerprints on the publisher workstation, then replace
`agent/release-signing.gpg` on the repository's `main` branch. Every host uses the
new keyring on its next update check; no host visits or controller changes are
needed. For a planned transition, export both approved public keys into this file,
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.
For a compromised key, remove it from the repository keyring immediately and
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)
states that it does not check key expiration or revocation. Keep the keyring's
contents restricted to currently trusted keys. Removing all keys blocks new
installations and updates; it does not stop or remove already installed agents.
### Inspecting updates
The installer places the updater at
`/usr/local/libexec/electric-cherry-update.py` and enables
@@ -370,8 +503,10 @@ The agent uses HTTPS, operating-system certificate validation, outbound heartbea
and polling; Docker has no exposed remote API. Agent endpoints reject HTTP unless
`EC_AGENT_ALLOW_HTTP=true` is explicitly enabled for development. The agent's
insecure development option is limited to loopback. Public release assets contain
no credentials. The release checksum is authenticated by the controller's HTTPS
origin; there is no separate offline release-signing infrastructure.
no credentials. HTTPS protects transport; an offline publisher signature over the
release manifest authenticates agent and updater code using the public keyring
fetched directly from the trusted repository before each check. Controller
credentials do not grant release-signing authority or control the key source.
Browser sessions do not authorize agents. Similarly, host credentials never grant
access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`,
@@ -397,9 +532,11 @@ explicit installer `--reenroll` when local credentials already exist.
or `409` when the agent needs updating or unresolved ownership remains.
- `POST /api/agent/enroll`: consume enrollment token and return host credentials.
- `GET /api/agent/release/manifest.json`: public agent version, protocol and
checksum plus `updaterProtocolVersion` and `updaterSha256` for installer bootstrap.
checksum plus `updaterProtocolVersion`, `updaterSha256`, and `installerSha256`.
- `GET /api/agent/release/manifest.json.sig`: detached publisher signature over
the exact manifest bytes; absent for an unsigned build.
- `GET /api/agent/release/ec-agent.py` and `ec-agent-update.py`: public release
source, verified against that HTTPS manifest before installation.
source, verified against the signed manifest before installation.
- `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches.
- `GET /api/agent/operations`: deliver/reconcile durable operation IDs.
- `POST /api/agent/operations/:id`: authenticated acceptance, progress or result.
@@ -576,10 +713,11 @@ and invalidates unused installer links. A separate cleanup service removes the
host's downloaded game libraries, instance files, agent journal and configuration,
owned containers, and recorded runtime images unused by other containers. It
stops and removes the agent service and executable, automatic update timer,
update service, updater executable and update state, then removes its own files.
update service, updater executable and update state (including any legacy local
signing keyring), then removes its own files.
The controller hides the host and revokes its ordinary credentials only after
receiving the final uninstall receipt. Adding the machine again requires a new
host enrollment.
host enrollment; the installer fetches release trust from the repository again.
Game folders uploaded through Electric Cherry belong to the controller. Those
original uploads remain available for other hosts and future deployments; only
@@ -834,14 +972,19 @@ npm run build
```
The build packages the agent, standalone updater and installer into the static
release, generates the agent/updater checksum manifest and serves those same
assets in the Node-adapter image. Development serves the freshly packaged static
release even when an older production build exists. The Python programs use only
the standard library.
release, generates their checksum manifest and serves those same assets in the
Node-adapter image. Sign production artifacts using the
[offline workflow](#publishing-signed-releases) before building. Development
serves the freshly packaged static release even when an older production build
exists. The Python programs use only the standard library; signature verification
also requires the host's `gpgv` executable. Real signing tests require `gpg` and
`gpgv`, installed by CI; local runs skip those tests when either tool is absent.
Automated tests cover enrollment/authentication, rotation/revocation, host
readiness, scoped ports, immutable library publication, streaming/ranges,
checksums, interrupted downloads, instance generation ownership, local occupancy
signed manifests, forged checksums, unknown signing keys, repository key refresh
and rotation, repository failures, interrupted downloads,
instance generation ownership, local occupancy
holds, controller restart/reconnect, ambiguous results, occupied campaign staging,
activation before images, collections and draining. Host deletion tests cover
ownership guards, interrupted cleanup, uninstall receipts, credential revocation
@@ -852,13 +995,18 @@ A real Debian/Docker/Plutonium acceptance run is still required before productio
rollout. Automated simulations do not establish successful deployment on real
game hosts. Run:
- Installer on Debian 12 and 13 amd64, then rerun without changing identity/data.
- Bootstrap an existing host with the current installer, then publish a newer
compatible agent release. Verify a timer-driven update, its authenticated
- Publish the public key in the repository and run the generated enrollment
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
compatible signed agent release. Verify a timer-driven update, its authenticated
heartbeat and unchanged game containers. Exercise an active-operation hold,
bad checksum, failed startup/heartbeat rollback, controller downgrade, network
loss and host reboot during update. Verify updater protocol mismatch requests
an installer rerun.
missing/invalid signatures, an unknown key, a forged manifest matching changed
code, bad checksum, failed startup/heartbeat rollback, controller downgrade,
network loss and host reboot during update. Verify updater protocol mismatch
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.
- 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
+2 -1
View File
@@ -20,7 +20,8 @@ function publicAsset(routeId: string | null, path: string, method: string) {
'/agent/ec-agent.py',
'/agent/ec-agent-update.py',
'/agent/install.sh',
'/agent/manifest.json'
'/agent/manifest.json',
'/agent/manifest.json.sig'
].includes(path))
);
}
+3 -1
View File
@@ -70,5 +70,7 @@ export function hostApiError(cause: unknown): Response {
export function installerCommand(controller: string, token: string): string {
const origin = new URL(controller).origin;
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
return `curl --fail --silent --show-error --proto '=https' ${quote(origin + '/api/agent/install.sh')} | sudo bash -s -- --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)}`;
}
+2 -1
View File
@@ -3,7 +3,8 @@ import { resolve } from 'node:path';
/** adapter-node copies public assets to build/client; Vite serves static in development. */
export async function agentReleaseAsset(
file: 'install.sh' | 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'
file:
'install.sh' | 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json' | 'manifest.json.sig'
): Promise<string> {
// A prior production build must never shadow a freshly packaged development release.
if (process.env.NODE_ENV === 'development')
@@ -52,6 +52,11 @@
<p role="status">
{name} created. Run this command with administrator access on that machine.
</p>
<p>
This command downloads the installer from the public Electric Cherry repository. The
host automatically fetches the publisher's public signing key from that repository
during installation and before every update check.
</p>
<textarea
readonly
aria-label="Host installation command"
+23 -9
View File
@@ -1,17 +1,31 @@
import { error } from '@sveltejs/kit';
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
export async function GET({ params }: { params: { file: string } }) {
if (!['ec-agent.py', 'ec-agent-update.py', 'manifest.json'].includes(params.file))
if (
!['ec-agent.py', 'ec-agent-update.py', 'manifest.json', 'manifest.json.sig'].includes(
params.file
)
)
error(404, 'Release asset not found.');
return new Response(
await agentReleaseAsset(
params.file as 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'
),
{
let content: string;
try {
content = await agentReleaseAsset(
params.file as
'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json' | 'manifest.json.sig'
);
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code === 'ENOENT')
error(404, 'Release asset not found.');
throw cause;
}
return new Response(content, {
headers: {
'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain',
'Content-Type': params.file.endsWith('.sig')
? 'application/pgp-signature'
: params.file.endsWith('.json')
? 'application/json'
: 'text/plain',
'Cache-Control': 'no-cache'
}
}
);
});
}
+5
View File
@@ -608,6 +608,11 @@
>
</div>
{#if enrollment}<p>Expires {$timeFormat.dateTime(enrollment.expiresAt)}</p>
<p>
This command downloads the installer from the public Electric Cherry
repository. The host fetches its public signing key from that
repository during installation and before every update check.
</p>
<pre>{enrollment.command}</pre>{/if}{:else}<p>
Administrators manage enrollment and agent credentials.
</p>{/if}
+82 -32
View File
@@ -19,13 +19,16 @@ import re
import stat
import subprocess
import sys
import tempfile
import time
import urllib.parse
import urllib.request
import uuid
UPDATER_PROTOCOL_VERSION = 1
UPDATER_PROTOCOL_VERSION = 2
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
# 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")
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
@@ -36,6 +39,8 @@ UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
AGENT_SERVICE = "electric-cherry-agent.service"
MAX_MANIFEST = 16 * 1024
MAX_SIGNATURE = 16 * 1024
MAX_KEYRING = 256 * 1024
MAX_SOURCE = 8 * 1024 * 1024
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})$")
@@ -150,6 +155,72 @@ def source_version(source):
return declarations[0]
def fetch_url(url, limit, source):
request = urllib.request.Request(url, headers={"User-Agent": "electric-cherry-updater/2", "Cache-Control": "no-cache"})
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(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:
raise IntegrityError("The controller release requires a detached manifest signature")
trusted(UPDATE_DIR, directory=True)
# An isolated home excludes any ambient GnuPG keyring or configuration.
# The exact downloaded bytes are verified before manifest JSON is parsed.
with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary:
home = Path(temporary)
keyring = home / "release-signing.gpg"
keyring.write_bytes(public_key)
manifest_path = home / "manifest.json"
signature_path = home / "manifest.json.sig"
manifest_path.write_bytes(manifest)
signature_path.write_bytes(signature)
try:
result = subprocess.run(["gpgv", "--homedir", str(home), "--keyring", str(keyring), str(signature_path), str(manifest_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, check=False)
except (OSError, subprocess.TimeoutExpired):
raise IntegrityError("Release signature verification is unavailable; install or repair gpgv on this host") from None
if result.returncode:
raise IntegrityError("The release manifest signature is invalid or is not signed by a trusted release key")
@contextlib.contextmanager
def exclusive_lock(path):
trusted(path, missing=True)
@@ -242,40 +313,19 @@ class Updater:
return state
def fetch(self, path, limit):
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/1"})
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
return fetch_url(self.installation["controller"] + path, limit, "controller release")
def manifest(self):
public_key = fetch_release_key()
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
try:
value = json.loads(self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST))
signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE)
except ContactError:
raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None
self.ensure_directory()
verify_release_signature(manifest, signature, public_key)
try:
value = json.loads(manifest)
except (ValueError, UnicodeError):
raise IntegrityError("The controller release manifest is invalid") from None
if not isinstance(value, dict) or type(value.get("protocolVersion")) is not int or value["protocolVersion"] != 1 or value.get("platform") != "debian-amd64":
+46 -6
View File
@@ -33,7 +33,7 @@ command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux gpgv
installation_preflight() {
if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then
echo 'The existing enrollment disappeared during installation. Stop and inspect host deletion before trying again.' >&2
@@ -142,7 +142,7 @@ PY
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
if [ -d "$data_dir" ]; then existing_data_root=true; fi
installation_preflight
temporary_dir=$(mktemp -d)
temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
updater_timer_was_active=false
update_lock_held=false
activity_lock_held=false
@@ -167,21 +167,61 @@ installation_cleanup() {
exit "$ec_install_status"
}
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%/}"
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/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"
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" <<'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.
import ast, hashlib, json, pathlib, re, sys
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
manifest_path = pathlib.Path(sys.argv[1])
manifest_bytes = manifest_path.read_bytes()
signature = manifest_path.with_name('manifest.json.sig')
keyring = pathlib.Path(sys.argv[4])
# 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()
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:
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:
raise SystemExit('Release signing metadata exceeds its size limit.')
try:
with tempfile.TemporaryDirectory(prefix='gpgv-', dir=manifest_path.parent) as home:
verified = subprocess.run(['gpgv', '--homedir', home, '--keyring', str(keyring),
str(signature), '-'], input=manifest_bytes,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=30, check=False)
except (OSError, subprocess.TimeoutExpired):
raise SystemExit('Release signature verification requires a working gpgv installation.') from None
if verified.returncode:
raise SystemExit('Release signature verification failed. The installed agent was left unchanged.')
manifest = json.loads(manifest_bytes)
if not isinstance(manifest, dict):
raise SystemExit('Unsupported agent or updater release manifest.')
agent = pathlib.Path(sys.argv[2]).read_bytes()
updater = pathlib.Path(sys.argv[3]).read_bytes()
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
raise SystemExit('Agent release checksum verification failed.')
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
raise SystemExit('Updater release checksum verification failed.')
if manifest.get('platform') != 'debian-amd64' or manifest.get('protocolVersion') != 1 or manifest.get('updaterProtocolVersion') != 1:
if manifest.get('platform') != 'debian-amd64' or type(manifest.get('protocolVersion')) is not int or manifest['protocolVersion'] != 1 or type(manifest.get('updaterProtocolVersion')) is not int or manifest['updaterProtocolVersion'] != 2:
raise SystemExit('Unsupported agent or updater release manifest.')
versions = [node.value.value for node in ast.parse(agent).body
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets)
+3 -2
View File
@@ -3,6 +3,7 @@
"protocolVersion": 1,
"platform": "debian-amd64",
"sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701",
"updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
"updaterProtocolVersion": 2,
"updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
"installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
}
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSpocb6VBfQzvdIh9HF6fdSNniN+gUCasmr1RsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwwLDMACgkQxen3UjZ4jfprZQD+PPqrtEYWTImhdy1eFAfv
k5XD1IaFOgSvcsL9P0tj69IBAIRGt3zqWw9E8vFeyZXz58GfnscyFI6ws5ln+l3h
i2UP
=Er1r
-----END PGP SIGNATURE-----
+2 -1
View File
@@ -499,7 +499,8 @@ describe('machine and route identity boundaries', () => {
'/agent/ec-agent.py',
'/agent/ec-agent-update.py',
'/agent/install.sh',
'/agent/manifest.json'
'/agent/manifest.json',
'/agent/manifest.json.sig'
]) {
const call = requestFor(null, { path });
expect((await call.invoke()).status).toBe(200);
+26 -7
View File
@@ -24,7 +24,8 @@ describe('public agent release artifacts', () => {
'install.sh',
'ec-agent.py',
'ec-agent-update.py',
'manifest.json'
'manifest.json',
'manifest.json.sig'
] as const) {
expect(await agentReleaseAsset(file)).toBe('fresh-release');
}
@@ -32,7 +33,8 @@ describe('public agent release artifacts', () => {
resolve('static/agent/install.sh'),
resolve('static/agent/ec-agent.py'),
resolve('static/agent/ec-agent-update.py'),
resolve('static/agent/manifest.json')
resolve('static/agent/manifest.json'),
resolve('static/agent/manifest.json.sig')
]);
});
@@ -54,7 +56,8 @@ describe('public agent release artifacts', () => {
'install.sh',
'ec-agent.py',
'ec-agent-update.py',
'manifest.json'
'manifest.json',
'manifest.json.sig'
] as const) {
expect(await agentReleaseAsset(file)).toBe('packaged-release');
}
@@ -62,15 +65,18 @@ describe('public agent release artifacts', () => {
resolve('build/client/agent/install.sh'),
resolve('build/client/agent/ec-agent.py'),
resolve('build/client/agent/ec-agent-update.py'),
resolve('build/client/agent/manifest.json')
resolve('build/client/agent/manifest.json'),
resolve('build/client/agent/manifest.json.sig')
]);
});
it.each(['ec-agent.py', 'ec-agent-update.py', 'manifest.json'])(
it.each(['ec-agent.py', 'ec-agent-update.py', 'manifest.json', 'manifest.json.sig'])(
'serves the packaged %s without a browser session or host credential',
async (file) => {
const content = file.endsWith('.json')
? '{"version":"1.2.0","updaterProtocolVersion":1}'
? '{"version":"1.2.0","updaterProtocolVersion":2}'
: file.endsWith('.sig')
? '-----BEGIN PGP SIGNATURE-----\nrelease-signature\n-----END PGP SIGNATURE-----\n'
: '# verified release source\n';
files.readFile.mockResolvedValue(content);
const response = await GET({ params: { file } });
@@ -78,7 +84,11 @@ describe('public agent release artifacts', () => {
expect(await response.text()).toBe(content);
expect(response.headers.get('cache-control')).toBe('no-cache');
expect(response.headers.get('content-type')).toContain(
file.endsWith('.json') ? 'application/json' : 'text/plain'
file.endsWith('.sig')
? 'application/pgp-signature'
: file.endsWith('.json')
? 'application/json'
: 'text/plain'
);
expect(files.readFile.mock.calls).toEqual([
[resolve('build/client/agent', file), 'utf8']
@@ -86,6 +96,13 @@ describe('public agent release artifacts', () => {
}
);
it('returns 404 for an unsigned release without generating a signature', async () => {
files.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
await expect(GET({ params: { file: 'manifest.json.sig' } })).rejects.toMatchObject({
status: 404
});
});
it('serves the updater from development assets when no packaged asset exists', async () => {
files.readFile
.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
@@ -111,6 +128,8 @@ describe('public agent release artifacts', () => {
'update-status.json',
'config.json',
'install.sh',
'manifest.json.sig/anything',
'release-signing.key',
'ec-agent-update.py/anything'
])('rejects non-release file %s before reading disk', async (file) => {
await expect(GET({ params: { file } })).rejects.toMatchObject({ status: 404 });
+7
View File
@@ -38,6 +38,13 @@ async function enrolled() {
}
describe('native host enrollment and scoped authentication', () => {
it('downloads the installer from the fixed repository and quotes the enrollment token', async () => {
const { installerCommand } = await import('../../src/lib/server/hosts/http.js');
expect(installerCommand('https://controller.example/hosts/new', "token'quote")).toBe(
"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'"
);
});
it.each(['instance.inspect', 'rcon.status-batch'])(
'expires undelivered %s on timeout and delivers the next fresh check',
async (kind) => {