#!/usr/bin/env python3 """Fill in any empty service tokens in CoreSystemConfig.json, and write tokens.txt. Runs before validation on every build (see lib/coreconfig.sh), so a fresh config only needs the interesting fields filled in — addresses, rooms, which services you want — and the random secrets appear on their own. THE CRITICAL PART IS THAT GENERATED TOKENS ARE WRITTEN BACK TO THE CONFIG. A token is only useful because two machines agree on it: the container host runs `identity` with IDENTITY_TOKEN, and every kiosk image is built with the same value baked into its URLs. Generating fresh randomness per build would produce a door panel that cannot talk to the service it was built for — so this fills empties ONCE, persists them, and never touches a value that is already set. tokens.txt is a convenience copy for the human: same values, plus what each one is for and which of them this script deliberately cannot generate. Usage: generate-tokens.py [config.json] """ from __future__ import annotations import json import secrets import sys from pathlib import Path # name -> (enable flag that makes it required, what it's for) GENERATABLE = { "identity_token": ("identity", "identity's API — also baked into the door panel and kitchen display"), "pantry_vision_token": ("pantry_vision", "pantry-vision's API — also baked into the kitchen display"), "transit_token": ("transit", "transit's API — called by Home Assistant for voice departures"), } # Deliberately NOT generated, with the reason. Inventing a value for any of these would # produce something that looks configured and cannot work. NOT_GENERATABLE = { "ha_token": "a Long-Lived Access Token from Home Assistant's own UI (profile -> Security). " "It doesn't exist until HA is running and an account exists, and only HA can mint it.", "opnsense_api_key": "issued by OPNsense itself (System -> Access -> Users -> API keys), which " "shows the key and secret once and downloads them as a .txt. Give that user " "only the 'Services: Intrusion Detection' privilege.", "opnsense_api_secret": "the other half of the OPNsense API key above — they are minted together " "and only OPNsense has them.", "mqtt_password": "must match what Mosquitto is configured to accept. Generating one here " "would just mean nothing can connect.", "admin_password_hash": "a crypt(3) hash for the installer — generate with: mkpasswd -m sha-512", "kiosk_password": "only needed if a kiosk account should have a password at all.", "ssh_authorized_key": "your existing PUBLIC key (~/.ssh/id_ed25519.pub) — a generated one " "would have no private half you hold.", } def main(argv: list[str]) -> int: path = Path(argv[1]) if len(argv) > 1 else Path(__file__).resolve().parent.parent / "CoreSystemConfig.json" if not path.exists(): print(f"error: {path} not found", file=sys.stderr) return 2 try: cfg = json.loads(path.read_text()) except json.JSONDecodeError as exc: print(f"error: {path} is not valid JSON — line {exc.lineno}: {exc.msg}", file=sys.stderr) return 2 secrets_block = cfg.setdefault("secrets", {}) enable = (cfg.get("container_host", {}) or {}).get("enable", {}) or {} generated = [] for name, (flag, _purpose) in GENERATABLE.items(): # A token for a service you haven't enabled isn't needed, and filling it in # anyway would put a live-looking credential in a file for no reason. if not enable.get(flag): continue if (secrets_block.get(name) or "").strip(): continue secrets_block[name] = secrets.token_hex(32) generated.append(name) if generated: # Written back with the same 2-space indent the template uses, so the diff is # just the token lines rather than the whole file reformatting. path.write_text(json.dumps(cfg, indent=2) + "\n") print(f" generated {len(generated)} token(s) into {path.name}: {', '.join(generated)}") _write_tokens_txt(path.parent / "tokens.txt", cfg, enable) return 0 def _write_tokens_txt(dest: Path, cfg: dict, enable: dict) -> None: secrets_block = cfg.get("secrets", {}) or {} lines = [ "SmartestHome — service tokens", "=" * 60, "", "GENERATED by tools/generate-tokens.py from CoreSystemConfig.json.", "This is a convenience copy for you, not a source of truth — the config is.", "Regenerated on every build; edit the config, not this file.", "", "TREAT THIS FILE AS A CREDENTIAL. It is gitignored, but it is plain text on", "disk: every token below grants full API access to the service named, and", "identity's in particular can grant a person the right to open a smart lock.", "", ] for name, (flag, purpose) in GENERATABLE.items(): if not enable.get(flag): lines += [f"{name}:", " (not set — container_host.enable." + flag + " is false)", ""] continue value = (secrets_block.get(name) or "").strip() or "(EMPTY — run a build to generate)" lines += [f"{name}:", f" {value}", f" for: {purpose}", ""] lines += ["", "Not generated here, and why:", ""] for name, why in NOT_GENERATABLE.items(): value = (secrets_block.get(name) or "").strip() state = "set" if value else "EMPTY" lines += [f"{name} [{state}]", f" {why}", ""] dest.write_text("\n".join(lines)) print(f" wrote {dest.name}") if __name__ == "__main__": sys.exit(main(sys.argv))