diff --git a/modules/audit-logger/audit.ts b/modules/audit-logger/audit.ts index 9ed2f6d..aa7ee46 100644 --- a/modules/audit-logger/audit.ts +++ b/modules/audit-logger/audit.ts @@ -11,15 +11,18 @@ export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string { return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log"; } -/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. */ +/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. The id + * is the event's own x-event-id (ADR 0047) — the handle a reader dedups the at-least-once trail on. */ export async function record(event: Event, path: string): Promise { const line = JSON.stringify({ - id: event.type + "@" + event.at, // a stable-ish key until x-event-id headers land (ADR 0047) + id: event.id, type: event.type, source: event.source, node: event.node, at: event.at, + ...(event.causationId ? { causationId: event.causationId } : {}), + ...(event.schema ? { schema: event.schema } : {}), body: event.body, }) + "\n"; await mkdir(dirname(path), { recursive: true }).catch(() => {}); diff --git a/modules/audit-logger/module.json b/modules/audit-logger/module.json index 72bf5b9..d46a715 100644 --- a/modules/audit-logger/module.json +++ b/modules/audit-logger/module.json @@ -1,15 +1,37 @@ { "module": "audit-logger", "version": "1", - "consumes": [ - "#" - ], + "consumes": ["#"], + "own-secrets": { + "broker": "/var/lib/audit-logger/broker" + }, "resources": [ { - "id": "log", + "id": "state", "type": "directory", "path": "/var/lib/audit-logger", "mode": "0700" + }, + { + "id": "trail", + "type": "directory", + "path": "/var/lib/audit-logger/trail", + "mode": "0700" + }, + { + "id": "run", + "type": "container", + "name": "mesh-audit-logger", + "image": "mesh-runtime-audit@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/audit-logger/broker:/run/secrets/broker:ro", + "/var/lib/audit-logger/trail:/trail" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "AUDIT_LOG": "/trail/audit.log" + } } ] } diff --git a/modules/bazarr/client.ts b/modules/bazarr/client.ts new file mode 100644 index 0000000..9d6acce --- /dev/null +++ b/modules/bazarr/client.ts @@ -0,0 +1,165 @@ +// The Bazarr API client — bazarr's own code, living in the module (novox/hq ADR 0044). Bazarr +// manages subtitles for a Sonarr/Radarr library: it tracks which episodes and movies are still +// missing subtitles, searches providers for them, and records what it downloaded. This client +// talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it. + +import { readFileSync } from "node:fs"; + +export interface WantedSubtitle { + kind: "episode" | "movie"; + title: string; // series + episode, or movie title + path?: string; + seriesId?: number; // sonarr series id (episodes) + episodeId?: number; // sonarr episode id (episodes) + radarrId?: number; // radarr movie id (movies) + missing: string[]; // language names still missing +} + +export interface ProviderSubtitle { + provider: string; + language: string; + hearingImpaired: boolean; + forced: boolean; + score?: number; + release?: string; + subtitle: string; // the opaque token Bazarr uses to download this exact result +} + +export interface HistoryEntry { + kind: "episode" | "movie"; + id: string; // stable dedup key across polls + title: string; + language?: string; + provider?: string; + path?: string; + timestamp?: string; + description?: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class BazarrClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key + * there is nothing to talk to, so this throws rather than run half-configured. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient { + const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE); + const url = cfg.url ?? env.MESH_BAZARR_URL; + const apiKey = cfg.apiKey ?? env.MESH_BAZARR_API_KEY; + if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL"); + if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY"); + return new BazarrClient(url, apiKey); + } + + private async request(method: string, path: string, params: Record = {}): Promise { + const url = new URL(`${this.baseUrl}/api${path}`); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url.toString(), { method, headers: { "X-API-KEY": this.apiKey, Accept: "application/json" } }); + if (!res.ok) throw new Error(`Bazarr API ${method} ${path}: ${res.status} ${await res.text()}`); + // Downloads/patches return an empty body; only GETs carry JSON. + const text = await res.text(); + return text ? JSON.parse(text) : {}; + } + + private get(path: string, params?: Record): Promise { + return this.request("GET", path, params); + } + + private languageNames(missing: any[]): string[] { + return (missing ?? []).map((m: any) => m?.name ?? m?.code2 ?? m?.code3).filter(Boolean); + } + + /** Episodes and movies still missing subtitles — Bazarr's core "what's left to do" list. */ + async getWanted(limit = 50): Promise { + const [eps, movies] = await Promise.all([ + this.get("/episodes/wanted", { start: "0", length: String(limit) }), + this.get("/movies/wanted", { start: "0", length: String(limit) }), + ]); + const episodes: WantedSubtitle[] = (eps?.data ?? []).map((e: any) => ({ + kind: "episode" as const, + title: `${e.seriesTitle ?? e.series ?? "Unknown"} — ${e.episodeTitle ?? e.episode_title ?? ""}`.trim(), + path: e.path, + seriesId: e.sonarrSeriesId, + episodeId: e.sonarrEpisodeId, + missing: this.languageNames(e.missing_subtitles), + })); + const films: WantedSubtitle[] = (movies?.data ?? []).map((m: any) => ({ + kind: "movie" as const, + title: m.title ?? "Unknown", + path: m.path, + radarrId: m.radarrId, + missing: this.languageNames(m.missing_subtitles), + })); + return [...episodes, ...films]; + } + + /** Ask providers what subtitles are available for one wanted episode — a manual search. */ + async searchEpisode(episodeId: number): Promise { + const raw = await this.get("/providers/episodes", { episodeid: String(episodeId) }); + return this.mapProviderResults(raw); + } + + /** Ask providers what subtitles are available for one movie — a manual search. */ + async searchMovie(radarrId: number): Promise { + const raw = await this.get("/providers/movies", { radarrid: String(radarrId) }); + return this.mapProviderResults(raw); + } + + private mapProviderResults(raw: any): ProviderSubtitle[] { + const list = Array.isArray(raw) ? raw : (raw?.data ?? []); + return list.map((r: any) => ({ + provider: r.provider, + language: r.language?.name ?? r.language ?? "unknown", + hearingImpaired: Boolean(r.hearing_impaired ?? r.hi), + forced: Boolean(r.forced), + score: r.score, + release: r.release_info?.[0] ?? r.release_info, + subtitle: r.subtitle, + })); + } + + /** Recent subtitle-download history, episodes and movies together, newest first. Each entry + * carries a stable id so the events poller can tell a fresh download from one already seen. */ + async getHistory(limit = 40): Promise { + const [eps, movies] = await Promise.all([ + this.get("/episodes/history", { start: "0", length: String(limit) }), + this.get("/movies/history", { start: "0", length: String(limit) }), + ]); + const key = (kind: string, r: any): string => + `${kind}:${r.timestamp ?? r.parsed_timestamp ?? ""}:${r.subtitles_path ?? r.path ?? ""}:${r.language?.code3 ?? r.language ?? ""}`; + const episodes: HistoryEntry[] = (eps?.data ?? []).map((r: any) => ({ + kind: "episode" as const, + id: key("episode", r), + title: `${r.seriesTitle ?? "Unknown"} — ${r.episodeTitle ?? ""}`.trim(), + language: r.language?.name ?? r.language, + provider: r.provider, + path: r.subtitles_path, + timestamp: r.timestamp, + description: r.description, + })); + const films: HistoryEntry[] = (movies?.data ?? []).map((r: any) => ({ + kind: "movie" as const, + id: key("movie", r), + title: r.title ?? "Unknown", + language: r.language?.name ?? r.language, + provider: r.provider, + path: r.subtitles_path, + timestamp: r.timestamp, + description: r.description, + })); + return [...episodes, ...films]; + } +} diff --git a/modules/bazarr/index.ts b/modules/bazarr/index.ts new file mode 100644 index 0000000..d915972 --- /dev/null +++ b/modules/bazarr/index.ts @@ -0,0 +1,47 @@ +// bazarr's events. The tool runtime imports this once the broker is bound. Bazarr's one genuinely +// observable thing is a subtitle arriving: it works away in the background, searching providers for +// the missing-subtitle list, and when it succeeds a subtitle appears in its history. That is worth +// announcing to the mesh. +// +// Emits (novox/hq ADR 0046/0047): +// module.bazarr.subtitle.downloaded — a subtitle was fetched for an episode or movie +// +// Bazarr has nothing on the mesh it usefully reacts to (a download completing is Sonarr/Radarr's +// business, and they trigger Bazarr directly), so it consumes nothing — a pure emitter. +// +// The event is observation-based: poll history and diff. Primed silently on the first look, or a +// restart would re-announce the whole recent history as freshly downloaded. + +import { emit } from "@novox/mesh-sdk/events"; +import { BazarrClient } from "./client.js"; + +const bazarr = BazarrClient.fromEnv(); + +const seen = new Set(); +let primed = false; +async function pollHistory(): Promise { + const entries = await bazarr.getHistory(40); + for (const entry of entries) { + if (seen.has(entry.id)) continue; + if (primed) { + await emit("module.bazarr.subtitle.downloaded", { + kind: entry.kind, + title: entry.title, + language: entry.language, + provider: entry.provider, + path: entry.path, + }); + } + seen.add(entry.id); + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[bazarr] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollHistory, 60_000); + +console.log("[bazarr] watching subtitle-download history"); diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index 90e6ac2..724f0e3 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.bazarr.subtitle.downloaded" + ], + "own-secrets": { + "broker": "/var/lib/mesh/bazarr/broker" + }, "listens": [ { "port": 6767, @@ -13,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/bazarr", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -68,6 +80,35 @@ "/services/media/anime:/anime", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/bazarr/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-bazarr", + "image": "mesh-runtime-bazarr@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro", + "/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro", + "/services/bazarr/config:/var/lib/bazarr/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_BAZARR_URL": "http://127.0.0.1:6767", + "MESH_BAZARR_CONFIG_FILE": "/run/config/config.json", + "MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/bazarr/package.json b/modules/bazarr/package.json new file mode 100644 index 0000000..20c3e13 --- /dev/null +++ b/modules/bazarr/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-bazarr", + "version": "0.1.0", + "description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/bazarr/tools/index.ts b/modules/bazarr/tools/index.ts new file mode 100644 index 0000000..3e0a574 --- /dev/null +++ b/modules/bazarr/tools/index.ts @@ -0,0 +1,57 @@ +// bazarr's tools — its own code (novox/hq ADR 0044), importing bazarr's client. They return +// structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { BazarrClient } from "../client.js"; + +export function getBazarrTools(bazarr: BazarrClient): ToolDefinition[] { + return [ + { + name: "bazarr_wanted", + description: "Episodes and movies still missing subtitles, with the languages each still needs.", + input: { limit: { type: "number", description: "max items per kind (default 50)" } }, + run: async (args) => { + const wanted = await bazarr.getWanted(args.limit ? Number(args.limit) : 50); + return { count: wanted.length, wanted }; + }, + }, + { + name: "bazarr_search_subtitles", + description: "Manually search subtitle providers for one wanted item — pass an episodeId or a radarrId.", + input: { + episodeId: { type: "number", description: "a Sonarr episode id (from bazarr_wanted)" }, + radarrId: { type: "number", description: "a Radarr movie id (from bazarr_wanted)" }, + }, + run: async (args) => { + if (args.episodeId !== undefined) { + const results = await bazarr.searchEpisode(Number(args.episodeId)); + return { kind: "episode", episodeId: Number(args.episodeId), count: results.length, results }; + } + if (args.radarrId !== undefined) { + const results = await bazarr.searchMovie(Number(args.radarrId)); + return { kind: "movie", radarrId: Number(args.radarrId), count: results.length, results }; + } + throw new Error("pass either episodeId or radarrId"); + }, + }, + { + name: "bazarr_history", + description: "Recent subtitle-download history — what was downloaded, for which title, from which provider.", + input: { limit: { type: "number", description: "max entries per kind (default 40)" } }, + run: async (args) => { + const history = await bazarr.getHistory(args.limit ? Number(args.limit) : 40); + return { count: history.length, history }; + }, + }, + ]; +} + +// Exposed only when Bazarr is configured; otherwise bazarr contributes no tools rather than +// failing the whole runtime. +registerModuleTools("bazarr", (env) => { + try { + return getBazarrTools(BazarrClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/bazarr/tsconfig.json b/modules/bazarr/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/bazarr/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/cloudflare-dns/client.ts b/modules/cloudflare-dns/client.ts new file mode 100644 index 0000000..725e042 --- /dev/null +++ b/modules/cloudflare-dns/client.ts @@ -0,0 +1,127 @@ +// cloudflare-dns's own code (novox/hq ADR 0044). It provides the mesh `public-dns` interface +// (ADR 0049): a public name that resolves to the mesh's public ingress. Cloudflare is one registrar +// behind the neutral interface — a consumer names `public-dns`, never Cloudflare — so this file is +// the only place Cloudflare's API appears, and swapping registrars swaps only this module. + +import { readFileSync } from "node:fs"; + +export interface PublicRecord { + id: string; + name: string; + type: string; + content: string; +} + +export class CloudflareClient { + constructor( + private readonly token: string, + private readonly zoneId: string, + /** The zone this registers under, e.g. "example.com". */ + readonly domain: string, + /** What every public name points at — the mesh's public ingress (the reverse proxy). */ + readonly ingress: string, + ) {} + + static fromEnv(env: NodeJS.ProcessEnv = process.env): CloudflareClient { + // Which zone, domain and ingress are a mesh's own facts, not this module's — so they are + // settings, merged into a config file the mesh manages (novox/hq ADR 0051), read here. The + // token is the one secret and stays an own-secret. Env is honoured as a fallback for a + // hand-run instance, but the deployed path is the config file settings fill. + const config = readConfig(env.MESH_CLOUDFLARE_CONFIG_FILE); + const token = env.MESH_CLOUDFLARE_TOKEN ?? readSecret(env.MESH_CLOUDFLARE_TOKEN_FILE); + const zoneId = config.zone ?? env.MESH_CLOUDFLARE_ZONE_ID; + const domain = config.domain ?? env.MESH_PUBLIC_DOMAIN; + const ingress = config.ingress ?? env.MESH_PUBLIC_INGRESS; + if (!token || !zoneId || !domain || !ingress) { + throw new Error( + "cloudflare-dns is not configured — set its zone, domain and ingress in settings (and the " + + "token as its own-secret); until then it registers nothing", + ); + } + return new CloudflareClient(token, zoneId, domain, ingress); + } + + /** + * The public name a consumer gets: derived from its identity under the mesh's domain. Derived, not + * contributed, for the same reason minio derives a bucket name — the harness hands `remove` only + * the identity, so teardown must recompute exactly what creation made. + */ + nameFor(consumer: string): string { + return `${consumer.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase()}.${this.domain}`; + } + + /** An IP points at itself (A/AAAA); a hostname points through a CNAME. */ + private recordType(): "A" | "AAAA" | "CNAME" { + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(this.ingress)) return "A"; + if (this.ingress.includes(":")) return "AAAA"; + return "CNAME"; + } + + private async api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, { + method, + headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = (await res.json()) as { success?: boolean; result?: unknown; errors?: unknown }; + if (!res.ok || json.success === false) { + throw new Error(`cloudflare ${method} ${path}: ${res.status} ${JSON.stringify(json.errors ?? json)}`); + } + return json.result as T; + } + + async findRecord(name: string): Promise { + const records = await this.api( + "GET", + `/zones/${this.zoneId}/dns_records?name=${encodeURIComponent(name)}`, + ); + return records[0]; + } + + /** Point a public name at the mesh's ingress, idempotently — create it, or update one already there. */ + async upsert(name: string): Promise { + const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false }; + const existing = await this.findRecord(name); + if (existing) { + return this.api("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body); + } + return this.api("POST", `/zones/${this.zoneId}/dns_records`, body); + } + + /** Remove a public name, idempotently — a record already gone is not an error on reconcile. */ + async remove(name: string): Promise { + const existing = await this.findRecord(name); + if (existing) await this.api("DELETE", `/zones/${this.zoneId}/dns_records/${existing.id}`); + } + + /** Every record in the zone, for the diagnostic tool. */ + async records(): Promise { + return this.api("GET", `/zones/${this.zoneId}/dns_records`); + } +} + +function readSecret(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} + +interface Config { + zone?: string; + domain?: string; + ingress?: string; +} + +/** The settings-managed config file (a JSON document the mesh merges settings into). Absent or + * unparseable yields an empty config, which fromEnv then reports as unconfigured. */ +function readConfig(path: string | undefined): Config { + if (!path) return {}; + try { + return JSON.parse(readFileSync(path, "utf8")) as Config; + } catch { + return {}; + } +} diff --git a/modules/cloudflare-dns/module.json b/modules/cloudflare-dns/module.json new file mode 100644 index 0000000..294ddd1 --- /dev/null +++ b/modules/cloudflare-dns/module.json @@ -0,0 +1,77 @@ +{ + "module": "cloudflare-dns", + "version": "1", + "provides": [ + { + "name": "public-dns", + "scope": "mesh" + } + ], + "serves": { + "public-dns": {} + }, + "grants": { + "public-dns": "/var/lib/cloudflare-dns/grants" + }, + "receives": { + "public-dns": "/var/lib/cloudflare-dns/grants/mesh.json" + }, + "own-secrets": { + "token": "/var/lib/cloudflare-dns/token", + "broker": "/var/lib/mesh/cloudflare-dns/broker" + }, + "emits": [ + "module.cloudflare-dns.record.created", + "module.cloudflare-dns.record.removed" + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/cloudflare-dns", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/cloudflare-dns", + "mode": "0700" + }, + { + "id": "grants", + "type": "directory", + "path": "/var/lib/cloudflare-dns/grants", + "mode": "0700" + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/cloudflare-dns/config.json", + "merge": "json", + "content": "{}", + "mode": "0600" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-cloudflare-dns", + "image": "mesh-runtime-cloudflare-dns@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/cloudflare-dns/config.json:/run/config/config.json:ro", + "/var/lib/cloudflare-dns/grants:/grants", + "/var/lib/cloudflare-dns/token:/run/secrets/token:ro", + "/var/lib/mesh/cloudflare-dns/broker:/run/secrets/broker:ro" + ], + "env": { + "MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token", + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json", + "MESH_RECEIVES": "/var/lib/cloudflare-dns/grants/mesh.json" + } + } + ], + "capabilities": [ + "container-runtime" + ] +} diff --git a/modules/cloudflare-dns/package.json b/modules/cloudflare-dns/package.json new file mode 100644 index 0000000..01e230a --- /dev/null +++ b/modules/cloudflare-dns/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-cloudflare-dns", + "version": "0.1.0", + "description": "cloudflare-dns — a public-dns provider (ADR 0049): registers public names at Cloudflare. + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/cloudflare-dns/provisioner/index.ts b/modules/cloudflare-dns/provisioner/index.ts new file mode 100644 index 0000000..d214dab --- /dev/null +++ b/modules/cloudflare-dns/provisioner/index.ts @@ -0,0 +1,44 @@ +// cloudflare-dns's provisioner — the adapter making it a provider of the mesh `public-dns` interface +// (novox/hq ADR 0049). The reconcile loop and the contributions file are the sdk harness's; this +// writes only the per-registrar half: register a consumer's public name at Cloudflare, pointing it +// at the mesh's ingress, and remove it when the consumer is withdrawn (ADR 0053). +// +// The `public-dns` interface hands a consumer { fqdn, target, ttl } — a name that resolves publicly +// and what it resolves to. Like umami's analytics it is a *data* provision, not a credential one: +// nothing the mesh mints is set here (a DNS record is public, and the only secret is this module's +// own Cloudflare token, which never leaves). So the password the harness carries is unused; the name +// is derived from the login the mesh gave the consumer, which the consumer can derive too. Delivering +// the record back to the consumer is the data-provision return path ADR 0053 leaves out of scope. + +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { CloudflareClient } from "../client.js"; + +const cloudflare = CloudflareClient.fromEnv(); + +runProvisioner("public-dns", { + async create(p: Provision): Promise { + const fqdn = cloudflare.nameFor(p.as); + await cloudflare.upsert(fqdn); + await announce("module.cloudflare-dns.record.created", { + name: fqdn, + target: cloudflare.ingress, + consumer: p.consumer ?? "", + }); + }, + + async remove(p: { as: string }): Promise { + const fqdn = cloudflare.nameFor(p.as); + await cloudflare.remove(fqdn); + await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: p.as }); + }, +}); + +/** Emit best-effort: a broker hiccup must never fail or reverse a DNS change that already happened. */ +async function announce(type: string, body: unknown): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[cloudflare-dns] could not emit ${type}: ${err}`); + } +} diff --git a/modules/cloudflare-dns/tools/index.ts b/modules/cloudflare-dns/tools/index.ts new file mode 100644 index 0000000..529e7fd --- /dev/null +++ b/modules/cloudflare-dns/tools/index.ts @@ -0,0 +1,23 @@ +// cloudflare-dns's tool — the diagnostic: what public names the mesh currently publishes here. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { CloudflareClient } from "../client.js"; + +export function getCloudflareDnsTools(cloudflare: CloudflareClient): ToolDefinition[] { + return [ + { + name: "cloudflare_dns_records", + description: "The public DNS records in the mesh's zone — the names it currently publishes.", + input: {}, + run: async () => ({ domain: cloudflare.domain, ingress: cloudflare.ingress, records: await cloudflare.records() }), + }, + ]; +} + +registerModuleTools("cloudflare-dns", (env) => { + try { + return getCloudflareDnsTools(CloudflareClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/cloudflare-dns/tsconfig.json b/modules/cloudflare-dns/tsconfig.json new file mode 100644 index 0000000..c2a8df0 --- /dev/null +++ b/modules/cloudflare-dns/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts", + "provisioner/index.ts" + ] +} \ No newline at end of file diff --git a/modules/dnsmasq/client.ts b/modules/dnsmasq/client.ts new file mode 100644 index 0000000..ae3540f --- /dev/null +++ b/modules/dnsmasq/client.ts @@ -0,0 +1,59 @@ +// dnsmasq's own code, living in the module (novox/hq ADR 0044). dnsmasq here is a pure resolver: it +// answers the mesh's generated wildcard names (..) and forwards nothing. So +// its code reads what it was told to answer, and can resolve through itself to prove that it does. + +import { readFile } from "node:fs/promises"; +import { Resolver } from "node:dns/promises"; + +export interface AnsweredName { + /** A machine's internal name, e.g. "anchor.internal" — it and everything under it resolve here. */ + name: string; + address: string; +} + +export class DnsmasqClient { + constructor( + private readonly resolverPath: string, + private readonly address: string, + ) {} + + /** + * Build from the environment. Both values are node-local facts with mesh-chosen defaults — the + * wildcard file the module is sent, and the loopback address its config listens on — so there is + * nothing to be unconfigured about; it never throws. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): DnsmasqClient { + return new DnsmasqClient( + env.MESH_DNSMASQ_RESOLVER_PATH ?? "/etc/mesh-resolver/nodes.conf", + env.MESH_DNSMASQ_ADDRESS ?? "127.0.0.55", + ); + } + + /** The names this resolver answers, read from the mesh-generated wildcard file. */ + async answeredNames(): Promise { + let text: string; + try { + text = await readFile(this.resolverPath, "utf8"); + } catch { + return []; // not yet on the network, or the file has not been written — no names, not an error + } + const names: AnsweredName[] = []; + for (const line of text.split("\n")) { + // dnsmasq wildcard syntax the mesh writes: address=/./
+ const match = line.match(/^address=\/([^/]+)\/(.+)$/); + if (match) names.push({ name: match[1], address: match[2] }); + } + return names; + } + + /** Resolve a name through this node's own resolver — the check that wildcard-resolution answers. */ + async resolve(name: string): Promise { + const resolver = new Resolver(); + resolver.setServers([this.address]); + try { + return await resolver.resolve4(name); + } catch { + return await resolver.resolve6(name); + } + } +} diff --git a/modules/dnsmasq/index.ts b/modules/dnsmasq/index.ts new file mode 100644 index 0000000..15a50b9 --- /dev/null +++ b/modules/dnsmasq/index.ts @@ -0,0 +1,38 @@ +// dnsmasq's events. The resolver's answered set changes whenever the mesh rewrites the wildcard file +// and restarts it — a machine joined the private network or left it. This watches that file and +// announces, from the resolver's own vantage, that a name became (or stopped being) resolvable on +// this node: DNS having actually propagated here, distinct from the mesh's own node.* events. +// +// Emits (novox/hq ADR 0046/0047): +// module.dnsmasq.name.added — this node's resolver now answers a machine's name +// module.dnsmasq.name.removed — it no longer does + +import { emit } from "@novox/mesh-sdk/events"; +import { DnsmasqClient } from "./client.js"; + +const dnsmasq = DnsmasqClient.fromEnv(); + +// name -> address, primed silently so a restart does not re-announce every name it already answered. +const known = new Map(); +let primed = false; + +async function poll(): Promise { + const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address])); + if (primed) { + for (const [name, address] of now) { + if (!known.has(name)) await emit("module.dnsmasq.name.added", { name, address }); + } + for (const [name] of known) { + if (!now.has(name)) await emit("module.dnsmasq.name.removed", { name }); + } + } + known.clear(); + for (const [name, address] of now) known.set(name, address); + primed = true; +} + +const run = (): void => void poll().catch((err) => console.error(`[dnsmasq] ${err}`)); +setInterval(run, 30_000); +run(); + +console.log("[dnsmasq] watching the resolver's answered names"); diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 79d311d..7b2c0cd 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -7,6 +7,13 @@ "provides": [ "wildcard-resolution" ], + "emits": [ + "module.dnsmasq.name.added", + "module.dnsmasq.name.removed" + ], + "own-secrets": { + "broker": "/var/lib/mesh/dnsmasq/broker" + }, "claims": [ { "name": "the-dns-port", @@ -23,6 +30,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/dnsmasq", + "mode": "0700" + }, { "id": "package", "type": "package", diff --git a/modules/dnsmasq/package.json b/modules/dnsmasq/package.json new file mode 100644 index 0000000..4e18a47 --- /dev/null +++ b/modules/dnsmasq/package.json @@ -0,0 +1,9 @@ +{ + "name": "@novox/module-dnsmasq", + "version": "0.1.0", + "description": "dnsmasq — the mesh's resolver: answers wildcard node names. Its tools and events live here.", + "type": "module", + "private": true, + "dependencies": { "@novox/mesh-sdk": "^0.1.0" }, + "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" } +} diff --git a/modules/dnsmasq/tools/index.ts b/modules/dnsmasq/tools/index.ts new file mode 100644 index 0000000..472d322 --- /dev/null +++ b/modules/dnsmasq/tools/index.ts @@ -0,0 +1,31 @@ +// dnsmasq's tools — a resolver's two useful questions: what does it answer, and does it answer a +// given name. Moved into the module (novox/hq ADR 0044); the mesh serves them through the sdk. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { DnsmasqClient } from "../client.js"; + +export function getDnsmasqTools(dnsmasq: DnsmasqClient): ToolDefinition[] { + return [ + { + name: "dnsmasq_names", + description: "The mesh names this node's resolver answers — each a machine and everything under it.", + input: {}, + run: async () => ({ names: await dnsmasq.answeredNames() }), + }, + { + name: "dnsmasq_resolve", + description: "Resolve a mesh name through this node's own resolver — the check that wildcard-resolution answers.", + input: { name: { type: "string", description: "a name to resolve, e.g. plex.anchor.internal" } }, + run: async (args) => { + const name = String(args.name); + try { + return { name, addresses: await dnsmasq.resolve(name) }; + } catch (err) { + return { name, addresses: [], error: err instanceof Error ? err.message : String(err) }; + } + }, + }, + ]; +} + +registerModuleTools("dnsmasq", (env) => getDnsmasqTools(DnsmasqClient.fromEnv(env))); diff --git a/modules/dnsmasq/tsconfig.json b/modules/dnsmasq/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/dnsmasq/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/firewall/client.ts b/modules/firewall/client.ts new file mode 100644 index 0000000..5b71df0 --- /dev/null +++ b/modules/firewall/client.ts @@ -0,0 +1,21 @@ +// The firewall's own code, in the module (novox/hq ADR 0044). The mesh computes this node's whole +// rule set from every module's `listens` and writes it to /etc/nftables.conf (novox/hq ADR 0050); +// the module loads it (the nftables service, reloaded whenever the rules change). This code exists +// only to read back what is actually enforced — the enforcement itself is declarative. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +export class FirewallClient { + static fromEnv(_env: NodeJS.ProcessEnv = process.env): FirewallClient { + return new FirewallClient(); + } + + /** The mesh's live table — exactly what is dropping and accepting on this node right now. */ + async ruleset(): Promise { + const { stdout } = await run("nft", ["list", "table", "inet", "mesh"]); + return stdout; + } +} diff --git a/modules/firewall/module.json b/modules/firewall/module.json new file mode 100644 index 0000000..e8331a2 --- /dev/null +++ b/modules/firewall/module.json @@ -0,0 +1,33 @@ +{ + "module": "firewall", + "version": "1", + "capabilities": [ + "firewall" + ], + "claims": [ + { + "name": "the-packet-filter", + "scope": "node" + } + ], + "filtering": { + "into": "/etc/nftables.conf" + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "nftables" + }, + { + "id": "load", + "type": "service", + "unit": "nftables.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "filtering" + ] + } + ] +} diff --git a/modules/firewall/package.json b/modules/firewall/package.json new file mode 100644 index 0000000..c80274f --- /dev/null +++ b/modules/firewall/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-firewall", + "version": "0.1.0", + "description": "firewall — applies the mesh-computed packet filter (ADR 0050). Its diagnostic tool lives here. + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/firewall/tools/index.ts b/modules/firewall/tools/index.ts new file mode 100644 index 0000000..198e5de --- /dev/null +++ b/modules/firewall/tools/index.ts @@ -0,0 +1,19 @@ +// firewall's tools — one, and the useful one: what is actually enforced. The rules are the mesh's, +// computed from every module's listens; this reads the live table so a declared scope can be checked +// against what the packet filter is really doing. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { FirewallClient } from "../client.js"; + +export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] { + return [ + { + name: "firewall_rules", + description: "The mesh's live nftables rules on this node — what is actually accepting and dropping.", + input: {}, + run: async () => ({ ruleset: await firewall.ruleset() }), + }, + ]; +} + +registerModuleTools("firewall", () => getFirewallTools(FirewallClient.fromEnv())); diff --git a/modules/firewall/tsconfig.json b/modules/firewall/tsconfig.json new file mode 100644 index 0000000..91d5b91 --- /dev/null +++ b/modules/firewall/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts" + ] +} \ No newline at end of file diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts new file mode 100644 index 0000000..5fe7182 --- /dev/null +++ b/modules/gitea/client.ts @@ -0,0 +1,251 @@ +// The Gitea API client — gitea's own code, living in the module (novox/hq ADR 0044). Moved out of +// the shared hal sdk, where a change to Gitea's API rebuilt everything; here it rebuilds only +// gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea +// does. + +import { readFileSync } from "node:fs"; + +/** A repository, trimmed to what the mesh cares about. */ +export interface GiteaRepo { + full_name: string; + name: string; + owner: string; + private: boolean; + description?: string; + html_url: string; + default_branch?: string; +} + +/** An issue, with its labels flattened to names. */ +export interface GiteaIssue { + number: number; + title: string; + state: string; + user?: string; + labels: string[]; + html_url: string; + body?: string; +} + +/** A pull request, trimmed to the fields a reviewer or an event body needs. */ +export interface GiteaPull { + number: number; + title: string; + state: string; + merged: boolean; + user?: string; + head?: string; + base?: string; + html_url: string; +} + +export interface GiteaLabel { + id: number; + name: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class GiteaClient { + readonly baseUrl: string; + private cachedUsername: string | null = null; + + constructor( + url: string, + private readonly token: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. URL and token come from MESH_GITEA_URL / + * MESH_GITEA_TOKEN (the mesh's own names), falling back to the bare GITEA_* names and, for the + * URL, to the forge's loopback port. A token is required — without one there is no authenticated + * call to make, so this throws rather than hand back a client that fails on first use. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { + const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); + const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; + const token = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; + if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN"); + return new GiteaClient(url, token); + } + + private async request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl}/api/v1${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `token ${this.token}`, + ...(options.headers as Record | undefined), + }, + }); + if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); + if (res.status === 204) return null as T; + const text = await res.text(); + return (text ? JSON.parse(text) : null) as T; + } + + /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. + * Path is relative to /api/v1. */ + async api(path: string, options: RequestInit = {}): Promise { + return this.request(path, options); + } + + // ---- Repositories ---- + + async listRepos(page = 1, limit = 20): Promise { + const repos = await this.request(`/user/repos?page=${page}&limit=${limit}`); + return (repos ?? []).map(GiteaClient.mapRepo); + } + + async createRepo(data: { + name: string; + description?: string; + private?: boolean; + auto_init?: boolean; + }): Promise { + return GiteaClient.mapRepo(await this.request("/user/repos", { method: "POST", body: JSON.stringify(data) })); + } + + async deleteRepo(owner: string, repo: string): Promise { + await this.request(`/repos/${owner}/${repo}`, { method: "DELETE" }); + } + + // ---- Issues ---- + + async listIssues(owner: string, repo: string, params: Record = {}): Promise { + const qs = new URLSearchParams({ type: "issues", ...params }).toString(); + const issues = await this.request(`/repos/${owner}/${repo}/issues?${qs}`); + return (issues ?? []).map(GiteaClient.mapIssue); + } + + async getIssue(owner: string, repo: string, index: number): Promise { + return GiteaClient.mapIssue(await this.request(`/repos/${owner}/${repo}/issues/${index}`)); + } + + async createIssue( + owner: string, + repo: string, + data: { title: string; body?: string; labels?: number[] }, + ): Promise { + return GiteaClient.mapIssue( + await this.request(`/repos/${owner}/${repo}/issues`, { method: "POST", body: JSON.stringify(data) }), + ); + } + + /** Patch an issue's state — the one edit the close tool needs. */ + async setIssueState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise { + return GiteaClient.mapIssue( + await this.request(`/repos/${owner}/${repo}/issues/${index}`, { + method: "PATCH", + body: JSON.stringify({ state }), + }), + ); + } + + async addComment(owner: string, repo: string, index: number, body: string): Promise<{ id: number; html_url: string }> { + const c = await this.request(`/repos/${owner}/${repo}/issues/${index}/comments`, { + method: "POST", + body: JSON.stringify({ body }), + }); + return { id: c.id, html_url: c.html_url }; + } + + // ---- Labels ---- + + async listLabels(owner: string, repo: string): Promise { + const labels = await this.request(`/repos/${owner}/${repo}/labels`); + return (labels ?? []).map((l: any) => ({ id: l.id, name: l.name })); + } + + async createLabel( + owner: string, + repo: string, + data: { name: string; color: string; description?: string }, + ): Promise { + const l = await this.request(`/repos/${owner}/${repo}/labels`, { method: "POST", body: JSON.stringify(data) }); + return { id: l.id, name: l.name }; + } + + /** Resolve a label name to its id, creating it if it does not exist — so create-issue can take + * human label names and not numeric ids. */ + async getOrCreateLabel(owner: string, repo: string, name: string, color = "#0075ca"): Promise { + const existing = (await this.listLabels(owner, repo)).find((l) => l.name === name); + if (existing) return existing.id; + return (await this.createLabel(owner, repo, { name, color })).id; + } + + // ---- Pull requests ---- + + async listPullRequests(owner: string, repo: string, params: Record = {}): Promise { + const qs = new URLSearchParams(params).toString(); + const prs = await this.request(`/repos/${owner}/${repo}/pulls?${qs}`); + return (prs ?? []).map(GiteaClient.mapPull); + } + + async getPullRequest(owner: string, repo: string, index: number): Promise { + return GiteaClient.mapPull(await this.request(`/repos/${owner}/${repo}/pulls/${index}`)); + } + + async createPullRequest( + owner: string, + repo: string, + data: { title: string; body?: string; head: string; base: string }, + ): Promise { + return GiteaClient.mapPull( + await this.request(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: JSON.stringify(data) }), + ); + } + + async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise { + await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, { + method: "POST", + body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch }), + }); + } + + // ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ---- + + private static mapRepo(r: any): GiteaRepo { + return { + full_name: r.full_name, + name: r.name, + owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown", + private: Boolean(r.private), + description: r.description || undefined, + html_url: r.html_url, + default_branch: r.default_branch, + }; + } + + private static mapIssue(i: any): GiteaIssue { + return { + number: i.number, + title: i.title, + state: i.state, + user: i.user?.login, + labels: (i.labels ?? []).map((l: any) => l.name), + html_url: i.html_url, + body: i.body || undefined, + }; + } + + private static mapPull(p: any): GiteaPull { + return { + number: p.number, + title: p.title, + state: p.state, + merged: Boolean(p.merged), + user: p.user?.login, + head: p.head?.ref, + base: p.base?.ref, + html_url: p.html_url, + }; + } +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts new file mode 100644 index 0000000..15366b1 --- /dev/null +++ b/modules/gitea/index.ts @@ -0,0 +1,59 @@ +// gitea's events. The tool runtime imports this once the broker is bound. It watches the forge and +// emits what appeared. +// +// Emits (novox/hq ADR 0046/0047): +// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool) +// +// issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh +// takes that action — the natural point, and one process only. repo.created belongs here instead: +// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the +// repo list is the only way to catch every path — and keeping it out of the create-repo tool means +// the fact is never announced twice from two processes. +// +// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering +// the forge for an immediacy nobody asked for is not. + +import { emit } from "@novox/mesh-sdk/events"; +import { GiteaClient } from "./client.js"; + +// Without a token there is nothing to watch; log and stay quiet rather than crash the runtime. +let gitea: GiteaClient | null = null; +try { + gitea = GiteaClient.fromEnv(); +} catch (err) { + console.log(`[gitea] not watching — ${err instanceof Error ? err.message : String(err)}`); +} + +// New repositories, by diffing the repo list. Primed silently on the first look, or a restart would +// re-announce every existing repository as freshly created. +const seen = new Set(); +let primed = false; +async function pollRepos(client: GiteaClient): Promise { + const repos = await client.listRepos(1, 50); + for (const repo of repos) { + if (!seen.has(repo.full_name)) { + if (primed) { + await emit("module.gitea.repo.created", { + full_name: repo.full_name, + owner: repo.owner, + name: repo.name, + private: repo.private, + html_url: repo.html_url, + }); + } + seen.add(repo.full_name); + } + } + primed = true; +} + +if (gitea) { + const client = gitea; + const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[gitea] ${err}`)); + setInterval(run, everyMs); + run(); + }; + tick(() => pollRepos(client), 60_000); + console.log("[gitea] watching for new repositories"); +} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index feb28b8..f539bd9 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -18,6 +18,11 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.gitea.repo.created", + "module.gitea.issue.opened", + "module.gitea.pull.merged" + ], "listens": [ { "port": 3000, @@ -33,9 +38,16 @@ } ], "own-secrets": { - "internal-token": "/var/lib/gitea/internal-token.secret" + "internal-token": "/var/lib/gitea/internal-token.secret", + "broker": "/var/lib/mesh/gitea/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/gitea", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -76,6 +88,33 @@ "volumes": [ "/services/gitea/gitea:/data" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/gitea/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-gitea", + "image": "mesh-runtime-gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", + "/var/lib/mesh/gitea/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_GITEA_URL": "http://127.0.0.1:3000", + "MESH_GITEA_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/gitea/package.json b/modules/gitea/package.json new file mode 100644 index 0000000..fe30cf5 --- /dev/null +++ b/modules/gitea/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-gitea", + "version": "0.1.0", + "description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts new file mode 100644 index 0000000..d970d9f --- /dev/null +++ b/modules/gitea/tools/index.ts @@ -0,0 +1,306 @@ +// gitea's tools — moved here from the shared sdk (novox/hq ADR 0044), importing gitea's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. +// +// Two tools emit an event at the natural point of the action they take (novox/hq ADR 0046/0047): +// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on +// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos +// are far more often born from a `git push` or the web UI than from this tool, so the events +// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and +// the poll double-announcing the same repo from two processes. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { emit } from "@novox/mesh-sdk/events"; +import { GiteaClient } from "../client.js"; + +/** Coerce a comma-separated label string into names; empty/absent yields none. */ +function parseLabels(raw: unknown): string[] { + if (raw === undefined || raw === null || raw === "") return []; + return String(raw) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { + return [ + // ---- Repositories ---- + { + name: "gitea_list_repos", + description: "List repositories for the authenticated Gitea user.", + input: { + page: { type: "number", description: "page number (default 1)" }, + limit: { type: "number", description: "how many per page (default 20)" }, + }, + run: async (args) => ({ + repos: await gitea.listRepos(args.page ? Number(args.page) : 1, args.limit ? Number(args.limit) : 20), + }), + }, + { + name: "gitea_create_repo", + description: "Create a repository owned by the authenticated user.", + input: { + name: { type: "string", description: "the repository name" }, + description: { type: "string", description: "an optional description" }, + private: { type: "boolean", description: "private repo (default true)" }, + auto_init: { type: "boolean", description: "initialise with a README (default true)" }, + }, + run: async (args) => { + const repo = await gitea.createRepo({ + name: String(args.name), + description: args.description ? String(args.description) : undefined, + private: args.private === undefined ? true : Boolean(args.private), + auto_init: args.auto_init === undefined ? true : Boolean(args.auto_init), + }); + return { repo }; + }, + }, + { + name: "gitea_delete_repo", + description: "Delete a repository. Destructive and irreversible — requires confirm=true.", + input: { + owner: { type: "string", description: "the repository owner" }, + name: { type: "string", description: "the repository name" }, + confirm: { type: "boolean", description: "must be true to actually delete" }, + }, + run: async (args) => { + if (!args.confirm) return { deleted: false, reason: "confirm must be true to delete a repository" }; + await gitea.deleteRepo(String(args.owner), String(args.name)); + return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` }; + }, + }, + + // ---- Issues ---- + { + name: "gitea_list_issues", + description: "List issues for a repository, filterable by state and labels.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + state: { type: "string", description: "open | closed | all (default open)" }, + labels: { type: "string", description: "comma-separated label names to filter by" }, + page: { type: "number", description: "page number (default 1)" }, + }, + run: async (args) => { + const params: Record = { + state: args.state ? String(args.state) : "open", + page: String(args.page ? Number(args.page) : 1), + }; + if (args.labels) params.labels = String(args.labels); + return { issues: await gitea.listIssues(String(args.owner), String(args.repo), params) }; + }, + }, + { + name: "gitea_get_issue", + description: "Get a single issue by its number.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the issue number" }, + }, + run: async (args) => ({ + issue: await gitea.getIssue(String(args.owner), String(args.repo), Number(args.number)), + }), + }, + { + name: "gitea_create_issue", + description: "Open a new issue. Label names are resolved to ids, creating any that are missing.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + title: { type: "string", description: "the issue title" }, + body: { type: "string", description: "the issue body (markdown)" }, + labels: { type: "string", description: "comma-separated label names" }, + }, + run: async (args) => { + const owner = String(args.owner); + const repo = String(args.repo); + const names = parseLabels(args.labels); + const labelIds = names.length + ? await Promise.all(names.map((n) => gitea.getOrCreateLabel(owner, repo, n))) + : undefined; + const issue = await gitea.createIssue(owner, repo, { + title: String(args.title), + body: args.body ? String(args.body) : undefined, + labels: labelIds, + }); + // The mesh just opened an issue — announce it the moment it exists. + await emit("module.gitea.issue.opened", { + owner, + repo, + number: issue.number, + title: issue.title, + user: issue.user, + html_url: issue.html_url, + }); + return { issue }; + }, + }, + { + name: "gitea_close_issue", + description: "Close an open issue.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the issue number" }, + }, + run: async (args) => ({ + issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "closed"), + }), + }, + { + name: "gitea_add_comment", + description: "Add a comment to an issue or pull request.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the issue or PR number" }, + body: { type: "string", description: "the comment body (markdown)" }, + }, + run: async (args) => ({ + comment: await gitea.addComment(String(args.owner), String(args.repo), Number(args.number), String(args.body)), + }), + }, + + // ---- Pull requests ---- + { + name: "gitea_list_pull_requests", + description: "List pull requests for a repository.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + state: { type: "string", description: "open | closed | all (default open)" }, + page: { type: "number", description: "page number (default 1)" }, + limit: { type: "number", description: "how many per page (default 20)" }, + }, + run: async (args) => ({ + pulls: await gitea.listPullRequests(String(args.owner), String(args.repo), { + state: args.state ? String(args.state) : "open", + page: String(args.page ? Number(args.page) : 1), + limit: String(args.limit ? Number(args.limit) : 20), + }), + }), + }, + { + name: "gitea_get_pull_request", + description: "Get a single pull request by its number.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the PR number" }, + }, + run: async (args) => ({ + pull: await gitea.getPullRequest(String(args.owner), String(args.repo), Number(args.number)), + }), + }, + { + name: "gitea_create_pull_request", + description: "Open a pull request from a head branch into a base branch.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + title: { type: "string", description: "the PR title" }, + body: { type: "string", description: "the PR body (markdown)" }, + head: { type: "string", description: "the source branch" }, + base: { type: "string", description: "the target branch (default main)" }, + }, + run: async (args) => ({ + pull: await gitea.createPullRequest(String(args.owner), String(args.repo), { + title: String(args.title), + body: args.body ? String(args.body) : undefined, + head: String(args.head), + base: args.base ? String(args.base) : "main", + }), + }), + }, + { + name: "gitea_merge_pull_request", + description: "Merge a pull request, optionally deleting the source branch afterwards.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the PR number" }, + method: { type: "string", description: "merge | rebase | squash (default merge)" }, + delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" }, + }, + run: async (args) => { + const owner = String(args.owner); + const repo = String(args.repo); + const number = Number(args.number); + const method = args.method ? String(args.method) : "merge"; + const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); + // Read the PR first, so the merged event carries a title and branches, not just a number. + const pull = await gitea.getPullRequest(owner, repo, number); + await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); + await emit("module.gitea.pull.merged", { + owner, + repo, + number, + title: pull.title, + head: pull.head, + base: pull.base, + method, + html_url: pull.html_url, + }); + return { merged: true, number, method, deleted_branch: deleteBranch }; + }, + }, + + // ---- Labels ---- + { + name: "gitea_list_labels", + description: "List every label defined in a repository.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + }, + run: async (args) => ({ labels: await gitea.listLabels(String(args.owner), String(args.repo)) }), + }, + { + name: "gitea_create_label", + description: "Create a label in a repository.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + name: { type: "string", description: "the label name" }, + color: { type: "string", description: "hex colour, e.g. #0075ca" }, + description: { type: "string", description: "an optional description" }, + }, + run: async (args) => ({ + label: await gitea.createLabel(String(args.owner), String(args.repo), { + name: String(args.name), + color: String(args.color), + description: args.description ? String(args.description) : undefined, + }), + }), + }, + + // ---- Escape hatch ---- + { + name: "gitea_api", + description: "Make an authenticated Gitea API call for any endpoint without a dedicated tool. Path is relative to /api/v1.", + input: { + path: { type: "string", description: "API path relative to /api/v1, e.g. /repos/owner/repo/branches" }, + method: { type: "string", description: "GET | POST | PUT | PATCH | DELETE (default GET)" }, + body: { type: "object", description: "JSON request body for POST/PUT/PATCH" }, + }, + run: async (args) => { + const method = args.method ? String(args.method) : "GET"; + const result = await gitea.api(String(args.path), { + method, + ...(args.body ? { body: JSON.stringify(args.body) } : {}), + }); + return { result }; + }, + }, + ]; +} + +// The tools exist only when a token can be found; without one, gitea contributes none rather than +// failing the whole runtime. +registerModuleTools("gitea", (env) => { + try { + return getGiteaTools(GiteaClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/gitea/tsconfig.json b/modules/gitea/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/gitea/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/grafana/client.ts b/modules/grafana/client.ts new file mode 100644 index 0000000..00916c3 --- /dev/null +++ b/modules/grafana/client.ts @@ -0,0 +1,120 @@ +// Grafana's API client — grafana's own code, living in the module (novox/hq ADR 0044). Ported from +// the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both +// this module's tools and its events entrypoint import it, and nothing outside grafana does. + +import { readFileSync } from "node:fs"; + +export interface GrafanaHealth { + database: string; + version: string; + commit: string; +} + +export interface GrafanaDatasource { + id: number; + uid: string; + name: string; + type: string; + url: string; + isDefault: boolean; + database?: string; +} + +export interface GrafanaDashboard { + uid: string; + title: string; + url: string; + tags: string[]; + folderTitle?: string; +} + +export interface GrafanaAlert { + /** The rule name (labels.alertname), the stable identity a firing alert is diffed on. */ + name: string; + /** Grafana unified-alerting state: "Normal" | "Pending" | "Alerting". */ + state: string; + labels: Record; + activeAt?: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class GrafanaClient { + readonly baseUrl: string; + private readonly authHeader: string; + + constructor(url: string, authHeader: string) { + this.baseUrl = url.replace(/\/$/, ""); + this.authHeader = authHeader; + } + + /** + * Build from the module's resolved environment. Auth is a service-account/API token + * (MESH_GRAFANA_TOKEN, sent as Bearer) when present, else HTTP basic with the admin password the + * module keeps as its own secret (MESH_GRAFANA_PASSWORD, user MESH_GRAFANA_USER, default admin). + * Throws when neither is configured — the module then contributes nothing rather than failing. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient { + const cfg = meshConfig(env.MESH_GRAFANA_CONFIG_FILE); + const url = cfg.url ?? env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`; + const token = cfg.token ?? env.MESH_GRAFANA_TOKEN; + if (token) return new GrafanaClient(url, `Bearer ${token}`); + const password = cfg.password ?? env.MESH_GRAFANA_PASSWORD; + if (password) { + const user = cfg.user ?? env.MESH_GRAFANA_USER ?? "admin"; + return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`); + } + throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD"); + } + + private async get(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + headers: { Authorization: this.authHeader, Accept: "application/json" }, + }); + if (!res.ok) throw new Error(`Grafana API ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async health(): Promise { + const h = await this.get("/api/health"); + return { database: h.database ?? "unknown", version: h.version ?? "unknown", commit: h.commit ?? "unknown" }; + } + + async listDatasources(): Promise { + const arr = (await this.get("/api/datasources")) as any[]; + return arr.map((d) => ({ + id: d.id, uid: d.uid, name: d.name, type: d.type, url: d.url, + isDefault: !!d.isDefault, database: d.database || undefined, + })); + } + + async listDashboards(query?: string): Promise { + const params = new URLSearchParams({ type: "dash-db" }); + if (query) params.set("query", query); + const arr = (await this.get(`/api/search?${params.toString()}`)) as any[]; + return arr.map((d) => ({ + uid: d.uid, title: d.title, url: d.url, tags: d.tags ?? [], folderTitle: d.folderTitle || undefined, + })); + } + + /** + * Active alert instances from unified alerting's Prometheus-compatible surface. Grafana without + * alerting configured answers this with an empty set (or a 404, surfaced by get) — callers treat + * "no alerts" and "no alerting" alike. + */ + async listAlerts(): Promise { + const data = (await this.get("/api/prometheus/grafana/api/v1/alerts")).data ?? {}; + const alerts = (data.alerts ?? []) as any[]; + return alerts.map((a) => ({ + name: a.labels?.alertname ?? "unknown", + state: a.state ?? "unknown", + labels: a.labels ?? {}, + activeAt: a.activeAt || undefined, + })); + } +} diff --git a/modules/grafana/index.ts b/modules/grafana/index.ts new file mode 100644 index 0000000..bb497c8 --- /dev/null +++ b/modules/grafana/index.ts @@ -0,0 +1,58 @@ +// grafana's events. The tool runtime imports this once the broker is bound. It watches unified +// alerting and announces when an alert instance starts firing. +// +// Emits (novox/hq ADR 0046/0047): +// module.grafana.alert.firing — an alert instance entered the Alerting state +// +// A Grafana with no alerting configured simply never has a firing alert, so this observes nothing +// and emits nothing — no error, no noise. + +import { emit } from "@novox/mesh-sdk/events"; +import { GrafanaClient, type GrafanaAlert } from "./client.js"; + +// Constructed lazily so an unconfigured node (no auth) loads this entrypoint without crashing the +// events host — it simply watches nothing. +let grafana: GrafanaClient | undefined; +try { + grafana = GrafanaClient.fromEnv(); +} catch (err) { + console.log(`[grafana] not configured, not watching alerts: ${err}`); +} + +// Firing alerts, by diffing the set currently in the Alerting state. Primed silently on the first +// look so alerts already firing when this started are not announced as freshly firing. +const firing = new Set(); +let primed = false; + +const alertKey = (a: GrafanaAlert): string => + `${a.name}:${Object.entries(a.labels).sort().map(([k, v]) => `${k}=${v}`).join(",")}`; + +async function pollAlerts(client: GrafanaClient): Promise { + const now = new Set(); + const byKey = new Map(); + for (const a of await client.listAlerts()) { + if (a.state.toLowerCase() !== "alerting") continue; + const key = alertKey(a); + now.add(key); + byKey.set(key, a); + } + if (primed) { + for (const key of now) { + if (!firing.has(key)) { + const a = byKey.get(key)!; + await emit("module.grafana.alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt }); + } + } + } + firing.clear(); + for (const key of now) firing.add(key); + primed = true; +} + +if (grafana) { + const client = grafana; + const run = (): void => void pollAlerts(client).catch((err) => console.error(`[grafana] ${err}`)); + setInterval(run, 30_000); + run(); + console.log("[grafana] watching for firing alerts"); +} diff --git a/modules/grafana/module.json b/modules/grafana/module.json index 5dcbbd5..8513f35 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -1,8 +1,12 @@ { "module": "grafana", "version": "1", + "emits": [ + "module.grafana.alert.firing" + ], "own-secrets": { - "admin": "/var/lib/grafana-module/admin.secret" + "admin": "/var/lib/grafana-module/admin.secret", + "broker": "/var/lib/mesh/grafana/broker" }, "capabilities": [ "container-runtime" @@ -16,6 +20,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/grafana", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -50,6 +60,33 @@ "volumes": [ "/services/grafana/data:/var/lib/grafana" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/grafana/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-grafana", + "image": "mesh-runtime-grafana@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/grafana/broker:/run/secrets/broker:ro", + "/var/lib/mesh/grafana/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_GRAFANA_URL": "http://127.0.0.1:3000", + "MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/grafana/package.json b/modules/grafana/package.json new file mode 100644 index 0000000..49003a9 --- /dev/null +++ b/modules/grafana/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-grafana", + "version": "0.1.0", + "description": "grafana — monitoring dashboards. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/grafana/tools/index.ts b/modules/grafana/tools/index.ts new file mode 100644 index 0000000..55fc89b --- /dev/null +++ b/modules/grafana/tools/index.ts @@ -0,0 +1,55 @@ +// grafana's tools — moved here from the shared hal sdk (novox/hq ADR 0044), importing grafana's own +// client. They return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { GrafanaClient } from "../client.js"; + +export function getGrafanaTools(grafana: GrafanaClient): ToolDefinition[] { + return [ + { + name: "grafana_status", + description: "Grafana server health — database state, version, build commit.", + input: {}, + run: async () => grafana.health(), + }, + { + name: "grafana_list_datasources", + description: "List Grafana data sources — name, type, backing URL, which is default.", + input: {}, + run: async () => { + const datasources = await grafana.listDatasources(); + return { count: datasources.length, datasources }; + }, + }, + { + name: "grafana_list_dashboards", + description: "List Grafana dashboards, optionally filtered by a name query.", + input: { query: { type: "string", description: "filter dashboards by name (optional)" } }, + run: async (args) => { + const query = args.query ? String(args.query) : undefined; + const dashboards = await grafana.listDashboards(query); + return { count: dashboards.length, dashboards }; + }, + }, + { + name: "grafana_alerts", + description: "Active Grafana alert instances and their state (Alerting, Pending, Normal).", + input: {}, + run: async () => { + const alerts = await grafana.listAlerts(); + const firing = alerts.filter((a) => a.state.toLowerCase() === "alerting"); + return { count: alerts.length, firing: firing.length, alerts }; + }, + }, + ]; +} + +// The tools exist only when Grafana auth can be resolved; without it, grafana contributes none +// rather than failing the whole tool runtime. +registerModuleTools("grafana", (env) => { + try { + return getGrafanaTools(GrafanaClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/grafana/tsconfig.json b/modules/grafana/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/grafana/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/home-assistant/client.ts b/modules/home-assistant/client.ts new file mode 100644 index 0000000..d3900ba --- /dev/null +++ b/modules/home-assistant/client.ts @@ -0,0 +1,88 @@ +// The Home Assistant API client — home-assistant's own code, living in the module (novox/hq +// ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside +// home-assistant does. Talks to the HA REST API (/api) with a long-lived access token. + +import { readFileSync } from "node:fs"; + +export interface HAEntityState { + entity_id: string; + state: string; + attributes: Record; + last_changed?: string; + last_updated?: string; +} + +export interface HAConfig { + location_name?: string; + version?: string; + components?: string[]; + time_zone?: string; + state?: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class HomeAssistantClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly token: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The URL defaults to the local server (HA runs on + * the node); the token is the long-lived access token minted in HA's profile — required, since + * every API call is Bearer-authenticated and there is nowhere to discover it from. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient { + const cfg = meshConfig(env.MESH_HOMEASSISTANT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`; + const token = cfg.token ?? env.MESH_HOMEASSISTANT_TOKEN; + if (!token) throw new Error("no Home Assistant token — set MESH_HOMEASSISTANT_TOKEN"); + return new HomeAssistantClient(url, token); + } + + private async request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + Accept: "application/json", + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) throw new Error(`Home Assistant API ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async getConfig(): Promise { + return (await this.request("/api/config")) as HAConfig; + } + + /** All entity states, or one entity when an id is given. */ + async getStates(): Promise { + return (await this.request("/api/states")) as HAEntityState[]; + } + + async getState(entityId: string): Promise { + return (await this.request(`/api/states/${encodeURIComponent(entityId)}`)) as HAEntityState; + } + + /** Call a service (e.g. switch.turn_on) — how "turn the light on" reaches HA. Returns the states + * the call changed. */ + async callService(domain: string, service: string, data: Record = {}): Promise { + return (await this.request(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, { + method: "POST", + body: JSON.stringify(data), + })) as HAEntityState[]; + } +} diff --git a/modules/home-assistant/index.ts b/modules/home-assistant/index.ts new file mode 100644 index 0000000..0c9f275 --- /dev/null +++ b/modules/home-assistant/index.ts @@ -0,0 +1,66 @@ +// home-assistant's events. The tool runtime imports this once the broker is bound. It watches the +// entities whose state changing is a real signal — a door opening, a lock turning, a switch +// flipping — and emits when one does. +// +// Emits (novox/hq ADR 0046/0047): +// module.home-assistant.state.changed — a watched entity's state value changed +// +// Bounded on purpose. Home Assistant has hundreds of entities and many (temperature, humidity, +// power draw) tick constantly; emitting every tick would be noise, not signal. So the watch is +// limited to actuator/contact domains where a change is an event a human would care about, and +// only the discrete `state` value is diffed — not the attribute bag. The set is overridable with +// MESH_HOMEASSISTANT_WATCH (comma-separated entity ids) for a node that wants a specific few. + +import { emit } from "@novox/mesh-sdk/events"; +import { HomeAssistantClient, type HAEntityState } from "./client.js"; + +const ha = HomeAssistantClient.fromEnv(); + +// Domains whose state changing is meaningful rather than a continuous reading. +const WATCH_DOMAINS = new Set(["binary_sensor", "lock", "cover", "switch", "input_boolean", "light", "alarm_control_panel"]); + +// An explicit allowlist of entity ids, if the node set one; otherwise fall back to the domain filter. +const watchList = (process.env.MESH_HOMEASSISTANT_WATCH ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +const watchSet = watchList.length ? new Set(watchList) : null; + +function isWatched(s: HAEntityState): boolean { + if (watchSet) return watchSet.has(s.entity_id); + return WATCH_DOMAINS.has(s.entity_id.split(".")[0] ?? ""); +} + +const nameOf = (s: HAEntityState): string | undefined => + typeof s.attributes.friendly_name === "string" ? s.attributes.friendly_name : undefined; + +// Last seen state per watched entity. Primed silently on the first poll so a restart does not +// re-announce the current state of everything as a fresh change. +const lastState = new Map(); +let primed = false; + +async function pollStates(): Promise { + const states = (await ha.getStates()).filter(isWatched); + for (const s of states) { + const prev = lastState.get(s.entity_id); + if (primed && prev !== undefined && prev !== s.state) { + await emit("module.home-assistant.state.changed", { + entity: s.entity_id, + name: nameOf(s), + from: prev, + to: s.state, + }); + } + lastState.set(s.entity_id, s.state); + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[home-assistant] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollStates, 15_000); + +console.log("[home-assistant] watching entity states, emitting on change"); diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 6003229..2f01f45 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.home-assistant.state.changed" + ], + "own-secrets": { + "broker": "/var/lib/mesh/home-assistant/broker" + }, "listens": [ { "port": 8123, @@ -13,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/home-assistant", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -32,6 +44,35 @@ "volumes": [ "/services/home-assistant/config:/config" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/home-assistant/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-home-assistant", + "image": "mesh-runtime-home-assistant@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/home-assistant/broker:/run/secrets/broker:ro", + "/var/lib/mesh/home-assistant/config.json:/run/config/config.json:ro", + "/services/home-assistant/config:/var/lib/home-assistant/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_HOMEASSISTANT_URL": "http://127.0.0.1:8123", + "MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json", + "MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/home-assistant/package.json b/modules/home-assistant/package.json new file mode 100644 index 0000000..d5a8523 --- /dev/null +++ b/modules/home-assistant/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-home-assistant", + "version": "0.1.0", + "description": "home-assistant — home automation platform. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/home-assistant/tools/index.ts b/modules/home-assistant/tools/index.ts new file mode 100644 index 0000000..4325658 --- /dev/null +++ b/modules/home-assistant/tools/index.ts @@ -0,0 +1,76 @@ +// home-assistant's tools — its own code (novox/hq ADR 0044), importing its own client. They return +// structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { HomeAssistantClient, type HAEntityState } from "../client.js"; + +/** Trim an entity to the fields worth returning — the full attribute bag is large and mostly noise. */ +function summarize(s: HAEntityState): { entity_id: string; state: string; name?: string; last_changed?: string } { + return { + entity_id: s.entity_id, + state: s.state, + name: typeof s.attributes.friendly_name === "string" ? s.attributes.friendly_name : undefined, + last_changed: s.last_changed, + }; +} + +export function getHomeAssistantTools(ha: HomeAssistantClient): ToolDefinition[] { + return [ + { + name: "homeassistant_states", + description: + "Entity states from Home Assistant. With no argument, lists every entity; with `entity` (e.g. light.kitchen), returns just that one with its full attributes.", + input: { entity: { type: "string", description: "an entity id to fetch one entity; omitted lists all" } }, + run: async (args) => { + if (args.entity) { + const s = await ha.getState(String(args.entity)); + return { entity_id: s.entity_id, state: s.state, attributes: s.attributes, last_changed: s.last_changed }; + } + const states = await ha.getStates(); + return { count: states.length, entities: states.map(summarize) }; + }, + }, + { + name: "homeassistant_call_service", + description: + "Call a Home Assistant service — turn a switch/light on or off, lock a door, etc. Give the domain (e.g. switch), the service (e.g. turn_on), and optionally a target entity and extra data.", + input: { + domain: { type: "string", description: "the service domain, e.g. light, switch, lock" }, + service: { type: "string", description: "the service, e.g. turn_on, turn_off, toggle" }, + entity: { type: "string", description: "the entity id to target (optional)" }, + data: { type: "object", description: "extra service data merged into the call (optional)" }, + }, + run: async (args) => { + const data: Record = { ...(args.data as Record | undefined) }; + if (args.entity) data.entity_id = String(args.entity); + const changed = await ha.callService(String(args.domain), String(args.service), data); + return { changed: changed.map(summarize) }; + }, + }, + { + name: "homeassistant_config", + description: "Home Assistant instance config: version, location, timezone, loaded components.", + input: {}, + run: async () => { + const c = await ha.getConfig(); + return { + location: c.location_name, + version: c.version, + time_zone: c.time_zone, + state: c.state, + components: c.components?.length ?? 0, + }; + }, + }, + ]; +} + +// The tools exist only when a token is configured; without one, home-assistant contributes none +// rather than failing the whole runtime. +registerModuleTools("home-assistant", (env) => { + try { + return getHomeAssistantTools(HomeAssistantClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/home-assistant/tsconfig.json b/modules/home-assistant/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/home-assistant/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/icecast/client.ts b/modules/icecast/client.ts new file mode 100644 index 0000000..7035c3d --- /dev/null +++ b/modules/icecast/client.ts @@ -0,0 +1,97 @@ +// Icecast's API client — icecast's own code, living in the module (novox/hq ADR 0044). Icecast is an +// audio streaming server: sources push mountpoints in, listeners pull them out. Its `/status-json.xsl` +// endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and +// the basis for both the status tool and the stream started/stopped events. + +import { readFileSync } from "node:fs"; + +export interface IcecastMount { + /** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */ + mount: string; + listeners: number; + name?: string; + description?: string; + streamStart?: string; + bitrate?: number; + serverType?: string; +} + +export interface IcecastStatus { + mounts: IcecastMount[]; + totalListeners: number; + mountCount: number; +} + +// The raw shape of one in status-json.xsl. `source` is absent with no mounts, a lone object +// with one, and an array with several — normalised below. +interface RawSource { + listenurl?: string; + listeners?: number; + server_name?: string; + server_description?: string; + stream_start_iso8601?: string; + stream_start?: string; + bitrate?: number; + server_type?: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class IcecastClient { + readonly baseUrl: string; + private readonly authHeader?: string; + + constructor(url: string, adminUser?: string, adminPassword?: string) { + this.baseUrl = url.replace(/\/$/, ""); + // status-json.xsl is public on most instances; basic auth is used only where admin locked it down. + if (adminUser && adminPassword) { + this.authHeader = "Basic " + Buffer.from(`${adminUser}:${adminPassword}`).toString("base64"); + } + } + + static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient { + const cfg = meshConfig(env.MESH_ICECAST_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`); + return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER, cfg.password ?? env.MESH_ICECAST_ADMIN_PASSWORD); + } + + async getStatus(): Promise { + const headers: Record = { Accept: "application/json" }; + if (this.authHeader) headers.Authorization = this.authHeader; + const res = await fetch(`${this.baseUrl}/status-json.xsl`, { headers }); + if (!res.ok) throw new Error(`Icecast status: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { icestats?: { source?: RawSource | RawSource[] } }; + + const raw = data.icestats?.source; + const sources: RawSource[] = raw == null ? [] : Array.isArray(raw) ? raw : [raw]; + const mounts = sources.map((s) => ({ + mount: this.mountFromUrl(s.listenurl), + listeners: s.listeners ?? 0, + name: s.server_name, + description: s.server_description, + streamStart: s.stream_start_iso8601 ?? s.stream_start, + bitrate: s.bitrate, + serverType: s.server_type, + })); + return { + mounts, + totalListeners: mounts.reduce((n, m) => n + m.listeners, 0), + mountCount: mounts.length, + }; + } + + /** Icecast names the mount only inside the listen URL's path; pull it back out (fall back to raw). */ + private mountFromUrl(listenurl?: string): string { + if (!listenurl) return "unknown"; + try { + return new URL(listenurl).pathname; + } catch { + return listenurl; + } + } +} diff --git a/modules/icecast/index.ts b/modules/icecast/index.ts new file mode 100644 index 0000000..38c2080 --- /dev/null +++ b/modules/icecast/index.ts @@ -0,0 +1,49 @@ +// icecast's events. The tool runtime imports this once the broker is bound. It watches the streaming +// server and announces when a mountpoint goes live or drops. +// +// Emits (novox/hq ADR 0046/0047): +// module.icecast.stream.started / .stopped — a mountpoint appeared or disappeared +// +// A mountpoint exists only while a source is connected, so the set of mounts diffed over time is +// exactly the set of live streams. Primed silently on the first look, so streams already running when +// this starts are not announced as freshly begun. Polling is unhurried — a stream a few seconds late +// is still the event, and hammering the status endpoint buys immediacy nobody asked for. + +import { emit } from "@novox/mesh-sdk/events"; +import { IcecastClient, type IcecastMount } from "./client.js"; + +const icecast = IcecastClient.fromEnv(); + +const live = new Map(); +let primed = false; + +async function pollMounts(): Promise { + const { mounts } = await icecast.getStatus(); + const now = new Map(mounts.map((m) => [m.mount, m])); + if (primed) { + for (const [mount, m] of now) { + if (!live.has(mount)) { + await emit("module.icecast.stream.started", { + mount, + name: m.name, + description: m.description, + bitrate: m.bitrate, + }); + } + } + for (const [mount, m] of live) { + if (!now.has(mount)) { + await emit("module.icecast.stream.stopped", { mount, name: m.name }); + } + } + } + live.clear(); + for (const [mount, m] of now) live.set(mount, m); + primed = true; +} + +const run = (): void => void pollMounts().catch((err) => console.error(`[icecast] ${err}`)); +setInterval(run, 15_000); +run(); + +console.log("[icecast] watching mountpoints for streams starting and stopping"); diff --git a/modules/icecast/module.json b/modules/icecast/module.json index 77d208e..6d49425 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -4,10 +4,15 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.icecast.stream.started", + "module.icecast.stream.stopped" + ], "own-secrets": { "source": "/var/lib/icecast-module/source.secret", "admin": "/var/lib/icecast-module/admin.secret", - "relay": "/var/lib/icecast-module/relay.secret" + "relay": "/var/lib/icecast-module/relay.secret", + "broker": "/var/lib/mesh/icecast/broker" }, "listens": [ { @@ -18,6 +23,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/icecast", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -42,6 +53,33 @@ "ports": [ "8000" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/icecast/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-icecast", + "image": "mesh-runtime-icecast@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/icecast/broker:/run/secrets/broker:ro", + "/var/lib/mesh/icecast/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_ICECAST_URL": "http://127.0.0.1:8000", + "MESH_ICECAST_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/icecast/package.json b/modules/icecast/package.json new file mode 100644 index 0000000..fa2b925 --- /dev/null +++ b/modules/icecast/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-icecast", + "version": "0.1.0", + "description": "icecast — audio streaming server. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/icecast/tools/index.ts b/modules/icecast/tools/index.ts new file mode 100644 index 0000000..86b5828 --- /dev/null +++ b/modules/icecast/tools/index.ts @@ -0,0 +1,26 @@ +// icecast's tools (novox/hq ADR 0044), importing icecast's own client. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { IcecastClient } from "../client.js"; + +export function getIcecastTools(icecast: IcecastClient): ToolDefinition[] { + return [ + { + name: "icecast_status", + description: "Icecast streaming status: live mountpoints, each with its listener count, plus the total.", + input: {}, + run: async () => { + const status = await icecast.getStatus(); + return status; + }, + }, + ]; +} + +registerModuleTools("icecast", (env) => { + try { + return getIcecastTools(IcecastClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/icecast/tsconfig.json b/modules/icecast/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/icecast/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/influxdb/client.ts b/modules/influxdb/client.ts new file mode 100644 index 0000000..ab0fc73 --- /dev/null +++ b/modules/influxdb/client.ts @@ -0,0 +1,116 @@ +// The InfluxDB API client — influxdb's own code, living in the module (novox/hq ADR 0044). Only +// this module's tools import it. Talks to the InfluxDB 2.x HTTP API (/api/v2) with a token. + +import { readFileSync } from "node:fs"; + +export interface InfluxHealth { + name?: string; + status?: string; + message?: string; + version?: string; +} + +export interface InfluxBucket { + id: string; + name: string; + orgID?: string; + retentionSeconds?: number; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class InfluxDBClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly token: string, + private readonly org: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The token is the InfluxDB API token (the admin + * token the server was initialised with, or a scoped one) — required, since every /api/v2 call + * is token-authenticated. The org scopes bucket listing and queries. + */ + 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"}`; + 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); + } + + private async request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Token ${this.token}`, + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) throw new Error(`InfluxDB API ${path}: ${res.status} ${await res.text()}`); + return res; + } + + /** Server health — the one endpoint that needs no token, but we send it anyway. */ + async health(): Promise { + return (await (await this.request("/health")).json()) as InfluxHealth; + } + + async listBuckets(): Promise { + const body = (await (await this.request("/api/v2/buckets")).json()) as { buckets?: unknown[] }; + return (body.buckets ?? []).map((b) => { + const bucket = b as Record; + const rules = (bucket.retentionRules as { everySeconds?: number }[] | undefined) ?? []; + return { + id: String(bucket.id), + name: String(bucket.name), + orgID: bucket.orgID ? String(bucket.orgID) : undefined, + retentionSeconds: rules[0]?.everySeconds, + }; + }); + } + + /** + * Run a read-only Flux query and return the raw CSV InfluxDB answers with, plus a light parse + * into rows. Read-only: Flux has no write verb, and the token's own permissions bound the rest — + * this client never calls the write endpoint. + */ + async query(flux: string): Promise<{ csv: string; rows: Record[] }> { + const res = await this.request(`/api/v2/query?org=${encodeURIComponent(this.org)}`, { + method: "POST", + headers: { + "Content-Type": "application/vnd.flux", + Accept: "application/csv", + }, + body: flux, + }); + const csv = await res.text(); + return { csv, rows: parseAnnotatedCsv(csv) }; + } +} + +/** Parse InfluxDB's annotated CSV into rows keyed by column header. Annotation lines (starting + * with #) and blanks are skipped; the first non-annotation line is the header. */ +function parseAnnotatedCsv(csv: string): Record[] { + const lines = csv.split("\n").filter((l) => l.trim() && !l.startsWith("#")); + if (lines.length < 2) return []; + const header = lines[0].split(","); + return lines.slice(1).map((line) => { + const cells = line.split(","); + const row: Record = {}; + header.forEach((h, i) => { + if (h) row[h] = cells[i] ?? ""; + }); + return row; + }); +} diff --git a/modules/influxdb/module.json b/modules/influxdb/module.json index ad64e0c..62c31dc 100644 --- a/modules/influxdb/module.json +++ b/modules/influxdb/module.json @@ -6,7 +6,8 @@ ], "own-secrets": { "admin": "/var/lib/influxdb-module/admin.secret", - "admin-token": "/var/lib/influxdb-module/admin-token.secret" + "admin-token": "/var/lib/influxdb-module/admin-token.secret", + "broker": "/var/lib/mesh/influxdb/broker" }, "listens": [ { @@ -17,6 +18,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/influxdb", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -59,6 +66,35 @@ "/services/influxdb/data:/var/lib/influxdb2", "/services/influxdb/config:/etc/influxdb2" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/influxdb/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-influxdb", + "image": "mesh-runtime-influxdb@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro", + "/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro", + "/services/influxdb/config:/var/lib/influxdb/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_INFLUXDB_URL": "http://127.0.0.1:8086", + "MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json", + "MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/influxdb/package.json b/modules/influxdb/package.json new file mode 100644 index 0000000..ad7b949 --- /dev/null +++ b/modules/influxdb/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-influxdb", + "version": "0.1.0", + "description": "influxdb — time-series database. Its API client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/influxdb/tools/index.ts b/modules/influxdb/tools/index.ts new file mode 100644 index 0000000..a2e13ce --- /dev/null +++ b/modules/influxdb/tools/index.ts @@ -0,0 +1,46 @@ +// influxdb's tools — its own code (novox/hq ADR 0044), importing its own client. They return +// structured data; the mesh serves them through the sdk's tool harness. Read-only: health, bucket +// listing, and Flux queries — no write path is exposed. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { InfluxDBClient } from "../client.js"; + +export function getInfluxDBTools(influx: InfluxDBClient): ToolDefinition[] { + return [ + { + name: "influxdb_health", + description: "InfluxDB server health and version.", + input: {}, + run: async () => influx.health(), + }, + { + name: "influxdb_list_buckets", + description: "List InfluxDB buckets in the org, with their retention.", + input: {}, + run: async () => { + const buckets = await influx.listBuckets(); + return { count: buckets.length, buckets }; + }, + }, + { + name: "influxdb_query", + description: + "Run a read-only Flux query against InfluxDB and return the parsed rows (plus raw CSV). The query is Flux, e.g. from(bucket:\"default\") |> range(start:-1h).", + input: { flux: { type: "string", description: "the Flux query to run" } }, + run: async (args) => { + const { csv, rows } = await influx.query(String(args.flux)); + return { rowCount: rows.length, rows, csv }; + }, + }, + ]; +} + +// The tools exist only when a token is configured; without one, influxdb contributes none rather +// than failing the whole runtime. +registerModuleTools("influxdb", (env) => { + try { + return getInfluxDBTools(InfluxDBClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/influxdb/tsconfig.json b/modules/influxdb/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/influxdb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} diff --git a/modules/jackett/client.ts b/modules/jackett/client.ts new file mode 100644 index 0000000..1e76be3 --- /dev/null +++ b/modules/jackett/client.ts @@ -0,0 +1,99 @@ +// The Jackett API client — jackett's own code, living in the module (novox/hq ADR 0044). Jackett is +// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client +// talks its /api/v2.0 REST API, and only jackett's tools import it. + +import { readFileSync } from "node:fs"; + +export interface JackettIndexer { + id: string; + name: string; + type: string; // "public" | "private" | "semi-public" + configured: boolean; + siteLink?: string; + lastError?: string; +} + +export interface JackettResult { + title: string; + tracker: string; + category?: string; + size: number; + seeders?: number; + peers?: number; + publishDate?: string; + link?: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class JackettClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and + * the key must be present — without them there is nothing to talk to, so this throws and the + * module contributes no tools rather than failing half-configured. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient { + const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_JACKETT_URL; + const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY; + if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL"); + if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY"); + return new JackettClient(url, apiKey); + } + + private async get(path: string, params: Record = {}): Promise { + const url = new URL(`${this.baseUrl}${path}`); + url.searchParams.set("apikey", this.apiKey); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(`Jackett API ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + /** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */ + async getIndexers(configuredOnly = true): Promise { + const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" }); + const list = Array.isArray(raw) ? raw : []; + return list.map((i: any) => ({ + id: i.id, + name: i.name, + type: i.type, + configured: i.configured ?? false, + siteLink: i.site_link, + lastError: i.last_error || undefined, + })); + } + + /** + * A Torznab search across one indexer, or the "all" aggregate. Jackett returns a normalised JSON + * result set regardless of the underlying tracker, which is the whole point of the proxy. + */ + async search(query: string, indexer = "all", limit = 25): Promise { + const raw = await this.get(`/api/v2.0/indexers/${encodeURIComponent(indexer)}/results`, { Query: query }); + const results = Array.isArray(raw?.Results) ? raw.Results : []; + return results.slice(0, limit).map((r: any) => ({ + title: r.Title, + tracker: r.Tracker ?? r.TrackerId ?? "unknown", + category: Array.isArray(r.CategoryDesc) ? r.CategoryDesc.join(", ") : r.CategoryDesc, + size: r.Size ?? 0, + seeders: r.Seeders, + peers: r.Peers, + publishDate: r.PublishDate, + link: r.Link ?? r.Details, + })); + } +} diff --git a/modules/jackett/module.json b/modules/jackett/module.json index f1e4a74..d4ed722 100644 --- a/modules/jackett/module.json +++ b/modules/jackett/module.json @@ -13,6 +13,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/jackett", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -36,6 +42,38 @@ "volumes": [ "/services/jackett/config:/config" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/jackett/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-jackett", + "image": "mesh-runtime-jackett@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/jackett/broker:/run/secrets/broker:ro", + "/var/lib/mesh/jackett/config.json:/run/config/config.json:ro", + "/services/jackett/config:/var/lib/jackett/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_JACKETT_URL": "http://127.0.0.1:9117", + "MESH_JACKETT_CONFIG_FILE": "/run/config/config.json", + "MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config" + }, + "restart-on": [ + "runtime-config" + ] } - ] + ], + "own-secrets": { + "broker": "/var/lib/mesh/jackett/broker" + } } diff --git a/modules/jackett/package.json b/modules/jackett/package.json new file mode 100644 index 0000000..8ef721f --- /dev/null +++ b/modules/jackett/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-jackett", + "version": "0.1.0", + "description": "jackett — indexer proxy. Its API client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/jackett/tools/index.ts b/modules/jackett/tools/index.ts new file mode 100644 index 0000000..b10ccad --- /dev/null +++ b/modules/jackett/tools/index.ts @@ -0,0 +1,48 @@ +// jackett's tools — its own code (novox/hq ADR 0044), importing jackett's client. Jackett has +// nothing worth watching (an indexer proxy answers queries; it has no timeline of its own), so it +// is a tools-only module: no events entrypoint, no broker. What is useful is asking it things. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { JackettClient } from "../client.js"; + +export function getJackettTools(jackett: JackettClient): ToolDefinition[] { + return [ + { + name: "jackett_indexers", + description: "List the indexers Jackett proxies, with their type and any last error.", + input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } }, + run: async (args) => { + const indexers = await jackett.getIndexers(!args.all); + return { count: indexers.length, indexers }; + }, + }, + { + name: "jackett_search", + description: "Torznab search across Jackett's indexers, returning normalised torrent results.", + input: { + query: { type: "string", description: "the search query" }, + indexer: { type: "string", description: 'an indexer id, or "all" to aggregate (default "all")' }, + limit: { type: "number", description: "max results (default 25)" }, + }, + run: async (args) => { + const query = String(args.query); + const results = await jackett.search( + query, + args.indexer ? String(args.indexer) : "all", + args.limit ? Number(args.limit) : 25, + ); + return { query, count: results.length, results }; + }, + }, + ]; +} + +// Only exposed when Jackett is configured; otherwise jackett contributes no tools rather than +// failing the whole runtime. +registerModuleTools("jackett", (env) => { + try { + return getJackettTools(JackettClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/jackett/tsconfig.json b/modules/jackett/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/jackett/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} diff --git a/modules/keycloak/client.ts b/modules/keycloak/client.ts new file mode 100644 index 0000000..830418c --- /dev/null +++ b/modules/keycloak/client.ts @@ -0,0 +1,229 @@ +// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0044). +// Moved out of the shared hal sdk, where a change to Keycloak's admin API rebuilt everything; here +// it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and +// nothing outside keycloak does. + +import { readFileSync } from "node:fs"; + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class KeycloakClient { + readonly baseUrl: string; + readonly defaultRealm: string; + // The admin token is short-lived; caching it (minus a safety margin) spares every call a fresh + // password grant, and a 401 mid-flight refreshes it once rather than failing the request. + private tokenCache: { token: string; expiresAt: number } | null = null; + + constructor( + url: string, + private readonly adminUser: string, + private readonly adminPass: string, + defaultRealm = "master", + ) { + this.baseUrl = url.replace(/\/+$/, ""); + this.defaultRealm = defaultRealm; + } + + /** + * Build from the module's resolved environment. Admin URL, credentials and the fallback realm are + * read from MESH_KEYCLOAK_* — the names the mesh sets — falling back to the container's own + * KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD so a co-located server needs nothing configured twice. + * Throws when no admin password can be found: without it the client can do nothing, so failing + * here lets the tool runtime expose no keycloak tools rather than tools that always error. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): 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"; + 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); + } + + private async getToken(): Promise { + if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) return this.tokenCache.token; + + const res = await fetch(`${this.baseUrl}/realms/master/protocol/openid-connect/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "password", + client_id: "admin-cli", + username: this.adminUser, + password: this.adminPass, + }), + }); + if (!res.ok) throw new Error(`Keycloak token request failed: ${res.status} ${await res.text()}`); + + const data = (await res.json()) as { access_token: string; expires_in: number }; + this.tokenCache = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 30) * 1000 }; + return data.access_token; + } + + private async request(path: string, options: RequestInit = {}): Promise { + const doRequest = async (token: string): Promise => + fetch(`${this.baseUrl}/admin/realms${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(options.headers as Record), + }, + }); + + let res = await doRequest(await this.getToken()); + // A cached token that expired against the server's clock reads as 401; drop it and retry once. + if (res.status === 401) { + this.tokenCache = null; + res = await doRequest(await this.getToken()); + } + if (!res.ok) throw new Error(`Keycloak API error ${res.status}: ${await res.text()}`); + // 201/204 carry no body — the admin API's create/update/delete answer with an empty response. + if (res.status === 201 || res.status === 204) return null as T; + return res.json() as Promise; + } + + // Realms + async listRealms(): Promise> { + return this.request("/"); + } + + // Users + async listUsers(realm: string, params: { search?: string; max?: number } = {}): Promise { + const qs = new URLSearchParams(); + if (params.search) qs.set("search", params.search); + if (params.max) qs.set("max", String(params.max)); + const query = qs.toString(); + return this.request(`/${realm}/users${query ? `?${query}` : ""}`); + } + + async createUser(realm: string, data: { + username: string; + email?: string; + enabled?: boolean; + credentials?: Array<{ type: string; value: string; temporary: boolean }>; + }): Promise { + await this.request(`/${realm}/users`, { method: "POST", body: JSON.stringify({ enabled: true, ...data }) }); + } + + async updateUser(realm: string, userId: string, data: Record): Promise { + await this.request(`/${realm}/users/${userId}`, { method: "PUT", body: JSON.stringify(data) }); + } + + async deleteUser(realm: string, userId: string): Promise { + await this.request(`/${realm}/users/${userId}`, { method: "DELETE" }); + } + + async resetPassword(realm: string, userId: string, password: string, temporary = false): Promise { + await this.request(`/${realm}/users/${userId}/reset-password`, { + method: "PUT", + body: JSON.stringify({ type: "password", value: password, temporary }), + }); + } + + async getUserSessions(realm: string, userId: string): Promise { + return this.request(`/${realm}/users/${userId}/sessions`); + } + + // Clients + async listClients(realm: string): Promise { + return this.request(`/${realm}/clients`); + } + + async createClient(realm: string, data: { + clientId: string; + name?: string; + rootUrl?: string; + redirectUris?: string[]; + publicClient?: boolean; + protocol?: string; + }): Promise { + await this.request(`/${realm}/clients`, { + method: "POST", + body: JSON.stringify({ protocol: "openid-connect", enabled: true, ...data }), + }); + } + + // The admin API addresses a client by its internal UUID, not the human clientId a caller knows; + // every client-scoped call resolves the one to the other first. + private async resolveClientId(realm: string, clientId: string): Promise { + const clients = (await this.listClients(realm)) as Array>; + const client = clients.find((c) => c.clientId === clientId); + if (!client) throw new Error(`Client '${clientId}' not found in realm '${realm}'`); + return client.id as string; + } + + async deleteClient(realm: string, clientId: string): Promise { + await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" }); + } + + async getClientSecret(realm: string, clientId: string): Promise { + const id = await this.resolveClientId(realm, clientId); + const result = await this.request<{ value: string }>(`/${realm}/clients/${id}/client-secret`); + return result.value; + } + + async addProtocolMapper(realm: string, clientId: string, mapper: { + name: string; + protocolMapper: string; + config: Record; + }): Promise { + const id = await this.resolveClientId(realm, clientId); + await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, { + method: "POST", + body: JSON.stringify({ protocol: "openid-connect", ...mapper }), + }); + } + + // Roles + async listRealmRoles(realm: string): Promise> { + return this.request(`/${realm}/roles`); + } + + async createRealmRole(realm: string, data: { name: string; description?: string }): Promise { + await this.request(`/${realm}/roles`, { method: "POST", body: JSON.stringify(data) }); + } + + async getUserRealmRoles(realm: string, userId: string): Promise> { + return this.request(`/${realm}/users/${userId}/role-mappings/realm`); + } + + async getAvailableRealmRoles(realm: string, userId: string): Promise> { + return this.request(`/${realm}/users/${userId}/role-mappings/realm/available`); + } + + async assignRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise { + await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "POST", body: JSON.stringify(roles) }); + } + + async removeRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise { + await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "DELETE", body: JSON.stringify(roles) }); + } + + // Groups + async listGroups(realm: string): Promise> { + return this.request(`/${realm}/groups`); + } + + async createGroup(realm: string, name: string): Promise { + await this.request(`/${realm}/groups`, { method: "POST", body: JSON.stringify({ name }) }); + } + + async getUserGroups(realm: string, userId: string): Promise> { + return this.request(`/${realm}/users/${userId}/groups`); + } + + async addUserToGroup(realm: string, userId: string, groupId: string): Promise { + await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "PUT" }); + } + + async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise { + await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "DELETE" }); + } +} diff --git a/modules/keycloak/index.ts b/modules/keycloak/index.ts new file mode 100644 index 0000000..1a3bd85 --- /dev/null +++ b/modules/keycloak/index.ts @@ -0,0 +1,44 @@ +// keycloak's events. Keycloak's worth to the mesh is in what it changes — an identity created, a +// client registered, a password reset — so its events are emitted from the admin actions themselves +// (novox/hq ADR 0046/0047), not scraped back by polling. This module is the single vocabulary for +// them: every keycloak event goes through one of the helpers here, and the tools call them at the +// point the change succeeds. +// +// Emits: +// module.keycloak.user.created / .deleted — an identity appeared or was removed +// module.keycloak.password.reset — a user's credential was reset (no secret in the body) +// module.keycloak.client.created — an OIDC client was registered +// module.keycloak.group.created — a group was created +// module.keycloak.role.created — a realm role was created +// Consumes: +// nothing — Keycloak is upstream of the things that authenticate against it; it reacts to none of +// their events. There is no honest `on(...)` to write, so there is none. + +import { emit } from "@novox/mesh-sdk/events"; + +// A completed admin action must not be undone by a flaky broker: the change already happened in +// Keycloak, so a failed emit is logged and swallowed rather than thrown back through the tool. +async function announce(type: string, body: Record): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[keycloak] emit ${type} failed: ${err}`); + } +} + +export const events = { + userCreated: (realm: string, username: string, email?: string) => + announce("module.keycloak.user.created", { realm, username, ...(email ? { email } : {}) }), + userDeleted: (realm: string, userId: string) => + announce("module.keycloak.user.deleted", { realm, userId }), + passwordReset: (realm: string, userId: string) => + announce("module.keycloak.password.reset", { realm, userId }), + clientCreated: (realm: string, clientId: string, name?: string) => + announce("module.keycloak.client.created", { realm, clientId, ...(name ? { name } : {}) }), + groupCreated: (realm: string, name: string) => + announce("module.keycloak.group.created", { realm, name }), + roleCreated: (realm: string, name: string) => + announce("module.keycloak.role.created", { realm, name }), +}; + +console.log("[keycloak] event surface ready — identity, client, group and role changes are announced"); diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 5096b20..4d4c4b0 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -18,6 +18,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.keycloak.user.created", + "module.keycloak.user.deleted", + "module.keycloak.password.reset", + "module.keycloak.client.created", + "module.keycloak.group.created", + "module.keycloak.role.created" + ], "listens": [ { "port": 8080, @@ -27,9 +35,16 @@ } ], "own-secrets": { - "admin": "/var/lib/keycloak/admin.secret" + "admin": "/var/lib/keycloak/admin.secret", + "broker": "/var/lib/mesh/keycloak/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/keycloak", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -76,6 +91,33 @@ "ports": [ "8080" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/keycloak/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-keycloak", + "image": "mesh-runtime-keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/keycloak/broker:/run/secrets/broker: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:8080", + "MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/keycloak/package.json b/modules/keycloak/package.json new file mode 100644 index 0000000..6076ad3 --- /dev/null +++ b/modules/keycloak/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-keycloak", + "version": "0.1.0", + "description": "keycloak — identity and access. Its admin API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/keycloak/tools/index.ts b/modules/keycloak/tools/index.ts new file mode 100644 index 0000000..0382713 --- /dev/null +++ b/modules/keycloak/tools/index.ts @@ -0,0 +1,378 @@ +// keycloak's tools — moved here from the shared sdk (novox/hq ADR 0044), importing keycloak's own +// client. They return structured data (not the hal MCP `{content:[...]}` shape); the mesh serves +// them through the sdk's tool harness. Write actions announce themselves through the module's event +// surface at the point they succeed. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { KeycloakClient } from "../client.js"; +import { events } from "../index.js"; + +export function getKeycloakTools(kc: KeycloakClient): ToolDefinition[] { + // Almost every tool is realm-scoped; an omitted realm falls back to the one the module resolved + // from its environment, so the common single-realm case needs no argument. + const realmOf = (args: Readonly>): string => + args.realm ? String(args.realm) : kc.defaultRealm; + + return [ + // Realms & sessions + { + name: "keycloak_list_realms", + description: "List all Keycloak realms.", + input: {}, + run: async () => { + const realms = await kc.listRealms(); + return { realms: realms.map((r) => ({ id: r.id, realm: r.realm, displayName: r.displayName, enabled: r.enabled })) }; + }, + }, + { + name: "keycloak_list_sessions", + description: "List active sessions for a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + }, + run: async (args) => ({ sessions: await kc.getUserSessions(realmOf(args), String(args.user_id)) }), + }, + + // Users + { + name: "keycloak_list_users", + description: "List users in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + search: { type: "string", description: "search by username, email, first/last name" }, + max: { type: "number", description: "maximum number of results" }, + }, + run: async (args) => ({ + users: await kc.listUsers(realmOf(args), { + search: args.search ? String(args.search) : undefined, + max: args.max ? Number(args.max) : undefined, + }), + }), + }, + { + name: "keycloak_create_user", + description: "Create a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + username: { type: "string", description: "username" }, + email: { type: "string", description: "email address" }, + password: { type: "string", description: "initial password" }, + temporary_password: { type: "boolean", description: "require a password change on first login (default true)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const username = String(args.username); + const email = args.email ? String(args.email) : undefined; + const credentials = args.password + ? [{ type: "password", value: String(args.password), temporary: args.temporary_password !== false }] + : undefined; + await kc.createUser(realm, { username, email, credentials }); + await events.userCreated(realm, username, email); + return { created: { realm, username, email } }; + }, + }, + { + name: "keycloak_delete_user", + description: "Delete a user from a Keycloak realm (requires confirm).", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + if (args.confirm !== true) return { aborted: "confirm must be true to delete a user" }; + await kc.deleteUser(realm, userId); + await events.userDeleted(realm, userId); + return { deleted: { realm, userId } }; + }, + }, + { + name: "keycloak_update_user", + description: "Update a user's attributes in a Keycloak realm (enable/disable, change email, name).", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + enabled: { type: "boolean", description: "enable or disable the user" }, + email: { type: "string", description: "new email address" }, + firstName: { type: "string", description: "new first name" }, + lastName: { type: "string", description: "new last name" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + const updates: Record = {}; + if (args.enabled !== undefined) updates.enabled = args.enabled === true; + if (args.email !== undefined) updates.email = String(args.email); + if (args.firstName !== undefined) updates.firstName = String(args.firstName); + if (args.lastName !== undefined) updates.lastName = String(args.lastName); + if (Object.keys(updates).length === 0) return { aborted: "no updates provided" }; + await kc.updateUser(realm, userId, updates); + return { updated: { realm, userId, fields: Object.keys(updates) } }; + }, + }, + { + name: "keycloak_reset_password", + description: "Reset a user's password in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + password: { type: "string", description: "new password" }, + temporary: { type: "boolean", description: "require a password change on next login (default false)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + await kc.resetPassword(realm, userId, String(args.password), args.temporary === true); + await events.passwordReset(realm, userId); + return { reset: { realm, userId } }; + }, + }, + + // Clients + { + name: "keycloak_list_clients", + description: "List OIDC clients in a Keycloak realm.", + input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } }, + run: async (args) => { + const clients = (await kc.listClients(realmOf(args))) as Array>; + return { + clients: clients.map((c) => ({ + id: c.id, clientId: c.clientId, name: c.name, enabled: c.enabled, + protocol: c.protocol, publicClient: c.publicClient, rootUrl: c.rootUrl, + })), + }; + }, + }, + { + name: "keycloak_create_client", + description: "Create an OIDC client in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID (e.g. 'my-app')" }, + name: { type: "string", description: "display name" }, + root_url: { type: "string", description: "root URL of the application" }, + redirect_uris: { type: "array", description: "allowed redirect URIs" }, + public_client: { type: "boolean", description: "public client, no client secret (default true)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const clientId = String(args.client_id); + const name = args.name ? String(args.name) : undefined; + await kc.createClient(realm, { + clientId, + name, + rootUrl: args.root_url ? String(args.root_url) : undefined, + redirectUris: Array.isArray(args.redirect_uris) ? args.redirect_uris.map(String) : undefined, + publicClient: args.public_client !== false, + }); + await events.clientCreated(realm, clientId, name); + return { created: { realm, clientId, name } }; + }, + }, + { + name: "keycloak_delete_client", + description: "Delete an OIDC client from a Keycloak realm (requires confirm).", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID (e.g. 'my-app')" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + const realm = realmOf(args); + const clientId = String(args.client_id); + if (args.confirm !== true) return { aborted: "confirm must be true to delete a client" }; + await kc.deleteClient(realm, clientId); + return { deleted: { realm, clientId } }; + }, + }, + { + name: "keycloak_get_client_secret", + description: "Get the client secret for a confidential OIDC client.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID" }, + }, + run: async (args) => ({ secret: await kc.getClientSecret(realmOf(args), String(args.client_id)) }), + }, + { + name: "keycloak_add_protocol_mapper", + description: + "Add a protocol mapper to an OIDC client. Common types: oidc-usermodel-realm-role-mapper " + + "(realm roles), oidc-usermodel-attribute-mapper (user attributes), oidc-audience-mapper.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID (e.g. 'grafana')" }, + name: { type: "string", description: "mapper name (e.g. 'realm roles')" }, + mapper_type: { type: "string", description: "protocol mapper type (e.g. 'oidc-usermodel-realm-role-mapper')" }, + claim_name: { type: "string", description: "token claim name (e.g. 'realm_access.roles')" }, + claim_type: { type: "string", description: "JSON type: String, long, int, boolean (default String)" }, + multivalued: { type: "boolean", description: "whether the claim has multiple values (default false)" }, + id_token: { type: "boolean", description: "include in ID token (default true)" }, + access_token: { type: "boolean", description: "include in access token (default true)" }, + userinfo: { type: "boolean", description: "include in userinfo response (default true)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const clientId = String(args.client_id); + const name = String(args.name); + await kc.addProtocolMapper(realm, clientId, { + name, + protocolMapper: String(args.mapper_type), + config: { + "claim.name": String(args.claim_name), + "jsonType.label": args.claim_type ? String(args.claim_type) : "String", + "multivalued": String(args.multivalued === true), + "id.token.claim": String(args.id_token !== false), + "access.token.claim": String(args.access_token !== false), + "userinfo.token.claim": String(args.userinfo !== false), + }, + }); + return { added: { realm, clientId, mapper: name } }; + }, + }, + + // Groups + { + name: "keycloak_list_groups", + description: "List groups in a Keycloak realm.", + input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } }, + run: async (args) => ({ groups: await kc.listGroups(realmOf(args)) }), + }, + { + name: "keycloak_create_group", + description: "Create a group in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + name: { type: "string", description: "group name" }, + }, + run: async (args) => { + const realm = realmOf(args); + const name = String(args.name); + await kc.createGroup(realm, name); + await events.groupCreated(realm, name); + return { created: { realm, group: name } }; + }, + }, + { + name: "keycloak_get_user_groups", + description: "List the groups a user belongs to in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + }, + run: async (args) => ({ groups: await kc.getUserGroups(realmOf(args), String(args.user_id)) }), + }, + { + name: "keycloak_add_user_to_group", + description: "Add a user to a group in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + group_id: { type: "string", description: "group ID (UUID)" }, + }, + run: async (args) => { + const realm = realmOf(args); + await kc.addUserToGroup(realm, String(args.user_id), String(args.group_id)); + return { added: { realm, userId: String(args.user_id), groupId: String(args.group_id) } }; + }, + }, + { + name: "keycloak_remove_user_from_group", + description: "Remove a user from a group in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + group_id: { type: "string", description: "group ID (UUID)" }, + }, + run: async (args) => { + const realm = realmOf(args); + await kc.removeUserFromGroup(realm, String(args.user_id), String(args.group_id)); + return { removed: { realm, userId: String(args.user_id), groupId: String(args.group_id) } }; + }, + }, + + // Roles + { + name: "keycloak_get_user_roles", + description: "List the realm roles assigned to a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + }, + run: async (args) => ({ roles: await kc.getUserRealmRoles(realmOf(args), String(args.user_id)) }), + }, + { + name: "keycloak_create_role", + description: "Create a realm role in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + role_name: { type: "string", description: "role name" }, + description: { type: "string", description: "role description" }, + }, + run: async (args) => { + const realm = realmOf(args); + const name = String(args.role_name); + await kc.createRealmRole(realm, { name, description: args.description ? String(args.description) : undefined }); + await events.roleCreated(realm, name); + return { created: { realm, role: name } }; + }, + }, + { + name: "keycloak_assign_user_role", + description: "Assign an existing realm role to a user. Create it first with keycloak_create_role if needed.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + role_name: { type: "string", description: "role name to assign" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + const roleName = String(args.role_name); + // The mapping API needs the role's UUID, which only the "available" list carries; if the + // role is neither available nor already assigned it does not exist in this realm. + const available = await kc.getAvailableRealmRoles(realm, userId); + const role = available.find((r) => r.name === roleName); + if (!role) { + const assigned = await kc.getUserRealmRoles(realm, userId); + if (assigned.find((r) => r.name === roleName)) return { alreadyAssigned: { realm, userId, role: roleName } }; + return { notFound: { realm, role: roleName } }; + } + await kc.assignRealmRoles(realm, userId, [{ id: role.id, name: role.name }]); + return { assigned: { realm, userId, role: roleName } }; + }, + }, + { + name: "keycloak_remove_user_role", + description: "Remove a realm role from a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + role_name: { type: "string", description: "role name to remove" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + const roleName = String(args.role_name); + const assigned = await kc.getUserRealmRoles(realm, userId); + const role = assigned.find((r) => r.name === roleName); + if (!role) return { notAssigned: { realm, userId, role: roleName } }; + await kc.removeRealmRoles(realm, userId, [{ id: role.id, name: role.name }]); + return { removed: { realm, userId, role: roleName } }; + }, + }, + ]; +} + +// The tools exist only when the client can be configured; without an admin password, keycloak +// contributes none rather than failing the whole runtime. +registerModuleTools("keycloak", (env) => { + try { + return getKeycloakTools(KeycloakClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/keycloak/tsconfig.json b/modules/keycloak/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/keycloak/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts new file mode 100644 index 0000000..32053cb --- /dev/null +++ b/modules/mailu/client.ts @@ -0,0 +1,229 @@ +// The Mailu API client — mailu's own code, living in the module (novox/hq ADR 0044). Moved out of +// the shared hal sdk, where a change to Mailu's surface rebuilt everything; here it rebuilds only +// mailu. Both this module's tools and its events entrypoint import it, and nothing outside mailu does. +// +// hal drove Mailu through its flask CLI over `docker compose exec` into the admin container. That +// coupling was to the container, not to Mailu: it needed a shell on the box. The module's real +// coupling is the admin REST API, so that is what this client speaks — a token and a URL, no shell. +// The one exception is reading a mailbox: the admin API exposes no message reads, so that alone +// falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve. + +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +export interface MailuUser { + email: string; + displayed_name?: string; + global_admin?: boolean; + enabled?: boolean; + forward_enabled?: boolean; + forward_destination?: string[]; + quota_bytes?: number; +} + +export interface MailuAlias { + email: string; + destination: string[]; + wildcard?: boolean; +} + +export interface MailuDomain { + name: string; +} + +/** One parsed message from a doveadm fetch — the subset the read/search tools surface. */ +export interface MailMessage { + date?: string; + from?: string; + subject?: string; + preview?: string; +} + +// The fields we ask doveadm for, once — kept together so read and search stay identical in shape. +const FETCH_FIELDS = "date.received hdr.subject hdr.from body.snippet"; + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class MailuClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + /** The container name doveadm runs in — reads bypass the API, so they need the box, not a token. */ + private readonly imapContainer: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. MESH_MAILU_URL points at the admin API (e.g. the + * admin container's /api/v1), MESH_MAILU_API_KEY authenticates against it. Both are required — a + * client with neither would only fail later, one call at a time, so it fails here instead. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient { + const cfg = meshConfig(env.MESH_MAILU_CONFIG_FILE); + const url = cfg.url ?? env.MESH_MAILU_URL; + const apiKey = cfg.apiKey ?? env.MESH_MAILU_API_KEY; + if (!url || !apiKey) { + throw new Error("Mailu is not configured — set MESH_MAILU_URL and MESH_MAILU_API_KEY"); + } + const imapContainer = cfg.container ?? env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap"; + return new MailuClient(url, apiKey, imapContainer); + } + + // --- The admin REST API: users, aliases, domains. --------------------------------------------- + + private async api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + // Mailu's admin API takes the token directly in Authorization, no scheme prefix. + Authorization: this.apiKey, + Accept: "application/json", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + if (!res.ok) throw new Error(`Mailu API ${method} ${path}: ${res.status} ${await res.text()}`); + // DELETE and some writes answer with an empty body or a bare string; guard the JSON parse. + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; + } + + async listUsers(): Promise { + const users = await this.api("GET", "/user"); + return (users ?? []).map((u) => ({ + email: u.email, + displayed_name: u.displayed_name, + global_admin: u.global_admin, + enabled: u.enabled, + forward_enabled: u.forward_enabled, + forward_destination: u.forward_destination, + quota_bytes: u.quota_bytes, + })); + } + + /** Create a mailbox. Mailu wants the full address and the plaintext password it will hash. */ + async createUser(email: string, password: string): Promise { + await this.api("POST", "/user", { email, raw_password: password }); + } + + async changePassword(email: string, password: string): Promise { + await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password }); + } + + async deleteUser(email: string): Promise { + await this.api("DELETE", `/user/${encodeURIComponent(email)}`); + } + + async listAliases(): Promise { + const aliases = await this.api("GET", "/alias"); + return (aliases ?? []).map((a) => ({ + email: a.email, + // The API returns destination as a comma-joined string on some versions, a list on others. + destination: Array.isArray(a.destination) + ? a.destination + : String(a.destination ?? "").split(",").map((d: string) => d.trim()).filter(Boolean), + wildcard: a.wildcard, + })); + } + + async createAlias(email: string, destination: string[], wildcard = false): Promise { + await this.api("POST", "/alias", { email, destination, wildcard }); + } + + async deleteAlias(email: string): Promise { + await this.api("DELETE", `/alias/${encodeURIComponent(email)}`); + } + + async listDomains(): Promise { + const domains = await this.api("GET", "/domain"); + return (domains ?? []).map((d) => ({ name: d.name })); + } + + // --- Reading mail: doveadm, because the admin API has no message reads. ----------------------- + + /** Recent messages in a mailbox, newest last, capped to `limit`. */ + async readMail(user: string, mailbox = "INBOX", limit = 10): Promise { + const messages = await this.doveadmFetch(user, ["mailbox", mailbox]); + return messages.slice(-limit); + } + + /** + * Search a mailbox by subject, sender and/or date. doveadm fetch takes a search query directly, + * so we build one from whichever criteria were given — `all` when none were, to avoid an empty + * query that would match nothing. + */ + async searchMail( + user: string, + criteria: { subject?: string; from?: string; since?: string }, + limit = 10, + ): Promise { + const query: string[] = []; + if (criteria.subject) query.push("subject", criteria.subject); + if (criteria.from) query.push("from", criteria.from); + if (criteria.since) query.push("since", criteria.since); + if (query.length === 0) query.push("all"); + const messages = await this.doveadmFetch(user, query); + return messages.slice(-limit); + } + + private async doveadmFetch(user: string, query: string[]): Promise { + const output = await run( + "docker", + ["exec", "-i", this.imapContainer, "doveadm", "fetch", "-u", user, FETCH_FIELDS, ...query], + { timeout: 30_000 }, + ) + .then((r) => r.stdout) + // An empty mailbox is not an error; doveadm says so on stderr and exits non-zero. + .catch((e: { stderr?: string; message?: string }) => { + const text = `${e.stderr ?? ""}${e.message ?? ""}`; + if (text.includes("no matching mails")) return ""; + throw e; + }); + return parseDoveadmFetch(output).map(toMailMessage); + } +} + +// doveadm fetch prints one record per message, records separated by a blank line (a form feed in +// some builds), each field on its own `name: value` line. A folded value continues on later lines. +function parseDoveadmFetch(output: string): Array> { + const messages: Array> = []; + let current: Record = {}; + for (const line of output.split("\n")) { + if (line === "" || line === "\f") { + if (Object.keys(current).length) { + messages.push(current); + current = {}; + } + continue; + } + const colonIdx = line.indexOf(": "); + if (colonIdx > 0) { + const key = line.slice(0, colonIdx); + const value = line.slice(colonIdx + 2); + current[key] = current[key] ? `${current[key]}\n${value}` : value; + } + } + if (Object.keys(current).length) messages.push(current); + return messages; +} + +function toMailMessage(m: Record): MailMessage { + const preview = m["body.snippet"]; + return { + date: m["date.received"]?.trim(), + from: m["hdr.from"]?.trim(), + subject: m["hdr.subject"]?.trim(), + preview: preview ? preview.trim().slice(0, 200) : undefined, + }; +} diff --git a/modules/mailu/index.ts b/modules/mailu/index.ts new file mode 100644 index 0000000..3526dd7 --- /dev/null +++ b/modules/mailu/index.ts @@ -0,0 +1,61 @@ +// mailu's events. The tool runtime imports this once the broker is bound. It watches the mail +// server's accounts and announces what changed, so the rest of the mesh can react to a mailbox +// appearing or an alias being pointed somewhere new. +// +// Emits (novox/hq ADR 0046/0047): +// module.mailu.user.created / .deleted — a mailbox appeared or was removed +// module.mailu.alias.created / .deleted — an alias was added or removed +// +// Consumes: nothing. Mail accounts are not something the mesh should mutate in the background off +// another module's event — a wrong reaction here silently loses mail. mailu observes and announces; +// it does not act on what others do. If a real consumer is ever wanted, it is a deliberate addition. +// +// Detection is by polling the admin API and diffing, exactly as plex diffs its sessions: the change +// may have come from the web admin as easily as from a tool, and a diff catches both. The first +// look primes silently, or a restart would re-announce every existing account as freshly created. + +import { emit } from "@novox/mesh-sdk/events"; +import { MailuClient } from "./client.js"; + +const mailu = MailuClient.fromEnv(); + +// A generic diff over a keyed set: emit `created` for keys that appeared, `deleted` for keys that +// went away, and stay silent until primed. Users and aliases are the same shape of watch. +function watcher(created: string, deleted: string): (keys: string[]) => Promise { + const known = new Set(); + let primed = false; + return async (keys: string[]) => { + const now = new Set(keys); + if (primed) { + for (const key of now) if (!known.has(key)) await emit(created, { email: key }); + for (const key of known) if (!now.has(key)) await emit(deleted, { email: key }); + } + known.clear(); + for (const key of now) known.add(key); + primed = true; + }; +} + +const watchUsers = watcher("module.mailu.user.created", "module.mailu.user.deleted"); +const watchAliases = watcher("module.mailu.alias.created", "module.mailu.alias.deleted"); + +async function pollUsers(): Promise { + await watchUsers((await mailu.listUsers()).map((u) => u.email)); +} + +async function pollAliases(): Promise { + await watchAliases((await mailu.listAliases()).map((a) => a.email)); +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const runOnce = (): void => void fn().catch((err) => console.error(`[mailu] ${err}`)); + setInterval(runOnce, everyMs); + runOnce(); +}; + +// Accounts and aliases change on human time, not machine time — a minute's latency is fine, and +// polling the admin API harder buys immediacy nobody asked for. +tick(pollUsers, 60_000); +tick(pollAliases, 60_000); + +console.log("[mailu] watching users and aliases"); diff --git a/modules/mailu/module.json b/modules/mailu/module.json index b1fe323..d73c409 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.mailu.user.created", + "module.mailu.user.deleted", + "module.mailu.alias.created", + "module.mailu.alias.deleted" + ], "listens": [ { "port": 25, @@ -43,9 +49,16 @@ "own-secrets": { "secret-key": "/var/lib/mailu/secret-key.secret", "database": "/var/lib/mailu/database.secret", - "admin": "/var/lib/mailu/admin.secret" + "admin": "/var/lib/mailu/admin.secret", + "broker": "/var/lib/mesh/mailu/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/mailu", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -269,6 +282,34 @@ "/services/mailu/data/certs:/certs", "/services/mailu/data/overrides/nginx:/overrides:ro" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/mailu/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-mailu", + "image": "mesh-runtime-mailu@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/mailu/broker:/run/secrets/broker:ro", + "/var/lib/mesh/mailu/config.json:/run/config/config.json:ro", + "/var/run/docker.sock:/var/run/docker.sock" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_MAILU_URL": "http://127.0.0.1:80", + "MESH_MAILU_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/mailu/package.json b/modules/mailu/package.json new file mode 100644 index 0000000..c640eac --- /dev/null +++ b/modules/mailu/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-mailu", + "version": "0.1.0", + "description": "mailu — mail server. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/mailu/tools/index.ts b/modules/mailu/tools/index.ts new file mode 100644 index 0000000..e948075 --- /dev/null +++ b/modules/mailu/tools/index.ts @@ -0,0 +1,154 @@ +// mailu's tools — moved here from the shared sdk (novox/hq ADR 0044), importing mailu's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. Deletions are +// guarded by an explicit `confirm`, since removing a mailbox destroys its mail and cannot be undone. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { MailuClient } from "../client.js"; + +export function getMailuTools(mailu: MailuClient): ToolDefinition[] { + return [ + { + name: "mailu_list_users", + description: "List all email accounts on the mail server.", + input: {}, + run: async () => { + const users = await mailu.listUsers(); + return { count: users.length, users }; + }, + }, + { + name: "mailu_create_user", + description: "Create a new email account.", + input: { + email: { type: "string", description: "full address, e.g. user@example.com" }, + password: { type: "string", description: "the account's initial password" }, + }, + run: async (args) => { + const email = String(args.email); + await mailu.createUser(email, String(args.password)); + return { created: email }; + }, + }, + { + name: "mailu_change_password", + description: "Change the password of an email account.", + input: { + email: { type: "string", description: "full address of the account" }, + password: { type: "string", description: "the new password" }, + }, + run: async (args) => { + const email = String(args.email); + await mailu.changePassword(email, String(args.password)); + return { changed: email }; + }, + }, + { + name: "mailu_delete_user", + description: "Delete an email account. DESTRUCTIVE — removes the mailbox and all its mail.", + input: { + email: { type: "string", description: "full address of the account to delete" }, + confirm: { type: "boolean", description: "must be true — deletion is irreversible" }, + }, + run: async (args) => { + const email = String(args.email); + if (args.confirm !== true) return { aborted: "confirm must be true to delete a user", email }; + await mailu.deleteUser(email); + return { deleted: email }; + }, + }, + { + name: "mailu_list_aliases", + description: "List all email aliases and where they forward.", + input: {}, + run: async () => { + const aliases = await mailu.listAliases(); + return { count: aliases.length, aliases }; + }, + }, + { + name: "mailu_create_alias", + description: "Create an email alias forwarding to one or more destinations.", + input: { + localpart: { type: "string", description: "the part before @, e.g. 'sales'" }, + domain: { type: "string", description: "the domain, e.g. example.com" }, + destination: { type: "string", description: "destination address(es), comma-separated" }, + wildcard: { type: "boolean", description: "match any localpart under the domain (optional)" }, + }, + run: async (args) => { + const email = `${String(args.localpart)}@${String(args.domain)}`; + const destination = String(args.destination).split(",").map((d) => d.trim()).filter(Boolean); + await mailu.createAlias(email, destination, args.wildcard === true); + return { created: email, destination }; + }, + }, + { + name: "mailu_delete_alias", + description: "Delete an email alias.", + input: { + email: { type: "string", description: "the alias address to delete" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + const email = String(args.email); + if (args.confirm !== true) return { aborted: "confirm must be true to delete an alias", email }; + await mailu.deleteAlias(email); + return { deleted: email }; + }, + }, + { + name: "mailu_list_domains", + description: "List the mail domains the server handles.", + input: {}, + run: async () => { + const domains = await mailu.listDomains(); + return { count: domains.length, domains }; + }, + }, + { + name: "mailu_read_mail", + description: "Read recent messages in a user's mailbox — date, from, subject and a preview.", + input: { + user: { type: "string", description: "the mailbox owner's address" }, + mailbox: { type: "string", description: "which mailbox (default INBOX)" }, + limit: { type: "number", description: "how many recent messages (default 10)" }, + }, + run: async (args) => { + const user = String(args.user); + const mailbox = args.mailbox ? String(args.mailbox) : "INBOX"; + const messages = await mailu.readMail(user, mailbox, args.limit ? Number(args.limit) : 10); + return { user, mailbox, count: messages.length, messages }; + }, + }, + { + name: "mailu_search_mail", + description: "Search a user's mailbox by subject, sender, and/or date.", + input: { + user: { type: "string", description: "the mailbox owner's address" }, + subject: { type: "string", description: "substring to match in the subject (optional)" }, + from: { type: "string", description: "sender address or name to match (optional)" }, + since: { type: "string", description: "only messages since a date, e.g. 01-Jan-2026 (optional)" }, + limit: { type: "number", description: "maximum results (default 10)" }, + }, + run: async (args) => { + const user = String(args.user); + const criteria = { + subject: args.subject ? String(args.subject) : undefined, + from: args.from ? String(args.from) : undefined, + since: args.since ? String(args.since) : undefined, + }; + const messages = await mailu.searchMail(user, criteria, args.limit ? Number(args.limit) : 10); + return { user, count: messages.length, messages }; + }, + }, + ]; +} + +// The tools exist only when the admin API is configured; without it, mailu contributes none rather +// than failing the whole runtime. +registerModuleTools("mailu", (env) => { + try { + return getMailuTools(MailuClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/mailu/tsconfig.json b/modules/mailu/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/mailu/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/minio/client.ts b/modules/minio/client.ts new file mode 100644 index 0000000..4086b58 --- /dev/null +++ b/modules/minio/client.ts @@ -0,0 +1,352 @@ +// The MinIO admin client — minio's own code, living in the module (novox/hq ADR 0044). Ported out +// of the shared hal sdk, where a change to MinIO's surface rebuilt everything; here it rebuilds only +// minio. This module's tools, its provisioner and its events entrypoint import it; nothing outside +// minio does. +// +// It speaks two planes with node built-ins only (never the `minio` npm package): +// - the S3 data plane over `fetch`, signed with AWS Signature V4 (node:crypto) — bucket and object +// operations, and presigned URLs; +// - the admin plane through the `mc` CLI (node:child_process) — scoped service accounts, whose +// creation the MinIO admin REST API guards behind an encrypted payload `fetch` cannot form. +// This mirrors hal's MinIOClient/MinIOAdmin split, folded into one client the module builds from env. + +import { createHash, createHmac } from "node:crypto"; +import { execFile } from "node:child_process"; +import { readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface MinioBucket { + name: string; + creationDate?: Date; +} + +export interface MinioObject { + name: string; + size: number; + lastModified: Date; + etag?: string; +} + +export interface MinioObjectStat { + size: number; + lastModified: Date; + etag?: string; + contentType?: string; +} + +export interface MinioBucketInfo { + name: string; + exists: boolean; + region: string; + /** Sampled from the first page of a listing (up to 1000 keys) — a summary, not an audit. */ + sampledObjects: number; + sampledBytes: number; +} + +/** A scoped credential a consumer receives: an access key/secret pair confined to one bucket. */ +export interface AccessKey { + accessKey: string; + secretKey: string; +} + +interface MinioOptions { + endpoint: string; + rootUser: string; + rootPassword: string; + region: string; + mcBin: string; + mcConfigDir: string; +} + +export class MinioClient { + readonly baseUrl: string; + readonly region: string; + private readonly rootUser: string; + private readonly rootPassword: string; + private readonly mcBin: string; + private readonly mcConfigDir: string; + private aliasReady = false; + + constructor(opts: MinioOptions) { + this.baseUrl = opts.endpoint.replace(/\/$/, ""); + this.region = opts.region; + this.rootUser = opts.rootUser; + this.rootPassword = opts.rootPassword; + this.mcBin = opts.mcBin; + this.mcConfigDir = opts.mcConfigDir; + } + + /** + * Build from the module's resolved environment. The endpoint and root identity come from + * MESH_MINIO_*; the password may be given inline or as a mounted secret file (the manifest mounts + * root.secret), so the provisioner container needs nothing written by hand. Throws when + * unconfigured — an object store the module cannot reach is not a usable client. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): MinioClient { + const endpoint = env.MESH_MINIO_ENDPOINT; + const rootUser = env.MESH_MINIO_ROOT_USER; + const passwordFile = env.MESH_MINIO_ROOT_PASSWORD_FILE; + const rootPassword = env.MESH_MINIO_ROOT_PASSWORD ?? (passwordFile ? readFileSync(passwordFile, "utf8").trim() : undefined); + if (!endpoint || !rootUser || !rootPassword) { + throw new Error("minio is not configured — set MESH_MINIO_ENDPOINT, MESH_MINIO_ROOT_USER and MESH_MINIO_ROOT_PASSWORD"); + } + return new MinioClient({ + endpoint, + rootUser, + rootPassword, + region: env.MESH_MINIO_REGION ?? "us-east-1", + mcBin: env.MESH_MINIO_MC_BIN ?? "mc", + mcConfigDir: env.MESH_MINIO_MC_CONFIG ?? join(tmpdir(), ".mc-mesh"), + }); + } + + // --- S3 data plane (signed fetch) --------------------------------------- + + async listBuckets(): Promise { + const { status, text } = await this.request("GET", "/"); + if (status !== 200) throw new Error(`minio listBuckets: ${status} ${text}`); + const out: MinioBucket[] = []; + const re = /\s*([^<]+)<\/Name>\s*([^<]*)<\/CreationDate>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + out.push({ name: m[1], creationDate: m[2] ? new Date(m[2]) : undefined }); + } + return out; + } + + async bucketExists(bucket: string): Promise { + const { status } = await this.request("HEAD", `/${bucket}`); + if (status === 200) return true; + if (status === 404) return false; + throw new Error(`minio bucketExists ${bucket}: ${status}`); + } + + async createBucket(bucket: string): Promise { + const { status, text } = await this.request("PUT", `/${bucket}`); + // 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail. + if (status !== 200 && status !== 409) throw new Error(`minio createBucket ${bucket}: ${status} ${text}`); + } + + async removeBucket(bucket: string): Promise { + const { status, text } = await this.request("DELETE", `/${bucket}`); + // 204 removed; 404 already gone — removal is idempotent too. + if (status !== 204 && status !== 404) throw new Error(`minio removeBucket ${bucket}: ${status} ${text}`); + } + + async listObjects(bucket: string, prefix = "", recursive = false, maxKeys = 100): Promise { + const query: Record = { "list-type": "2", "max-keys": String(maxKeys) }; + if (prefix) query.prefix = prefix; + if (!recursive) query.delimiter = "/"; + const { status, text } = await this.request("GET", `/${bucket}`, query); + if (status !== 200) throw new Error(`minio listObjects ${bucket}: ${status} ${text}`); + const out: MinioObject[] = []; + for (const block of text.split("").slice(1)) { + const key = tag(block, "Key"); + if (!key) continue; + out.push({ + name: key, + size: Number(tag(block, "Size") ?? "0"), + lastModified: new Date(tag(block, "LastModified") ?? 0), + etag: tag(block, "ETag")?.replace(/"|"/g, ""), + }); + } + return out; + } + + async statObject(bucket: string, object: string): Promise { + const { status, headers } = await this.request("HEAD", `/${bucket}/${object}`); + if (status !== 200) throw new Error(`minio statObject ${bucket}/${object}: ${status}`); + const lm = headers.get("last-modified"); + return { + size: Number(headers.get("content-length") ?? "0"), + lastModified: lm ? new Date(lm) : new Date(0), + etag: headers.get("etag")?.replace(/"/g, "") ?? undefined, + contentType: headers.get("content-type") ?? undefined, + }; + } + + async bucketInfo(bucket: string): Promise { + const exists = await this.bucketExists(bucket); + if (!exists) return { name: bucket, exists: false, region: this.region, sampledObjects: 0, sampledBytes: 0 }; + const objects = await this.listObjects(bucket, "", true, 1000); + return { + name: bucket, + exists: true, + region: this.region, + sampledObjects: objects.length, + sampledBytes: objects.reduce((n, o) => n + o.size, 0), + }; + } + + /** A time-limited URL for GET (download) or PUT (upload) of one object — query-string SigV4. */ + presignedUrl(method: "GET" | "PUT", bucket: string, object: string, expires = 86400): string { + const { amzDate, dateStamp } = this.stamp(); + const scope = `${dateStamp}/${this.region}/s3/aws4_request`; + const host = new URL(this.baseUrl).host; + const params: Record = { + "X-Amz-Algorithm": "AWS4-HMAC-SHA256", + "X-Amz-Credential": `${this.rootUser}/${scope}`, + "X-Amz-Date": amzDate, + "X-Amz-Expires": String(expires), + "X-Amz-SignedHeaders": "host", + }; + const path = uriEncode(`/${bucket}/${object}`, false); + const canonicalQuery = encodeQuery(params); + const canonicalRequest = [method, path, canonicalQuery, `host:${host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n"); + const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); + const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); + return `${this.baseUrl}${path}?${canonicalQuery}&X-Amz-Signature=${signature}`; + } + + // --- admin plane (mc CLI) ------------------------------------------------ + + /** + * Create a service account scoped to one bucket, under a given access key and secret key, and + * return the pair. The secret key is the mesh's — the mesh mints one password per consumer and + * hands a copy to both ends (novox/hq ADR 0053), so minio sets that as the secret rather than + * generating one the consumer could never learn. The MinIO admin REST API encrypts this request + * with a key derived (Argon2) from the root secret, which node built-ins cannot reproduce — so, as + * hal did, the module drives the `mc` CLI, which the runtime image bundles. + */ + async createAccessKey(bucket: string, accessKey: string, secretKey: string): Promise { + await this.ensureAlias(); + const policyPath = join(this.mcConfigDir, `policy-${accessKey}.json`); + writeFileSync(policyPath, bucketPolicy(bucket), { mode: 0o600 }); + try { + await this.mc( + "admin", "user", "svcacct", "add", "mesh", this.rootUser, + "--access-key", accessKey, + "--secret-key", secretKey, + "--policy", policyPath, + ); + } finally { + try { unlinkSync(policyPath); } catch { /* best effort */ } + } + return { accessKey, secretKey }; + } + + async removeAccessKey(accessKey: string): Promise { + await this.ensureAlias(); + await this.mc("admin", "user", "svcacct", "rm", "mesh", accessKey); + } + + private async ensureAlias(): Promise { + if (this.aliasReady) return; + await this.mc("alias", "set", "mesh", this.baseUrl, this.rootUser, this.rootPassword); + this.aliasReady = true; + } + + private async mc(...args: string[]): Promise { + const { stdout } = await execFileAsync(this.mcBin, ["--config-dir", this.mcConfigDir, ...args], { timeout: 30_000 }); + return stdout.trim(); + } + + // --- SigV4 request plumbing --------------------------------------------- + + private async request( + method: string, + path: string, + query: Record = {}, + ): Promise<{ status: number; headers: Headers; text: string }> { + const { amzDate, dateStamp } = this.stamp(); + const host = new URL(this.baseUrl).host; + const payloadHash = sha256hex(""); // no request bodies are sent on this client + const encodedPath = uriEncode(path, false); + const canonicalQuery = encodeQuery(query); + const signedHeaders = "host;x-amz-content-sha256;x-amz-date"; + const canonicalHeaders = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`; + const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n"); + const scope = `${dateStamp}/${this.region}/s3/aws4_request`; + const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); + const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); + const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + + const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`; + const res = await fetch(url, { + method, + // `host` is set by fetch from the URL; the wire header matches what we signed. + headers: { Authorization: authorization, "x-amz-content-sha256": payloadHash, "x-amz-date": amzDate }, + }); + const text = method === "HEAD" ? "" : await res.text(); + return { status: res.status, headers: res.headers, text }; + } + + private signingKey(dateStamp: string): Buffer { + const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp); + const kRegion = hmac(kDate, this.region); + const kService = hmac(kRegion, "s3"); + return hmac(kService, "aws4_request"); + } + + private stamp(): { amzDate: string; dateStamp: string } { + const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ""); + return { amzDate, dateStamp: amzDate.slice(0, 8) }; + } +} + +// --- module-scoped helpers ------------------------------------------------- + +/** A deterministic 20-char access key id from a consumer name, so removal needs no stored state: + * the provisioner recomputes the same id at teardown that it minted at creation. */ +export function accessKeyFor(consumer: string): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const digest = createHash("sha256").update(consumer).digest(); + let out = ""; + for (let i = 0; i < 20; i++) out += chars[digest[i] % chars.length]; + return out; +} + +/** A DNS-safe bucket name derived from a consumer — the removable identity of its storage. */ +export function bucketFor(consumer: string): string { + const name = consumer.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63); + return name.length >= 3 ? name : `mesh-${name}`; +} + +function bucketPolicy(bucket: string): string { + return JSON.stringify({ + Version: "2012-10-17", + Statement: [{ + Effect: "Allow", + Action: ["s3:*"], + Resource: [`arn:aws:s3:::${bucket}`, `arn:aws:s3:::${bucket}/*`], + }], + }); +} + +function tag(xml: string, name: string): string | undefined { + const m = new RegExp(`<${name}>([^<]*)`).exec(xml); + return m ? m[1] : undefined; +} + +function hmac(key: Buffer | string, data: string): Buffer { + return createHmac("sha256", key).update(data, "utf8").digest(); +} + +function sha256hex(data: string): string { + return createHash("sha256").update(data, "utf8").digest("hex"); +} + +/** AWS canonical query: each key/value URI-encoded (slash included), sorted by encoded key. */ +function encodeQuery(params: Record): string { + return Object.keys(params) + .map((k) => [uriEncode(k, true), uriEncode(params[k], true)] as const) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([k, v]) => `${k}=${v}`) + .join("&"); +} + +/** RFC 3986 URI encoding, byte-correct via UTF-8. Path callers keep "/" literal; query callers don't. */ +function uriEncode(str: string, encodeSlash: boolean): string { + let out = ""; + for (const byte of Buffer.from(str, "utf8")) { + const c = String.fromCharCode(byte); + if (/[A-Za-z0-9_.~-]/.test(c)) out += c; + else if (c === "/" && !encodeSlash) out += c; + else out += "%" + byte.toString(16).toUpperCase().padStart(2, "0"); + } + return out; +} diff --git a/modules/minio/module.json b/modules/minio/module.json index 12c285a..2b11d43 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -10,6 +10,10 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.minio.bucket.created", + "module.minio.bucket.removed" + ], "listens": [ { "port": 9000, @@ -31,9 +35,16 @@ "s3-bucket": "/var/lib/minio/grants" }, "own-secrets": { - "root": "/var/lib/minio/root.secret" + "root": "/var/lib/minio/root.secret", + "broker": "/var/lib/mesh/minio/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/minio", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -87,21 +98,23 @@ ] }, { - "id": "provisioner", + "id": "runtime", "type": "container", - "name": "mesh-provision-objectstore", - "image": "mesh-provision-objectstore@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-minio", + "image": "mesh-runtime-minio@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "minio", - "env": { - "GRANTS": "/var/lib/minio/grants", - "MESH_OBJECTSTORE_URL": "http://minio:9000", - "MESH_OBJECTSTORE_ROOT_USER": "meshroot", - "MESH_OBJECTSTORE_ROOT_PASSWORD_FILE": "/run/secrets/root" - }, "volumes": [ + "/var/lib/mesh/minio/broker:/run/secrets/broker:ro", "/var/lib/minio/grants:/var/lib/minio/grants:ro", "/var/lib/minio/root.secret:/run/secrets/root:ro" - ] + ], + "env": { + "MESH_MINIO_ENDPOINT": "http://minio:9000", + "MESH_MINIO_ROOT_USER": "meshroot", + "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECEIVES": "/var/lib/minio/grants/mesh.json" + } } ] } diff --git a/modules/minio/package.json b/modules/minio/package.json new file mode 100644 index 0000000..c1839d6 --- /dev/null +++ b/modules/minio/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-minio", + "version": "0.1.0", + "description": "minio — S3-compatible object store. Its admin client, tools, provisioner and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts new file mode 100644 index 0000000..dd5957b --- /dev/null +++ b/modules/minio/provisioner/index.ts @@ -0,0 +1,66 @@ +// minio's provisioner — the adapter that makes minio a provider of the mesh `s3-bucket` interface +// (the name in module.json's `provides`). 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 minio +// creates and removes a consumer's bucket and its scoped access key (novox/hq ADR 0044/0045/0053). +// +// The `s3-bucket` interface: a consumer connects to an S3 endpoint with an access key confined to +// its own bucket. It depends on `s3-bucket`, not on minio, so any S3-compatible provider could serve +// it. +// +// **The access key and its secret are the mesh's, not the provisioner's (ADR 0053).** The mesh +// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio +// creates the service account under exactly that access key with exactly that secret — a credential +// the provisioner invented is one the consumer could never present. The bucket is derived from the +// login, so teardown recomputes it with nothing to persist. + +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { MinioClient, bucketFor } from "../client.js"; + +const minio = MinioClient.fromEnv(); + +runProvisioner("s3-bucket", { + async create(p: Provision): Promise { + const bucket = bucketFor(p.as); + const accessKeyId = p.as; + + if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket); + // Re-mint the scoped key idempotently: drop any prior one under this id, then add it back with + // the mesh's secret. + try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ } + await minio.createAccessKey(bucket, accessKeyId, p.password); + + await announce("module.minio.bucket.created", { + bucket, + consumer: p.consumer ?? "", + accessKey: accessKeyId, + endpoint: minio.baseUrl, + }); + }, + + async remove(p: { as: string }): Promise { + const bucket = bucketFor(p.as); + + // Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if + // empty; a bucket that still holds objects is left for an operator rather than erroring on every + // reconcile tick — access is already gone, and silently deleting a consumer's data would be worse. + try { await minio.removeAccessKey(p.as); } catch { /* already gone */ } + try { + await minio.removeBucket(bucket); + } catch (err) { + console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); + } + + await announce("module.minio.bucket.removed", { bucket, accessKey: p.as }); + }, +}); + +/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a + * bucket that was made. */ +async function announce(type: string, body: unknown): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[minio] could not emit ${type}: ${err}`); + } +} diff --git a/modules/minio/tools/index.ts b/modules/minio/tools/index.ts new file mode 100644 index 0000000..dc218b5 --- /dev/null +++ b/modules/minio/tools/index.ts @@ -0,0 +1,80 @@ +// minio's tools — ported here from the shared sdk (novox/hq ADR 0044), importing minio's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. These are the +// read/inspect operations useful to an operator; creating storage for a consumer is the provisioner's +// job, not a tool's. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { MinioClient } from "../client.js"; + +export function getMinioTools(minio: MinioClient): ToolDefinition[] { + return [ + { + name: "minio_list_buckets", + description: "List every S3 bucket in the object store, with creation dates.", + input: {}, + run: async () => { + const buckets = await minio.listBuckets(); + return { + count: buckets.length, + buckets: buckets.map((b) => ({ name: b.name, createdAt: b.creationDate?.toISOString() })), + }; + }, + }, + { + name: "minio_list_objects", + description: "List objects in a bucket, optionally under a prefix.", + input: { + bucket: { type: "string", description: "the bucket name" }, + prefix: { type: "string", description: "only keys under this prefix (e.g. 'photos/')" }, + recursive: { type: "boolean", description: "descend into nested prefixes (default false)" }, + limit: { type: "number", description: "max keys to return (default 100)" }, + }, + run: async (args) => { + const bucket = String(args.bucket); + const objects = await minio.listObjects( + bucket, + args.prefix ? String(args.prefix) : "", + Boolean(args.recursive), + args.limit ? Number(args.limit) : 100, + ); + return { + bucket, + count: objects.length, + objects: objects.map((o) => ({ key: o.name, size: o.size, lastModified: o.lastModified.toISOString(), etag: o.etag })), + }; + }, + }, + { + name: "minio_bucket_info", + description: "Summary of one bucket: whether it exists, its region, and a sampled object count and size.", + input: { bucket: { type: "string", description: "the bucket name" } }, + run: async (args) => minio.bucketInfo(String(args.bucket)), + }, + { + name: "minio_presigned_url", + description: "A time-limited URL to download (GET) or upload (PUT) one object without credentials.", + input: { + bucket: { type: "string", description: "the bucket name" }, + object: { type: "string", description: "the object key" }, + method: { type: "string", description: "'GET' to download (default) or 'PUT' to upload" }, + expires: { type: "number", description: "seconds until the URL expires (default 86400 = 24h)" }, + }, + run: async (args) => { + const method = String(args.method ?? "GET").toUpperCase() === "PUT" ? "PUT" : "GET"; + const expires = args.expires ? Number(args.expires) : 86400; + const url = minio.presignedUrl(method, String(args.bucket), String(args.object), expires); + return { method, bucket: String(args.bucket), object: String(args.object), expiresInSeconds: expires, url }; + }, + }, + ]; +} + +// The tools exist only when the object store is configured and reachable; without it minio +// contributes none rather than failing the whole tool runtime. +registerModuleTools("minio", (env) => { + try { + return getMinioTools(MinioClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/minio/tsconfig.json b/modules/minio/tsconfig.json new file mode 100644 index 0000000..c2a8df0 --- /dev/null +++ b/modules/minio/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts", + "provisioner/index.ts" + ] +} \ No newline at end of file diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts new file mode 100644 index 0000000..b860680 --- /dev/null +++ b/modules/nextcloud/client.ts @@ -0,0 +1,102 @@ +// Nextcloud's client — nextcloud's own code, living in the module (novox/hq ADR 0044). Both this +// module's tools and its events entrypoint import it, and nothing outside nextcloud does. +// +// Nextcloud is administered two ways, and this client speaks both: +// - occ, its admin CLI, is a PHP script inside the container runnable only as the web user. We +// reach it with `docker exec`, the same side channel an operator would use by hand — turned +// into something the mesh can call. Users and apps come from here. +// - the OCS Sharing API answers over HTTP with the admin credentials. Shares come from here, +// because occ has no version-stable "list every share" across the releases we run. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +export interface NextcloudUser { + uid: string; + displayName: string; +} + +export interface NextcloudShare { + /** The OCS share id — the stable identity a new share is diffed on. */ + id: string; + path: string; + shareType: number; + shareWith?: string; + owner: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class NextcloudClient { + constructor( + private readonly container: string, + private readonly ocsUrl: string, + private readonly adminUser: string, + private readonly adminPassword: string, + ) {} + + /** + * Build from the module's resolved environment. occ needs only the container name (default + * "nextcloud"); the OCS surface needs the admin password the module keeps as its own secret + * (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default admin, URL the local + * container). The admin password is treated as the "configured for mesh administration" signal: + * throws without it, and the module then contributes nothing rather than failing. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient { + const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE); + const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; + const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; + const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; + const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD; + if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD"); + return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword); + } + + /** Run occ inside the container as the web user, returning its stdout, throwing its own message. */ + occ(args: string[]): string { + try { + return execFileSync("docker", ["exec", "-u", "www-data", this.container, "php", "occ", ...args], { + encoding: "utf8", timeout: 60_000, + }).trim(); + } catch (err: any) { + const detail = String(err?.stderr ?? err?.stdout ?? err?.message ?? "").trim(); + throw new Error(detail || `occ produced no output — is the ${this.container} container running?`); + } + } + + listUsers(): NextcloudUser[] { + // user:list --output=json answers an object of uid → display name. + const raw = this.occ(["user:list", "--output=json"]); + const map = JSON.parse(raw || "{}") as Record; + return Object.entries(map).map(([uid, displayName]) => ({ uid, displayName })); + } + + listApps(): { enabled: string[]; disabled: string[] } { + const raw = this.occ(["app:list", "--output=json"]); + const parsed = JSON.parse(raw || "{}") as { enabled?: Record; disabled?: Record }; + return { enabled: Object.keys(parsed.enabled ?? {}), disabled: Object.keys(parsed.disabled ?? {}) }; + } + + /** List every share, over the OCS Sharing API with the admin credentials. */ + async listShares(): Promise { + const auth = Buffer.from(`${this.adminUser}:${this.adminPassword}`).toString("base64"); + const res = await fetch( + `${this.ocsUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, + { headers: { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true", Accept: "application/json" } }, + ); + if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${await res.text()}`); + const rows = ((await res.json())?.ocs?.data ?? []) as any[]; + return rows.map((s) => ({ + id: String(s.id), + path: s.path ?? "", + shareType: Number(s.share_type ?? -1), + shareWith: s.share_with || undefined, + owner: s.uid_owner ?? "unknown", + })); + } +} diff --git a/modules/nextcloud/index.ts b/modules/nextcloud/index.ts new file mode 100644 index 0000000..3fc26f1 --- /dev/null +++ b/modules/nextcloud/index.ts @@ -0,0 +1,57 @@ +// nextcloud's events. The tool runtime imports this once the broker is bound. It watches the user +// list and the share list and announces new arrivals. +// +// Emits (novox/hq ADR 0046/0047): +// module.nextcloud.user.created — a user account appeared (occ user:list) +// module.nextcloud.share.created — a share appeared (OCS shares) +// +// Both are diffed and primed silently on the first look, so a restart does not re-announce every +// existing user and share as freshly created. + +import { emit } from "@novox/mesh-sdk/events"; +import { NextcloudClient } from "./client.js"; + +// Constructed lazily so an unconfigured node (no admin password) loads this entrypoint without +// crashing the events host — it simply watches nothing. +let nextcloud: NextcloudClient | undefined; +try { + nextcloud = NextcloudClient.fromEnv(); +} catch (err) { + console.log(`[nextcloud] not configured, not watching: ${err}`); +} + +const knownUsers = new Set(); +let usersPrimed = false; +async function pollUsers(client: NextcloudClient): Promise { + const users = client.listUsers(); + for (const u of users) { + if (knownUsers.has(u.uid)) continue; + if (usersPrimed) await emit("module.nextcloud.user.created", { uid: u.uid, displayName: u.displayName }); + knownUsers.add(u.uid); + } + usersPrimed = true; +} + +const knownShares = new Set(); +let sharesPrimed = false; +async function pollShares(client: NextcloudClient): Promise { + const shares = await client.listShares(); + for (const s of shares) { + if (knownShares.has(s.id)) continue; + if (sharesPrimed) await emit("module.nextcloud.share.created", { id: s.id, path: s.path, shareType: s.shareType, shareWith: s.shareWith, owner: s.owner }); + knownShares.add(s.id); + } + sharesPrimed = true; +} + +if (nextcloud) { + const client = nextcloud; + const tick = (fn: (c: NextcloudClient) => Promise): void => { + const run = (): void => void fn(client).catch((err) => console.error(`[nextcloud] ${err}`)); + setInterval(run, 60_000); + run(); + }; + tick(pollUsers); + tick(pollShares); + console.log("[nextcloud] watching users and shares"); +} diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index 20faa8d..1544b10 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -21,8 +21,13 @@ "postgres-database": "/var/lib/nextcloud-module/database.secret", "s3-bucket": "/var/lib/nextcloud-module/store.secret" }, + "emits": [ + "module.nextcloud.user.created", + "module.nextcloud.share.created" + ], "own-secrets": { - "admin": "/var/lib/nextcloud-module/admin.secret" + "admin": "/var/lib/nextcloud-module/admin.secret", + "broker": "/var/lib/mesh/nextcloud/broker" }, "capabilities": [ "container-runtime" @@ -36,6 +41,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/nextcloud", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -70,6 +81,34 @@ "volumes": [ "/services/nextcloud/html:/var/www/html" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/nextcloud/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-nextcloud", + "image": "mesh-runtime-nextcloud@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro", + "/var/run/docker.sock:/var/run/docker.sock" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_NEXTCLOUD_URL": "http://127.0.0.1:80", + "MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/nextcloud/package.json b/modules/nextcloud/package.json new file mode 100644 index 0000000..841026f --- /dev/null +++ b/modules/nextcloud/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-nextcloud", + "version": "0.1.0", + "description": "nextcloud — file sync and share. Its client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/nextcloud/tools/index.ts b/modules/nextcloud/tools/index.ts new file mode 100644 index 0000000..224e8b7 --- /dev/null +++ b/modules/nextcloud/tools/index.ts @@ -0,0 +1,57 @@ +// nextcloud's tools — importing nextcloud's own client (novox/hq ADR 0044). occ runs inside the +// container; shares come over OCS. They return structured data; the mesh serves them through the +// sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { NextcloudClient } from "../client.js"; + +export function getNextcloudTools(nextcloud: NextcloudClient): ToolDefinition[] { + return [ + { + name: "nextcloud_users", + description: "List Nextcloud user accounts — uid and display name — via occ.", + input: {}, + run: async () => { + const users = nextcloud.listUsers(); + return { count: users.length, users }; + }, + }, + { + name: "nextcloud_shares", + description: "List Nextcloud shares — path, type, who it is shared with — via the OCS API.", + input: {}, + run: async () => { + const shares = await nextcloud.listShares(); + return { count: shares.length, shares }; + }, + }, + { + name: "nextcloud_apps", + description: "List Nextcloud apps, split into enabled and disabled, via occ.", + input: {}, + run: async () => nextcloud.listApps(), + }, + { + name: "nextcloud_occ", + description: + "Run an arbitrary occ admin command, e.g. status, 'config:system:get trusted_domains', " + + "user:list. occ is Nextcloud's CLI inside the container, run as the web user.", + input: { args: { type: "array", description: 'occ arguments, e.g. ["config:system:get","trusted_domains"]' } }, + run: async (args) => { + const occArgs = (args.args ?? []) as unknown[]; + if (!Array.isArray(occArgs) || occArgs.length === 0) throw new Error('args must be a non-empty array, e.g. ["status"]'); + return { output: nextcloud.occ(occArgs.map(String)) || "(no output)" }; + }, + }, + ]; +} + +// The tools exist only when the admin password can be resolved; without it, nextcloud contributes +// none rather than failing the whole tool runtime. +registerModuleTools("nextcloud", (env) => { + try { + return getNextcloudTools(NextcloudClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/nextcloud/tsconfig.json b/modules/nextcloud/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/nextcloud/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/nodered/client.ts b/modules/nodered/client.ts new file mode 100644 index 0000000..d760ca6 --- /dev/null +++ b/modules/nodered/client.ts @@ -0,0 +1,96 @@ +// Node-RED's admin-API client — nodered's own code, living in the module (novox/hq ADR 0044). Only +// this module's tools import it; nodered has nothing to poll, so there is no events entrypoint. +// +// Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow +// configuration, GET /nodes for installed node modules. A default install has no auth; when +// adminAuth is on, a bearer token (minted at /auth/token) is required. + +import { readFileSync } from "node:fs"; + +export interface NodeRedFlow { + /** The tab (flow) node id. */ + id: string; + label: string; + disabled: boolean; +} + +export interface NodeRedNodeModule { + name: string; + version: string; + types: string[]; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class NodeRedClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly token?: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. MESH_NODERED_URL locates the admin API and is the + * "this node runs Node-RED" signal — throws when unset, and the module then contributes nothing + * rather than failing on every node. MESH_NODERED_TOKEN is the bearer token when adminAuth is on; + * a default install needs none. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient { + const cfg = meshConfig(env.MESH_NODERED_CONFIG_FILE); + const url = cfg.url ?? env.MESH_NODERED_URL; + if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL"); + return new NodeRedClient(url, cfg.token ?? env.MESH_NODERED_TOKEN); + } + + private headers(extra: Record = {}): Record { + return { Accept: "application/json", ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...extra }; + } + + private async req(path: string, init: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl}${path}`, init); + if (!res.ok) throw new Error(`Node-RED ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + /** The full flow configuration — the flat array of every node across every tab. */ + async getConfig(): Promise { + const body = await this.req("/flows", { headers: this.headers() }); + // /flows answers a bare array by default, or { rev, flows } to a v2-aware client. + return Array.isArray(body) ? body : (body.flows ?? []); + } + + /** The tabs (flows), each a node of type "tab" in the configuration. */ + async listFlows(): Promise<{ flows: NodeRedFlow[]; nodeCount: number }> { + const config = await this.getConfig(); + const flows = config + .filter((n) => n.type === "tab") + .map((n) => ({ id: n.id, label: n.label ?? "(unnamed)", disabled: !!n.disabled })); + return { flows, nodeCount: config.length }; + } + + async listNodes(): Promise { + const modules = (await this.req("/nodes", { headers: this.headers() })) as any[]; + return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] })); + } + + /** + * Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to + * Node-RED's deployment types — "full" (default), "nodes", or "flows". + */ + async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> { + const body = await this.req("/flows", { + method: "POST", + headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }), + body: JSON.stringify(config), + }); + return { rev: body?.rev, nodeCount: config.length }; + } +} diff --git a/modules/nodered/module.json b/modules/nodered/module.json index b5d90d9..a512948 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -1,6 +1,12 @@ { "module": "nodered", "version": "1", + "emits": [ + "module.nodered.flows.deployed" + ], + "own-secrets": { + "broker": "/var/lib/mesh/nodered/broker" + }, "capabilities": [ "container-runtime" ], @@ -13,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/nodered", + "mode": "0700" + }, { "id": "data", "type": "directory", @@ -34,6 +46,33 @@ "volumes": [ "/services/nodered/data:/data" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/nodered/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-nodered", + "image": "mesh-runtime-nodered@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/nodered/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nodered/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_NODERED_URL": "http://127.0.0.1:1880", + "MESH_NODERED_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/nodered/package.json b/modules/nodered/package.json new file mode 100644 index 0000000..cfa5768 --- /dev/null +++ b/modules/nodered/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-nodered", + "version": "0.1.0", + "description": "nodered — flow-based automation. Its client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/nodered/tools/index.ts b/modules/nodered/tools/index.ts new file mode 100644 index 0000000..bd6c636 --- /dev/null +++ b/modules/nodered/tools/index.ts @@ -0,0 +1,64 @@ +// nodered's tools — importing nodered's own admin-API client (novox/hq ADR 0044). They return +// structured data; the mesh serves them through the sdk's tool harness. +// +// The deploy tool is nodered's one event source (novox/hq ADR 0046/0047): a successful deploy +// emits module.nodered.flows.deployed. nodered has nothing to observe on a timer, so there is no +// separate events entrypoint — the emit rides the action that causes it. The emit is best-effort: +// if no broker is bound, the deploy still succeeds and the announcement is simply skipped. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { emit } from "@novox/mesh-sdk/events"; +import { NodeRedClient } from "../client.js"; + +export function getNodeRedTools(nodered: NodeRedClient): ToolDefinition[] { + return [ + { + name: "nodered_list_flows", + description: "List Node-RED flows (tabs) — id, label, disabled state — and the total node count.", + input: {}, + run: async () => nodered.listFlows(), + }, + { + name: "nodered_list_nodes", + description: "List the Node-RED node modules installed in the runtime and their versions.", + input: {}, + run: async () => { + const nodes = await nodered.listNodes(); + return { count: nodes.length, nodes }; + }, + }, + { + name: "nodered_deploy", + description: + "Replace the whole Node-RED flow configuration and deploy it. `flows` is the full node " + + "array (as GET /flows returns). Emits module.nodered.flows.deployed on success.", + input: { + flows: { type: "array", description: "the full flow configuration — every node across every tab" }, + type: { type: "string", description: "deployment type: full (default), nodes, or flows" }, + }, + run: async (args) => { + const flows = args.flows as unknown[]; + if (!Array.isArray(flows)) throw new Error("flows must be an array of Node-RED nodes"); + const type = args.type ? String(args.type) : "full"; + const result = await nodered.deployFlows(flows, type); + // Best-effort announcement — a deploy must not fail because the broker is unbound here. + try { + await emit("module.nodered.flows.deployed", { rev: result.rev, nodeCount: result.nodeCount, type }); + } catch (err) { + console.error(`[nodered] deployed but could not emit: ${err}`); + } + return result; + }, + }, + ]; +} + +// The tools exist only when a Node-RED URL is configured; without one, nodered contributes none +// rather than failing the whole tool runtime. +registerModuleTools("nodered", (env) => { + try { + return getNodeRedTools(NodeRedClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/nodered/tsconfig.json b/modules/nodered/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/nodered/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} diff --git a/modules/nzbget/client.ts b/modules/nzbget/client.ts new file mode 100644 index 0000000..e217387 --- /dev/null +++ b/modules/nzbget/client.ts @@ -0,0 +1,170 @@ +// The NZBGet API client — nzbget's own code, living in the module (novox/hq ADR 0044). Ported from +// hal's shared nzbget tools, but self-contained: a change to NZBGet's JSON-RPC now rebuilds only +// nzbget and nothing else. Both this module's tools and its events entrypoint import it, and +// nothing outside nzbget does. NZBGet speaks JSON-RPC at /jsonrpc, behind HTTP Basic auth. + +import { readFileSync } from "node:fs"; + +export interface NzbgetStatus { + /** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */ + speedBytesPerSec: number; + remainingMB: number; + downloadedTodayMB: number; + downloadedMonthMB: number; + freeDiskMB: number; + paused: boolean; + postJobs: number; + uptimeSec: number; +} + +export interface NzbgetQueueItem { + /** The NZBID — stable while the item is queued, so events can diff on it. */ + id: number; + name: string; + status: string; + category: string; + sizeMB: number; + remainingMB: number; + percent: number; +} + +export interface NzbgetHistoryItem { + /** The NZBID — the same id the item carried in the queue. */ + id: number; + name: string; + /** NZBGet's own status string, e.g. "SUCCESS/ALL", "FAILURE/PAR", "DELETED/MANUAL". */ + status: string; + category: string; + sizeMB: number; + /** A genuine completion (status starts "SUCCESS") vs a failed or deleted entry — the difference + * between something to announce as done and something that merely left the queue. */ + success: boolean; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class NzbgetClient { + readonly rpcUrl: string; + private readonly auth: string; + + constructor(url: string, user: string, password: string) { + this.rpcUrl = `${url.replace(/\/$/, "")}/jsonrpc`; + this.auth = Buffer.from(`${user}:${password}`).toString("base64"); + } + + /** + * Build from the module's resolved environment. URL and password are read from MESH_NZBGET_URL + * and MESH_NZBGET_PASSWORD; both must be present — an unconfigured NZBGet throws rather than + * pretend to be reachable, so the tools/events simply do not load (the harness treats the throw + * as "exposes nothing"). The control username defaults to "nzbget", NZBGet's own default. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient { + const cfg = meshConfig(env.MESH_NZBGET_CONFIG_FILE); + const url = cfg.url ?? env.MESH_NZBGET_URL; + const password = cfg.password ?? env.MESH_NZBGET_PASSWORD; + if (!url || !password) { + throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD"); + } + const user = cfg.user ?? env.MESH_NZBGET_USER ?? "nzbget"; + return new NzbgetClient(url, user, password); + } + + private async rpc(method: string, params: unknown[] = []): Promise { + const res = await fetch(this.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Basic ${this.auth}` }, + body: JSON.stringify({ method, params, id: 1 }), + }); + if (!res.ok) throw new Error(`NZBGet API ${method}: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { result?: T; error?: unknown }; + if (data.error) throw new Error(`NZBGet RPC ${method}: ${JSON.stringify(data.error)}`); + return data.result as T; + } + + async getVersion(): Promise { + return this.rpc("version"); + } + + async getStatus(): Promise { + const s = await this.rpc>("status"); + const lo = Number(s.DownloadRateLo ?? 0); + const hi = Number(s.DownloadRateHi ?? 0); + return { + speedBytesPerSec: lo + hi * 4294967296, + remainingMB: Number(s.RemainingSizeMB ?? 0), + downloadedTodayMB: Number(s.DaySizeMB ?? 0), + downloadedMonthMB: Number(s.MonthSizeMB ?? 0), + freeDiskMB: Number(s.FreeDiskSpaceMB ?? 0), + paused: Boolean(s.DownloadPaused), + postJobs: Number(s.PostJobCount ?? 0), + uptimeSec: Number(s.UpTimeSec ?? 0), + }; + } + + async getQueue(): Promise { + const groups = await this.rpc[]>("listgroups", [0]); + return groups.map((g) => { + const size = Number(g.FileSizeMB ?? 0); + const remaining = Number(g.RemainingSizeMB ?? 0); + return { + id: Number(g.NZBID), + name: String(g.NZBName ?? "Unknown"), + status: String(g.Status ?? "unknown"), + category: String(g.Category ?? ""), + sizeMB: size, + remainingMB: remaining, + percent: size > 0 ? Math.round(((size - remaining) / size) * 100) : 0, + }; + }); + } + + async getHistory(limit = 20): Promise { + const history = await this.rpc[]>("history", [false]); + return history.slice(0, limit).map((h) => { + const status = String(h.Status ?? ""); + return { + id: Number(h.NZBID), + name: String(h.Name ?? "Unknown"), + status, + category: String(h.Category ?? ""), + sizeMB: Number(h.FileSizeMB ?? 0), + success: status.startsWith("SUCCESS"), + }; + }); + } + + /** Queue an NZB by URL. Returns the new NZBID; a non-positive id means NZBGet refused it. */ + async add(url: string, category = "", priority = 0, paused = false): Promise { + const id = await this.rpc("append", [ + "", url, category, priority, false, paused, "", 0, "SCORE", false, [], + ]); + if (!id || id <= 0) throw new Error("NZBGet refused the NZB (append returned 0)"); + return id; + } + + async pauseAll(): Promise { + await this.rpc("pausedownload"); + } + + async resumeAll(): Promise { + await this.rpc("resumedownload"); + } + + async pauseItem(id: number): Promise { + await this.rpc("editqueue", ["GroupPause", "", [id]]); + } + + async resumeItem(id: number): Promise { + await this.rpc("editqueue", ["GroupResume", "", [id]]); + } + + /** Delete an item from the queue or from history. */ + async delete(id: number, from: "queue" | "history" = "queue"): Promise { + await this.rpc("editqueue", [from === "history" ? "HistoryDelete" : "GroupDelete", "", [id]]); + } +} diff --git a/modules/nzbget/index.ts b/modules/nzbget/index.ts new file mode 100644 index 0000000..bf576ae --- /dev/null +++ b/modules/nzbget/index.ts @@ -0,0 +1,64 @@ +// nzbget's events. The tool runtime imports this once the broker is bound. It watches the download +// queue and the history and turns their comings and goings into mesh events. +// +// Emits (novox/hq ADR 0046/0047): +// module.nzbget.download.added — an NZB entered the queue +// module.nzbget.download.completed — an NZB finished successfully (left the queue, landed in +// history as SUCCESS). This exact routing key is what the +// plex module consumes (module.*.download.completed) to +// rescan, so the new file becomes a visible item. +// Consumes: none. +// +// Two diffs, each primed silently on the first look (like plex's and sonarr's index.ts) so a +// restart mid-download does not re-announce everything already in flight or already finished. The +// queue tells us what was grabbed; history — not the queue's disappearance — tells us what actually +// succeeded, since a failed or deleted download also leaves the queue. + +import { emit } from "@novox/mesh-sdk/events"; +import { NzbgetClient } from "./client.js"; + +const nzbget = NzbgetClient.fromEnv(); + +const inQueue = new Set(); +let queuePrimed = false; +async function pollQueue(): Promise { + const items = await nzbget.getQueue(); + const now = new Set(items.map((i) => i.id)); + if (queuePrimed) { + for (const item of items) { + if (!inQueue.has(item.id)) { + await emit("module.nzbget.download.added", { name: item.name, category: item.category, sizeMB: item.sizeMB }); + } + } + } + inQueue.clear(); + for (const id of now) inQueue.add(id); + queuePrimed = true; +} + +const seenHistory = new Set(); +let historyPrimed = false; +async function pollHistory(): Promise { + const items = await nzbget.getHistory(50); + for (const item of items) { + if (!seenHistory.has(item.id)) { + // A newly-appeared history entry is a completion only if it actually succeeded; a failure or + // a manual delete lands in history too, and neither is a "download.completed". + if (historyPrimed && item.success) { + await emit("module.nzbget.download.completed", { name: item.name, category: item.category, sizeMB: item.sizeMB }); + } + seenHistory.add(item.id); + } + } + historyPrimed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[nzbget] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollQueue, 20_000); +tick(pollHistory, 30_000); + +console.log("[nzbget] watching the queue and history, emitting adds and completions"); diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index 95b41ca..2c0b92d 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -4,6 +4,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.nzbget.download.added", + "module.nzbget.download.completed" + ], + "consumes": [], + "own-secrets": { + "broker": "/var/lib/mesh/nzbget/broker" + }, "listens": [ { "port": 6789, @@ -13,6 +21,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/nzbget", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -44,6 +58,35 @@ "/services/nzbget/config:/config", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/nzbget/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-nzbget", + "image": "mesh-runtime-nzbget@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/nzbget/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nzbget/config.json:/run/config/config.json:ro", + "/services/nzbget/config:/var/lib/nzbget/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_NZBGET_URL": "http://127.0.0.1:6789", + "MESH_NZBGET_CONFIG_FILE": "/run/config/config.json", + "MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/nzbget/package.json b/modules/nzbget/package.json new file mode 100644 index 0000000..6e1af7f --- /dev/null +++ b/modules/nzbget/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-nzbget", + "version": "0.1.0", + "description": "nzbget — Usenet download client. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/nzbget/tools/index.ts b/modules/nzbget/tools/index.ts new file mode 100644 index 0000000..1f87377 --- /dev/null +++ b/modules/nzbget/tools/index.ts @@ -0,0 +1,88 @@ +// nzbget's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing nzbget's own +// client. They return structured data (not the pre-formatted text hal returned); the mesh serves +// them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { NzbgetClient } from "../client.js"; + +export function getNzbgetTools(nzbget: NzbgetClient): ToolDefinition[] { + return [ + { + name: "nzbget_status", + description: "NZBGet server status: download speed, queue remaining, disk free, paused state.", + input: {}, + run: async () => nzbget.getStatus(), + }, + { + name: "nzbget_queue", + description: "List the current NZBGet download queue — what is downloading and how far along.", + input: {}, + run: async () => { + const items = await nzbget.getQueue(); + return { count: items.length, items }; + }, + }, + { + name: "nzbget_history", + description: "Recent NZBGet download history, newest first — completed, failed and deleted items.", + input: { limit: { type: "number", description: "how many entries (default 20)" } }, + run: async (args) => { + const items = await nzbget.getHistory(args.limit ? Number(args.limit) : 20); + return { count: items.length, items }; + }, + }, + { + name: "nzbget_add", + description: "Queue an NZB download by URL, optionally into a category.", + input: { + url: { type: "string", description: "URL to the NZB file" }, + category: { type: "string", description: "category name (determines download directory)" }, + priority: { type: "number", description: "-100 very low … 0 normal … 100 very high (default 0)" }, + paused: { type: "boolean", description: "add in paused state (default false)" }, + }, + run: async (args) => { + const id = await nzbget.add( + String(args.url), + args.category ? String(args.category) : "", + args.priority ? Number(args.priority) : 0, + args.paused === true || args.paused === "true", + ); + return { added: id, category: args.category ? String(args.category) : null }; + }, + }, + { + name: "nzbget_pause", + description: "Pause or resume all NZBGet downloads.", + input: { resume: { type: "boolean", description: "true to resume, false to pause (default false)" } }, + run: async (args) => { + const resume = args.resume === true || args.resume === "true"; + if (resume) await nzbget.resumeAll(); + else await nzbget.pauseAll(); + return { paused: !resume }; + }, + }, + { + name: "nzbget_delete", + description: "Delete an NZB from the queue or from history by its NZBID.", + input: { + id: { type: "number", description: "the NZBID to delete" }, + from: { type: "string", description: "'queue' (default) or 'history'" }, + }, + run: async (args) => { + const from = args.from === "history" ? "history" : "queue"; + await nzbget.delete(Number(args.id), from); + return { deleted: Number(args.id), from }; + }, + }, + ]; +} + +// The tools exist only when NZBGet is configured; without a URL and password, nzbget contributes +// none rather than failing the whole runtime. +registerModuleTools("nzbget", (env) => { + try { + return getNzbgetTools(NzbgetClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/nzbget/tsconfig.json b/modules/nzbget/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/nzbget/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/ombi/client.ts b/modules/ombi/client.ts new file mode 100644 index 0000000..8016ccb --- /dev/null +++ b/modules/ombi/client.ts @@ -0,0 +1,114 @@ +// The Ombi API client — ombi's own code, living in the module (novox/hq ADR 0044). Ombi is the +// request front-end: viewers ask for movies and shows, and an operator approves them. This client +// talks its /api/v1 REST API (keyed by an ApiKey header); ombi's tools and events import it. + +import { readFileSync } from "node:fs"; + +export interface OmbiRequest { + kind: "movie" | "tv"; + id: number; + title: string; + requestedBy?: string; + requestedDate?: string; + approved: boolean; + available: boolean; + denied: boolean; + tmdbId?: number; +} + +export interface RequestCounts { + pending: number; + approved: number; + available: number; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class OmbiClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's resolved environment. Ombi's API is keyed; without URL and key there + * is nothing to talk to, so this throws rather than run half-configured. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient { + const cfg = meshConfig(env.MESH_OMBI_CONFIG_FILE); + const url = cfg.url ?? env.MESH_OMBI_URL; + const apiKey = cfg.apiKey ?? env.MESH_OMBI_API_KEY; + if (!url) throw new Error("no Ombi URL — set MESH_OMBI_URL"); + if (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY"); + return new OmbiClient(url, apiKey); + } + + private async request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${this.baseUrl}/api/v1${path}`, { + method, + headers: { + ApiKey: this.apiKey, + Accept: "application/json", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`Ombi API ${method} ${path}: ${res.status} ${await res.text()}`); + const text = await res.text(); + return text ? JSON.parse(text) : {}; + } + + /** All requests, movies and TV together — who asked for what, and where each stands. */ + async getRequests(): Promise { + const [movies, tv] = await Promise.all([ + this.request("GET", "/Request/movie"), + this.request("GET", "/Request/tv"), + ]); + const films: OmbiRequest[] = (Array.isArray(movies) ? movies : []).map((r: any) => ({ + kind: "movie" as const, + id: r.id, + title: r.title ?? "Unknown", + requestedBy: r.requestedUser?.userName ?? r.requestedUser?.userAlias, + requestedDate: r.requestedDate, + approved: Boolean(r.approved), + available: Boolean(r.available), + denied: Boolean(r.denied), + tmdbId: r.theMovieDbId, + })); + // TV requests carry per-season child requests; the top-level record is approved when all its + // children are, which is the grain an operator acts on. + const shows: OmbiRequest[] = (Array.isArray(tv) ? tv : []).map((r: any) => { + const children: any[] = r.childRequests ?? []; + return { + kind: "tv" as const, + id: r.id, + title: r.title ?? "Unknown", + requestedBy: children[0]?.requestedUser?.userName, + requestedDate: children[0]?.requestedDate, + approved: children.length > 0 && children.every((c) => c.approved), + available: children.length > 0 && children.every((c) => c.available), + denied: children.some((c) => c.denied), + tmdbId: r.theMovieDbId, + }; + }); + return [...films, ...shows]; + } + + /** Live pending/approved/available counts — a one-line health read without listing everything. */ + async getCounts(): Promise { + const c = await this.request("GET", "/Request/count"); + return { pending: c.pending ?? 0, approved: c.approved ?? 0, available: c.available ?? 0 }; + } + + /** Approve a request. TV approval fans out to the request's child (per-season) requests. */ + async approve(kind: "movie" | "tv", id: number): Promise { + await this.request("POST", `/Request/${kind}/approve`, { id }); + } +} diff --git a/modules/ombi/index.ts b/modules/ombi/index.ts new file mode 100644 index 0000000..4b6ca9f --- /dev/null +++ b/modules/ombi/index.ts @@ -0,0 +1,58 @@ +// ombi's events. The tool runtime imports this once the broker is bound. Ombi's timeline is the +// request lifecycle: a viewer files a request, and later an operator approves it. Both transitions +// are worth announcing — the mesh can notify on a new request, and act on an approval (that is when +// a downloader should start looking). +// +// Emits (novox/hq ADR 0046/0047): +// module.ombi.request.created — a viewer filed a new request +// module.ombi.request.approved — a request was approved +// +// Ombi is the origin of these decisions, not a reactor to the mesh, so it consumes nothing. +// +// Both events are observation-based: poll the request list and diff. Creation is diffed on the set +// of request ids; approval on each request's approved flag flipping true. Primed silently on the +// first look, or a restart would re-announce every existing request and approval. + +import { emit } from "@novox/mesh-sdk/events"; +import { OmbiClient, type OmbiRequest } from "./client.js"; + +const ombi = OmbiClient.fromEnv(); + +// Remember each seen request and whether it was approved last time, keyed by kind+id (ids are only +// unique within a kind). +const approvedState = new Map(); +let primed = false; + +const keyOf = (r: OmbiRequest): string => `${r.kind}:${r.id}`; + +async function pollRequests(): Promise { + const requests = await ombi.getRequests(); + for (const r of requests) { + const key = keyOf(r); + const known = approvedState.has(key); + if (primed && !known) { + await emit("module.ombi.request.created", { + kind: r.kind, + id: r.id, + title: r.title, + requestedBy: r.requestedBy, + tmdbId: r.tmdbId, + }); + } + // Approval: the flag went from false to true for a request we already knew about. + if (primed && known && r.approved && approvedState.get(key) === false) { + await emit("module.ombi.request.approved", { kind: r.kind, id: r.id, title: r.title, tmdbId: r.tmdbId }); + } + approvedState.set(key, r.approved); + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[ombi] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollRequests, 30_000); + +console.log("[ombi] watching requests for new filings and approvals"); diff --git a/modules/ombi/module.json b/modules/ombi/module.json index 45d8a62..f16bea2 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -4,6 +4,13 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.ombi.request.created", + "module.ombi.request.approved" + ], + "own-secrets": { + "broker": "/var/lib/mesh/ombi/broker" + }, "listens": [ { "port": 3579, @@ -13,6 +20,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/ombi", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -36,6 +49,35 @@ "volumes": [ "/services/ombi/config:/config" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/ombi/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-ombi", + "image": "mesh-runtime-ombi@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/ombi/broker:/run/secrets/broker:ro", + "/var/lib/mesh/ombi/config.json:/run/config/config.json:ro", + "/services/ombi/config:/var/lib/ombi/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_OMBI_URL": "http://127.0.0.1:3579", + "MESH_OMBI_CONFIG_FILE": "/run/config/config.json", + "MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/ombi/package.json b/modules/ombi/package.json new file mode 100644 index 0000000..d7542a1 --- /dev/null +++ b/modules/ombi/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-ombi", + "version": "0.1.0", + "description": "ombi — media requests. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/ombi/tools/index.ts b/modules/ombi/tools/index.ts new file mode 100644 index 0000000..00d854e --- /dev/null +++ b/modules/ombi/tools/index.ts @@ -0,0 +1,46 @@ +// ombi's tools — its own code (novox/hq ADR 0044), importing ombi's client. They return structured +// data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { OmbiClient } from "../client.js"; + +export function getOmbiTools(ombi: OmbiClient): ToolDefinition[] { + return [ + { + name: "ombi_requests", + description: "List media requests — movies and shows — with who asked and whether each is approved or available.", + input: { pending: { type: "boolean", description: "only requests not yet approved (default false)" } }, + run: async (args) => { + let requests = await ombi.getRequests(); + if (args.pending) requests = requests.filter((r) => !r.approved && !r.denied); + const counts = await ombi.getCounts(); + return { counts, count: requests.length, requests }; + }, + }, + { + name: "ombi_approve", + description: "Approve a media request by its kind and id (from ombi_requests).", + input: { + kind: { type: "string", description: '"movie" or "tv"' }, + id: { type: "number", description: "the request id" }, + }, + run: async (args) => { + const kind = String(args.kind); + if (kind !== "movie" && kind !== "tv") throw new Error('kind must be "movie" or "tv"'); + const id = Number(args.id); + await ombi.approve(kind, id); + return { approved: { kind, id } }; + }, + }, + ]; +} + +// Exposed only when Ombi is configured; otherwise ombi contributes no tools rather than failing the +// whole runtime. +registerModuleTools("ombi", (env) => { + try { + return getOmbiTools(OmbiClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/ombi/tsconfig.json b/modules/ombi/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/ombi/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/photos/client.ts b/modules/photos/client.ts new file mode 100644 index 0000000..2305b1f --- /dev/null +++ b/modules/photos/client.ts @@ -0,0 +1,110 @@ +// The photo app's API client — photos' own code, living in the module (novox/hq ADR 0044). The +// module packages a self-hosted photo library (immich-shaped: a REST API under `/api`, authenticated +// by an API key sent as the `x-api-key` header). The client speaks only what the tools and the +// item-added event need: server version and statistics, albums, and recent assets. + +import { readFileSync } from "node:fs"; + +export interface PhotosServerInfo { + version: string; + photos?: number; + videos?: number; + usageBytes?: number; +} + +export interface PhotosAlbum { + id: string; + name: string; + assetCount: number; + shared: boolean; +} + +export interface PhotosAsset { + id: string; + fileName?: string; + type?: string; + createdAt?: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class PhotosClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's environment. Unlike an open service, a photo library holds private data: + * the API key is required, and without it the module contributes nothing rather than reaching an + * unauthenticated endpoint. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient { + const cfg = meshConfig(env.MESH_PHOTOS_CONFIG_FILE); + const url = cfg.url ?? env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; + const key = cfg.apiKey ?? env.MESH_PHOTOS_API_KEY; + if (!key) throw new Error("no photos API key — set MESH_PHOTOS_API_KEY"); + return new PhotosClient(url, key); + } + + private async request(path: string, init: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + Accept: "application/json", + "x-api-key": this.apiKey, + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...(init.headers ?? {}), + }, + }); + if (!res.ok) throw new Error(`photos API ${path}: ${res.status} ${await res.text()}`); + return (await res.json()) as T; + } + + async getServerInfo(): Promise { + const version = await this.request<{ major: number; minor: number; patch: number }>("/api/server/version"); + const info: PhotosServerInfo = { version: `${version.major}.${version.minor}.${version.patch}` }; + // Statistics needs an admin key; a scoped key still gives version, so treat stats as best-effort. + try { + const stats = await this.request<{ photos: number; videos: number; usage: number }>("/api/server/statistics"); + info.photos = stats.photos; + info.videos = stats.videos; + info.usageBytes = stats.usage; + } catch { + // leave the counts unset + } + return info; + } + + async getAlbums(): Promise { + const albums = await this.request< + { id: string; albumName: string; assetCount: number; shared: boolean }[] + >("/api/albums"); + return albums.map((a) => ({ id: a.id, name: a.albumName, assetCount: a.assetCount, shared: a.shared })); + } + + /** Recent assets, newest first — via the metadata search, which is how this API returns a bounded, + * ordered slice of the library. The poll that emits item.added builds on this. */ + async getRecentAssets(limit = 20): Promise { + const data = await this.request<{ + assets?: { items?: { id: string; originalFileName?: string; type?: string; fileCreatedAt?: string }[] }; + }>("/api/search/metadata", { + method: "POST", + body: JSON.stringify({ size: limit, order: "desc" }), + }); + const items = data.assets?.items ?? []; + return items.map((a) => ({ + id: a.id, + fileName: a.originalFileName, + type: a.type, + createdAt: a.fileCreatedAt, + })); + } +} diff --git a/modules/photos/index.ts b/modules/photos/index.ts new file mode 100644 index 0000000..45cf390 --- /dev/null +++ b/modules/photos/index.ts @@ -0,0 +1,40 @@ +// photos' events. The tool runtime imports this once the broker is bound. It watches the library and +// announces newly added assets. +// +// Emits (novox/hq ADR 0046/0047): +// module.photos.item.added — a new asset appeared in the library +// +// New assets are found by diffing the recent-assets slice by asset id. Primed silently on the first +// look, so a restart does not re-announce the whole recent list as freshly added. + +import { emit } from "@novox/mesh-sdk/events"; +import { PhotosClient } from "./client.js"; + +const photos = PhotosClient.fromEnv(); + +const seen = new Set(); +let primed = false; + +async function pollRecent(): Promise { + const items = await photos.getRecentAssets(50); + for (const asset of items) { + if (!seen.has(asset.id)) { + if (primed) { + await emit("module.photos.item.added", { + id: asset.id, + fileName: asset.fileName, + kind: asset.type, + createdAt: asset.createdAt, + }); + } + seen.add(asset.id); + } + } + primed = true; +} + +const run = (): void => void pollRecent().catch((err) => console.error(`[photos] ${err}`)); +setInterval(run, 60_000); +run(); + +console.log("[photos] watching for newly added assets"); diff --git a/modules/photos/module.json b/modules/photos/module.json index e3bac87..b844b03 100644 --- a/modules/photos/module.json +++ b/modules/photos/module.json @@ -15,7 +15,19 @@ "secrets": { "s3-bucket": "/etc/photos/store.secret" }, + "emits": [ + "module.photos.item.added" + ], + "own-secrets": { + "broker": "/var/lib/mesh/photos/broker" + }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/photos", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -35,6 +47,36 @@ "/etc/photos/store.json:/etc/photos/store.json:ro", "/etc/photos/store.secret:/etc/photos/store.secret:ro" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/photos/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-photos", + "image": "mesh-runtime-photos@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/photos/broker:/run/secrets/broker:ro", + "/var/lib/mesh/photos/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_PHOTOS_URL": "http://127.0.0.1:2283", + "MESH_PHOTOS_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } + ], + "capabilities": [ + "container-runtime" ] } diff --git a/modules/photos/package.json b/modules/photos/package.json new file mode 100644 index 0000000..0bb719b --- /dev/null +++ b/modules/photos/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-photos", + "version": "0.1.0", + "description": "photos — self-hosted photo library. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/photos/tools/index.ts b/modules/photos/tools/index.ts new file mode 100644 index 0000000..b8c9619 --- /dev/null +++ b/modules/photos/tools/index.ts @@ -0,0 +1,43 @@ +// photos' tools (novox/hq ADR 0044), importing photos' own client. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { PhotosClient } from "../client.js"; + +export function getPhotosTools(photos: PhotosClient): ToolDefinition[] { + return [ + { + name: "photos_status", + description: "Photo library status: server version and, where the key allows, photo/video counts and storage used.", + input: {}, + run: async () => { + const [server, albums] = await Promise.all([photos.getServerInfo(), photos.getAlbums()]); + return { server, albumCount: albums.length }; + }, + }, + { + name: "photos_albums", + description: "List the albums in the photo library, each with its asset count and whether it is shared.", + input: {}, + run: async () => { + const albums = await photos.getAlbums(); + return { count: albums.length, albums }; + }, + }, + { + name: "photos_recent", + description: "Most recently added assets in the photo library, newest first.", + input: { limit: { type: "number", description: "how many assets (default 20)" } }, + run: async (args) => ({ items: await photos.getRecentAssets(args.limit ? Number(args.limit) : 20) }), + }, + ]; +} + +// The tools exist only when an API key is configured; without one, photos contributes none rather +// than reaching an unauthenticated endpoint or failing the whole runtime. +registerModuleTools("photos", (env) => { + try { + return getPhotosTools(PhotosClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/photos/tsconfig.json b/modules/photos/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/photos/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/plex/client.ts b/modules/plex/client.ts new file mode 100644 index 0000000..78c2288 --- /dev/null +++ b/modules/plex/client.ts @@ -0,0 +1,145 @@ +// The Plex API client — plex's own code, living in the module (novox/hq ADR 0044). Moved out of the +// shared hal sdk, where a change to Plex's API rebuilt everything; here it rebuilds only plex. Both +// this module's tools and its events entrypoint import it, and nothing outside plex does. + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface PlexLibrary { + key: string; + title: string; + type: string; + count?: number; +} + +export interface PlexSession { + key: string; + title: string; + user: string; + player: string; + state: string; + type: string; +} + +export interface PlexItem { + title: string; + type: string; + year?: number; + summary?: string; + addedAt?: string; +} + +export class PlexClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly token: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The token is read from MESH_PLEX_TOKEN, or + * discovered from the server's own Preferences.xml under the data directory — the same file Plex + * writes it to, so a running server needs nothing configured by hand. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): PlexClient { + const url = env.MESH_PLEX_URL ?? `http://127.0.0.1:${env.PLEX_PORT ?? "32400"}`; + const dataDir = env.MESH_PLEX_DATA_DIR ?? "/var/lib/plex"; + const token = env.MESH_PLEX_TOKEN ?? PlexClient.detectToken(dataDir); + if (!token) throw new Error("no Plex token — set MESH_PLEX_TOKEN or make the data dir readable"); + return new PlexClient(url, token); + } + + /** Discover the token from the server's Preferences.xml, falling back to null. */ + static detectToken(dataDir: string): string | null { + const prefs = join(dataDir, "config", "Library", "Application Support", "Plex Media Server", "Preferences.xml"); + if (existsSync(prefs)) { + const match = readFileSync(prefs, "utf8").match(/PlexOnlineToken="([^"]+)"/); + if (match) return match[1]; + } + return null; + } + + private async get(path: string): Promise { + const url = `${this.baseUrl}${path}`; + const sep = url.includes("?") ? "&" : "?"; + const res = await fetch(`${url}${sep}X-Plex-Token=${this.token}`, { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(`Plex API ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async getServerInfo(): Promise<{ name: string; version: string; platform: string }> { + const mc = (await this.get("/")).MediaContainer; + return { name: mc.friendlyName || mc.machineIdentifier, version: mc.version, platform: mc.platform }; + } + + async getLibraries(): Promise { + const dirs = (await this.get("/library/sections")).MediaContainer?.Directory ?? []; + return dirs.map((d: any) => ({ key: d.key, title: d.title, type: d.type, count: d.count })); + } + + async getSessions(): Promise { + const sessions = (await this.get("/status/sessions")).MediaContainer?.Metadata ?? []; + return sessions.map((s: any) => ({ + key: s.sessionKey ?? s.ratingKey, + title: s.title + (s.grandparentTitle ? ` (${s.grandparentTitle})` : ""), + user: s.User?.title ?? "unknown", + player: s.Player?.title ?? s.Player?.product ?? "unknown", + state: s.Player?.state ?? "unknown", + type: s.type, + })); + } + + async search(query: string): Promise { + const hubs = (await this.get(`/hubs/search?query=${encodeURIComponent(query)}&limit=20`)).MediaContainer?.Hub ?? []; + const results: PlexItem[] = []; + for (const hub of hubs) { + for (const m of hub.Metadata ?? []) { + results.push({ + title: m.title + (m.grandparentTitle ? ` (${m.grandparentTitle})` : ""), + type: m.type, + year: m.year, + summary: m.summary?.slice(0, 200), + }); + } + } + return results; + } + + async getRecentlyAdded(limit = 20): Promise { + const items = (await this.get(`/library/recentlyAdded?X-Plex-Container-Size=${limit}`)).MediaContainer?.Metadata ?? []; + return items.map((m: any) => ({ + title: m.title + (m.grandparentTitle ? ` (${m.grandparentTitle})` : ""), + type: m.type, + year: m.year, + summary: m.summary?.slice(0, 200), + addedAt: m.addedAt ? new Date(m.addedAt * 1000).toISOString() : undefined, + })); + } + + /** Ask Plex to rescan a library section — how a "new media arrived" event becomes a visible item. */ + async refreshLibrary(key: string): Promise { + await this.get(`/library/sections/${key}/refresh`); + } + + /** Rescan every library, for when what arrived is not known to belong to one. */ + async refreshAll(): Promise { + for (const library of await this.getLibraries()) await this.refreshLibrary(library.key); + } + + /** + * A health probe that never throws: report whether the Plex server this client is pointed at + * answers, and identify it when it does. Every other call assumes the server is up; this is the + * one that tells the mesh whether it is, so a diagnosis does not start from a stack trace. + */ + async reachable(): Promise<{ reachable: boolean; url: string; server?: { name: string; version: string }; error?: string }> { + try { + const info = await this.getServerInfo(); + return { reachable: true, url: this.baseUrl, server: { name: info.name, version: info.version } }; + } catch (err) { + return { reachable: false, url: this.baseUrl, error: err instanceof Error ? err.message : String(err) }; + } + } +} diff --git a/modules/plex/index.ts b/modules/plex/index.ts new file mode 100644 index 0000000..74830e2 --- /dev/null +++ b/modules/plex/index.ts @@ -0,0 +1,68 @@ +// plex's events. The tool runtime imports this once the broker is bound, and it does two things: +// it watches the server and emits what happened, and it reacts to the mesh's media events. +// +// Emits (novox/hq ADR 0046/0047): +// module.plex.playback.started / .stopped — someone began or ended watching +// module.plex.item.added — a new item appeared in a library +// Consumes: +// module.*.download.completed — a downloader finished; rescan so the file shows up +// +// The polling is deliberately unhurried: Plex is a neighbour on the same node, and an event a few +// seconds late is an event, whereas hammering the server for immediacy nobody asked for is not. + +import { emit, on } from "@novox/mesh-sdk/events"; +import { PlexClient, type PlexSession } from "./client.js"; + +const plex = PlexClient.fromEnv(); + +// Playback, by diffing the set of active sessions. Primed silently on the first look so a server +// that was already streaming when this started does not announce it as freshly begun. +const active = new Map(); +let playbackPrimed = false; +async function pollSessions(): Promise { + const sessions = await plex.getSessions(); + const now = new Map(sessions.map((s) => [s.key, s])); + if (playbackPrimed) { + for (const [key, s] of now) { + if (!active.has(key)) await emit("module.plex.playback.started", { title: s.title, user: s.user, player: s.player, kind: s.type }); + } + for (const [key, s] of active) { + if (!now.has(key)) await emit("module.plex.playback.stopped", { title: s.title, user: s.user, player: s.player }); + } + } + active.clear(); + for (const [key, s] of now) active.set(key, s); + playbackPrimed = true; +} + +// New items, by diffing recently-added. Primed silently too, or a restart would re-announce the +// whole recent list as new. +const seen = new Set(); +let itemsPrimed = false; +async function pollRecent(): Promise { + const items = await plex.getRecentlyAdded(20); + for (const item of items) { + const id = `${item.title}@${item.addedAt ?? ""}`; + if (!seen.has(id)) { + if (itemsPrimed) await emit("module.plex.item.added", item); + seen.add(id); + } + } + itemsPrimed = true; +} + +// A downloader finished somewhere on the mesh: rescan, so what it fetched becomes a visible item +// rather than a file Plex has not noticed. Idempotent — a rescan too many costs a little disk I/O. +await on("module.*.download.completed", async () => { + await plex.refreshAll(); +}); + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[plex] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollSessions, 15_000); +tick(pollRecent, 60_000); + +console.log("[plex] watching sessions and recently-added, reacting to downloads"); diff --git a/modules/plex/module.json b/modules/plex/module.json index d1287f3..bb9dc37 100644 --- a/modules/plex/module.json +++ b/modules/plex/module.json @@ -4,6 +4,17 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.plex.playback.started", + "module.plex.playback.stopped", + "module.plex.item.added" + ], + "consumes": [ + "module.*.download.completed" + ], + "own-secrets": { + "broker": "/var/lib/mesh/plex/broker" + }, "listens": [ { "port": 32400, @@ -13,6 +24,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/plex", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -82,6 +99,22 @@ "/services/media/music:/music", "/services/media/audiobooks:/audiobooks" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-plex", + "image": "mesh-runtime-plex@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/plex/broker:/run/secrets/broker:ro", + "/services/plex/config:/var/lib/plex/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_PLEX_URL": "http://127.0.0.1:32400", + "MESH_PLEX_DATA_DIR": "/var/lib/plex" + } } ] } diff --git a/modules/plex/package.json b/modules/plex/package.json new file mode 100644 index 0000000..65393e4 --- /dev/null +++ b/modules/plex/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-plex", + "version": "0.1.0", + "description": "plex — media server. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/plex/tools/index.ts b/modules/plex/tools/index.ts new file mode 100644 index 0000000..7a2115b --- /dev/null +++ b/modules/plex/tools/index.ts @@ -0,0 +1,74 @@ +// plex's tools — moved here from the shared sdk (novox/hq ADR 0044), importing plex's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { PlexClient } from "../client.js"; + +export function getPlexTools(plex: PlexClient): ToolDefinition[] { + return [ + { + name: "plex_status", + description: "Plex server status: server info, libraries, active sessions, recently added.", + input: {}, + run: async () => { + const [server, libraries, sessions, recent] = await Promise.all([ + plex.getServerInfo(), + plex.getLibraries(), + plex.getSessions(), + plex.getRecentlyAdded(10), + ]); + return { server, libraries, sessions, recentlyAdded: recent }; + }, + }, + { + name: "plex_reachable", + description: "Health probe: whether the Plex server answers, and which server it is. Never fails.", + input: {}, + run: async () => plex.reachable(), + }, + { + name: "plex_search", + description: "Search across all Plex libraries — movies, shows, episodes, music.", + input: { query: { type: "string", description: "the search query" } }, + run: async (args) => ({ query: String(args.query), results: await plex.search(String(args.query)) }), + }, + { + name: "plex_sessions", + description: "Active Plex playback sessions — who is watching what, and where.", + input: {}, + run: async () => { + const sessions = await plex.getSessions(); + return { count: sessions.length, sessions }; + }, + }, + { + name: "plex_recently_added", + description: "Recently added media in Plex.", + input: { limit: { type: "number", description: "how many items (default 20)" } }, + run: async (args) => ({ items: await plex.getRecentlyAdded(args.limit ? Number(args.limit) : 20) }), + }, + { + name: "plex_refresh", + description: "Ask Plex to rescan its libraries so new files on disk become visible items.", + input: { library: { type: "string", description: "a library section key; omitted rescans all" } }, + run: async (args) => { + if (args.library) { + await plex.refreshLibrary(String(args.library)); + return { refreshed: String(args.library) }; + } + await plex.refreshAll(); + return { refreshed: "all" }; + }, + }, + ]; +} + +// The tools exist only when a token can be found; without one, plex contributes none rather than +// failing the whole runtime. +registerModuleTools("plex", (env) => { + try { + return getPlexTools(PlexClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/plex/tsconfig.json b/modules/plex/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/plex/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/portainer/client.ts b/modules/portainer/client.ts new file mode 100644 index 0000000..a7ac3d1 --- /dev/null +++ b/modules/portainer/client.ts @@ -0,0 +1,107 @@ +// The Portainer API client — portainer's own code, living in the module (novox/hq ADR 0044). +// portainer is tools-only: its "events" would really be the underlying containers' lifecycle, +// which the host owns and emits — so this module reads Portainer's own resources (endpoints, +// stacks, containers) and exposes them, and stops there. + +import { readFileSync } from "node:fs"; + +export interface PortainerEndpoint { + id: number; + name: string; + type: number; + url: string; + status: number; +} + +export interface PortainerStack { + id: number; + name: string; + type: number; + endpointId: number; + status: number; +} + +export interface PortainerContainer { + id: string; + names: string[]; + image: string; + state: string; + status: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class PortainerClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly token: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. The URL is MESH_PORTAINER_URL (or the local + * dashboard port) and the API token is MESH_PORTAINER_TOKEN — an access token minted in + * Portainer, sent as X-API-Key. Throws when no token is configured, so a misconfigured module + * exposes nothing rather than calling Portainer unauthenticated. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient { + const cfg = meshConfig(env.MESH_PORTAINER_CONFIG_FILE); + const url = cfg.url ?? env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`; + const token = cfg.token ?? env.MESH_PORTAINER_TOKEN; + if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN"); + return new PortainerClient(url, token); + } + + private async get(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { headers: { "X-API-Key": this.token } }); + if (!res.ok) throw new Error(`Portainer ${path}: ${res.status} ${await res.text()}`); + return res.json() as Promise; + } + + /** The environments (endpoints) Portainer manages — each a Docker host or cluster it talks to. */ + async listEndpoints(): Promise { + const raw = await this.get("/api/endpoints"); + return (raw ?? []).map((e) => ({ + id: e.Id, + name: e.Name, + type: e.Type, + url: e.URL, + status: e.Status, + })); + } + + /** The stacks (compose/swarm deployments) Portainer knows about. */ + async listStacks(): Promise { + const raw = await this.get("/api/stacks"); + return (raw ?? []).map((s) => ({ + id: s.Id, + name: s.Name, + type: s.Type, + endpointId: s.EndpointId, + status: s.Status, + })); + } + + /** + * The containers on one endpoint, read through Portainer's Docker API proxy. Includes stopped + * containers, so the caller sees the whole picture rather than only what is running. + */ + async listContainers(endpointId: number): Promise { + const raw = await this.get(`/api/endpoints/${endpointId}/docker/containers/json?all=1`); + return (raw ?? []).map((c) => ({ + id: c.Id, + names: c.Names ?? [], + image: c.Image, + state: c.State, + status: c.Status, + })); + } +} diff --git a/modules/portainer/module.json b/modules/portainer/module.json index 0f0da7a..09d6898 100644 --- a/modules/portainer/module.json +++ b/modules/portainer/module.json @@ -13,6 +13,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/portainer", + "mode": "0700" + }, { "id": "data", "type": "directory", @@ -31,6 +37,36 @@ "/services/portainer/data:/data", "/var/run/docker.sock:/var/run/docker.sock" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/portainer/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-portainer", + "image": "mesh-runtime-portainer@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/portainer/broker:/run/secrets/broker:ro", + "/var/lib/mesh/portainer/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_PORTAINER_URL": "https://127.0.0.1:9443", + "MESH_PORTAINER_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } - ] + ], + "own-secrets": { + "broker": "/var/lib/mesh/portainer/broker" + } } diff --git a/modules/portainer/package.json b/modules/portainer/package.json new file mode 100644 index 0000000..77ee328 --- /dev/null +++ b/modules/portainer/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-portainer", + "version": "0.1.0", + "description": "portainer — container management UI. Its API client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/portainer/tools/index.ts b/modules/portainer/tools/index.ts new file mode 100644 index 0000000..192d830 --- /dev/null +++ b/modules/portainer/tools/index.ts @@ -0,0 +1,50 @@ +// portainer's tools — its own code (novox/hq ADR 0044), importing portainer's own client. They +// return structured data; the mesh serves them through the sdk's tool harness. portainer is +// tools-only (no events entrypoint): a container starting or stopping is the host's signal to emit, +// not Portainer's to re-announce. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { PortainerClient } from "../client.js"; + +export function getPortainerTools(portainer: PortainerClient): ToolDefinition[] { + return [ + { + name: "portainer_endpoints", + description: "List the environments (endpoints) Portainer manages — each a Docker host or cluster.", + input: {}, + run: async () => { + const endpoints = await portainer.listEndpoints(); + return { count: endpoints.length, endpoints }; + }, + }, + { + name: "portainer_stacks", + description: "List the stacks (compose/swarm deployments) Portainer knows about.", + input: {}, + run: async () => { + const stacks = await portainer.listStacks(); + return { count: stacks.length, stacks }; + }, + }, + { + name: "portainer_containers", + description: "List the containers on one Portainer endpoint, including stopped ones.", + input: { endpoint: { type: "number", description: "the endpoint id (see portainer_endpoints)" } }, + run: async (args) => { + const endpointId = Number(args.endpoint); + const containers = await portainer.listContainers(endpointId); + return { endpointId, count: containers.length, containers }; + }, + }, + ]; +} + +// The tools exist only when a token is configured; without one, portainer contributes none rather +// than failing the whole runtime. +registerModuleTools("portainer", (env) => { + try { + return getPortainerTools(PortainerClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/portainer/tsconfig.json b/modules/portainer/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/portainer/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} diff --git a/modules/postgres/client.ts b/modules/postgres/client.ts new file mode 100644 index 0000000..aae7c60 --- /dev/null +++ b/modules/postgres/client.ts @@ -0,0 +1,193 @@ +// postgres's admin client — postgres's own code, living in the module (novox/hq ADR 0044). Both this +// module's tools and its provisioner import it, and nothing outside postgres does. +// +// SQL is executed through `psql`, not a wire-protocol driver: the module may take NO npm dependency +// beyond @novox/mesh-sdk, and hand-rolling startup + SCRAM auth + the query protocol is more surface +// than this should carry — so it shells out to the client the postgres tools ship, the same way +// minio drives itself through `mc` and mailu through doveadm. One boundary, `query()`, and every +// method is built on it. + +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +export interface QueryResult { + /** The command tag postgres returns, e.g. "SELECT", "CREATE DATABASE". */ + readonly command: string; + readonly rows: Record[]; +} + +export interface PgConn { + readonly host: string; + readonly port: number; + readonly user: string; + readonly password: string; +} + + +export class PostgresClient { + constructor(private readonly conn: PgConn) {} + + /** + * Build from the module's resolved environment. Reads MESH_POSTGRES_* first (the documented + * names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner + * container. Throws if it cannot find a host and an admin password. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): PostgresClient { + const url = env.MESH_PROVISION_POSTGRES ? safeUrl(env.MESH_PROVISION_POSTGRES) : undefined; + const host = env.MESH_POSTGRES_HOST ?? url?.hostname; + const port = Number(env.MESH_POSTGRES_PORT ?? url?.port ?? "5432") || 5432; + const user = env.MESH_POSTGRES_USER ?? url?.username ?? "postgres"; + const password = env.MESH_POSTGRES_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE); + if (!host || !password) { + throw new Error("postgres host or admin password is not set — postgres's own code cannot reach the server"); + } + return new PostgresClient({ host, port, user, password }); + } + + get host(): string { + return this.conn.host; + } + + get port(): number { + return this.conn.port; + } + + /** Execute SQL against a database as the admin and return its rows, through `psql` (see header). */ + async query(sql: string, database = "postgres"): Promise { + // Executed through `psql`, the way minio drives itself through `mc` and mailu through doveadm: + // node has no postgres wire client without an npm dependency, and the module owns its own code + // (ADR 0044), so it shells out to the client the postgres tools ship. CSV so the rows come back + // structured; ON_ERROR_STOP so a failed statement is an error here, not a success with a warning. + const { stdout } = await run( + "psql", + ["-h", this.conn.host, "-p", String(this.conn.port), "-U", this.conn.user, "-d", database, + "-v", "ON_ERROR_STOP=1", "--no-psqlrc", "--csv", "-c", sql], + { env: { ...process.env, PGPASSWORD: this.conn.password }, maxBuffer: 16 << 20 }, + ); + const rows = parseCsvRows(stdout); + return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows }; + } + + /** + * Create a login role and a database it owns, idempotently. The DDL is the full, correct shape, run through query(). Extensions can be requested per + * database and are created as the admin (a plain owner cannot install most of them). + */ + async createDatabaseAndRole(database: string, role: string, password: string): Promise { + const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role)); + if (roles.rows.length === 0) { + await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`); + } else { + await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`); + } + const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database)); + if (dbs.rows.length === 0) { + await this.query(`CREATE DATABASE ${ident(database)} OWNER ${ident(role)}`); + } + await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`); + } + + /** Drop a database and its owning role, idempotently, after evicting live connections. */ + async dropDatabaseAndRole(database: string, role: string): Promise { + await this.query( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = " + + literal(database) + " AND pid <> pg_backend_pid()", + ); + await this.query(`DROP DATABASE IF EXISTS ${ident(database)}`); + await this.query(`DROP ROLE IF EXISTS ${ident(role)}`); + } + + /** List the non-template databases, with size, for the postgres_list_databases tool. */ + async listDatabases(): Promise<{ name: string; sizeBytes: number }[]> { + const res = await this.query( + "SELECT datname, pg_database_size(datname) AS size FROM pg_database WHERE datistemplate = false ORDER BY datname", + ); + return res.rows.map((r) => ({ name: String(r.datname), sizeBytes: Number(r.size) })); + } + + /** Run a read-only SQL statement against a named database, for the postgres_query tool. */ + async readOnlyQuery(database: string, sql: string): Promise { + // The read-only guarantee is a wrapping transaction the server honours. + return this.query(`BEGIN TRANSACTION READ ONLY; ${sql}; ROLLBACK;`, database); + } +} + +/** Generate a URL-safe password. */ +export function generatePassword(): string { + return randomBytes(24).toString("base64url"); +} + +/** Quote a SQL identifier (double quotes, doubled internal quotes). */ +export function ident(id: string): string { + return '"' + id.replace(/"/g, '""') + '"'; +} + +/** Quote a SQL string literal (single quotes, doubled internal quotes). */ +export function literal(val: string): string { + return "'" + val.replace(/'/g, "''") + "'"; +} + +function readSecretFile(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} + +function safeUrl(raw: string): URL | undefined { + try { + return new URL(raw); + } catch { + return undefined; + } +} + +/** Parse psql --csv output into row objects. RFC-4180: fields may be quoted, an embedded quote is + * doubled, and a quoted field may span newlines. Empty output (a DDL statement) yields no rows. */ +function parseCsvRows(csv: string): Record[] { + const records = parseCsv(csv); + if (records.length === 0) return []; + const [header, ...rows] = records; + return rows.map((cells) => { + const row: Record = {}; + header.forEach((name, i) => (row[name] = cells[i] ?? null)); + return row; + }); +} + +function parseCsv(text: string): string[][] { + const records: string[][] = []; + let field = ""; + let record: string[] = []; + let inQuotes = false; + let started = false; + const endRecord = (): void => { + if (started || field.length > 0 || record.length > 0) { + record.push(field); + records.push(record); + } + field = ""; + record = []; + started = false; + }; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inQuotes) { + if (c === '"') { + if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; + } else field += c; + } else if (c === '"') { inQuotes = true; started = true; } + else if (c === ",") { record.push(field); field = ""; started = true; } + else if (c === "\n" || c === "\r") { + if (c === "\r" && text[i + 1] === "\n") i++; + endRecord(); + } else { field += c; started = true; } + } + endRecord(); + return records; +} diff --git a/modules/postgres/index.ts b/modules/postgres/index.ts new file mode 100644 index 0000000..a7bbe12 --- /dev/null +++ b/modules/postgres/index.ts @@ -0,0 +1,25 @@ +// postgres's events entrypoint, loaded by the per-node tool host (the provisioner container runs +// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where +// the lifecycle actually happens (novox/hq ADR 0046/0047): +// module.postgres.database.provisioned — a consumer's database + owning role was created +// module.postgres.database.deprovisioned — that database was removed +// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a +// database and who lost one — observability the provider itself is best placed to log. + +import { on } from "@novox/mesh-sdk/events"; + +interface DatabaseEvent { + consumer: string; + database: string; + user?: string; +} + +await on("module.postgres.database.provisioned", async (e) => { + console.log(`[postgres] database provisioned for ${e.body.consumer} (db ${e.body.database})`); +}); + +await on("module.postgres.database.deprovisioned", async (e) => { + console.log(`[postgres] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`); +}); + +console.log("[postgres] auditing database lifecycle events"); diff --git a/modules/postgres/module.json b/modules/postgres/module.json index e2d8f03..3818a2f 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -10,6 +10,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.postgres.database.provisioned", + "module.postgres.database.deprovisioned" + ], + "consumes": [ + "module.postgres.database.provisioned", + "module.postgres.database.deprovisioned" + ], "listens": [ { "port": 5432, @@ -28,9 +36,16 @@ "postgres-database": "/var/lib/postgres/grants" }, "own-secrets": { - "superuser": "/var/lib/postgres/superuser.secret" + "superuser": "/var/lib/postgres/superuser.secret", + "broker": "/var/lib/mesh/postgres/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/postgres", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -82,20 +97,22 @@ ] }, { - "id": "provisioner", + "id": "runtime", "type": "container", - "name": "mesh-provision-postgres", - "image": "mesh-provision-postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-postgres", + "image": "mesh-runtime-postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "postgres", - "env": { - "GRANTS": "/var/lib/postgres/grants", - "MESH_PROVISION_POSTGRES": "postgres://postgres@postgres:5432/postgres?sslmode=disable", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser" - }, "volumes": [ + "/var/lib/mesh/postgres/broker:/run/secrets/broker:ro", "/var/lib/postgres/grants:/var/lib/postgres/grants:ro", "/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro" - ] + ], + "env": { + "MESH_PROVISION_POSTGRES": "postgres://postgres@postgres:5432/postgres?sslmode=disable", + "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser", + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECEIVES": "/var/lib/postgres/grants/mesh.json" + } } ] } diff --git a/modules/postgres/package.json b/modules/postgres/package.json new file mode 100644 index 0000000..5412054 --- /dev/null +++ b/modules/postgres/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-postgres", + "version": "0.1.0", + "description": "postgres — provides the mesh postgres-database interface. Its client, provisioner, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/postgres/provisioner/index.ts b/modules/postgres/provisioner/index.ts new file mode 100644 index 0000000..b3a4303 --- /dev/null +++ b/modules/postgres/provisioner/index.ts @@ -0,0 +1,48 @@ +// postgres's provisioner — the adapter that makes postgres a provider of the mesh +// `postgres-database` interface. The reconcile loop, the contributions file, and reading the mesh's +// minted password are the sdk harness's; this writes only the per-service half: how postgres creates +// and removes a consumer's database + owning role (novox/hq ADR 0044/0045/0053). +// +// The `postgres-database` interface: a consumer connects to a database it alone owns, as `as` with +// the password the mesh minted. +// +// **The role name and password are the mesh's, not the provisioner's (ADR 0053).** The mesh derives +// the login and hands it to both ends, and mints the password. postgres creates a role and a +// same-named database under exactly that login — a name the consumer cannot learn is a database it +// cannot reach. +// +// The DDL runs through PostgresClient.query(), which is the module's one pending boundary (see +// client.ts). + +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { PostgresClient } from "../client.js"; + +const postgres = PostgresClient.fromEnv(); + +/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */ +async function announce(type: string, body: Record): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[provisioner:postgres-database] emit ${type} failed: ${err}`); + } +} + +runProvisioner("postgres-database", { + async create(p: Provision): Promise { + // Database and owning role share the consumer's login, so the consumer owns exactly its own. + const database = p.as; + await postgres.createDatabaseAndRole(database, p.as, p.password); + await announce("module.postgres.database.provisioned", { + consumer: p.consumer ?? "", + database, + user: p.as, + }); + }, + + async remove(p: { as: string }): Promise { + await postgres.dropDatabaseAndRole(p.as, p.as); + await announce("module.postgres.database.deprovisioned", { database: p.as }); + }, +}); diff --git a/modules/postgres/tools/index.ts b/modules/postgres/tools/index.ts new file mode 100644 index 0000000..31ad53b --- /dev/null +++ b/modules/postgres/tools/index.ts @@ -0,0 +1,44 @@ +// postgres's tools — postgres's own code (novox/hq ADR 0044), importing postgres's own client. They +// return structured data; the mesh serves them through the sdk's tool harness. Both call through +// PostgresClient.query(), the module's one pending execution boundary (see client.ts): the tool +// shapes are fixed and correct, and surface the TODO honestly until that boundary is backed. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { PostgresClient } from "../client.js"; + +export function getPostgresTools(postgres: PostgresClient): ToolDefinition[] { + return [ + { + name: "postgres_list_databases", + description: "List the databases on the postgres server, with their on-disk size.", + input: {}, + run: async () => ({ databases: await postgres.listDatabases() }), + }, + { + name: "postgres_query", + description: "Run a read-only SQL query against a named database (wrapped in a read-only transaction).", + input: { + database: { type: "string", description: "the database to query" }, + sql: { type: "string", description: "the SELECT (or other read-only) statement" }, + }, + run: async (args) => { + const database = String(args.database ?? ""); + const sql = String(args.sql ?? ""); + if (!database) throw new Error("postgres_query: database is required"); + if (!sql) throw new Error("postgres_query: sql is required"); + const result = await postgres.readOnlyQuery(database, sql); + return { database, command: result.command, rows: result.rows }; + }, + }, + ]; +} + +// The tools exist only when the server can be reached from the environment; without it, postgres +// contributes none rather than failing the whole tool runtime. +registerModuleTools("postgres", (env) => { + try { + return getPostgresTools(PostgresClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/postgres/tsconfig.json b/modules/postgres/tsconfig.json new file mode 100644 index 0000000..51f4046 --- /dev/null +++ b/modules/postgres/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] +} diff --git a/modules/qbittorrent/client.ts b/modules/qbittorrent/client.ts new file mode 100644 index 0000000..b59c171 --- /dev/null +++ b/modules/qbittorrent/client.ts @@ -0,0 +1,180 @@ +// The qBittorrent API client — qbittorrent's own code, living in the module (novox/hq ADR 0044). +// Written against the WebUI API (/api/v2/...), self-contained so a change to it rebuilds only +// qbittorrent. Both this module's tools and its events entrypoint import it, and nothing outside +// qbittorrent does. +// +// The WebUI authenticates with a session cookie (SID) obtained by POSTing credentials, and guards +// against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is +// captured on login and carried by hand on every later call, with a single re-login on expiry. + +import { readFileSync } from "node:fs"; + +export interface QbTransferInfo { + dlSpeedBytesPerSec: number; + upSpeedBytesPerSec: number; + dlData: number; + upData: number; + connectionStatus: string; +} + +export interface QbTorrent { + hash: string; + name: string; + /** qBittorrent's state, e.g. "downloading", "stalledUP", "uploading", "pausedUP", "error". */ + state: string; + /** 0..1 — 1 means the download is complete. */ + progress: number; + sizeBytes: number; + dlSpeed: number; + upSpeed: number; + category: string; + ratio: number; + savePath: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class QbittorrentClient { + readonly baseUrl: string; + private sid: string | null = null; + + constructor( + baseUrl: string, + private readonly user: string, + private readonly password: string, + ) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. URL and password are read from + * MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD; both must be present — an unconfigured + * qBittorrent throws rather than pretend to be reachable, so the tools/events simply do not load + * (the harness treats the throw as "exposes nothing"). The user defaults to "admin". + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient { + const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_QBITTORRENT_URL; + const password = cfg.password ?? env.MESH_QBITTORRENT_PASSWORD; + if (!url || !password) { + throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD"); + } + const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? "admin"; + return new QbittorrentClient(url, user, password); + } + + private async login(): Promise { + const res = await fetch(`${this.baseUrl}/api/v2/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Referer: this.baseUrl }, + body: new URLSearchParams({ username: this.user, password: this.password }), + }); + if (!res.ok) throw new Error(`qBittorrent login: ${res.status} ${await res.text()}`); + if ((await res.text()).trim() !== "Ok.") { + throw new Error("qBittorrent login rejected — check credentials"); + } + const match = res.headers.get("set-cookie")?.match(/SID=([^;]+)/); + if (!match) throw new Error("qBittorrent login returned no SID cookie"); + this.sid = match[1]; + } + + private async call(method: "GET" | "POST", path: string, form?: Record): Promise { + if (!this.sid) await this.login(); + const doFetch = (): Promise => { + const headers: Record = { Referer: this.baseUrl, Cookie: `SID=${this.sid}` }; + const init: RequestInit = { method, headers }; + if (form) { + headers["Content-Type"] = "application/x-www-form-urlencoded"; + init.body = new URLSearchParams(form); + } + return fetch(`${this.baseUrl}/api/v2/${path}`, init); + }; + let res = await doFetch(); + if (res.status === 403) { + // The SID expired — re-authenticate once and retry, rather than fail a routine call. + await this.login(); + res = await doFetch(); + } + return res; + } + + private async getJson(path: string): Promise { + const res = await this.call("GET", path); + if (!res.ok) throw new Error(`qBittorrent GET ${path}: ${res.status} ${await res.text()}`); + return (await res.json()) as T; + } + + async getVersion(): Promise { + const res = await this.call("GET", "app/version"); + if (!res.ok) throw new Error(`qBittorrent app/version: ${res.status}`); + return (await res.text()).trim(); + } + + async getTransferInfo(): Promise { + const d = await this.getJson>("transfer/info"); + return { + dlSpeedBytesPerSec: Number(d.dl_info_speed ?? 0), + upSpeedBytesPerSec: Number(d.up_info_speed ?? 0), + dlData: Number(d.dl_info_data ?? 0), + upData: Number(d.up_info_data ?? 0), + connectionStatus: String(d.connection_status ?? "unknown"), + }; + } + + async getTorrents(filter?: string): Promise { + const path = filter ? `torrents/info?filter=${encodeURIComponent(filter)}` : "torrents/info"; + const list = await this.getJson[]>(path); + return list.map((t) => ({ + hash: String(t.hash), + name: String(t.name ?? "Unknown"), + state: String(t.state ?? "unknown"), + progress: Number(t.progress ?? 0), + sizeBytes: Number(t.size ?? 0), + dlSpeed: Number(t.dlspeed ?? 0), + upSpeed: Number(t.upspeed ?? 0), + category: String(t.category ?? ""), + ratio: Number(t.ratio ?? 0), + savePath: String(t.save_path ?? ""), + })); + } + + /** Add a torrent by magnet or http(s) .torrent URL, optionally into a category / save path. */ + async add(url: string, category = "", savepath = "", paused = false): Promise { + const form: Record = { urls: url, paused: paused ? "true" : "false" }; + if (category) form.category = category; + if (savepath) form.savepath = savepath; + const res = await this.call("POST", "torrents/add", form); + const text = (await res.text()).trim(); + if (!res.ok || text.toLowerCase() === "fails.") { + throw new Error(`qBittorrent refused the torrent: ${res.status} ${text}`); + } + } + + // qBittorrent 5.x renamed pause/resume to stop/start; try the modern name and fall back to the + // legacy one on a 404, so the client works against both. + private async command(modern: string, legacy: string, hashes: string): Promise { + let res = await this.call("POST", `torrents/${modern}`, { hashes }); + if (res.status === 404) res = await this.call("POST", `torrents/${legacy}`, { hashes }); + if (!res.ok) throw new Error(`qBittorrent torrents/${modern}: ${res.status} ${await res.text()}`); + } + + /** Pause torrents — a pipe-separated hash list, or "all" (the default). */ + async pause(hashes = "all"): Promise { + await this.command("stop", "pause", hashes); + } + + /** Resume torrents — a pipe-separated hash list, or "all" (the default). */ + async resume(hashes = "all"): Promise { + await this.command("start", "resume", hashes); + } + + async delete(hashes: string, deleteFiles = false): Promise { + const res = await this.call("POST", "torrents/delete", { hashes, deleteFiles: deleteFiles ? "true" : "false" }); + if (!res.ok) throw new Error(`qBittorrent torrents/delete: ${res.status} ${await res.text()}`); + } +} diff --git a/modules/qbittorrent/index.ts b/modules/qbittorrent/index.ts new file mode 100644 index 0000000..2b57c18 --- /dev/null +++ b/modules/qbittorrent/index.ts @@ -0,0 +1,52 @@ +// qbittorrent's events. The tool runtime imports this once the broker is bound. It watches the +// torrent list and turns its comings and goings into mesh events. +// +// Emits (novox/hq ADR 0046/0047): +// module.qbittorrent.download.added — a torrent was added +// module.qbittorrent.download.completed — a torrent finished downloading (progress reached 1). +// This exact routing key is what the plex module +// consumes (module.*.download.completed) to rescan, so +// the new file becomes a visible item. +// Consumes: none. +// +// The torrent list is polled and diffed by hash, primed silently on the first look (like plex's and +// sonarr's index.ts) so a restart does not re-announce everything already present. Completion is a +// progress crossing from below 1 to exactly 1 — a torrent added already-complete is announced only +// as added, never as freshly completed, since nothing was downloaded. + +import { emit } from "@novox/mesh-sdk/events"; +import { QbittorrentClient } from "./client.js"; + +const qb = QbittorrentClient.fromEnv(); + +const progressByHash = new Map(); +let primed = false; + +async function pollTorrents(): Promise { + const torrents = await qb.getTorrents(); + const now = new Map(torrents.map((t) => [t.hash, t])); + + if (primed) { + for (const [hash, t] of now) { + const before = progressByHash.get(hash); + if (before === undefined) { + await emit("module.qbittorrent.download.added", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); + } else if (before < 1 && t.progress >= 1) { + await emit("module.qbittorrent.download.completed", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); + } + } + } + + progressByHash.clear(); + for (const [hash, t] of now) progressByHash.set(hash, t.progress); + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[qbittorrent] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollTorrents, 20_000); + +console.log("[qbittorrent] watching torrents, emitting adds and completions"); diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index a8e8cb5..daa6629 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -4,6 +4,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.qbittorrent.download.added", + "module.qbittorrent.download.completed" + ], + "consumes": [], + "own-secrets": { + "broker": "/var/lib/mesh/qbittorrent/broker" + }, "listens": [ { "port": 8080, @@ -13,6 +21,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/qbittorrent", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -44,6 +58,35 @@ "/services/qbittorrent/config:/config", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/qbittorrent/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-qbittorrent", + "image": "mesh-runtime-qbittorrent@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/qbittorrent/broker:/run/secrets/broker:ro", + "/var/lib/mesh/qbittorrent/config.json:/run/config/config.json:ro", + "/services/qbittorrent/config:/var/lib/qbittorrent/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_QBITTORRENT_URL": "http://127.0.0.1:8080", + "MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json", + "MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/qbittorrent/package.json b/modules/qbittorrent/package.json new file mode 100644 index 0000000..865b977 --- /dev/null +++ b/modules/qbittorrent/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-qbittorrent", + "version": "0.1.0", + "description": "qbittorrent — BitTorrent download client. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/qbittorrent/tools/index.ts b/modules/qbittorrent/tools/index.ts new file mode 100644 index 0000000..49f9ec5 --- /dev/null +++ b/modules/qbittorrent/tools/index.ts @@ -0,0 +1,98 @@ +// qbittorrent's tools — living in the module (novox/hq ADR 0044), importing qbittorrent's own +// client. They return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { QbittorrentClient } from "../client.js"; + +export function getQbittorrentTools(qb: QbittorrentClient): ToolDefinition[] { + return [ + { + name: "qbittorrent_status", + description: "qBittorrent status: version, global transfer rates, and how many torrents are active.", + input: {}, + run: async () => { + const [version, transfer, torrents] = await Promise.all([ + qb.getVersion(), + qb.getTransferInfo(), + qb.getTorrents(), + ]); + const downloading = torrents.filter((t) => t.progress < 1).length; + return { version, transfer, torrents: torrents.length, downloading, seeding: torrents.length - downloading }; + }, + }, + { + name: "qbittorrent_torrents", + description: "List torrents — name, state, progress and speed. Optional filter narrows the set.", + input: { + filter: { type: "string", description: "one of all|downloading|seeding|completed|paused|active|inactive|stalled" }, + }, + run: async (args) => { + const items = await qb.getTorrents(args.filter ? String(args.filter) : undefined); + return { count: items.length, torrents: items }; + }, + }, + { + name: "qbittorrent_add", + description: "Add a torrent by magnet link or .torrent URL, optionally into a category.", + input: { + url: { type: "string", description: "magnet link or http(s) URL to a .torrent" }, + category: { type: "string", description: "category name (determines save directory)" }, + savepath: { type: "string", description: "explicit save path (overrides the category default)" }, + paused: { type: "boolean", description: "add in paused state (default false)" }, + }, + run: async (args) => { + await qb.add( + String(args.url), + args.category ? String(args.category) : "", + args.savepath ? String(args.savepath) : "", + args.paused === true || args.paused === "true", + ); + return { added: String(args.url), category: args.category ? String(args.category) : null }; + }, + }, + { + name: "qbittorrent_pause", + description: "Pause torrents — a pipe-separated hash list, or 'all' (the default).", + input: { hashes: { type: "string", description: "pipe-separated torrent hashes, or 'all' (default)" } }, + run: async (args) => { + const hashes = args.hashes ? String(args.hashes) : "all"; + await qb.pause(hashes); + return { paused: hashes }; + }, + }, + { + name: "qbittorrent_resume", + description: "Resume torrents — a pipe-separated hash list, or 'all' (the default).", + input: { hashes: { type: "string", description: "pipe-separated torrent hashes, or 'all' (default)" } }, + run: async (args) => { + const hashes = args.hashes ? String(args.hashes) : "all"; + await qb.resume(hashes); + return { resumed: hashes }; + }, + }, + { + name: "qbittorrent_delete", + description: "Remove torrents by hash, optionally deleting their files on disk.", + input: { + hashes: { type: "string", description: "pipe-separated torrent hashes, or 'all'" }, + deleteFiles: { type: "boolean", description: "also delete downloaded files (default false)" }, + }, + run: async (args) => { + const hashes = String(args.hashes); + const deleteFiles = args.deleteFiles === true || args.deleteFiles === "true"; + await qb.delete(hashes, deleteFiles); + return { deleted: hashes, deleteFiles }; + }, + }, + ]; +} + +// The tools exist only when qBittorrent is configured; without a URL and password, qbittorrent +// contributes none rather than failing the whole runtime. +registerModuleTools("qbittorrent", (env) => { + try { + return getQbittorrentTools(QbittorrentClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/qbittorrent/tsconfig.json b/modules/qbittorrent/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/qbittorrent/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/radarr/client.ts b/modules/radarr/client.ts new file mode 100644 index 0000000..6a659eb --- /dev/null +++ b/modules/radarr/client.ts @@ -0,0 +1,144 @@ +// The Radarr API client — radarr's own code, living in the module (novox/hq ADR 0044). Ported from +// the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a +// change to Radarr's API rebuilds only radarr and nothing else. Both this module's tools and its +// events entrypoint import it, and nothing outside radarr does. + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Radarr speaks the v3 API; its content is "movie". +const API_VERSION = "v3"; +const CONTENT_ENDPOINT = "movie"; +const APP_NAME = "Radarr"; + +export interface RadarrQueueItem { + /** The queue record id — stable while the item is in the queue, so events can diff on it. */ + id: number; + title: string; + status: string; + size: string; + sizeleft: string; + timeleft?: string; +} + +export interface RadarrCalendarItem { + title: string; + date: string; + overview?: string; +} + +export interface RadarrContentItem { + title: string; + year?: number; + status?: string; + monitored: boolean; +} + +export class RadarrClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The URL defaults to the server on this node (the + * runtime shares its network), and the API key is read from MESH_RADARR_API_KEY or, failing that, + * discovered from the server's own config.xml under MESH_RADARR_CONFIG_DIR — the same file Radarr + * writes it to, so a running server needs nothing configured by hand. Throws when no key can be + * found, so the tools/events simply do not load (the harness treats the throw as "exposes + * nothing"). + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): RadarrClient { + const url = env.MESH_RADARR_URL ?? `http://127.0.0.1:${env.MESH_RADARR_PORT ?? "7878"}`; + const configDir = env.MESH_RADARR_CONFIG_DIR ?? "/config"; + const apiKey = env.MESH_RADARR_API_KEY ?? RadarrClient.detectApiKey(configDir); + if (!apiKey) { + throw new Error("Radarr not configured — set MESH_RADARR_API_KEY or make the config dir readable"); + } + return new RadarrClient(url, apiKey); + } + + /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app + * writes into config.xml at the root of its config directory. */ + static detectApiKey(configDir: string): string | null { + const config = join(configDir, "config.xml"); + if (existsSync(config)) { + const match = readFileSync(config, "utf8").match(/([^<]+)<\/ApiKey>/); + if (match) return match[1]; + } + return null; + } + + private async get(endpoint: string, params?: Record): Promise { + const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + } + const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } }); + if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async getStatus(): Promise<{ appName: string; version: string }> { + const data = (await this.get("system/status")) as { appName?: string; version?: string }; + return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" }; + } + + async getContent(limit?: number): Promise { + const data = await this.get(CONTENT_ENDPOINT); + const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []); + const mapped = items.map((item) => ({ + title: item.title ?? "Unknown", + year: item.year, + status: item.status, + monitored: item.monitored ?? true, + })); + return limit ? mapped.slice(0, limit) : mapped; + } + + /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ + async searchContent(term: string): Promise { + const all = await this.getContent(); + const lower = term.toLowerCase(); + return all.filter((item) => item.title.toLowerCase().includes(lower)); + } + + async getQueue(): Promise<{ totalRecords: number; items: RadarrQueueItem[] }> { + const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] }; + const records = data.records ?? []; + return { + totalRecords: data.totalRecords ?? records.length, + items: records.map((r) => ({ + id: r.id, + title: r.title ?? r.movie?.title ?? "Unknown", + status: r.status ?? "unknown", + size: formatBytes(r.size ?? 0), + sizeleft: formatBytes(r.sizeleft ?? 0), + timeleft: r.timeleft, + })), + }; + } + + async getCalendar(days = 7): Promise { + const start = new Date().toISOString().split("T")[0]; + const end = new Date(Date.now() + days * 86400000).toISOString().split("T")[0]; + const data = await this.get("calendar", { start, end }); + const items: any[] = Array.isArray(data) ? data : []; + return items.map((item) => ({ + title: item.title ?? item.movie?.title ?? "Unknown", + date: item.inCinemas ?? item.digitalRelease ?? "", + overview: item.overview?.slice(0, 150), + })); + } +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} diff --git a/modules/radarr/index.ts b/modules/radarr/index.ts new file mode 100644 index 0000000..3b08be7 --- /dev/null +++ b/modules/radarr/index.ts @@ -0,0 +1,55 @@ +// radarr's events. The tool runtime imports this once the broker is bound. It watches the download +// queue and turns its comings and goings into mesh events. +// +// Emits (novox/hq ADR 0046/0047): +// module.radarr.movie.grabbed — a release entered the queue (Radarr grabbed it) +// module.radarr.download.completed — a release left the queue, imported. This exact routing key +// is what the plex module consumes (module.*.download.completed) +// to rescan, so the new movie becomes a visible item. +// Consumes: none. +// +// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a +// restart mid-download does not re-announce everything already in flight as freshly grabbed. + +import { emit } from "@novox/mesh-sdk/events"; +import { RadarrClient, type RadarrQueueItem } from "./client.js"; + +const radarr = RadarrClient.fromEnv(); + +// Radarr removes an item from the queue once it has been imported; a "warning"/"failed" status is +// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish. +const FAILED_STATUSES = new Set(["failed", "warning"]); + +const inQueue = new Map(); +let primed = false; + +async function pollQueue(): Promise { + const { items } = await radarr.getQueue(); + const now = new Map(items.map((i) => [i.id, i])); + + if (primed) { + // Entered the queue since last look — Radarr grabbed a release. + for (const [id, item] of now) { + if (!inQueue.has(id)) await emit("module.radarr.movie.grabbed", { title: item.title, status: item.status }); + } + // Left the queue — imported and done, unless it was last seen failing. + for (const [id, item] of inQueue) { + if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { + await emit("module.radarr.download.completed", { title: item.title }); + } + } + } + + inQueue.clear(); + for (const [id, item] of now) inQueue.set(id, item); + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[radarr] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollQueue, 30_000); + +console.log("[radarr] watching the download queue, emitting grabs and completions"); diff --git a/modules/radarr/module.json b/modules/radarr/module.json index 51a782f..607972f 100644 --- a/modules/radarr/module.json +++ b/modules/radarr/module.json @@ -4,6 +4,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.radarr.movie.grabbed", + "module.radarr.download.completed" + ], + "consumes": [], + "own-secrets": { + "broker": "/var/lib/mesh/radarr/broker" + }, "listens": [ { "port": 7878, @@ -13,6 +21,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/radarr", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -52,6 +66,22 @@ "/services/media/movies:/movies", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-radarr", + "image": "mesh-runtime-radarr@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/radarr/broker:/run/secrets/broker:ro", + "/services/radarr/config:/var/lib/radarr/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RADARR_URL": "http://127.0.0.1:7878", + "MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config" + } } ] } diff --git a/modules/radarr/package.json b/modules/radarr/package.json new file mode 100644 index 0000000..9393ff4 --- /dev/null +++ b/modules/radarr/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-radarr", + "version": "0.1.0", + "description": "radarr — movie management. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/radarr/tools/index.ts b/modules/radarr/tools/index.ts new file mode 100644 index 0000000..d147012 --- /dev/null +++ b/modules/radarr/tools/index.ts @@ -0,0 +1,78 @@ +// radarr's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing radarr's own +// client. They return structured data (not pre-formatted text as hal did); the mesh serves them +// through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { RadarrClient } from "../client.js"; + +export function getRadarrTools(radarr: RadarrClient): ToolDefinition[] { + return [ + { + name: "radarr_status", + description: "Radarr status overview: version, movie count, monitored count, queue size.", + input: {}, + run: async () => { + const [status, content, queue] = await Promise.all([ + radarr.getStatus(), + radarr.getContent(), + radarr.getQueue(), + ]); + return { + app: status.appName, + version: status.version, + movies: content.length, + monitored: content.filter((c) => c.monitored).length, + queue: queue.totalRecords, + }; + }, + }, + { + name: "radarr_library", + description: "List movies from the Radarr library.", + input: { limit: { type: "number", description: "max items to return (default 50)" } }, + run: async (args) => { + const items = await radarr.getContent(args.limit ? Number(args.limit) : 50); + return { count: items.length, movies: items }; + }, + }, + { + name: "radarr_search", + description: "Search the Radarr library for movies by title (filters existing content, not indexers).", + input: { query: { type: "string", description: "the search term" } }, + run: async (args) => { + const query = String(args.query); + return { query, results: await radarr.searchContent(query) }; + }, + }, + { + name: "radarr_queue", + description: "Show the Radarr download queue — what is downloading and how far along.", + input: {}, + run: async () => { + const queue = await radarr.getQueue(); + return { count: queue.totalRecords, items: queue.items }; + }, + }, + { + name: "radarr_calendar", + description: "Upcoming movie releases from the Radarr calendar.", + input: { days: { type: "number", description: "how many days to look ahead (default 7)" } }, + run: async (args) => { + const days = args.days ? Number(args.days) : 7; + const items = await radarr.getCalendar(days); + items.sort((a, b) => a.date.localeCompare(b.date)); + return { days, count: items.length, items }; + }, + }, + ]; +} + +// The tools exist only when Radarr is configured; without a URL and key, radarr contributes none +// rather than failing the whole runtime. +registerModuleTools("radarr", (env) => { + try { + return getRadarrTools(RadarrClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/radarr/tsconfig.json b/modules/radarr/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/radarr/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/redis/client.ts b/modules/redis/client.ts new file mode 100644 index 0000000..cb17695 --- /dev/null +++ b/modules/redis/client.ts @@ -0,0 +1,232 @@ +// redis's admin client — redis's own code, living in the module (novox/hq ADR 0044). It speaks +// RESP directly over a raw TCP socket (node:net) so the module carries no npm dependency beyond +// @novox/mesh-sdk: no redis driver, no redis-cli in the image. Both this module's tools and its +// provisioner import it, and nothing outside redis does. +// +// The client keeps a single connection and runs one command at a time in FIFO order — enough for +// an admin surface (PING, INFO, ACL SETUSER/DELUSER, arbitrary commands). Replies come back in the +// order requests were sent, which is what the queue below relies on. + +import { createConnection, type Socket } from "node:net"; +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; + +/** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */ +export type RespValue = string | number | null | RespValue[]; + +interface Waiter { + resolve: (v: RespValue) => void; + reject: (e: Error) => void; +} + +export class RedisClient { + private socket: Socket | null = null; + private buffer = Buffer.alloc(0); + private queue: Waiter[] = []; + + constructor( + readonly host: string, + readonly port: number, + private readonly password: string, + private readonly username = "default", + ) {} + + /** + * Build from the module's resolved environment. Reads MESH_REDIS_* first (the documented + * names), falling back to the MESH_PROVISION_* keys the manifest already sets on the + * provisioner container so the module runs unchanged there. Throws if it cannot find a host and + * an admin password — the right failure, because without them nothing it does can work. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): RedisClient { + const endpoint = env.MESH_PROVISION_REDIS ?? ""; // "host:port" + const host = env.MESH_REDIS_HOST ?? (endpoint ? endpoint.split(":")[0] : undefined); + const port = Number(env.MESH_REDIS_PORT ?? (endpoint.includes(":") ? endpoint.split(":")[1] : "") ?? "6379") || 6379; + const username = env.MESH_REDIS_USERNAME ?? "default"; + const password = env.MESH_REDIS_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE); + if (!host || !password) { + throw new Error("redis host or admin password is not set — redis's own code cannot reach the server"); + } + return new RedisClient(host, port, password, username); + } + + /** Open the connection (idempotent) and authenticate. Reconnects if the socket has gone away. */ + async connect(): Promise { + if (this.socket && !this.socket.destroyed) return; + await new Promise((resolve, reject) => { + const sock = createConnection({ host: this.host, port: this.port }); + this.socket = sock; + sock.on("data", (chunk: Buffer | string) => this.onData(typeof chunk === "string" ? Buffer.from(chunk) : chunk)); + sock.on("error", (err) => { + this.failAll(err); + reject(err); + }); + sock.on("close", () => this.failAll(new Error("redis connection closed"))); + sock.once("connect", () => resolve()); + }); + if (this.password) { + const args = this.username && this.username !== "default" + ? ["AUTH", this.username, this.password] + : ["AUTH", this.password]; + await this.send(args); + } + } + + /** Run one command and return its parsed reply. A RESP error reply rejects the promise. */ + async command(...args: (string | number)[]): Promise { + await this.connect(); + return this.send(args.map(String)); + } + + async ping(): Promise { + return (await this.command("PING")) === "PONG"; + } + + /** Server INFO, returned both raw and parsed into the flat key/value map redis emits. */ + async info(section?: string): Promise<{ raw: string; fields: Record }> { + const raw = String((await this.command("INFO", ...(section ? [section] : []))) ?? ""); + const fields: Record = {}; + for (const line of raw.split(/\r?\n/)) { + if (!line || line.startsWith("#")) continue; + const idx = line.indexOf(":"); + if (idx > 0) fields[line.slice(0, idx)] = line.slice(idx + 1); + } + return { raw, fields }; + } + + /** + * Create (or reset to a known state) an ACL user scoped to one keyspace prefix. `reset` first + * clears any prior rules so the call is idempotent, then the user is enabled with the given + * password, confined to keys matching `:*`, and allowed the ordinary command set. The + * consumer connects as this user and can touch nothing outside its prefix. + */ + async createAclUser(username: string, password: string, keyspacePrefix: string): Promise { + await this.command("ACL", "SETUSER", username, "reset", "on", `>${password}`, `~${keyspacePrefix}:*`, "+@all"); + } + + async deleteAclUser(username: string): Promise { + await this.command("ACL", "DELUSER", username); + } + + close(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + } + + // --- connection plumbing --- + + /** Send an already-connected command, queuing its reply against the FIFO of in-flight requests. */ + private send(args: string[]): Promise { + const sock = this.socket; + if (!sock) return Promise.reject(new Error("redis socket is not connected")); + return new Promise((resolve, reject) => { + this.queue.push({ resolve, reject }); + sock.write(encodeCommand(args)); + }); + } + + /** Feed incoming bytes to the parser, resolving as many queued replies as the buffer completes. */ + private onData(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (this.queue.length > 0) { + let parsed: { value: RespValue | Error; next: number } | null; + try { + parsed = parseReply(this.buffer, 0); + } catch (err) { + const waiter = this.queue.shift(); + waiter?.reject(err instanceof Error ? err : new Error(String(err))); + this.buffer = Buffer.alloc(0); + continue; + } + if (!parsed) break; // one full reply not yet in the buffer + this.buffer = this.buffer.subarray(parsed.next); + const waiter = this.queue.shift(); + if (!waiter) break; + if (parsed.value instanceof Error) waiter.reject(parsed.value); + else waiter.resolve(parsed.value); + } + } + + /** A socket error or close fails every pending command and forces a fresh connect next time. */ + private failAll(err: Error): void { + const pending = this.queue; + this.queue = []; + for (const waiter of pending) waiter.reject(err); + this.buffer = Buffer.alloc(0); + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + } +} + +/** Generate a URL-safe password with no RESP-hostile characters. */ +export function generatePassword(): string { + return randomBytes(24).toString("base64url"); +} + +function readSecretFile(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} + +// --- RESP wire format --- + +/** Encode a command as a RESP array of bulk strings. */ +function encodeCommand(args: string[]): Buffer { + let head = `*${args.length}\r\n`; + for (const a of args) head += `$${Buffer.byteLength(a)}\r\n${a}\r\n`; + return Buffer.from(head, "utf8"); +} + +/** + * Parse one RESP reply starting at `i`. Returns the value and the offset just past it, or null if + * the buffer does not yet hold a complete reply (the caller waits for more bytes). A `-` error + * reply is returned as an Error value; the client turns that into a rejection. + */ +function parseReply(buf: Buffer, i: number): { value: RespValue | Error; next: number } | null { + if (i >= buf.length) return null; + const type = buf[i]; + const eol = buf.indexOf("\r\n", i + 1); + if (eol === -1) return null; // header line not yet complete + const line = buf.toString("utf8", i + 1, eol); + const after = eol + 2; + switch (type) { + case 0x2b: // '+' simple string + return { value: line, next: after }; + case 0x2d: // '-' error + return { value: new Error(line), next: after }; + case 0x3a: // ':' integer + return { value: Number(line), next: after }; + case 0x24: { + // '$' bulk string + const len = Number(line); + if (len === -1) return { value: null, next: after }; + const end = after + len; + if (buf.length < end + 2) return null; // body not fully arrived + return { value: buf.toString("utf8", after, end), next: end + 2 }; + } + case 0x2a: { + // '*' array + const count = Number(line); + if (count === -1) return { value: null, next: after }; + const arr: RespValue[] = []; + let cursor = after; + for (let k = 0; k < count; k++) { + const el = parseReply(buf, cursor); + if (!el) return null; // array not fully arrived + if (el.value instanceof Error) throw el.value; + arr.push(el.value); + cursor = el.next; + } + return { value: arr, next: cursor }; + } + default: + return { value: new Error(`unexpected RESP type byte 0x${type.toString(16)}`), next: after }; + } +} diff --git a/modules/redis/index.ts b/modules/redis/index.ts new file mode 100644 index 0000000..870c886 --- /dev/null +++ b/modules/redis/index.ts @@ -0,0 +1,25 @@ +// redis's events entrypoint, loaded by the per-node tool host (the provisioner container runs +// ./provisioner separately). The cache lifecycle events are EMITTED from the provisioner, where the +// lifecycle actually happens (novox/hq ADR 0046/0047): +// module.redis.cache.provisioned — a consumer's ACL user + keyspace was created +// module.redis.cache.deprovisioned — that user was removed +// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a +// cache and who lost one — observability the provider itself is best placed to log. + +import { on } from "@novox/mesh-sdk/events"; + +interface CacheEvent { + consumer: string; + username: string; + keyspacePrefix?: string; +} + +await on("module.redis.cache.provisioned", async (e) => { + console.log(`[redis] cache provisioned for ${e.body.consumer} (user ${e.body.username})`); +}); + +await on("module.redis.cache.deprovisioned", async (e) => { + console.log(`[redis] cache deprovisioned for ${e.body.consumer} (user ${e.body.username})`); +}); + +console.log("[redis] auditing cache lifecycle events"); diff --git a/modules/redis/module.json b/modules/redis/module.json index 5f223c6..5150f69 100644 --- a/modules/redis/module.json +++ b/modules/redis/module.json @@ -10,6 +10,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.redis.cache.provisioned", + "module.redis.cache.deprovisioned" + ], + "consumes": [ + "module.redis.cache.provisioned", + "module.redis.cache.deprovisioned" + ], "serves": { "redis-cache": {} }, @@ -20,7 +28,8 @@ "redis-cache": "/var/lib/redis-module/grants" }, "own-secrets": { - "default": "/var/lib/redis-module/default.secret" + "default": "/var/lib/redis-module/default.secret", + "broker": "/var/lib/mesh/redis/broker" }, "listens": [ { @@ -31,6 +40,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/redis", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -81,20 +96,22 @@ ] }, { - "id": "provisioner", + "id": "runtime", "type": "container", - "name": "mesh-provision-redis", - "image": "mesh-provision-redis@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-redis", + "image": "mesh-runtime-redis@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "redis", - "env": { - "GRANTS": "/var/lib/redis-module/grants", - "MESH_PROVISION_REDIS": "redis:6379", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default" - }, "volumes": [ + "/var/lib/mesh/redis/broker:/run/secrets/broker:ro", "/var/lib/redis-module/grants:/var/lib/redis-module/grants:ro", "/var/lib/redis-module/default.secret:/run/secrets/default:ro" - ] + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECEIVES": "/var/lib/redis-module/grants/mesh.json", + "MESH_PROVISION_REDIS": "redis:6379", + "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default" + } } ] } diff --git a/modules/redis/package.json b/modules/redis/package.json new file mode 100644 index 0000000..3032fb0 --- /dev/null +++ b/modules/redis/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-redis", + "version": "0.1.0", + "description": "redis — provides the mesh redis-cache interface. Its RESP client, provisioner, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/redis/provisioner/index.ts b/modules/redis/provisioner/index.ts new file mode 100644 index 0000000..0ad363b --- /dev/null +++ b/modules/redis/provisioner/index.ts @@ -0,0 +1,46 @@ +// redis's provisioner — the adapter that makes redis a provider of the mesh `redis-cache` +// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are +// the sdk harness's; this writes only the per-service half: how redis creates and removes a +// per-consumer cache (novox/hq ADR 0044/0045/0053). +// +// The `redis-cache` interface: a consumer connects as `as` with the password the mesh minted, and +// stores its keys under `:*`, isolated from every other consumer by an ACL user scoped to +// exactly that prefix. +// +// **The login and password are the mesh's, not the provisioner's (ADR 0053).** The mesh derives the +// login and hands it to both ends so they agree, and mints the password and delivers a copy to each. +// redis creates exactly that login with exactly that password — a name or password the provisioner +// invented is one the consumer could never present. + +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { RedisClient } from "../client.js"; + +const redis = RedisClient.fromEnv(); + +/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */ +async function announce(type: string, body: Record): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[provisioner:redis-cache] emit ${type} failed: ${err}`); + } +} + +runProvisioner("redis-cache", { + async create(p: Provision): Promise { + // The keyspace is scoped to the consumer's own login, so one cannot read another's keys. + const keyspacePrefix = p.as; + await redis.createAclUser(p.as, p.password, keyspacePrefix); + await announce("module.redis.cache.provisioned", { + consumer: p.consumer ?? "", + username: p.as, + keyspacePrefix, + }); + }, + + async remove(p: { as: string }): Promise { + await redis.deleteAclUser(p.as); + await announce("module.redis.cache.deprovisioned", { username: p.as }); + }, +}); diff --git a/modules/redis/tools/index.ts b/modules/redis/tools/index.ts new file mode 100644 index 0000000..239758c --- /dev/null +++ b/modules/redis/tools/index.ts @@ -0,0 +1,52 @@ +// redis's tools — redis's own code (novox/hq ADR 0044), importing redis's own RESP client. They +// return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { RedisClient, type RespValue } from "../client.js"; + +export function getRedisTools(redis: RedisClient): ToolDefinition[] { + return [ + { + name: "redis_ping", + description: "Check that the redis server is reachable and responding (PONG).", + input: {}, + run: async () => ({ ok: await redis.ping() }), + }, + { + name: "redis_info", + description: "Redis server info — memory, clients, keyspace. Omit section for everything.", + input: { section: { type: "string", description: "an INFO section, e.g. 'memory', 'clients', 'keyspace'" } }, + run: async (args) => { + const { raw, fields } = await redis.info(args.section ? String(args.section) : undefined); + return { fields, raw }; + }, + }, + { + name: "redis_command", + description: "Run an arbitrary redis command, e.g. 'DBSIZE', 'GET key', 'ACL LIST'. Admin surface.", + input: { command: { type: "string", description: "the command and its arguments, space-separated" } }, + run: async (args) => { + const parts = tokenize(String(args.command ?? "")); + if (parts.length === 0) throw new Error("redis_command: empty command"); + const reply: RespValue = await redis.command(...parts); + return { command: parts.join(" "), reply }; + }, + }, + ]; +} + +/** Split a command line into arguments, honouring double-quoted spans. */ +function tokenize(command: string): string[] { + const matches = command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; + return matches.map((p) => p.replace(/^"|"$/g, "")); +} + +// The tools exist only when the server can be reached from the environment; without it, redis +// contributes none rather than failing the whole tool runtime. +registerModuleTools("redis", (env) => { + try { + return getRedisTools(RedisClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/redis/tsconfig.json b/modules/redis/tsconfig.json new file mode 100644 index 0000000..51f4046 --- /dev/null +++ b/modules/redis/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] +} diff --git a/modules/registry/client.ts b/modules/registry/client.ts new file mode 100644 index 0000000..d78d61f --- /dev/null +++ b/modules/registry/client.ts @@ -0,0 +1,81 @@ +// The Docker Registry v2 client — registry's own code, living in the module (novox/hq ADR 0044). +// Ported from the shared hal sdk, where a change to the registry API rebuilt everything; here it +// rebuilds only registry. Both this module's tools and its events entrypoint import it. + +export interface RegistryImage { + repo: string; + tag: string; + digest: string; +} + +export class RegistryClient { + readonly baseUrl: string; + + // Auth is optional: a mesh-internal registry often runs open on the node, so a Basic header is + // sent only when credentials were configured — an empty one would look like a failed login. + constructor( + url: string, + private readonly authHeader?: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. The URL is MESH_REGISTRY_URL (or the local + * registry port), and credentials — if the registry requires them — are MESH_REGISTRY_USER and + * MESH_REGISTRY_PASSWORD. Throws when no URL is configured, so a misconfigured module exposes + * nothing rather than talking to the wrong place. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): RegistryClient { + const url = env.MESH_REGISTRY_URL ?? `http://127.0.0.1:${env.REGISTRY_PORT ?? "5000"}`; + if (!url) throw new Error("no registry URL — set MESH_REGISTRY_URL"); + const user = env.MESH_REGISTRY_USER; + const password = env.MESH_REGISTRY_PASSWORD; + const authHeader = + user && password ? `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}` : undefined; + return new RegistryClient(url, authHeader); + } + + private headers(extra: Record = {}): Record { + return { ...(this.authHeader ? { Authorization: this.authHeader } : {}), ...extra }; + } + + private async getJson(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { headers: this.headers() }); + if (!res.ok) throw new Error(`Registry ${path}: ${res.status} ${await res.text()}`); + return res.json() as Promise; + } + + /** The catalog — every repository the registry holds. */ + async listRepositories(): Promise { + const data = await this.getJson<{ repositories: string[] | null }>("/v2/_catalog"); + return data.repositories ?? []; + } + + /** The tags of one repository. */ + async listTags(repo: string): Promise { + const data = await this.getJson<{ tags: string[] | null }>(`/v2/${repo}/tags/list`); + return data.tags ?? []; + } + + /** The content digest of a repo:tag — the stable identity a tag currently points at. */ + async getManifestDigest(repo: string, tag: string): Promise { + const res = await fetch(`${this.baseUrl}/v2/${repo}/manifests/${tag}`, { + method: "HEAD", + headers: this.headers({ Accept: "application/vnd.docker.distribution.manifest.v2+json" }), + }); + if (!res.ok) throw new Error(`Registry manifest ${repo}:${tag}: ${res.status} ${await res.text()}`); + const digest = res.headers.get("docker-content-digest"); + if (!digest) throw new Error(`no Docker-Content-Digest for ${repo}:${tag}`); + return digest; + } + + /** Delete a manifest by digest. Garbage collection reclaims the storage later. */ + async deleteManifest(repo: string, digest: string): Promise { + const res = await fetch(`${this.baseUrl}/v2/${repo}/manifests/${digest}`, { + method: "DELETE", + headers: this.headers({ Accept: "application/vnd.docker.distribution.manifest.v2+json" }), + }); + if (!res.ok) throw new Error(`Registry delete ${repo}@${digest}: ${res.status} ${await res.text()}`); + } +} diff --git a/modules/registry/index.ts b/modules/registry/index.ts new file mode 100644 index 0000000..6bdd54f --- /dev/null +++ b/modules/registry/index.ts @@ -0,0 +1,51 @@ +// registry's events. The tool runtime imports this once the broker is bound. +// +// Emits (novox/hq ADR 0046/0047): +// module.registry.image.pushed — a new image (repo:tag) was published to the registry +// +// This is a genuinely useful signal: a build finished and its image is now pullable, so anything +// on the mesh that redeploys, mirrors or announces releases can react without polling the registry +// itself. It is discovered by diffing the catalog and each repo's tags — the registry has no push +// webhook of its own, so the module watches for it. +// +// The polling is deliberately unhurried: a new image a minute late is still the event, whereas +// hammering the registry's catalog for immediacy nobody asked for is not. + +import { emit } from "@novox/mesh-sdk/events"; +import { RegistryClient } from "./client.js"; + +const registry = RegistryClient.fromEnv(); + +// Every repo:tag we have already accounted for. Primed silently on the first look so a registry +// that was already full when this started does not announce its whole history as freshly pushed. +const seen = new Set(); +let primed = false; + +async function pollCatalog(): Promise { + const repos = await registry.listRepositories(); + for (const repo of repos) { + let tags: string[]; + try { + tags = await registry.listTags(repo); + } catch { + continue; // a repo can vanish between catalog and tag read — skip it, catch it next tick + } + for (const tag of tags) { + const id = `${repo}:${tag}`; + if (!seen.has(id)) { + if (primed) await emit("module.registry.image.pushed", { repo, tag }); + seen.add(id); + } + } + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[registry] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollCatalog, 60_000); + +console.log("[registry] watching the catalog for newly pushed images"); diff --git a/modules/registry/module.json b/modules/registry/module.json index 6fe2ba0..08cb45f 100644 --- a/modules/registry/module.json +++ b/modules/registry/module.json @@ -16,6 +16,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.registry.image.pushed" + ], + "own-secrets": { + "broker": "/var/lib/mesh/registry/broker" + }, "serves": { "artifact-store": { "port": 5000 diff --git a/modules/registry/package.json b/modules/registry/package.json new file mode 100644 index 0000000..74a4955 --- /dev/null +++ b/modules/registry/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-registry", + "version": "0.1.0", + "description": "registry — private Docker image registry. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/registry/tools/index.ts b/modules/registry/tools/index.ts new file mode 100644 index 0000000..e354baf --- /dev/null +++ b/modules/registry/tools/index.ts @@ -0,0 +1,59 @@ +// registry's tools — moved here from the shared sdk (novox/hq ADR 0044), importing registry's own +// client. They return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { RegistryClient } from "../client.js"; + +export function getRegistryTools(registry: RegistryClient): ToolDefinition[] { + return [ + { + name: "registry_list", + description: "List every repository in the Docker registry (the catalog).", + input: {}, + run: async () => { + const repositories = await registry.listRepositories(); + return { count: repositories.length, repositories }; + }, + }, + { + name: "registry_tags", + description: "List the tags of one repository in the Docker registry.", + input: { repo: { type: "string", description: "the repository name, e.g. 'novox/mesh'" } }, + run: async (args) => { + const repo = String(args.repo); + const tags = await registry.listTags(repo); + return { repo, count: tags.length, tags }; + }, + }, + { + name: "registry_delete_image", + description: + "Delete an image tag from the registry (DESTRUCTIVE). Removes the manifest; storage is reclaimed by garbage collection later. Requires confirm: true.", + input: { + repo: { type: "string", description: "the repository name, e.g. 'novox/mesh'" }, + tag: { type: "string", description: "the tag to delete, e.g. 'latest'" }, + confirm: { type: "boolean", description: "must be true to actually delete" }, + }, + run: async (args) => { + const repo = String(args.repo); + const tag = String(args.tag); + if (args.confirm !== true) { + return { deleted: false, reason: "confirm must be true to delete an image" }; + } + const digest = await registry.getManifestDigest(repo, tag); + await registry.deleteManifest(repo, digest); + return { deleted: true, repo, tag, digest, note: "run registry garbage collection to reclaim storage" }; + }, + }, + ]; +} + +// The tools exist only when a registry URL is configured; otherwise registry contributes none +// rather than failing the whole runtime. +registerModuleTools("registry", (env) => { + try { + return getRegistryTools(RegistryClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/registry/tsconfig.json b/modules/registry/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/registry/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/searxng/client.ts b/modules/searxng/client.ts new file mode 100644 index 0000000..8bddb1a --- /dev/null +++ b/modules/searxng/client.ts @@ -0,0 +1,86 @@ +// SearXNG's API client — searxng's own code, living in the module (novox/hq ADR 0044). SearXNG is a +// privacy-respecting metasearch engine: it forwards a query to many upstream engines and returns the +// merged results. Its JSON API (`/search?q=...&format=json`) is what makes a `searxng_search` tool +// useful; the client speaks only that. No credential — the instance is reached inside the mesh. + +import { readFileSync } from "node:fs"; + +export interface SearxResult { + title: string; + url: string; + content?: string; + engine?: string; + category?: string; + score?: number; +} + +export interface SearxSearch { + query: string; + numberOfResults: number; + results: SearxResult[]; + suggestions: string[]; + answers: string[]; +} + +export interface SearxOptions { + categories?: string; + language?: string; + pageno?: number; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class SearxngClient { + readonly baseUrl: string; + + constructor(url: string) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's environment. No key: SearXNG's search API is open on the mesh, so a URL + * is all it takes — defaulting to the container's own listen port. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): SearxngClient { + const cfg = meshConfig(env.MESH_SEARXNG_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`); + return new SearxngClient(url); + } + + async search(query: string, opts: SearxOptions = {}): Promise { + const params = new URLSearchParams({ q: query, format: "json" }); + if (opts.categories) params.set("categories", opts.categories); + if (opts.language) params.set("language", opts.language); + if (opts.pageno) params.set("pageno", String(opts.pageno)); + + const res = await fetch(`${this.baseUrl}/search?${params.toString()}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new Error(`SearXNG search: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { + results?: SearxResult[]; + suggestions?: string[]; + answers?: string[]; + number_of_results?: number; + }; + const results = data.results ?? []; + return { + query, + // SearXNG's own count is often 0 even with results; fall back to what we actually got. + numberOfResults: data.number_of_results || results.length, + results: results.map((r) => ({ + title: r.title, + url: r.url, + content: r.content, + engine: r.engine, + category: r.category, + score: r.score, + })), + suggestions: data.suggestions ?? [], + answers: data.answers ?? [], + }; + } +} diff --git a/modules/searxng/module.json b/modules/searxng/module.json index 8262a4b..d1d48dc 100644 --- a/modules/searxng/module.json +++ b/modules/searxng/module.json @@ -5,7 +5,8 @@ "container-runtime" ], "own-secrets": { - "secret": "/var/lib/searxng-module/secret.secret" + "secret": "/var/lib/searxng-module/secret.secret", + "broker": "/var/lib/mesh/searxng/broker" }, "listens": [ { @@ -16,6 +17,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/searxng", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -64,6 +71,33 @@ "ports": [ "8080" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/searxng/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-searxng", + "image": "mesh-runtime-searxng@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/searxng/broker:/run/secrets/broker:ro", + "/var/lib/mesh/searxng/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_SEARXNG_URL": "http://127.0.0.1:8080", + "MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/searxng/package.json b/modules/searxng/package.json new file mode 100644 index 0000000..45c1fbd --- /dev/null +++ b/modules/searxng/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-searxng", + "version": "0.1.0", + "description": "searxng — privacy-respecting metasearch. Its API client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/searxng/tools/index.ts b/modules/searxng/tools/index.ts new file mode 100644 index 0000000..f30c8c2 --- /dev/null +++ b/modules/searxng/tools/index.ts @@ -0,0 +1,37 @@ +// searxng's tools (novox/hq ADR 0044), importing searxng's own client. A stateless metasearch: one +// query in, merged results out — genuinely useful, and with nothing to observe over time it stays +// tools-only (no events, no broker). + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { SearxngClient } from "../client.js"; + +export function getSearxngTools(searxng: SearxngClient): ToolDefinition[] { + return [ + { + name: "searxng_search", + description: "Search the web through SearXNG's privacy-respecting metasearch — merged results from many engines.", + input: { + query: { type: "string", description: "the search query" }, + categories: { type: "string", description: "comma-separated categories, e.g. 'general', 'news', 'images'" }, + language: { type: "string", description: "language code, e.g. 'en' or 'en-US'" }, + }, + run: async (args) => { + const found = await searxng.search(String(args.query), { + categories: args.categories ? String(args.categories) : undefined, + language: args.language ? String(args.language) : undefined, + }); + return found; + }, + }, + ]; +} + +// A URL always resolves (it defaults), so this normally contributes its tool; the try/catch keeps a +// misconfiguration from taking the whole runtime down. +registerModuleTools("searxng", (env) => { + try { + return getSearxngTools(SearxngClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/searxng/tsconfig.json b/modules/searxng/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/searxng/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} diff --git a/modules/sonarr/client.ts b/modules/sonarr/client.ts new file mode 100644 index 0000000..fbf7ec2 --- /dev/null +++ b/modules/sonarr/client.ts @@ -0,0 +1,144 @@ +// The Sonarr API client — sonarr's own code, living in the module (novox/hq ADR 0044). Ported from +// the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a +// change to Sonarr's API rebuilds only sonarr and nothing else. Both this module's tools and its +// events entrypoint import it, and nothing outside sonarr does. + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Sonarr speaks the v3 API; its content is "series". +const API_VERSION = "v3"; +const CONTENT_ENDPOINT = "series"; +const APP_NAME = "Sonarr"; + +export interface SonarrQueueItem { + /** The queue record id — stable while the item is in the queue, so events can diff on it. */ + id: number; + title: string; + status: string; + size: string; + sizeleft: string; + timeleft?: string; +} + +export interface SonarrCalendarItem { + title: string; + date: string; + overview?: string; +} + +export interface SonarrContentItem { + title: string; + year?: number; + status?: string; + monitored: boolean; +} + +export class SonarrClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The URL defaults to the server on this node + * (the runtime shares its network), and the API key is read from MESH_SONARR_API_KEY or, failing + * that, discovered from the server's own config.xml under MESH_SONARR_CONFIG_DIR — the same file + * Sonarr writes it to, so a running server needs nothing configured by hand (as plex does with + * its token). Throws when no key can be found, so the tools/events simply do not load (the harness + * treats the throw as "exposes nothing"). + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): SonarrClient { + const url = env.MESH_SONARR_URL ?? `http://127.0.0.1:${env.MESH_SONARR_PORT ?? "8989"}`; + const configDir = env.MESH_SONARR_CONFIG_DIR ?? "/config"; + const apiKey = env.MESH_SONARR_API_KEY ?? SonarrClient.detectApiKey(configDir); + if (!apiKey) { + throw new Error("Sonarr not configured — set MESH_SONARR_API_KEY or make the config dir readable"); + } + return new SonarrClient(url, apiKey); + } + + /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app + * writes into config.xml at the root of its config directory. */ + static detectApiKey(configDir: string): string | null { + const config = join(configDir, "config.xml"); + if (existsSync(config)) { + const match = readFileSync(config, "utf8").match(/([^<]+)<\/ApiKey>/); + if (match) return match[1]; + } + return null; + } + + private async get(endpoint: string, params?: Record): Promise { + const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + } + const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } }); + if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async getStatus(): Promise<{ appName: string; version: string }> { + const data = (await this.get("system/status")) as { appName?: string; version?: string }; + return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" }; + } + + async getContent(limit?: number): Promise { + const data = await this.get(CONTENT_ENDPOINT); + const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []); + const mapped = items.map((item) => ({ + title: item.title ?? "Unknown", + year: item.year, + status: item.status, + monitored: item.monitored ?? true, + })); + return limit ? mapped.slice(0, limit) : mapped; + } + + /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ + async searchContent(term: string): Promise { + const all = await this.getContent(); + const lower = term.toLowerCase(); + return all.filter((item) => item.title.toLowerCase().includes(lower)); + } + + async getQueue(): Promise<{ totalRecords: number; items: SonarrQueueItem[] }> { + const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] }; + const records = data.records ?? []; + return { + totalRecords: data.totalRecords ?? records.length, + items: records.map((r) => ({ + id: r.id, + title: r.title ?? r.series?.title ?? "Unknown", + status: r.status ?? "unknown", + size: formatBytes(r.size ?? 0), + sizeleft: formatBytes(r.sizeleft ?? 0), + timeleft: r.timeleft, + })), + }; + } + + async getCalendar(days = 7): Promise { + const start = new Date().toISOString().split("T")[0]; + const end = new Date(Date.now() + days * 86400000).toISOString().split("T")[0]; + const data = await this.get("calendar", { start, end }); + const items: any[] = Array.isArray(data) ? data : []; + return items.map((item) => ({ + title: item.title ?? item.series?.title ?? "Unknown", + date: item.airDateUtc ?? "", + overview: item.overview?.slice(0, 150), + })); + } +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} diff --git a/modules/sonarr/index.ts b/modules/sonarr/index.ts new file mode 100644 index 0000000..fb03857 --- /dev/null +++ b/modules/sonarr/index.ts @@ -0,0 +1,55 @@ +// sonarr's events. The tool runtime imports this once the broker is bound. It watches the download +// queue and turns its comings and goings into mesh events. +// +// Emits (novox/hq ADR 0046/0047): +// module.sonarr.episode.grabbed — a release entered the queue (Sonarr grabbed it) +// module.sonarr.download.completed — a release left the queue, imported. This exact routing key +// is what the plex module consumes (module.*.download.completed) +// to rescan, so the new episode becomes a visible item. +// Consumes: none. +// +// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a +// restart mid-download does not re-announce everything already in flight as freshly grabbed. + +import { emit } from "@novox/mesh-sdk/events"; +import { SonarrClient, type SonarrQueueItem } from "./client.js"; + +const sonarr = SonarrClient.fromEnv(); + +// Sonarr removes an item from the queue once it has been imported; a "warning"/"failed" status is +// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish. +const FAILED_STATUSES = new Set(["failed", "warning"]); + +const inQueue = new Map(); +let primed = false; + +async function pollQueue(): Promise { + const { items } = await sonarr.getQueue(); + const now = new Map(items.map((i) => [i.id, i])); + + if (primed) { + // Entered the queue since last look — Sonarr grabbed a release. + for (const [id, item] of now) { + if (!inQueue.has(id)) await emit("module.sonarr.episode.grabbed", { title: item.title, status: item.status }); + } + // Left the queue — imported and done, unless it was last seen failing. + for (const [id, item] of inQueue) { + if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { + await emit("module.sonarr.download.completed", { title: item.title }); + } + } + } + + inQueue.clear(); + for (const [id, item] of now) inQueue.set(id, item); + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[sonarr] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollQueue, 30_000); + +console.log("[sonarr] watching the download queue, emitting grabs and completions"); diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json index b42fb6c..9e19164 100644 --- a/modules/sonarr/module.json +++ b/modules/sonarr/module.json @@ -4,6 +4,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.sonarr.episode.grabbed", + "module.sonarr.download.completed" + ], + "consumes": [], + "own-secrets": { + "broker": "/var/lib/mesh/sonarr/broker" + }, "listens": [ { "port": 8989, @@ -13,6 +21,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/sonarr", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -60,6 +74,22 @@ "/services/media/anime:/anime", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-sonarr", + "image": "mesh-runtime-sonarr@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/sonarr/broker:/run/secrets/broker:ro", + "/services/sonarr/config:/var/lib/sonarr/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_SONARR_URL": "http://127.0.0.1:8989", + "MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config" + } } ] } diff --git a/modules/sonarr/package.json b/modules/sonarr/package.json new file mode 100644 index 0000000..7fd642d --- /dev/null +++ b/modules/sonarr/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-sonarr", + "version": "0.1.0", + "description": "sonarr — TV series management. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/sonarr/tools/index.ts b/modules/sonarr/tools/index.ts new file mode 100644 index 0000000..ea5a586 --- /dev/null +++ b/modules/sonarr/tools/index.ts @@ -0,0 +1,78 @@ +// sonarr's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing sonarr's own +// client. They return structured data (not pre-formatted text as hal did); the mesh serves them +// through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { SonarrClient } from "../client.js"; + +export function getSonarrTools(sonarr: SonarrClient): ToolDefinition[] { + return [ + { + name: "sonarr_status", + description: "Sonarr status overview: version, series count, monitored count, queue size.", + input: {}, + run: async () => { + const [status, content, queue] = await Promise.all([ + sonarr.getStatus(), + sonarr.getContent(), + sonarr.getQueue(), + ]); + return { + app: status.appName, + version: status.version, + series: content.length, + monitored: content.filter((c) => c.monitored).length, + queue: queue.totalRecords, + }; + }, + }, + { + name: "sonarr_library", + description: "List series from the Sonarr library.", + input: { limit: { type: "number", description: "max items to return (default 50)" } }, + run: async (args) => { + const items = await sonarr.getContent(args.limit ? Number(args.limit) : 50); + return { count: items.length, series: items }; + }, + }, + { + name: "sonarr_search", + description: "Search the Sonarr library for series by title (filters existing content, not indexers).", + input: { query: { type: "string", description: "the search term" } }, + run: async (args) => { + const query = String(args.query); + return { query, results: await sonarr.searchContent(query) }; + }, + }, + { + name: "sonarr_queue", + description: "Show the Sonarr download queue — what is downloading and how far along.", + input: {}, + run: async () => { + const queue = await sonarr.getQueue(); + return { count: queue.totalRecords, items: queue.items }; + }, + }, + { + name: "sonarr_calendar", + description: "Upcoming episode releases from the Sonarr calendar.", + input: { days: { type: "number", description: "how many days to look ahead (default 7)" } }, + run: async (args) => { + const days = args.days ? Number(args.days) : 7; + const items = await sonarr.getCalendar(days); + items.sort((a, b) => a.date.localeCompare(b.date)); + return { days, count: items.length, items }; + }, + }, + ]; +} + +// The tools exist only when Sonarr is configured; without a URL and key, sonarr contributes none +// rather than failing the whole runtime. +registerModuleTools("sonarr", (env) => { + try { + return getSonarrTools(SonarrClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/sonarr/tsconfig.json b/modules/sonarr/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/sonarr/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/tautulli/client.ts b/modules/tautulli/client.ts new file mode 100644 index 0000000..2096a07 --- /dev/null +++ b/modules/tautulli/client.ts @@ -0,0 +1,108 @@ +// Tautulli's API client — tautulli's own code, living in the module (novox/hq ADR 0044). Both this +// module's tools and its events entrypoint import it, and nothing outside tautulli does. +// +// Tautulli speaks one endpoint: GET /api/v2?apikey=…&cmd=…&, answering +// { response: { result: "success" | "error", message, data } }. This client unwraps that envelope +// and hands back only the data. + +import { readFileSync } from "node:fs"; + +export interface TautulliSession { + user: string; + title: string; + mediaType: string; + state: string; + progressPercent: number; + player: string; +} + +export interface TautulliWatch { + /** Tautulli's history row id — the stable identity a recorded watch is diffed on. */ + id: number; + user: string; + title: string; + mediaType: string; + /** "watched" | "watching" | ... — Tautulli's own watched_status label. */ + watchedStatus: string; + percentComplete: number; + /** Unix seconds the play started, as Tautulli reports it. */ + date?: number; +} + +export interface TautulliHomeStat { + statId: string; + rows: Array>; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class TautulliClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The API key is read from MESH_TAUTULLI_APIKEY + * (Tautulli mints it in Settings → Web Interface); the base URL defaults to the local container. + * Throws when no key is configured — the module then contributes nothing rather than failing. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient { + const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`); + const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY; + if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY"); + return new TautulliClient(url, apiKey); + } + + /** Call one Tautulli command and return its unwrapped data, throwing on a non-success result. */ + private async cmd(command: string, params: Record = {}): Promise { + const q = new URLSearchParams({ apikey: this.apiKey, cmd: command, ...params }); + const res = await fetch(`${this.baseUrl}/api/v2?${q.toString()}`); + if (!res.ok) throw new Error(`Tautulli ${command}: ${res.status} ${await res.text()}`); + const body = (await res.json()).response ?? {}; + if (body.result !== "success") throw new Error(`Tautulli ${command}: ${body.message ?? "error"}`); + return body.data; + } + + async getActivity(): Promise<{ streamCount: number; sessions: TautulliSession[] }> { + const data = await this.cmd("get_activity"); + const sessions = ((data?.sessions ?? []) as any[]).map((s) => ({ + user: s.friendly_name ?? s.user ?? "unknown", + title: s.full_title ?? s.title ?? "unknown", + mediaType: s.media_type ?? "unknown", + state: s.state ?? "unknown", + progressPercent: Number(s.progress_percent ?? 0), + player: s.player ?? "unknown", + })); + return { streamCount: Number(data?.stream_count ?? sessions.length), sessions }; + } + + async getHistory(length = 25): Promise { + const data = await this.cmd("get_history", { length: String(length), order_column: "date", order_dir: "desc" }); + return ((data?.data ?? []) as any[]).map((r) => ({ + id: Number(r.row_id ?? r.id ?? r.reference_id ?? 0), + user: r.friendly_name ?? r.user ?? "unknown", + title: r.full_title ?? r.title ?? "unknown", + mediaType: r.media_type ?? "unknown", + watchedStatus: String(r.watched_status ?? ""), + percentComplete: Number(r.percent_complete ?? 0), + date: r.date != null ? Number(r.date) : undefined, + })); + } + + /** The home-page statistics blocks — most-watched shows, most-active users, and so on. */ + async getHomeStats(): Promise { + const data = (await this.cmd("get_home_stats")) as any[]; + return (data ?? []).map((s) => ({ statId: s.stat_id, rows: s.rows ?? [] })); + } +} diff --git a/modules/tautulli/index.ts b/modules/tautulli/index.ts new file mode 100644 index 0000000..faa6c77 --- /dev/null +++ b/modules/tautulli/index.ts @@ -0,0 +1,48 @@ +// tautulli's events. The tool runtime imports this once the broker is bound. It watches Tautulli's +// history and announces each newly recorded watch. +// +// Emits (novox/hq ADR 0046/0047): +// module.tautulli.watch.recorded — a play appeared in Tautulli's history +// +// Diffed on the history row id and primed silently on the first look, so a restart does not +// re-announce the whole existing history as freshly watched. + +import { emit } from "@novox/mesh-sdk/events"; +import { TautulliClient, type TautulliWatch } from "./client.js"; + +// Constructed lazily so an unconfigured node (no API key) loads this entrypoint without crashing +// the events host — it simply watches nothing. +let tautulli: TautulliClient | undefined; +try { + tautulli = TautulliClient.fromEnv(); +} catch (err) { + console.log(`[tautulli] not configured, not watching history: ${err}`); +} + +const seen = new Set(); +let primed = false; + +async function pollHistory(client: TautulliClient): Promise { + const history = await client.getHistory(25); + for (const w of history) { + if (w.id === 0 || seen.has(w.id)) continue; + if (primed) await emitWatch(w); + seen.add(w.id); + } + primed = true; +} + +async function emitWatch(w: TautulliWatch): Promise { + await emit("module.tautulli.watch.recorded", { + title: w.title, user: w.user, mediaType: w.mediaType, + watchedStatus: w.watchedStatus, percentComplete: w.percentComplete, at: w.date, + }); +} + +if (tautulli) { + const client = tautulli; + const run = (): void => void pollHistory(client).catch((err) => console.error(`[tautulli] ${err}`)); + setInterval(run, 60_000); + run(); + console.log("[tautulli] watching watch history"); +} diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json index da05783..1b63d19 100644 --- a/modules/tautulli/module.json +++ b/modules/tautulli/module.json @@ -1,6 +1,12 @@ { "module": "tautulli", "version": "1", + "emits": [ + "module.tautulli.watch.recorded" + ], + "own-secrets": { + "broker": "/var/lib/mesh/tautulli/broker" + }, "capabilities": [ "container-runtime" ], @@ -13,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/tautulli", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -36,6 +48,35 @@ "volumes": [ "/services/tautulli/config:/config" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/tautulli/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-tautulli", + "image": "mesh-runtime-tautulli@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/tautulli/broker:/run/secrets/broker:ro", + "/var/lib/mesh/tautulli/config.json:/run/config/config.json:ro", + "/services/tautulli/config:/var/lib/tautulli/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_TAUTULLI_URL": "http://127.0.0.1:8181", + "MESH_TAUTULLI_CONFIG_FILE": "/run/config/config.json", + "MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/tautulli/package.json b/modules/tautulli/package.json new file mode 100644 index 0000000..9cd4335 --- /dev/null +++ b/modules/tautulli/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-tautulli", + "version": "0.1.0", + "description": "tautulli — Plex watch statistics. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/tautulli/tools/index.ts b/modules/tautulli/tools/index.ts new file mode 100644 index 0000000..b5697d6 --- /dev/null +++ b/modules/tautulli/tools/index.ts @@ -0,0 +1,44 @@ +// tautulli's tools — importing tautulli's own client (novox/hq ADR 0044). They return structured +// data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { TautulliClient } from "../client.js"; + +export function getTautulliTools(tautulli: TautulliClient): ToolDefinition[] { + return [ + { + name: "tautulli_activity", + description: "Current Plex activity as Tautulli sees it — who is streaming what, and progress.", + input: {}, + run: async () => tautulli.getActivity(), + }, + { + name: "tautulli_history", + description: "Recent Plex watch history — who watched what, and whether they finished.", + input: { length: { type: "number", description: "how many rows (default 25)" } }, + run: async (args) => { + const history = await tautulli.getHistory(args.length ? Number(args.length) : 25); + return { count: history.length, history }; + }, + }, + { + name: "tautulli_stats", + description: "Tautulli home statistics — most-watched media, most-active users, and platforms.", + input: {}, + run: async () => { + const stats = await tautulli.getHomeStats(); + return { count: stats.length, stats }; + }, + }, + ]; +} + +// The tools exist only when an API key can be resolved; without one, tautulli contributes none +// rather than failing the whole tool runtime. +registerModuleTools("tautulli", (env) => { + try { + return getTautulliTools(TautulliClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/tautulli/tsconfig.json b/modules/tautulli/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/tautulli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/umami/module.json b/modules/umami/module.json index b4b5d19..022da13 100644 --- a/modules/umami/module.json +++ b/modules/umami/module.json @@ -4,7 +4,6 @@ "capabilities": [ "container-runtime" ], - "requires": [ "postgres-database" ], @@ -19,7 +18,6 @@ "secrets": { "postgres-database": "/var/lib/umami/database.secret" }, - "provides": [ { "name": "analytics", @@ -35,12 +33,11 @@ "grants": { "analytics": "/var/lib/umami/grants" }, - "own-secrets": { "app-secret": "/var/lib/umami/app.secret", - "admin": "/var/lib/umami/admin.secret" + "admin": "/var/lib/umami/admin.secret", + "broker": "/var/lib/mesh/umami/broker" }, - "listens": [ { "port": 3000, @@ -49,8 +46,13 @@ "why": "one port serves two surfaces: the dashboard (the proxy gates it to the mesh) and the public collection endpoint that the browsers of every tracked site POST to — so the port itself must be reachable from anywhere" } ], - "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/umami", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -96,17 +98,22 @@ ] }, { - "id": "provisioner", + "id": "runtime", "type": "container", - "name": "mesh-provision-umami-analytics", - "image": "mesh-provision-umami-analytics@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-umami", + "image": "mesh-runtime-umami@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "umami", - "env-file": [ - "/var/lib/umami/provisioner.env" - ], "volumes": [ + "/var/lib/mesh/umami/broker:/run/secrets/broker:ro", "/var/lib/umami/grants:/var/lib/umami/grants", "/var/lib/umami/admin.secret:/run/secrets/admin:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECEIVES": "/var/lib/umami/grants/mesh.json" + }, + "env-file": [ + "/var/lib/umami/provisioner.env" ] } ] diff --git a/modules/umami/provisioner/index.ts b/modules/umami/provisioner/index.ts index 33f6f5f..f9ba5c2 100644 --- a/modules/umami/provisioner/index.ts +++ b/modules/umami/provisioner/index.ts @@ -1,35 +1,39 @@ // umami's provisioner — the adapter that makes umami a provider of the mesh `analytics` interface. -// The watching, sealing and grant-file handling are the sdk harness's; this writes only the -// per-service half: how umami creates and removes a tracked site (novox/hq ADR 0044/0045). +// The reconcile loop and the contributions file are the sdk harness's; this writes only the +// per-service half: how umami creates and removes a tracked site (novox/hq ADR 0044/0045/0053). // // The `analytics` interface: a consumer contributes `{ domain }` (the site it wants tracked) and -// receives `{ siteId, snippet, dashboard }`. umami adapts its own API to that contract, so a -// consumer depends on `analytics`, not on umami. +// receives `{ siteId, snippet, dashboard }`. +// +// **A note on scope (ADR 0053).** ADR 0053 corrects *credential* provisions: the mesh mints a secret +// and the provider creates a login with it. Analytics is not that shape — it mints no secret the +// consumer authenticates with; what the consumer needs back is data umami *generates* (the siteId). +// The credential-provisioner contract returns nothing, so the siteId does not travel back to the +// consumer here. That return path — for a provider that generates data rather than being handed a +// secret — is a separate concern and is not solved by this decision. umami still reconciles its +// sites off the mesh's contributions (it keys on the login the mesh derived), which is what this +// keeps working. -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { UmamiClient } from "../client.js"; const umami = UmamiClient.fromEnv(); runProvisioner("analytics", { - async create(grant: Grant): Promise { - const domain = grant.values.domain ?? grant.consumer; - const name = grant.values.name ?? domain; + async create(p: Provision): Promise { + const domain = String(p.values.domain ?? p.as); + const name = String(p.values.name ?? domain); const token = await umami.getToken(); - const site = (await umami.findWebsite(token, domain)) ?? (await umami.createWebsite(token, domain, name)); - return { - fields: { - siteId: site.id, - snippet: umami.snippet(site.id), - dashboard: umami.dashboard(site.id), - }, - }; + // Idempotent: only create the site if it is not already there. + if (!(await umami.findWebsite(token, domain))) { + await umami.createWebsite(token, domain, name); + } }, - async remove(grant: Grant): Promise { - const domain = grant.values.domain ?? grant.consumer; + async remove(p: { as: string }): Promise { const token = await umami.getToken(); - const site = await umami.findWebsite(token, domain); + // Keyed on the mesh-derived login, the one identity the harness carries into removal. + const site = await umami.findWebsite(token, p.as); if (site) await umami.deleteWebsite(token, site.id); }, }); diff --git a/modules/verdaccio/client.ts b/modules/verdaccio/client.ts new file mode 100644 index 0000000..0a9acb0 --- /dev/null +++ b/modules/verdaccio/client.ts @@ -0,0 +1,91 @@ +// The Verdaccio (npm registry) client — verdaccio's own code, living in the module (novox/hq +// ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside +// verdaccio does. + +import { readFileSync } from "node:fs"; + +export interface VerdaccioPackage { + name: string; + version?: string; + description?: string; + time?: string; +} + +export interface PackageInfo { + name: string; + latest?: string; + versions: string[]; + description?: string; + modified?: string; +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + +export class VerdaccioClient { + readonly baseUrl: string; + + // A bearer token is optional: package listing and reading are public on most registries, so the + // token is sent only when configured, for a registry that gates reads behind auth. + constructor( + url: string, + private readonly token?: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. The URL is MESH_VERDACCIO_URL (or the local + * port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient { + const cfg = meshConfig(env.MESH_VERDACCIO_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`); + if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL"); + return new VerdaccioClient(url, cfg.token ?? env.MESH_VERDACCIO_TOKEN); + } + + private async getJson(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + headers: { + Accept: "application/json", + ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), + }, + }); + if (!res.ok) throw new Error(`Verdaccio ${path}: ${res.status} ${await res.text()}`); + return res.json() as Promise; + } + + /** + * Every package the registry hosts, from Verdaccio's own web API — the same list its UI shows. + * Each entry carries the latest version and the time it was last published. + */ + async listPackages(): Promise { + const raw = await this.getJson("/-/verdaccio/data/packages"); + return (raw ?? []).map((p) => ({ + name: p.name, + version: p.version ?? p["dist-tags"]?.latest, + description: p.description, + time: p.time?.modified ?? p.time, + })); + } + + /** + * The full detail of one package — its dist-tags, every published version, and timestamps — + * from the standard npm packument endpoint (`GET /`). + */ + async getPackageInfo(name: string): Promise { + const doc = await this.getJson(`/${encodeURIComponent(name).replace(/%2F/g, "/")}`); + return { + name: doc.name ?? name, + latest: doc["dist-tags"]?.latest, + versions: Object.keys(doc.versions ?? {}), + description: doc.description, + modified: doc.time?.modified, + }; + } +} diff --git a/modules/verdaccio/index.ts b/modules/verdaccio/index.ts new file mode 100644 index 0000000..6eeb889 --- /dev/null +++ b/modules/verdaccio/index.ts @@ -0,0 +1,45 @@ +// verdaccio's events. The tool runtime imports this once the broker is bound. +// +// Emits (novox/hq ADR 0046/0047): +// module.verdaccio.package.published — a new package version was published to the registry +// +// A genuinely useful signal: a package was just published, so anything on the mesh that pins, +// mirrors or announces dependency releases can react without polling the registry. Verdaccio has +// no publish webhook, so the module discovers it by diffing the package list's latest versions. +// +// The polling is deliberately unhurried: a publish a minute late is still the event, whereas +// hammering the registry for immediacy nobody asked for is not. + +import { emit } from "@novox/mesh-sdk/events"; +import { VerdaccioClient } from "./client.js"; + +const verdaccio = VerdaccioClient.fromEnv(); + +// The latest version we have seen per package name. Primed silently on the first look so a registry +// that was already populated when this started does not announce its whole catalog as freshly +// published. +const latest = new Map(); +let primed = false; + +async function pollPackages(): Promise { + const packages = await verdaccio.listPackages(); + for (const pkg of packages) { + if (!pkg.version) continue; + const known = latest.get(pkg.name); + if (known !== pkg.version) { + // A name we have not seen, or a name whose latest version moved — both are a publish. + if (primed) await emit("module.verdaccio.package.published", { name: pkg.name, version: pkg.version }); + latest.set(pkg.name, pkg.version); + } + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[verdaccio] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollPackages, 60_000); + +console.log("[verdaccio] watching the registry for newly published packages"); diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index 34196f2..17f963a 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.verdaccio.package.published" + ], + "own-secrets": { + "broker": "/var/lib/mesh/verdaccio/broker" + }, "listens": [ { "port": 4873, @@ -13,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/verdaccio", + "mode": "0700" + }, { "id": "conf", "type": "directory", @@ -46,6 +58,33 @@ "/services/verdaccio/storage:/verdaccio/storage", "/services/verdaccio/conf:/verdaccio/conf" ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/verdaccio/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-verdaccio", + "image": "mesh-runtime-verdaccio@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/verdaccio/broker:/run/secrets/broker:ro", + "/var/lib/mesh/verdaccio/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_VERDACCIO_URL": "http://127.0.0.1:4873", + "MESH_VERDACCIO_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/verdaccio/package.json b/modules/verdaccio/package.json new file mode 100644 index 0000000..644ef27 --- /dev/null +++ b/modules/verdaccio/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-verdaccio", + "version": "0.1.0", + "description": "verdaccio — private npm registry. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/verdaccio/tools/index.ts b/modules/verdaccio/tools/index.ts new file mode 100644 index 0000000..055745d --- /dev/null +++ b/modules/verdaccio/tools/index.ts @@ -0,0 +1,35 @@ +// verdaccio's tools — its own code (novox/hq ADR 0044), importing verdaccio's own client. They +// return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { VerdaccioClient } from "../client.js"; + +export function getVerdaccioTools(verdaccio: VerdaccioClient): ToolDefinition[] { + return [ + { + name: "verdaccio_list_packages", + description: "List every package hosted on the private npm registry, with each one's latest version.", + input: {}, + run: async () => { + const packages = await verdaccio.listPackages(); + return { count: packages.length, packages }; + }, + }, + { + name: "verdaccio_package_info", + description: "Details of one package on the registry: its latest tag, all published versions, and description.", + input: { name: { type: "string", description: "the package name, e.g. '@novox/mesh-sdk'" } }, + run: async (args) => verdaccio.getPackageInfo(String(args.name)), + }, + ]; +} + +// The tools exist only when a registry URL is configured; otherwise verdaccio contributes none +// rather than failing the whole runtime. +registerModuleTools("verdaccio", (env) => { + try { + return getVerdaccioTools(VerdaccioClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/verdaccio/tsconfig.json b/modules/verdaccio/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/verdaccio/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +}