393 lines
11 KiB
Python
393 lines
11 KiB
Python
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()
|