#!/usr/bin/env python3 import argparse import fcntl import hashlib import json import os import re import subprocess import sys import tempfile import urllib.request from urllib.parse import urljoin CONFIG = "/etc/ekklesia-classroom/device.conf" LOCKFILE = "/run/ekklesia-classroom-update.lock" REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service" CACHE_DIR = "/var/cache/ekklesia-classroom" PACKAGE = "ekklesia-classroom" # What a published version looks like: 0.2.6 for a feature release, # 0.2.6.1 for a maintenance release of it. Anything else in a manifest is # a mistake on the server, and a classroom does nothing with it. The same # pattern is in tools/release-lib.sh; tests/test-release-versions.sh holds # the two to one list of good and bad versions. # # Only the SHAPE is decided here. Which of two versions is newer is always # dpkg's answer, because dpkg is what installs them. VERSION_PATTERN = re.compile( r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$" ) SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") def log(message): print(f"[ekklesia-classroom-update] {message}", flush=True) def read_config(): config = { "UPDATE_URL": "https://updates.riverofglorymn.org", "CHANNEL": "stable", } if os.path.exists(CONFIG): with open(CONFIG, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) config[key.strip()] = value.strip() return config def installed_version(): result = subprocess.run( ["dpkg-query", "-W", "-f=${Version}", "ekklesia-classroom"], capture_output=True, text=True, ) if result.returncode != 0: return "0.0.0" return result.stdout.strip() def valid_version(version): return isinstance(version, str) and bool(VERSION_PATTERN.match(version)) def version_is_newer(candidate, current): result = subprocess.run( ["dpkg", "--compare-versions", candidate, "gt", current], stderr=subprocess.DEVNULL, ) return result.returncode == 0 def manifest_problem(manifest): """Why this manifest must not be acted on, or None if it may be. A manifest names a version, the package file for exactly that version, and that file's checksum. The package name is checked against the version, not merely read, so a manifest that says 0.2.6.1 and points at the 0.2.6 file is refused rather than installed and then offered again at every check. """ if not isinstance(manifest, dict): return "the manifest is not an object" version = manifest.get("version") if not valid_version(version): return f"the manifest names an invalid version: {version!r}" package = manifest.get("package") expected = f"/packages/{PACKAGE}_{version}_amd64.deb" if package != expected: return f"the manifest names {package!r} for version {version}" checksum = manifest.get("sha256") if not isinstance(checksum, str) or not SHA256_PATTERN.match(checksum.lower()): return f"the manifest's sha256 is not a checksum: {checksum!r}" return None def package_identity(path): """(Package, Version) as dpkg reads them out of a downloaded file.""" result = subprocess.run( ["dpkg-deb", "--field", path, "Package", "Version"], capture_output=True, text=True, ) if result.returncode != 0: return None, None fields = {} for line in result.stdout.splitlines(): if ":" in line: key, value = line.split(":", 1) fields[key.strip()] = value.strip() return fields.get("Package"), fields.get("Version") def fetch_json(url): request = urllib.request.Request( url, headers={"User-Agent": "Ekklesia-Classroom-Updater/0.1"}, ) with urllib.request.urlopen(request, timeout=30) as response: return json.load(response) def download(url, destination): request = urllib.request.Request( url, headers={"User-Agent": "Ekklesia-Classroom-Updater/0.1"}, ) with urllib.request.urlopen(request, timeout=120) as response: with open(destination, "wb") as f: while True: chunk = response.read(1024 * 1024) if not chunk: break f.write(chunk) def sha256(path): digest = hashlib.sha256() with open(path, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break digest.update(chunk) return digest.hexdigest() def main(): parser = argparse.ArgumentParser() parser.add_argument( "--no-reboot", action="store_true", help="Install the update but do not reboot automatically", ) parser.add_argument( "--check", action="store_true", help="Check for an update without installing it", ) args = parser.parse_args() with open(LOCKFILE, "w", encoding="utf-8") as lock: try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: log("Another update is already running.") return 0 config = read_config() base_url = config["UPDATE_URL"].rstrip("/") + "/" channel = config["CHANNEL"] manifest_url = urljoin(base_url, f"{channel}/manifest.json") current = installed_version() log(f"Current version: {current}") log(f"Channel: {channel}") log(f"Checking: {manifest_url}") try: manifest = fetch_json(manifest_url) except Exception as exc: log(f"Unable to fetch update manifest: {exc}") return 1 problem = manifest_problem(manifest) if problem: log(f"Ignoring the {channel} manifest: {problem}.") return 1 candidate = manifest["version"] log(f"Available version: {candidate}") if not version_is_newer(candidate, current): log("Already up to date.") return 0 if args.check: log(f"Update available: {current} -> {candidate}") return 0 package_url = urljoin(base_url, manifest["package"].lstrip("/")) expected_hash = manifest["sha256"].lower() os.makedirs(CACHE_DIR, exist_ok=True) package_path = os.path.join( CACHE_DIR, os.path.basename(manifest["package"]), ) temporary_path = package_path + ".download" log(f"Downloading {package_url}") try: download(package_url, temporary_path) except Exception as exc: log(f"Download failed: {exc}") return 1 actual_hash = sha256(temporary_path) if actual_hash != expected_hash: log("SHA-256 verification FAILED.") log(f"Expected: {expected_hash}") log(f"Received: {actual_hash}") os.unlink(temporary_path) return 1 log("SHA-256 verification passed.") # The checksum proves the file is the one the manifest describes; # this proves the manifest described the right file. A package # that installs as some other version would be offered again at # every check, and installed again, and the room rebooted, for # ever. name, version = package_identity(temporary_path) if name != PACKAGE or version != candidate: log(f"The package is {name} {version}, not {PACKAGE} {candidate}.") os.unlink(temporary_path) return 1 os.replace(temporary_path, package_path) log(f"Installing {candidate}") result = subprocess.run( [ "apt-get", "install", "-y", package_path, ] ) if result.returncode != 0: log("Package installation failed.") return result.returncode log(f"Update to {candidate} installed successfully.") if manifest.get("requires_reboot", False): if args.no_reboot: log("Update requires reboot, but --no-reboot was specified.") else: # Not now. At 03:00 nobody is there; after a missed 03:00, # checked when the computer is next switched on, somebody # usually is. classroom-reboot-when-idle waits until nobody # has touched it for half an hour and nothing is playing. log("Update requires reboot. Rebooting once nobody is using this computer.") subprocess.run(["systemctl", "start", "--no-block", REBOOT_WHEN_IDLE]) return 0 if __name__ == "__main__": sys.exit(main())