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:
2026-09-05 12:04:14 +02:00
parent 8aa09c013c
commit 229c6a83d1
17 changed files with 1192 additions and 0 deletions
+260
View File
@@ -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) };
}
}
}
+135
View File
@@ -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"
}
}
+14
View File
@@ -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"
}
}
+143
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}