@owine/unifi-network-mcp 2.15.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -77
  3. package/dist/client.d.ts +26 -1
  4. package/dist/client.js +199 -24
  5. package/dist/config.d.ts +11 -0
  6. package/dist/config.js +102 -3
  7. package/dist/guard.d.ts +46 -0
  8. package/dist/guard.js +180 -0
  9. package/dist/index.js +17 -4
  10. package/dist/instructions.d.ts +9 -0
  11. package/dist/instructions.js +32 -0
  12. package/dist/tls.d.ts +14 -0
  13. package/dist/tls.js +58 -0
  14. package/dist/tools/acl.js +123 -50
  15. package/dist/tools/classic.d.ts +3 -0
  16. package/dist/tools/classic.js +54 -0
  17. package/dist/tools/client-history.js +22 -12
  18. package/dist/tools/clients.js +10 -9
  19. package/dist/tools/devices.js +21 -20
  20. package/dist/tools/dns-policies.js +27 -27
  21. package/dist/tools/exposure.d.ts +3 -0
  22. package/dist/tools/exposure.js +118 -0
  23. package/dist/tools/firewall.d.ts +2 -1
  24. package/dist/tools/firewall.js +200 -75
  25. package/dist/tools/hotspot.js +35 -28
  26. package/dist/tools/index.d.ts +13 -1
  27. package/dist/tools/index.js +67 -4
  28. package/dist/tools/networks.d.ts +2 -1
  29. package/dist/tools/networks.js +44 -31
  30. package/dist/tools/sites.js +2 -2
  31. package/dist/tools/supporting.js +22 -21
  32. package/dist/tools/switching.js +16 -15
  33. package/dist/tools/system.js +1 -1
  34. package/dist/tools/traffic-matching.d.ts +2 -1
  35. package/dist/tools/traffic-matching.js +22 -20
  36. package/dist/tools/wifi.js +76 -30
  37. package/dist/utils/full-put.d.ts +11 -2
  38. package/dist/utils/full-put.js +25 -2
  39. package/dist/utils/output-schemas.d.ts +241 -4
  40. package/dist/utils/output-schemas.js +225 -49
  41. package/dist/utils/params.d.ts +12 -0
  42. package/dist/utils/params.js +21 -0
  43. package/dist/utils/pick.d.ts +6 -0
  44. package/dist/utils/pick.js +11 -0
  45. package/dist/utils/responses.d.ts +2 -0
  46. package/dist/utils/responses.js +28 -2
  47. package/dist/utils/safety.d.ts +35 -1
  48. package/dist/utils/safety.js +53 -3
  49. package/dist/utils/secrets.js +3 -0
  50. package/dist/utils/wan-exposure.d.ts +69 -0
  51. package/dist/utils/wan-exposure.js +245 -0
  52. package/dist/write-log.d.ts +52 -0
  53. package/dist/write-log.js +178 -0
  54. package/dist/write-tools.d.ts +27 -0
  55. package/dist/write-tools.js +50 -0
  56. package/package.json +19 -15
package/dist/client.js CHANGED
@@ -1,42 +1,163 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.NetworkClient = void 0;
3
+ exports.NetworkClient = exports.CLASSIC_COLLECTIONS = void 0;
4
+ exports.withCallSignal = withCallSignal;
5
+ const node_async_hooks_1 = require("node:async_hooks");
6
+ // undici's own fetch, not the global one: Node's built-in fetch bundles an
7
+ // older undici whose handlers the Agent from tls.ts (undici 8) rejects.
8
+ const undici_1 = require("undici");
9
+ const config_js_1 = require("./config.js");
10
+ const guard_js_1 = require("./guard.js");
11
+ const tls_js_1 = require("./tls.js");
12
+ const write_log_js_1 = require("./write-log.js");
13
+ // Well under undici's 300s default, so a stalled controller fails the call
14
+ // before the MCP host gives up on it.
15
+ const REQUEST_TIMEOUT_MS = 30_000;
16
+ const CONTROLLER_TIMEOUT_MS = 25_000;
17
+ // What undici reports when the console closed the connection under a
18
+ // request, typically a kept-alive socket it had already timed out.
19
+ const SOCKET_CLOSED = ["UND_ERR_SOCKET", "ECONNRESET", "EPIPE"];
20
+ function socketClosed(err) {
21
+ const cause = err instanceof Error ? err.cause : undefined;
22
+ return cause instanceof Error && "code" in cause && typeof cause.code === "string" && SOCKET_CLOSED.includes(cause.code);
23
+ }
24
+ /**
25
+ * Send a read, and once more if the console closed the connection under it. A
26
+ * connection the console closed while it sat idle fails the next request on
27
+ * it; nothing reached the console, and a read changes nothing anyway, so it
28
+ * goes out again on a fresh connection.
29
+ */
30
+ function retryClosedSocket(send) {
31
+ return send().catch((err) => {
32
+ if (socketClosed(err))
33
+ return send();
34
+ throw err;
35
+ });
36
+ }
37
+ /**
38
+ * A request that failed in flight, worded so the model knows whether retrying
39
+ * is safe: a timed-out or cut-off write may already have been applied, and
40
+ * repeating a create would duplicate it. The timeout also covers reading the
41
+ * body, so this applies to the whole exchange, not only to fetch().
42
+ */
43
+ function inFlightError(read, method, timeoutMs, err) {
44
+ const hint = read
45
+ ? "Retry when the console responds."
46
+ : `The ${method} may still have been applied; check the resource before retrying.`;
47
+ if (err instanceof DOMException && err.name === "TimeoutError") {
48
+ return new Error(`UniFi controller did not respond within ${timeoutMs / 1000}s. ${hint}`, { cause: err });
49
+ }
50
+ if (!read && socketClosed(err)) {
51
+ return new Error(`The UniFi controller closed the connection during the ${method}. ${hint}`, { cause: err });
52
+ }
53
+ return err;
54
+ }
55
+ // The abort signal of the MCP tool call a request is made for. Without it a
56
+ // cancelled (or abandoned) call keeps its request running, and a write lands
57
+ // after the host has already reported the call as failed.
58
+ const callSignal = new node_async_hooks_1.AsyncLocalStorage();
59
+ /** Run fn so that every request it makes is aborted when signal is. */
60
+ function withCallSignal(signal, fn) {
61
+ return signal ? callSignal.run(signal, fn) : fn();
62
+ }
63
+ const ERROR_BODY_LIMIT = 500;
64
+ /**
65
+ * The useful part of an error body for the model: the API's documented
66
+ * message, code and request ID, or a capped excerpt of anything else (a proxy
67
+ * or UniFi OS HTML error page would otherwise land in the context whole).
68
+ */
69
+ function describeErrorBody(text) {
70
+ try {
71
+ const body = JSON.parse(text);
72
+ if (body && typeof body === "object" && "message" in body && typeof body.message === "string") {
73
+ const details = [
74
+ "code" in body && typeof body.code === "string" ? body.code : undefined,
75
+ "requestId" in body && typeof body.requestId === "string" ? `request ${body.requestId}` : undefined,
76
+ ].filter((detail) => detail !== undefined);
77
+ const message = capped(body.message);
78
+ return details.length > 0 ? `${message} (${details.join("; ")})` : message;
79
+ }
80
+ }
81
+ catch {
82
+ // Not JSON: fall through to the excerpt.
83
+ }
84
+ return capped(text);
85
+ }
86
+ function capped(text) {
87
+ const collapsed = text.replace(/\s+/g, " ").trim();
88
+ return collapsed.length > ERROR_BODY_LIMIT ? `${collapsed.slice(0, ERROR_BODY_LIMIT)}…` : collapsed;
89
+ }
90
+ function requestSignal(timeoutMs) {
91
+ const timeout = AbortSignal.timeout(timeoutMs);
92
+ const caller = callSignal.getStore();
93
+ return caller ? AbortSignal.any([caller, timeout]) : timeout;
94
+ }
95
+ /** The classic controller REST collections the classic read tools list. */
96
+ exports.CLASSIC_COLLECTIONS = ["networkconf", "firewallgroup", "firewallrule", "portforward"];
97
+ const HISTORY = {
98
+ name: "Controller history",
99
+ query: "history query",
100
+ notEmpty: "This is not an empty history result.",
101
+ tooLarge: "reduce the page size or time window",
102
+ };
103
+ const CLASSIC = {
104
+ name: "Classic controller API",
105
+ query: "classic API read",
106
+ notEmpty: "This is not an empty result.",
107
+ tooLarge: "the collection is too large to return",
108
+ };
4
109
  class NetworkClient {
5
- baseUrl;
6
110
  headers;
7
111
  networkUrl;
112
+ dispatcher;
113
+ policy;
8
114
  constructor(config) {
9
115
  // Cloud Connector only exists behind api.ui.com. A console ID set against a
10
116
  // local controller is ignored rather than fatal: the other tools work fine
11
117
  // against that controller, and taking the whole server down helps nobody.
12
- const useConnector = config.consoleId !== undefined && config.host === "api.ui.com";
118
+ const useConnector = config.consoleId !== undefined && (0, config_js_1.isSiteManagerHost)(config.host);
13
119
  if (config.consoleId !== undefined && !useConnector) {
14
120
  console.error(`Ignoring UNIFI_NETWORK_CONSOLE_ID: Cloud Connector requires host api.ui.com, but host is ${config.host}.`);
15
121
  }
16
122
  const consolePath = useConnector ? `/v1/connector/consoles/${config.consoleId}` : "";
17
123
  this.networkUrl = `https://${config.host}${consolePath}/proxy/network`;
18
- this.baseUrl = `${this.networkUrl}/integration/v1`;
19
124
  this.headers = {
20
125
  "X-API-KEY": config.apiKey,
21
126
  "Content-Type": "application/json",
22
127
  };
23
- if (!config.verifySsl) {
24
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
25
- }
128
+ this.dispatcher = (0, tls_js_1.consoleDispatcher)(config);
129
+ this.policy = { readOnly: config.readOnly, ...(config.writeTools ? { writeTools: config.writeTools } : {}) };
26
130
  }
131
+ /**
132
+ * path is relative to the Integration API, as the tools pass it. The guard
133
+ * sees it as the canonical path under the Network application; the host and
134
+ * any Cloud Connector prefix are added only once it has passed.
135
+ */
27
136
  async request(method, path, body) {
28
- const url = `${this.baseUrl}${path}`;
137
+ const canonical = `/integration/v1${path}`;
138
+ this.guard(method, canonical);
139
+ const url = `${this.networkUrl}${canonical}`;
140
+ const send = () => this.send(method, url, body).catch((err) => {
141
+ throw inFlightError(method === "GET", method, REQUEST_TIMEOUT_MS, err);
142
+ });
143
+ return method === "GET" ? retryClosedSocket(send) : send();
144
+ }
145
+ async send(method, url, body) {
29
146
  const options = {
30
147
  method,
31
148
  headers: this.headers,
149
+ redirect: "error",
150
+ signal: requestSignal(REQUEST_TIMEOUT_MS),
151
+ dispatcher: this.dispatcher,
32
152
  };
33
153
  if (body !== undefined) {
34
154
  options.body = JSON.stringify(body);
35
155
  }
36
- const response = await fetch(url, options);
156
+ const response = await (0, undici_1.fetch)(url, options);
157
+ if (method !== "GET")
158
+ (0, write_log_js_1.noteWriteStatus)(response.status);
37
159
  if (!response.ok) {
38
- const text = await response.text();
39
- throw new Error(`HTTP ${response.status}: ${text}`);
160
+ throw new Error(`HTTP ${response.status}: ${describeErrorBody(await response.text())}`);
40
161
  }
41
162
  const contentType = response.headers.get("content-type") ?? "";
42
163
  if (contentType.includes("application/json")) {
@@ -44,6 +165,19 @@ class NetworkClient {
44
165
  }
45
166
  return response.text();
46
167
  }
168
+ /** Check a request against the guard before anything is sent, and note writes for the write log. */
169
+ guard(method, path) {
170
+ try {
171
+ const route = (0, guard_js_1.guardRequest)(method, path, this.policy);
172
+ if (route.tools !== undefined)
173
+ (0, write_log_js_1.noteWriteRequest)(method, path);
174
+ }
175
+ catch (err) {
176
+ if (err instanceof guard_js_1.GuardError)
177
+ (0, write_log_js_1.noteRefusal)("request_guard", method, path);
178
+ throw err;
179
+ }
180
+ }
47
181
  async get(path) {
48
182
  return this.request("GET", path);
49
183
  }
@@ -80,31 +214,64 @@ class NetworkClient {
80
214
  ...(mac ? { mac: mac.toLowerCase() } : {}),
81
215
  });
82
216
  }
217
+ /** A classic controller REST collection, read whole: the controller does not page these. */
218
+ async getClassic(siteReference, collection) {
219
+ this.validateSiteReference(siteReference);
220
+ if (!exports.CLASSIC_COLLECTIONS.includes(collection))
221
+ throw new Error(`Unknown classic collection ${collection}`);
222
+ return this.classicRequest(`/api/s/${siteReference}/rest/${collection}`);
223
+ }
224
+ /**
225
+ * The site's classic settings (get/setting): one record per section, keyed
226
+ * by its key field. Sections hold credentials (SSH passwords, RADIUS and
227
+ * cloud-access keys), so callers copy out only the fields they need.
228
+ */
229
+ async getClassicSettings(siteReference) {
230
+ this.validateSiteReference(siteReference);
231
+ return this.classicRequest(`/api/s/${siteReference}/get/setting`);
232
+ }
83
233
  validateSiteReference(siteReference) {
84
234
  if (!/^[A-Za-z0-9_-]{1,64}$/.test(siteReference)) {
85
235
  throw new Error("Use the site's internalReference from unifi_list_sites (for example default)");
86
236
  }
87
237
  }
88
238
  async historyRequest(path, body) {
89
- const response = await fetch(`${this.networkUrl}${path}`, {
90
- method: body === undefined ? "GET" : "POST", headers: this.headers,
91
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
92
- redirect: "error", signal: AbortSignal.timeout(25000),
239
+ const method = body === undefined ? "GET" : "POST";
240
+ this.guard(method, path);
241
+ // Both history endpoints are reads, the POST included.
242
+ return this.readController(HISTORY, method, path, body).catch((err) => {
243
+ throw inFlightError(true, method, CONTROLLER_TIMEOUT_MS, err);
93
244
  });
245
+ }
246
+ /** The classic controller API, outside /integration/v1. Only GETs of the collections the guard allows. */
247
+ async classicRequest(path) {
248
+ this.guard("GET", path);
249
+ return retryClosedSocket(() => this.readController(CLASSIC, "GET", path).catch((err) => {
250
+ throw inFlightError(true, "GET", CONTROLLER_TIMEOUT_MS, err);
251
+ }));
252
+ }
253
+ async readController(kind, method, path, body) {
254
+ const options = {
255
+ method, headers: this.headers,
256
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
257
+ redirect: "error", signal: requestSignal(CONTROLLER_TIMEOUT_MS), dispatcher: this.dispatcher,
258
+ };
259
+ const response = await (0, undici_1.fetch)(`${this.networkUrl}${path}`, options);
94
260
  if (!response.ok) {
95
261
  // Drain before throwing: an unread body pins the socket until GC, so a
96
262
  // run of failures (say, a mis-scoped API key) would leak connections.
97
263
  await response.body?.cancel();
98
- throw new Error(`Controller history returned HTTP ${response.status}; verify API-key access and controller support. This is not an empty history result.`);
264
+ throw new Error(`${kind.name} returned HTTP ${response.status}; verify API-key access and controller support. ${kind.notEmpty}`);
99
265
  }
100
266
  if (!response.headers.get("content-type")?.includes("application/json")) {
101
267
  await response.body?.cancel();
102
- throw new Error("Controller history returned non-JSON data; verify controller authentication");
268
+ throw new Error(`${kind.name} returned non-JSON data; verify controller authentication`);
103
269
  }
104
270
  // Bound the body before parsing: controller session records can be large.
105
- const reader = response.body?.getReader();
106
- if (!reader)
107
- throw new Error("Controller history returned no response body");
271
+ if (response.body === null)
272
+ throw new Error(`${kind.name} returned no response body`);
273
+ // undici types the body as an untyped stream; a fetch body is always bytes.
274
+ const reader = response.body.getReader();
108
275
  const chunks = [];
109
276
  let bytes = 0;
110
277
  try {
@@ -115,7 +282,7 @@ class NetworkClient {
115
282
  bytes += value.length;
116
283
  if (bytes > 10000000) {
117
284
  await reader.cancel();
118
- throw new Error("Controller history exceeds 10 MB; reduce the page size or time window");
285
+ throw new Error(`${kind.name} exceeds 10 MB; ${kind.tooLarge}`);
119
286
  }
120
287
  chunks.push(value);
121
288
  }
@@ -127,13 +294,21 @@ class NetworkClient {
127
294
  if (text.trim() === "") {
128
295
  // A bare JSON.parse would surface "Unexpected end of JSON input", which
129
296
  // reads as a broken tool rather than an unusable controller response.
130
- throw new Error("Controller history returned an empty body; verify controller support. This is not an empty history result.");
297
+ throw new Error(`${kind.name} returned an empty body; verify controller support. ${kind.notEmpty}`);
298
+ }
299
+ let result;
300
+ try {
301
+ result = JSON.parse(text);
302
+ }
303
+ catch {
304
+ // The parser's message quotes the body around the fault, and classic
305
+ // records hold credentials: never pass it on.
306
+ throw new Error(`${kind.name} returned malformed JSON; verify controller support. ${kind.notEmpty}`);
131
307
  }
132
- const result = JSON.parse(text);
133
308
  if (result && typeof result === "object" && "meta" in result) {
134
309
  const meta = result.meta;
135
310
  if (meta && typeof meta === "object" && "rc" in meta && meta.rc !== "ok") {
136
- throw new Error("Controller rejected the history query (meta.rc is not ok)");
311
+ throw new Error(`Controller rejected the ${kind.query} (meta.rc is not ok)`);
137
312
  }
138
313
  }
139
314
  return result;
package/dist/config.d.ts CHANGED
@@ -1,10 +1,21 @@
1
1
  import { z } from "zod";
2
+ /**
3
+ * Whether host is api.ui.com however it is written: URL parsing lower-cases
4
+ * it and drops a port, and a trailing dot names the same host. The request
5
+ * goes to api.ui.com in each case, so each must get the same treatment.
6
+ */
7
+ export declare function isSiteManagerHost(host: string): boolean;
2
8
  declare const configSchema: z.ZodObject<{
3
9
  host: z.ZodString;
4
10
  apiKey: z.ZodString;
5
11
  consoleId: z.ZodOptional<z.ZodString>;
6
12
  verifySsl: z.ZodDefault<z.ZodBoolean>;
7
13
  readOnly: z.ZodDefault<z.ZodBoolean>;
14
+ writeTools: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string[], string>>>;
15
+ allowWanExposure: z.ZodOptional<z.ZodLiteral<true>>;
16
+ wanZoneIds: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodString>, z.ZodTransform<string[], string[]>>>;
17
+ writeLog: z.ZodOptional<z.ZodString>;
18
+ caCert: z.ZodOptional<z.ZodString>;
8
19
  }, z.core.$strip>;
9
20
  export type Config = z.infer<typeof configSchema>;
10
21
  export declare function loadConfig(): Config;
package/dist/config.js CHANGED
@@ -1,7 +1,35 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSiteManagerHost = isSiteManagerHost;
3
4
  exports.loadConfig = loadConfig;
5
+ const node_fs_1 = require("node:fs");
6
+ const node_path_1 = require("node:path");
4
7
  const zod_1 = require("zod");
8
+ const tls_js_1 = require("./tls.js");
9
+ const responses_js_1 = require("./utils/responses.js");
10
+ const write_tools_js_1 = require("./write-tools.js");
11
+ function holdsCertificate(pem) {
12
+ try {
13
+ return (0, tls_js_1.pemCertificates)(pem).length > 0;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /**
20
+ * Whether host is api.ui.com however it is written: URL parsing lower-cases
21
+ * it and drops a port, and a trailing dot names the same host. The request
22
+ * goes to api.ui.com in each case, so each must get the same treatment.
23
+ */
24
+ function isSiteManagerHost(host) {
25
+ try {
26
+ return new URL(`https://${host}`).hostname.replace(/\.$/, "") === "api.ui.com";
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
33
  const configSchema = zod_1.z.object({
6
34
  host: zod_1.z.string().min(1).describe("UniFi Network host (IP or hostname)"),
7
35
  apiKey: zod_1.z.string().min(1).describe("UniFi Network API key"),
@@ -18,8 +46,59 @@ const configSchema = zod_1.z.object({
18
46
  .boolean()
19
47
  .default(true)
20
48
  .describe("When true, only read-only tools are registered"),
49
+ writeTools: zod_1.z.string()
50
+ .transform((value, ctx) => {
51
+ const { tools, unknown } = (0, write_tools_js_1.parseWriteTools)(value);
52
+ if (unknown.length > 0) {
53
+ ctx.addIssue({
54
+ code: "custom",
55
+ message: `UNIFI_NETWORK_WRITE_TOOLS names unknown write tools or groups: ${unknown.join(", ")}. Groups: ${Object.keys(write_tools_js_1.WRITE_TOOL_GROUPS).join(", ")}. Tools: ${write_tools_js_1.WRITE_TOOLS.join(", ")}. Use "none" for no write tools.`,
56
+ });
57
+ return zod_1.z.NEVER;
58
+ }
59
+ return tools;
60
+ })
61
+ .optional()
62
+ .describe("The write tools to register; undefined registers them all"),
63
+ allowWanExposure: zod_1.z
64
+ .literal(true)
65
+ .optional()
66
+ .describe("When true, writes that could let traffic in from the WAN fall back to the confirm flow instead of being refused"),
67
+ wanZoneIds: zod_1.z
68
+ .array(zod_1.z.string().regex(UUID, "UNIFI_NETWORK_WAN_ZONE_IDS must be a comma-separated list of firewall zone IDs (UUIDs)"))
69
+ .transform((ids) => ids.map((id) => id.toLowerCase()))
70
+ .optional()
71
+ .describe("Firewall zone IDs to treat as the WAN, in addition to the system-defined External zone"),
72
+ writeLog: zod_1.z.string()
73
+ .refine(node_path_1.isAbsolute, "UNIFI_NETWORK_WRITE_LOG must be an absolute path (MCP hosts do not expand ~)")
74
+ .optional()
75
+ .describe("JSON Lines file every write tool call is appended to"),
76
+ caCert: zod_1.z.string()
77
+ .refine(holdsCertificate, "UNIFI_NETWORK_CA_CERT must name a PEM file containing a certificate")
78
+ .optional()
79
+ .describe("PEM certificate(s) to trust for the console, e.g. its own self-signed certificate"),
80
+ }).refine((config) => config.verifySsl || config.caCert === undefined, {
81
+ path: ["caCert"],
82
+ message: "UNIFI_NETWORK_CA_CERT pins a certificate to verify against; it cannot be combined with UNIFI_NETWORK_VERIFY_SSL=false",
83
+ }).refine((config) => config.verifySsl || !isSiteManagerHost(config.host), {
84
+ // Opting out of verification is for local consoles with self-signed
85
+ // certificates. A Site Manager key reaches every console on the account
86
+ // and crosses the internet, so it never goes out unverified.
87
+ path: ["verifySsl"],
88
+ message: "TLS verification cannot be disabled for api.ui.com; unset UNIFI_NETWORK_VERIFY_SSL",
21
89
  });
22
90
  function loadConfig() {
91
+ const caCertPath = process.env.UNIFI_NETWORK_CA_CERT;
92
+ let caCert;
93
+ if (caCertPath) {
94
+ try {
95
+ caCert = (0, node_fs_1.readFileSync)(caCertPath, "utf8");
96
+ }
97
+ catch (err) {
98
+ console.error(`Configuration error: cannot read UNIFI_NETWORK_CA_CERT (${caCertPath}): ${(0, responses_js_1.errorMessage)(err)}`);
99
+ process.exit(1);
100
+ }
101
+ }
23
102
  const result = configSchema.safeParse({
24
103
  host: process.env.UNIFI_NETWORK_HOST,
25
104
  apiKey: process.env.UNIFI_NETWORK_API_KEY,
@@ -27,10 +106,30 @@ function loadConfig() {
27
106
  ? { consoleId: process.env.UNIFI_NETWORK_CONSOLE_ID } : {}),
28
107
  verifySsl: process.env.UNIFI_NETWORK_VERIFY_SSL?.toLowerCase() !== "false",
29
108
  readOnly: process.env.UNIFI_NETWORK_READ_ONLY?.toLowerCase() !== "false",
109
+ ...(caCert !== undefined ? { caCert } : {}),
110
+ ...(process.env.UNIFI_NETWORK_ALLOW_WAN_EXPOSURE?.toLowerCase() === "true"
111
+ ? { allowWanExposure: true } : {}),
112
+ ...(process.env.UNIFI_NETWORK_WAN_ZONE_IDS
113
+ ? { wanZoneIds: process.env.UNIFI_NETWORK_WAN_ZONE_IDS.split(",").map((id) => id.trim()).filter((id) => id !== "") } : {}),
114
+ ...(process.env.UNIFI_NETWORK_WRITE_LOG
115
+ ? { writeLog: process.env.UNIFI_NETWORK_WRITE_LOG } : {}),
116
+ ...(process.env.UNIFI_NETWORK_WRITE_TOOLS !== undefined
117
+ ? { writeTools: process.env.UNIFI_NETWORK_WRITE_TOOLS } : {}),
30
118
  });
31
119
  if (!result.success) {
32
- console.error("Configuration error:", result.error.format());
33
- process.exit(1);
120
+ console.error(`Configuration error:\n${zod_1.z.prettifyError(result.error)}`);
121
+ return process.exit(1);
122
+ }
123
+ const config = result.data;
124
+ if (config.readOnly && config.writeTools !== undefined) {
125
+ // Still validated above, so a typo shows up before writes are turned on.
126
+ console.error("Ignoring UNIFI_NETWORK_WRITE_TOOLS: the server is read-only, so no write tools load. Set UNIFI_NETWORK_READ_ONLY=false to use it.");
127
+ delete config.writeTools;
128
+ }
129
+ if (config.readOnly && config.writeLog !== undefined) {
130
+ // Nothing would be logged, and opening the file could stop the server.
131
+ console.error("Ignoring UNIFI_NETWORK_WRITE_LOG: the server is read-only, so there are no write calls to log. Set UNIFI_NETWORK_READ_ONLY=false to use it.");
132
+ delete config.writeLog;
34
133
  }
35
- return result.data;
134
+ return config;
36
135
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The request guard: every request NetworkClient makes is checked here before
3
+ * fetch is called, against a fixed allowlist of the endpoints the tools in
4
+ * src/tools/ use. Anything else is refused and nothing is sent, so a tool bug,
5
+ * a smuggled ID or a misled model cannot reach the rest of the console with
6
+ * this API key (the rest of the legacy controller API, Protect, another
7
+ * console behind the Cloud Connector).
8
+ *
9
+ * Paths are canonical and relative to the Network application: they start
10
+ * /integration/v1, /v2/api or /api/s, never with the Cloud Connector prefix,
11
+ * which NetworkClient adds only after the guard has passed.
12
+ */
13
+ export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
14
+ export type GuardReason = "malformed" | "method" | "not_allowlisted" | "query" | "read_only" | "tool_disabled";
15
+ export declare class GuardError extends Error {
16
+ readonly reason: GuardReason;
17
+ constructor(reason: GuardReason, message: string);
18
+ }
19
+ export interface Route {
20
+ method: Method;
21
+ /** Canonical path; each {id} is one ID segment, and {site} a site's internalReference. */
22
+ template: string;
23
+ /** Query parameters the endpoint may be sent; any other is refused. */
24
+ query?: readonly string[];
25
+ /** Query parameters that must be present. */
26
+ requiredQuery?: readonly string[];
27
+ /**
28
+ * The write tools that send this request. A route without tools is a read,
29
+ * allowed whatever write tools are enabled — including stat/session's POST,
30
+ * which only queries statistics.
31
+ */
32
+ tools?: readonly string[];
33
+ }
34
+ export interface GuardPolicy {
35
+ readOnly: boolean;
36
+ /** Enabled write tools; undefined enables every write tool. */
37
+ writeTools?: readonly string[];
38
+ }
39
+ /** Every request the tools make. Keep in step with src/tools/; tests/guard.test.ts sweeps every tool against it. */
40
+ export declare const ROUTES: readonly Route[];
41
+ /**
42
+ * Check one request and return the route it matched. Throws GuardError,
43
+ * before anything is sent, for a malformed path, an unknown endpoint, an
44
+ * unexpected query parameter, or a write the policy does not allow.
45
+ */
46
+ export declare function guardRequest(method: string, path: string, policy: GuardPolicy): Route;