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
This commit is contained in:
@@ -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 <ApiKey> 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>([^<]+)<\/ApiKey>/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async get(endpoint: string, params?: Record<string, string>): Promise<unknown> {
|
||||
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<BookshelfContentItem[]> {
|
||||
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<BookshelfContentItem[]> {
|
||||
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]}`;
|
||||
}
|
||||
@@ -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<number, BookshelfQueueItem>();
|
||||
let primed = false;
|
||||
|
||||
async function pollQueue(): Promise<void> {
|
||||
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<void>, 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");
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
const args = jail
|
||||
? ["fail2ban-client", "set", jail, "unbanip", ip]
|
||||
: ["fail2ban-client", "unban", ip];
|
||||
const { stdout } = await run("sudo", args);
|
||||
return stdout;
|
||||
}
|
||||
}
|
||||
@@ -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 = { <iptables> -C f2b-<name> -j <returntype> >/dev/null 2>&1; } || { <iptables> -N f2b-<name> || true; <iptables> -A f2b-<name> -j <returntype>; }\n { <iptables> -C INPUT -p <protocol> -j f2b-<name> >/dev/null 2>&1; } || { <iptables> -I INPUT -p <protocol> -j f2b-<name>; }\n { <iptables> -C DOCKER-USER -p <protocol> -j f2b-<name> >/dev/null 2>&1; } || { <iptables> -I DOCKER-USER -p <protocol> -j f2b-<name>; }\n\nactionstop = <iptables> -D INPUT -p <protocol> -j f2b-<name> 2>/dev/null || true\n <iptables> -D DOCKER-USER -p <protocol> -j f2b-<name> 2>/dev/null || true\n <iptables> -F f2b-<name>\n <iptables> -X f2b-<name>\n\nactioncheck = <iptables> -n -L f2b-<name> >/dev/null\n\nactionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>\n\nactionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>\n\n[Init]\n\nchain = INPUT\nname = default\nprotocol = tcp\nblocktype = REJECT --reject-with icmp-port-unreachable\nreturntype = RETURN\nlockingopt = -w\niptables = iptables <lockingopt>\n\n[Init?family=inet6]\n\nblocktype = REJECT --reject-with icmp6-port-unreachable\niptables = ip6tables <lockingopt>\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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/<site>/...), 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<T> {
|
||||
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<string>;
|
||||
json: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface UniInit {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
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<UniReply> {
|
||||
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<string, string> {
|
||||
if (!file) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>;
|
||||
} 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<void> {
|
||||
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<T>(method: string, path: string, body?: unknown): Promise<T[]> {
|
||||
if (!this.cookie) await this.login();
|
||||
|
||||
const doRequest = async (): Promise<UniReply> => {
|
||||
const headers: Record<string, string> = {
|
||||
"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<T>;
|
||||
if (data.meta.rc !== "ok") throw new Error(`UniFi API error: ${data.meta.msg}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// --- Port forwarding ---
|
||||
|
||||
async listPortForwards(): Promise<UnifiPortForward[]> {
|
||||
return this.request<UnifiPortForward>("GET", `/api/s/${this.site}/rest/portforward`);
|
||||
}
|
||||
|
||||
async createPortForward(rule: Omit<UnifiPortForward, "_id" | "site_id">): Promise<UnifiPortForward> {
|
||||
const result = await this.request<UnifiPortForward>("POST", `/api/s/${this.site}/rest/portforward`, rule);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async updatePortForward(id: string, rule: Partial<UnifiPortForward>): Promise<UnifiPortForward> {
|
||||
const result = await this.request<UnifiPortForward>("PUT", `/api/s/${this.site}/rest/portforward/${id}`, rule);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async deletePortForward(id: string): Promise<void> {
|
||||
await this.request<unknown>("DELETE", `/api/s/${this.site}/rest/portforward/${id}`);
|
||||
}
|
||||
|
||||
// --- Devices ---
|
||||
|
||||
async listDevices(): Promise<UnifiDevice[]> {
|
||||
return this.request<UnifiDevice>("GET", `/api/s/${this.site}/stat/device`);
|
||||
}
|
||||
|
||||
// --- Clients ---
|
||||
|
||||
async listClients(): Promise<UnifiClientDevice[]> {
|
||||
return this.request<UnifiClientDevice>("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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user