#!/bin/bash # Electric Cherry host installer. Downloaded release integrity is checked before # execution. Existing Docker data and unrelated containers are never changed. set -euo pipefail umask 077 controller='' enrollment_token='' data_dir='/var/lib/electric-cherry' reenroll='' existing_enrollment=false existing_data_root=false while [ "$#" -gt 0 ]; do case "$1" in --controller) controller="${2:?Missing controller URL}"; shift 2 ;; --token) enrollment_token="${2:?Missing enrollment token}"; shift 2 ;; --data-dir) data_dir="${2:?Missing data directory}"; shift 2 ;; --reenroll) reenroll='--force-enroll'; shift ;; *) echo "Unknown installer option" >&2; exit 2 ;; esac done [ "$(id -u)" = 0 ] || { echo 'Run the installer as root.' >&2; exit 1; } case "$controller" in https://*) ;; *) echo '--controller must be an HTTPS URL.' >&2; exit 2 ;; esac [ -n "$enrollment_token" ] || { echo '--token is required.' >&2; exit 2; } case "$data_dir" in /*) ;; *) echo '--data-dir must be absolute.' >&2; exit 2 ;; esac case "$data_dir" in *[!a-zA-Z0-9/_.-]*|*/../*|*/./*|*/..|*/.|*//*|*/) echo 'Choose a canonical, dedicated data directory.' >&2; exit 2 ;; esac [ ! -L "$data_dir" ] || { echo 'Data directory must not be a symlink.' >&2; exit 2; } . /etc/os-release [ "${ID:-}" = debian ] || { echo 'Supported platform: Debian 12 or 13.' >&2; exit 1; } case "${VERSION_ID:-}" in 12|13) ;; *) echo 'Supported platform: Debian 12 or 13.' >&2; exit 1 ;; esac [ "$(dpkg --print-architecture)" = amd64 ] || { echo 'Supported architecture: amd64.' >&2; exit 1; } command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; } export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux installation_preflight() { if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then echo 'The existing enrollment disappeared during installation. Stop and inspect host deletion before trying again.' >&2 return 1 fi if [ "$existing_data_root" = true ] && [ ! -d "$data_dir" ]; then echo 'The existing data directory disappeared during installation. Stop and inspect host deletion before trying again.' >&2 return 1 fi python3 - "$controller" "$data_dir" /etc/electric-cherry/agent.json <<'PY' # INSTALLATION_PREFLIGHT: this runs before touching managed directories or services. import json, pathlib, re, stat, sys controller, raw_root, config_name = sys.argv[1:] root = pathlib.Path(raw_root) config = pathlib.Path(config_name) reserved = { '/', '/bin', '/boot', '/dev', '/etc', '/home', '/lib', '/lib64', '/media', '/mnt', '/opt', '/proc', '/root', '/run', '/sbin', '/srv', '/sys', '/tmp', '/usr', '/usr/local', '/usr/local/bin', '/usr/local/lib', '/usr/local/libexec', '/var', '/var/cache', '/var/lib', '/var/log', '/var/tmp', } if (not re.fullmatch(r'/[A-Za-z0-9/_.-]+', raw_root) or str(root) != raw_root or any(part in ('.', '..') for part in raw_root.split('/')[1:]) or len(root.parts) < 3 or raw_root in reserved or config.parent == root or config.parent in root.parents): raise SystemExit('Choose a canonical, dedicated data directory outside the agent configuration directory.') installed_paths = [config, pathlib.Path('/usr/local/libexec/electric-cherry-agent.py'), pathlib.Path('/usr/local/libexec/electric-cherry-agent.py.update'), pathlib.Path('/usr/local/libexec/electric-cherry-uninstall.py'), pathlib.Path('/usr/local/libexec/electric-cherry-uninstall.py.update'), pathlib.Path('/usr/local/libexec/electric-cherry-update.py'), pathlib.Path('/etc/systemd/system/electric-cherry-agent.service'), pathlib.Path('/etc/systemd/system/electric-cherry-uninstall.service'), pathlib.Path('/etc/systemd/system/electric-cherry-update.service'), pathlib.Path('/etc/systemd/system/electric-cherry-update.timer')] if any(root == path or root in path.parents for path in installed_paths): raise SystemExit('Choose a dedicated data directory outside the agent executable and service directories.') def trusted_directories(path): for current in [*reversed(path.parents), path]: try: info = current.lstat() except FileNotFoundError: continue if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: raise SystemExit('Installation directories and their parents must be root-owned, not symlinks, and not writable by other users.') def trusted_file(path): info = path.lstat() if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: raise SystemExit('Existing installation metadata must be regular, root-owned files that other users cannot modify.') def trusted_json(path): trusted_file(path) value = json.loads(path.read_text()) if not isinstance(value, dict): raise SystemExit('Existing installation metadata is invalid.') return value trusted_directories(root) trusted_directories(config.parent) mountinfo = pathlib.Path('/proc/1/mountinfo') if mountinfo.exists(): for line in mountinfo.read_text().splitlines(): fields = line.split() if len(fields) < 5: continue mount = re.sub(r'\\([0-7]{3})', lambda match: chr(int(match[1], 8)), fields[4]) if mount == raw_root or mount.startswith(raw_root + '/'): raise SystemExit('Use a dedicated subdirectory of the mounted disk, with no filesystems mounted inside it, so host deletion can remove the entire data directory.') request = config.with_name('decommission.json') if request.exists() or request.is_symlink(): raise SystemExit('Host deletion is in progress. Finish or repair that deletion before installing again.') trusted_directories(config.parent / 'update') journal = config.parent / 'update' / 'journal.json' if journal.exists() or journal.is_symlink(): raise SystemExit('An automatic update needs recovery. Let electric-cherry-update.service finish or repair that update before rerunning the installer.') update_lock = config.with_name('update.lock') if update_lock.exists() or update_lock.is_symlink(): trusted_file(update_lock) saved = None if config.exists() or config.is_symlink(): saved = trusted_json(config) if saved.get('controller', '').rstrip('/') != controller.rstrip('/') or saved.get('root') != raw_root: raise SystemExit('Existing enrollment uses a different controller or data directory. Preserve the existing installation and migrate it explicitly.') if not saved.get('hostId') or not saved.get('credential'): raise SystemExit('Existing enrollment is incomplete. Repair its credentials before upgrading.') marker = root / '.electric-cherry.json' if marker.exists() or marker.is_symlink(): owned = trusted_json(marker) if not saved or owned != {'version': 1, 'root': raw_root, 'hostId': saved['hostId'], 'controller': controller.rstrip('/')}: raise SystemExit('Data directory ownership does not match this enrollment. Preserve it and migrate explicitly.') elif root.exists() and any(root.iterdir()): # Upgrade installations predating ownership markers only when the entire # root has the known layout. Never adopt a directory of unrelated files. if not saved or any(path.name not in {'agent', 'instances', 'libraries'} for path in root.iterdir()): raise SystemExit('The data directory contains files not owned by this installation. Choose an empty, dedicated directory.') for directory in ('agent', 'instances', 'libraries'): trusted_directories(root / directory) activity_lock = root / 'agent' / 'activity.lock' if activity_lock.exists() or activity_lock.is_symlink(): trusted_file(activity_lock) PY } if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi if [ -d "$data_dir" ]; then existing_data_root=true; fi installation_preflight temporary_dir=$(mktemp -d) updater_timer_was_active=false update_lock_held=false activity_lock_held=false installer_finished=false installation_cleanup() { local ec_install_status=$? trap - EXIT if [ "$activity_lock_held" = true ]; then flock --unlock 8 || true exec 8>&- fi if [ "$update_lock_held" = true ]; then flock --unlock 9 || true exec 9>&- fi # Recovery must remain scheduled when an upgrade or its second preflight # fails. Release the worker lock before restarting the prior active timer. if [ "$installer_finished" != true ] && [ "$updater_timer_was_active" = true ]; then systemctl start electric-cherry-update.timer >/dev/null 2>&1 || true fi rm -rf "$temporary_dir" || true exit "$ec_install_status" } trap installation_cleanup EXIT 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/ec-agent.py" -o "$temporary_dir/ec-agent.py" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py" python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" <<'PY' # INSTALLATION_RELEASE: check both downloads before installing or executing either. import ast, hashlib, json, pathlib, re, sys manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) agent = pathlib.Path(sys.argv[2]).read_bytes() updater = pathlib.Path(sys.argv[3]).read_bytes() if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'): raise SystemExit('Agent release checksum verification failed.') if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'): raise SystemExit('Updater release checksum verification failed.') if manifest.get('platform') != 'debian-amd64' or manifest.get('protocolVersion') != 1 or manifest.get('updaterProtocolVersion') != 1: raise SystemExit('Unsupported agent or updater release manifest.') versions = [node.value.value for node in ast.parse(agent).body if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)] if len(versions) != 1 or versions[0] != manifest.get('version') or not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', versions[0]): raise SystemExit('Agent release version does not match its manifest.') compile(agent, 'ec-agent.py', 'exec') compile(updater, 'ec-agent-update.py', 'exec') PY # INSTALLATION_DOCKER: Debian 13 splits the client out of docker.io. if ! command -v docker >/dev/null; then if [ "$VERSION_ID" = 13 ] && [ "$(dpkg-query -W -f='${Status}' docker.io 2>/dev/null)" = 'install ok installed' ]; then # Recover an earlier daemon-only install, even if it created Docker data. # Add only the client; do not replace or restart the existing engine. apt-get install -y --no-install-recommends --no-remove docker-cli elif [ -e /var/lib/docker ] || [ -e /etc/docker/daemon.json ]; then echo 'Existing Docker data/configuration detected without a Docker CLI. Repair Docker, then rerun.' >&2 exit 1 else docker_packages=(docker.io) if [ "$VERSION_ID" = 13 ]; then docker_packages+=(docker-cli); fi apt-get install -y --no-install-recommends "${docker_packages[@]}" systemctl enable --now docker fi fi command -v docker >/dev/null || { echo 'Docker CLI is missing after installation. Repair the Docker client package, then rerun.' >&2; exit 1; } docker info >/dev/null || { echo 'Docker is installed but unavailable. Inspect systemctl status docker and journalctl -u docker, then rerun.' >&2; exit 1; } install -d -m 0700 /etc/electric-cherry # Wait for an already-running updater to finish; never interrupt its rollback. # The same lock also prevents a timer firing while files are being installed. if systemctl cat electric-cherry-update.timer >/dev/null 2>&1; then if systemctl is-active --quiet electric-cherry-update.timer; then updater_timer_was_active=true fi systemctl stop electric-cherry-update.timer fi exec 9>/etc/electric-cherry/update.lock flock --exclusive 9 update_lock_held=true installation_preflight # Serialize with running operations, especially a deletion request that could # otherwise start between preflight and stopping the main agent. if [ -d "$data_dir/agent" ]; then exec 8>"$data_dir/agent/activity.lock" flock --exclusive 8 activity_lock_held=true installation_preflight fi # Stop only the agent while replacing its own executable/journal owner. Existing # game containers keep running under Docker throughout installation and updates. if systemctl cat electric-cherry-agent.service >/dev/null 2>&1; then systemctl stop electric-cherry-agent.service fi installation_preflight install -d -m 0755 "$data_dir" "$data_dir/libraries" "$data_dir/instances" install -d -m 0700 "$data_dir/agent" available_kb=$(df -Pk "$data_dir" | awk 'NR==2 {print $4}') [ "$available_kb" -ge 1048576 ] || { echo 'At least 1 GiB of available game storage is required.' >&2; exit 1; } install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-agent.py 2>/dev/null || { install -d -m 0755 /usr/local/libexec install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-agent.py } # A separate worker survives stopping/removing the agent service. Its only # entry point is a fixed, authenticated deletion request prepared by the agent. install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-uninstall.py install -m 0755 "$temporary_dir/ec-agent-update.py" /usr/local/libexec/electric-cherry-update.py printf '%s' "$enrollment_token" > "$temporary_dir/enrollment-token" unset enrollment_token python3 /usr/local/libexec/electric-cherry-agent.py --enroll --controller "$controller" --root "$data_dir" --token-file "$temporary_dir/enrollment-token" ${reenroll:+"$reenroll"} python3 - "$data_dir" /etc/electric-cherry/agent.json <<'PY' # INSTALLATION_OWNERSHIP: bind deletion authority to this exact enrollment/root. import json, os, pathlib, sys, uuid root = pathlib.Path(sys.argv[1]) config = json.loads(pathlib.Path(sys.argv[2]).read_text()) if config.get('root') != str(root) or not config.get('hostId') or not config.get('controller'): raise SystemExit('Enrollment did not establish ownership of this data directory.') marker = root / '.electric-cherry.json' temporary = root / ('.electric-cherry.' + uuid.uuid4().hex + '.tmp') try: with temporary.open('x') as stream: os.chmod(temporary, 0o600) json.dump({'version': 1, 'root': str(root), 'hostId': config['hostId'], 'controller': config['controller'].rstrip('/')}, stream) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, marker) directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY) try: os.fsync(directory_fd) finally: os.close(directory_fd) finally: temporary.unlink(missing_ok=True) PY cat > /etc/systemd/system/electric-cherry-agent.service < /etc/systemd/system/electric-cherry-uninstall.service <<'EOF' [Unit] Description=Remove an Electric Cherry Host Installation After=network-online.target docker.service Wants=network-online.target [Service] Type=oneshot ExecStart=/usr/bin/python3 /usr/local/libexec/electric-cherry-uninstall.py --uninstall /etc/electric-cherry/decommission.json Restart=on-failure RestartSec=15 TimeoutStartSec=0 UMask=0077 [Install] WantedBy=multi-user.target EOF cat > /etc/systemd/system/electric-cherry-update.service <<'EOF' [Unit] Description=Update the Electric Cherry Host Agent After=network-online.target docker.service Wants=network-online.target [Service] Type=oneshot ExecStart=/usr/bin/python3 /usr/local/libexec/electric-cherry-update.py TimeoutStartSec=0 UMask=0077 EOF cat > /etc/systemd/system/electric-cherry-update.timer <<'EOF' [Unit] Description=Check for Electric Cherry Host Agent Updates [Timer] OnBootSec=2min OnUnitInactiveSec=5min RandomizedDelaySec=30s AccuracySec=1s Unit=electric-cherry-update.service [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable electric-cherry-agent if [ "$activity_lock_held" = true ]; then flock --unlock 8 exec 8>&- activity_lock_held=false fi systemctl restart electric-cherry-agent systemctl is-active --quiet electric-cherry-agent || { echo 'Agent service did not start. Inspect journalctl -u electric-cherry-agent.' >&2; exit 1; } systemctl enable --now electric-cherry-update.timer installer_finished=true echo 'Electric Cherry host agent installed and enrolled. Automatic agent updates are enabled; host readiness is shown in Hosts.'