Files
mesh-catalog/modules/unifi/tools/index.ts
T
jschoubben d1f8ab86d1 unifi: list networks and set the DNS their DHCP hands out
Which DNS server the home network's DHCP hands out could be changed only
in the controller's own interface or by hand against its API (novox/hq
issue 198).
2026-10-02 11:53:16 +02:00

191 lines
7.4 KiB
TypeScript

// unifi's tools — its own code (novox/hq ADR 0039), 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, networks, devices, clients — and exposes them, and stops there.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { UnifiApiClient, type UnifiNetwork, type UnifiPortForward } from "../client.js";
function summarizeNetwork(n: UnifiNetwork): Record<string, unknown> {
const dns = [n.dhcpd_dns_1, n.dhcpd_dns_2, n.dhcpd_dns_3, n.dhcpd_dns_4].filter((s): s is string => !!s);
return {
id: n._id,
name: n.name,
purpose: n.purpose,
subnet: n.ip_subnet ?? null,
dhcp: n.dhcpd_enabled ?? null,
// What DHCP hands out as DNS: the listed servers when set, otherwise the gateway itself.
dhcp_dns: n.dhcpd_dns_enabled ? dns : "the gateway",
};
}
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_networks",
description: "List the networks the UniFi controller manages, with the DNS servers each one's DHCP hands out.",
input: {},
run: async () => {
const nets = await unifi.listNetworks();
return { count: nets.length, networks: nets.map(summarizeNetwork) };
},
},
{
name: "unifi_set_network_dns",
description:
"Set the DNS servers a network's DHCP hands out to its devices (see unifi_list_networks for ids). " +
"Up to four addresses, comma-separated; \"gateway\" hands out the gateway itself. Devices pick it up when they renew.",
input: {
id: { type: "string", description: "the network id" },
dns: { type: "string", description: "comma-separated DNS server addresses, or \"gateway\"" },
},
run: async (args) => {
const asked = String(args.dns ?? "").trim();
if (!asked) return { updated: false, reason: "say dns: addresses, or \"gateway\"" };
const servers = asked === "gateway" ? [] : asked.split(",").map((s) => s.trim()).filter(Boolean);
if (servers.length > 4) return { updated: false, reason: "DHCP hands out at most four DNS servers" };
const fields: Partial<UnifiNetwork> = {
dhcpd_dns_enabled: servers.length > 0,
dhcpd_dns_1: servers[0] ?? "",
dhcpd_dns_2: servers[1] ?? "",
dhcpd_dns_3: servers[2] ?? "",
dhcpd_dns_4: servers[3] ?? "",
};
const net = await unifi.updateNetwork(String(args.id), fields);
return { updated: summarizeNetwork(net) };
},
},
{
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 [];
}
});