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).
191 lines
7.4 KiB
TypeScript
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 [];
|
|
}
|
|
});
|