Compare commits

..
Author SHA1 Message Date
jschoubben fc61e7514f radarr: reach nzbget, qbittorrent and jackett through the mesh
radarr reached its download clients as nzbget:6789 and qbittorrent:8112 and its indexers
through jackett:9117 or indexers.zurag.be - HAL container names and a public route,
typed into its database by hand. Nothing on the mesh answers those names.

It now requires nzbget-api, qbittorrent-api and jackett-api. A run-once step
(downloads/, declared last, restart-on its bindings, credentials and settings) writes
host, port, TLS, base path, user and credential into radarr through radarr's own API:

- A download client is the mesh's when its name is downloads.<provision>.name and its
  kind the provider's; it is registered when missing. A jackett feed is the mesh's when
  its host is the bound one, one the step bound before, or one in
  downloads.jackett-api.adopt-hosts; the jackett indexers in
  downloads.jackett-api.indexers are registered when missing. Every other entry is left
  alone, and nothing is ever deleted.
- Only connection fields, only when they differ. The stored password is masked, so the
  app tests the entry with the credential it holds; only if that fails is the delivered
  one written. Categories, priorities and "enabled" are never touched.
- Each credential is tried against its provider first. A minted value (nothing accepted
  yet) is never written; the step exits 1 naming the exact secret accept.

The step is byte-identical in sonarr, radarr, lidarr and bookshelf (the same Servarr
API, v3 or v1): each module builds from its own directory, so each carries a copy, and
test/downloads.test.ts fails if a sibling's copy differs.

Verified: strict typecheck, the Dockerfile build, 14 unit tests; and the compiled step
against fresh pinned sonarr/radarr/lidarr/bookshelf with throwaway nzbget, qBittorrent
and jackett - minted credentials refused with nothing written, accepted ones registered
and tested by the app, a migration-shaped radarr repointed, reruns unchanged.
2026-09-30 13:07:10 +02:00
jschoubben 57a877fe47 radarr: its config dir is placed, its image is the one ace runs
The manifest named /services/radarr/config and /var/lib/mesh/radarr/route.json, host
paths ADR 0112 takes out of definitions. The config dir is now a pathless ${dir:config}
(0700, 1000:1000) mounted into the server and, read-only, into the runtime that reads the
API key from config.xml; the route binding lives in a placed state dir, as jackett and
searxng do. /var/lib/mesh/radarr stays for the broker secret.

The image is pinned to 6.4.4.10685-ls317, the digest ace runs today. The old pin
(6.3.0.10514-ls314) was older than the running version, and Radarr migrates its database
forward on start, so pointing the older build at ace's data is not safe.

The container mount points stay /movies and /downloads: Radarr's database stores its root
folder and the download clients' reported paths under exactly those names, and there are
no remote path mappings to absorb a change. The generic access paths stay; ace's
(/storage/media/movies, /storage/downloads) and its media owner 1001:2000 wait on hq 153.

Based on feat/servarr-api-provision (#156), which makes radarr provide radarr-api.

Verified: catalogue key tests with MESH_CATALOGUE set (73 manifests parsed, not skipped);
a throwaway container of the pinned image on a fresh 0700 1000:1000 config dir answers
/ping, serves the v3 API with the key it generated and refuses a wrong one (401), accepts
/movies as a writable root folder; the runtime's client discovers the key from that
config.xml and reads the queue.
2026-09-30 11:53:44 +02:00
jschoubben f14c763463 ombi: reach sonarr, radarr and lidarr through the mesh
ombi keeps its Servarr connections in its own database, so the mesh has no
file to write them into. A run-once step reads the three bindings and pair
credentials and writes host, port, TLS, base path and key into ombi through
ombi's own API - only when they differ, and nothing else ombi keeps.

Until the operator accepts an app's key for this pair the mesh delivers a
value it minted, which no Servarr app accepts. The step tries the key against
the app first and, refused, writes nothing and fails naming the secret accept
that fixes it, so the old working key in ombi is never replaced by a dead one.

Declared last so its failing gates nothing else of ombi (ADR 0136), and
restart-on its six inputs so it runs again when a provider moves (ADR 0099).
2026-09-30 00:39:19 +02:00
jschoubben ab44ff02e1 sonarr, radarr, lidarr: provide their API to the mesh
A consumer on another machine (ombi first; jackett, bazarr and home-assistant
later) reached these by container name on HAL's shared network, which the mesh
does not have. Each app now provides <app>-api at mesh scope and serves the
software port, so the mesh tells a consumer where it is and redirects the
port to where the machine published it.

Named per app, not one servarr-api: a requirement is matched by name and
answered by exactly one provider per node, so a consumer cannot require one
name from three providers - and ombi's code is written against each app's
own API version (ADR 0027).

No grants and no provisioner: a Servarr instance has one API key, which the
mesh cannot mint. The operator accepts it as the pair credential for each
consumer (ADR 0092).
2026-09-30 00:39:19 +02:00
jschoubben c4c44efb1b Merge remote-tracking branch 'origin/fix/sidecars-dial-the-port-they-were-given' into feat/servarr-api-provision 2026-09-30 00:25:55 +02:00
jschoubben 5cc6258326 Sidecars dial the port they were given, not the software's
A host-network sidecar reaches its service over the machine's loopback, and
the mesh publishes that service on a machine port it assigns (ADR 0038) —
so dialling the software's port reaches whatever else holds it. On ace,
searxng's sidecar dialled 127.0.0.1:8080 and got unifi's inform port. The
same shape in bazarr, bookshelf, lidarr, nzbget, qbittorrent, radarr and
sonarr; each now asks with ${port:N} (hq 088). Found in review of ace's
module preparation.
2026-09-29 23:50:19 +02:00
jschoubben 21f5301268 ombi: its config is placed, its image is the one ace runs
ombi's definition named /services/ombi/config (a HAL machine path) in three
places and pinned an image older than the one ace runs. Ombi migrates its
own SQLite schema, so a take onto the older pin (v4.53.10-ls267) would start
it on a database the newer build (ls269) already touched.

- config is a pathless placed directory, mounted as ${dir:config}
- a state directory placed at the assignment root carries route.json
- image pinned to the digest ace runs today (v4.53.10-ls269)
- the sidecar reaches ombi on the machine port the mesh assigns
  (${port:3579}) rather than assuming 3579 is free
- the sidecar no longer mounts ombi's data directory: MESH_OMBI_CONFIG_DIR
  is read by no code, and the mount exposed the databases for nothing

Verified: catalogue tests (MESH_CATALOGUE set, 6 pass, none skipped); the
pinned image starts as PUID 1000 in a 0700 dir and answers /api/v1/Status
200; data owned 1001:2000 (ace's media ids) under a 1000:1000 dir is
re-owned by the image's init and serves 200; a minted ApiKey is refused
(401) - the api-key secret must be accepted from ombi's own settings.
2026-09-29 23:38:56 +02:00
38 changed files with 2106 additions and 1422 deletions
+1 -1
View File
@@ -94,7 +94,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:6767",
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
+1 -1
View File
@@ -76,7 +76,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:8787",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:${port:8787}",
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
},
"artifact": "runtime"
+16 -74
View File
@@ -5,7 +5,7 @@
"alert.firing"
],
"own-secrets": {
"admin": "/var/lib/mesh/grafana/admin",
"admin": "/var/lib/grafana-module/admin.secret",
"broker": "/var/lib/mesh/grafana/broker"
},
"capabilities": [
@@ -30,87 +30,45 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/grafana-module",
"mode": "0700"
},
{
"id": "data",
"type": "directory",
"path": "/services/grafana/data",
"mode": "0700",
"owner": "472:472"
},
{
"id": "admin-secret",
"id": "server-env",
"type": "file",
"path": "${dir:state}/admin.secret",
"mode": "0400",
"owner": "472:472",
"content": "${secret:admin}"
},
{
"id": "oidc-secret",
"type": "file",
"path": "${dir:state}/oidc-client.secret",
"mode": "0400",
"owner": "472:472",
"content": "${secret:oidc-client}"
},
{
"id": "oidc-env",
"type": "file",
"path": "${dir:state}/oidc.env",
"mode": "0644",
"content": "GF_SERVER_ROOT_URL=https://${bound:route:name}\nGF_AUTH_GENERIC_OAUTH_ENABLED=true\nGF_AUTH_GENERIC_OAUTH_NAME=Keycloak\nGF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as}\nGF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client\nGF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles\nGF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path}\nGF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path}\nGF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path}\nGF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer'\nGF_AUTH_GENERIC_OAUTH_USE_PKCE=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true\n"
},
{
"id": "influxdb-secret",
"type": "file",
"path": "${dir:state}/influxdb-api.secret",
"mode": "0400",
"owner": "472:472",
"content": "${secret:influxdb-api}"
},
{
"id": "influxdb-datasource",
"type": "file",
"path": "${dir:state}/datasource-influxdb.yaml",
"mode": "0644",
"content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding; grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb-api}\n"
"path": "/var/lib/grafana-module/server.env",
"mode": "0600",
"content": "GF_SECURITY_ADMIN_PASSWORD=${secret:admin}\n"
},
{
"id": "server",
"type": "container",
"name": "grafana",
"image": "grafana/grafana@sha256:ac461fb352abc50da10a51c7d02462e9c05488f11f53f14b3ad79a8145f638a0",
"image": "grafana/grafana@sha256:f772d434e8fab0049deb2b1b30abd43342bcfca1537614aa8d36080232cf4283",
"ports": [
"3000"
],
"volumes": [
"${dir:data}:/var/lib/grafana",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:state}/oidc-client.secret:/run/secrets/oidc-client:ro",
"${dir:state}/influxdb-api.secret:/run/secrets/influxdb-api:ro",
"${dir:state}/datasource-influxdb.yaml:/etc/grafana/provisioning/datasources/mesh-influxdb.yaml:ro"
"/services/grafana/data:/var/lib/grafana"
],
"env": {
"GF_SECURITY_ADMIN_PASSWORD__FILE": "/run/secrets/admin"
},
"env-file": [
"${dir:state}/oidc.env"
"/var/lib/grafana-module/server.env"
],
"restart-on": [
"oidc-env",
"oidc-secret",
"influxdb-datasource",
"influxdb-secret"
]
"secrets-in-environment": "grafana honours GF_SECURITY_ADMIN_PASSWORD__FILE; convertible, awaiting a bed that exercises the admin password (assigned-grafana serves tools only)"
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/grafana/config.json",
"mode": "0600",
"content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n",
"content": "{}\n",
"merge": "json"
},
{
@@ -124,7 +82,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GRAFANA_URL": "http://127.0.0.1:3000",
"MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
@@ -134,32 +92,16 @@
}
],
"requires": [
"route",
"oidc-client",
"influxdb-api"
"route"
],
"contributes": {
"route": {
"label": "grafana",
"endpoint": "web"
},
"oidc-client": {
"label": "grafana",
"endpoint": "web",
"callback": "/login/generic_oauth"
},
"influxdb-api": {
"access": "read"
}
},
"binds": {
"route": "${dir:state}/route.json",
"oidc-client": "${dir:state}/oidc.json",
"influxdb-api": "${dir:state}/influxdb.json"
},
"secrets": {
"oidc-client": "/var/lib/mesh/grafana/oidc-client",
"influxdb-api": "/var/lib/mesh/grafana/influxdb-api"
"route": "/var/lib/mesh/grafana/route.json"
},
"build": {
"on": [
+2 -2
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/influxdb
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.ts tools/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -21,4 +21,4 @@ COPY --from=build /app/modules/influxdb/dist /app/modules/influxdb/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# 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/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js
+3 -118
View File
@@ -17,25 +17,6 @@ export interface InfluxBucket {
retentionSeconds?: number;
}
/** One permission of an authorization, as InfluxDB represents it: an action on a resource type,
* in one org, optionally narrowed to one resource by id (no id = every resource of that type). */
export interface InfluxPermission {
action: "read" | "write";
resource: { type: string; orgID?: string; id?: string; name?: string; org?: string };
}
/** A v1-compatibility ("legacy") authorization: a username (InfluxDB calls it `token`) and a
* password the caller chooses, scoped by permissions. The one credential InfluxDB 2.x lets a
* caller set to a value it did not generate — which is what a mesh-minted password needs. */
export interface LegacyAuthorization {
id: string;
token: string;
orgID: string;
status?: "active" | "inactive";
description?: string;
permissions: InfluxPermission[];
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
@@ -43,20 +24,13 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; }
}
/** A secret delivered as a file, trimmed; undefined when there is none, so the caller can fall back. */
function tokenFromFile(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").trim() || undefined; }
catch { return undefined; }
}
export class InfluxDBClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly token: string,
readonly org: string,
private readonly org: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
@@ -69,10 +43,8 @@ export class InfluxDBClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient {
const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE);
const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`;
// The token reaches the process as a file (novox/hq ADR 0086); the environment variable stays
// only for a workstation running the tools by hand.
const token = cfg.token ?? tokenFromFile(env.MESH_INFLUXDB_TOKEN_FILE) ?? env.MESH_INFLUXDB_TOKEN;
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN_FILE");
const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN;
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN");
const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
return new InfluxDBClient(url, token, org);
}
@@ -89,93 +61,6 @@ export class InfluxDBClient {
return res;
}
/** Like request, but the answer is returned whatever its status, for the caller to read. */
private async raw(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${this.baseUrl}${path}`, {
...init,
headers: { Authorization: `Token ${this.token}`, ...(init?.headers ?? {}) },
});
}
private async send(path: string, method: string, body?: unknown): Promise<Response> {
return this.request(path, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
/** The id of the org of this name, or undefined when there is none. */
async orgID(name: string): Promise<string | undefined> {
const res = await this.raw(`/api/v2/orgs?org=${encodeURIComponent(name)}`);
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`InfluxDB API /api/v2/orgs: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { orgs?: { id: string; name: string }[] };
return body.orgs?.find((o) => o.name === name)?.id;
}
/** The bucket of exactly this name in the org, or undefined. */
async findBucket(orgID: string, name: string): Promise<InfluxBucket | undefined> {
const res = await this.raw(`/api/v2/buckets?orgID=${encodeURIComponent(orgID)}&name=${encodeURIComponent(name)}`);
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`InfluxDB API /api/v2/buckets: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { buckets?: { id: string; name: string; orgID?: string }[] };
const b = body.buckets?.find((x) => x.name === name);
return b ? { id: b.id, name: b.name, orgID: b.orgID } : undefined;
}
/** Create a bucket that keeps its data for ever — retention is the operator's choice, never the mesh's. */
async createBucket(orgID: string, name: string, description: string): Promise<InfluxBucket> {
const b = (await (await this.send("/api/v2/buckets", "POST", {
orgID, name, description, retentionRules: [],
})).json()) as { id: string; name: string; orgID?: string };
return { id: b.id, name: b.name, orgID: b.orgID };
}
/** The v1 authorization whose username is exactly this, or undefined. */
async findLegacy(username: string): Promise<LegacyAuthorization | undefined> {
const path = `/private/legacy/authorizations?token=${encodeURIComponent(username)}`;
const res = await this.raw(path);
// InfluxDB answers a filter matching nothing with 404, not an empty list.
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`InfluxDB API ${path}: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { authorizations?: LegacyAuthorization[] };
return body.authorizations?.find((a) => a.token === username);
}
async createLegacy(a: Omit<LegacyAuthorization, "id">): Promise<LegacyAuthorization> {
return (await (await this.send("/private/legacy/authorizations", "POST", a)).json()) as LegacyAuthorization;
}
/** Set a v1 authorization's password. InfluxDB keeps only a hash of it, so it can be set, never read. */
async setLegacyPassword(id: string, password: string): Promise<void> {
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}/password`, "POST", { password });
}
async updateLegacy(id: string, patch: { status?: "active" | "inactive"; description?: string }): Promise<void> {
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "PATCH", patch);
}
async deleteLegacy(id: string): Promise<void> {
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "DELETE");
}
/**
* Whether this username and password sign in on the v1 API — the consumer's own view. Asked with
* a statement that reads nothing (`SHOW DATABASES` lists only what the credential may read), sent
* with Basic auth so the password is never in a URL. 401 is a wrong password or no such user;
* anything else that is not a server error means InfluxDB knew who was asking.
*/
async legacySignsIn(username: string, password: string): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/query?q=${encodeURIComponent("SHOW DATABASES")}`, {
headers: { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` },
});
await res.arrayBuffer();
if (res.status === 401) return false;
if (res.status >= 500) throw new Error(`InfluxDB v1 /query: ${res.status}`);
return true;
}
/** Server health — the one endpoint that needs no token, but we send it anyway. */
async health(): Promise<InfluxHealth> {
return (await (await this.request("/health")).json()) as InfluxHealth;
-186
View File
@@ -1,186 +0,0 @@
// What the `influxdb-api` provision means in InfluxDB: one v1-compatibility authorization per
// consumer, in the org this module serves, under the username and password the mesh gave both ends,
// allowed exactly the access the consumer contributed. The provisioner (provisioner/index.ts) is the
// sdk harness calling these; they are here, apart from it, so they can be exercised against a fake
// InfluxDB without a broker or a contributions file.
//
// **Why a v1 authorization and not a v2 API token.** The mesh mints the consumer's password and
// hands it to both ends (novox/hq ADR 0048); the provider sets it, and never hands one back. An
// InfluxDB 2.x API token is generated by the server — `POST /api/v2/authorizations` ignores a token
// the caller sends — so a token could only ever be the operator's to accept, one per pair, by hand.
// A v1 authorization is a username and a password the caller chooses (8–72 characters; the mesh
// mints 40), stored hashed, and it reads and writes through InfluxQL (`/query`) and line protocol
// (`/write`), which every bucket answers under its own name as a database (InfluxDB maps each
// bucket to a database of the same name by itself). That is what grafana's InfluxDB data source
// speaks, and what Node-RED's influxdb nodes speak in their 1.x mode — so the mesh can make every
// consumer's credential, rotate it and withdraw it, with no person in the loop.
//
// **What a consumer contributes.** `access`: "read" (the default), "write" or "read-write".
// `buckets`: the buckets it may use, by name. A reader that names none may read every bucket of the
// org — a dashboard is pointed at data, it does not own it. A writer must name its buckets: writing
// everywhere, the org's system buckets included, is never what a consumer means. A named bucket
// that does not exist is created, keeping its data for ever; the mesh never deletes a bucket.
//
// **Only what the mesh made is touched.** An authorization this module creates is named with the
// mesh's identity prefix and its description starts with MARK. One with the same username that
// lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted.
// Every other authorization, token, user and bucket in the instance is left exactly as it was.
import type { InfluxDBClient, InfluxPermission, LegacyAuthorization } from "./client.js";
/** How a description marks an authorization as the mesh's own work. */
export const MARK = "[mesh]";
/** The prefix the mesh gives every consumer identity (novox/hq ADR 0049). */
const IDENTITY_PREFIX = "mesh_";
/** One consumer, as the harness hands it over. */
export interface ApiGrant {
readonly as: string;
readonly password: string;
readonly values: Readonly<Record<string, unknown>>;
readonly consumer?: string;
}
export type Access = "read" | "write" | "read-write";
/** What a contribution asks for, checked. Refused when it cannot be served as asked. */
export function askedFor(values: Readonly<Record<string, unknown>>): { access: Access; buckets: string[] } {
const access = values.access ?? "read";
if (access !== "read" && access !== "write" && access !== "read-write") {
throw new Error(`contributes an access of ${JSON.stringify(access)} — it is "read", "write" or "read-write"`);
}
const raw = values.buckets ?? [];
if (!Array.isArray(raw) || raw.some((b) => typeof b !== "string" || b.trim() === "")) {
throw new Error(`contributes buckets of ${JSON.stringify(raw)} — a list of bucket names`);
}
const buckets = [...new Set((raw as string[]).map((b) => b.trim()))].sort();
if (access !== "read" && buckets.length === 0) {
throw new Error(`asks to write and names no bucket (\`buckets\`) — a writer names what it writes to`);
}
if (buckets.some((b) => b.startsWith("_"))) {
throw new Error(`names a system bucket (${buckets.filter((b) => b.startsWith("_")).join(", ")}) — those are InfluxDB's own`);
}
return { access: access as Access, buckets };
}
/** The permissions a grant resolves to, given each named bucket's id. */
export function permissionsFor(orgID: string, access: Access, bucketIDs: string[]): InfluxPermission[] {
const actions: ("read" | "write")[] = access === "read-write" ? ["read", "write"] : [access];
const out: InfluxPermission[] = [];
for (const action of actions) {
if (bucketIDs.length === 0) {
out.push({ action, resource: { type: "buckets", orgID } });
continue;
}
for (const id of bucketIDs) out.push({ action, resource: { type: "buckets", orgID, id } });
}
return out;
}
/** A permission as a comparable string: what InfluxDB answers carries names and links besides. */
function key(p: InfluxPermission): string {
return `${p.action}:${p.resource.type}:${p.resource.orgID ?? ""}:${p.resource.id ?? "*"}`;
}
function samePermissions(a: readonly InfluxPermission[], b: readonly InfluxPermission[]): boolean {
const x = a.map(key).sort();
const y = b.map(key).sort();
return x.length === y.length && x.every((v, i) => v === y[i]);
}
export function marked(a: Pick<LegacyAuthorization, "token" | "description">): boolean {
return a.token.startsWith(IDENTITY_PREFIX) && (a.description ?? "").startsWith(MARK);
}
function describe(g: ApiGrant): string {
return `${MARK} made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`;
}
export class ApiGrants {
constructor(private readonly influx: InfluxDBClient, readonly org: string) {}
private async orgID(): Promise<string> {
const id = await this.influx.orgID(this.org);
if (!id) throw new Error(`InfluxDB has no org ${JSON.stringify(this.org)} — the org this module serves must exist`);
return id;
}
/** The ids of the named buckets, creating any that are missing when `create` says so. Undefined
* when one is missing and may not be created (a read-only question). */
private async bucketIDs(orgID: string, names: string[], create: ApiGrant | undefined): Promise<string[] | undefined> {
const ids: string[] = [];
for (const name of names) {
let b = await this.influx.findBucket(orgID, name);
if (!b) {
if (!create) return undefined;
b = await this.influx.createBucket(orgID, name, `${MARK} made by the mesh for ${create.as}; the mesh never deletes it`);
}
ids.push(b.id);
}
return ids.sort();
}
/** Create the consumer's authorization, or bring the mesh's existing one back to what the grant
* says. Idempotent: a second apply of the same grant changes nothing beyond re-asserting the
* password, which InfluxDB can be told but never asked. */
async ensure(g: ApiGrant): Promise<"created" | "updated" | "unchanged"> {
if (!g.as.startsWith(IDENTITY_PREFIX)) {
throw new Error(`${g.as} is not a mesh identity — the mesh names every consumer ${IDENTITY_PREFIX}<node>_<module>`);
}
const { access, buckets } = askedFor(g.values);
const orgID = await this.orgID();
const found = await this.influx.findLegacy(g.as);
if (found && !marked(found)) {
throw new Error(
`InfluxDB already has a v1 authorization ${g.as} the mesh did not make — left alone; ` +
`delete it if the mesh should own that name`);
}
const want = permissionsFor(orgID, access, (await this.bucketIDs(orgID, buckets, g))!);
if (found && found.orgID === orgID && samePermissions(found.permissions, want)) {
// Only what differs is written. The password cannot be read back, so it is tried instead.
let changed = false;
if (found.status === "inactive") {
await this.influx.updateLegacy(found.id, { status: "active" });
changed = true;
}
if (!(await this.influx.legacySignsIn(g.as, g.password))) {
await this.influx.setLegacyPassword(found.id, g.password);
changed = true;
}
return changed ? "updated" : "unchanged";
}
// InfluxDB cannot change an authorization's permissions in place, so the mesh's own is made
// again. Only ever one the mesh made: a foreign one was refused above.
if (found) await this.influx.deleteLegacy(found.id);
const made = await this.influx.createLegacy({
token: g.as, orgID, status: "active", description: describe(g), permissions: want,
});
await this.influx.setLegacyPassword(made.id, g.password);
return found ? "updated" : "created";
}
/** Whether InfluxDB still holds this consumer's authorization exactly as the grant says: present,
* the mesh's, active, allowed what was asked and nothing more, and signing in with the mesh's
* password. Reads only — a missing bucket is "not held", never created here. */
async holds(g: ApiGrant): Promise<boolean> {
const { access, buckets } = askedFor(g.values);
const orgID = await this.influx.orgID(this.org);
if (!orgID) return false;
const found = await this.influx.findLegacy(g.as);
if (!found || !marked(found) || found.status === "inactive" || found.orgID !== orgID) return false;
const ids = await this.bucketIDs(orgID, buckets, undefined);
if (!ids || !samePermissions(found.permissions, permissionsFor(orgID, access, ids))) return false;
return this.influx.legacySignsIn(g.as, g.password);
}
/** Withdraw a consumer's authorization — only one the mesh made. Its buckets and their data stay. */
async remove(as: string): Promise<"removed" | "absent" | "not ours"> {
const found = await this.influx.findLegacy(as);
if (!found) return "absent";
if (!marked(found)) return "not ours";
await this.influx.deleteLegacy(found.id);
return "removed";
}
}
+29 -62
View File
@@ -1,12 +1,6 @@
{
"module": "influxdb",
"version": "1",
"provides": [
{
"name": "influxdb-api",
"scope": "mesh"
}
],
"capabilities": [
"container-runtime"
],
@@ -19,23 +13,9 @@
"port": 8086,
"protocol": "tcp",
"from": "mesh",
"why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant"
"why": "queries and writes, over http"
}
],
"serves": {
"influxdb-api": {
"scheme": "http",
"port": 8086,
"org": "mesh",
"bucket": "default"
}
},
"receives": {
"influxdb-api": "${dir:grants}/mesh.json"
},
"grants": {
"influxdb-api": "${dir:grants}"
},
"resources": [
{
"id": "mesh-state",
@@ -46,50 +26,46 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/influxdb-module",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/influxdb-module/server.env",
"mode": "0600",
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD=${secret:admin}\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${secret:admin-token}\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
},
{
"id": "data",
"type": "directory",
"path": "/services/influxdb/data",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "config",
"type": "directory",
"path": "/services/influxdb/config",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "grants",
"type": "directory",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
},
{
"id": "server",
"type": "container",
"name": "influxdb",
"image": "influxdb@sha256:f75e48af0598e8aec7986e991a848d19a119101a7d563a2e5db1dfaac9c45daa",
"env-file": [
"${dir:state}/server.env"
"/var/lib/influxdb-module/server.env"
],
"ports": [
"8086"
],
"volumes": [
"${dir:data}:/var/lib/influxdb2",
"${dir:config}:/etc/influxdb2",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro"
]
"/services/influxdb/data:/var/lib/influxdb2",
"/services/influxdb/config:/etc/influxdb2"
],
"secrets-in-environment": "the image honours DOCKER_INFLUXDB_INIT_PASSWORD_FILE and _ADMIN_TOKEN_FILE; convertible, awaiting a bed that proves it"
},
{
"id": "runtime-config",
@@ -107,15 +83,13 @@
"volumes": [
"/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro",
"/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro",
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
"${dir:grants}:${dir:grants}:ro"
"/services/influxdb/config:/var/lib/influxdb/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_URL": "http://127.0.0.1:8086",
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
"MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
"MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config"
},
"restart-on": [
"runtime-config"
@@ -123,22 +97,6 @@
"artifact": "runtime"
}
],
"requires": [
"route",
"secret"
],
"contributes": {
"route": {
"label": "influxdb",
"endpoint": "api"
}
},
"secrets": {
"secret": {
"admin": "${dir:state}/admin.secret",
"admin-token": "${dir:state}/admin-token.secret"
}
},
"build": {
"on": [
{
@@ -159,5 +117,14 @@
"from": "Dockerfile"
}
]
},
"requires": [
"secret"
],
"secrets": {
"secret": {
"admin": "/var/lib/influxdb-module/admin.secret",
"admin-token": "/var/lib/influxdb-module/admin-token.secret"
}
}
}
+1 -6
View File
@@ -1,14 +1,9 @@
{
"name": "@novox/module-influxdb",
"version": "0.1.0",
"description": "influxdb — time-series database; provides the mesh influxdb-api interface. Its API client, provisioner and tools live here (novox/hq ADR 0039).",
"description": "influxdb — time-series database. Its API client and tools live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts grants.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
-54
View File
@@ -1,54 +0,0 @@
// influxdb's provisioner — the adapter that makes influxdb a provider of the mesh `influxdb-api`
// interface. The reconcile loop, the contributions file and reading the mesh's minted secret are the
// sdk harness's; this writes only the per-service half: how InfluxDB creates, checks and removes a
// consumer's credential (novox/hq ADR 0039/0040/0048). What that credential is, and why it is a v1
// authorization, is in ../grants.ts.
//
// The `influxdb-api` interface: a consumer reaches `${bound:influxdb-api:scheme}://…:at:…:port`,
// signs in as `${bound:influxdb-api:as}` with the password the mesh minted for the pair, and reads
// or writes the org's buckets as databases of the same name — `${bound:influxdb-api:bucket}` being
// the one this instance serves by default. The org and the default bucket are the assignment's
// settings, which reach both what is served and this module's config.json, so the org a consumer is
// told and the org its credential is made in cannot disagree.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { InfluxDBClient } from "../client.js";
import { ApiGrants } from "../grants.js";
let grants: ApiGrants | undefined;
try {
const influx = InfluxDBClient.fromEnv();
grants = new ApiGrants(influx, influx.org);
} catch (err) {
// No admin token: nothing can be provisioned, and the tools loaded beside this must still serve.
console.error(`[provisioner:influxdb-api] not started: ${err instanceof Error ? err.message : err}`);
}
if (grants) serve(grants);
function serve(grants: ApiGrants): void {
runProvisioner("influxdb-api", {
async create(p: Provision): Promise<void> {
const done = await grants.ensure(p);
if (done !== "unchanged") {
console.log(`[provisioner:influxdb-api] ${done} v1 authorization ${p.as} in org ${grants.org}`);
}
},
async remove(p: { as: string }): Promise<void> {
const done = await grants.remove(p.as);
if (done === "not ours") {
console.error(`[provisioner:influxdb-api] ${p.as}: an authorization of that name exists that the mesh did not make — left alone`);
} else if (done === "removed") {
console.log(`[provisioner:influxdb-api] removed v1 authorization ${p.as}; its buckets and their data stay`);
}
},
// Asked every minute by the harness: whether InfluxDB still holds this consumer's authorization
// exactly as the mesh gave it, so one deleted, disabled or re-passworded behind the mesh's back is
// made whole again (hq issue 120).
async holds(p: Provision): Promise<boolean> {
return grants.holds(p);
},
});
}
-246
View File
@@ -1,246 +0,0 @@
// What holds influxdb to the `influxdb-api` provision (grants.ts): one v1 authorization per consumer,
// under the username and password the mesh gave, allowed only what the consumer contributed; made
// once and brought back on every apply; buckets created when missing and never deleted; and an
// authorization the mesh did not make — same name or not — never adopted, changed or deleted.
//
// InfluxDB is a fake: the routes the module touches, answering with the status codes and shapes
// InfluxDB 2.9 gives (a filter matching nothing is a 404, a password outside 8–72 characters a 400,
// an inactive authorization or a wrong password a 401 on /query). Run against the compiled module
// (npm test builds first), the way the runtime loads it.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { InfluxDBClient } from "../dist/client.js";
import { ApiGrants, MARK, askedFor, marked } from "../dist/grants.js";
type Rec = Record<string, any>;
const ADMIN = "operator-token";
const orgs = new Map<string, string>([["zurag", "org1"]]);
let buckets: Rec[] = [];
let auths: Rec[] = [];
let calls: string[] = [];
let seq = 0;
function body(req: IncomingMessage): Promise<any> {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
});
}
function send(res: ServerResponse, status: number, value?: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(value === undefined ? "" : JSON.stringify(value));
}
const server = createServer(async (req, res) => {
const url = new URL(req.url!, "http://fake");
const p = url.pathname;
calls.push(`${req.method} ${p}`);
if (p === "/query") {
const basic = (req.headers.authorization ?? "").replace(/^Basic /, "");
const [u, pw] = Buffer.from(basic, "base64").toString().split(":");
const a = auths.find((x) => x.token === u);
if (!a || a.status !== "active" || a.password === undefined || a.password !== pw) {
return send(res, 401, { code: "unauthorized", message: "Unauthorized" });
}
return send(res, 200, { results: [{ statement_id: 0 }] });
}
if (req.headers.authorization !== `Token ${ADMIN}`) return send(res, 401, { code: "unauthorized" });
if (p === "/api/v2/orgs") {
const id = orgs.get(url.searchParams.get("org") ?? "");
if (!id) return send(res, 404, { code: "not found", message: "organization name not found" });
return send(res, 200, { orgs: [{ id, name: url.searchParams.get("org") }] });
}
if (p === "/api/v2/buckets" && req.method === "GET") {
const found = buckets.filter((b) => b.orgID === url.searchParams.get("orgID") && b.name === url.searchParams.get("name"));
if (found.length === 0) return send(res, 404, { code: "not found", message: "bucket not found" });
return send(res, 200, { buckets: found });
}
if (p === "/api/v2/buckets" && req.method === "POST") {
const b = { ...(await body(req)), id: `b${++seq}` };
buckets.push(b);
return send(res, 201, b);
}
if (p === "/private/legacy/authorizations" && req.method === "GET") {
const found = auths.filter((a) => a.token === url.searchParams.get("token"));
if (found.length === 0) return send(res, 404, { code: "not found", message: "authorization not found" });
// Never answers with the password: InfluxDB keeps only its hash.
return send(res, 200, { authorizations: found.map(({ password, ...a }) => ({ ...a, links: {} })) });
}
if (p === "/private/legacy/authorizations" && req.method === "POST") {
const a = await body(req);
if (auths.some((x) => x.token === a.token)) return send(res, 409, { code: "conflict", message: "token already exists" });
const made = { ...a, id: `a${++seq}`, status: a.status ?? "active" };
auths.push(made);
return send(res, 201, made);
}
const m = /^\/private\/legacy\/authorizations\/([^/]+)(\/password)?$/.exec(p);
const a = m && auths.find((x) => x.id === m[1]);
if (!a) return send(res, 404, { code: "not found" });
if (m![2] && req.method === "POST") {
const { password } = await body(req);
if (typeof password !== "string" || password.length < 8 || password.length > 72) {
return send(res, 400, { code: "invalid", message: "passwords must be between 8 and 72 characters long" });
}
a.password = password;
return send(res, 204);
}
if (req.method === "PATCH") {
Object.assign(a, await body(req));
return send(res, 200, a);
}
if (req.method === "DELETE") {
auths = auths.filter((x) => x !== a);
return send(res, 204);
}
send(res, 405);
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
after(() => server.close());
const port = (server.address() as { port: number }).port;
const grants = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "zurag"), "zurag");
const PW = "mesh-minted-password-of-forty-characters";
/** Grafana on ace, as the mesh hands it to the provisioner. */
function grafana(password = PW, values: Record<string, unknown> = { access: "read" }) {
return { as: "mesh_ace_grafana", password, consumer: "ace", values };
}
/** Node-RED on ace: writes one bucket. */
function nodered(password = PW, values: Record<string, unknown> = { access: "write", buckets: ["zurag"] }) {
return { as: "mesh_ace_nodered", password, consumer: "ace", values };
}
function only(token: string): Rec {
const found = auths.filter((a) => a.token === token);
assert.equal(found.length, 1, `exactly one authorization ${token}, found ${found.length}`);
return found[0];
}
function perms(a: Rec): string[] {
return a.permissions.map((p: Rec) => `${p.action}:${p.resource.type}:${p.resource.id ?? "*"}`).sort();
}
beforeEach(() => {
buckets = [{ id: "zb", orgID: "org1", name: "zurag" }];
auths = [];
calls = [];
});
test("what a contribution may ask for, and what is refused", () => {
assert.deepEqual(askedFor({}), { access: "read", buckets: [] });
assert.deepEqual(askedFor({ access: "read-write", buckets: ["b", "a", "a"] }), { access: "read-write", buckets: ["a", "b"] });
assert.throws(() => askedFor({ access: "admin" }), /access/);
assert.throws(() => askedFor({ access: "write" }), /names no bucket/);
assert.throws(() => askedFor({ buckets: "zurag" }), /list of bucket names/);
assert.throws(() => askedFor({ access: "write", buckets: ["_monitoring"] }), /system bucket/);
});
test("a reader is given one authorization, reading every bucket of the org, under the mesh's password", async () => {
assert.equal(await grants.ensure(grafana()), "created");
const a = only("mesh_ace_grafana");
assert.equal(a.orgID, "org1");
assert.equal(a.status, "active");
assert.ok(a.description.startsWith(MARK));
assert.deepEqual(perms(a), ["read:buckets:*"]);
assert.equal(a.password, PW);
assert.equal(await grants.holds(grafana()), true);
});
test("a writer is allowed its own buckets only, and a missing one is made — never deleted", async () => {
assert.equal(await grants.ensure(nodered(PW, { access: "write", buckets: ["zurag", "printer"] })), "created");
const made = buckets.find((b) => b.name === "printer");
assert.ok(made, "the missing bucket was created");
assert.deepEqual(made!.retentionRules, [], "kept for ever: retention is the operator's choice");
assert.deepEqual(perms(only("mesh_ace_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
assert.equal(await grants.remove("mesh_ace_nodered"), "removed");
assert.equal(buckets.length, 2, "withdrawing the consumer leaves every bucket and its data");
});
test("applying the same grant again writes nothing", async () => {
await grants.ensure(grafana());
calls = [];
assert.equal(await grants.ensure(grafana()), "unchanged");
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
only("mesh_ace_grafana");
});
test("a rotated password is set in place; a changed access remakes only the mesh's own", async () => {
await grants.ensure(nodered());
const id = only("mesh_ace_nodered").id;
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), false);
assert.equal(await grants.ensure(nodered("rotated-password-0123456789")), "updated");
assert.equal(only("mesh_ace_nodered").id, id, "updated, not replaced");
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), true);
await grants.ensure(nodered(PW, { access: "read-write", buckets: ["zurag"] }));
assert.deepEqual(perms(only("mesh_ace_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
assert.equal(await grants.holds(nodered(PW, { access: "read-write", buckets: ["zurag"] })), true);
});
test("an authorization disabled, re-passworded or deleted behind the mesh's back is not held, and is made whole", async () => {
await grants.ensure(grafana());
only("mesh_ace_grafana").status = "inactive";
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.ensure(grafana()), "updated");
assert.equal(await grants.holds(grafana()), true);
only("mesh_ace_grafana").password = "somebody-else-set-this";
assert.equal(await grants.holds(grafana()), false);
await grants.ensure(grafana());
assert.equal(await grants.holds(grafana()), true);
auths = [];
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.ensure(grafana()), "created");
});
test("holds only reads, and a bucket gone missing is not held rather than made", async () => {
await grants.ensure(nodered());
buckets = [];
calls = [];
assert.equal(await grants.holds(nodered()), false);
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
assert.equal(buckets.length, 0);
});
test("an authorization of the same name the mesh did not make is refused, and left exactly as it was", async () => {
auths = [{ id: "theirs", token: "mesh_ace_grafana", orgID: "org1", status: "active", description: "hand-made",
permissions: [{ action: "write", resource: { type: "buckets", orgID: "org1" } }], password: "their-password" }];
const before = JSON.stringify(auths);
await assert.rejects(grants.ensure(grafana()), /did not make/);
assert.equal(JSON.stringify(auths), before);
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.remove("mesh_ace_grafana"), "not ours");
assert.equal(auths.length, 1, "never deleted");
});
test("the predecessor's own v1 users and tokens are never touched", async () => {
auths = [{ id: "hal", token: "grafana", orgID: "org1", status: "active", description: "",
permissions: [{ action: "read", resource: { type: "buckets", orgID: "org1" } }], password: "old-password" }];
await grants.ensure(grafana());
assert.equal(auths.find((a) => a.id === "hal")!.password, "old-password");
assert.equal(await grants.remove("grafana"), "not ours");
assert.equal(marked({ token: "grafana", description: `${MARK} x` }), false, "the mark needs the mesh's name too");
});
test("an org the instance does not have, or a non-mesh name, makes nothing", async () => {
const elsewhere = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "nope"), "nope");
await assert.rejects(elsewhere.ensure(grafana()), /no org "nope"/);
await assert.rejects(grants.ensure({ ...grafana(), as: "grafana" }), /not a mesh identity/);
assert.equal(auths.length, 0);
});
test("a withdrawn consumer's authorization is removed, and an absent one is not an error", async () => {
await grants.ensure(grafana());
assert.equal(await grants.remove("mesh_ace_grafana"), "removed");
assert.equal(auths.length, 0);
assert.equal(await grants.remove("mesh_ace_grafana"), "absent");
});
+1 -6
View File
@@ -8,10 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": [
"client.ts",
"grants.ts",
"provisioner/index.ts",
"tools/index.ts"
]
"include": ["client.ts", "tools/index.ts"]
}
+2 -2
View File
@@ -17,7 +17,7 @@ FROM ${BUILD_BASE} AS build
# resolved away.
WORKDIR /app/modules/keycloak
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts oidc.ts index.ts provisioner/index.ts tools/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}
@@ -27,4 +27,4 @@ COPY --from=build /app/modules/keycloak/dist /app/modules/keycloak/dist
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js,/app/modules/keycloak/dist/provisioner/index.js
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js
+2 -90
View File
@@ -12,45 +12,6 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; }
}
/** A secret file's value, trailing newline trimmed; undefined when unset or unreadable. */
function secretFile(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").replace(/\n$/, "") || undefined; }
catch { return undefined; }
}
/** A client as the admin API represents it — only the fields this module reads or writes are typed;
* the rest travel through untouched, so an update never drops what somebody else set. */
export interface ClientRepresentation {
id?: string;
clientId: string;
name?: string;
enabled?: boolean;
protocol?: string;
publicClient?: boolean;
clientAuthenticatorType?: string;
secret?: string;
rootUrl?: string;
baseUrl?: string;
redirectUris?: string[];
webOrigins?: string[];
standardFlowEnabled?: boolean;
implicitFlowEnabled?: boolean;
directAccessGrantsEnabled?: boolean;
serviceAccountsEnabled?: boolean;
attributes?: Record<string, string>;
protocolMappers?: ProtocolMapperRepresentation[];
[other: string]: unknown;
}
export interface ProtocolMapperRepresentation {
id?: string;
name: string;
protocol: string;
protocolMapper: string;
config: Record<string, string>;
}
export class KeycloakClient {
readonly baseUrl: string;
readonly defaultRealm: string;
@@ -79,13 +40,8 @@ export class KeycloakClient {
const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE);
const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`;
const adminUser = cfg.user ?? env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin";
// The admin password reaches the runtime as a file (novox/hq ADR 0086): the module's own `admin`
// secret, mounted read-only. The environment forms stay for a co-located server that has them.
const adminPass = cfg.password ?? secretFile(env.MESH_KEYCLOAK_PASSWORD_FILE)
?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
if (!adminPass) {
throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)");
}
const adminPass = cfg.password ?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD");
const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master";
return new KeycloakClient(url, adminUser, adminPass, realm);
}
@@ -203,50 +159,6 @@ export class KeycloakClient {
return client.id as string;
}
/** The one client with exactly this clientId, or undefined. The admin API's `clientId` filter is an
* exact match unless `search=true` is asked for. */
async findClient(realm: string, clientId: string): Promise<ClientRepresentation | undefined> {
const found = await this.request<ClientRepresentation[]>(
`/${realm}/clients?clientId=${encodeURIComponent(clientId)}`);
return found.find((c) => c.clientId === clientId);
}
async createClientFrom(realm: string, rep: ClientRepresentation): Promise<void> {
await this.request(`/${realm}/clients`, { method: "POST", body: JSON.stringify(rep) });
}
/** Replace a client's representation, addressed by its internal id. */
async updateClient(realm: string, id: string, rep: ClientRepresentation): Promise<void> {
await this.request(`/${realm}/clients/${id}`, { method: "PUT", body: JSON.stringify(rep) });
}
async deleteClientById(realm: string, id: string): Promise<void> {
await this.request(`/${realm}/clients/${id}`, { method: "DELETE" });
}
async clientSecretById(realm: string, id: string): Promise<string | undefined> {
const result = await this.request<{ value?: string }>(`/${realm}/clients/${id}/client-secret`);
return result.value;
}
async listClientMappers(realm: string, id: string): Promise<ProtocolMapperRepresentation[]> {
return this.request(`/${realm}/clients/${id}/protocol-mappers/models`);
}
async addClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, {
method: "POST",
body: JSON.stringify(mapper),
});
}
async updateClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
await this.request(`/${realm}/clients/${id}/protocol-mappers/models/${mapper.id}`, {
method: "PUT",
body: JSON.stringify(mapper),
});
}
async deleteClient(realm: string, clientId: string): Promise<void> {
await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" });
}
+5 -44
View File
@@ -1,12 +1,6 @@
{
"module": "keycloak",
"version": "1",
"provides": [
{
"name": "oidc-client",
"scope": "mesh"
}
],
"requires": [
"postgres-database",
"route"
@@ -47,19 +41,6 @@
"why": "anything the mesh runs that authenticates a person"
}
],
"serves": {
"oidc-client": {
"authorization-path": "/protocol/openid-connect/auth",
"token-path": "/protocol/openid-connect/token",
"userinfo-path": "/protocol/openid-connect/userinfo"
}
},
"receives": {
"oidc-client": "/var/lib/keycloak/grants/mesh.json"
},
"grants": {
"oidc-client": "/var/lib/keycloak/grants"
},
"own-secrets": {
"admin": "/var/lib/keycloak/admin.secret",
"broker": "/var/lib/mesh/keycloak/broker"
@@ -77,12 +58,6 @@
"path": "/var/lib/keycloak",
"mode": "0700"
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/keycloak/grants",
"mode": "0700"
},
{
"id": "admin-env",
"type": "file",
@@ -102,13 +77,6 @@
"type": "network",
"name": "keycloak"
},
{
"id": "hostname",
"type": "file",
"path": "/var/lib/keycloak/hostname.env",
"mode": "0644",
"content": "KC_HOSTNAME=https://${bound:route:name}\n"
},
{
"id": "server",
"type": "container",
@@ -122,20 +90,17 @@
"KC_DB": "postgres",
"KC_HTTP_ENABLED": "true",
"KC_HEALTH_ENABLED": "true",
"KC_HOSTNAME": "https://keycloak.novox.be",
"KC_PROXY_HEADERS": "xforwarded"
},
"env-file": [
"/var/lib/keycloak/admin.env",
"/var/lib/keycloak/database.env",
"/var/lib/keycloak/hostname.env"
"/var/lib/keycloak/database.env"
],
"ports": [
"8080"
],
"secrets-in-environment": "KC_DB_PASSWORD is convertible through a generated keycloak.conf (db-password=); KEYCLOAK_ADMIN_PASSWORD is env-only before Keycloak 26; not yet converted",
"restart-on": [
"hostname"
]
"secrets-in-environment": "KC_DB_PASSWORD is convertible through a generated keycloak.conf (db-password=); KEYCLOAK_ADMIN_PASSWORD is env-only before Keycloak 26; not yet converted"
},
{
"id": "runtime-config",
@@ -152,16 +117,12 @@
"network": "host",
"volumes": [
"/var/lib/mesh/keycloak/broker:/run/secrets/broker:ro",
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro",
"/var/lib/keycloak/admin.secret:/run/secrets/admin:ro",
"/var/lib/keycloak/grants:/var/lib/keycloak/grants:ro"
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "/run/secrets/admin",
"MESH_RECEIVES": "/var/lib/keycloak/grants/mesh.json"
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
-185
View File
@@ -1,185 +0,0 @@
// What the `oidc-client` provision means in Keycloak: one confidential OpenID Connect client per
// consumer, in the realm this module serves, under the name and secret the mesh gave both ends.
// The provisioner (provisioner/index.ts) is the sdk harness calling these; they are here, apart from
// it, so they can be exercised against a fake admin API without a broker or a contributions file.
//
// **The client id and the secret are the mesh's, not Keycloak's (novox/hq ADR 0048).** The mesh
// derives the consumer's identity (`as`, e.g. `mesh_ace_grafana`) and hands it to both ends — the
// consumer names it as its client id through `${bound:oidc-client:as}` — and mints the secret, which
// this sets as the client's secret. Keycloak generates neither.
//
// **Where the consumer's browser comes back to is the consumer's to say.** Its contribution carries
// `callback` (a path, e.g. `/login/generic_oauth`) and the `label`/`endpoint` of the endpoint it is
// reached on; the mesh composes that endpoint's names into `name` (public) and `internal-name`
// (private network) exactly as it does for a route (novox/hq ADR 0056, 0138), so the redirect URI
// registered here is built from the same names the proxy serves the consumer under.
//
// **Only what the mesh made is touched.** A client this module creates carries the attribute
// `mesh.provisioned=true`, and its id starts with the mesh's own prefix. A client with the same id
// that lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted.
import type { ClientRepresentation, KeycloakClient, ProtocolMapperRepresentation } from "./client.js";
/** The attribute marking a client as the mesh's own work. */
export const MARK = "mesh.provisioned";
/** The mapper every mesh client carries: realm roles as a flat `roles` claim in the id token, the
* access token and userinfo — what a consumer maps its own roles from (grafana's role path reads
* `roles[*]`), and what the predecessor added to its hand-made clients by hand. */
export const ROLES_MAPPER: ProtocolMapperRepresentation = {
name: "realm roles",
protocol: "openid-connect",
protocolMapper: "oidc-usermodel-realm-role-mapper",
config: {
"claim.name": "roles",
"jsonType.label": "String",
multivalued: "true",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
},
};
/** One consumer, as the harness hands it over. */
export interface OidcGrant {
readonly as: string;
readonly password: string;
readonly values: Readonly<Record<string, unknown>>;
readonly consumer?: string;
}
/** The realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`. The issuer is
* the one value an assignment sets (it is also what consumers are served), so the realm is read
* out of it rather than set a second time where the two could disagree. */
export function realmOf(issuer: string): string {
let path: string;
try {
path = new URL(issuer).pathname;
} catch {
throw new Error(`the issuer ${JSON.stringify(issuer)} is not a URL`);
}
const m = /\/realms\/([^/]+)\/?$/.exec(path);
if (!m) throw new Error(`the issuer ${JSON.stringify(issuer)} does not end in /realms/<realm>`);
return decodeURIComponent(m[1]);
}
/** The redirect URIs a consumer's contribution asks for: its callback under each name the mesh
* composed for its endpoint. Refused when there is nothing to register — a client that accepts no
* redirect is a client nobody can log in through, and one that accepts any is worse. */
export function redirectsOf(values: Readonly<Record<string, unknown>>): { root: string; redirects: string[] } {
const callback = values.callback;
if (typeof callback !== "string" || !callback.startsWith("/")) {
throw new Error(`contributes no callback path (\`callback\`, starting with "/"): ${JSON.stringify(callback)}`);
}
const names: string[] = [];
for (const key of ["name", "internal-name"]) {
const n = values[key];
if (typeof n === "string" && n.trim() !== "" && !names.includes(n.trim())) names.push(n.trim());
}
if (names.length === 0) {
throw new Error("has no name the mesh composed (`name` / `internal-name`) — contribute a `label` and the `endpoint` it is reached on");
}
return { root: `https://${names[0]}`, redirects: names.map((n) => `https://${n}${callback}`) };
}
/** The fields the mesh owns on a client it made. Everything else on the client is left as found. */
function wanted(g: OidcGrant): ClientRepresentation {
const { root, redirects } = redirectsOf(g.values);
return {
clientId: g.as,
name: g.as,
description: `made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`,
enabled: true,
protocol: "openid-connect",
publicClient: false,
clientAuthenticatorType: "client-secret",
secret: g.password,
rootUrl: root,
baseUrl: root,
redirectUris: redirects,
standardFlowEnabled: true,
implicitFlowEnabled: false,
directAccessGrantsEnabled: false,
serviceAccountsEnabled: false,
};
}
function sameSet(a: readonly string[] | undefined, b: readonly string[]): boolean {
const x = [...(a ?? [])].sort();
const y = [...b].sort();
return x.length === y.length && x.every((v, i) => v === y[i]);
}
function marked(c: ClientRepresentation): boolean {
return c.attributes?.[MARK] === "true";
}
export class OidcClients {
constructor(private readonly kc: KeycloakClient, readonly realm: string) {}
/** Create the consumer's client, or bring the mesh's existing one back to what the grant says.
* Returns whether it was newly created. Idempotent: applying the same grant twice changes nothing
* the second time beyond re-asserting it. */
async ensure(g: OidcGrant): Promise<"created" | "updated"> {
const want = wanted(g);
const found = await this.kc.findClient(this.realm, g.as);
if (found && !marked(found)) {
throw new Error(
`realm ${this.realm} already has a client ${g.as} the mesh did not make — left alone; ` +
`delete or rename it if the mesh should own that id`);
}
if (!found) {
await this.kc.createClientFrom(this.realm, {
...want,
attributes: { [MARK]: "true" },
protocolMappers: [ROLES_MAPPER],
});
return "created";
}
// Overlay what the mesh owns on what is there, so a field Keycloak added or an operator set on a
// field the mesh does not own survives the update.
await this.kc.updateClient(this.realm, found.id!, {
...found,
...want,
attributes: { ...(found.attributes ?? {}), [MARK]: "true" },
});
await this.ensureMapper(found.id!);
return "updated";
}
private async ensureMapper(id: string): Promise<void> {
const mappers = await this.kc.listClientMappers(this.realm, id);
const have = mappers.find((m) => m.name === ROLES_MAPPER.name);
if (!have) {
await this.kc.addClientMapper(this.realm, id, ROLES_MAPPER);
return;
}
const drifted =
have.protocolMapper !== ROLES_MAPPER.protocolMapper ||
Object.entries(ROLES_MAPPER.config).some(([k, v]) => have.config?.[k] !== v);
if (drifted) {
await this.kc.updateClientMapper(this.realm, id, { ...ROLES_MAPPER, id: have.id });
}
}
/** Whether Keycloak still holds this consumer's client exactly as the grant says: present, the
* mesh's, enabled, confidential, with the mesh's secret and the redirects asked for. Reads only. */
async holds(g: OidcGrant): Promise<boolean> {
const want = wanted(g);
const found = await this.kc.findClient(this.realm, g.as);
if (!found || !marked(found) || found.enabled === false || found.publicClient) return false;
if (!sameSet(found.redirectUris, want.redirectUris!)) return false;
const mappers = await this.kc.listClientMappers(this.realm, found.id!);
if (!mappers.some((m) => m.name === ROLES_MAPPER.name)) return false;
return (await this.kc.clientSecretById(this.realm, found.id!)) === g.password;
}
/** Withdraw a consumer's client — only one the mesh made. Returns what happened, for the log. */
async remove(as: string): Promise<"removed" | "absent" | "not ours"> {
const found = await this.kc.findClient(this.realm, as);
if (!found) return "absent";
if (!marked(found)) return "not ours";
await this.kc.deleteClientById(this.realm, found.id!);
return "removed";
}
}
+2 -6
View File
@@ -1,15 +1,11 @@
{
"name": "@novox/module-keycloak",
"version": "0.1.0",
"description": "keycloak — identity and access; provides the mesh oidc-client interface. Its admin API client, provisioner, tools and events live here (novox/hq ADR 0039).",
"description": "keycloak — identity and access. Its admin API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
-73
View File
@@ -1,73 +0,0 @@
// keycloak's provisioner — the adapter that makes keycloak a provider of the mesh `oidc-client`
// interface. The reconcile loop, the contributions file and reading the mesh's minted secret are the
// sdk harness's; this writes only the per-service half: how Keycloak creates, checks and removes a
// consumer's client (novox/hq ADR 0039/0040/0048). What a client is, and which ones are the mesh's,
// is in ../oidc.ts.
//
// The `oidc-client` interface: a consumer logs people in through the realm this module serves, as
// the confidential client `as` with the secret the mesh minted, and is redirected back to the
// callback it contributed under the names the mesh composed for its endpoint. What it is served —
// the issuer and the endpoint paths under it — is in the manifest's `serves`, settled with the
// assignment's settings.
//
// **The realm is read out of the issuer**, the one value an assignment sets (settings reach both the
// served facts and this module's config.json): a realm set in one place and an issuer in another
// would let the consumer be told one realm while its client is made in another.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { readFileSync } from "node:fs";
import { KeycloakClient } from "../client.js";
import { OidcClients, realmOf } from "../oidc.js";
/** The issuer this assignment serves, from the settings-merged config the mesh delivers. */
function issuer(): string {
const file = process.env.MESH_KEYCLOAK_CONFIG_FILE;
let cfg: Record<string, unknown> = {};
if (file) {
try {
cfg = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
} catch {
// Absent or unreadable: fall through to the environment, and refuse below if that is empty too.
}
}
const said = typeof cfg.issuer === "string" ? cfg.issuer : process.env.MESH_KEYCLOAK_ISSUER;
if (!said) throw new Error("no issuer — the module's config.json carries none and MESH_KEYCLOAK_ISSUER is unset");
return said;
}
const clients = new OidcClients(KeycloakClient.fromEnv(), realmOf(issuer()));
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
async function announce(type: string, body: Record<string, string>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:oidc-client] emit ${type} failed: ${err}`);
}
}
runProvisioner("oidc-client", {
async create(p: Provision): Promise<void> {
const done = await clients.ensure(p);
if (done === "created") {
console.log(`[provisioner:oidc-client] created client ${p.as} in realm ${clients.realm}`);
await announce("client.created", { realm: clients.realm, clientId: p.as, consumer: p.consumer ?? "" });
}
},
async remove(p: { as: string }): Promise<void> {
const done = await clients.remove(p.as);
if (done === "not ours") {
console.error(`[provisioner:oidc-client] ${p.as}: a client of that id exists that the mesh did not make — left alone`);
} else if (done === "removed") {
console.log(`[provisioner:oidc-client] removed client ${p.as} from realm ${clients.realm}`);
}
},
// Asked every minute by the harness: whether Keycloak still holds this consumer's client exactly as
// the mesh gave it, so a client deleted or edited behind the mesh's back is made again (hq issue 120).
async holds(p: Provision): Promise<boolean> {
return clients.holds(p);
},
});
-239
View File
@@ -1,239 +0,0 @@
// What holds keycloak to the `oidc-client` provision (oidc.ts): one confidential client per consumer,
// under the id and secret the mesh gave, redirecting only to the consumer's own callback under the
// names the mesh composed; made once and brought back on every apply; and a client the mesh did not
// make — same id or not — never adopted, changed or deleted.
//
// Keycloak is a fake: the admin routes the module touches, answering with the status codes and the
// shapes Keycloak gives. Run against the compiled module (npm test builds first), the way the runtime
// loads it.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { KeycloakClient } from "../dist/client.js";
import { MARK, OidcClients, ROLES_MAPPER, realmOf, redirectsOf } from "../dist/oidc.js";
type Client = Record<string, any>;
/** The realm's clients, by internal id, and what the fake was asked. */
const realm = "Novox";
const clients = new Map<string, Client>();
const calls: string[] = [];
function body(req: IncomingMessage): Promise<any> {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
});
}
function send(res: ServerResponse, status: number, value?: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(value === undefined ? "" : JSON.stringify(value));
}
const server = createServer(async (req, res) => {
const url = new URL(req.url!, "http://fake");
calls.push(`${req.method} ${url.pathname}`);
if (url.pathname === "/realms/master/protocol/openid-connect/token") {
return send(res, 200, { access_token: "t", expires_in: 300 });
}
const base = `/admin/realms/${realm}/clients`;
if (!url.pathname.startsWith(base)) return send(res, 404, { error: "Realm not found." });
const rest = url.pathname.slice(base.length).split("/").filter(Boolean);
if (rest.length === 0 && req.method === "GET") {
const want = url.searchParams.get("clientId");
return send(res, 200, [...clients.values()].filter((c) => !want || c.clientId === want));
}
if (rest.length === 0 && req.method === "POST") {
const rep = await body(req);
if ([...clients.values()].some((c) => c.clientId === rep.clientId)) {
return send(res, 409, { errorMessage: `Client ${rep.clientId} already exists` });
}
const id = randomUUID();
const mappers = (rep.protocolMappers ?? []).map((m: Client) => ({ ...m, id: randomUUID() }));
clients.set(id, { ...rep, id, protocolMappers: mappers });
return send(res, 201);
}
const c = clients.get(rest[0]);
if (!c) return send(res, 404, { error: "Could not find client" });
if (rest.length === 1 && req.method === "PUT") {
// Keycloak ignores protocolMappers on a client update: they have their own endpoints.
const rep = await body(req);
clients.set(c.id, { ...rep, id: c.id, protocolMappers: c.protocolMappers });
return send(res, 204);
}
if (rest.length === 1 && req.method === "DELETE") {
clients.delete(c.id);
return send(res, 204);
}
if (rest[1] === "client-secret" && req.method === "GET") {
return send(res, 200, { type: "secret", value: c.secret });
}
if (rest[1] === "protocol-mappers") {
if (req.method === "GET") return send(res, 200, c.protocolMappers ?? []);
if (req.method === "POST") {
c.protocolMappers = [...(c.protocolMappers ?? []), { ...(await body(req)), id: randomUUID() }];
return send(res, 201);
}
if (req.method === "PUT") {
const m = await body(req);
c.protocolMappers = c.protocolMappers.map((x: Client) => (x.id === rest[4] ? m : x));
return send(res, 204);
}
}
send(res, 405);
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
after(() => server.close());
const port = (server.address() as { port: number }).port;
const oidc = new OidcClients(new KeycloakClient(`http://127.0.0.1:${port}`, "admin", "pw"), realm);
/** Grafana on ace, as the mesh hands it to the provisioner. */
function grafana(secret = "s3cret", values: Record<string, unknown> = {}) {
return {
as: "mesh_ace_grafana",
password: secret,
consumer: "ace",
values: {
label: "grafana", endpoint: "web", port: 20010, callback: "/login/generic_oauth",
name: "grafana.zurag.be", "internal-name": "grafana.ace.internal", ...values,
},
};
}
function only(clientId: string): Client {
const found = [...clients.values()].filter((c) => c.clientId === clientId);
assert.equal(found.length, 1, `exactly one client ${clientId}, found ${found.length}`);
return found[0];
}
test("the realm is read out of the issuer, and an issuer that names none is refused", () => {
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox"), "Novox");
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox/"), "Novox");
assert.equal(realmOf("http://127.0.0.1:18500/realms/master"), "master");
assert.throws(() => realmOf("https://keycloak.novox.be"), /realms/);
assert.throws(() => realmOf("keycloak"), /not a URL/);
});
test("the redirect is the consumer's callback under every name the mesh composed for it", () => {
assert.deepEqual(redirectsOf(grafana().values), {
root: "https://grafana.zurag.be",
redirects: ["https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"],
});
// A route reaching only the private network has only the internal name, and that is enough.
assert.deepEqual(redirectsOf({ callback: "/cb", "internal-name": "x.ace.internal" }).redirects,
["https://x.ace.internal/cb"]);
assert.throws(() => redirectsOf({ name: "grafana.zurag.be" }), /callback/);
assert.throws(() => redirectsOf({ name: "grafana.zurag.be", callback: "login" }), /callback/);
assert.throws(() => redirectsOf({ callback: "/cb" }), /label/);
});
test("a consumer is given one confidential client, under its id and the mesh's secret", async () => {
clients.clear();
assert.equal(await oidc.ensure(grafana()), "created");
const c = only("mesh_ace_grafana");
assert.equal(c.publicClient, false);
assert.equal(c.clientAuthenticatorType, "client-secret");
assert.equal(c.secret, "s3cret");
assert.equal(c.enabled, true);
assert.equal(c.standardFlowEnabled, true);
assert.equal(c.directAccessGrantsEnabled, false);
assert.equal(c.implicitFlowEnabled, false);
assert.deepEqual(c.redirectUris, [
"https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
assert.equal(c.attributes[MARK], "true");
assert.deepEqual(c.protocolMappers.map((m: Client) => m.name), [ROLES_MAPPER.name]);
assert.equal(await oidc.holds(grafana()), true);
});
test("applying the same grant again makes no second client", async () => {
clients.clear();
await oidc.ensure(grafana());
assert.equal(await oidc.ensure(grafana()), "updated");
assert.equal(await oidc.ensure(grafana()), "updated");
only("mesh_ace_grafana");
assert.equal(only("mesh_ace_grafana").protocolMappers.length, 1, "the roles mapper is not added twice");
});
test("a new secret or a moved name is applied in place, and what the mesh does not own survives", async () => {
clients.clear();
await oidc.ensure(grafana());
const id = only("mesh_ace_grafana").id;
// Something the mesh does not own, set on the client after it was made.
clients.get(id)!.consentRequired = true;
clients.get(id)!.attributes["post.logout.redirect.uris"] = "+";
assert.equal(await oidc.holds(grafana("rotated")), false, "a rotated secret is not held until applied");
await oidc.ensure(grafana("rotated", { name: "dash.zurag.be" }));
const c = only("mesh_ace_grafana");
assert.equal(c.id, id, "updated, not replaced");
assert.equal(c.secret, "rotated");
assert.deepEqual(c.redirectUris, [
"https://dash.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
assert.equal(c.rootUrl, "https://dash.zurag.be");
assert.equal(c.consentRequired, true);
assert.equal(c.attributes["post.logout.redirect.uris"], "+");
assert.equal(c.attributes[MARK], "true");
assert.equal(await oidc.holds(grafana("rotated", { name: "dash.zurag.be" })), true);
});
test("a client lost or edited behind the mesh's back is not held, and is made whole again", async () => {
clients.clear();
await oidc.ensure(grafana());
const c = only("mesh_ace_grafana");
c.redirectUris = ["*"];
assert.equal(await oidc.holds(grafana()), false, "a widened redirect is not what the mesh gave");
await oidc.ensure(grafana());
assert.equal(await oidc.holds(grafana()), true);
only("mesh_ace_grafana").protocolMappers = [];
assert.equal(await oidc.holds(grafana()), false, "a client without its roles mapper is not held");
await oidc.ensure(grafana());
assert.equal(await oidc.holds(grafana()), true);
clients.clear();
assert.equal(await oidc.holds(grafana()), false);
});
test("a client of the same id the mesh did not make is refused, and left exactly as it was", async () => {
clients.clear();
clients.set("theirs", { id: "theirs", clientId: "mesh_ace_grafana", secret: "their-secret", redirectUris: ["*"] });
const before = JSON.stringify(clients.get("theirs"));
const writes = calls.length;
await assert.rejects(oidc.ensure(grafana()), /did not make/);
assert.equal(JSON.stringify(clients.get("theirs")), before);
assert.ok(calls.slice(writes).every((c) => c.startsWith("GET") || c.startsWith("POST /realms/master")),
`only reads were made: ${calls.slice(writes).join(", ")}`);
assert.equal(await oidc.holds(grafana()), false);
assert.equal(await oidc.remove("mesh_ace_grafana"), "not ours");
assert.ok(clients.has("theirs"), "a client the mesh did not make is never deleted");
});
test("the predecessor's hand-made client is never touched: the mesh's has its own id", async () => {
clients.clear();
clients.set("hal", { id: "hal", clientId: "grafana", secret: "old", redirectUris: ["https://grafana.zurag.be/*"] });
await oidc.ensure(grafana());
assert.equal(clients.get("hal")!.secret, "old");
only("mesh_ace_grafana");
assert.equal(await oidc.remove("grafana"), "not ours");
assert.ok(clients.has("hal"));
});
test("a withdrawn consumer's client is removed, and an absent one is not an error", async () => {
clients.clear();
await oidc.ensure(grafana());
assert.equal(await oidc.remove("mesh_ace_grafana"), "removed");
assert.equal([...clients.values()].length, 0);
assert.equal(await oidc.remove("mesh_ace_grafana"), "absent");
});
test("a contribution with no callback makes no client at all", async () => {
clients.clear();
await assert.rejects(oidc.ensure({ ...grafana(), values: { name: "grafana.zurag.be" } }), /callback/);
assert.equal(clients.size, 0);
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "oidc.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "tools/index.ts"]
}
+14 -1
View File
@@ -1,6 +1,19 @@
{
"module": "lidarr",
"version": "1",
"provides": [
{
"name": "lidarr-api",
"scope": "mesh"
}
],
"serves": {
"lidarr-api": {
"scheme": "http",
"port": 8686,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -75,7 +88,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LIDARR_URL": "http://127.0.0.1:8686",
"MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}",
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
},
"artifact": "runtime"
+1 -1
View File
@@ -81,7 +81,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NZBGET_URL": "http://127.0.0.1:6789",
"MESH_NZBGET_URL": "http://127.0.0.1:${port:6789}",
"MESH_NZBGET_PASSWORD_FILE": "/run/secrets/password",
"MESH_NZBGET_CONFIG_FILE": "/run/config/config.json",
"MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config"
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/ombi
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 servarr/settings.ts servarr/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/ombi/dist /app/modules/ombi/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/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js
# NOT dist/servarr/index.js: that is a step the host runs to completion, named by the `servarr`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+58 -10
View File
@@ -28,10 +28,15 @@
"path": "/var/lib/mesh/ombi",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/ombi/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -39,7 +44,7 @@
"id": "server",
"type": "container",
"name": "ombi",
"image": "lscr.io/linuxserver/ombi@sha256:a6f76ac521ba01eee2e9f0c23a3fed22e56630d97a04d5eeaeaa36c1e681640d",
"image": "lscr.io/linuxserver/ombi@sha256:22d6ebadbaaa728571353e74dc2173719e0fb02d4eaec551a7e9d2ee99ef68ac",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -49,7 +54,7 @@
"3579"
],
"volumes": [
"/services/ombi/config:/config"
"${dir:config}:/config"
]
},
{
@@ -68,24 +73,59 @@
"volumes": [
"/var/lib/mesh/ombi/broker:/run/secrets/broker:ro",
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro",
"/services/ombi/config:/var/lib/ombi/config:ro"
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_OMBI_URL": "http://127.0.0.1:3579",
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json",
"MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config"
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
},
{
"id": "servarr",
"type": "container",
"name": "mesh-ombi-servarr",
"network": "host",
"run-once": true,
"volumes": [
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
"${dir:state}/sonarr-api.json:/run/servarr/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/servarr/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/servarr/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/servarr/radarr-api.secret:ro",
"${dir:state}/lidarr-api.json:/run/servarr/lidarr-api.json:ro",
"${dir:state}/lidarr-api.secret:/run/servarr/lidarr-api.secret:ro"
],
"env": {
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
"MESH_SERVARR_DIR": "/run/servarr"
},
"args": [
"run",
"/app/modules/ombi/dist/servarr/index.js"
],
"restart-on": [
"bound-sonarr-api",
"secret-sonarr-api",
"bound-radarr-api",
"secret-radarr-api",
"bound-lidarr-api",
"secret-lidarr-api"
],
"artifact": "runtime"
}
],
"requires": [
"route"
"lidarr-api",
"radarr-api",
"route",
"sonarr-api"
],
"contributes": {
"route": {
@@ -94,7 +134,15 @@
}
},
"binds": {
"route": "/var/lib/mesh/ombi/route.json"
"route": "${dir:state}/route.json",
"sonarr-api": "${dir:state}/sonarr-api.json",
"radarr-api": "${dir:state}/radarr-api.json",
"lidarr-api": "${dir:state}/lidarr-api.json"
},
"secrets": {
"sonarr-api": "${dir:state}/sonarr-api.secret",
"radarr-api": "${dir:state}/radarr-api.secret",
"lidarr-api": "${dir:state}/lidarr-api.secret"
},
"build": {
"on": [
+5
View File
@@ -4,6 +4,11 @@
"description": "ombi — media requests. 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 servarr/settings.ts servarr/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"
},
+59
View File
@@ -0,0 +1,59 @@
// ombi's Servarr step — run once by the host after ombi's server starts, and run again whenever a
// binding or pair credential it reads changes (the container's `restart-on`, novox/hq ADR 0099).
//
// **A step, not a loop**, for the reason route-adapter gives: 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 any app could not be put right — a refused credential, an unreachable app, an
// ombi that cannot reach it — so the node reports the step failed and the host runs it again on the
// next apply. It is declared last in the manifest, so its failing gates nothing else of ombi's
// (novox/hq ADR 0136).
//
// Reads, per app, `<dir>/<provision>.json` (the binding) and `<dir>/<provision>.secret` (the pair
// credential), where <dir> is MESH_SERVARR_DIR. Never prints a key.
import { join } from "node:path";
import { APPS, ombiReady, readBinding, readIfThere, reconcileApp, type Http } from "./settings.js";
const dir = process.env.MESH_SERVARR_DIR ?? "/run/servarr";
const url = process.env.MESH_OMBI_URL ?? "http://127.0.0.1:3579";
const apiKey = (await readIfThere(process.env.MESH_OMBI_API_KEY_FILE))?.trim() ?? process.env.MESH_OMBI_API_KEY ?? "";
const waitSeconds = Number(process.env.MESH_OMBI_WAIT_SECONDS ?? "180");
const http: Http = { fetch: (u, init) => fetch(u, init) };
if (!apiKey) {
console.error("[ombi-servarr] no ombi API key — ombi's own `api-key` secret has not been accepted");
process.exit(1);
}
const ombi = { url, apiKey };
if (!(await ombiReady(http, ombi, waitSeconds * 1000))) {
console.error(`[ombi-servarr] ombi did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
let failed = 0;
for (const spec of APPS) {
const outcome = await reconcileApp(
http,
ombi,
spec,
await readBinding(join(dir, `${spec.provision}.json`)),
await readIfThere(join(dir, `${spec.provision}.secret`)),
);
switch (outcome.result) {
case "unchanged":
console.log(`[ombi-servarr] ${outcome.app}: already as the mesh says; connection tested`);
break;
case "written":
console.log(`[ombi-servarr] ${outcome.app}: wrote ${outcome.fields.join(", ")}; connection tested`);
break;
case "refused":
failed++;
console.error(`[ombi-servarr] ${outcome.app}: ${outcome.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
+304
View File
@@ -0,0 +1,304 @@
// Where ombi reaches Sonarr, Radarr and Lidarr — decided by the mesh, written into ombi by ombi's
// own API.
//
// **Why this exists.** ombi keeps its connection to each Servarr app in its own database
// (OmbiSettings.db), not in a file, so the mesh has nowhere to write `${bound:sonarr-api:at}` for it.
// ombi requires `sonarr-api`, `radarr-api` and `lidarr-api`; the mesh delivers, for each, a binding
// (where the app is: `at`, and what it serves: `port`, `scheme`, `url-base`) and a pair credential
// (the app's API key, accepted by the operator — a Servarr app has exactly one key and the mesh
// cannot mint it). This step reads those files and makes ombi's settings say the same thing.
//
// **Only the connection, and only when it differs.** Host, port, TLS, base path and API key. The
// quality profile, root folder, language profile, tags, "enabled" and every other choice an operator
// made in ombi's settings screen are left exactly as they are: the mesh knows where the app is, not
// what ombi should do with it. Radarr's 4K instance is a different Radarr and is not touched.
//
// **A credential the app refuses is never written.** Until the operator accepts the app's API key
// for this pair, the mesh delivers a value it minted itself, which no Servarr app will ever accept
// (novox/hq ADR 0092). Writing it would replace a working key in ombi with a dead one. So the key is
// tried against the app first; refused, nothing for that app 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/servarr.test.ts).
import { readFile } from "node:fs/promises";
/** One Servarr app ombi connects to, and the shape of that connection in ombi's API. */
export interface ServarrApp {
/** The app, as ombi's API names it: /Settings/<app>, /Tester/<app>. */
app: "sonarr" | "radarr" | "lidarr";
/** The provision it is required as — the manifest's `requires`, `binds` and `secrets` key. */
provision: string;
/** The app's own status endpoint, which answers 401 to a wrong key. */
statusPath: string;
/**
* Where the one connection sits in ombi's settings document. Radarr's is `{radarr, radarr4K}`
* (two Radarr instances); only `radarr` is this provision's.
*/
within?: string;
}
export const APPS: readonly ServarrApp[] = [
{ app: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" },
{ app: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status", within: "radarr" },
{ app: "lidarr", provision: "lidarr-api", statusPath: "/api/v1/system/status" },
];
/** The connection fields ombi keeps for an app — the only ones this step ever writes. */
export interface Connection {
ip: string;
port: number;
ssl: boolean;
/** ombi's name for the app's URL base; null when the app is served at the root. */
subDir: string | null;
apiKey: string;
}
/** What the mesh wrote at `binds.<provision>`: the binding document (controller's boundFile). */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
export type Wanted = { ok: true; connection: Connection; from: string } | { ok: false; problem: string };
/**
* The connection the mesh says ombi should use, from the binding and the pair credential.
*
* Refused rather than guessed when the binding cannot be dialled from ombi's own container: a
* loopback `at` — what the mesh hands a machine that is not on the private network — is ombi's
* container itself, not the app.
*/
export function wanted(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Wanted {
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() : "";
const serves = binding.serves ?? {};
const port = Number(serves.port);
if (!at) {
return { ok: false, problem: `the ${spec.provision} binding names no host (at)` };
}
if (isLoopback(at)) {
return {
ok: false,
problem:
`the ${spec.provision} binding says ${spec.app} is at ${at}, which from ombi's own container is ` +
`ombi itself. The mesh hands loopback to a machine that is not on the private network; put it ` +
`on the private network so ${spec.app} has an address ombi can dial`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${spec.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 ${spec.provision} binding serves scheme ${scheme}, which ombi cannot dial` };
}
const key = (credential ?? "").trim();
if (!key) {
return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` };
}
return {
ok: true,
from: typeof binding.from === "string" ? binding.from : "",
connection: { ip: at, port, ssl: scheme === "https", subDir: subDirOf(serves["url-base"]), apiKey: key },
};
}
/** ombi's `subDir`: the URL base with its slashes trimmed, null when there is none. */
export function subDirOf(urlBase: unknown): string | null {
const trimmed = typeof urlBase === "string" ? urlBase.trim().replace(/^\/+|\/+$/g, "") : "";
return trimmed === "" ? null : trimmed;
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
/** Which connection fields differ between what ombi holds and what the mesh says. Names only. */
export function differing(current: Record<string, unknown> | undefined, want: Connection): (keyof Connection)[] {
const now = current ?? {};
const out: (keyof Connection)[] = [];
if (String(now.ip ?? "") !== want.ip) out.push("ip");
if (Number(now.port ?? 0) !== want.port) out.push("port");
if (Boolean(now.ssl) !== want.ssl) out.push("ssl");
if (subDirOf(now.subDir) !== want.subDir) out.push("subDir");
if (String(now.apiKey ?? "") !== want.apiKey) out.push("apiKey");
return out;
}
/** ombi's settings for the app with the connection laid over them and nothing else changed. */
export function withConnection(current: Record<string, unknown> | undefined, want: Connection): Record<string, unknown> {
return { ...(current ?? {}), ip: want.ip, port: want.port, ssl: want.ssl, subDir: want.subDir, apiKey: want.apiKey };
}
/** The app's base URL as the step dials it — the same host and port ombi will be given. */
export function appUrl(want: Connection): string {
const scheme = want.ssl ? "https" : "http";
const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip;
return `${scheme}://${host}:${want.port}${want.subDir ? `/${want.subDir}` : ""}`;
}
/** How one app came out. */
export type Outcome =
| { app: string; result: "unchanged" }
| { app: string; result: "written"; fields: string[] }
| { app: string; result: "refused"; problem: string };
/** The HTTP the step needs, so a test can stand fakes in for ombi and the apps. */
export interface Http {
fetch(url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }): Promise<{
status: number;
text(): Promise<string>;
}>;
}
export interface Ombi {
url: string;
apiKey: string;
}
async function ombiCall(http: Http, ombi: Ombi, method: string, path: string, body?: unknown): Promise<unknown> {
const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1${path}`, {
method,
headers: {
ApiKey: ombi.apiKey,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (res.status < 200 || res.status >= 300) {
// The body is ombi's error, never a request echo, so it carries no key.
throw new Error(`ombi ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
}
return text ? (JSON.parse(text) as unknown) : undefined;
}
/**
* Does the app take this key? `true` it does, `false` it refused it (401/403), and a thrown error
* when it could not be asked — unreachable, or answering something that is neither.
*/
export async function appTakes(http: Http, spec: ServarrApp, want: Connection): Promise<boolean> {
const res = await http.fetch(`${appUrl(want)}${spec.statusPath}`, {
method: "GET",
headers: { "X-Api-Key": want.apiKey, Accept: "application/json" },
});
if (res.status === 401 || res.status === 403) return false;
if (res.status >= 200 && res.status < 300) return true;
throw new Error(`${spec.app} answered ${res.status} at ${spec.statusPath}`);
}
/** The remedy for a refused key, in the controller's own words (ADR 0092). */
export function acceptRemedy(spec: ServarrApp, from: string): string {
return (
`${spec.app} refuses the ${spec.provision} credential the mesh delivered, so it was not written ` +
`into ombi. A Servarr app has one API key and the mesh cannot make it: accept ${spec.app}'s own ` +
`key for this pair — \`secret accept <this node> ombi ${spec.provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${spec.app}'s ApiKey>\``
);
}
/**
* Bring ombi's connection to one app in line with the mesh: check the key against the app, compare,
* write only the connection fields when they differ, then have ombi test the connection from its own
* container. Never throws: every failure is an outcome with a reason.
*/
export async function reconcileApp(
http: Http,
ombi: Ombi,
spec: ServarrApp,
binding: Binding | undefined,
credential: string | undefined,
): Promise<Outcome> {
const w = wanted(spec, binding, credential);
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
// narrow.
if ("problem" in w) return { app: spec.app, result: "refused", problem: w.problem };
const want = w.connection;
try {
if (!(await appTakes(http, spec, want))) {
return { app: spec.app, result: "refused", problem: acceptRemedy(spec, w.from) };
}
} catch (err) {
return {
app: spec.app,
result: "refused",
problem: `${spec.app} could not be asked whether it takes the key at ${want.ip}:${want.port}: ${message(err)}`,
};
}
try {
const document = (await ombiCall(http, ombi, "GET", `/Settings/${spec.app}`)) as Record<string, unknown> | undefined;
const current = spec.within ? (document?.[spec.within] as Record<string, unknown> | undefined) : document;
const fields = differing(current, want);
if (fields.length > 0) {
const next = withConnection(current, want);
const body = spec.within ? { ...(document ?? {}), [spec.within]: next } : next;
const saved = await ombiCall(http, ombi, "POST", `/Settings/${spec.app}`, body);
if (saved === false) {
return { app: spec.app, result: "refused", problem: `ombi declined to save its ${spec.app} settings` };
}
}
// ombi's own test, from ombi's own container — the path the step's check above did not take.
const tested = (await ombiCall(http, ombi, "POST", `/Tester/${spec.app}`, withConnection(current, want))) as
| { isValid?: boolean; expectedSubDir?: string | null }
| undefined;
if (!tested?.isValid) {
const hint = tested?.expectedSubDir ? ` (ombi expected the base path ${tested.expectedSubDir})` : "";
return {
app: spec.app,
result: "refused",
problem:
`ombi cannot reach ${spec.app} at ${want.ip}:${want.port} from its own container${hint}` +
(fields.length > 0 ? `; its settings were written (${fields.join(", ")})` : ""),
};
}
return fields.length > 0 ? { app: spec.app, result: "written", fields } : { app: spec.app, result: "unchanged" };
} catch (err) {
return { app: spec.app, result: "refused", problem: message(err) };
}
}
/** Wait for ombi to answer, because the step runs right after its container starts. */
export async function ombiReady(http: Http, ombi: Ombi, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1/Status`, { method: "GET" });
if (res.status === 200) return true;
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, pauseMs));
}
}
/** 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 | undefined): Promise<Binding | undefined> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as Binding;
} catch {
return undefined;
}
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+147
View File
@@ -0,0 +1,147 @@
// What holds ombi's Servarr step (servarr/settings.ts): the connection ombi keeps for each app is
// made to say what the mesh bound — host, port, TLS, base path, key — and nothing else it keeps is
// touched; nothing is written when nothing differs; Radarr's 4K instance is left alone; and a key the
// app refuses (the mesh's own minted value, before the operator accepts the app's key) is never
// written, with the `secret accept` that fixes it named.
//
// ombi and the apps are fakes: the routes the step touches, answering as the real ones do (checked
// against lscr.io/linuxserver/ombi 4.53.10 and the catalogue's pinned sonarr/radarr/lidarr).
import { test } from "node:test";
import assert from "node:assert/strict";
import { APPS, differing, reconcileApp, subDirOf, wanted, type Binding, type Http, type ServarrApp } from "../servarr/settings.ts";
const SONARR = APPS.find((a) => a.app === "sonarr") as ServarrApp;
const RADARR = APPS.find((a) => a.app === "radarr") as ServarrApp;
const LIDARR = APPS.find((a) => a.app === "lidarr") as ServarrApp;
const THE_KEY = "the-apps-own-key";
function binding(provision: string, port: number, at = "ace.internal"): Binding {
return { binding: 1, provision, from: "ace", at, as: "mesh_ace_ombi", serves: { scheme: "http", port, "url-base": "" } } as Binding;
}
interface Call {
method: string;
url: string;
body?: unknown;
}
/** ombi's settings store and the apps' key check, behind one fetch. */
function fakes(settings: Record<string, unknown>, opts: { appKey?: string; reachable?: boolean } = {}) {
const calls: Call[] = [];
const appKey = opts.appKey ?? THE_KEY;
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
const body = init?.body ? (JSON.parse(init.body) as unknown) : undefined;
calls.push({ method, url, body });
const reply = (status: number, value?: unknown) => ({
status,
text: async () => (value === undefined ? "" : JSON.stringify(value)),
});
const u = new URL(url);
if (u.pathname.endsWith("/system/status")) {
if (opts.reachable === false) throw new Error("connect ECONNREFUSED");
return init?.headers?.["X-Api-Key"] === appKey ? reply(200, { version: "4" }) : reply(401);
}
if (init?.headers?.ApiKey !== "ombi-key") return reply(401);
const m = u.pathname.match(/^\/api\/v1\/(Settings|Tester)\/(\w+)$/);
if (!m) return reply(404);
const [, kind, app] = m;
if (kind === "Settings" && method === "GET") return reply(200, settings[app]);
if (kind === "Settings" && method === "POST") {
settings[app] = body;
return reply(200, true);
}
const tried = body as { apiKey?: string };
return reply(200, { isValid: tried.apiKey === appKey, expectedSubDir: null });
},
};
return { http, calls, settings };
}
const OMBI = { url: "http://127.0.0.1:3579", apiKey: "ombi-key" };
const operatorSonarr = () => ({
enabled: true, apiKey: "old-key", qualityProfile: "3", seasonFolders: true, rootPath: "10",
qualityProfileAnime: "7", rootPathAnime: "9", languageProfile: 1, ssl: false, subDir: null,
ip: "sonarr", port: 8989, id: 5,
});
test("it writes the connection the mesh bound, and keeps every other setting ombi had", async () => {
const f = fakes({ sonarr: operatorSonarr() });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), `${THE_KEY}\n`);
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["ip", "port", "apiKey"] });
assert.deepEqual(f.settings.sonarr, {
...operatorSonarr(), ip: "ace.internal", port: 20101, apiKey: THE_KEY, ssl: false, subDir: null,
});
// Checked against the app itself, at the bound address, before anything was written.
assert.equal(f.calls[0].url, "http://ace.internal:20101/api/v3/system/status");
});
test("nothing is written when ombi already says what the mesh says", async () => {
const f = fakes({ sonarr: { ...operatorSonarr(), ip: "ace.internal", port: 20101, apiKey: THE_KEY } });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), THE_KEY);
assert.deepEqual(out, { app: "sonarr", result: "unchanged" });
assert.equal(f.calls.filter((c) => c.method === "POST" && c.url.includes("/Settings/")).length, 0);
});
test("a key the app refuses is never written, and the accept that fixes it is named", async () => {
const f = fakes({ sonarr: operatorSonarr() });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), "a-value-the-mesh-minted");
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /secret accept <this node> ombi sonarr-api --provider ace/);
assert.doesNotMatch((out as { problem: string }).problem, /a-value-the-mesh-minted/);
assert.deepEqual(f.settings.sonarr, operatorSonarr(), "ombi's working settings were left alone");
assert.equal(f.calls.some((c) => c.url.includes("/api/v1/")), false, "ombi was not even asked");
});
test("an app it cannot reach is reported, and ombi is left alone", async () => {
const f = fakes({ sonarr: operatorSonarr() }, { reachable: false });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), THE_KEY);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /could not be asked.*ECONNREFUSED/);
assert.deepEqual(f.settings.sonarr, operatorSonarr());
});
test("radarr's connection is written inside its combined document, and the 4K instance is untouched", async () => {
const fourK = { enabled: true, apiKey: "4k-key", ip: "radarr4k", port: 7879, defaultQualityProfile: "9", id: 7 };
const f = fakes({ radarr: { radarr: { enabled: true, apiKey: "old", ip: "radarr", port: 7878, defaultRootPath: "/movies", id: 6 }, radarr4K: fourK } });
const out = await reconcileApp(f.http, OMBI, RADARR, binding("radarr-api", 20102), THE_KEY);
assert.equal(out.result, "written");
const doc = f.settings.radarr as { radarr: Record<string, unknown>; radarr4K: unknown };
assert.deepEqual(doc.radarr4K, fourK);
assert.equal(doc.radarr.ip, "ace.internal");
assert.equal(doc.radarr.port, 20102);
assert.equal(doc.radarr.defaultRootPath, "/movies");
});
test("lidarr is checked on its own API version", async () => {
const f = fakes({ lidarr: { enabled: true, apiKey: null, ip: null, port: 0, id: 0 } });
const out = await reconcileApp(f.http, OMBI, LIDARR, binding("lidarr-api", 20103), THE_KEY);
assert.equal(out.result, "written");
assert.equal(f.calls[0].url, "http://ace.internal:20103/api/v1/system/status");
});
test("a loopback binding is refused: from ombi's container it is ombi itself", () => {
const w = wanted(SONARR, binding("sonarr-api", 20101, "127.0.0.1"), THE_KEY);
assert.equal(w.ok, false);
assert.match((w as { problem: string }).problem, /private network/);
});
test("the base path is ombi's subDir, slashes trimmed; empty is none", () => {
assert.equal(subDirOf(""), null);
assert.equal(subDirOf("/sonarr/"), "sonarr");
assert.deepEqual(
differing({ ip: "h", port: 1, ssl: false, subDir: "", apiKey: "k" }, { ip: "h", port: 1, ssl: false, subDir: null, apiKey: "k" }),
[],
);
});
test("an https binding sets ombi's ssl flag", () => {
const b = binding("sonarr-api", 443);
(b.serves as Record<string, unknown>).scheme = "https";
const w = wanted(SONARR, b, THE_KEY);
assert.equal(w.ok && w.connection.ssl, true);
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "servarr/index.ts"]
}
+1 -1
View File
@@ -82,7 +82,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:8080",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:${port:8080}",
"MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password",
"MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json",
"MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config"
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/radarr
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 downloads/settings.ts downloads/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/radarr/dist /app/modules/radarr/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/radarr/dist/index.js,/app/modules/radarr/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
@@ -0,0 +1,188 @@
// 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
@@ -0,0 +1,778 @@
// 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);
}
+90 -7
View File
@@ -1,6 +1,19 @@
{
"module": "radarr",
"version": "1",
"provides": [
{
"name": "radarr-api",
"scope": "mesh"
}
],
"serves": {
"radarr-api": {
"scheme": "http",
"port": 7878,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -18,7 +31,7 @@
"port": 7878,
"protocol": "tcp",
"from": "mesh",
"why": "managing films"
"why": "managing films: its web UI, and the API other modules reach as radarr-api"
}
],
"accesses": [
@@ -38,10 +51,15 @@
"path": "/var/lib/mesh/radarr",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/radarr/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -49,7 +67,7 @@
"id": "server",
"type": "container",
"name": "radarr",
"image": "lscr.io/linuxserver/radarr@sha256:119aaa4a4f7349bcd2a136c5373a0d7925b5479915c7dfe0c0ad352db2a6d438",
"image": "lscr.io/linuxserver/radarr@sha256:c960f2b52ec6542dbe6707c5a21e696a7c74fd8b17997454f4d10a55dacee133",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -59,7 +77,7 @@
"7878"
],
"volumes": [
"/services/radarr/config:/config",
"${dir:config}:/config",
"/services/media/movies:/movies",
"/services/media/downloads:/downloads"
]
@@ -71,17 +89,74 @@
"network": "host",
"volumes": [
"/var/lib/mesh/radarr/broker:/run/secrets/broker:ro",
"/services/radarr/config:/var/lib/radarr/config:ro"
"${dir:config}:/var/lib/radarr/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RADARR_URL": "http://127.0.0.1:7878",
"MESH_RADARR_URL": "http://127.0.0.1:${port:7878}",
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/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-radarr-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/radarr/config:ro",
"${dir:downloads-memory}:/var/lib/downloads"
],
"env": {
"MESH_DOWNLOADS_APP": "radarr",
"MESH_DOWNLOADS_API": "v3",
"MESH_DOWNLOADS_APP_URL": "http://127.0.0.1:${port:7878}",
"MESH_DOWNLOADS_APP_CONFIG": "/var/lib/radarr/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/radarr/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": {
@@ -91,7 +166,15 @@
}
},
"binds": {
"route": "/var/lib/mesh/radarr/route.json"
"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"
},
"build": {
"on": [
+5
View File
@@ -4,6 +4,11 @@
"description": "radarr — movie 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
@@ -0,0 +1,365 @@
// 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"]
"include": ["client.ts", "index.ts", "tools/index.ts", "downloads/settings.ts", "downloads/index.ts"]
}
+1 -1
View File
@@ -100,7 +100,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SEARXNG_URL": "http://127.0.0.1:8080",
"MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}",
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
+14 -1
View File
@@ -1,6 +1,19 @@
{
"module": "sonarr",
"version": "1",
"provides": [
{
"name": "sonarr-api",
"scope": "mesh"
}
],
"serves": {
"sonarr-api": {
"scheme": "http",
"port": 8989,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -80,7 +93,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SONARR_URL": "http://127.0.0.1:8989",
"MESH_SONARR_URL": "http://127.0.0.1:${port:8989}",
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
},
"artifact": "runtime"