Compare commits

..
Author SHA1 Message Date
jschoubben 1e34ecc16b home-assistant: a new MQTT entry says plain MQTT for its certificate choices
Home Assistant's MQTT user flow shows no value for set_ca_cert and set_client_cert (a reconfigure
pre-fills them from the entry), and refuses a submit without them — found against the pinned
2026.9.3 in a throwaway instance.
2026-09-30 13:11:40 +02:00
jschoubben 31af3f0ecc home-assistant: its broker and its Sonarr, Radarr and Lidarr come from the mesh
Home Assistant reached mosquitto and the three Servarr apps at 127.0.0.1 and a port typed into its
own storage. It now requires mqtt-topic (asking for every topic: discovery and the devices' topics
are its job), sonarr-api, radarr-api and lidarr-api, and a run-once `provisions` step — declared
last, restarted when a binding or pair credential changes — makes Home Assistant's config entries
say what the mesh bound, through Home Assistant's own config flows and never its .storage:

- MQTT: the broker is asked first whether it takes the delivered login; then the integration's
  reconfigure flow sets broker, port, username and password, every other setting sent back as Home
  Assistant pre-filled it, and Home Assistant's own connection test must pass. A digest of what was
  written makes a rerun "already as the mesh says". Refused anywhere, nothing is written and Home
  Assistant keeps the login it has.
- Sonarr/Radarr/Lidarr: the bound key is tried against the app (a minted key is never written; the
  failure names the `secret accept`); no entry is made through the user flow; a reauth Home
  Assistant started is finished with the bound key (and URL where the integration asks); an entry
  already at the bound URL, or at another URL reaching the same running app, is left as it is.
  These integrations have no reconfigure flow, so a working entry elsewhere is refused loudly —
  the step never removes an entry.

The sidecar's URL now uses the port it was given.
2026-09-30 13:01:14 +02:00
jschoubben 958a6f4e3a Merge origin/feat/servarr-api-provision (#156) into feat/home-assistant-for-ace
home-assistant requires sonarr-api, radarr-api and lidarr-api, which #156 makes sonarr, radarr and
lidarr provide; this branch needs those providers to resolve.
2026-09-30 12:49:55 +02:00
jschoubben 1df2a0b346 home-assistant: its directories are placed, and it runs the build in use
The module stated /services/home-assistant/config and bound its route under
/var/lib/mesh — novox's layout, a path no definition may carry (ADR 0112).
The config dir and the module's state are now placed (${dir:config},
${dir:state}); the route binds into ${dir:state}.

The sidecar no longer mounts Home Assistant's config dir: nothing reads
MESH_HOMEASSISTANT_CONFIG_DIR, and the mount handed it the auth store and
secrets.yaml for nothing. The config dir loses owner 1000:1000 — the image
runs as root, the uid was the predecessor's host-user convention.

Two more listens that the software opens by default and LAN devices dial in
on, which a converged filter would otherwise close: 1400 (Sonos event
callback) and 18555 (bundled go2rtc WebRTC). Host network, so the machine
port is the software's.

Image pinned to the 2026.9.3 build ace's predecessor runs (2026-09-18);
Home Assistant migrates its recorder schema, so older than running is unsafe.

Verified: catalogue tests pass with MESH_CATALOGUE on this tree; the pinned
image boots on a fresh root-owned 0700 config dir (manifest 200, API 401
without a token), and refuses X-Forwarded-For from an untrusted proxy (400).
2026-09-29 23:39:41 +02:00
17 changed files with 1303 additions and 1433 deletions
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/home-assistant
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/home-assistant/dist /app/modules/home-assistant/d
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/home-assistant/dist/index.js,/app/modules/home-assistant/dist/tools/index.js
# NOT dist/provisions/index.js: that is a step the host runs to completion, named by the
# `provisions` container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run
# inside the serving sidecar too, and exit it.
+93 -13
View File
@@ -18,7 +18,21 @@
"port": 8123,
"protocol": "tcp",
"from": "mesh",
"why": "the dashboard and the API"
"why": "the dashboard, the API and the companion apps"
},
{
"name": "sonos-events",
"port": 1400,
"protocol": "tcp",
"from": "mesh",
"why": "the Sonos integration's event callback: speakers push their state changes here"
},
{
"name": "webrtc",
"port": 18555,
"protocol": "tcp",
"from": "mesh",
"why": "the bundled go2rtc's WebRTC port, which camera streams to a browser use"
}
],
"resources": [
@@ -28,24 +42,33 @@
"path": "/var/lib/mesh/home-assistant",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/home-assistant/config",
"mode": "0700",
"owner": "1000:1000"
"mode": "0700"
},
{
"id": "written",
"type": "directory",
"mode": "0700"
},
{
"id": "server",
"type": "container",
"name": "home-assistant",
"image": "ghcr.io/home-assistant/home-assistant@sha256:14931c6b13756317849f46da1d01b45937a1150db66c081cfe529d48215943fe",
"image": "ghcr.io/home-assistant/home-assistant@sha256:d8922685169707fd91e8b9729902d975f06157d005e422874d201e0261dda196",
"network": "host",
"env": {
"TZ": "Etc/UTC"
},
"volumes": [
"/services/home-assistant/config:/config"
"${dir:config}:/config"
]
},
{
@@ -64,33 +87,90 @@
"volumes": [
"/var/lib/mesh/home-assistant/broker:/run/secrets/broker:ro",
"/var/lib/mesh/home-assistant/token:/run/secrets/token:ro",
"/var/lib/mesh/home-assistant/config.json:/run/config/config.json:ro",
"/services/home-assistant/config:/var/lib/home-assistant/config:ro"
"/var/lib/mesh/home-assistant/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:8123",
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json",
"MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config"
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
},
{
"id": "provisions",
"type": "container",
"name": "mesh-home-assistant-provisions",
"network": "host",
"run-once": true,
"volumes": [
"/var/lib/mesh/home-assistant/token:/run/secrets/token:ro",
"${dir:written}:/var/lib/home-assistant-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
"${dir:state}/sonarr-api.json:/run/provisions/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/provisions/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/provisions/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/provisions/radarr-api.secret:ro",
"${dir:state}/lidarr-api.json:/run/provisions/lidarr-api.json:ro",
"${dir:state}/lidarr-api.secret:/run/provisions/lidarr-api.secret:ro"
],
"env": {
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_PROVISIONS_DIR": "/run/provisions",
"MESH_WRITTEN_DIR": "/var/lib/home-assistant-provisions"
},
"args": [
"run",
"/app/modules/home-assistant/dist/provisions/index.js"
],
"restart-on": [
"bound-mqtt-topic",
"secret-mqtt-topic",
"bound-sonarr-api",
"secret-sonarr-api",
"bound-radarr-api",
"secret-radarr-api",
"bound-lidarr-api",
"secret-lidarr-api"
],
"artifact": "runtime"
}
],
"requires": [
"route"
"lidarr-api",
"mqtt-topic",
"radarr-api",
"route",
"sonarr-api"
],
"contributes": {
"mqtt-topic": {
"topics": [
"#"
]
},
"route": {
"label": "home-assistant",
"endpoint": "web"
}
},
"binds": {
"route": "/var/lib/mesh/home-assistant/route.json"
"route": "${dir:state}/route.json",
"mqtt-topic": "${dir:state}/mqtt-topic.json",
"sonarr-api": "${dir:state}/sonarr-api.json",
"radarr-api": "${dir:state}/radarr-api.json",
"lidarr-api": "${dir:state}/lidarr-api.json"
},
"secrets": {
"mqtt-topic": "${dir:state}/mqtt-topic.secret",
"sonarr-api": "${dir:state}/sonarr-api.secret",
"radarr-api": "${dir:state}/radarr-api.secret",
"lidarr-api": "${dir:state}/lidarr-api.secret"
},
"build": {
"on": [
+6 -1
View File
@@ -1,9 +1,14 @@
{
"name": "@novox/module-home-assistant",
"version": "0.1.0",
"description": "home-assistant — home automation platform. Its API client, tools and events live here (novox/hq ADR 0039).",
"description": "home-assistant \u2014 home automation platform. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts index.ts tools/index.ts provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
@@ -0,0 +1,486 @@
// 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.<provision>` (the controller's binding document). */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
/** 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<string, unknown> | 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<string | undefined>;
set(name: string, digest: string): Promise<void>;
}
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<string, unknown> {
const out: Record<string, unknown> = {};
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<Outcome> {
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<Outcome> {
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<string, unknown>;
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<string, string> | 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:<port>` and the binding says `ace.internal:<port>`, 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<string, string> }): Promise<{ status: number; text(): Promise<string> }>;
}
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<string, unknown> } | { 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<Status> {
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<string, unknown> };
}
/** Whether two status answers came from one running app. */
export function sameInstance(a: Record<string, unknown>, b: Record<string, unknown>): 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 <this node> home-assistant ${spec.provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${spec.domain}'s ApiKey>\``
);
}
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<string, unknown> {
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<Outcome> {
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) };
}
}
+160
View File
@@ -0,0 +1,160 @@
// Home Assistant's own configuration API, as the provisions step uses it — the supported way to
// change an integration's connection. Home Assistant keeps every integration in
// `.storage/core.config_entries`, a file it owns and rewrites; the mesh may not write it, and it is
// not a file the mesh could merge into. What Home Assistant offers instead is the same thing its UI
// uses: **config flows** over REST (`/api/config/config_entries/flow`) — a user flow creates an
// entry, a reconfigure flow changes one, a reauth flow (which Home Assistant starts itself when a
// credential stops working) replaces its credential — each validated by the integration's own
// connection test before anything is saved. The two things REST does not answer (which flows Home
// Assistant has started, which device an entry made) come over its WebSocket API.
//
// Nothing here reads `.storage`. Authenticated with the module's accepted long-lived access token.
/** A config entry as `GET /api/config/config_entries/entry` lists it — no data, no credentials. */
export interface ConfigEntry {
entry_id: string;
domain: string;
title?: string;
source?: string;
state?: string;
disabled_by?: string | null;
}
/** One field of a flow's form, as Home Assistant serializes a voluptuous schema. */
export interface SchemaField {
name: string;
type?: string;
required?: boolean;
optional?: boolean;
default?: unknown;
description?: { suggested_value?: unknown } | null;
/** A section (`type: "expandable"`) carries its own fields. */
schema?: SchemaField[];
}
/** What a flow answered: another form, an entry made, or the flow ended (abort). */
export interface FlowResult {
type: string;
flow_id?: string;
handler?: string;
step_id?: string;
data_schema?: SchemaField[] | null;
errors?: Record<string, string> | null;
reason?: string;
result?: { entry_id?: string } | unknown;
}
/** A flow in progress that Home Assistant started itself (a reauth, a discovery). */
export interface FlowProgress {
flow_id: string;
handler: string;
step_id?: string;
context?: { source?: string; entry_id?: string };
}
/** A device from the device registry; an integration names where its app is as configuration_url. */
export interface DeviceEntry {
id: string;
config_entries?: string[];
configuration_url?: string | null;
}
export interface Hass {
entries(domain: string): Promise<ConfigEntry[]>;
/** A user flow for `handler`, or — given an entry — a reconfigure flow for it. */
startFlow(handler: string, entryId?: string): Promise<FlowResult>;
stepFlow(flowId: string, input: Record<string, unknown>): Promise<FlowResult>;
abortFlow(flowId: string): Promise<void>;
flowsInProgress(): Promise<FlowProgress[]>;
devices(): Promise<DeviceEntry[]>;
}
/** Home Assistant over HTTP: REST for entries and flows, one short WebSocket session per question. */
export class HassApi implements Hass {
private readonly base: string;
constructor(url: string, private readonly token: string) {
this.base = url.replace(/\/$/, "");
}
private async rest(method: string, path: string, body?: unknown): Promise<unknown> {
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) {
// Home Assistant's error text names fields, never echoes their values.
throw new Error(`Home Assistant ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
}
return text ? (JSON.parse(text) as unknown) : undefined;
}
async entries(domain: string): Promise<ConfigEntry[]> {
return ((await this.rest("GET", `/api/config/config_entries/entry?domain=${encodeURIComponent(domain)}`)) ??
[]) as ConfigEntry[];
}
async startFlow(handler: string, entryId?: string): Promise<FlowResult> {
return (await this.rest("POST", "/api/config/config_entries/flow", {
handler,
show_advanced_options: true,
...(entryId ? { entry_id: entryId } : {}),
})) as FlowResult;
}
async stepFlow(flowId: string, input: Record<string, unknown>): Promise<FlowResult> {
return (await this.rest("POST", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`, input)) as FlowResult;
}
async abortFlow(flowId: string): Promise<void> {
await this.rest("DELETE", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`).catch(() => undefined);
}
async flowsInProgress(): Promise<FlowProgress[]> {
return (await this.ws("config_entries/flow/progress")) as FlowProgress[];
}
async devices(): Promise<DeviceEntry[]> {
return (await this.ws("config/device_registry/list")) as DeviceEntry[];
}
/** One WebSocket command: connect, authenticate, ask, close. */
private ws(type: string): Promise<unknown> {
const url = `${this.base.replace(/^http/, "ws")}/api/websocket`;
return new Promise((resolve, reject) => {
const socket = new WebSocket(url);
const timer = setTimeout(() => {
socket.close();
reject(new Error(`Home Assistant's WebSocket did not answer ${type} within 30s`));
}, 30_000);
const done = (fn: () => void): void => {
clearTimeout(timer);
socket.close();
fn();
};
socket.onerror = () => done(() => reject(new Error(`Home Assistant's WebSocket at ${url} failed`)));
socket.onmessage = (event: { data: unknown }) => {
const msg = JSON.parse(String(event.data)) as {
type: string;
id?: number;
success?: boolean;
result?: unknown;
error?: { message?: string };
};
if (msg.type === "auth_required") socket.send(JSON.stringify({ type: "auth", access_token: this.token }));
else if (msg.type === "auth_invalid") done(() => reject(new Error("Home Assistant refused the token")));
else if (msg.type === "auth_ok") socket.send(JSON.stringify({ id: 1, type }));
else if (msg.type === "result" && msg.id === 1) {
if (msg.success) done(() => resolve(msg.result));
else done(() => reject(new Error(`Home Assistant ${type}: ${msg.error?.message ?? "failed"}`)));
}
};
});
}
}
@@ -0,0 +1,84 @@
// home-assistant's provisions step — run once by the host after Home Assistant starts, and again
// whenever a binding or pair credential it reads changes (the container's `restart-on`, novox/hq
// ADR 0099). It points Home Assistant's MQTT integration at the `mqtt-topic` broker and its Sonarr,
// Radarr and Lidarr integrations at the `sonarr-api`, `radarr-api` and `lidarr-api` apps, through
// Home Assistant's own config flows (connections.ts). It connects to no mesh broker.
//
// Exits non-zero when anything could not be put right, so the node reports the step failed and the
// host runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
// else of home-assistant's (novox/hq ADR 0136). Never prints a key or password.
import { join } from "node:path";
import { APPS, MQTT_PROVISION, reconcileApp, reconcileMqtt, type Outcome } from "./connections.js";
import { HassApi } from "./hass.js";
import { marksIn, readBinding, readIfThere } from "./mesh.js";
import { probeBroker } from "./probe.js";
const dir = process.env.MESH_PROVISIONS_DIR ?? "/run/provisions";
const url = process.env.MESH_HOMEASSISTANT_URL ?? "http://127.0.0.1:8123";
const token = (await readIfThere(process.env.MESH_HOMEASSISTANT_TOKEN_FILE))?.trim() ?? "";
const marks = marksIn(process.env.MESH_WRITTEN_DIR ?? "/var/lib/home-assistant-provisions");
const waitSeconds = Number(process.env.MESH_HOMEASSISTANT_WAIT_SECONDS ?? "300");
if (!token) {
console.error("[hass-provisions] no Home Assistant token — home-assistant's own `token` secret has not been accepted");
process.exit(1);
}
/** Home Assistant answers /api/ with 200 once it is up and the token is good. */
async function ready(): Promise<boolean> {
const until = Date.now() + waitSeconds * 1000;
for (;;) {
try {
const res = await fetch(`${url.replace(/\/$/, "")}/api/`, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 200) return true;
if (res.status === 401 || res.status === 403) {
console.error("[hass-provisions] Home Assistant refuses the token — accept a long-lived access token it issued");
return false;
}
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, 3000));
}
}
if (!(await ready())) {
console.error(`[hass-provisions] Home Assistant did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
const hass = new HassApi(url, token);
const read = async (p: string) => [await readBinding(join(dir, `${p}.json`)), await readIfThere(join(dir, `${p}.secret`))] as const;
const outcomes: Outcome[] = [];
{
const [binding, secret] = await read(MQTT_PROVISION);
outcomes.push(await reconcileMqtt({ hass, probe: probeBroker, marks }, binding, secret));
}
for (const spec of APPS) {
const [binding, secret] = await read(spec.provision);
outcomes.push(await reconcileApp({ hass, http: { fetch: (u, init) => fetch(u, init) } }, spec, binding, secret));
}
let failed = 0;
for (const o of outcomes) {
switch (o.result) {
case "unchanged":
console.log(`[hass-provisions] ${o.what}: already as the mesh says${o.note ? ` — ${o.note}` : ""}`);
break;
case "written":
console.log(`[hass-provisions] ${o.what}: wrote ${o.fields.join(", ")}; Home Assistant's own test passed${o.note ? ` — ${o.note}` : ""}`);
break;
case "equivalent":
console.log(`[hass-provisions] ${o.what}: ${o.note}`);
break;
case "refused":
failed++;
console.error(`[hass-provisions] ${o.what}: ${o.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
+42
View File
@@ -0,0 +1,42 @@
// What the mesh delivered to home-assistant's provisions step, and the step's own small memory.
//
// Per provision it requires, the mesh writes two files beside each other (the manifest's `binds` and
// `secrets`): `<provision>.json`, the binding — where the provider is (`at`), what it serves (`port`,
// `scheme`, …) and the login this module presents (`as`) — and `<provision>.secret`, the pair
// credential. Nothing here guesses a host, a port or a key.
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Binding, Marks } from "./connections.js";
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A binding file parsed, or undefined when absent or not JSON. */
export async function readBinding(path: string): Promise<Binding | undefined> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as Binding;
} catch {
return undefined;
}
}
export function marksIn(dir: string): Marks {
return {
async get(name) {
return (await readIfThere(join(dir, `${name}.digest`)))?.trim() || undefined;
},
async set(name, value) {
await mkdir(dir, { recursive: true, mode: 0o700 });
const path = join(dir, `${name}.digest`);
await writeFile(`${path}.tmp`, `${value}\n`, { mode: 0o600 });
await rename(`${path}.tmp`, path);
},
};
}
+117
View File
@@ -0,0 +1,117 @@
// Ask the broker, before Home Assistant is told anything, whether it takes the login and password
// the mesh delivered — and whether that login may subscribe to Home Assistant's discovery topics.
//
// One MQTT 3.1.1 session: CONNECT (clean, a throwaway client id, so Home Assistant's own session is
// never taken over), read the CONNACK, optionally SUBSCRIBE once and read the SUBACK, DISCONNECT.
// No dependency: the handful of bytes MQTT needs for this are written here.
import { randomBytes } from "node:crypto";
import { connect } from "node:net";
export interface ProbeResult {
/** 0 accepted; 4 bad username or password; 5 not authorised. */
connack: number;
/** The SUBACK return code for the filter asked about: 0–2 granted, 0x80 refused. */
suback?: number;
}
export type Probe = (host: string, port: number, username: string, password: string, subscribe?: string) => Promise<ProbeResult>;
function str(v: string): Buffer {
const b = Buffer.from(v, "utf8");
const len = Buffer.alloc(2);
len.writeUInt16BE(b.length);
return Buffer.concat([len, b]);
}
function packet(type: number, body: Buffer): Buffer {
let remaining = body.length;
const lenBytes: number[] = [];
do {
let byte = remaining % 128;
remaining = Math.floor(remaining / 128);
if (remaining > 0) byte |= 0x80;
lenBytes.push(byte);
} while (remaining > 0);
return Buffer.concat([Buffer.from([type, ...lenBytes]), body]);
}
/** The first complete packet in `buf`: its type byte, its body, and how many bytes it took. */
export function firstPacket(buf: Buffer): { type: number; body: Buffer; used: number } | undefined {
if (buf.length < 2) return undefined;
let length = 0;
let multiplier = 1;
let i = 1;
for (;;) {
if (i >= buf.length) return undefined;
const byte = buf[i++];
length += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) break;
multiplier *= 128;
if (i > 4) throw new Error("malformed MQTT remaining length");
}
if (buf.length < i + length) return undefined;
return { type: buf[0], body: buf.subarray(i, i + length), used: i + length };
}
export const probeBroker: Probe = (host, port, username, password, subscribe) => {
const connectBody = Buffer.concat([
str("MQTT"),
Buffer.from([4, 0xc2, 0, 10]), // level 4 (3.1.1); username + password + clean session; keepalive 10s
str(`mesh-probe-${randomBytes(6).toString("hex")}`),
str(username),
str(password),
]);
return new Promise((resolve, reject) => {
const socket = connect({ host, port });
let buf = Buffer.alloc(0);
const result: ProbeResult = { connack: -1 };
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`no answer from the broker at ${host}:${port} within 10s`));
}, 10_000);
const finish = (): void => {
clearTimeout(timer);
if (result.connack === 0) socket.end(Buffer.from([0xe0, 0]));
else socket.destroy();
resolve(result);
};
socket.on("connect", () => socket.write(packet(0x10, connectBody)));
socket.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
let p;
try {
p = firstPacket(buf);
} catch (err) {
clearTimeout(timer);
socket.destroy();
reject(err);
return;
}
if (!p) return;
buf = buf.subarray(p.used);
const kind = p.type >> 4;
if (kind === 2) {
result.connack = p.body[1] ?? -1;
if (result.connack !== 0 || !subscribe) return finish();
// SUBSCRIBE, packet id 1, one filter at QoS 0.
socket.write(packet(0x82, Buffer.concat([Buffer.from([0, 1]), str(subscribe), Buffer.from([0])])));
} else if (kind === 9) {
result.suback = p.body[2];
return finish();
}
}
});
socket.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
socket.on("close", () => {
if (result.connack === -1) {
clearTimeout(timer);
reject(new Error(`the broker at ${host}:${port} closed the connection without answering`));
}
});
});
};
@@ -0,0 +1,293 @@
// What holds home-assistant's provisions step (provisions/*.ts): Home Assistant's MQTT entry is
// made to use the broker, port and login the mesh bound — only after the broker takes that login,
// through the reconfigure flow, keeping every other setting as Home Assistant pre-filled it, and not
// again once it already says so; its Sonarr/Radarr/Lidarr entries are made, finished (reauth), left
// alone when they already reach the bound app, and never removed; a key the app refuses (the mesh's
// minted value before the operator accepts the app's) is never written.
//
// Home Assistant and the apps are fakes answering as the real ones do (flow shapes checked against
// ghcr.io/home-assistant/home-assistant 2026.9.3, the build ace runs).
import { test } from "node:test";
import assert from "node:assert/strict";
import type { ConfigEntry, DeviceEntry, FlowProgress, FlowResult, Hass, SchemaField } from "../provisions/hass.ts";
import type { Binding, Marks } from "../provisions/connections.ts";
import { APPS, formValues, reconcileApp, reconcileMqtt, sameUrl, type Http, type ServarrApp } from "../provisions/connections.ts";
import type { Probe } from "../provisions/probe.ts";
const PWD_NOT_CHANGED = "__**password_not_changed**__";
const MINTED = "mesh-minted-password";
function mqttBinding(): Binding {
return { provision: "mqtt-topic", from: "ace", at: "ace.internal", as: "mesh_ace_hass", serves: { scheme: "mqtt", port: 1883 } };
}
/** The MQTT reconfigure form as Home Assistant serializes it, pre-filled from an entry. */
function brokerForm(data: Record<string, unknown>): SchemaField[] {
return [
{ name: "broker", type: "string", required: true, description: { suggested_value: data.broker } },
{ name: "port", type: "integer", required: true, default: 1883, description: { suggested_value: data.port } },
{ name: "protocol", type: "select", required: true, default: "3.1.1", description: { suggested_value: data.protocol } },
{ name: "username", type: "string", optional: true, description: { suggested_value: data.username } },
{ name: "password", type: "string", optional: true, description: { suggested_value: data.password ? PWD_NOT_CHANGED : undefined } },
{
name: "other_settings",
type: "expandable",
required: true,
schema: [
{ name: "keepalive", type: "integer", optional: true, description: { suggested_value: 60 } },
{ name: "transport", type: "select", required: true, default: "tcp", description: { suggested_value: "tcp" } },
{ name: "set_ca_cert", type: "select", required: true, description: { suggested_value: "off" } },
{ name: "set_client_cert", type: "boolean", required: true, description: { suggested_value: false } },
],
},
];
}
interface FakeOpts {
entries?: Record<string, (ConfigEntry & { data: Record<string, unknown> })[]>;
/** What Home Assistant's own connection test accepts. */
accepts?: (data: Record<string, unknown>) => boolean;
reauth?: FlowProgress[];
devices?: DeviceEntry[];
}
function fakeHass(opts: FakeOpts = {}) {
const entries = opts.entries ?? {};
const calls: string[] = [];
const submitted: Record<string, unknown>[] = [];
const flows = new Map<string, { handler: string; entryId?: string; step: string; reauth?: boolean }>();
let n = 0;
const accepts = opts.accepts ?? (() => true);
const form = (id: string, step: string, schema: SchemaField[], errors?: Record<string, string>): FlowResult => ({
type: "form", flow_id: id, step_id: step, data_schema: schema, errors: errors ?? null,
});
const servarrUser: SchemaField[] = [
{ name: "url", type: "string", required: true },
{ name: "api_key", type: "string", required: true },
{ name: "more_options", type: "expandable", required: true, schema: [{ name: "verify_ssl", type: "boolean", optional: true, default: false }] },
];
const hass: Hass = {
async entries(domain) {
calls.push(`entries ${domain}`);
return (entries[domain] ?? []).map(({ data: _d, ...e }) => e);
},
async startFlow(handler, entryId) {
calls.push(`start ${handler}${entryId ? ` ${entryId}` : ""}`);
const id = `f${++n}`;
if (handler === "mqtt") {
const entry = entryId ? entries.mqtt.find((e) => e.entry_id === entryId) : undefined;
if (entryId && !entry) return { type: "abort", reason: "not_found" };
flows.set(id, { handler, entryId, step: "broker" });
return form(id, "broker", brokerForm(entry?.data ?? {}));
}
if (entryId) return { type: "abort", reason: "not_implemented" }; // no reconfigure for Servarr
flows.set(id, { handler, step: "user" });
return form(id, "user", servarrUser);
},
async stepFlow(flowId, input) {
calls.push(`step ${flowId}`);
const flow = flows.get(flowId);
if (!flow) throw new Error(`Home Assistant POST flow/${flowId} answered 404`);
if (flow.step === "reauth_confirm") {
flow.step = "user";
return form(flowId, "user", [
{ name: "url", type: "string", required: true, default: "http://old:1" },
{ name: "api_key", type: "string", optional: true },
{ name: "verify_ssl", type: "boolean", optional: true, default: false },
]);
}
submitted.push(input);
if (flow.handler === "mqtt") {
const entry = entries.mqtt?.find((e) => e.entry_id === flow.entryId);
const data = { ...input, ...(input.password === PWD_NOT_CHANGED ? { password: entry?.data.password } : {}) };
if (!accepts(data)) return form(flowId, "broker", brokerForm(data), { base: "cannot_connect" });
flows.delete(flowId);
if (entry) {
entry.data = data;
return { type: "abort", reason: "reconfigure_successful" };
}
(entries.mqtt ??= []).push({ entry_id: "new-mqtt", domain: "mqtt", state: "loaded", data });
return { type: "create_entry", result: { entry_id: "new-mqtt" } };
}
if (!accepts(input)) return form(flowId, "user", servarrUser, { base: "invalid_auth" });
flows.delete(flowId);
if (flow.reauth) return { type: "abort", reason: "reauth_successful" };
(entries[flow.handler] ??= []).push({ entry_id: `new-${flow.handler}`, domain: flow.handler, state: "loaded", data: input });
return { type: "create_entry", result: { entry_id: `new-${flow.handler}` } };
},
async abortFlow(flowId) {
calls.push(`abort ${flowId}`);
flows.delete(flowId);
},
async flowsInProgress() {
for (const f of opts.reauth ?? []) flows.set(f.flow_id, { handler: f.handler, entryId: f.context?.entry_id, step: "reauth_confirm", reauth: true });
return opts.reauth ?? [];
},
async devices() {
return opts.devices ?? [];
},
};
return { hass, calls, submitted, entries };
}
function memoryMarks(): Marks & { store: Map<string, string> } {
const store = new Map<string, string>();
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
}
const takes = (suback = 0): Probe => async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_hass" && pass === MINTED ? 0 : 5, suback });
const aceMqttEntry = () => ({
entry_id: "7d1e", domain: "mqtt", state: "loaded",
data: { broker: "127.0.0.1", port: 1883, protocol: "5", username: "luffy", password: "luffys-password" },
});
test("mqtt: the broker is asked first; a login it does not take is never written", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const out = await reconcileMqtt({ hass: f.hass, probe: async () => ({ connack: 5 }), marks: memoryMarks() }, mqttBinding(), MINTED);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_ace_hass/);
assert.deepEqual(f.calls, []); // Home Assistant not even asked
assert.equal(f.entries.mqtt[0].data.username, "luffy");
});
test("mqtt: ace's entry (127.0.0.1, luffy) is moved to the bound broker and login, every other setting kept", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const marks = memoryMarks();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), `${MINTED}\n`);
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["broker", "username", "password"] });
assert.deepEqual(f.entries.mqtt[0].data, {
broker: "ace.internal", port: 1883, protocol: "5", username: "mesh_ace_hass", password: MINTED,
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
});
assert.ok(marks.store.get("mqtt"));
assert.ok(![...marks.store.values()].some((v) => v.includes(MINTED)));
// Run again: nothing differs, the flow is opened to read and closed without submitting.
const before = f.submitted.length;
const again = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
assert.deepEqual(again, { what: "mqtt", result: "unchanged" });
assert.equal(f.submitted.length, before);
assert.match(f.calls.at(-1) ?? "", /^abort /);
});
test("mqtt: a new password alone is written (the digest tells)", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const marks = memoryMarks();
await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
const rotated: Probe = async () => ({ connack: 0, suback: 0 });
const out = await reconcileMqtt({ hass: f.hass, probe: rotated, marks }, mqttBinding(), "rotated");
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["password"] });
assert.equal(f.entries.mqtt[0].data.password, "rotated");
});
test("mqtt: Home Assistant's own connection test refusing saves nothing and fails loudly", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] }, accepts: () => false });
const marks = memoryMarks();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /cannot_connect.*unchanged/);
assert.equal(f.entries.mqtt[0].data.username, "luffy");
assert.equal(marks.store.size, 0);
});
test("mqtt: a fresh Home Assistant gets an entry; a grant without the discovery topics is warned about", async () => {
const f = fakeHass();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(0x80), marks: memoryMarks() }, mqttBinding(), MINTED);
assert.equal(out.result, "written");
assert.match((out as { note?: string }).note ?? "", /may not subscribe to homeassistant\/#/);
assert.equal(f.entries.mqtt[0].data.broker, "ace.internal");
});
test("mqtt: two entries, or a binding without a port, are refused rather than guessed", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry(), { ...aceMqttEntry(), entry_id: "other" }] } });
assert.equal((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, mqttBinding(), MINTED)).result, "refused");
const noPort = { ...mqttBinding(), serves: {} };
assert.match(((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, noPort, MINTED)) as { problem: string }).problem, /no usable port/);
});
// ---- Servarr ----
const SONARR = APPS.find((a) => a.domain === "sonarr") as ServarrApp;
const RADARR = APPS.find((a) => a.domain === "radarr") as ServarrApp;
const KEY = "the-apps-own-key";
const servarrBinding = (port: number, at = "ace.internal"): Binding => ({ provision: "sonarr-api", from: "ace", at, as: "mesh_ace_hass", serves: { scheme: "http", port, "url-base": "" } });
/** One running Sonarr, answering on several addresses (127.0.0.1 and ace.internal are one host). */
function apps(instances: Record<string, { startTime: string }>): Http & { asked: string[] } {
const asked: string[] = [];
return {
asked,
async fetch(url, init) {
asked.push(url);
const u = new URL(url);
const inst = instances[`${u.hostname}:${u.port}`];
if (!inst) throw new Error("connect ECONNREFUSED");
if (init?.headers?.["X-Api-Key"] !== KEY) return { status: 401, text: async () => "" };
return { status: 200, text: async () => JSON.stringify({ version: "4.0.15", appData: "/config", startTime: inst.startTime }) };
},
};
}
const oneSonarr = () => apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "t1" } });
const sonarrEntry = (state = "loaded") => ({ entry_id: "5a1d", domain: "sonarr", state, data: { url: "http://127.0.0.1:8989", api_key: KEY } });
test("servarr: the mesh's minted key is never written; the remedy names the accept", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] } });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), "minted-by-the-mesh");
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider ace/);
assert.deepEqual(f.calls, []);
});
test("servarr: ace's entry at 127.0.0.1 reaches the same Sonarr the mesh bound at ace.internal — left, and said", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
assert.equal(out.result, "equivalent");
assert.equal(f.submitted.length, 0);
});
test("servarr: an entry already at the bound URL is unchanged", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://ace.internal:8989" }] });
assert.deepEqual(await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY), { what: "sonarr", result: "unchanged" });
});
test("servarr: a working entry that reaches a different app is refused, and nothing is removed", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
const two = apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
const out = await reconcileApp({ hass: f.hass, http: two }, SONARR, servarrBinding(8989), KEY);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /never does/);
assert.equal(f.entries.sonarr.length, 1);
});
test("servarr: no entry — one is made at the bound URL through the user flow", async () => {
const f = fakeHass({ entries: {} });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
assert.deepEqual(out, { what: "sonarr", result: "written", fields: ["entry"] });
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:8989", api_key: KEY, more_options: { verify_ssl: false } });
});
test("servarr: a reauth Home Assistant started is finished with the bound URL and key", async () => {
const entry = { ...sonarrEntry("setup_error"), domain: "radarr", entry_id: "1955" };
const f = fakeHass({
entries: { radarr: [entry] },
reauth: [{ flow_id: "r1", handler: "radarr", step_id: "reauth_confirm", context: { source: "reauth", entry_id: "1955" } }],
});
const radarr = apps({ "ace.internal:7878": { startTime: "t" } });
const out = await reconcileApp({ hass: f.hass, http: radarr }, RADARR, { ...servarrBinding(7878), provision: "radarr-api" }, KEY);
assert.deepEqual(out, { what: "radarr", result: "written", fields: ["api_key", "url"] });
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:7878", api_key: KEY, verify_ssl: false });
});
test("form values: suggested first, then default, sections nested", () => {
assert.deepEqual(formValues(brokerForm({ broker: "b", port: 1, protocol: "5", username: "u", password: "p" })), {
broker: "b", port: 1, protocol: "5", username: "u", password: PWD_NOT_CHANGED,
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
});
assert.ok(sameUrl("http://ace.internal:8989/", "http://ace.internal:8989"));
assert.ok(sameUrl("http://ACE.internal", "http://ace.internal:80"));
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://ace.internal:8989"));
});
+10 -1
View File
@@ -8,5 +8,14 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
"include": [
"client.ts",
"index.ts",
"tools/index.ts",
"provisions/hass.ts",
"provisions/probe.ts",
"provisions/connections.ts",
"provisions/mesh.ts",
"provisions/index.ts"
]
}
+1 -4
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/sonarr
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,6 +22,3 @@ COPY --from=build /app/modules/sonarr/dist /app/modules/sonarr/dist
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/sonarr/dist/index.js,/app/modules/sonarr/dist/tools/index.js
# NOT dist/downloads/index.js: that is a step the host runs to completion, named by the `downloads`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
-188
View File
@@ -1,188 +0,0 @@
// The downloads step — run once by the host after the app's server starts, and again whenever a
// binding, a pair credential or the step's settings change (the container's `restart-on`,
// novox/hq ADR 0099). Byte-identical in sonarr, radarr, lidarr and bookshelf; see settings.ts.
//
// **A step, not a loop**: everything it does is a function of files the mesh writes, and the host
// already knows when they change. It connects to no broker.
//
// Exits non-zero when anything could not be put right — a refused credential, an unreachable
// provider, an entry the app would not save — so the node reports the step failed and the host
// runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
// else of the module's (novox/hq ADR 0136).
//
// Reads, in MESH_DOWNLOADS_DIR, `<provision>.json` (the binding), `<provision>.secret` (the pair
// credential) and the settings file; remembers in MESH_DOWNLOADS_MEMORY the jackett hosts it has
// pointed feeds at, so a jackett that moves takes its feeds with it. Never prints a credential.
import { mkdir, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
CLIENTS,
JACKETT,
acceptRemedy,
appConfig,
appReady,
listEntries,
providerTakes,
readIfThere,
readJson,
reconcileClient,
reconcileIndexers,
scrub,
stepSettings,
wanted,
type App,
type Binding,
type Http,
type Entry,
type Outcome,
type Took,
} from "./settings.js";
const module = process.env.MESH_DOWNLOADS_APP ?? "app";
const dir = process.env.MESH_DOWNLOADS_DIR ?? "/run/downloads";
const settingsFile = process.env.MESH_DOWNLOADS_SETTINGS ?? join(dir, "downloads.json");
const memoryFile = process.env.MESH_DOWNLOADS_MEMORY ?? "/var/lib/downloads/memory.json";
const waitSeconds = Number(process.env.MESH_DOWNLOADS_WAIT_SECONDS ?? "180");
const tag = `[${module}-downloads]`;
const http: Http = { fetch: (u, init) => fetch(u, init) };
const secrets: string[] = [];
const say = (line: string) => console.log(scrub(`${tag} ${line}`, secrets));
const fail = (line: string) => console.error(scrub(`${tag} ${line}`, secrets));
const config = appConfig((await readIfThere(process.env.MESH_DOWNLOADS_APP_CONFIG)) ?? "");
if (!config.apiKey) {
fail(`no API key in ${module}'s config.xml yet — ${module} writes it on its first start; the step runs again on the next apply`);
process.exit(1);
}
secrets.push(config.apiKey);
const app: App = {
module,
url: `${(process.env.MESH_DOWNLOADS_APP_URL ?? "http://127.0.0.1").replace(/\/$/, "")}${config.urlBase}`,
apiKey: config.apiKey,
api: process.env.MESH_DOWNLOADS_API ?? "v3",
};
const settings = stepSettings(await readJson(settingsFile));
if (!(await appReady(http, app, waitSeconds * 1000))) {
fail(`${module} did not answer at ${app.url} within ${waitSeconds}s`);
process.exit(1);
}
const outcomes: Outcome[] = [];
// The download clients.
let clients: Entry[];
try {
clients = await listEntries(http, app, "downloadclient");
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
process.exit(1);
}
for (const kind of CLIENTS) {
const credential = await readIfThere(join(dir, `${kind.provision}.secret`));
if (credential) secrets.push(credential.trim());
const w = wanted(kind.provision, (await readJson(join(dir, `${kind.provision}.json`))) as Binding | undefined, credential, true);
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
// narrow.
if ("problem" in w) {
outcomes.push({ what: kind.provision, result: "refused", problem: w.problem });
continue;
}
const ep = w.endpoint;
try {
if (!(await providerTakes(http, kind.provision, ep)).took) {
outcomes.push({
what: kind.provision,
result: "refused",
problem: acceptRemedy(app, settings.node, kind.provision, kind.provider, kind.secretName, ep.from),
});
continue;
}
} catch (err) {
outcomes.push({
what: kind.provision,
result: "refused",
problem: `${kind.provider} could not be asked whether it takes the credential at ${ep.host}:${ep.port}: ${err instanceof Error ? err.message : String(err)}`,
});
continue;
}
outcomes.push(...(await reconcileClient(http, app, kind, settings.names[kind.provision], ep, clients)));
}
// The indexers, through jackett.
{
const credential = await readIfThere(join(dir, `${JACKETT}.secret`));
if (credential) secrets.push(credential.trim());
const w = wanted(JACKETT, (await readJson(join(dir, `${JACKETT}.json`))) as Binding | undefined, credential, false);
if ("problem" in w) {
outcomes.push({ what: JACKETT, result: "refused", problem: w.problem });
} else {
const ep = w.endpoint;
let took: Took | undefined;
try {
took = await providerTakes(http, JACKETT, ep);
} catch (err) {
outcomes.push({
what: JACKETT,
result: "refused",
problem: `jackett could not be asked whether it takes the key at ${ep.host}:${ep.port}: ${err instanceof Error ? err.message : String(err)}`,
});
}
if (took && !took.took) {
outcomes.push({ what: JACKETT, result: "refused", problem: acceptRemedy(app, settings.node, JACKETT, "jackett", "API key", ep.from) });
} else if (took) {
const memory = ((await readJson(memoryFile)) ?? {}) as Record<string, { hosts?: string[] } | undefined>;
const remembered = Array.isArray(memory[JACKETT]?.hosts) ? (memory[JACKETT]?.hosts as string[]) : [];
try {
const indexers = await listEntries(http, app, "indexer");
outcomes.push(
...(await reconcileIndexers(http, app, ep, took.configured ?? new Map(), settings.indexers, remembered, indexers)),
);
} catch (err) {
outcomes.push({ what: JACKETT, result: "refused", problem: err instanceof Error ? err.message : String(err) });
}
// Remember where the feeds now point, so they are still recognised as the mesh's if jackett
// moves. Written whole and renamed, so a crash leaves the old memory, never half of one.
const hosts = [...new Set([...remembered, ep.host.toLowerCase()])].sort();
if (hosts.join() !== [...remembered].sort().join()) {
try {
await mkdir(dirname(memoryFile), { recursive: true });
await writeFile(`${memoryFile}.tmp`, JSON.stringify({ ...memory, [JACKETT]: { hosts } }, null, 2) + "\n", { mode: 0o600 });
await rename(`${memoryFile}.tmp`, memoryFile);
} catch (err) {
outcomes.push({
what: JACKETT,
result: "notice",
note: `could not remember ${ep.host} as a jackett host (${err instanceof Error ? err.message : String(err)}); if jackett moves, list it in downloads.jackett-api.adopt-hosts`,
});
}
}
}
}
}
let failed = 0;
for (const o of outcomes) {
switch (o.result) {
case "unchanged":
say(`${o.what}: already as the mesh says${o.untested ? "" : "; connection tested"}`);
break;
case "written":
say(`${o.what}: wrote ${o.fields.join(", ")}${o.untested ? "" : "; connection tested"}`);
break;
case "created":
say(`${o.what}: registered; connection tested`);
break;
case "notice":
say(`${o.what}: ${o.note}`);
break;
case "refused":
failed++;
fail(`${o.what}: ${o.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
-778
View File
@@ -1,778 +0,0 @@
// Where a Servarr app reaches its download clients and its indexer proxy — decided by the mesh,
// written into the app by the app's own API.
//
// **One file, four copies.** sonarr, radarr, lidarr and bookshelf (a Readarr fork) keep their
// download clients and indexers behind the same API — `/api/v3/…` for the first two, `/api/v1/…`
// for the others — so this step is the same code in each. Each module builds from its own
// directory (novox/hq ADR 0069), so each carries a byte-identical copy under `downloads/`;
// `test/downloads.test.ts` checks the copies agree wherever the siblings are checked out beside
// it. Change all four together.
//
// **Why this exists.** The app keeps its connection to nzbget, qBittorrent and jackett in its own
// database, not in a file, so the mesh has nowhere to write `${bound:nzbget-api:at}` for it. The
// module requires `nzbget-api`, `qbittorrent-api` and `jackett-api`; the mesh delivers, for each,
// a binding (where the provider is — `at` — and what it serves: `port`, `scheme`, `url-base`, and
// for a download client the `username`) and a pair credential (nzbget's ControlPassword,
// qBittorrent's WebUI password, jackett's API key — each the provider's one and only, accepted by
// the operator per pair; the mesh cannot mint them). This step reads those files and makes the
// app's entries say the same thing.
//
// **Which entries are the mesh's.** Never a guess from what an entry looks like:
// - a download client is the mesh's when its name is the one the module's settings give for
// that provision (`downloads.<provision>.name`) and its kind is that provider's. On a fresh
// machine the step registers it under that name; on a migrated one the assignment names the
// entry the migration adopts ("NZBGet" on ace).
// - a Torznab indexer is the mesh's when it reads a jackett feed
// (`…/api/v2.0/indexers/<id>/results/torznab`) on a host the mesh put there — the bound `at`,
// one it bound before (remembered by the step), or one the settings adopt
// (`downloads.jackett-api.adopt-hosts`, which is how a migration names the entries that pointed
// at the old container). `downloads.jackett-api.indexers` lists the jackett indexers the app
// should have; one missing is registered.
// Everything else — every entry a person made, an nzbget elsewhere, a seedbox's jackett, a Newznab
// indexer — is left exactly as it is. **Nothing is ever deleted.**
//
// **Only the connection, and only when it differs.** Host, port, TLS, base path, user name and
// the credential. Categories, priorities, "enabled", seed criteria and every other choice made in
// the app are left alone. The stored credential cannot be read back (the app masks it), so the
// step asks the app to test the entry with the mesh's host and port and the credential it already
// holds: passing, the credential is already the provider's one and only; failing, the delivered
// credential is written.
//
// **A credential the provider refuses is never written.** Until the operator accepts the
// provider's secret for this pair, the mesh delivers a value it minted itself, which no provider
// will ever accept (novox/hq ADR 0092). Writing it would replace a working password with a dead
// one. So it is tried against the provider first; refused, nothing of that provision is written
// and the step fails naming the `secret accept` that fixes it.
//
// Pure logic and a small HTTP seam, so it is tested against fake servers (test/downloads.test.ts).
// Never prints a credential, an API key, or a URL carrying one.
import { readFile } from "node:fs/promises";
/** A download-client provision and the shape of its entry in the app. */
export interface ClientKind {
provision: "nzbget-api" | "qbittorrent-api";
/** The app's `implementation` for it. */
implementation: "Nzbget" | "QBittorrent";
/** The provider, as a person calls it. */
provider: string;
/** What the pair credential is, in the provider's words. */
secretName: string;
}
export const CLIENTS: readonly ClientKind[] = [
{ provision: "nzbget-api", implementation: "Nzbget", provider: "nzbget", secretName: "ControlPassword" },
{ provision: "qbittorrent-api", implementation: "QBittorrent", provider: "qBittorrent", secretName: "WebUI password" },
];
export const JACKETT = "jackett-api";
/** What the mesh wrote at `binds.<provision>`: the binding document. */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
/** Where a provider is, as the mesh bound it, and the credential for it. */
export interface Endpoint {
scheme: "http" | "https";
host: string;
port: number;
/** "" at the root, otherwise "/base" — one leading slash, none trailing. */
urlBase: string;
/** The user a download client logs in as; "" for jackett, which takes a key. */
username: string;
credential: string;
/** The provider's node, for the `secret accept` remedy. */
from: string;
}
export type Wanted = { ok: true; endpoint: Endpoint } | { ok: false; problem: string };
/**
* The endpoint the mesh says to use, from a binding and its pair credential.
*
* Refused rather than guessed when the binding cannot be dialled from the app's own container: a
* loopback `at` — what the mesh hands a machine that is not on the private network — is the app's
* container itself.
*/
export function wanted(
provision: string,
binding: Binding | undefined,
credential: string | undefined,
needsUser: boolean,
): Wanted {
if (!binding) {
return { ok: false, problem: `no binding for ${provision} was delivered — the mesh writes it before this step runs` };
}
const host = typeof binding.at === "string" ? binding.at.trim() : "";
const serves = binding.serves ?? {};
const port = Number(serves.port);
if (!host) return { ok: false, problem: `the ${provision} binding names no host (at)` };
if (isLoopback(host)) {
return {
ok: false,
problem:
`the ${provision} binding says the provider is at ${host}, which from the app's own container is the ` +
`app itself. The mesh hands loopback to a machine that is not on the private network; put it on the ` +
`private network so the provider has an address the app can dial`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${provision} binding serves no usable port (${String(serves.port)})` };
}
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") {
return { ok: false, problem: `the ${provision} binding serves scheme ${scheme}, which the app cannot dial` };
}
const username = typeof serves.username === "string" ? serves.username.trim() : "";
if (needsUser && !username) {
return { ok: false, problem: `the ${provision} binding serves no username for the app to log in as` };
}
const key = (credential ?? "").trim();
if (!key) return { ok: false, problem: `the ${provision} credential is empty or was not delivered` };
return {
ok: true,
endpoint: {
scheme,
host,
port,
urlBase: normBase(serves["url-base"]),
username,
credential: key,
from: typeof binding.from === "string" ? binding.from : "",
},
};
}
/** A URL base as "" or "/x/y": slashes trimmed, one put back in front. */
export function normBase(v: unknown): string {
const s = typeof v === "string" ? v.trim().replace(/^\/+|\/+$/g, "") : "";
return s === "" ? "" : `/${s}`;
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
function hostForUrl(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
/** The provider's base URL, as the step dials it and as the app is given it. */
export function baseUrl(ep: Endpoint): string {
return `${ep.scheme}://${hostForUrl(ep.host)}:${ep.port}${ep.urlBase}`;
}
// ---------------------------------------------------------------------------------------------
// HTTP
/** The HTTP the step needs, so a test can stand fakes in for the app and the providers. */
export interface Http {
fetch(
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string },
): Promise<{ status: number; text(): Promise<string> }>;
}
/** The app, as the step reaches it from the host. */
export interface App {
/** The module, for messages and the remedy: sonarr, radarr, lidarr, bookshelf. */
module: string;
/** Its own URL, base path included. */
url: string;
apiKey: string;
/** v3 (sonarr, radarr) or v1 (lidarr, bookshelf). */
api: string;
}
interface Answer {
status: number;
body: unknown;
}
async function appCall(http: Http, app: App, method: string, path: string, body?: unknown): Promise<Answer> {
const res = await http.fetch(`${app.url.replace(/\/$/, "")}/api/${app.api}${path}`, {
method,
headers: {
"X-Api-Key": app.apiKey,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed: unknown = undefined;
if (text) {
try {
parsed = JSON.parse(text) as unknown;
} catch {
parsed = text;
}
}
return { status: res.status, body: parsed };
}
async function appGet(http: Http, app: App, path: string): Promise<unknown> {
const a = await appCall(http, app, "GET", path);
if (a.status < 200 || a.status >= 300) throw new Error(`${app.module} GET ${path} answered ${a.status}`);
return a.body;
}
/** One of the app's validation failures: the only parts of an answer the step ever prints. */
interface Failure {
property: string;
message: string;
warning: boolean;
}
function failuresOf(body: unknown): Failure[] {
const list = Array.isArray(body) ? body : [];
return list.map((f) => {
const o = (f ?? {}) as Record<string, unknown>;
return {
property: String(o.propertyName ?? ""),
message: String(o.errorMessage ?? ""),
warning: o.isWarning === true,
};
});
}
/**
* One shape rather than a union on `ok`: the Dockerfile compiles without strict, where a boolean
* discriminant does not narrow.
*/
interface Verdict {
ok: boolean;
/** Warnings the app raised on a pass; errors (and any warnings) on a failure. */
failures: Failure[];
status: number;
saved?: Entry;
}
function verdict(a: Answer): Verdict {
if (a.status >= 200 && a.status < 300) return { ok: true, failures: [], status: a.status };
const failures = failuresOf(a.body);
if (a.status === 400 && failures.length > 0 && failures.every((f) => f.warning)) return { ok: true, failures, status: a.status };
return { ok: false, failures, status: a.status };
}
/** The app's own test of an entry, from the app's own container. */
async function appTest(http: Http, app: App, resource: string, entry: Entry): Promise<Verdict> {
return verdict(await appCall(http, app, "POST", `/${resource}/test`, entry));
}
/**
* Save an entry. The app tests an enabled entry before saving it; a test that passes with only
* warnings ("a category is recommended") is saved with `forceSave`, the same as pressing "save
* anyway" in its screen. An error is never forced.
*/
async function appSave(http: Http, app: App, resource: string, entry: Entry, create: boolean): Promise<Verdict> {
const path = create ? `/${resource}` : `/${resource}/${entry.id}`;
const method = create ? "POST" : "PUT";
let a = await appCall(http, app, method, path, entry);
let v = verdict(a);
if (v.ok && (a.status < 200 || a.status >= 300)) {
a = await appCall(http, app, method, `${path}?forceSave=true`, entry);
v = verdict(a);
if (v.ok && (a.status < 200 || a.status >= 300)) {
return { ok: false, failures: failuresOf(a.body), status: a.status };
}
}
if (!v.ok) return v;
return { ...v, saved: (a.body ?? undefined) as Entry | undefined };
}
// ---------------------------------------------------------------------------------------------
// Entries
/** An entry as the app's API gives it: a download client or an indexer. */
export interface Entry {
id?: number;
name?: string;
implementation?: string;
enable?: boolean;
enableRss?: boolean;
enableAutomaticSearch?: boolean;
enableInteractiveSearch?: boolean;
supportsRss?: boolean;
supportsSearch?: boolean;
fields?: { name: string; value?: unknown; [k: string]: unknown }[];
[k: string]: unknown;
}
export function field(e: Entry, name: string): unknown {
return e.fields?.find((f) => f.name === name)?.value;
}
/** The entry with these fields' values replaced — added when the app's shape lacks one. */
export function withFields(e: Entry, values: Record<string, unknown>): Entry {
const fields = (e.fields ?? []).map((f) => (f.name in values ? { ...f, value: values[f.name] } : { ...f }));
for (const [name, value] of Object.entries(values)) {
if (!fields.some((f) => f.name === name)) fields.push({ name, value });
}
return { ...e, fields };
}
function enabled(e: Entry): boolean {
return e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true || e.enableInteractiveSearch === true;
}
/** What one provision, or one entry of it, came to. */
export type Outcome =
| { what: string; result: "unchanged"; untested?: boolean }
| { what: string; result: "written"; fields: string[]; untested?: boolean }
| { what: string; result: "created"; fields: string[] }
| { what: string; result: "notice"; note: string }
| { what: string; result: "refused"; problem: string };
function said(fs: Failure[]): string {
return fs.length === 0 ? "no reason given" : fs.map((f) => (f.property ? `${f.property}: ${f.message}` : f.message)).join("; ");
}
/**
* Bring one of the mesh's entries in line: `connection` is the non-secret fields as the mesh says
* them, `differs` which of them the entry does not already say, `secretField` where the credential
* goes. Never throws: every failure is an outcome with a reason.
*/
export async function reconcileEntry(
http: Http,
app: App,
resource: "downloadclient" | "indexer",
what: string,
current: Entry,
connection: Record<string, unknown>,
differs: string[],
secretField: string,
credential: string,
testable = true,
): Promise<Outcome> {
try {
const base = withFields(current, connection); // the credential as the app holds it — masked
if (!testable) {
// Nothing to test against (jackett no longer has the indexer it names): the connection is
// still the mesh's to state, and the key goes with it when the address changes.
if (differs.length === 0) return { what, result: "unchanged", untested: true };
const s = await appSave(http, app, resource, withFields(base, { [secretField]: credential }), false);
if (!s.ok) return { what, result: "refused", problem: `${app.module} would not save it: ${said(s.failures)}` };
return { what, result: "written", fields: [...differs, secretField], untested: true };
}
const first = await appTest(http, app, resource, base);
if (first.ok) {
// The credential the app holds already works against the provider at the mesh's address —
// and the provider has exactly one, so it is the delivered one.
if (differs.length === 0) return { what, result: "unchanged" };
const s = await appSave(http, app, resource, base, false);
if (!s.ok) return { what, result: "refused", problem: `${app.module} would not save it: ${said(s.failures)}` };
return { what, result: "written", fields: differs };
}
// The app's test failed with what it holds. The delivered credential was already checked
// against the provider, so writing it is safe; the app tests an enabled entry again on save.
const s = await appSave(http, app, resource, withFields(base, { [secretField]: credential }), false);
if (!s.ok) {
return {
what,
result: "refused",
problem: `${app.module} tested it at the mesh's address and would not save it (nothing was written): ${said(s.failures)}`,
};
}
const stored = (await appGet(http, app, `/${resource}/${current.id}`)) as Entry;
const second = await appTest(http, app, resource, stored);
const fields = [...differs, secretField];
if (second.ok) return { what, result: "written", fields };
const why = `written (${fields.join(", ")}), and ${app.module}'s own test still fails: ${said(second.failures)}`;
return enabled(stored) ? { what, result: "refused", problem: why } : { what, result: "notice", note: `${why} — it is disabled, so nothing uses it` };
} catch (err) {
return { what, result: "refused", problem: message(err) };
}
}
/**
* Register a new entry from the app's own template for that kind: its defaults, the name, the
* connection and the credential. A category the provider does not have (nzbget refuses one it was
* not told about) is left empty rather than failing the registration, and said.
*/
export async function createEntry(
http: Http,
app: App,
resource: "downloadclient" | "indexer",
what: string,
implementation: string,
name: string,
values: Record<string, unknown>,
): Promise<Outcome> {
try {
const schema = (await appGet(http, app, `/${resource}/schema`)) as Entry[];
const template = (schema ?? []).find((s) => s.implementation === implementation);
if (!template) return { what, result: "refused", problem: `${app.module} has no ${implementation} to register` };
let entry: Entry = withFields({ ...template, id: undefined, name }, values);
if (resource === "downloadclient") entry.enable = true;
else {
entry.enableRss = template.supportsRss !== false;
entry.enableAutomaticSearch = template.supportsSearch !== false;
entry.enableInteractiveSearch = template.supportsSearch !== false;
}
let s = await appSave(http, app, resource, entry, true);
let note = "";
if (!s.ok && s.failures.length > 0 && s.failures.every((f) => /category/i.test(f.property))) {
const emptied: Record<string, unknown> = {};
for (const f of entry.fields ?? []) if (/category$/i.test(f.name) && !/imported/i.test(f.name)) emptied[f.name] = "";
entry = withFields(entry, emptied);
s = await appSave(http, app, resource, entry, true);
note = " with its category left empty — the provider has none by the app's default name; set one in both";
}
if (!s.ok) return { what, result: "refused", problem: `${app.module} would not register it: ${said(s.failures)}` };
const created = s.saved;
if (created?.id !== undefined) {
const t = await appTest(http, app, resource, (await appGet(http, app, `/${resource}/${created.id}`)) as Entry);
if (!t.ok) {
return { what, result: "refused", problem: `registered as "${name}"${note}, and ${app.module}'s own test fails: ${said(t.failures)}` };
}
}
return note
? { what, result: "notice", note: `registered as "${name}"${note}` }
: { what, result: "created", fields: Object.keys(values) };
} catch (err) {
return { what, result: "refused", problem: message(err) };
}
}
// ---------------------------------------------------------------------------------------------
// Providers: does it take the credential?
/**
* Does the provider take this credential? `took` it does (for jackett, with the ids of the indexers
* it has configured); `refused` it said no; a thrown error when it could not be asked.
*/
export interface Took {
took: boolean;
configured?: Map<string, string>;
}
export async function providerTakes(http: Http, provision: string, ep: Endpoint): Promise<Took> {
const base = baseUrl(ep);
if (provision === "nzbget-api") {
const auth = Buffer.from(`${ep.username}:${ep.credential}`).toString("base64");
const res = await http.fetch(`${base}/jsonrpc/version`, { method: "GET", headers: { Authorization: `Basic ${auth}` } });
await res.text();
if (res.status === 401 || res.status === 403) return { took: false };
if (res.status >= 200 && res.status < 300) return { took: true };
throw new Error(`nzbget answered ${res.status}`);
}
if (provision === "qbittorrent-api") {
const form = `username=${encodeURIComponent(ep.username)}&password=${encodeURIComponent(ep.credential)}`;
const res = await http.fetch(`${base}/api/v2/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const text = (await res.text()).trim();
if (res.status === 401 || /^fails\.?$/i.test(text)) return { took: false };
if (res.status === 403) {
throw new Error(
"qBittorrent answered 403: it has banned this address after failed logins (WebUI\\BanDuration); it lifts by itself",
);
}
if (res.status >= 200 && res.status < 300) return { took: true };
throw new Error(`qBittorrent answered ${res.status}`);
}
if (provision === JACKETT) {
const url = `${base}/api/v2.0/indexers/all/results/torznab/api?t=indexers&configured=true&apikey=${encodeURIComponent(ep.credential)}`;
const res = await http.fetch(url, { method: "GET" });
const text = await res.text();
if (res.status === 401 || res.status === 403) return { took: false };
if (res.status < 200 || res.status >= 300) throw new Error(`jackett answered ${res.status}`);
// jackett answers a wrong key 200 with an error document: code 100, "Invalid API Key".
if (/<error\b[^>]*\bcode="100"/i.test(text)) return { took: false };
if (/<error\b/i.test(text)) throw new Error("jackett answered with an error document");
return { took: true, configured: configuredIndexers(text) };
}
throw new Error(`no check for ${provision}`);
}
/** jackett's configured indexers, id → title, from its `t=indexers` feed. */
export function configuredIndexers(xml: string): Map<string, string> {
const out = new Map<string, string>();
const re = /<indexer\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/indexer>/g;
for (let m = re.exec(xml); m; m = re.exec(xml)) {
const title = /<title>([\s\S]*?)<\/title>/.exec(m[2])?.[1]?.trim() ?? m[1];
out.set(m[1], decodeXml(title));
}
return out;
}
function decodeXml(s: string): string {
return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
}
/** The remedy for a refused credential, in the controller's own words (ADR 0092). */
export function acceptRemedy(app: App, node: string, provision: string, provider: string, secretName: string, from: string): string {
return (
`${provider} refuses the ${provision} credential the mesh delivered, so nothing of ${provision} was written ` +
`into ${app.module}. ${provider} has one ${secretName} and the mesh cannot make it: accept it for this pair — ` +
`\`secret accept ${node || "<this node>"} ${app.module} ${provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${provider}'s ${secretName}>\``
);
}
// ---------------------------------------------------------------------------------------------
// Download clients
/** The non-secret fields of a download client as the mesh says them. */
export function clientConnection(ep: Endpoint): Record<string, unknown> {
return { host: ep.host, port: ep.port, useSsl: ep.scheme === "https", urlBase: ep.urlBase === "" ? null : ep.urlBase, username: ep.username };
}
/** Which of them the entry does not already say. Names only. */
export function clientDiffers(e: Entry, ep: Endpoint): string[] {
const out: string[] = [];
if (String(field(e, "host") ?? "").toLowerCase() !== ep.host.toLowerCase()) out.push("host");
if (Number(field(e, "port") ?? 0) !== ep.port) out.push("port");
if (Boolean(field(e, "useSsl")) !== (ep.scheme === "https")) out.push("useSsl");
if (normBase(field(e, "urlBase")) !== ep.urlBase) out.push("urlBase");
if (String(field(e, "username") ?? "") !== ep.username) out.push("username");
return out;
}
/**
* Bring the app's download client for one provision in line: the entry by that name and kind is
* the mesh's and is updated; none, it is registered. An entry by that name of another kind, or by
* that name in another case, is somebody else's and is refused rather than touched.
*/
export async function reconcileClient(
http: Http,
app: App,
kind: ClientKind,
name: string,
ep: Endpoint,
entries: Entry[],
): Promise<Outcome[]> {
const what = `${kind.provision} ("${name}")`;
const exact = entries.filter((e) => e.name === name);
const alike = entries.filter((e) => e.name !== name && String(e.name ?? "").toLowerCase() === name.toLowerCase());
if (exact.length > 0 && exact[0].implementation !== kind.implementation) {
return [{
what,
result: "refused",
problem: `the download client named "${name}" is a ${exact[0].implementation}, not ${kind.implementation}; it is not the mesh's and was left alone. Name the mesh's entry otherwise in downloads.${kind.provision}.name`,
}];
}
if (exact.length === 0 && alike.length > 0) {
return [{
what,
result: "refused",
problem: `${app.module} already has "${alike[0].name}", which is not the mesh's name ("${name}") and was left alone. To have the mesh manage it, set downloads.${kind.provision}.name to "${alike[0].name}"`,
}];
}
const outcomes: Outcome[] = [];
const others = entries.filter((e) => e.implementation === kind.implementation && e.name !== name);
if (others.length > 0) {
outcomes.push({
what,
result: "notice",
note: `also present and not the mesh's, left alone: ${others.map((o) => `"${o.name}"`).join(", ")}`,
});
}
if (exact.length === 0) {
outcomes.push(
await createEntry(http, app, "downloadclient", what, kind.implementation, name, {
...clientConnection(ep),
password: ep.credential,
}),
);
return outcomes;
}
outcomes.push(
await reconcileEntry(http, app, "downloadclient", what, exact[0], clientConnection(ep), clientDiffers(exact[0], ep), "password", ep.credential),
);
return outcomes;
}
// ---------------------------------------------------------------------------------------------
// Indexers
const FEED = /\/api\/v2\.0\/indexers\/([^/]+)\/results\/torznab\/?$/;
/** A Torznab entry reading a jackett feed: the host it points at and the jackett indexer's id. */
export function jackettFeed(e: Entry): { host: string; id: string } | undefined {
if (e.implementation !== "Torznab") return undefined;
const raw = String(field(e, "baseUrl") ?? "").trim();
if (!raw) return undefined;
let u: URL;
try {
u = new URL(raw);
} catch {
return undefined;
}
const path = `${u.pathname.replace(/\/+$/, "")}/${String(field(e, "apiPath") ?? "").replace(/^\/+/, "")}`;
const m = FEED.exec(path);
if (!m) return undefined;
return { host: u.hostname.replace(/^\[|\]$/g, "").toLowerCase(), id: decodeURIComponent(m[1]) };
}
/** The feed's two fields as the mesh says them. */
export function feedConnection(ep: Endpoint, id: string): Record<string, unknown> {
return { baseUrl: baseUrl(ep), apiPath: `/api/v2.0/indexers/${id}/results/torznab/` };
}
function sameUrl(a: string, b: string): boolean {
try {
const x = new URL(a);
const y = new URL(b);
const port = (u: URL) => u.port || (u.protocol === "https:" ? "443" : "80");
return (
x.protocol === y.protocol &&
x.hostname.toLowerCase() === y.hostname.toLowerCase() &&
port(x) === port(y) &&
x.pathname.replace(/\/+$/, "") === y.pathname.replace(/\/+$/, "")
);
} catch {
return false;
}
}
export function feedDiffers(e: Entry, ep: Endpoint, id: string): string[] {
const want = feedConnection(ep, id);
const out: string[] = [];
if (!sameUrl(String(field(e, "baseUrl") ?? ""), String(want.baseUrl))) out.push("baseUrl");
const path = (v: unknown) => `/${String(v ?? "").replace(/^\/+|\/+$/g, "")}`;
if (path(field(e, "apiPath")) !== path(want.apiPath)) out.push("apiPath");
return out;
}
export interface IndexerSettings {
/** Hosts whose jackett feeds the mesh takes over — a migration's old names. */
adoptHosts: string[];
/** jackett indexer ids the app should have. */
indexers: string[];
}
/**
* Bring the app's jackett feeds in line: every one the mesh manages is pointed at the bound jackett
* with its key, and every listed jackett indexer the app lacks is registered.
*/
export async function reconcileIndexers(
http: Http,
app: App,
ep: Endpoint,
configured: Map<string, string>,
settings: IndexerSettings,
remembered: string[],
entries: Entry[],
): Promise<Outcome[]> {
const ours = new Set([ep.host, ...settings.adoptHosts, ...remembered].map((h) => h.trim().toLowerCase()).filter(Boolean));
const outcomes: Outcome[] = [];
const managed = new Set<string>();
for (const e of entries) {
const feed = jackettFeed(e);
if (!feed || !ours.has(feed.host)) continue;
managed.add(feed.id);
const what = `jackett-api ("${e.name}", jackett indexer ${feed.id})`;
const known = feed.id === "all" || configured.has(feed.id);
const outcome = await reconcileEntry(
http, app, "indexer", what, e, feedConnection(ep, feed.id), feedDiffers(e, ep, feed.id), "apiKey", ep.credential, known,
);
outcomes.push(outcome);
if (!known && outcome.result !== "refused") {
outcomes.push({ what, result: "notice", note: `jackett has no indexer "${feed.id}" configured, so it was not tested; configure it in jackett or remove the entry in ${app.module}` });
}
}
for (const id of settings.indexers) {
if (managed.has(id)) continue;
const what = `jackett-api (jackett indexer ${id})`;
if (id !== "all" && !configured.has(id)) {
outcomes.push({ what, result: "refused", problem: `downloads.jackett-api.indexers lists "${id}", and jackett has no indexer by that id configured` });
continue;
}
const name = `Jackett - ${id === "all" ? "all" : configured.get(id)}`;
outcomes.push(await createEntry(http, app, "indexer", what, "Torznab", name, { ...feedConnection(ep, id), apiKey: ep.credential }));
}
if (outcomes.length === 0) {
outcomes.push({ what: "jackett-api", result: "notice", note: `${app.module} has no jackett feed the mesh manages, and downloads.jackett-api.indexers lists none to register` });
}
return outcomes;
}
// ---------------------------------------------------------------------------------------------
// Files
/** The module's settings for this step, from its merged `downloads.json`. */
export interface StepSettings {
node: string;
names: Record<string, string>;
indexers: IndexerSettings;
}
export function stepSettings(raw: unknown): StepSettings {
const doc = (raw ?? {}) as Record<string, unknown>;
const d = (doc.downloads ?? {}) as Record<string, Record<string, unknown> | undefined>;
const names: Record<string, string> = {};
for (const k of CLIENTS) {
const n = d[k.provision]?.name;
names[k.provision] = typeof n === "string" && n.trim() ? n.trim() : k.provision.replace(/-api$/, "");
}
const j = d[JACKETT] ?? {};
const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.trim() !== "").map((x) => x.trim()) : []);
return {
node: typeof doc.node === "string" ? doc.node : "",
names,
indexers: { adoptHosts: strings(j["adopt-hosts"]), indexers: strings(j.indexers) },
};
}
/** The app's own key and base path, from its config.xml. */
export function appConfig(xml: string): { apiKey: string; urlBase: string } {
const tag = (t: string) => new RegExp(`<${t}>([^<]*)</${t}>`).exec(xml)?.[1]?.trim() ?? "";
return { apiKey: tag("ApiKey"), urlBase: normBase(tag("UrlBase")) };
}
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A JSON file parsed, or undefined when absent or not JSON. */
export async function readJson(path: string | undefined): Promise<unknown> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
/** Wait for the app to answer, because the step runs right after its container starts. */
export async function appReady(http: Http, app: App, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const a = await appCall(http, app, "GET", "/system/status");
if (a.status === 200) return true;
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, pauseMs));
}
}
export async function listEntries(http: Http, app: App, resource: "downloadclient" | "indexer"): Promise<Entry[]> {
return ((await appGet(http, app, `/${resource}`)) as Entry[]) ?? [];
}
/** Anything printed goes through this: the credentials the step holds never reach a log. */
export function scrub(text: string, secrets: string[]): string {
let out = text;
for (const s of secrets) if (s && s.length >= 4) out = out.split(s).join("<redacted>");
return out;
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+6 -76
View File
@@ -31,7 +31,7 @@
"port": 8989,
"protocol": "tcp",
"from": "mesh",
"why": "managing series: its web UI, and the API other modules reach as sonarr-api"
"why": "managing series"
}
],
"accesses": [
@@ -55,15 +55,10 @@
"path": "/var/lib/mesh/sonarr",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/sonarr/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -71,7 +66,7 @@
"id": "server",
"type": "container",
"name": "sonarr",
"image": "lscr.io/linuxserver/sonarr@sha256:a5c1a5fecbef946927ab90ad68df319ac5fe644057e5fc18cd993f01ac07b2b2",
"image": "lscr.io/linuxserver/sonarr@sha256:c19aa4ecdf03d73e1d5c901da33744cb7eb4d921f89bafed1ca264601d7fa224",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -81,7 +76,7 @@
"8989"
],
"volumes": [
"${dir:config}:/config",
"/services/sonarr/config:/config",
"/services/media/series:/series",
"/services/media/anime:/anime",
"/services/media/downloads:/downloads"
@@ -94,7 +89,7 @@
"network": "host",
"volumes": [
"/var/lib/mesh/sonarr/broker:/run/secrets/broker:ro",
"${dir:config}:/var/lib/sonarr/config:ro"
"/services/sonarr/config:/var/lib/sonarr/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
@@ -102,66 +97,9 @@
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
},
"artifact": "runtime"
},
{
"id": "downloads-config",
"type": "file",
"path": "${dir:state}/downloads.json",
"mode": "0600",
"content": "{\n \"node\": \"${machine:name}\",\n \"downloads\": {\n \"nzbget-api\": {\n \"name\": \"nzbget\"\n },\n \"qbittorrent-api\": {\n \"name\": \"qbittorrent\"\n },\n \"jackett-api\": {\n \"adopt-hosts\": [],\n \"indexers\": []\n }\n }\n}\n",
"merge": "json"
},
{
"id": "downloads-memory",
"type": "directory",
"mode": "0700"
},
{
"id": "downloads",
"type": "container",
"name": "mesh-sonarr-downloads",
"network": "host",
"run-once": true,
"volumes": [
"${dir:state}/downloads.json:/run/downloads/downloads.json:ro",
"${dir:state}/nzbget-api.json:/run/downloads/nzbget-api.json:ro",
"${dir:state}/nzbget-api.secret:/run/downloads/nzbget-api.secret:ro",
"${dir:state}/qbittorrent-api.json:/run/downloads/qbittorrent-api.json:ro",
"${dir:state}/qbittorrent-api.secret:/run/downloads/qbittorrent-api.secret:ro",
"${dir:state}/jackett-api.json:/run/downloads/jackett-api.json:ro",
"${dir:state}/jackett-api.secret:/run/downloads/jackett-api.secret:ro",
"${dir:config}:/var/lib/sonarr/config:ro",
"${dir:downloads-memory}:/var/lib/downloads"
],
"env": {
"MESH_DOWNLOADS_APP": "sonarr",
"MESH_DOWNLOADS_API": "v3",
"MESH_DOWNLOADS_APP_URL": "http://127.0.0.1:${port:8989}",
"MESH_DOWNLOADS_APP_CONFIG": "/var/lib/sonarr/config/config.xml",
"MESH_DOWNLOADS_DIR": "/run/downloads",
"MESH_DOWNLOADS_SETTINGS": "/run/downloads/downloads.json",
"MESH_DOWNLOADS_MEMORY": "/var/lib/downloads/memory.json"
},
"args": [
"run",
"/app/modules/sonarr/dist/downloads/index.js"
],
"restart-on": [
"downloads-config",
"bound-nzbget-api",
"secret-nzbget-api",
"bound-qbittorrent-api",
"secret-qbittorrent-api",
"bound-jackett-api",
"secret-jackett-api"
],
"artifact": "runtime"
}
],
"requires": [
"jackett-api",
"nzbget-api",
"qbittorrent-api",
"route"
],
"contributes": {
@@ -171,15 +109,7 @@
}
},
"binds": {
"route": "${dir:state}/route.json",
"nzbget-api": "${dir:state}/nzbget-api.json",
"qbittorrent-api": "${dir:state}/qbittorrent-api.json",
"jackett-api": "${dir:state}/jackett-api.json"
},
"secrets": {
"nzbget-api": "${dir:state}/nzbget-api.secret",
"qbittorrent-api": "${dir:state}/qbittorrent-api.secret",
"jackett-api": "${dir:state}/jackett-api.secret"
"route": "/var/lib/mesh/sonarr/route.json"
},
"build": {
"on": [
-5
View File
@@ -4,11 +4,6 @@
"description": "sonarr — TV series management. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
-365
View File
@@ -1,365 +0,0 @@
// What holds the downloads step (downloads/settings.ts): the app's download clients and jackett
// feeds are made to say what the mesh bound — host, port, TLS, base path, user, credential — and
// nothing else they keep is touched; only the mesh's entries are touched, and nothing is ever
// deleted; nothing is written when nothing differs; a missing one is registered; and a credential
// the provider refuses (the mesh's own minted value, before the operator accepts the provider's)
// is never written, with the `secret accept` that fixes it named.
//
// The app and the providers are fakes: the routes the step touches, answering as the real ones do
// (checked against the catalogue's pinned sonarr, radarr, lidarr, bookshelf, nzbget, qBittorrent
// and jackett): the app masks a stored password as "********", tests an enabled entry before
// saving it, refuses a warning unless forceSave, and jackett answers a wrong key 200 with an error.
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
CLIENTS,
acceptRemedy,
jackettFeed,
providerTakes,
reconcileClient,
reconcileIndexers,
stepSettings,
wanted,
type App,
type Binding,
type Endpoint,
type Entry,
type Http,
} from "../downloads/settings.ts";
const NZBGET = CLIENTS.find((c) => c.provision === "nzbget-api")!;
const QBIT = CLIENTS.find((c) => c.provision === "qbittorrent-api")!;
const APP: App = { module: "sonarr", url: "http://127.0.0.1:8989", apiKey: "app-key", api: "v3" };
const MASK = "********";
interface Provider {
host: string;
port: number;
user?: string;
secret: string;
configured?: Record<string, string>;
}
const nzbget: Provider = { host: "ace.internal", port: 20201, user: "luffy", secret: "nzb-real" };
const qbit: Provider = { host: "ace.internal", port: 8112, user: "luffy", secret: "qbt-real" };
const jackett: Provider = { host: "ace.internal", port: 20204, secret: "jackett-real", configured: { rutracker: "RuTracker", torrent9: "Torrent9" } };
function binding(p: Provider, provision: string, extra: Record<string, unknown> = {}): Binding {
return {
provision,
from: "ace",
at: p.host,
as: "mesh_ace_sonarr",
serves: { scheme: "http", port: p.port, "url-base": "", ...(p.user ? { username: p.user } : {}), ...extra },
};
}
function endpoint(p: Provider, provision: string, credential = p.secret): Endpoint {
const w = wanted(provision, binding(p, provision), credential, provision !== "jackett-api");
if (!w.ok) throw new Error(w.problem);
return w.endpoint;
}
const f = (name: string, value: unknown) => ({ name, value });
function client(id: number, name: string, implementation: string, host: string, port: number, password: string, extra: Record<string, unknown> = {}): Entry {
return {
id, name, implementation, enable: true, priority: 1, tags: [],
fields: [f("host", host), f("port", port), f("useSsl", false), f("urlBase", null), f("username", "luffy"), f("password", password), f("tvCategory", "Series"), ...Object.entries(extra).map(([k, v]) => f(k, v))],
};
}
function feed(id: number, name: string, baseUrl: string, jid: string, key: string, on = true): Entry {
return {
id, name, implementation: "Torznab", enableRss: on, enableAutomaticSearch: on, enableInteractiveSearch: on, priority: 25,
fields: [f("baseUrl", baseUrl), f("apiPath", `/api/v2.0/indexers/${jid}/results/torznab/`), f("apiKey", key), f("categories", [5000])],
};
}
interface Call {
method: string;
url: string;
body?: unknown;
}
/** A Servarr app and the three providers behind one fetch. */
function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const calls: Call[] = [];
const store: Record<string, Entry[]> = {
downloadclient: structuredClone(start.clients ?? []),
indexer: structuredClone(start.indexers ?? []),
};
let next = 100;
const secretOf: Record<string, string> = { downloadclient: "password", indexer: "apiKey" };
const masked = (e: Entry): Entry => ({ ...e, fields: e.fields!.map((x) => (x.name === "password" || x.name === "apiKey") && x.value ? { ...x, value: MASK } : { ...x }) });
const val = (e: Entry, n: string) => e.fields?.find((x) => x.name === n)?.value;
/** What the app's own test says: dialled from its container, with its stored secret for a mask. */
const appTest = (kind: string, e: Entry): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
let secret = val(e, secretOf[kind]);
if (secret === MASK) secret = val(store[kind].find((s) => s.id === e.id) ?? {}, secretOf[kind]);
if (kind === "downloadclient") {
const p = e.implementation === "Nzbget" ? nzbget : qbit;
if (val(e, "host") !== p.host || val(e, "port") !== p.port) return [{ propertyName: "Host", errorMessage: "Unable to connect", isWarning: false }];
if (secret !== p.secret || val(e, "username") !== p.user) return [{ propertyName: "Username", errorMessage: "Authentication Failure", isWarning: false }];
if (val(e, "tvCategory") === "") return [{ propertyName: "TvCategory", errorMessage: "A category is recommended", isWarning: true }];
if (e.implementation === "Nzbget" && val(e, "tvCategory") === "tv") return [{ propertyName: "TvCategory", errorMessage: "Category does not exist", isWarning: false }];
return [];
}
const u = new URL(String(val(e, "baseUrl")));
if (u.hostname !== jackett.host || Number(u.port) !== jackett.port) return [{ propertyName: "BaseUrl", errorMessage: "Unable to connect", isWarning: false }];
if (secret !== jackett.secret) return [{ propertyName: "ApiKey", errorMessage: "Invalid API Key", isWarning: false }];
const id = /indexers\/([^/]+)\//.exec(String(val(e, "apiPath")))?.[1] ?? "";
if (!(id in jackett.configured!)) return [{ propertyName: "", errorMessage: "Unknown indexer", isWarning: false }];
return [];
};
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
const body = init?.body && init.headers?.["Content-Type"] === "application/json" ? (JSON.parse(init.body) as Entry) : init?.body;
calls.push({ method, url, body });
const reply = (status: number, value?: unknown) => ({ status, text: async () => (value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value)) });
const u = new URL(url);
// Providers.
if (u.pathname === "/jsonrpc/version") {
const want = "Basic " + Buffer.from(`${nzbget.user}:${nzbget.secret}`).toString("base64");
return init?.headers?.Authorization === want ? reply(200, { result: "26.0" }) : reply(401);
}
if (u.pathname === "/api/v2/auth/login") {
const form = new URLSearchParams(String(init?.body ?? ""));
return form.get("username") === qbit.user && form.get("password") === qbit.secret ? reply(204) : reply(401, "Unauthorized");
}
if (u.pathname.endsWith("/torznab/api")) {
if (u.searchParams.get("apikey") !== jackett.secret) return reply(200, '<?xml version="1.0"?><error code="100" description="Invalid API Key" />');
const items = Object.entries(jackett.configured!).map(([id, t]) => `<indexer id="${id}" configured="true"><title>${t}</title></indexer>`).join("");
return reply(200, `<?xml version="1.0"?><indexers>${items}</indexers>`);
}
// The app.
if (init?.headers?.["X-Api-Key"] !== APP.apiKey) return reply(401);
const m = /^\/api\/v3\/(downloadclient|indexer)(?:\/(schema|test|\d+))?$/.exec(u.pathname);
if (!m) return reply(404);
const [, kind, sub] = m;
const force = u.searchParams.get("forceSave") === "true";
if (method === "GET" && !sub) return reply(200, store[kind].map(masked));
if (method === "GET" && sub === "schema") {
return reply(200, kind === "downloadclient"
? [client(0, "", "Nzbget", "localhost", 6789, MASK, {}), client(0, "", "QBittorrent", "localhost", 8080, "", {})].map((e) => ({ ...e, fields: e.fields!.map((x) => x.name === "tvCategory" ? { ...x, value: e.implementation === "Nzbget" ? "tv" : "tv-sonarr" } : x.name === "username" ? { ...x, value: null } : x) }))
: [{ ...feed(0, "", "", "x", "", false), supportsRss: true, supportsSearch: true, fields: [f("baseUrl", null), f("apiPath", "/api"), f("apiKey", null), f("categories", [5030, 5040])] }]);
}
if (method === "GET") {
const e = store[kind].find((s) => s.id === Number(sub));
return e ? reply(200, masked(e)) : reply(404);
}
if (method === "POST" && sub === "test") {
const fails = appTest(kind, body as Entry);
return fails.length ? reply(400, fails) : reply(200);
}
// Save: tested when enabled; a mask keeps what is stored.
const e = body as Entry;
const on = e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true;
if (on) {
const fails = appTest(kind, e);
if (fails.some((x) => !x.isWarning) || (fails.length && !force)) return reply(400, fails);
}
if (method === "POST" && !sub) {
if (store[kind].some((s) => String(s.name).toLowerCase() === String(e.name).toLowerCase())) {
return reply(400, [{ propertyName: "Name", errorMessage: "Should be unique", isWarning: false }]);
}
const created = { ...e, id: next++ };
store[kind].push(created);
return reply(201, masked(created));
}
if (method === "PUT") {
const i = store[kind].findIndex((s) => s.id === Number(sub));
if (i < 0) return reply(404);
const was = store[kind][i];
store[kind][i] = { ...e, fields: e.fields!.map((x) => x.value === MASK ? { ...x, value: val(was, x.name) } : x) };
return reply(202, masked(store[kind][i]));
}
return reply(405);
},
};
const saves = () => calls.filter((c) => (c.method === "PUT" || (c.method === "POST" && !c.url.endsWith("/test"))) && c.url.includes("/api/v3/"));
return { http, calls, store, saves };
}
const aceClients = () => [
client(1, "NZBGet", "Nzbget", "nzbget", 6789, "nzb-real"),
client(2, "qBitTorrent", "QBittorrent", "qbittorrent", 8112, "qbt-real"),
];
test("the migration's download client is repointed, its category and everything else kept", async () => {
const f = fakes({ clients: aceClients() });
const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(nzbget, "nzbget-api"), f.store.downloadclient.map((e) => ({ ...e })));
assert.deepEqual(out, [{ what: 'nzbget-api ("NZBGet")', result: "written", fields: ["host", "port"] }]);
const saved = f.store.downloadclient.find((e) => e.id === 1)!;
const v = (n: string) => saved.fields!.find((x) => x.name === n)?.value;
assert.equal(v("host"), "ace.internal");
assert.equal(v("port"), 20201);
assert.equal(v("tvCategory"), "Series");
assert.equal(v("password"), "nzb-real", "the password it held works, so it was kept, not rewritten");
assert.equal(saved.priority, 1);
});
test("rerun: already as the mesh says, nothing saved", async () => {
const f = fakes({ clients: [client(1, "NZBGet", "Nzbget", "ace.internal", 20201, "nzb-real")] });
const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(nzbget, "nzbget-api"), f.store.downloadclient);
assert.deepEqual(out, [{ what: 'nzbget-api ("NZBGet")', result: "unchanged" }]);
assert.equal(f.saves().length, 0);
});
test("a stale password is replaced by the delivered one, which the provider took first", async () => {
const f = fakes({ clients: [client(2, "qBitTorrent", "QBittorrent", "ace.internal", 8112, "old-pass")] });
const out = await reconcileClient(f.http, APP, QBIT, "qBitTorrent", endpoint(qbit, "qbittorrent-api"), f.store.downloadclient);
assert.deepEqual(out, [{ what: 'qbittorrent-api ("qBitTorrent")', result: "written", fields: ["password"] }]);
assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "password")?.value, "qbt-real");
});
test("a minted credential is refused by the provider: nothing written, the accept named", async () => {
const f = fakes({ clients: aceClients() });
const ep = endpoint(nzbget, "nzbget-api", "a-value-the-mesh-minted");
assert.deepEqual(await providerTakes(f.http, "nzbget-api", ep), { took: false });
const remedy = acceptRemedy(APP, "ace", "nzbget-api", "nzbget", "ControlPassword", ep.from);
assert.match(remedy, /`secret accept ace sonarr nzbget-api --provider ace --from <file holding nzbget's ControlPassword>`/);
assert.doesNotMatch(remedy, /minted/);
assert.equal(f.calls.some((c) => c.url.includes("/api/v3/")), false, "the app was not even asked");
const q = endpoint(qbit, "qbittorrent-api", "minted");
assert.deepEqual(await providerTakes(f.http, "qbittorrent-api", q), { took: false });
const j = endpoint(jackett, "jackett-api", "minted");
assert.deepEqual(await providerTakes(f.http, "jackett-api", j), { took: false });
assert.equal(f.calls.find((c) => c.url.includes("apikey="))?.url.includes("jackett-real"), false);
});
test("a fresh app gets the mesh's client registered, under the mesh's name", async () => {
const f = fakes({});
const out = await reconcileClient(f.http, APP, QBIT, "qbittorrent", endpoint(qbit, "qbittorrent-api"), []);
assert.equal(out[0].result, "created");
const e = f.store.downloadclient[0];
assert.equal(e.name, "qbittorrent");
assert.equal(e.enable, true);
assert.equal(e.fields!.find((x) => x.name === "tvCategory")?.value, "tv-sonarr", "the app's own default category");
});
test("nzbget without the app's default category: registered with none, and said", async () => {
const f = fakes({});
const out = await reconcileClient(f.http, APP, NZBGET, "nzbget", endpoint(nzbget, "nzbget-api"), []);
assert.equal(out[0].result, "notice");
assert.match((out[0] as { note: string }).note, /category left empty/);
assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "tvCategory")?.value, "");
});
test("somebody else's entries are never touched: another name of the same kind, and a clash of names", async () => {
const mine = client(7, "My seedbox", "QBittorrent", "seedbox.example", 443, "theirs");
const f = fakes({ clients: [mine] });
const out = await reconcileClient(f.http, APP, QBIT, "qbittorrent", endpoint(qbit, "qbittorrent-api"), f.store.downloadclient);
assert.equal(out[0].result, "notice");
assert.deepEqual(f.store.downloadclient.find((e) => e.id === 7), mine);
// Same name in another case: refused, and the setting that adopts it named.
const g = fakes({ clients: aceClients() });
const clash = await reconcileClient(g.http, APP, NZBGET, "nzbget", endpoint(nzbget, "nzbget-api"), g.store.downloadclient);
assert.equal(clash[0].result, "refused");
assert.match((clash[0] as { problem: string }).problem, /downloads\.nzbget-api\.name to "NZBGet"/);
assert.equal(g.saves().length, 0);
});
test("jackett feeds: the migration's are repointed, a person's is left, a listed one is registered", async () => {
const personal = feed(9, "Seedbox jackett", "https://jackett.seedbox.example", "rutracker", "their-key");
const f = fakes({
indexers: [
feed(5, "Torznab - RuTracker", "https://indexers.zurag.be", "rutracker-ru", "jackett-real", false),
feed(6, "Torznab - Torrent9", "https://indexers.zurag.be", "torrent9", "jackett-real", false),
feed(4, "Jackett - RARBG", "http://jackett:9117", "therarbg", "old", false),
personal,
],
});
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(
f.http, APP, endpoint(jackett, "jackett-api"), configured,
{ adoptHosts: ["indexers.zurag.be", "jackett"], indexers: ["rutracker"] }, [], f.store.indexer,
);
const byWhat = Object.fromEntries(out.map((o) => [o.what + ":" + o.result, o]));
// torrent9: jackett has it; repointed, key already right, tested.
assert.ok(byWhat['jackett-api ("Torznab - Torrent9", jackett indexer torrent9):written']);
// rutracker-ru and therarbg: jackett has neither — repointed with the key, untested, and said.
assert.ok(byWhat['jackett-api ("Jackett - RARBG", jackett indexer therarbg):written']);
assert.ok(byWhat['jackett-api ("Jackett - RARBG", jackett indexer therarbg):notice']);
// rutracker is listed and nothing of the mesh's reads it: registered.
assert.ok(byWhat["jackett-api (jackett indexer rutracker):created"]);
assert.deepEqual(f.store.indexer.find((e) => e.id === 9), personal, "a person's jackett is not the mesh's");
const t9 = f.store.indexer.find((e) => e.id === 6)!;
assert.equal(t9.fields!.find((x) => x.name === "baseUrl")?.value, "http://ace.internal:20204");
assert.equal(t9.enableRss, false, "disabled stays disabled");
const created = f.store.indexer.find((e) => e.name === "Jackett - RuTracker")!;
assert.equal(created.enableRss, true);
assert.equal(f.calls.some((c) => c.method === "DELETE"), false, "nothing is ever deleted");
});
test("jackett feeds rerun: nothing saved, and a remembered host keeps a moved jackett's feeds", async () => {
const f = fakes({ indexers: [feed(6, "Torznab - Torrent9", "http://ace.internal:20204", "torrent9", "jackett-real")] });
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(f.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: ["torrent9"] }, [], f.store.indexer);
assert.deepEqual(out.map((o) => o.result), ["unchanged"]);
assert.equal(f.saves().length, 0);
// jackett moved to novox: the feed on ace.internal is still the mesh's because it was remembered.
const moved = { ...jackett, host: "novox.internal" };
const g = fakes({ indexers: [feed(6, "Torznab - Torrent9", "http://ace.internal:20204", "torrent9", "jackett-real")] });
const saved = jackett.host;
jackett.host = moved.host;
try {
const again = await reconcileIndexers(g.http, APP, endpoint(moved, "jackett-api"), configured, { adoptHosts: [], indexers: [] }, ["ace.internal"], g.store.indexer);
assert.equal(again[0].result, "written");
assert.equal(g.store.indexer[0].fields!.find((x) => x.name === "baseUrl")?.value, "http://novox.internal:20204");
} finally {
jackett.host = saved;
}
});
test("an enabled entry the app cannot test at the mesh's address is not saved", async () => {
const f = fakes({ clients: [client(1, "NZBGet", "Nzbget", "nzbget", 6789, "nzb-real")] });
const elsewhere = { ...nzbget, port: 1 };
const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(elsewhere, "nzbget-api"), f.store.downloadclient);
assert.equal(out[0].result, "refused");
assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "host")?.value, "nzbget", "left as it was");
});
test("bindings: loopback, no user, no credential are refused; the base path is normalised", () => {
assert.equal(wanted("nzbget-api", { ...binding(nzbget, "nzbget-api"), at: "127.0.0.1" }, "x", true).ok, false);
assert.equal(wanted("nzbget-api", binding({ ...nzbget, user: undefined }, "nzbget-api"), "x", true).ok, false);
assert.equal(wanted("nzbget-api", binding(nzbget, "nzbget-api"), " \n", true).ok, false);
const w = wanted("jackett-api", binding(jackett, "jackett-api", { "url-base": "jackett/" }), "k", false);
assert.equal(w.ok && w.endpoint.urlBase, "/jackett");
});
test("a feed is read whole: base path in the base URL or in the API path", () => {
const e = feed(1, "x", "http://ace.internal:9117/jackett", "rutracker", "k");
assert.deepEqual(jackettFeed(e), { host: "ace.internal", id: "rutracker" });
assert.equal(jackettFeed({ ...e, implementation: "Newznab" }), undefined);
assert.equal(jackettFeed({ ...e, fields: [f("baseUrl", "https://api.nzbgeek.info"), f("apiPath", "/api")] }), undefined);
});
test("settings: names default to the provider's, adoption and registration read from the merged file", () => {
assert.deepEqual(stepSettings({ node: "ace" }), { node: "ace", names: { "nzbget-api": "nzbget", "qbittorrent-api": "qbittorrent" }, indexers: { adoptHosts: [], indexers: [] } });
const s = stepSettings({ node: "ace", downloads: { "nzbget-api": { name: "NZBGet" }, "jackett-api": { "adopt-hosts": ["jackett"], indexers: ["torrent9", 3] } } });
assert.equal(s.names["nzbget-api"], "NZBGet");
assert.deepEqual(s.indexers, { adoptHosts: ["jackett"], indexers: ["torrent9"] });
});
// The four copies are one step. Where the siblings are checked out beside this module, they must
// be byte-identical — a fix made in one and not the others is a bug in three apps.
test("the step is the same in sonarr, radarr, lidarr and bookshelf", () => {
const here = dirname(dirname(fileURLToPath(import.meta.url)));
const modules = dirname(here);
for (const file of ["downloads/settings.ts", "downloads/index.ts", "test/downloads.test.ts"]) {
const mine = readFileSync(join(here, file), "utf8");
for (const sibling of ["sonarr", "radarr", "lidarr", "bookshelf"]) {
const theirs = join(modules, sibling, file);
if (existsSync(theirs)) assert.equal(readFileSync(theirs, "utf8"), mine, `${sibling}/${file} differs`);
}
}
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts", "downloads/settings.ts", "downloads/index.ts"]
"include": ["client.ts", "index.ts", "tools/index.ts"]
}