from pathlib import Path import hashlib import hmac import json import os import re import secrets import threading import time import httpx UPDATE_STATE = Path("/config/homecore_update.json") SHARE = Path("/share/homecore") SECRET_FILE = SHARE / "updater-secret" REQUEST_FILE = SHARE / "update-request.json" RESULT_FILE = SHARE / "update-result.json" SHARE.mkdir(parents=True, exist_ok=True) def version_tuple(value): parts = re.findall(r"\d+", str(value)) return tuple(int(x) for x in parts[:4]) def canonical(data): return json.dumps( data, sort_keys=True, separators=(",", ":"), ).encode() def load_json(path): try: return json.loads(path.read_text()) except Exception: return {} def save_json(path, data): tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") os.replace(tmp, path) def load_update_state(): return load_json(UPDATE_STATE) def save_update_state(data): save_json(UPDATE_STATE, data) def read_secret(): if not SECRET_FILE.exists(): raise RuntimeError( "HomeCore Updater is not installed or is not ready" ) secret = SECRET_FILE.read_text().strip() if len(secret) < 32: raise RuntimeError("HomeCore Updater shared secret is invalid") return secret.encode() def sign_body(body): secret = read_secret() return hmac.new( secret, canonical(body), hashlib.sha256, ).hexdigest() def verify_signed(data): if not isinstance(data, dict): return False signature = str(data.get("signature", "")).strip() if not signature: return False body = {k: v for k, v in data.items() if k != "signature"} try: expected = sign_body(body) except Exception: return False return hmac.compare_digest(signature, expected) class HomeCoreUpdateManager: """ Manual, per-controller HomeCore update coordinator. This controller never updates itself. HomeCore Admin sends a command to this controller, which writes a locally authenticated request for the separate HomeCore Updater app. The Updater performs the Supervisor update. """ def __init__( self, current_version, cloud_api_fn, load_controller_state, controller_session_fn, ): self.current_version = current_version self.cloud_api_fn = cloud_api_fn self.load_controller_state = load_controller_state self.controller_session_fn = controller_session_fn self.last_report = None def cloud_api(self): return self.cloud_api_fn()["cloud_api"].rstrip("/") def report(self, status, request_id="", target_version="", detail=""): key = (status, request_id, target_version, detail) if key == self.last_report: return self.last_report = key print( f"HOMECORE UPDATE: {status} " f"request={request_id or '-'} " f"current={self.current_version} " f"target={target_version or '-'} " f"{detail}", flush=True, ) state = self.load_controller_state() controller_id = state.get("controller_id") if not controller_id: return try: token = self.controller_session_fn() httpx.post( self.cloud_api() + f"/api/v1/controllers/{controller_id}/update-status", headers={"Authorization": "Bearer " + token}, json={ "request_id": request_id, "status": status, "target_version": target_version, "current_version": self.current_version, "detail": detail, }, timeout=15, ).raise_for_status() except Exception as exc: print("HOMECORE UPDATE: status report failed:", exc, flush=True) def update_command(self): state = self.load_controller_state() controller_id = state.get("controller_id") if not controller_id: return None token = self.controller_session_fn() r = httpx.get( self.cloud_api() + f"/api/v1/controllers/{controller_id}/update-command", headers={"Authorization": "Bearer " + token}, timeout=15, ) r.raise_for_status() body = r.json() return body.get("command") if isinstance(body, dict) else None def delegated_result(self): data = load_json(RESULT_FILE) if not data or not verify_signed(data): return {} return data def process_result(self): st = load_update_state() request_id = str(st.get("request_id", "")) target = str(st.get("target_version", "")) if not request_id or not target: return False result = self.delegated_result() if str(result.get("request_id", "")) != request_id: return False status = str(result.get("status", "")) detail = str(result.get("detail", "")) if status == "failed": self.report("failed", request_id, target, detail) save_update_state( { **st, "status": "failed", "detail": detail, "failed_at": int(time.time()), } ) return True if status == "installing": self.report("installing", request_id, target, detail) return True if status == "completed": if self.current_version == target: self.report( "completed", request_id, target, detail or "HomeCore Controller update completed successfully", ) save_update_state( { **st, "status": "completed", "completed_at": int(time.time()), } ) return True # The Updater has completed replacement but this old process may # still be exiting. The new controller version will report success. return True return False def delegate(self, request_id, target): state = self.load_controller_state() controller_id = str(state.get("controller_id", "")).strip() if not controller_id: raise RuntimeError("Controller is not bound") body = { "schema": 1, "request_id": request_id, "target_version": target, "current_version": self.current_version, "controller_id": controller_id, "issued_at": int(time.time()), "nonce": secrets.token_hex(16), } body["signature"] = sign_body(body) save_json(REQUEST_FILE, body) print( "HOMECORE UPDATE: delegated to HomeCore Updater " f"request={request_id} target={target}", flush=True, ) def check_once(self): self.process_result() command = self.update_command() if not command: return request_id = str(command.get("request_id", "")).strip() target = str(command.get("target_version", "")).strip() if not request_id or not target: raise RuntimeError("HomeCore Cloud returned an incomplete update command") previous = load_update_state() if str(previous.get("request_id", "")) == request_id: status = str(previous.get("status", "")) if status in ("delegated", "installing", "completed", "failed"): # Never execute the same Admin request twice. self.process_result() return if target == self.current_version: self.report( "completed", request_id, target, "Controller already has the requested HomeCore version", ) save_update_state( { "status": "completed", "request_id": request_id, "target_version": target, "completed_at": int(time.time()), } ) return if version_tuple(target) < version_tuple(self.current_version): raise RuntimeError( "Automatic downgrade is not supported; controller has " + self.current_version + " and request targets " + target ) self.report( "preparing", request_id, target, "Manual HomeCore Admin update request received", ) # This proves the separate updater is installed before we acknowledge # the request as delegated. read_secret() self.delegate(request_id, target) save_update_state( { "status": "delegated", "request_id": request_id, "target_version": target, "delegated_at": int(time.time()), } ) self.report( "installing", request_id, target, "Handed to HomeCore Updater; backup and install pending", ) def run(self): # If this is the new version after a delegated update, report the # Updater's completed result back to HomeCore Admin immediately. try: self.process_result() except Exception as exc: print("HOMECORE UPDATE: startup result check failed:", exc, flush=True) while True: try: self.check_once() except Exception as exc: st = load_update_state() request_id = str(st.get("request_id", "")) target = str(st.get("target_version", "")) if not request_id: try: cmd = self.update_command() or {} request_id = str(cmd.get("request_id", "")) target = str(cmd.get("target_version", "")) except Exception: pass failed_state = { **st, "status": "failed", "request_id": request_id, "target_version": target, "detail": str(exc), "failed_at": int(time.time()), } save_update_state(failed_state) self.report("failed", request_id, target, str(exc)) time.sleep(10) def start_update_manager( current_version, cloud_api_fn, load_controller_state, controller_session_fn, ): manager = HomeCoreUpdateManager( current_version=current_version, cloud_api_fn=cloud_api_fn, load_controller_state=load_controller_state, controller_session_fn=controller_session_fn, ) threading.Thread( target=manager.run, daemon=True, name="homecore-update-manager", ).start()