// How home-assistant's provisions step brings Home Assistant's integrations in line with what the // mesh bound: the MQTT integration to `mqtt-topic`, the Sonarr, Radarr and Lidarr integrations to // `sonarr-api`, `radarr-api` and `lidarr-api`. Pure logic over two seams — Home Assistant's config // flows (hass.ts) and the broker/apps — so it is tested against fakes (test/provisions.test.ts). // // The half that reads files and talks HTTP lives beside it (mesh.ts, hass.ts, probe.ts, index.ts). import { createHash } from "node:crypto"; import type { Hass, SchemaField } from "./hass.js"; import type { Probe } from "./probe.js"; /** What the mesh wrote at `binds.` (the controller's binding document). */ export interface Binding { provision?: string; from?: string; at?: string; as?: string; serves?: Record; } /** How one provision came out. Never carries a credential. */ export type Outcome = | { what: string; result: "unchanged"; note?: string } | { what: string; result: "written"; fields: string[]; note?: string } | { what: string; result: "equivalent"; note: string } | { what: string; result: "refused"; problem: string }; /** A port the binding serves, or undefined when it names none usable. */ export function portOf(serves: Record | undefined): number | undefined { const port = Number(serves?.port); return Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined; } /** A host as it goes into a URL: an IPv6 literal bracketed. */ export function urlHost(host: string): string { return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; } /** * What this step last wrote, per target, as a digest: the only way to know "already as the mesh * says" for a credential Home Assistant will not show back. A sha256 over the target and the values, * never the values; kept in the module's own placed directory. */ export interface Marks { get(name: string): Promise; set(name: string, digest: string): Promise; } export function digest(...parts: (string | number)[]): string { return createHash("sha256").update(parts.map(String).join("\u0000")).digest("hex"); } /** An error as text with the credential taken out, raw and URL-encoded. */ export function scrub(err: unknown, ...secrets: (string | undefined)[]): string { let text = err instanceof Error ? err.message : String(err); for (const s of secrets) { if (!s) continue; for (const form of new Set([s, encodeURIComponent(s)])) text = text.split(form).join("***"); } return text; } /** * What a form would submit if a person pressed "submit" without touching it: each field's * suggested value (what Home Assistant pre-fills from the entry), else its default; a section's * fields nested under its name. The step lays only the connection fields over this, so every other * choice the entry carries is sent back exactly as Home Assistant showed it. */ export function formValues(schema: readonly SchemaField[] | null | undefined): Record { const out: Record = {}; for (const field of schema ?? []) { if (Array.isArray(field.schema)) { out[field.name] = formValues(field.schema); continue; } const suggested = field.description?.suggested_value; if (suggested !== undefined && suggested !== null) out[field.name] = suggested; else if (field.default !== undefined) out[field.name] = field.default; } return out; } /** Whether a form has a field of this name at its top level. */ export function hasField(schema: readonly SchemaField[] | null | undefined, name: string): boolean { return (schema ?? []).some((f) => f.name === name); } // ---- MQTT ---- // Home Assistant's MQTT integration, pointed at the broker the mesh bound — `mqtt-topic`. // // **Why a step.** Home Assistant keeps its broker, login and password in its MQTT config entry // (`.storage/core.config_entries`), not in a file the mesh could fill with `${bound:mqtt-topic:at}`. // So this reads the binding and the pair credential and makes the entry say the same thing, through // the MQTT integration's own reconfigure flow — the flow its "Reconfigure" button runs, which tests // the connection itself and saves nothing it could not connect with. // // **Only the connection, and only when it differs.** Broker, port, username, password. The protocol // version, client id, keepalive, TLS choices and discovery options the entry holds are sent back // exactly as Home Assistant pre-filled them. Whether the password already matches cannot be read // back (Home Assistant never shows a stored password), so the step keeps a digest of what it last // wrote: equal broker/port/username and an equal digest is "already as the mesh says". // // **Nothing loses its connection without someone seeing it.** Before Home Assistant is touched the // broker itself is asked whether it takes the delivered login (the provisioner creates it within // seconds of the grant): if not, nothing is written and the step fails saying why, and Home // Assistant keeps the login it has — the carried `luffy` on ace, which mosquitto keeps. If Home // Assistant's own connection test refuses the new settings, the flow saves nothing, and the step // fails with Home Assistant's reason. A login that may not subscribe to the discovery topics is said // as a warning: discovery would find nothing. export const MQTT_PROVISION = "mqtt-topic"; /** Home Assistant's discovery prefix, subscribed to whenever discovery is on (the default). */ export const DISCOVERY_FILTER = "homeassistant/#"; export interface MqttWanted { host: string; port: number; username: string; password: string; } export type Wanted = { ok: true; want: MqttWanted } | { ok: false; problem: string }; /** The broker, port and login the mesh says Home Assistant uses. */ export function wantedMqtt(binding: Binding | undefined, credential: string | undefined): Wanted { if (!binding) return { ok: false, problem: `no binding for ${MQTT_PROVISION} was delivered — the mesh writes it before this step runs` }; const host = typeof binding.at === "string" ? binding.at.trim() : ""; if (!host) return { ok: false, problem: `the ${MQTT_PROVISION} binding names no host (at)` }; const port = portOf(binding.serves); if (port === undefined) return { ok: false, problem: `the ${MQTT_PROVISION} binding serves no usable port (${String(binding.serves?.port)})` }; const scheme = binding.serves?.scheme; if (scheme !== undefined && scheme !== "mqtt") { return { ok: false, problem: `the ${MQTT_PROVISION} binding serves scheme ${String(scheme)}; this step writes plain MQTT` }; } const username = typeof binding.as === "string" ? binding.as.trim() : ""; if (!username) return { ok: false, problem: `the ${MQTT_PROVISION} binding names no login (as)` }; const password = (credential ?? "").replace(/\n$/, ""); if (!password) return { ok: false, problem: `the ${MQTT_PROVISION} credential is empty or was not delivered` }; return { ok: true, want: { host, port, username, password } }; } export interface MqttDeps { hass: Hass; probe: Probe; marks: Marks; } const markFor = (entryId: string, w: MqttWanted): string => digest("mqtt", entryId, w.host, w.port, w.username, w.password); /** Bring Home Assistant's MQTT entry in line with the mesh. Never throws: every failure is an outcome. */ export async function reconcileMqtt(deps: MqttDeps, binding: Binding | undefined, credential: string | undefined): Promise { const what = "mqtt"; const w = wantedMqtt(binding, credential); if ("problem" in w) return { what, result: "refused", problem: w.problem }; const want = w.want; // The broker first: a login it does not take is never written into Home Assistant. let note: string | undefined; try { const probe = await deps.probe(want.host, want.port, want.username, want.password, DISCOVERY_FILTER); if (probe.connack === 4 || probe.connack === 5) { return { what, result: "refused", problem: `the broker at ${want.host}:${want.port} does not (yet) take the login ${want.username} with the delivered ` + `password (CONNACK ${probe.connack}); mosquitto's provisioner creates it from the grant — nothing was ` + `written, and Home Assistant keeps the broker login it has`, }; } if (probe.connack !== 0) { return { what, result: "refused", problem: `the broker at ${want.host}:${want.port} answered CONNACK ${probe.connack}; nothing was written` }; } if (probe.suback === 0x80) { note = `warning: ${want.username} may not subscribe to ${DISCOVERY_FILTER} — MQTT discovery will find nothing; ` + `grant it with the mqtt-topic contribution's \`topics\``; } } catch (err) { return { what, result: "refused", problem: `the broker at ${want.host}:${want.port} could not be asked: ${scrub(err, want.password)}; nothing was written`, }; } try { const entries = (await deps.hass.entries("mqtt")).filter((e) => e.domain === "mqtt"); if (entries.length > 1) { return { what, result: "refused", problem: `Home Assistant has ${entries.length} MQTT entries; which one the mesh owns is not guessed` }; } if (entries.length === 0) return await createEntry(deps, want, note); const entry = entries[0]; const flow = await deps.hass.startFlow("mqtt", entry.entry_id); if (flow.type !== "form" || !flow.flow_id || !flow.data_schema) { if (flow.flow_id) await deps.hass.abortFlow(flow.flow_id); return { what, result: "refused", problem: `Home Assistant's MQTT reconfigure flow answered ${flow.type}${flow.reason ? ` (${flow.reason})` : ""}` }; } const current = formValues(flow.data_schema); const fields: string[] = []; if (String(current.broker ?? "") !== want.host) fields.push("broker"); if (Number(current.port ?? 0) !== want.port) fields.push("port"); if (String(current.username ?? "") !== want.username) fields.push("username"); if ((await deps.marks.get("mqtt")) !== markFor(entry.entry_id, want)) fields.push("password"); if (fields.length === 0) { await deps.hass.abortFlow(flow.flow_id); return note ? { what, result: "unchanged", note } : { what, result: "unchanged" }; } const saved = await deps.hass.stepFlow(flow.flow_id, { ...current, broker: want.host, port: want.port, username: want.username, password: want.password, }); if (saved.type === "abort" && saved.reason === "reconfigure_successful") { await deps.marks.set("mqtt", markFor(entry.entry_id, want)); return { what, result: "written", fields, ...(note ? { note } : {}) }; } if (saved.flow_id) await deps.hass.abortFlow(saved.flow_id); return { what, result: "refused", problem: `Home Assistant's own connection test refused ${want.username}@${want.host}:${want.port} ` + `(${describe(saved)}); its MQTT entry is unchanged`, }; } catch (err) { return { what, result: "refused", problem: scrub(err, want.password) }; } } /** A fresh Home Assistant has no MQTT entry: made through the integration's user flow. */ async function createEntry(deps: MqttDeps, want: MqttWanted, note?: string): Promise { const what = "mqtt"; let flow = await deps.hass.startFlow("mqtt"); if (flow.type === "form" && flow.step_id !== "broker" && flow.flow_id) { // Anything before the broker form (none outside the Supervisor) is not this step's to answer. await deps.hass.abortFlow(flow.flow_id); return { what, result: "refused", problem: `Home Assistant's MQTT user flow asked ${flow.step_id} before the broker` }; } if (flow.type !== "form" || !flow.flow_id) { return { what, result: "refused", problem: `Home Assistant's MQTT user flow answered ${describe(flow)}` }; } const shown = formValues(flow.data_schema); // A new entry's form has no value for its two certificate choices (a reconfigure pre-fills them // from the entry): plain MQTT, so neither a CA nor a client certificate. const other = (shown.other_settings ?? {}) as Record; if (flow.data_schema?.some((f) => f.name === "other_settings")) { shown.other_settings = { set_ca_cert: "off", set_client_cert: false, ...other }; } flow = await deps.hass.stepFlow(flow.flow_id, { ...shown, broker: want.host, port: want.port, username: want.username, password: want.password, }); if (flow.type === "create_entry") { const id = (flow.result as { entry_id?: string } | undefined)?.entry_id; if (id) await deps.marks.set("mqtt", markFor(id, want)); return { what, result: "written", fields: ["entry"], ...(note ? { note } : {}) }; } if (flow.flow_id) await deps.hass.abortFlow(flow.flow_id); return { what, result: "refused", problem: `Home Assistant refused a new MQTT entry for ${want.host}:${want.port} (${describe(flow)})` }; } export function describe(r: { type: string; reason?: string; errors?: Record | null }): string { const errors = r.errors ? Object.entries(r.errors).map(([k, v]) => `${k}: ${v}`).join(", ") : ""; return [r.type, r.reason, errors].filter(Boolean).join(" — "); } // ---- Sonarr, Radarr, Lidarr ---- // Home Assistant's Sonarr, Radarr and Lidarr integrations, pointed at the apps the mesh bound — // `sonarr-api`, `radarr-api`, `lidarr-api` (their providers: mesh-catalog #156). // // **What Home Assistant lets anyone change, and what it does not.** Each integration keeps a URL and // an API key in its config entry. None of the three has a reconfigure flow: Home Assistant changes // them only through the flow its UI runs — // - a **user flow** makes a new entry (validated against the app); // - a **reauth flow**, which Home Assistant starts by itself when the app refuses the key it holds, // takes a new key (Sonarr) or a new URL and key (Radarr, Lidarr); // - anything else — the URL of a working entry — only by removing the integration and adding it // again, which throws away its entities' names, areas and history links. **This step never // removes an entry.** // So, per app: // 1. The bound key is tried against the bound app first. Refused, nothing is written: until the // operator accepts the app's own key for this pair, the mesh delivers a value it minted, which // no Servarr app takes (novox/hq ADR 0092) — the failure names the `secret accept` that fixes it. // 2. No entry: one is made through the user flow. // 3. A reauth flow Home Assistant started for the entry: finished with the bound key (and URL, // where the integration's reauth asks for one). // 4. An entry whose URL (read from the device the integration registered, `configuration_url`) // is the bound one and which is loaded: already as the mesh says. The key needs no digest here: // a Servarr app has one key, so an entry loaded against the app holds the key the app took. // 5. A working entry at a different URL that reaches **the same app** — the same process, by the // app's own status (start time, data folder, version) — is left as it is and said: ace's entries // say `127.0.0.1:` and the binding says `ace.internal:`, one Sonarr either way. // 6. Anything else is refused, loudly, with what the operator can do; nothing is removed. export interface ServarrApp { /** The integration's domain, also the app. */ domain: "sonarr" | "radarr" | "lidarr"; /** The provision it is required as: the `requires`, `binds` and `secrets` key. */ provision: string; /** The app's status endpoint: answers 401 to a wrong key, and says which process answered. */ statusPath: string; } export const APPS: readonly ServarrApp[] = [ { domain: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" }, { domain: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status" }, { domain: "lidarr", provision: "lidarr-api", statusPath: "/api/v1/system/status" }, ]; /** The HTTP the step needs toward the apps, so a test can stand fakes in. */ export interface Http { fetch(url: string, init?: { method?: string; headers?: Record }): Promise<{ status: number; text(): Promise }>; } export type AppWanted = { ok: true; url: string; key: string; from: string } | { ok: false; problem: string }; /** The URL and key the mesh says Home Assistant uses for this app. */ export function wantedApp(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): AppWanted { if (!binding) return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` }; const at = typeof binding.at === "string" ? binding.at.trim() : ""; if (!at) return { ok: false, problem: `the ${spec.provision} binding names no host (at)` }; const port = portOf(binding.serves); if (port === undefined) return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(binding.serves?.port)})` }; const scheme = typeof binding.serves?.scheme === "string" && binding.serves.scheme ? binding.serves.scheme : "http"; if (scheme !== "http" && scheme !== "https") return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}` }; const base = typeof binding.serves?.["url-base"] === "string" ? String(binding.serves["url-base"]).trim().replace(/^\/+|\/+$/g, "") : ""; const key = (credential ?? "").trim(); if (!key) return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` }; return { ok: true, url: `${scheme}://${urlHost(at)}:${port}${base ? `/${base}` : ""}`, key, from: typeof binding.from === "string" ? binding.from : "", }; } /** Two URLs naming the same place: scheme, host, port (explicit or default) and base path. */ export function sameUrl(a: string | null | undefined, b: string): boolean { if (!a) return false; try { const x = new URL(a); const y = new URL(b); const port = (u: URL) => u.port || (u.protocol === "https:" ? "443" : "80"); const path = (u: URL) => u.pathname.replace(/\/+$/, ""); return x.protocol === y.protocol && x.hostname.toLowerCase() === y.hostname.toLowerCase() && port(x) === port(y) && path(x) === path(y); } catch { return false; } } type Status = { taken: true; status: Record } | { taken: false }; /** The app's status with this key: `taken: false` when it refuses the key; throws when it cannot be asked. */ export async function appStatus(http: Http, spec: ServarrApp, url: string, key: string): Promise { const res = await http.fetch(`${url.replace(/\/+$/, "")}${spec.statusPath}`, { method: "GET", headers: { "X-Api-Key": key, Accept: "application/json" }, }); if (res.status === 401 || res.status === 403) return { taken: false }; if (res.status < 200 || res.status >= 300) throw new Error(`${spec.domain} answered ${res.status} at ${spec.statusPath}`); return { taken: true, status: JSON.parse(await res.text()) as Record }; } /** Whether two status answers came from one running app. */ export function sameInstance(a: Record, b: Record): boolean { const facts = ["startTime", "appData", "version"]; return facts.every((k) => a[k] !== undefined && a[k] !== null && a[k] === b[k]); } /** The remedy for a refused key, in the controller's words (ADR 0092). */ export function acceptRemedy(spec: ServarrApp, from: string): string { return ( `${spec.domain} refuses the ${spec.provision} credential the mesh delivered, so nothing was written into ` + `Home Assistant. A Servarr app has one API key and the mesh cannot make it: accept ${spec.domain}'s own key ` + `for this pair — \`secret accept home-assistant ${spec.provision} --provider ${from || ""} ` + `--from \`` ); } export interface ServarrDeps { hass: Hass; http: Http; } /** The input a Servarr form takes: what it shows, with the URL (where asked) and the key laid over. */ function servarrInput(schema: readonly SchemaField[] | null | undefined, url: string, key: string): Record { const input = formValues(schema); if (hasField(schema, "url")) input.url = url; if (hasField(schema, "api_key")) input.api_key = key; return input; } /** Bring Home Assistant's entry for one app in line with the mesh. Never throws. */ export async function reconcileApp(deps: ServarrDeps, spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Promise { const what = spec.domain; const w = wantedApp(spec, binding, credential); if ("problem" in w) return { what, result: "refused", problem: w.problem }; let bound: Status; try { bound = await appStatus(deps.http, spec, w.url, w.key); } catch (err) { return { what, result: "refused", problem: `${spec.domain} could not be asked at ${w.url}: ${scrub(err, w.key)}` }; } if (!bound.taken) return { what, result: "refused", problem: acceptRemedy(spec, w.from) }; try { const entries = (await deps.hass.entries(spec.domain)).filter((e) => e.domain === spec.domain); if (entries.length > 1) { return { what, result: "refused", problem: `Home Assistant has ${entries.length} ${spec.domain} entries; which one the mesh owns is not guessed` }; } // No entry: made, through the integration's own user flow, which validates the key itself. if (entries.length === 0) { const flow = await deps.hass.startFlow(spec.domain); if (flow.type !== "form" || !flow.flow_id) return { what, result: "refused", problem: `Home Assistant's ${spec.domain} user flow answered ${describe(flow)}` }; const made = await deps.hass.stepFlow(flow.flow_id, servarrInput(flow.data_schema, w.url, w.key)); if (made.type === "create_entry") return { what, result: "written", fields: ["entry"] }; if (made.flow_id) await deps.hass.abortFlow(made.flow_id); return { what, result: "refused", problem: `Home Assistant refused a new ${spec.domain} entry at ${w.url} (${describe(made)})` }; } const entry = entries[0]; if (entry.disabled_by) return { what, result: "unchanged", note: `the ${spec.domain} entry is disabled (by ${entry.disabled_by}); left alone` }; // A reauth Home Assistant started because the app refused its key: finished with the bound one. const reauth = (await deps.hass.flowsInProgress()).find( (f) => f.handler === spec.domain && f.context?.source === "reauth" && f.context?.entry_id === entry.entry_id, ); if (reauth) { let step = await deps.hass.stepFlow(reauth.flow_id, {}); // reauth_confirm: a confirmation, no fields if (step.type === "form" && step.flow_id && step.step_id !== "reauth_confirm") { const input = servarrInput(step.data_schema, w.url, w.key); const fields = ["api_key", ...(hasField(step.data_schema, "url") ? ["url"] : [])]; step = await deps.hass.stepFlow(step.flow_id, input); if (step.type === "abort" && step.reason === "reauth_successful") return { what, result: "written", fields }; } return { what, result: "refused", problem: `Home Assistant's ${spec.domain} reauth did not take the bound key and URL (${describe(step)})` }; } const device = (await deps.hass.devices()).find((d) => d.config_entries?.includes(entry.entry_id) && d.configuration_url); const current = device?.configuration_url ?? undefined; if (entry.state === "loaded" && sameUrl(current, w.url)) return { what, result: "unchanged" }; if (entry.state === "loaded" && current) { let there: Status | undefined; try { there = await appStatus(deps.http, spec, current, w.key); } catch { there = undefined; } if (there?.taken && sameInstance(there.status, bound.status)) { return { what, result: "equivalent", note: `Home Assistant reaches ${spec.domain} at ${current}, the same running app the mesh bound at ${w.url}; ` + `Home Assistant has no way to change a working ${spec.domain} entry's URL short of removing it, so it is left as it is`, }; } } return { what, result: "refused", problem: `Home Assistant's ${spec.domain} entry (${entry.state ?? "unknown state"}) points at ${current ?? "an unknown URL"}, ` + `not the ${spec.domain} the mesh bound at ${w.url}. Home Assistant only lets a working entry's URL change by ` + `removing and re-adding the integration, which this step never does: remove it in Home Assistant ` + `(Settings → Devices & services → ${spec.domain}) and the next run adds it at the bound URL`, }; } catch (err) { return { what, result: "refused", problem: scrub(err, w.key) }; } }