diff --git a/homecore_controller/CHANGELOG.md b/homecore_controller/CHANGELOG.md index cac1a36..f5cbcc7 100644 --- a/homecore_controller/CHANGELOG.md +++ b/homecore_controller/CHANGELOG.md @@ -1,8 +1,14 @@ -# Changelog +# HomeCore Controller Changelog + +## 0.1.1 + +- Controller setup can no longer create properties. +- Owner signs in first. +- HomeCore displays only homes already owned by that account. +- Installer selects the correct home from a list. +- Controller binding remains permanent. +- Improved commissioning errors and version reporting. ## 0.1.0 -- Initial Owner/property binding. -- Persistent Ed25519 controller identity. -- Signed controller session authentication. -- Home Assistant version heartbeat. -- Ingress commissioning screen. + +- Initial HomeCore Controller commissioning release. diff --git a/homecore_controller/app/main.py b/homecore_controller/app/main.py index 9dae2ae..f891160 100644 --- a/homecore_controller/app/main.py +++ b/homecore_controller/app/main.py @@ -1,82 +1,482 @@ from pathlib import Path -import os,json,base64,hashlib,time,threading +import os +import json +import base64 +import hashlib +import time +import threading +import secrets +import html + import httpx -from fastapi import FastAPI,Form,HTTPException +from fastapi import FastAPI, Form, HTTPException from fastapi.responses import HTMLResponse from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization import uvicorn -DATA=Path('/config');DATA.mkdir(parents=True,exist_ok=True) -STATE=DATA/'state.json';KEY=DATA/'controller_ed25519.pem';OPTIONS=Path('/data/options.json') +VERSION = "0.1.1" + +DATA = Path("/config") +DATA.mkdir(parents=True, exist_ok=True) + +STATE = DATA / "state.json" +KEY = DATA / "controller_ed25519.pem" +OPTIONS = Path("/data/options.json") + +PENDING = {} def options(): - try:return json.loads(OPTIONS.read_text()) - except Exception:return {'cloud_api':'https://api.homecore.example.com'} -def ensure_key(): - if KEY.exists():return serialization.load_pem_private_key(KEY.read_bytes(),password=None) - k=Ed25519PrivateKey.generate();KEY.write_bytes(k.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()));return k -KEYOBJ=ensure_key() -def pub_pem():return KEYOBJ.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo).decode() -def fingerprint():return hashlib.sha256(KEYOBJ.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw)).hexdigest() -def load_state(): - try:return json.loads(STATE.read_text()) - except Exception:return {} -def save_state(s):STATE.write_text(json.dumps(s,indent=2)) -def supervisor_get(path): - token=os.getenv('SUPERVISOR_TOKEN') - if not token:return None try: - r=httpx.get('http://supervisor'+path,headers={'Authorization':'Bearer '+token},timeout=5);r.raise_for_status();return r.json() - except Exception:return None + return json.loads(OPTIONS.read_text()) + except Exception: + return {"cloud_api": "https://api.homecore.home-core.co.uk"} + +def ensure_key(): + if KEY.exists(): + return serialization.load_pem_private_key(KEY.read_bytes(), password=None) + + k = Ed25519PrivateKey.generate() + KEY.write_bytes( + k.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + return k + +KEYOBJ = ensure_key() + +def pub_pem(): + return KEYOBJ.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + +def fingerprint(): + raw = KEYOBJ.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + return hashlib.sha256(raw).hexdigest() + +def load_state(): + try: + return json.loads(STATE.read_text()) + except Exception: + return {} + +def save_state(s): + STATE.write_text(json.dumps(s, indent=2)) + +def supervisor_get(path): + token = os.getenv("SUPERVISOR_TOKEN") + if not token: + return None + + try: + r = httpx.get( + "http://supervisor" + path, + headers={"Authorization": "Bearer " + token}, + timeout=5, + ) + r.raise_for_status() + return r.json() + except Exception: + return None + +def ha_version(): + info = supervisor_get("/core/info") or {} + if isinstance(info, dict): + return ((info.get("data") or {}).get("version") or "") + return "" + +def cloud_detail(response): + try: + data = response.json() + if isinstance(data, dict): + return data.get("detail") or str(data) + except Exception: + pass + return f"HomeCore Cloud returned HTTP {response.status_code}" def controller_session(): - s=load_state() - if not s.get('controller_id'):return None - api=options()['cloud_api'].rstrip('/') + s = load_state() + if not s.get("controller_id"): + return None + + api = options()["cloud_api"].rstrip("/") + with httpx.Client(timeout=15) as c: - ch=c.get(f"{api}/api/v1/controllers/{s['controller_id']}/challenge");ch.raise_for_status();challenge=ch.json()['challenge'] - sig=base64.b64encode(KEYOBJ.sign(challenge.encode())).decode() - r=c.post(f"{api}/api/v1/controllers/{s['controller_id']}/session",json={'challenge':challenge,'signature_b64':sig});r.raise_for_status();return r.json()['controller_token'] + ch = c.get(f"{api}/api/v1/controllers/{s['controller_id']}/challenge") + ch.raise_for_status() + + challenge = ch.json()["challenge"] + + sig = base64.b64encode( + KEYOBJ.sign(challenge.encode()) + ).decode() + + r = c.post( + f"{api}/api/v1/controllers/{s['controller_id']}/session", + json={ + "challenge": challenge, + "signature_b64": sig, + }, + ) + r.raise_for_status() + + return r.json()["controller_token"] def heartbeat_loop(): while True: try: - s=load_state() - if s.get('controller_id'): - token=controller_session(); api=options()['cloud_api'].rstrip('/') - info=supervisor_get('/core/info') or {}; ha_ver=((info.get('data') or {}).get('version') or '') if isinstance(info,dict) else '' - httpx.post(f"{api}/api/v1/controllers/{s['controller_id']}/heartbeat",headers={'Authorization':'Bearer '+token},json={'software_version':'0.1.0','ha_version':ha_ver},timeout=10) - except Exception:pass + s = load_state() + + if s.get("controller_id"): + token = controller_session() + api = options()["cloud_api"].rstrip("/") + + httpx.post( + f"{api}/api/v1/controllers/{s['controller_id']}/heartbeat", + headers={"Authorization": "Bearer " + token}, + json={ + "software_version": VERSION, + "ha_version": ha_version(), + }, + timeout=10, + ) + + except Exception: + pass + time.sleep(60) -app=FastAPI(title='HomeCore Controller',version='0.1.0') -STYLE='''''' +app = FastAPI( + title="HomeCore Controller", + version=VERSION, +) -@app.get('/',response_class=HTMLResponse) +STYLE = ''' + + + +''' + +def page(body): + return HTMLResponse( + STYLE + + '
{html.escape(message)}
' + 'No controller binding has been changed.
' + ) + +@app.get("/", response_class=HTMLResponse) def setup_page(): - s=load_state() - if s.get('controller_id'): - return STYLE+f'Controller: {s["controller_id"]}
Property: {s.get("property_id")}
This Green is permanently bound to the HomeCore property.
Sign in with the customer's HomeCore Owner account. HomeCore will create or select the property and permanently bind this Home Assistant Green.
Controller: {html.escape(s["controller_id"])}
' + f'Property: {html.escape(s.get("property_id",""))}
' + f'HomeCore Controller version: {VERSION}
' + 'This Home Assistant Green is permanently bound to this HomeCore property.
' + ) + + return page( + 'Sign in with the customer\'s HomeCore Owner account. ' + 'You will then choose one of the homes already created in HomeCore.
' + + '' + + '' + 'Homes must be created in the HomeCore customer portal before a controller can be connected.' + '
' + ) + +@app.post("/select-property", response_class=HTMLResponse) +def select_property( + email: str = Form(...), + password: str = Form(...), +): + + api = options()["cloud_api"].rstrip("/") + + try: + with httpx.Client(timeout=20) as c: + + r = c.post( + api + "/api/v1/auth/login", + json={ + "email": email, + "password": password, + }, + ) + + if r.status_code >= 400: + return error_page("Incorrect HomeCore email address or password.") + + token = r.json()["access_token"] + + headers = { + "Authorization": "Bearer " + token + } + + r = c.get( + api + "/api/v1/properties", + headers=headers, + ) + + if r.status_code >= 400: + return error_page(cloud_detail(r)) + + owned = [ + p for p in r.json() + if p.get("owner") is True + ] + + except Exception as e: + return error_page( + "The controller could not contact HomeCore Cloud. " + str(e) + ) + + if not owned: + return error_page( + "This Owner account does not have a HomeCore home yet. " + "Create the home at homecore.home-core.co.uk, then return here." + ) + + nonce = secrets.token_urlsafe(32) + + PENDING[nonce] = { + "token": token, + "expires": time.time() + 300, + "property_ids": [p["id"] for p in owned], + } + + choices = "".join( + f'' + for p in owned + ) + + return page( + 'Only homes owned by this HomeCore account can be connected.
' + + '' + + '' + 'After connection, this controller cannot be claimed by another HomeCore account.' + '
' + ) + +@app.post("/bind", response_class=HTMLResponse) +def bind( + nonce: str = Form(...), + property_id: str = Form(...), +): + + if load_state().get("controller_id"): + raise HTTPException( + 409, + "Controller already bound", + ) + + pending = PENDING.get(nonce) + + if not pending: + return error_page( + "The setup session has expired. Sign in again." + ) + + if pending["expires"] < time.time(): + PENDING.pop(nonce, None) + return error_page( + "The setup session has expired. Sign in again." + ) + + if property_id not in pending["property_ids"]: + return error_page( + "This HomeCore account does not own the selected property." + ) + + token = pending["token"] + + api = options()["cloud_api"].rstrip("/") + + headers = { + "Authorization": "Bearer " + token + } + + payload = { + "property_id": property_id, + "public_key": pub_pem(), + "fingerprint": fingerprint(), + "software_version": VERSION, + "ha_version": ha_version(), + } + + try: + with httpx.Client(timeout=20) as c: + + r = c.post( + api + "/api/v1/controllers/bind", + headers=headers, + json=payload, + ) + + if r.status_code >= 400: + return error_page(cloud_detail(r)) + + out = r.json() + + except Exception as e: + return error_page( + "The controller could not contact HomeCore Cloud. " + str(e) + ) + + PENDING.pop(nonce, None) + save_state(out) - return HTMLResponse(STYLE+'This controller is now bound to the customer property. Sign into the HomeCore app with the same Owner account.
This Home Assistant Green is now permanently bound to the selected HomeCore home.
' + 'The customer can now sign into HomeCore using their normal HomeCore account.
' + f'HomeCore Controller {VERSION}
' + ) -threading.Thread(target=heartbeat_loop,daemon=True).start() -if __name__=='__main__':uvicorn.run(app,host='0.0.0.0',port=8099) +@app.get("/health") +def health(): + return { + "status": "ok", + "bound": bool(load_state().get("controller_id")), + "fingerprint": fingerprint(), + "version": VERSION, + } + +threading.Thread( + target=heartbeat_loop, + daemon=True, +).start() + +if __name__ == "__main__": + uvicorn.run( + app, + host="0.0.0.0", + port=8099, + ) diff --git a/homecore_controller/config.yaml b/homecore_controller/config.yaml index 38b1b9b..a8f56ad 100644 --- a/homecore_controller/config.yaml +++ b/homecore_controller/config.yaml @@ -1,5 +1,5 @@ name: HomeCore Controller -version: "0.1.0" +version: "0.1.1" slug: homecore_controller description: HomeCore local controller agent for Home Assistant Green. url: https://homecore.home-core.co.uk