diff --git a/homecore_updater/Dockerfile b/homecore_updater/Dockerfile new file mode 100644 index 0000000..68733ff --- /dev/null +++ b/homecore_updater/Dockerfile @@ -0,0 +1,13 @@ +FROM ghcr.io/home-assistant/base:latest +ARG BUILD_VERSION +ARG BUILD_ARCH +LABEL io.hass.version="$BUILD_VERSION" io.hass.type="app" io.hass.arch="$BUILD_ARCH" +RUN apk add --no-cache python3 py3-pip && python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt +COPY app /opt/homecore-updater +COPY run.sh /run.sh +RUN chmod a+x /run.sh +WORKDIR /opt/homecore-updater +CMD ["/run.sh"] diff --git a/homecore_updater/README.md b/homecore_updater/README.md new file mode 100644 index 0000000..d22a8bb --- /dev/null +++ b/homecore_updater/README.md @@ -0,0 +1,10 @@ +# HomeCore Updater + +Internal HomeCore maintenance app. + +It has no customer UI. It watches `/share/homecore/update-request.json` for +locally signed requests from HomeCore Controller and performs the requested +HomeCore Controller update through Home Assistant Supervisor. + +Automatic rollout is not supported. HomeCore Admin must create each update +request for a specific controller. diff --git a/homecore_updater/app/__pycache__/main.cpython-312.pyc b/homecore_updater/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..b34a2e4 Binary files /dev/null and b/homecore_updater/app/__pycache__/main.cpython-312.pyc differ diff --git a/homecore_updater/app/main.py b/homecore_updater/app/main.py new file mode 100644 index 0000000..6839579 --- /dev/null +++ b/homecore_updater/app/main.py @@ -0,0 +1,374 @@ +from pathlib import Path +import hashlib +import hmac +import json +import os +import secrets +import time + +import httpx + + +VERSION = "1.0.0" + +SHARE = Path("/share/homecore") +CONFIG = Path("/config") +SECRET_LOCAL = CONFIG / "updater-secret" +SECRET_SHARED = SHARE / "updater-secret" +REQUEST_FILE = SHARE / "update-request.json" +RESULT_FILE = SHARE / "update-result.json" +STATE_FILE = CONFIG / "updater-state.json" + +SUPERVISOR = "http://supervisor" + +SHARE.mkdir(parents=True, exist_ok=True) +CONFIG.mkdir(parents=True, exist_ok=True) + + +def atomic_write(path: Path, data: bytes): + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_bytes(data) + os.replace(tmp, path) + + +def canonical(data): + return json.dumps( + data, + sort_keys=True, + separators=(",", ":"), + ).encode() + + +def ensure_secret(): + if SECRET_LOCAL.exists(): + secret = SECRET_LOCAL.read_text().strip() + else: + secret = secrets.token_hex(32) + SECRET_LOCAL.write_text(secret + "\n") + os.chmod(SECRET_LOCAL, 0o600) + + atomic_write(SECRET_SHARED, (secret + "\n").encode()) + try: + os.chmod(SECRET_SHARED, 0o600) + except Exception: + pass + + return secret.encode() + + +SECRET = ensure_secret() + + +def load_json(path): + try: + return json.loads(path.read_text()) + except Exception: + return {} + + +def save_json(path, data): + atomic_write( + path, + (json.dumps(data, indent=2, sort_keys=True) + "\n").encode(), + ) + + +def supervisor_headers(): + token = os.getenv("SUPERVISOR_TOKEN") + if not token: + raise RuntimeError("SUPERVISOR_TOKEN is not available") + return { + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + } + + +def supervisor_request(method, path, payload=None, timeout=120): + r = httpx.request( + method, + SUPERVISOR + path, + headers=supervisor_headers(), + json=payload, + timeout=timeout, + ) + r.raise_for_status() + + try: + body = r.json() + except Exception: + return {} + + if isinstance(body, dict): + if body.get("result") == "error": + raise RuntimeError(body.get("message") or "Supervisor error") + if "data" in body: + return body["data"] + + return body + + +def find_homecore_controller(): + data = supervisor_request("GET", "/store/addons", timeout=30) + apps = data.get("addons", []) if isinstance(data, dict) else (data or []) + + candidates = [] + for app in apps: + slug = str(app.get("slug", "")) + name = str(app.get("name", "")) + if ( + name == "HomeCore Controller" + or slug == "homecore_controller" + or slug.endswith("_homecore_controller") + ): + candidates.append(app) + + if not candidates: + raise RuntimeError( + "HomeCore Controller was not found in the Home Assistant App store" + ) + + installed = [x for x in candidates if x.get("installed")] + return (installed or candidates)[0] + + +def sign_result(body): + sig = hmac.new(SECRET, canonical(body), hashlib.sha256).hexdigest() + return {**body, "signature": sig} + + +def write_result(status, request, detail=""): + body = { + "schema": 1, + "request_id": str(request.get("request_id", "")), + "target_version": str(request.get("target_version", "")), + "status": status, + "detail": detail, + "updated_at": int(time.time()), + "updater_version": VERSION, + } + save_json(RESULT_FILE, sign_result(body)) + print( + "HOMECORE UPDATER:" + f" {status}" + f" request={body['request_id'] or '-'}" + f" target={body['target_version'] or '-'}" + f" {detail}", + flush=True, + ) + + +def verify_request(request): + if not isinstance(request, dict): + raise RuntimeError("Update request is not valid JSON") + + signature = str(request.get("signature", "")).strip() + if not signature: + raise RuntimeError("Update request signature is missing") + + body = {k: v for k, v in request.items() if k != "signature"} + expected = hmac.new( + SECRET, + canonical(body), + hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(signature, expected): + raise RuntimeError("Update request signature is invalid") + + if int(body.get("schema", 0)) != 1: + raise RuntimeError("Unsupported update request schema") + + request_id = str(body.get("request_id", "")).strip() + target = str(body.get("target_version", "")).strip() + controller_id = str(body.get("controller_id", "")).strip() + + if not request_id or not target or not controller_id: + raise RuntimeError("Update request is incomplete") + + age = int(time.time()) - int(body.get("issued_at", 0) or 0) + if age < -120 or age > 900: + raise RuntimeError("Update request has expired") + + return body + + +def installed_version(app): + value = app.get("version") + return str(value or "") + + +def repository_version(app): + return str( + app.get("version_latest") + or app.get("version_available") + or app.get("version") + or "" + ) + + +def perform_update(request): + request_id = request["request_id"] + target = request["target_version"] + + write_result("preparing", request, "Manual update accepted by HomeCore Updater") + + supervisor_request("POST", "/store/reload", timeout=60) + time.sleep(3) + + app = find_homecore_controller() + slug = str(app.get("slug", "")).strip() + installed = installed_version(app) + latest = repository_version(app) + + print( + "HOMECORE UPDATER: repository check" + f" installed={installed or '-'}" + f" latest={latest or '-'}" + f" target={target}", + flush=True, + ) + + if not slug: + raise RuntimeError("HomeCore Controller slug could not be determined") + + if latest != target: + raise RuntimeError( + "Home Assistant repository currently offers " + + (latest or "no version") + + "; target is " + + target + ) + + if installed == target: + write_result( + "completed", + request, + "HomeCore Controller already has the requested version", + ) + return + + supervisor_request("GET", f"/store/addons/{slug}/availability", timeout=60) + + write_result( + "installing", + request, + "Backup requested; Supervisor is updating HomeCore Controller", + ) + + # This app is deliberately separate from HomeCore Controller, so this is + # not a self-update. Supervisor is allowed to update the other app. + supervisor_request( + "POST", + f"/store/addons/{slug}/update", + { + "backup": True, + "background": True, + }, + timeout=120, + ) + + deadline = time.time() + 600 + last_seen = "" + + while time.time() < deadline: + time.sleep(5) + + supervisor_request("POST", "/store/reload", timeout=60) + app = find_homecore_controller() + current = installed_version(app) + + if current != last_seen: + print( + "HOMECORE UPDATER: waiting for controller" + f" current={current or '-'} target={target}", + flush=True, + ) + last_seen = current + + if current == target: + write_result( + "completed", + request, + "HomeCore Controller update completed successfully", + ) + return + + raise RuntimeError( + "Timed out waiting for HomeCore Controller to reach " + target + ) + + +def main(): + print( + "HOMECORE UPDATER:" + f" version={VERSION}" + " supervisor_token=" + + ("available" if os.getenv("SUPERVISOR_TOKEN") else "missing"), + flush=True, + ) + print( + "HOMECORE UPDATER: waiting for signed requests in " + + str(REQUEST_FILE), + flush=True, + ) + + last_seen_request = "" + + while True: + try: + if REQUEST_FILE.exists(): + raw = load_json(REQUEST_FILE) + req_id = str(raw.get("request_id", "")) + + if req_id and req_id != last_seen_request: + request = verify_request(raw) + state = load_json(STATE_FILE) + + if str(state.get("request_id", "")) == req_id: + # Never execute an identical request twice. + last_seen_request = req_id + else: + save_json( + STATE_FILE, + { + "request_id": req_id, + "target_version": request["target_version"], + "status": "running", + "started_at": int(time.time()), + }, + ) + + try: + perform_update(request) + save_json( + STATE_FILE, + { + "request_id": req_id, + "target_version": request["target_version"], + "status": "completed", + "completed_at": int(time.time()), + }, + ) + except Exception as exc: + write_result("failed", request, str(exc)) + save_json( + STATE_FILE, + { + "request_id": req_id, + "target_version": request["target_version"], + "status": "failed", + "detail": str(exc), + "failed_at": int(time.time()), + }, + ) + + last_seen_request = req_id + + except Exception as exc: + print("HOMECORE UPDATER: watcher error:", exc, flush=True) + + time.sleep(2) + + +if __name__ == "__main__": + main() diff --git a/homecore_updater/config.yaml b/homecore_updater/config.yaml new file mode 100644 index 0000000..740eff3 --- /dev/null +++ b/homecore_updater/config.yaml @@ -0,0 +1,20 @@ +name: HomeCore Updater +version: "1.0.0" +slug: homecore_updater +description: HomeCore maintenance service for manual per-controller updates. +url: https://homecore.home-core.co.uk +init: false +arch: [aarch64] +hassio_api: true +hassio_role: admin +startup: services +stage: experimental +backup: hot +boot: auto +options: {} +schema: {} +map: + - type: addon_config + read_only: false + - type: share + read_only: false diff --git a/homecore_updater/requirements.txt b/homecore_updater/requirements.txt new file mode 100644 index 0000000..cee3237 --- /dev/null +++ b/homecore_updater/requirements.txt @@ -0,0 +1 @@ +httpx==0.28.1 diff --git a/homecore_updater/run.sh b/homecore_updater/run.sh new file mode 100644 index 0000000..87a9512 --- /dev/null +++ b/homecore_updater/run.sh @@ -0,0 +1,3 @@ +#!/usr/bin/with-contenv sh +set -eu +exec python /opt/homecore-updater/main.py