Compare commits
3
Commits
df0d395138
...
44e755a166
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e755a166 | ||
|
|
a93ddb5ef9 | ||
|
|
6f71822d41 |
@@ -10,3 +10,7 @@ archive
|
|||||||
*.log
|
*.log
|
||||||
test-fixtures
|
test-fixtures
|
||||||
data
|
data
|
||||||
|
# Offline release signing material must never enter the build context.
|
||||||
|
**/.gnupg
|
||||||
|
**/private-keys-v1.d
|
||||||
|
**/secring.gpg
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Install Python and native build tools
|
- name: Install Python and native build tools
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
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
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
+82
-32
@@ -19,13 +19,16 @@ import re
|
|||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
UPDATER_PROTOCOL_VERSION = 1
|
UPDATER_PROTOCOL_VERSION = 2
|
||||||
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
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")
|
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
|
||||||
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
|
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
|
||||||
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
|
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
|
||||||
@@ -36,6 +39,8 @@ UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
|
|||||||
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
||||||
AGENT_SERVICE = "electric-cherry-agent.service"
|
AGENT_SERVICE = "electric-cherry-agent.service"
|
||||||
MAX_MANIFEST = 16 * 1024
|
MAX_MANIFEST = 16 * 1024
|
||||||
|
MAX_SIGNATURE = 16 * 1024
|
||||||
|
MAX_KEYRING = 256 * 1024
|
||||||
MAX_SOURCE = 8 * 1024 * 1024
|
MAX_SOURCE = 8 * 1024 * 1024
|
||||||
HEALTH_TIMEOUT = 90
|
HEALTH_TIMEOUT = 90
|
||||||
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
|
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
|
||||||
@@ -150,6 +155,72 @@ def source_version(source):
|
|||||||
return declarations[0]
|
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
|
@contextlib.contextmanager
|
||||||
def exclusive_lock(path):
|
def exclusive_lock(path):
|
||||||
trusted(path, missing=True)
|
trusted(path, missing=True)
|
||||||
@@ -242,40 +313,19 @@ class Updater:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def fetch(self, path, limit):
|
def fetch(self, path, limit):
|
||||||
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/1"})
|
return fetch_url(self.installation["controller"] + path, limit, "controller release")
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
||||||
raise ContactError("Agent release redirects are not allowed")
|
|
||||||
try:
|
|
||||||
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
raise ContactError("The controller did not return a complete agent release")
|
|
||||||
length = response.headers.get("Content-Length")
|
|
||||||
if length is not None and (not length.isdecimal() or int(length) > limit):
|
|
||||||
raise IntegrityError("The controller release exceeds its download size limit")
|
|
||||||
chunks, total = [], 0
|
|
||||||
deadline = time.monotonic() + 120
|
|
||||||
while True:
|
|
||||||
if time.monotonic() >= deadline:
|
|
||||||
raise ContactError("The controller release download timed out")
|
|
||||||
# HTTPResponse.read1 performs one socket read. read(n) can
|
|
||||||
# wait indefinitely for n bytes from a trickling server.
|
|
||||||
block = response.read1(min(64 * 1024, limit + 1 - total))
|
|
||||||
if not block:
|
|
||||||
break
|
|
||||||
total += len(block)
|
|
||||||
if total > limit:
|
|
||||||
raise IntegrityError("The controller release exceeds its download size limit")
|
|
||||||
chunks.append(block)
|
|
||||||
if length is not None and total != int(length):
|
|
||||||
raise ContactError("The controller release download was interrupted")
|
|
||||||
return b"".join(chunks)
|
|
||||||
except (OSError, ValueError, http.client.HTTPException):
|
|
||||||
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
|
|
||||||
|
|
||||||
def manifest(self):
|
def manifest(self):
|
||||||
|
public_key = fetch_release_key()
|
||||||
|
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
|
||||||
try:
|
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):
|
except (ValueError, UnicodeError):
|
||||||
raise IntegrityError("The controller release manifest is invalid") from None
|
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":
|
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
@@ -33,7 +33,7 @@ command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
|
|||||||
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update -qq
|
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() {
|
installation_preflight() {
|
||||||
if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then
|
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
|
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 [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
|
||||||
if [ -d "$data_dir" ]; then existing_data_root=true; fi
|
if [ -d "$data_dir" ]; then existing_data_root=true; fi
|
||||||
installation_preflight
|
installation_preflight
|
||||||
temporary_dir=$(mktemp -d)
|
temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
|
||||||
updater_timer_was_active=false
|
updater_timer_was_active=false
|
||||||
update_lock_held=false
|
update_lock_held=false
|
||||||
activity_lock_held=false
|
activity_lock_held=false
|
||||||
@@ -167,21 +167,61 @@ installation_cleanup() {
|
|||||||
exit "$ec_install_status"
|
exit "$ec_install_status"
|
||||||
}
|
}
|
||||||
trap installation_cleanup EXIT
|
trap installation_cleanup EXIT
|
||||||
|
# INSTALLATION_TRUST: always fetch directly from the repository, never the controller.
|
||||||
|
key_status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
|
||||||
|
--connect-timeout 25 --max-time 120 --max-filesize 262144 \
|
||||||
|
--header 'Cache-Control: no-cache' --write-out '%{http_code}' \
|
||||||
|
'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg' \
|
||||||
|
-o "$temporary_dir/release-signing.gpg")
|
||||||
|
[ "$key_status" = 200 ] && [ -s "$temporary_dir/release-signing.gpg" ] || {
|
||||||
|
echo 'The release-signing repository did not return a public key. Installation stopped.' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# INSTALLATION_TRUST_END
|
||||||
controller="${controller%/}"
|
controller="${controller%/}"
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
|
||||||
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig"
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
|
||||||
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" <<'PY'
|
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" "$temporary_dir/release-signing.gpg" <<'PY'
|
||||||
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
|
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
|
||||||
import ast, hashlib, json, pathlib, re, sys
|
import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
|
||||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
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()
|
agent = pathlib.Path(sys.argv[2]).read_bytes()
|
||||||
updater = pathlib.Path(sys.argv[3]).read_bytes()
|
updater = pathlib.Path(sys.argv[3]).read_bytes()
|
||||||
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
|
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
|
||||||
raise SystemExit('Agent release checksum verification failed.')
|
raise SystemExit('Agent release checksum verification failed.')
|
||||||
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
|
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
|
||||||
raise SystemExit('Updater release checksum verification failed.')
|
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.')
|
raise SystemExit('Unsupported agent or updater release manifest.')
|
||||||
versions = [node.value.value for node in ast.parse(agent).body
|
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)
|
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets)
|
||||||
|
|||||||
+26
-10
@@ -5,14 +5,11 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
|
|
||||||
|
|
||||||
def package(source, target):
|
def manifest_bytes(agent, updater, installer):
|
||||||
"""Read version metadata without importing or executing release code."""
|
"""Return the deterministic bytes signed by the offline release signer."""
|
||||||
source, target = Path(source), Path(target)
|
# Inspect source without importing or executing release code.
|
||||||
agent = (source / "ec_agent.py").read_bytes()
|
|
||||||
updater = (source / "ec_agent_update.py").read_bytes()
|
|
||||||
versions = [node.value.value for node in ast.parse(agent).body
|
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)
|
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)]
|
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(agent, "ec_agent.py", "exec")
|
||||||
compile(updater, "ec_agent_update.py", "exec")
|
compile(updater, "ec_agent_update.py", "exec")
|
||||||
manifest = {"version": versions[0], "protocolVersion": 1, "platform": "debian-amd64",
|
manifest = {"version": versions[0], "protocolVersion": 1, "platform": "debian-amd64",
|
||||||
"sha256": hashlib.sha256(agent).hexdigest(), "updaterProtocolVersion": 1,
|
"sha256": hashlib.sha256(agent).hexdigest(), "updaterProtocolVersion": 2,
|
||||||
"updaterSha256": hashlib.sha256(updater).hexdigest()}
|
"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)
|
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.py").write_bytes(agent)
|
||||||
(target / "ec-agent-update.py").write_bytes(updater)
|
(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 = 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")
|
temporary_manifest.replace(target / "manifest.json")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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()
|
||||||
@@ -11,7 +11,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
|
||||||
SOURCE = (Path(__file__).resolve().parents[1] / "install.sh").read_text()
|
SOURCE = (Path(__file__).resolve().parents[1] / "install.sh").read_text()
|
||||||
@@ -33,6 +33,8 @@ class InstallerTests(unittest.TestCase):
|
|||||||
self.root = self.base / "managed"
|
self.root = self.base / "managed"
|
||||||
self.config = self.base / "configuration" / "agent.json"
|
self.config = self.base / "configuration" / "agent.json"
|
||||||
self.config.parent.mkdir()
|
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.controller = "https://controller.example"
|
||||||
self.saved = {"root": str(self.root), "controller": self.controller,
|
self.saved = {"root": str(self.root), "controller": self.controller,
|
||||||
"hostId": "host-one", "credential": "private"}
|
"hostId": "host-one", "credential": "private"}
|
||||||
@@ -80,6 +82,10 @@ class InstallerTests(unittest.TestCase):
|
|||||||
self.preflight()
|
self.preflight()
|
||||||
self.assertEqual((self.root / "unrelated.txt").read_text(), "keep")
|
self.assertEqual((self.root / "unrelated.txt").read_text(), "keep")
|
||||||
|
|
||||||
|
def test_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):
|
def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self):
|
||||||
self.legacy_installation()
|
self.legacy_installation()
|
||||||
original_config = self.config.read_bytes()
|
original_config = self.config.read_bytes()
|
||||||
@@ -334,23 +340,74 @@ docker() {
|
|||||||
self.assertNotIn("Docker is installed but unavailable", result.stderr)
|
self.assertNotIn("Docker is installed but unavailable", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryTrustTests(unittest.TestCase):
|
||||||
|
def fetch(self, status="200", body="public key", curl_exit=0):
|
||||||
|
block = SOURCE[SOURCE.index("# INSTALLATION_TRUST:"):SOURCE.index("# INSTALLATION_TRUST_END")]
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
script = r'''set -euo pipefail
|
||||||
|
curl() {
|
||||||
|
printf '%s\n' "$@" > "$temporary_dir/curl-args"
|
||||||
|
printf '%s' "$EC_TEST_BODY" > "$temporary_dir/release-signing.gpg"
|
||||||
|
printf '%s' "$EC_TEST_STATUS"
|
||||||
|
return "$EC_TEST_EXIT"
|
||||||
|
}
|
||||||
|
''' + block + "\nprintf 'verified-fetch'"
|
||||||
|
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
|
||||||
|
env={**os.environ, "temporary_dir": temporary, "EC_TEST_BODY": body,
|
||||||
|
"EC_TEST_STATUS": status, "EC_TEST_EXIT": str(curl_exit)})
|
||||||
|
args = (Path(temporary) / "curl-args").read_text().splitlines()
|
||||||
|
return result, args
|
||||||
|
|
||||||
|
def test_installer_fetches_fixed_repository_key_with_bounds_and_tls(self):
|
||||||
|
result, args = self.fetch()
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout, "verified-fetch")
|
||||||
|
self.assertIn("https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg", args)
|
||||||
|
for option, value in (("--proto", "=https"), ("--max-filesize", "262144"),
|
||||||
|
("--max-time", "120"), ("--connect-timeout", "25"),
|
||||||
|
("--header", "Cache-Control: no-cache")):
|
||||||
|
self.assertEqual(args[args.index(option) + 1], value)
|
||||||
|
self.assertNotIn("--location", args)
|
||||||
|
self.assertNotIn("--insecure", args)
|
||||||
|
self.assertLess(SOURCE.index("# INSTALLATION_TRUST:"), SOURCE.index('$controller/api/agent/release/manifest.json'))
|
||||||
|
|
||||||
|
def test_repository_failure_redirect_or_empty_key_stops_installation(self):
|
||||||
|
for status, body, code in (("302", "redirect", 0), ("404", "missing", 22),
|
||||||
|
("200", "", 0), ("200", "partial", 28), ("200", "oversized", 63)):
|
||||||
|
with self.subTest(status=status, body=body):
|
||||||
|
result, _ = self.fetch(status, body, code)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertNotIn("verified-fetch", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
class ReleaseTests(unittest.TestCase):
|
class ReleaseTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.temporary = tempfile.TemporaryDirectory()
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
self.source = Path(self.temporary.name) / "source"
|
self.source = Path(self.temporary.name).resolve() / "source"
|
||||||
self.target = Path(self.temporary.name) / "release"
|
self.target = Path(self.temporary.name).resolve() / "release"
|
||||||
self.source.mkdir()
|
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.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 / "ec_agent_update.py").write_text('raise RuntimeError("must never execute during verification")\n')
|
||||||
(self.source / "install.sh").write_text("#!/bin/bash\n")
|
(self.source / "install.sh").write_text("#!/bin/bash\n")
|
||||||
PACKAGER.package(self.source, self.target)
|
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):
|
def tearDown(self):
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
|
|
||||||
def verify(self):
|
def verify(self):
|
||||||
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py")]
|
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py"), str(self.keyring)]
|
||||||
with patch.object(sys, "argv", argv):
|
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"), {})
|
exec(compile(RELEASE, "installer-release", "exec"), {})
|
||||||
|
|
||||||
def manifest(self, **changes):
|
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):
|
def test_package_derives_version_and_hashes_both_sources_without_executing_them(self):
|
||||||
manifest = self.manifest()
|
manifest = self.manifest()
|
||||||
self.assertEqual(manifest["version"], "3.12.7")
|
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["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(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())
|
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)
|
path.write_bytes(original)
|
||||||
|
|
||||||
def test_installer_rejects_missing_or_unsupported_updater_protocol(self):
|
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)
|
self.manifest(updaterProtocolVersion=protocol)
|
||||||
with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"):
|
with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"):
|
||||||
self.verify()
|
self.verify()
|
||||||
|
|
||||||
|
def test_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):
|
def test_installer_rejects_manifest_version_mismatch_and_invalid_code(self):
|
||||||
self.manifest(version="3.12.8")
|
self.manifest(version="3.12.8")
|
||||||
with self.assertRaisesRegex(SystemExit, "version does not match"):
|
with self.assertRaisesRegex(SystemExit, "version does not match"):
|
||||||
|
|||||||
@@ -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
@@ -5,7 +5,9 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
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():
|
if directory and not path.is_dir() or not directory and not path.is_file():
|
||||||
raise module.UpdateError("Wrong file type")
|
raise module.UpdateError("Wrong file type")
|
||||||
return path
|
return path
|
||||||
self.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:
|
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 = {"hostId": "host-one", "root": str(self.root), "controller": "https://controller.example", "credential": "secret-enrollment-credential"}
|
||||||
self.config_bytes = json.dumps(self.config).encode()
|
self.config_bytes = json.dumps(self.config).encode()
|
||||||
module.AGENT_CONFIG.write_bytes(self.config_bytes)
|
module.AGENT_CONFIG.write_bytes(self.config_bytes)
|
||||||
@@ -78,7 +87,8 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.write_health = True
|
self.write_health = True
|
||||||
self.downloads = []
|
self.downloads = []
|
||||||
self.source = CANDIDATE
|
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):
|
def tearDown(self):
|
||||||
for item in reversed(self.patches):
|
for item in reversed(self.patches):
|
||||||
@@ -108,6 +118,10 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.downloads.append(path)
|
self.downloads.append(path)
|
||||||
if path.endswith("manifest.json"):
|
if path.endswith("manifest.json"):
|
||||||
return json.dumps(self.release).encode()
|
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"):
|
if path.endswith("ec-agent.py"):
|
||||||
return self.source
|
return self.source
|
||||||
self.fail("Unexpected download " + path)
|
self.fail("Unexpected download " + path)
|
||||||
@@ -128,6 +142,7 @@ class UpdateTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_verified_upgrade_waits_for_authenticated_candidate_health(self):
|
def test_verified_upgrade_waits_for_authenticated_candidate_health(self):
|
||||||
self.assertEqual(self.updater().run(), "updated")
|
self.assertEqual(self.updater().run(), "updated")
|
||||||
|
self.signature_verifier.assert_called_once_with(json.dumps(self.release).encode(), self.signature, self.public_key)
|
||||||
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
|
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
|
||||||
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE)
|
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE)
|
||||||
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
|
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
|
||||||
@@ -137,6 +152,36 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.assertFalse((module.UPDATE_DIR / "agent.backup.py").exists())
|
self.assertFalse((module.UPDATE_DIR / "agent.backup.py").exists())
|
||||||
self.assertEqual(next(command for command in self.commands if command[0] != "show"), ("stop", module.AGENT_SERVICE))
|
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):
|
def test_equal_or_older_versions_do_not_download_or_restart(self):
|
||||||
for target in ("1.0.0", "0.9.9"):
|
for target in ("1.0.0", "0.9.9"):
|
||||||
with self.subTest(target=target):
|
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.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
|
||||||
self.assertEqual(self.commands, [])
|
self.assertEqual(self.commands, [])
|
||||||
|
|
||||||
|
def test_every_check_fetches_key_before_controller_even_without_a_new_version(self):
|
||||||
|
self.release["version"] = "1.0.0"
|
||||||
|
keys = [b"first public key", b"replacement public key"]
|
||||||
|
self.fetch_key.side_effect = keys
|
||||||
|
updater = self.updater()
|
||||||
|
def controller_fetch(path, limit):
|
||||||
|
self.assertGreater(self.fetch_key.call_count, self.signature_verifier.call_count)
|
||||||
|
return self.fetch(path, limit)
|
||||||
|
updater.fetch = controller_fetch
|
||||||
|
for _ in keys:
|
||||||
|
self.assertEqual(updater.run(), "current")
|
||||||
|
self.assertEqual(self.fetch_key.call_count, 2)
|
||||||
|
self.assertEqual([call.args[2] for call in self.signature_verifier.call_args_list], keys)
|
||||||
|
self.assert_unchanged()
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
|
||||||
|
def test_repository_failure_never_uses_old_host_key_or_contacts_controller(self):
|
||||||
|
(self.config_dir / "release-signing.gpg").write_bytes(b"old trusted key")
|
||||||
|
self.fetch_key.side_effect = module.ContactError("Repository unavailable")
|
||||||
|
self.assertEqual(self.updater().run(), "deferred")
|
||||||
|
self.assert_unchanged()
|
||||||
|
self.assertEqual(self.downloads, [])
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
self.assertTrue(self.active)
|
||||||
|
self.signature_verifier.assert_not_called()
|
||||||
|
|
||||||
def test_busy_operation_defers_update_without_stopping_agent(self):
|
def test_busy_operation_defers_update_without_stopping_agent(self):
|
||||||
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
|
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
|
||||||
self.assertEqual(self.updater().run(), "deferred")
|
self.assertEqual(self.updater().run(), "deferred")
|
||||||
@@ -207,7 +278,7 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.downloads.clear()
|
self.downloads.clear()
|
||||||
self.source = CANDIDATE
|
self.source = CANDIDATE
|
||||||
self.assertEqual(self.updater().run(), "failed")
|
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):
|
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'):
|
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):
|
def test_incompatible_protocol_platform_or_version_never_downloads_code(self):
|
||||||
original = dict(self.release)
|
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):
|
with self.subTest(key=key, value=value):
|
||||||
self.release = {**original, key: value}
|
self.release = {**original, key: value}
|
||||||
self.assertIn(self.updater().run(), ("failed", "deferred"))
|
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.assertEqual(sum(command == ("start", module.AGENT_SERVICE) for command in self.commands), 2)
|
||||||
self.downloads.clear()
|
self.downloads.clear()
|
||||||
self.assertEqual(self.updater().run(), "failed")
|
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):
|
def test_changed_artifact_of_failed_target_can_be_retried(self):
|
||||||
updater = self.updater()
|
updater = self.updater()
|
||||||
@@ -266,9 +337,15 @@ class UpdateTests(unittest.TestCase):
|
|||||||
def test_crash_between_executable_replacements_recovers_from_backups(self):
|
def test_crash_between_executable_replacements_recovers_from_backups(self):
|
||||||
self.crash_during_second_replacement()
|
self.crash_during_second_replacement()
|
||||||
self.downloads.clear()
|
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.assertEqual(self.updater().run(), "failed")
|
||||||
self.assert_unchanged()
|
self.assert_unchanged()
|
||||||
self.assertEqual(self.downloads, [])
|
self.assertEqual(self.downloads, [])
|
||||||
|
self.signature_verifier.assert_not_called()
|
||||||
|
self.fetch_key.assert_not_called()
|
||||||
self.assertTrue(self.active)
|
self.assertTrue(self.active)
|
||||||
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
||||||
|
|
||||||
@@ -414,5 +491,163 @@ class UpdateSecurityTests(unittest.TestCase):
|
|||||||
module.version(value)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
The controller uses SvelteKit, TypeScript and SQLite (`better-sqlite3`), with the
|
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
|
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
|
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
|
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
|
## 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
|
```sh
|
||||||
npm ci
|
npm ci
|
||||||
@@ -89,8 +92,10 @@ npm run build
|
|||||||
|
|
||||||
The [Gitea Actions workflow](../../.gitea/workflows/ci.yml) runs these checks on
|
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
|
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
|
with Python 3, `gpg`, `gpg-agent`, `gpgv` and native build tools for `better-sqlite3`. Signing
|
||||||
Docker jobs with the `ubuntu-latest` label. `npm run check` runs before the tests
|
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
|
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.
|
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,
|
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`.
|
without `--omit=dev`.
|
||||||
|
|
||||||
`npm run dev` and `npm run build` regenerate the downloadable agent, installer,
|
`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
|
under `build/reports` and launch preparation under `build/launch`. Development
|
||||||
and test commands compile those workers automatically. Restart `npm run dev`
|
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
|
after changing agent or worker code, or run `npm run agent:package` to refresh
|
||||||
|
|||||||
@@ -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.
|
- A controller machine with Docker Compose and an HTTPS reverse proxy.
|
||||||
- Debian 12 or 13 amd64 game hosts with storage for game files and instances.
|
- Debian 12 or 13 amd64 game hosts with storage for game files and instances.
|
||||||
|
- A signed agent release 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.
|
- Your complete T5 or T6 game folders, including the DLC you intend to host.
|
||||||
- Plutonium server keys for each game, with one unused key per new concurrent lobby.
|
- Plutonium server keys for each game, with one unused key per new concurrent lobby.
|
||||||
|
|
||||||
@@ -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
|
defaults to `/root/zombies/.electric-cherry`; choose a dedicated directory for a
|
||||||
new controller. See [.env.example](../../.env.example) for the other settings.
|
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
|
```sh
|
||||||
docker compose up -d --build
|
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
|
1. Open **Releases → Game files** and upload the complete local folder for T5 or
|
||||||
T6, including DLC. Uploads can run before any host is enrolled. The latest
|
T6, including DLC. Uploads can run before any host is enrolled. The latest
|
||||||
verified upload becomes that game's default files.
|
verified upload becomes that game's default files.
|
||||||
2. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and
|
2. Ensure the release publisher has [published its public key and signed release](native-hosts.md#publishing-signed-releases).
|
||||||
bind address, and run the generated enrollment command on the host.
|
Hosts fetch the key automatically during installation and before every update
|
||||||
3. Wait for automatic setup to prepare the Plutainer runtime and available game
|
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.
|
files. The host page reports download, verification, and readiness progress.
|
||||||
|
|
||||||
The installer sets up the Python agent as a systemd service and enables automatic
|
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
|
agent updates. It installs Docker if needed. Multiple instances share read-only
|
||||||
base game files; each instance has its own writable data. See
|
base game files; each instance has its own writable data. See
|
||||||
[Native hosts](native-hosts.md) for storage layout, network access, and recovery.
|
[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
|
## Launch your first server
|
||||||
|
|
||||||
|
|||||||
+187
-39
@@ -12,9 +12,11 @@ the agent owns local execution and its durable operation journal.
|
|||||||
and roles; agent endpoints authenticate their own host credentials. See
|
and roles; agent endpoints authenticate their own host credentials. See
|
||||||
[Users and access](authentication.md) for setup and password recovery.
|
[Users and access](authentication.md) for setup and password recovery.
|
||||||
2. Open **Hosts → Add Host**, choose a name, local game-port range and bind address,
|
2. Open **Hosts → Add Host**, choose a name, local game-port range and bind address,
|
||||||
and generate an enrollment. Run the displayed command on Debian 12/13 amd64.
|
and generate an enrollment. Run the displayed command on the Debian 12/13
|
||||||
3. The installer validates the platform, installs Python 3 and an absent Docker
|
amd64 host. It downloads the installer directly from the public repository;
|
||||||
installation, verifies the downloaded agent and updater SHA-256 over HTTPS,
|
[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
|
enrolls it and enables `electric-cherry-agent.service` and its automatic update
|
||||||
timer. It needs no Git, Node.js or local build.
|
timer. It needs no Git, Node.js or local build.
|
||||||
4. In **Game Files**, upload your complete local folder under T5 or T6. Include
|
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
|
A fresh destination host downloads centrally stored game files without manual
|
||||||
copying or per-container setup. EC does not supply the game content itself.
|
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
|
### Docker installation
|
||||||
|
|
||||||
The installer uses Debian's Docker packages: `docker.io` on Debian 12, plus
|
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
|
## Automatic agent updates
|
||||||
|
|
||||||
New installations enable automatic agent updates by default. Each host checks
|
New installations enable automatic agent updates by default. Each host checks
|
||||||
its controller's release over HTTPS about every five minutes, beginning about
|
the repository public key and then its controller's release over HTTPS about
|
||||||
two minutes after boot, with up to 30 seconds of jitter. Updating the controller
|
every five minutes, beginning about two minutes after boot, with up to 30 seconds
|
||||||
therefore makes its compatible, newer agent release available to enrolled hosts.
|
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.
|
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
|
**Existing hosts must migrate to updater protocol 2 with the trusted installer.**
|
||||||
access**, generate an installer command and rerun it on the same machine, with
|
Protocol 1 bootstraps trust controller-provided checksums and remain exposed to
|
||||||
the same controller and data directory. Do not use `--reenroll` for this upgrade.
|
controller-forged updates until this migration is complete. Publishing a signed
|
||||||
The installer preserves host identity, credentials, game files and running
|
release does not upgrade those bootstraps: they request an installer rerun when
|
||||||
containers. Agent version 1.2.0 includes automatic update support; the host page
|
they see protocol 2, but a compromised controller could still serve protocol 1.
|
||||||
shows **Setup required** until a fresh heartbeat confirms the updater is installed.
|
|
||||||
|
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
|
The **Automatic agent updates** panel reports the running version, target
|
||||||
version, last check, progress and any error. Offline hosts show their last
|
version, last check, progress and any error. Offline hosts show their last
|
||||||
reported status. Deferred updates report why they are waiting: for example an
|
reported status. Deferred updates report why they are waiting: for example an
|
||||||
active agent operation, an unavailable controller or a newer updater bootstrap.
|
active agent operation, an unavailable repository/controller or a newer updater bootstrap.
|
||||||
Checks also defer while the agent service is stopped; an update does not start a
|
Checks also defer while the agent service is stopped; an update does not start a
|
||||||
service that was stopped outside the update workflow.
|
service that was stopped outside the update workflow.
|
||||||
The independent updater holds the same execution lock, verifies the release
|
The independent updater holds the same execution lock, authenticates the exact
|
||||||
checksum, version and supported protocol, replaces agent code, and restarts the
|
manifest bytes with the freshly fetched repository keyring before parsing them,
|
||||||
agent service. It never restarts Docker or game containers. It confirms an
|
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;
|
authenticated heartbeat from the new agent process before accepting the update;
|
||||||
failure restores the previous agent code. Its durable journal permits recovery
|
failure restores the previous agent code. Its durable journal permits recovery
|
||||||
after a host reboot or interrupted update.
|
after a host reboot or interrupted update.
|
||||||
|
|
||||||
The updater only installs newer compatible versions; rolling the controller back
|
The updater only installs newer compatible versions; replaying an older signed
|
||||||
does not downgrade agents. A rejected version/checksum pair stays blocked until
|
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
|
the controller publishes a different pair that is still newer than the installed
|
||||||
agent. Inspect the reported error when a host
|
agent. Inspect the reported error when a host
|
||||||
shows **Needs attention**. The updater itself is a stable bootstrap component:
|
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
|
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.
|
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
|
When publishing changed agent code, bump its numeric `VERSION` in
|
||||||
`agent/ec_agent.py` and rebuild the controller. Packaging derives the manifest
|
`agent/ec_agent.py`. Finalize and review all source changes before packaging and
|
||||||
version and checksums from the sources; an already installed version is never
|
signing. An already installed version is never replaced just because its bytes
|
||||||
replaced just because its bytes changed.
|
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
|
The installer places the updater at
|
||||||
`/usr/local/libexec/electric-cherry-update.py` and enables
|
`/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
|
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
|
`EC_AGENT_ALLOW_HTTP=true` is explicitly enabled for development. The agent's
|
||||||
insecure development option is limited to loopback. Public release assets contain
|
insecure development option is limited to loopback. Public release assets contain
|
||||||
no credentials. The release checksum is authenticated by the controller's HTTPS
|
no credentials. HTTPS protects transport; an offline publisher signature over the
|
||||||
origin; there is no separate offline release-signing infrastructure.
|
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
|
Browser sessions do not authorize agents. Similarly, host credentials never grant
|
||||||
access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`,
|
access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`,
|
||||||
@@ -397,9 +532,11 @@ explicit installer `--reenroll` when local credentials already exist.
|
|||||||
or `409` when the agent needs updating or unresolved ownership remains.
|
or `409` when the agent needs updating or unresolved ownership remains.
|
||||||
- `POST /api/agent/enroll`: consume enrollment token and return host credentials.
|
- `POST /api/agent/enroll`: consume enrollment token and return host credentials.
|
||||||
- `GET /api/agent/release/manifest.json`: public agent version, protocol and
|
- `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
|
- `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.
|
- `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches.
|
||||||
- `GET /api/agent/operations`: deliver/reconcile durable operation IDs.
|
- `GET /api/agent/operations`: deliver/reconcile durable operation IDs.
|
||||||
- `POST /api/agent/operations/:id`: authenticated acceptance, progress or result.
|
- `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,
|
host's downloaded game libraries, instance files, agent journal and configuration,
|
||||||
owned containers, and recorded runtime images unused by other containers. It
|
owned containers, and recorded runtime images unused by other containers. It
|
||||||
stops and removes the agent service and executable, automatic update timer,
|
stops and removes the agent service and executable, automatic update timer,
|
||||||
update service, updater executable 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
|
The controller hides the host and revokes its ordinary credentials only after
|
||||||
receiving the final uninstall receipt. Adding the machine again requires a new
|
receiving the final uninstall receipt. Adding the machine again requires a new
|
||||||
host enrollment.
|
host enrollment; the installer fetches release trust from the repository again.
|
||||||
|
|
||||||
Game folders uploaded through Electric Cherry belong to the controller. Those
|
Game folders uploaded through Electric Cherry belong to the controller. Those
|
||||||
original uploads remain available for other hosts and future deployments; only
|
original uploads remain available for other hosts and future deployments; only
|
||||||
@@ -834,14 +972,19 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
The build packages the agent, standalone updater and installer into the static
|
The build packages the agent, standalone updater and installer into the static
|
||||||
release, generates the agent/updater checksum manifest and serves those same
|
release, generates their checksum manifest and serves those same assets in the
|
||||||
assets in the Node-adapter image. Development serves the freshly packaged static
|
Node-adapter image. Sign production artifacts using the
|
||||||
release even when an older production build exists. The Python programs use only
|
[offline workflow](#publishing-signed-releases) before building. Development
|
||||||
the standard library.
|
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
|
Automated tests cover enrollment/authentication, rotation/revocation, host
|
||||||
readiness, scoped ports, immutable library publication, streaming/ranges,
|
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,
|
holds, controller restart/reconnect, ambiguous results, occupied campaign staging,
|
||||||
activation before images, collections and draining. Host deletion tests cover
|
activation before images, collections and draining. Host deletion tests cover
|
||||||
ownership guards, interrupted cleanup, uninstall receipts, credential revocation
|
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
|
rollout. Automated simulations do not establish successful deployment on real
|
||||||
game hosts. Run:
|
game hosts. Run:
|
||||||
|
|
||||||
- Installer on Debian 12 and 13 amd64, then rerun without changing identity/data.
|
- Publish the public key in the repository and run the generated enrollment
|
||||||
- Bootstrap an existing host with the current installer, then publish a newer
|
command on Debian 12 and 13 amd64 without a local keyring, then rerun without
|
||||||
compatible agent release. Verify a timer-driven update, its authenticated
|
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,
|
heartbeat and unchanged game containers. Exercise an active-operation hold,
|
||||||
bad checksum, failed startup/heartbeat rollback, controller downgrade, network
|
missing/invalid signatures, an unknown key, a forged manifest matching changed
|
||||||
loss and host reboot during update. Verify updater protocol mismatch requests
|
code, bad checksum, failed startup/heartbeat rollback, controller downgrade,
|
||||||
an installer rerun.
|
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.
|
- T5 and T6 deployments and a mixed collection from a fresh destination host.
|
||||||
- Warm the Plutonium runtime cache, then create and rebuild T5 and T6 lobbies;
|
- Warm the Plutonium runtime cache, then create and rebuild T5 and T6 lobbies;
|
||||||
verify the running revision and compare first-start downloads and launch times
|
verify the running revision and compare first-start downloads and launch times
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ function publicAsset(routeId: string | null, path: string, method: string) {
|
|||||||
'/agent/ec-agent.py',
|
'/agent/ec-agent.py',
|
||||||
'/agent/ec-agent-update.py',
|
'/agent/ec-agent-update.py',
|
||||||
'/agent/install.sh',
|
'/agent/install.sh',
|
||||||
'/agent/manifest.json'
|
'/agent/manifest.json',
|
||||||
|
'/agent/manifest.json.sig'
|
||||||
].includes(path))
|
].includes(path))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,5 +70,7 @@ export function hostApiError(cause: unknown): Response {
|
|||||||
export function installerCommand(controller: string, token: string): string {
|
export function installerCommand(controller: string, token: string): string {
|
||||||
const origin = new URL(controller).origin;
|
const origin = new URL(controller).origin;
|
||||||
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||||
return `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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { resolve } from 'node:path';
|
|||||||
|
|
||||||
/** adapter-node copies public assets to build/client; Vite serves static in development. */
|
/** adapter-node copies public assets to build/client; Vite serves static in development. */
|
||||||
export async function agentReleaseAsset(
|
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> {
|
): Promise<string> {
|
||||||
// A prior production build must never shadow a freshly packaged development release.
|
// A prior production build must never shadow a freshly packaged development release.
|
||||||
if (process.env.NODE_ENV === 'development')
|
if (process.env.NODE_ENV === 'development')
|
||||||
|
|||||||
@@ -52,6 +52,11 @@
|
|||||||
<p role="status">
|
<p role="status">
|
||||||
{name} created. Run this command with administrator access on that machine.
|
{name} created. Run this command with administrator access on that machine.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
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
|
<textarea
|
||||||
readonly
|
readonly
|
||||||
aria-label="Host installation command"
|
aria-label="Host installation command"
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
|
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
|
||||||
export async function GET({ params }: { params: { file: string } }) {
|
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.');
|
error(404, 'Release asset not found.');
|
||||||
return new Response(
|
let content: string;
|
||||||
await agentReleaseAsset(
|
try {
|
||||||
params.file as 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'
|
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: {
|
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'
|
'Cache-Control': 'no-cache'
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -608,6 +608,11 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if enrollment}<p>Expires {$timeFormat.dateTime(enrollment.expiresAt)}</p>
|
{#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>
|
<pre>{enrollment.command}</pre>{/if}{:else}<p>
|
||||||
Administrators manage enrollment and agent credentials.
|
Administrators manage enrollment and agent credentials.
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ import re
|
|||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
UPDATER_PROTOCOL_VERSION = 1
|
UPDATER_PROTOCOL_VERSION = 2
|
||||||
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
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")
|
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
|
||||||
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
|
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
|
||||||
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
|
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
|
||||||
@@ -36,6 +39,8 @@ UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
|
|||||||
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
||||||
AGENT_SERVICE = "electric-cherry-agent.service"
|
AGENT_SERVICE = "electric-cherry-agent.service"
|
||||||
MAX_MANIFEST = 16 * 1024
|
MAX_MANIFEST = 16 * 1024
|
||||||
|
MAX_SIGNATURE = 16 * 1024
|
||||||
|
MAX_KEYRING = 256 * 1024
|
||||||
MAX_SOURCE = 8 * 1024 * 1024
|
MAX_SOURCE = 8 * 1024 * 1024
|
||||||
HEALTH_TIMEOUT = 90
|
HEALTH_TIMEOUT = 90
|
||||||
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
|
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
|
||||||
@@ -150,6 +155,72 @@ def source_version(source):
|
|||||||
return declarations[0]
|
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
|
@contextlib.contextmanager
|
||||||
def exclusive_lock(path):
|
def exclusive_lock(path):
|
||||||
trusted(path, missing=True)
|
trusted(path, missing=True)
|
||||||
@@ -242,40 +313,19 @@ class Updater:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def fetch(self, path, limit):
|
def fetch(self, path, limit):
|
||||||
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/1"})
|
return fetch_url(self.installation["controller"] + path, limit, "controller release")
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
||||||
raise ContactError("Agent release redirects are not allowed")
|
|
||||||
try:
|
|
||||||
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
raise ContactError("The controller did not return a complete agent release")
|
|
||||||
length = response.headers.get("Content-Length")
|
|
||||||
if length is not None and (not length.isdecimal() or int(length) > limit):
|
|
||||||
raise IntegrityError("The controller release exceeds its download size limit")
|
|
||||||
chunks, total = [], 0
|
|
||||||
deadline = time.monotonic() + 120
|
|
||||||
while True:
|
|
||||||
if time.monotonic() >= deadline:
|
|
||||||
raise ContactError("The controller release download timed out")
|
|
||||||
# HTTPResponse.read1 performs one socket read. read(n) can
|
|
||||||
# wait indefinitely for n bytes from a trickling server.
|
|
||||||
block = response.read1(min(64 * 1024, limit + 1 - total))
|
|
||||||
if not block:
|
|
||||||
break
|
|
||||||
total += len(block)
|
|
||||||
if total > limit:
|
|
||||||
raise IntegrityError("The controller release exceeds its download size limit")
|
|
||||||
chunks.append(block)
|
|
||||||
if length is not None and total != int(length):
|
|
||||||
raise ContactError("The controller release download was interrupted")
|
|
||||||
return b"".join(chunks)
|
|
||||||
except (OSError, ValueError, http.client.HTTPException):
|
|
||||||
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
|
|
||||||
|
|
||||||
def manifest(self):
|
def manifest(self):
|
||||||
|
public_key = fetch_release_key()
|
||||||
|
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
|
||||||
try:
|
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):
|
except (ValueError, UnicodeError):
|
||||||
raise IntegrityError("The controller release manifest is invalid") from None
|
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":
|
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
@@ -33,7 +33,7 @@ command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
|
|||||||
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update -qq
|
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() {
|
installation_preflight() {
|
||||||
if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then
|
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
|
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 [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
|
||||||
if [ -d "$data_dir" ]; then existing_data_root=true; fi
|
if [ -d "$data_dir" ]; then existing_data_root=true; fi
|
||||||
installation_preflight
|
installation_preflight
|
||||||
temporary_dir=$(mktemp -d)
|
temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
|
||||||
updater_timer_was_active=false
|
updater_timer_was_active=false
|
||||||
update_lock_held=false
|
update_lock_held=false
|
||||||
activity_lock_held=false
|
activity_lock_held=false
|
||||||
@@ -167,21 +167,61 @@ installation_cleanup() {
|
|||||||
exit "$ec_install_status"
|
exit "$ec_install_status"
|
||||||
}
|
}
|
||||||
trap installation_cleanup EXIT
|
trap installation_cleanup EXIT
|
||||||
|
# INSTALLATION_TRUST: always fetch directly from the repository, never the controller.
|
||||||
|
key_status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
|
||||||
|
--connect-timeout 25 --max-time 120 --max-filesize 262144 \
|
||||||
|
--header 'Cache-Control: no-cache' --write-out '%{http_code}' \
|
||||||
|
'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg' \
|
||||||
|
-o "$temporary_dir/release-signing.gpg")
|
||||||
|
[ "$key_status" = 200 ] && [ -s "$temporary_dir/release-signing.gpg" ] || {
|
||||||
|
echo 'The release-signing repository did not return a public key. Installation stopped.' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# INSTALLATION_TRUST_END
|
||||||
controller="${controller%/}"
|
controller="${controller%/}"
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
|
||||||
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig"
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
|
||||||
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" <<'PY'
|
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" "$temporary_dir/release-signing.gpg" <<'PY'
|
||||||
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
|
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
|
||||||
import ast, hashlib, json, pathlib, re, sys
|
import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
|
||||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
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()
|
agent = pathlib.Path(sys.argv[2]).read_bytes()
|
||||||
updater = pathlib.Path(sys.argv[3]).read_bytes()
|
updater = pathlib.Path(sys.argv[3]).read_bytes()
|
||||||
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
|
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
|
||||||
raise SystemExit('Agent release checksum verification failed.')
|
raise SystemExit('Agent release checksum verification failed.')
|
||||||
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
|
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
|
||||||
raise SystemExit('Updater release checksum verification failed.')
|
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.')
|
raise SystemExit('Unsupported agent or updater release manifest.')
|
||||||
versions = [node.value.value for node in ast.parse(agent).body
|
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)
|
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"protocolVersion": 1,
|
"protocolVersion": 1,
|
||||||
"platform": "debian-amd64",
|
"platform": "debian-amd64",
|
||||||
"sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701",
|
"sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701",
|
||||||
"updaterProtocolVersion": 1,
|
"updaterProtocolVersion": 2,
|
||||||
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
"updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
|
||||||
|
"installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-----BEGIN PGP SIGNATURE-----
|
||||||
|
|
||||||
|
iJEEABYKADkWIQSpocb6VBfQzvdIh9HF6fdSNniN+gUCasmr1RsUgAAAAAAEAA5t
|
||||||
|
YW51MiwyLjUrMS4xMiwwLDMACgkQxen3UjZ4jfprZQD+PPqrtEYWTImhdy1eFAfv
|
||||||
|
k5XD1IaFOgSvcsL9P0tj69IBAIRGt3zqWw9E8vFeyZXz58GfnscyFI6ws5ln+l3h
|
||||||
|
i2UP
|
||||||
|
=Er1r
|
||||||
|
-----END PGP SIGNATURE-----
|
||||||
@@ -499,7 +499,8 @@ describe('machine and route identity boundaries', () => {
|
|||||||
'/agent/ec-agent.py',
|
'/agent/ec-agent.py',
|
||||||
'/agent/ec-agent-update.py',
|
'/agent/ec-agent-update.py',
|
||||||
'/agent/install.sh',
|
'/agent/install.sh',
|
||||||
'/agent/manifest.json'
|
'/agent/manifest.json',
|
||||||
|
'/agent/manifest.json.sig'
|
||||||
]) {
|
]) {
|
||||||
const call = requestFor(null, { path });
|
const call = requestFor(null, { path });
|
||||||
expect((await call.invoke()).status).toBe(200);
|
expect((await call.invoke()).status).toBe(200);
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ describe('public agent release artifacts', () => {
|
|||||||
'install.sh',
|
'install.sh',
|
||||||
'ec-agent.py',
|
'ec-agent.py',
|
||||||
'ec-agent-update.py',
|
'ec-agent-update.py',
|
||||||
'manifest.json'
|
'manifest.json',
|
||||||
|
'manifest.json.sig'
|
||||||
] as const) {
|
] as const) {
|
||||||
expect(await agentReleaseAsset(file)).toBe('fresh-release');
|
expect(await agentReleaseAsset(file)).toBe('fresh-release');
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,8 @@ describe('public agent release artifacts', () => {
|
|||||||
resolve('static/agent/install.sh'),
|
resolve('static/agent/install.sh'),
|
||||||
resolve('static/agent/ec-agent.py'),
|
resolve('static/agent/ec-agent.py'),
|
||||||
resolve('static/agent/ec-agent-update.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',
|
'install.sh',
|
||||||
'ec-agent.py',
|
'ec-agent.py',
|
||||||
'ec-agent-update.py',
|
'ec-agent-update.py',
|
||||||
'manifest.json'
|
'manifest.json',
|
||||||
|
'manifest.json.sig'
|
||||||
] as const) {
|
] as const) {
|
||||||
expect(await agentReleaseAsset(file)).toBe('packaged-release');
|
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/install.sh'),
|
||||||
resolve('build/client/agent/ec-agent.py'),
|
resolve('build/client/agent/ec-agent.py'),
|
||||||
resolve('build/client/agent/ec-agent-update.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',
|
'serves the packaged %s without a browser session or host credential',
|
||||||
async (file) => {
|
async (file) => {
|
||||||
const content = file.endsWith('.json')
|
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';
|
: '# verified release source\n';
|
||||||
files.readFile.mockResolvedValue(content);
|
files.readFile.mockResolvedValue(content);
|
||||||
const response = await GET({ params: { file } });
|
const response = await GET({ params: { file } });
|
||||||
@@ -78,7 +84,11 @@ describe('public agent release artifacts', () => {
|
|||||||
expect(await response.text()).toBe(content);
|
expect(await response.text()).toBe(content);
|
||||||
expect(response.headers.get('cache-control')).toBe('no-cache');
|
expect(response.headers.get('cache-control')).toBe('no-cache');
|
||||||
expect(response.headers.get('content-type')).toContain(
|
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([
|
expect(files.readFile.mock.calls).toEqual([
|
||||||
[resolve('build/client/agent', file), 'utf8']
|
[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 () => {
|
it('serves the updater from development assets when no packaged asset exists', async () => {
|
||||||
files.readFile
|
files.readFile
|
||||||
.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||||
@@ -111,6 +128,8 @@ describe('public agent release artifacts', () => {
|
|||||||
'update-status.json',
|
'update-status.json',
|
||||||
'config.json',
|
'config.json',
|
||||||
'install.sh',
|
'install.sh',
|
||||||
|
'manifest.json.sig/anything',
|
||||||
|
'release-signing.key',
|
||||||
'ec-agent-update.py/anything'
|
'ec-agent-update.py/anything'
|
||||||
])('rejects non-release file %s before reading disk', async (file) => {
|
])('rejects non-release file %s before reading disk', async (file) => {
|
||||||
await expect(GET({ params: { file } })).rejects.toMatchObject({ status: 404 });
|
await expect(GET({ params: { file } })).rejects.toMatchObject({ status: 404 });
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ async function enrolled() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('native host enrollment and scoped authentication', () => {
|
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'])(
|
it.each(['instance.inspect', 'rcon.status-batch'])(
|
||||||
'expires undelivered %s on timeout and delivers the next fresh check',
|
'expires undelivered %s on timeout and delivers the next fresh check',
|
||||||
async (kind) => {
|
async (kind) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user