@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.
- package/LICENSE +21 -0
- package/README.md +257 -77
- package/dist/client.d.ts +26 -1
- package/dist/client.js +199 -24
- package/dist/config.d.ts +11 -0
- package/dist/config.js +102 -3
- package/dist/guard.d.ts +46 -0
- package/dist/guard.js +180 -0
- package/dist/index.js +17 -4
- package/dist/instructions.d.ts +9 -0
- package/dist/instructions.js +32 -0
- package/dist/tls.d.ts +14 -0
- package/dist/tls.js +58 -0
- package/dist/tools/acl.js +123 -50
- package/dist/tools/classic.d.ts +3 -0
- package/dist/tools/classic.js +54 -0
- package/dist/tools/client-history.js +22 -12
- package/dist/tools/clients.js +10 -9
- package/dist/tools/devices.js +21 -20
- package/dist/tools/dns-policies.js +27 -27
- package/dist/tools/exposure.d.ts +3 -0
- package/dist/tools/exposure.js +118 -0
- package/dist/tools/firewall.d.ts +2 -1
- package/dist/tools/firewall.js +200 -75
- package/dist/tools/hotspot.js +35 -28
- package/dist/tools/index.d.ts +13 -1
- package/dist/tools/index.js +67 -4
- package/dist/tools/networks.d.ts +2 -1
- package/dist/tools/networks.js +44 -31
- package/dist/tools/sites.js +2 -2
- package/dist/tools/supporting.js +22 -21
- package/dist/tools/switching.js +16 -15
- package/dist/tools/system.js +1 -1
- package/dist/tools/traffic-matching.d.ts +2 -1
- package/dist/tools/traffic-matching.js +22 -20
- package/dist/tools/wifi.js +76 -30
- package/dist/utils/full-put.d.ts +11 -2
- package/dist/utils/full-put.js +25 -2
- package/dist/utils/output-schemas.d.ts +241 -4
- package/dist/utils/output-schemas.js +225 -49
- package/dist/utils/params.d.ts +12 -0
- package/dist/utils/params.js +21 -0
- package/dist/utils/pick.d.ts +6 -0
- package/dist/utils/pick.js +11 -0
- package/dist/utils/responses.d.ts +2 -0
- package/dist/utils/responses.js +28 -2
- package/dist/utils/safety.d.ts +35 -1
- package/dist/utils/safety.js +53 -3
- package/dist/utils/secrets.js +3 -0
- package/dist/utils/wan-exposure.d.ts +69 -0
- package/dist/utils/wan-exposure.js +245 -0
- package/dist/write-log.d.ts +52 -0
- package/dist/write-log.js +178 -0
- package/dist/write-tools.d.ts +27 -0
- package/dist/write-tools.js +50 -0
- 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
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
134
|
+
return config;
|
|
36
135
|
}
|
package/dist/guard.d.ts
ADDED
|
@@ -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;
|