From 229c6a83d1ea8651ed2d83c1095a91635f0ca055 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 12:04:14 +0200 Subject: [PATCH] Convert four more hal modules: bookshelf, unifi, fail2ban, marrytts - bookshelf: Servarr v1 fork on the radarr template (4 tools). - unifi: portainer-shaped tooled app (7 tools, 9 ports), settings-merged config. - fail2ban: host-level security module mirroring firewall (service + restart-on, no container); ban actions preserved as source ufw/iptables and FLAGGED to be rewritten nftables-native before it actually bans. - marrytts: manifest-only plain container (no tools), like resolv-conf. All typecheck against the built @novox/mesh-sdk; service images digest-pinned. Held from merge pending the hq initialization reconciliation. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/bookshelf/client.ts | 136 ++++++++++++++++ modules/bookshelf/index.ts | 60 +++++++ modules/bookshelf/module.json | 87 +++++++++++ modules/bookshelf/package.json | 14 ++ modules/bookshelf/tools/index.ts | 69 ++++++++ modules/bookshelf/tsconfig.json | 12 ++ modules/fail2ban/client.ts | 51 ++++++ modules/fail2ban/module.json | 80 ++++++++++ modules/fail2ban/package.json | 14 ++ modules/fail2ban/tools/index.ts | 55 +++++++ modules/fail2ban/tsconfig.json | 15 ++ modules/marrytts/module.json | 35 +++++ modules/unifi/client.ts | 260 +++++++++++++++++++++++++++++++ modules/unifi/module.json | 135 ++++++++++++++++ modules/unifi/package.json | 14 ++ modules/unifi/tools/index.ts | 143 +++++++++++++++++ modules/unifi/tsconfig.json | 12 ++ 17 files changed, 1192 insertions(+) create mode 100644 modules/bookshelf/client.ts create mode 100644 modules/bookshelf/index.ts create mode 100644 modules/bookshelf/module.json create mode 100644 modules/bookshelf/package.json create mode 100644 modules/bookshelf/tools/index.ts create mode 100644 modules/bookshelf/tsconfig.json create mode 100644 modules/fail2ban/client.ts create mode 100644 modules/fail2ban/module.json create mode 100644 modules/fail2ban/package.json create mode 100644 modules/fail2ban/tools/index.ts create mode 100644 modules/fail2ban/tsconfig.json create mode 100644 modules/marrytts/module.json create mode 100644 modules/unifi/client.ts create mode 100644 modules/unifi/module.json create mode 100644 modules/unifi/package.json create mode 100644 modules/unifi/tools/index.ts create mode 100644 modules/unifi/tsconfig.json diff --git a/modules/bookshelf/client.ts b/modules/bookshelf/client.ts new file mode 100644 index 0000000..7c9003a --- /dev/null +++ b/modules/bookshelf/client.ts @@ -0,0 +1,136 @@ +// The Bookshelf API client — bookshelf'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 Bookshelf's API rebuilds only bookshelf and nothing else. Both this module's +// tools and its events entrypoint import it, and nothing outside bookshelf does. +// +// Bookshelf is a Readarr fork (ghcr.io/pennydreadful/bookshelf). It speaks the Servarr v1 API; its +// content is "book". Unlike Sonarr/Radarr it exposes no calendar endpoint, so there is no calendar +// tool here — matching hal, which excluded bookshelf from its calendar-capable apps. + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Bookshelf speaks the v1 API; its content is "book". +const API_VERSION = "v1"; +const CONTENT_ENDPOINT = "book"; +const APP_NAME = "Bookshelf"; + +export interface BookshelfQueueItem { + /** 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 BookshelfContentItem { + title: string; + author?: string; + year?: number; + status?: string; + monitored: boolean; +} + +export class BookshelfClient { + 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_BOOKSHELF_API_KEY or, failing + * that, discovered from the server's own config.xml under MESH_BOOKSHELF_CONFIG_DIR — the same + * file Bookshelf 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): BookshelfClient { + const url = env.MESH_BOOKSHELF_URL ?? `http://127.0.0.1:${env.MESH_BOOKSHELF_PORT ?? "8787"}`; + const configDir = env.MESH_BOOKSHELF_CONFIG_DIR ?? "/config"; + const apiKey = env.MESH_BOOKSHELF_API_KEY ?? BookshelfClient.detectApiKey(configDir); + if (!apiKey) { + throw new Error("Bookshelf not configured — set MESH_BOOKSHELF_API_KEY or make the config dir readable"); + } + return new BookshelfClient(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", + author: item.author?.authorName ?? item.authorName, + year: item.releaseDate ? new Date(item.releaseDate).getFullYear() : 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) || + (item.author?.toLowerCase().includes(lower) ?? false), + ); + } + + async getQueue(): Promise<{ totalRecords: number; items: BookshelfQueueItem[] }> { + 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.book?.title ?? r.author?.authorName ?? "Unknown", + status: r.status ?? "unknown", + size: formatBytes(r.size ?? 0), + sizeleft: formatBytes(r.sizeleft ?? 0), + timeleft: r.timeleft, + })), + }; + } +} + +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/bookshelf/index.ts b/modules/bookshelf/index.ts new file mode 100644 index 0000000..6c3fcf4 --- /dev/null +++ b/modules/bookshelf/index.ts @@ -0,0 +1,60 @@ +// bookshelf'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 — the same mechanism radarr uses, +// applied to a Servarr book manager. +// +// Emits (novox/hq ADR 0046/0047): +// module.bookshelf.book.grabbed — a release entered the queue (Bookshelf grabbed it) +// module.bookshelf.download.completed — a release left the queue, imported. This routing key is +// what the plex module consumes (module.*.download.completed) +// to rescan, so a new audiobook becomes a visible item. +// Consumes: none. +// +// NOTE: the hal bookshelf module emitted no events (its hooks only did install-time provisioning). +// This queue watcher is new in nox, modelled exactly on radarr's — bookshelf is a Servarr app with +// the same queue semantics, so the diff-and-emit pattern carries over unchanged. +// +// 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 { BookshelfClient, type BookshelfQueueItem } from "./client.js"; + +const bookshelf = BookshelfClient.fromEnv(); + +// Bookshelf 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 bookshelf.getQueue(); + const now = new Map(items.map((i) => [i.id, i])); + + if (primed) { + // Entered the queue since last look — Bookshelf grabbed a release. + for (const [id, item] of now) { + if (!inQueue.has(id)) await emit("module.bookshelf.book.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.bookshelf.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(`[bookshelf] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollQueue, 30_000); + +console.log("[bookshelf] watching the download queue, emitting grabs and completions"); diff --git a/modules/bookshelf/module.json b/modules/bookshelf/module.json new file mode 100644 index 0000000..6242c72 --- /dev/null +++ b/modules/bookshelf/module.json @@ -0,0 +1,87 @@ +{ + "module": "bookshelf", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "emits": [ + "module.bookshelf.book.grabbed", + "module.bookshelf.download.completed" + ], + "consumes": [], + "own-secrets": { + "broker": "/var/lib/mesh/bookshelf/broker" + }, + "listens": [ + { + "port": 8787, + "protocol": "tcp", + "from": "mesh", + "why": "managing the ebook/audiobook library" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/bookshelf", + "mode": "0700" + }, + { + "id": "config", + "type": "directory", + "path": "/services/bookshelf/config", + "mode": "0700", + "owner": "1000:1000" + }, + { + "id": "media-books", + "type": "directory", + "path": "/services/media/books", + "mode": "0755", + "owner": "1000:1000" + }, + { + "id": "media-downloads", + "type": "directory", + "path": "/services/media/downloads", + "mode": "0755", + "owner": "1000:1000" + }, + { + "id": "server", + "type": "container", + "name": "bookshelf", + "image": "ghcr.io/pennydreadful/bookshelf@sha256:388eecc94362580eae31ee0a454be6af516f8a311f8432a521c202fb475f4359", + "env": { + "PUID": "1000", + "PGID": "1000", + "TZ": "Etc/UTC" + }, + "ports": [ + "8787" + ], + "volumes": [ + "/services/bookshelf/config:/config", + "/services/media/books:/books", + "/services/media/downloads:/downloads" + ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-bookshelf", + "image": "mesh-runtime-bookshelf@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/bookshelf/broker:/run/secrets/broker:ro", + "/services/bookshelf/config:/var/lib/bookshelf/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_BOOKSHELF_URL": "http://127.0.0.1:8787", + "MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config" + } + } + ] +} diff --git a/modules/bookshelf/package.json b/modules/bookshelf/package.json new file mode 100644 index 0000000..f39dee7 --- /dev/null +++ b/modules/bookshelf/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-bookshelf", + "version": "0.1.0", + "description": "bookshelf — ebook/audiobook management (Readarr fork). 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/bookshelf/tools/index.ts b/modules/bookshelf/tools/index.ts new file mode 100644 index 0000000..1e00c90 --- /dev/null +++ b/modules/bookshelf/tools/index.ts @@ -0,0 +1,69 @@ +// bookshelf's tools — ported from the shared hal `arr` sdk (novox/hq ADR 0044), importing +// bookshelf's own client. They return structured data (not the pre-formatted text hal returned); the +// mesh serves them through the sdk's tool harness. Bookshelf has no calendar endpoint, so there is +// no calendar tool — matching hal, which excluded it from its calendar-capable apps. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { BookshelfClient } from "../client.js"; + +export function getBookshelfTools(bookshelf: BookshelfClient): ToolDefinition[] { + return [ + { + name: "bookshelf_status", + description: "Bookshelf status overview: version, book count, monitored count, queue size.", + input: {}, + run: async () => { + const [status, content, queue] = await Promise.all([ + bookshelf.getStatus(), + bookshelf.getContent(), + bookshelf.getQueue(), + ]); + return { + app: status.appName, + version: status.version, + books: content.length, + monitored: content.filter((c) => c.monitored).length, + queue: queue.totalRecords, + }; + }, + }, + { + name: "bookshelf_library", + description: "List books from the Bookshelf library.", + input: { limit: { type: "number", description: "max items to return (default 50)" } }, + run: async (args) => { + const items = await bookshelf.getContent(args.limit ? Number(args.limit) : 50); + return { count: items.length, books: items }; + }, + }, + { + name: "bookshelf_search", + description: + "Search the Bookshelf library for books by title or author (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 bookshelf.searchContent(query) }; + }, + }, + { + name: "bookshelf_queue", + description: "Show the Bookshelf download queue — what is downloading and how far along.", + input: {}, + run: async () => { + const queue = await bookshelf.getQueue(); + return { count: queue.totalRecords, items: queue.items }; + }, + }, + ]; +} + +// The tools exist only when Bookshelf is configured; without a URL and key, bookshelf contributes +// none rather than failing the whole runtime. +registerModuleTools("bookshelf", (env) => { + try { + return getBookshelfTools(BookshelfClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/bookshelf/tsconfig.json b/modules/bookshelf/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/bookshelf/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/fail2ban/client.ts b/modules/fail2ban/client.ts new file mode 100644 index 0000000..109c4c1 --- /dev/null +++ b/modules/fail2ban/client.ts @@ -0,0 +1,51 @@ +// fail2ban's own code, in the module (novox/hq ADR 0044). The jails and the daemon are declared +// resources — the mesh writes /etc/fail2ban/jail.d/* and keeps fail2ban.service running (see +// module.json). This code exists only to read and steer the *live* state the daemon owns at +// runtime: which IPs are banned right now, and the manual ban/unban an operator reaches for. That +// state (the running bans, /var/lib/fail2ban's sqlite) is fail2ban's, not the mesh's — the mesh +// reconciles the config, never the ban list. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +export class Fail2banClient { + static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient { + return new Fail2banClient(); + } + + /** Overview of every jail, or the detailed status of one — currently-banned IPs and totals. */ + async status(jail?: string): Promise { + if (jail) { + const { stdout } = await run("sudo", ["fail2ban-client", "status", jail]); + return stdout; + } + const { stdout: overview } = await run("sudo", ["fail2ban-client", "status"]); + const match = overview.match(/Jail list:\s*(.+)/); + if (!match) return overview; + + const jails = match[1].split(",").map((j) => j.trim()).filter(Boolean); + const parts: string[] = [overview.trimEnd(), ""]; + for (const j of jails) { + const { stdout } = await run("sudo", ["fail2ban-client", "status", j]); + parts.push(`=== ${j} ===`, stdout.trimEnd(), ""); + } + return parts.join("\n"); + } + + /** Manually ban an IP in a jail. Mutates live state, not a mesh-managed file. */ + async ban(jail: string, ip: string): Promise { + const { stdout } = await run("sudo", ["fail2ban-client", "set", jail, "banip", ip]); + return stdout; + } + + /** Unban an IP from one jail, or from every jail when no jail is given. */ + async unban(ip: string, jail?: string): Promise { + const args = jail + ? ["fail2ban-client", "set", jail, "unbanip", ip] + : ["fail2ban-client", "unban", ip]; + const { stdout } = await run("sudo", args); + return stdout; + } +} diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json new file mode 100644 index 0000000..efbfe17 --- /dev/null +++ b/modules/fail2ban/module.json @@ -0,0 +1,80 @@ +{ + "module": "fail2ban", + "version": "1", + "capabilities": [ + "intrusion-prevention" + ], + "claims": [ + { + "name": "the-intrusion-prevention", + "scope": "node" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "fail2ban" + }, + { + "id": "jail-d", + "type": "directory", + "path": "/etc/fail2ban/jail.d", + "mode": "0755" + }, + { + "id": "action-d", + "type": "directory", + "path": "/etc/fail2ban/action.d", + "mode": "0755" + }, + { + "id": "jail-local", + "type": "file", + "path": "/etc/fail2ban/jail.local", + "mode": "0644", + "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" + }, + { + "id": "jail-sshd", + "type": "file", + "path": "/etc/fail2ban/jail.d/sshd.conf", + "mode": "0644", + "content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n" + }, + { + "id": "jail-recidive", + "type": "file", + "path": "/etc/fail2ban/jail.d/recidive.conf", + "mode": "0644", + "content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\nbantime = 1w\nfindtime = 1d\n" + }, + { + "id": "action-dualchain", + "type": "file", + "path": "/etc/fail2ban/action.d/iptables-allports-dualchain.conf", + "mode": "0644", + "content": "# Fail2Ban action: ban in both INPUT and DOCKER-USER chains\n# Used by recidive to block repeat offenders from both host and Docker services\n\n[INCLUDES]\n\nbefore = iptables.conf\n\n[Definition]\n\ntype = allports\n\nactionstart = { -C f2b- -j >/dev/null 2>&1; } || { -N f2b- || true; -A f2b- -j ; }\n { -C INPUT -p -j f2b- >/dev/null 2>&1; } || { -I INPUT -p -j f2b-; }\n { -C DOCKER-USER -p -j f2b- >/dev/null 2>&1; } || { -I DOCKER-USER -p -j f2b-; }\n\nactionstop = -D INPUT -p -j f2b- 2>/dev/null || true\n -D DOCKER-USER -p -j f2b- 2>/dev/null || true\n -F f2b-\n -X f2b-\n\nactioncheck = -n -L f2b- >/dev/null\n\nactionban = -I f2b- 1 -s -j \n\nactionunban = -D f2b- -s -j \n\n[Init]\n\nchain = INPUT\nname = default\nprotocol = tcp\nblocktype = REJECT --reject-with icmp-port-unreachable\nreturntype = RETURN\nlockingopt = -w\niptables = iptables \n\n[Init?family=inet6]\n\nblocktype = REJECT --reject-with icmp6-port-unreachable\niptables = ip6tables \n" + }, + { + "id": "logrotate", + "type": "file", + "path": "/etc/logrotate.d/fail2ban", + "mode": "0644", + "content": "/var/log/fail2ban.log {\n missingok\n notifempty\n postrotate\n /usr/bin/fail2ban-client flushlogs >/dev/null || true\n endscript\n}\n" + }, + { + "id": "run", + "type": "service", + "unit": "fail2ban.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "jail-local", + "jail-sshd", + "jail-recidive", + "action-dualchain" + ] + } + ] +} diff --git a/modules/fail2ban/package.json b/modules/fail2ban/package.json new file mode 100644 index 0000000..ecece34 --- /dev/null +++ b/modules/fail2ban/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-fail2ban", + "version": "0.1.0", + "description": "fail2ban — intrusion prevention: the mesh declares the jails and keeps the daemon running; its ban/unban/status tools 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/fail2ban/tools/index.ts b/modules/fail2ban/tools/index.ts new file mode 100644 index 0000000..1181114 --- /dev/null +++ b/modules/fail2ban/tools/index.ts @@ -0,0 +1,55 @@ +// fail2ban's tools — reading and steering the live ban state. The jails themselves are declared +// resources (module.json); these three touch what the running daemon holds: what is banned now, +// and the manual ban/unban an operator reaches for. The daemon's state is fail2ban's own, so this +// is the only way to see or change it — the mesh reconciles the config, not the bans. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { Fail2banClient } from "../client.js"; + +export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] { + return [ + { + name: "fail2ban_status", + description: + "fail2ban status on this node — the jails and their live bans. Omit `jail` for every jail, or name one for its detail.", + input: { + type: "object", + properties: { + jail: { + type: "string", + description: "A specific jail (e.g. sshd, recidive); omit for the overview of all jails.", + }, + }, + }, + run: async (args) => ({ status: await fail2ban.status(args.jail as string | undefined) }), + }, + { + name: "fail2ban_ban", + description: "Manually ban an IP address in a jail — a live change to the running daemon, not a mesh-managed file.", + input: { + type: "object", + properties: { + jail: { type: "string", description: "Jail name (e.g. sshd, recidive)." }, + ip: { type: "string", description: "IP address to ban." }, + }, + required: ["jail", "ip"], + }, + run: async (args) => ({ result: await fail2ban.ban(args.jail as string, args.ip as string) }), + }, + { + name: "fail2ban_unban", + description: "Unban an IP address from one jail, or from every jail when `jail` is omitted.", + input: { + type: "object", + properties: { + ip: { type: "string", description: "IP address to unban." }, + jail: { type: "string", description: "A specific jail; omit to unban from all jails." }, + }, + required: ["ip"], + }, + run: async (args) => ({ result: await fail2ban.unban(args.ip as string, args.jail as string | undefined) }), + }, + ]; +} + +registerModuleTools("fail2ban", () => getFail2banTools(Fail2banClient.fromEnv())); diff --git a/modules/fail2ban/tsconfig.json b/modules/fail2ban/tsconfig.json new file mode 100644 index 0000000..1f1b70a --- /dev/null +++ b/modules/fail2ban/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" + ] +} diff --git a/modules/marrytts/module.json b/modules/marrytts/module.json new file mode 100644 index 0000000..308ab9b --- /dev/null +++ b/modules/marrytts/module.json @@ -0,0 +1,35 @@ +{ + "module": "marrytts", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "port": 59125, + "protocol": "tcp", + "from": "mesh", + "why": "the MaryTTS text-to-speech HTTP API and web interface" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/marrytts", + "mode": "0700" + }, + { + "id": "server", + "type": "container", + "name": "marrytts", + "image": "synesthesiam/marytts@sha256:45970ecb3e21a2981c66c60563a70cf00be8e95c02565e7d74b3a73dcec7db2c", + "env": { + "TZ": "Europe/Brussels" + }, + "ports": [ + "59125" + ] + } + ] +} diff --git a/modules/unifi/client.ts b/modules/unifi/client.ts new file mode 100644 index 0000000..f6fbe6d --- /dev/null +++ b/modules/unifi/client.ts @@ -0,0 +1,260 @@ +// The UniFi controller API client — unifi's own code, living in the module (novox/hq ADR 0044). +// Moved out of the shared hal sdk, where a change to the UniFi API rebuilt everything; here it +// rebuilds only unifi. This module's tools import it, and nothing outside unifi does. +// +// The controller speaks its classic self-managed API (/api/login, /api/s//...), authenticated +// with a username and password and a session cookie. It presents a self-signed certificate, so the +// requests deliberately skip TLS verification — see uniFetch below. + +import { readFileSync } from "node:fs"; +import { request as httpsRequest } from "node:https"; + +export interface UnifiPortForward { + _id?: string; + name: string; + enabled: boolean; + pfwd_interface: string; + src: string; + dst_port: string; + fwd: string; + fwd_port: string; + proto: string; + log: boolean; + site_id?: string; +} + +export interface UnifiDevice { + _id: string; + name: string; + model: string; + type: string; + ip: string; + mac: string; + version: string; + adopted: boolean; + state: number; + uptime: number; +} + +export interface UnifiClientDevice { + _id: string; + name?: string; + hostname?: string; + ip: string; + mac: string; + oui: string; + is_wired: boolean; + network?: string; + last_seen: number; + uptime?: number; +} + +interface UnifiResponse { + meta: { rc: string; msg?: string }; + data: T[]; +} + +/** The minimal response shape uniFetch returns — enough for this client, without pretending to be + * the whole DOM `Response`. */ +interface UniReply { + ok: boolean; + status: number; + statusText: string; + setCookies: string[]; + text: () => Promise; + json: () => Promise; +} + +interface UniInit { + method?: string; + headers?: Record; + body?: string; +} + +/** + * Fetch wrapper that disables TLS verification for UniFi's self-signed certificate. Uses node:https + * directly rather than the built-in fetch, because fetch caches NODE_TLS_REJECT_UNAUTHORIZED at + * startup and scoped per-request toggling does not work — the reason the hal original reached for + * https as well. + */ +function uniFetch(url: string, init?: UniInit): Promise { + const parsed = new URL(url); + return new Promise((resolve, reject) => { + const req = httpsRequest( + parsed, + { + method: init?.method ?? "GET", + headers: init?.headers ?? {}, + rejectUnauthorized: false, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString(); + const status = res.statusCode ?? 0; + const rawCookies = res.headers["set-cookie"]; + const setCookies = Array.isArray(rawCookies) ? rawCookies : rawCookies ? [rawCookies] : []; + resolve({ + ok: status >= 200 && status < 300, + status, + statusText: res.statusMessage ?? "", + setCookies, + text: async () => body, + json: async () => JSON.parse(body) as unknown, + }); + }); + }, + ); + req.on("error", reject); + if (init?.body) req.write(init.body); + req.end(); + }); +} + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, username, password, + * site }. Read from MESH_UNIFI_CONFIG_FILE; absent or unreadable is an empty config, not a throw. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { + return JSON.parse(readFileSync(file, "utf8")) as Record; + } catch { + return {}; + } +} + +export class UnifiApiClient { + readonly baseUrl: string; + private cookie: string | null = null; + private csrfToken: string | null = null; + + constructor( + url: string, + private readonly username: string, + private readonly password: string, + private readonly site: string = "default", + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. URL, credentials and site come from the + * settings-merged config file, falling back to MESH_UNIFI_* env vars and finally the local + * controller port. Throws when no username/password is configured, so a misconfigured module + * exposes nothing rather than calling the controller unauthenticated. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): UnifiApiClient { + const cfg = meshConfig(env.MESH_UNIFI_CONFIG_FILE); + const url = cfg.url ?? env.MESH_UNIFI_URL ?? `https://127.0.0.1:${env.UNIFI_HTTPS_PORT ?? "8443"}`; + const username = cfg.username ?? env.MESH_UNIFI_USERNAME; + const password = cfg.password ?? env.MESH_UNIFI_PASSWORD; + const site = cfg.site ?? env.MESH_UNIFI_SITE ?? "default"; + if (!username || !password) { + throw new Error("no UniFi credentials — set MESH_UNIFI_USERNAME and MESH_UNIFI_PASSWORD"); + } + return new UnifiApiClient(url, username, password, site); + } + + private async login(): Promise { + const res = await uniFetch(`${this.baseUrl}/api/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: this.username, password: this.password }), + }); + if (!res.ok && res.status !== 302) { + throw new Error(`UniFi auth failed: ${res.status} ${await res.text()}`); + } + // Extract the session cookie and CSRF token from the response. + const cookies: string[] = []; + for (const c of res.setCookies) { + const name = c.split("=")[0]; + const value = c.split(";")[0]; + if (name === "TOKEN" || name === "unifises" || name === "csrf_token") { + cookies.push(value); + } + if (name === "csrf_token") { + this.csrfToken = value.split("=")[1]; + } + } + this.cookie = cookies.join("; "); + if (!this.cookie) { + throw new Error("UniFi auth: no session cookie returned"); + } + } + + private async request(method: string, path: string, body?: unknown): Promise { + if (!this.cookie) await this.login(); + + const doRequest = async (): Promise => { + const headers: Record = { + "Content-Type": "application/json", + Cookie: this.cookie!, + }; + if (this.csrfToken) headers["X-Csrf-Token"] = this.csrfToken; + return uniFetch(`${this.baseUrl}${path}`, { + method, + headers, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + }; + + let res = await doRequest(); + + if (res.status === 401) { + this.cookie = null; + await this.login(); + res = await doRequest(); + } + + if (!res.ok) throw new Error(`UniFi ${method} ${path}: ${res.status} ${await res.text()}`); + const data = (await res.json()) as UnifiResponse; + if (data.meta.rc !== "ok") throw new Error(`UniFi API error: ${data.meta.msg}`); + return data.data; + } + + // --- Port forwarding --- + + async listPortForwards(): Promise { + return this.request("GET", `/api/s/${this.site}/rest/portforward`); + } + + async createPortForward(rule: Omit): Promise { + const result = await this.request("POST", `/api/s/${this.site}/rest/portforward`, rule); + return result[0]; + } + + async updatePortForward(id: string, rule: Partial): Promise { + const result = await this.request("PUT", `/api/s/${this.site}/rest/portforward/${id}`, rule); + return result[0]; + } + + async deletePortForward(id: string): Promise { + await this.request("DELETE", `/api/s/${this.site}/rest/portforward/${id}`); + } + + // --- Devices --- + + async listDevices(): Promise { + return this.request("GET", `/api/s/${this.site}/stat/device`); + } + + // --- Clients --- + + async listClients(): Promise { + return this.request("GET", `/api/s/${this.site}/stat/sta`); + } + + /** + * A health probe that never throws: report whether the controller answers and can be logged into. + * Every other call assumes the controller is up and authenticated; 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; error?: string }> { + try { + await this.listDevices(); + return { reachable: true, url: this.baseUrl }; + } catch (err) { + return { reachable: false, url: this.baseUrl, error: err instanceof Error ? err.message : String(err) }; + } + } +} diff --git a/modules/unifi/module.json b/modules/unifi/module.json new file mode 100644 index 0000000..5bc5a5f --- /dev/null +++ b/modules/unifi/module.json @@ -0,0 +1,135 @@ +{ + "module": "unifi", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "port": 8443, + "protocol": "tcp", + "from": "mesh", + "why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later" + }, + { + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "device inform — how APs and switches check in and are adopted" + }, + { + "port": 3478, + "protocol": "udp", + "from": "mesh", + "why": "STUN, so managed devices can find the controller through NAT" + }, + { + "port": 10001, + "protocol": "udp", + "from": "mesh", + "why": "device discovery — the controller finds unadopted devices on the network" + }, + { + "port": 1902, + "protocol": "udp", + "from": "mesh", + "why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900" + }, + { + "port": 8843, + "protocol": "tcp", + "from": "mesh", + "why": "the guest captive portal over https" + }, + { + "port": 8880, + "protocol": "tcp", + "from": "mesh", + "why": "the guest captive portal over http" + }, + { + "port": 6789, + "protocol": "tcp", + "from": "mesh", + "why": "mobile-app speed-test throughput measurement" + }, + { + "port": 5514, + "protocol": "udp", + "from": "mesh", + "why": "remote syslog from managed devices" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/unifi", + "mode": "0700" + }, + { + "id": "data", + "type": "directory", + "path": "/services/unifi/data", + "mode": "0700", + "owner": "1000:1000" + }, + { + "id": "server", + "type": "container", + "name": "unifi-controller", + "image": "lscr.io/linuxserver/unifi-controller@sha256:fcd5d8b13a77a588c79c1b49e5fc9ad08115aa3bb1a3576c589c64908a68845f", + "ports": [ + "8443:8443", + "8080:8080", + "3478:3478/udp", + "10001:10001/udp", + "1902:1900/udp", + "8843:8843", + "8880:8880", + "6789:6789", + "5514:5514/udp" + ], + "env": { + "PUID": "1000", + "PGID": "1000", + "TZ": "Etc/UTC", + "MEM_LIMIT": "1024", + "MEM_STARTUP": "1024" + }, + "volumes": [ + "/services/unifi/data:/config" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/unifi/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-unifi", + "image": "mesh-runtime-unifi@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/unifi/broker:/run/secrets/broker:ro", + "/var/lib/mesh/unifi/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_UNIFI_URL": "https://127.0.0.1:8443", + "MESH_UNIFI_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] + } + ], + "own-secrets": { + "broker": "/var/lib/mesh/unifi/broker" + } +} diff --git a/modules/unifi/package.json b/modules/unifi/package.json new file mode 100644 index 0000000..19c01d6 --- /dev/null +++ b/modules/unifi/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-unifi", + "version": "0.1.0", + "description": "unifi — the UniFi network controller. 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/unifi/tools/index.ts b/modules/unifi/tools/index.ts new file mode 100644 index 0000000..c637129 --- /dev/null +++ b/modules/unifi/tools/index.ts @@ -0,0 +1,143 @@ +// unifi's tools — its own code (novox/hq ADR 0044), importing unifi's own client. They return +// structured data; the mesh serves them through the sdk's tool harness. unifi is tools-only (no +// events entrypoint): the controller does not push lifecycle events the mesh consumes, so this +// module reads its resources — port forwards, devices, clients — and exposes them, and stops there. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { UnifiApiClient, type UnifiPortForward } from "../client.js"; + +function summarizePortForward(r: UnifiPortForward): Record { + return { + id: r._id, + name: r.name, + enabled: r.enabled, + src: r.src || "any", + dst_port: r.dst_port, + forward: `${r.fwd}:${r.fwd_port}`, + proto: r.proto, + }; +} + +export function getUnifiTools(unifi: UnifiApiClient): ToolDefinition[] { + return [ + { + name: "unifi_reachable", + description: "Health probe: whether the UniFi controller answers and can be logged into. Never fails.", + input: {}, + run: async () => unifi.reachable(), + }, + { + name: "unifi_list_port_forwards", + description: "List all port-forwarding rules on the UniFi gateway.", + input: {}, + run: async () => { + const rules = await unifi.listPortForwards(); + return { count: rules.length, rules: rules.map(summarizePortForward) }; + }, + }, + { + name: "unifi_create_port_forward", + description: "Create a port-forwarding rule on the UniFi gateway.", + input: { + name: { type: "string", description: "rule name (e.g. 'Redis')" }, + dst_port: { type: "string", description: "external/WAN port (e.g. '6379')" }, + fwd: { type: "string", description: "forward to LAN IP (e.g. '192.0.2.10')" }, + fwd_port: { type: "string", description: "forward to port (e.g. '6379')" }, + proto: { type: "string", description: "protocol: tcp, udp or tcp_udp (default tcp)" }, + src: { type: "string", description: "source IP/CIDR restriction (omitted = any)" }, + enabled: { type: "boolean", description: "enable the rule (default true)" }, + }, + run: async (args) => { + const rule = await unifi.createPortForward({ + name: String(args.name), + dst_port: String(args.dst_port), + fwd: String(args.fwd), + fwd_port: String(args.fwd_port), + proto: args.proto ? String(args.proto) : "tcp", + src: args.src ? String(args.src) : "any", + enabled: args.enabled === undefined ? true : Boolean(args.enabled), + pfwd_interface: "wan", + log: false, + }); + return { created: summarizePortForward(rule) }; + }, + }, + { + name: "unifi_toggle_port_forward", + description: "Enable or disable a port-forwarding rule by id (see unifi_list_port_forwards).", + input: { + id: { type: "string", description: "the port-forward rule id" }, + enabled: { type: "boolean", description: "true to enable, false to disable" }, + }, + run: async (args) => { + const enabled = Boolean(args.enabled); + const rule = await unifi.updatePortForward(String(args.id), { enabled }); + return { updated: summarizePortForward(rule) }; + }, + }, + { + name: "unifi_delete_port_forward", + description: "Delete a port-forwarding rule by id. Requires confirm: true.", + input: { + id: { type: "string", description: "the port-forward rule id" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + if (!args.confirm) return { deleted: false, reason: "set confirm: true to delete" }; + await unifi.deletePortForward(String(args.id)); + return { deleted: true, id: String(args.id) }; + }, + }, + { + name: "unifi_list_devices", + description: "List the network devices (APs, switches, gateways) the UniFi controller manages.", + input: {}, + run: async () => { + const devices = await unifi.listDevices(); + return { + count: devices.length, + devices: devices.map((d) => ({ + name: d.name || d.mac, + model: d.model, + ip: d.ip, + state: d.state === 1 ? "online" : "offline", + adopted: d.adopted, + version: d.version, + uptimeHours: d.uptime ? Math.floor(d.uptime / 3600) : 0, + })), + }; + }, + }, + { + name: "unifi_list_clients", + description: "List the connected network clients — wired and wireless.", + input: {}, + run: async () => { + const clients = await unifi.listClients(); + return { + count: clients.length, + clients: clients + .slice() + .sort((a, b) => (a.name || a.hostname || a.ip).localeCompare(b.name || b.hostname || b.ip)) + .map((c) => ({ + name: c.name || c.hostname || c.mac, + ip: c.ip, + mac: c.mac, + type: c.is_wired ? "wired" : "wifi", + network: c.network, + })), + }; + }, + }, + ]; +} + +// The tools exist only when credentials are configured; without them, unifi contributes none rather +// than failing the whole runtime. +registerModuleTools("unifi", (env) => { + try { + return getUnifiTools(UnifiApiClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/unifi/tsconfig.json b/modules/unifi/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/unifi/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"] +} -- 2.54.0