SmartestHome/tools/generate-tokens.py

120 lines
5.2 KiB
Python
Executable File

#!/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.",
"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))