@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/utils/safety.d.ts
CHANGED
|
@@ -1,14 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
/** The confirm input of a DESTRUCTIVE tool. */
|
|
4
|
+
export declare const confirmDestructive: () => z.ZodOptional<z.ZodBoolean>;
|
|
2
5
|
export declare const READ_ONLY: ToolAnnotations;
|
|
3
6
|
export declare const WRITE: ToolAnnotations;
|
|
4
7
|
export declare const WRITE_NOT_IDEMPOTENT: ToolAnnotations;
|
|
5
8
|
export declare const DESTRUCTIVE: ToolAnnotations;
|
|
6
|
-
|
|
9
|
+
/**
|
|
10
|
+
* A dry run's result. confirmationRequired is the reason the real call needs
|
|
11
|
+
* confirm, if it does: the preview is what the user approves, so it has to
|
|
12
|
+
* say that the change opens access before they approve it, not after.
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatDryRun(method: string, path: string, body?: unknown, confirmationRequired?: string): {
|
|
7
15
|
content: {
|
|
8
16
|
type: "text";
|
|
9
17
|
text: string;
|
|
10
18
|
}[];
|
|
19
|
+
structuredContent: Record<string, unknown>;
|
|
11
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* Confirmation for a write that is routine in general but, with these inputs,
|
|
23
|
+
* opens access — an ALLOW rule, an open SSID, a batch of guest codes. reason
|
|
24
|
+
* says what opens, or is undefined when nothing does; a dry run changes
|
|
25
|
+
* nothing, so it never needs confirming.
|
|
26
|
+
*/
|
|
27
|
+
export declare function requireConfirmationIf(reason: string | undefined, confirm: boolean | undefined, dryRun: boolean | undefined): {
|
|
28
|
+
content: {
|
|
29
|
+
type: "text";
|
|
30
|
+
text: string;
|
|
31
|
+
}[];
|
|
32
|
+
isError: true;
|
|
33
|
+
} | null;
|
|
34
|
+
/** How a rule in an evaluation order treats the traffic it matches. */
|
|
35
|
+
export interface RuleEffect {
|
|
36
|
+
letsThrough: boolean;
|
|
37
|
+
blocks: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether a reorder opens access: whether it moves a rule that lets traffic
|
|
41
|
+
* through ahead of one that blocks, which it used to follow. Only rules in
|
|
42
|
+
* both orders are compared, and effect is asked only about the rules a
|
|
43
|
+
* reorder actually moves past one another. IDs are compared without case.
|
|
44
|
+
*/
|
|
45
|
+
export declare function reorderOpens(before: readonly string[], after: readonly string[], effect: (id: string) => Promise<RuleEffect>): Promise<boolean>;
|
|
12
46
|
export declare function requireConfirmation(confirm: boolean | undefined, action: string): {
|
|
13
47
|
content: {
|
|
14
48
|
type: "text";
|
package/dist/utils/safety.js
CHANGED
|
@@ -1,38 +1,88 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DESTRUCTIVE = exports.WRITE_NOT_IDEMPOTENT = exports.WRITE = exports.READ_ONLY = void 0;
|
|
3
|
+
exports.DESTRUCTIVE = exports.WRITE_NOT_IDEMPOTENT = exports.WRITE = exports.READ_ONLY = exports.confirmDestructive = void 0;
|
|
4
4
|
exports.formatDryRun = formatDryRun;
|
|
5
|
+
exports.requireConfirmationIf = requireConfirmationIf;
|
|
6
|
+
exports.reorderOpens = reorderOpens;
|
|
5
7
|
exports.requireConfirmation = requireConfirmation;
|
|
8
|
+
const zod_1 = require("zod");
|
|
9
|
+
const write_log_js_1 = require("../write-log.js");
|
|
10
|
+
/** The confirm input of a DESTRUCTIVE tool. */
|
|
11
|
+
const confirmDestructive = () => zod_1.z.boolean().optional().describe("Must be true to execute this destructive action; not needed with dryRun: true");
|
|
12
|
+
exports.confirmDestructive = confirmDestructive;
|
|
6
13
|
exports.READ_ONLY = {
|
|
7
14
|
readOnlyHint: true,
|
|
8
15
|
destructiveHint: false,
|
|
16
|
+
openWorldHint: false,
|
|
9
17
|
};
|
|
10
18
|
exports.WRITE = {
|
|
11
19
|
readOnlyHint: false,
|
|
12
20
|
destructiveHint: false,
|
|
13
21
|
idempotentHint: true,
|
|
22
|
+
openWorldHint: false,
|
|
14
23
|
};
|
|
15
24
|
exports.WRITE_NOT_IDEMPOTENT = {
|
|
16
25
|
readOnlyHint: false,
|
|
17
26
|
destructiveHint: false,
|
|
18
27
|
idempotentHint: false,
|
|
28
|
+
openWorldHint: false,
|
|
19
29
|
};
|
|
20
30
|
exports.DESTRUCTIVE = {
|
|
21
31
|
readOnlyHint: false,
|
|
22
32
|
destructiveHint: true,
|
|
23
33
|
idempotentHint: false,
|
|
34
|
+
openWorldHint: false,
|
|
24
35
|
};
|
|
25
|
-
|
|
36
|
+
/**
|
|
37
|
+
* A dry run's result. confirmationRequired is the reason the real call needs
|
|
38
|
+
* confirm, if it does: the preview is what the user approves, so it has to
|
|
39
|
+
* say that the change opens access before they approve it, not after.
|
|
40
|
+
*/
|
|
41
|
+
function formatDryRun(method, path, body, confirmationRequired) {
|
|
26
42
|
const preview = {
|
|
27
43
|
dryRun: true,
|
|
28
44
|
wouldExecute: { method, path, ...(body !== undefined ? { body } : {}) },
|
|
45
|
+
...(confirmationRequired !== undefined ? { confirmationRequired } : {}),
|
|
29
46
|
};
|
|
47
|
+
// structuredContent is required: the SDK rejects a result without it from
|
|
48
|
+
// any tool that declares an outputSchema.
|
|
30
49
|
return {
|
|
31
|
-
content: [{ type: "text", text: JSON.stringify(preview
|
|
50
|
+
content: [{ type: "text", text: JSON.stringify(preview) }],
|
|
51
|
+
structuredContent: preview,
|
|
32
52
|
};
|
|
33
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Confirmation for a write that is routine in general but, with these inputs,
|
|
56
|
+
* opens access — an ALLOW rule, an open SSID, a batch of guest codes. reason
|
|
57
|
+
* says what opens, or is undefined when nothing does; a dry run changes
|
|
58
|
+
* nothing, so it never needs confirming.
|
|
59
|
+
*/
|
|
60
|
+
function requireConfirmationIf(reason, confirm, dryRun) {
|
|
61
|
+
return reason === undefined || dryRun ? null : requireConfirmation(confirm, reason);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Whether a reorder opens access: whether it moves a rule that lets traffic
|
|
65
|
+
* through ahead of one that blocks, which it used to follow. Only rules in
|
|
66
|
+
* both orders are compared, and effect is asked only about the rules a
|
|
67
|
+
* reorder actually moves past one another. IDs are compared without case.
|
|
68
|
+
*/
|
|
69
|
+
async function reorderOpens(before, after, effect) {
|
|
70
|
+
const rank = new Map(before.map((id, index) => [id.toLowerCase(), index]));
|
|
71
|
+
const passes = [];
|
|
72
|
+
after.forEach((ahead, i) => {
|
|
73
|
+
for (const behind of after.slice(i + 1)) {
|
|
74
|
+
const [was, other] = [rank.get(ahead.toLowerCase()), rank.get(behind.toLowerCase())];
|
|
75
|
+
if (was !== undefined && other !== undefined && was > other)
|
|
76
|
+
passes.push([ahead, behind]);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const ids = [...new Set(passes.flat())];
|
|
80
|
+
const effects = new Map(await Promise.all(ids.map(async (id) => [id, await effect(id)])));
|
|
81
|
+
return passes.some(([ahead, behind]) => effects.get(ahead)?.letsThrough === true && effects.get(behind)?.blocks === true);
|
|
82
|
+
}
|
|
34
83
|
function requireConfirmation(confirm, action) {
|
|
35
84
|
if (confirm !== true) {
|
|
85
|
+
(0, write_log_js_1.noteRefusal)("confirmation_required");
|
|
36
86
|
return {
|
|
37
87
|
content: [
|
|
38
88
|
{
|
package/dist/utils/secrets.js
CHANGED
|
@@ -79,6 +79,9 @@ function containsRedacted(value) {
|
|
|
79
79
|
function stableStringify(value) {
|
|
80
80
|
if (Array.isArray(value))
|
|
81
81
|
return `[${value.map(stableStringify).join(",")}]`;
|
|
82
|
+
// JSON.stringify returns undefined for undefined and functions, whatever
|
|
83
|
+
// its lib.d.ts signature says.
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
82
85
|
if (value === null || typeof value !== "object")
|
|
83
86
|
return JSON.stringify(value) ?? "undefined";
|
|
84
87
|
const entries = Object.entries(value)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { NetworkClient } from "../client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Hard refusals for writes that would let traffic in from the WAN. Unlike
|
|
4
|
+
* confirm, which the model can pass after asking (or after being talked into
|
|
5
|
+
* it by data it read), these hold whatever the call says: the operator turns
|
|
6
|
+
* them off with UNIFI_NETWORK_ALLOW_WAN_EXPOSURE=true, and then the existing
|
|
7
|
+
* confirm flow applies.
|
|
8
|
+
*
|
|
9
|
+
* Which zone is the WAN: the Integration API marks zones only with
|
|
10
|
+
* metadata.origin, and names can be changed, so a zone is the WAN when its ID
|
|
11
|
+
* is in UNIFI_NETWORK_WAN_ZONE_IDS, or when it is system-defined and named
|
|
12
|
+
* "External". The ID list is the reliable signal; the name is the fallback
|
|
13
|
+
* for operators who have not set it. When the zones cannot be read, or a
|
|
14
|
+
* zone a change depends on is not among them, the write is refused: a check
|
|
15
|
+
* that cannot run must not pass.
|
|
16
|
+
*
|
|
17
|
+
* ACL rules need no check: they are switch-enforced IPV4/MAC rules between
|
|
18
|
+
* LAN hosts and have no zone, so they cannot admit WAN traffic past the
|
|
19
|
+
* gateway firewall. The Integration API has no port-forward endpoint.
|
|
20
|
+
*/
|
|
21
|
+
export interface WanPolicy {
|
|
22
|
+
/** UNIFI_NETWORK_ALLOW_WAN_EXPOSURE: skip these refusals. */
|
|
23
|
+
allowExposure: boolean;
|
|
24
|
+
/** UNIFI_NETWORK_WAN_ZONE_IDS, lower-cased. */
|
|
25
|
+
zoneIds: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
export declare const DEFAULT_WAN_POLICY: WanPolicy;
|
|
28
|
+
/** The checks for one site; zones and policies are read at most once per call. */
|
|
29
|
+
export declare class WanExposure {
|
|
30
|
+
private readonly client;
|
|
31
|
+
private readonly wan;
|
|
32
|
+
private readonly siteId;
|
|
33
|
+
private zones?;
|
|
34
|
+
private policies?;
|
|
35
|
+
constructor(client: NetworkClient, wan: WanPolicy, siteId: string);
|
|
36
|
+
/** A firewall policy, as it would be after the write: refused if it ALLOWs traffic from the WAN. */
|
|
37
|
+
firewallPolicy(policy: Record<string, unknown>): Promise<string | undefined>;
|
|
38
|
+
/** A zone create (zoneId undefined) or full update: refused if it changes the WAN zone or takes networks from it. */
|
|
39
|
+
firewallZone(zoneId: string | undefined, networkIds: readonly string[]): Promise<string | undefined>;
|
|
40
|
+
/** A zone delete: refused if it is the WAN zone. */
|
|
41
|
+
zoneDelete(zoneId: string): Promise<string | undefined>;
|
|
42
|
+
/** The zone a network is put in, and on an update the zone it leaves: refused if either is the WAN zone. */
|
|
43
|
+
networkZone(zoneId: string, currentZoneId?: string): Promise<string | undefined>;
|
|
44
|
+
/** A policy reorder: refused for policies that apply to traffic from the WAN. */
|
|
45
|
+
policyOrder(sourceZoneId: string): Promise<string | undefined>;
|
|
46
|
+
/** A traffic matching list update: refused if a policy that ALLOWs traffic from the WAN uses the list. */
|
|
47
|
+
matchingList(listId: string): Promise<string | undefined>;
|
|
48
|
+
/**
|
|
49
|
+
* Refuse a change that puts networks in zone when a policy ALLOWs traffic
|
|
50
|
+
* from the WAN into it: the networks would be reachable from the WAN with no
|
|
51
|
+
* policy changing. A policy's destination trafficFilter may narrow it to
|
|
52
|
+
* hosts outside the networks, but telling that apart takes more of the
|
|
53
|
+
* filter's shape than the docs pin down, so it counts either way.
|
|
54
|
+
*/
|
|
55
|
+
private refuseIfWanTarget;
|
|
56
|
+
private isWan;
|
|
57
|
+
private zone;
|
|
58
|
+
private zoneList;
|
|
59
|
+
private policyList;
|
|
60
|
+
private refusal;
|
|
61
|
+
}
|
|
62
|
+
/** The result of a refused write; method and path are the write it would have sent. */
|
|
63
|
+
export declare function wanRefusal(reason: string, method: string, path: string): {
|
|
64
|
+
content: {
|
|
65
|
+
type: "text";
|
|
66
|
+
text: string;
|
|
67
|
+
}[];
|
|
68
|
+
isError: true;
|
|
69
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WanExposure = exports.DEFAULT_WAN_POLICY = void 0;
|
|
4
|
+
exports.wanRefusal = wanRefusal;
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const write_log_js_1 = require("../write-log.js");
|
|
7
|
+
const responses_js_1 = require("./responses.js");
|
|
8
|
+
exports.DEFAULT_WAN_POLICY = { allowExposure: false, zoneIds: [] };
|
|
9
|
+
const Zone = zod_1.z.object({
|
|
10
|
+
id: zod_1.z.string(),
|
|
11
|
+
name: zod_1.z.string().optional(),
|
|
12
|
+
networkIds: zod_1.z.array(zod_1.z.string()).optional(),
|
|
13
|
+
metadata: zod_1.z.object({ origin: zod_1.z.string().optional() }).loose().optional(),
|
|
14
|
+
}).loose();
|
|
15
|
+
const Policy = zod_1.z.object({
|
|
16
|
+
id: zod_1.z.string().optional(),
|
|
17
|
+
name: zod_1.z.string().optional(),
|
|
18
|
+
action: zod_1.z.unknown().optional(),
|
|
19
|
+
source: zod_1.z.unknown().optional(),
|
|
20
|
+
destination: zod_1.z.unknown().optional(),
|
|
21
|
+
}).loose();
|
|
22
|
+
const PAGE_LIMIT = 200;
|
|
23
|
+
const MAX_PAGES = 50;
|
|
24
|
+
async function listAll(client, path, item) {
|
|
25
|
+
const page = zod_1.z.object({ data: zod_1.z.array(item), totalCount: zod_1.z.number().optional() }).loose();
|
|
26
|
+
const items = [];
|
|
27
|
+
for (let pages = 0; pages < MAX_PAGES; pages++) {
|
|
28
|
+
const { data, totalCount } = page.parse(await client.get(`${path}?offset=${items.length}&limit=${PAGE_LIMIT}`));
|
|
29
|
+
items.push(...data);
|
|
30
|
+
// Without a totalCount, only an empty page shows the list has ended: a
|
|
31
|
+
// controller may cap pages below PAGE_LIMIT, so a short page proves nothing.
|
|
32
|
+
if (data.length === 0 || (totalCount !== undefined && items.length >= totalCount))
|
|
33
|
+
return items;
|
|
34
|
+
}
|
|
35
|
+
// A list that does not end cannot be checked, which must not pass.
|
|
36
|
+
throw new Error(`the list did not end after ${MAX_PAGES} pages`);
|
|
37
|
+
}
|
|
38
|
+
class Refusal extends Error {
|
|
39
|
+
}
|
|
40
|
+
/** The checks for one site; zones and policies are read at most once per call. */
|
|
41
|
+
class WanExposure {
|
|
42
|
+
client;
|
|
43
|
+
wan;
|
|
44
|
+
siteId;
|
|
45
|
+
zones;
|
|
46
|
+
policies;
|
|
47
|
+
constructor(client, wan, siteId) {
|
|
48
|
+
this.client = client;
|
|
49
|
+
this.wan = wan;
|
|
50
|
+
this.siteId = siteId;
|
|
51
|
+
}
|
|
52
|
+
/** A firewall policy, as it would be after the write: refused if it ALLOWs traffic from the WAN. */
|
|
53
|
+
async firewallPolicy(policy) {
|
|
54
|
+
if (this.wan.allowExposure || !allowsTraffic(policy))
|
|
55
|
+
return undefined;
|
|
56
|
+
return this.refusal(async () => {
|
|
57
|
+
const zoneId = sourceZoneId(policy);
|
|
58
|
+
if (zoneId === undefined) {
|
|
59
|
+
throw new Refusal("the policy ALLOWs traffic but has no source.zoneId, so its source cannot be checked against the WAN zone");
|
|
60
|
+
}
|
|
61
|
+
const zone = await this.zone(zoneId, "its source zone");
|
|
62
|
+
if (this.isWan(zone))
|
|
63
|
+
throw new Refusal(`the policy would ALLOW traffic from the ${label(zone)} zone, which is the WAN`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** A zone create (zoneId undefined) or full update: refused if it changes the WAN zone or takes networks from it. */
|
|
67
|
+
async firewallZone(zoneId, networkIds) {
|
|
68
|
+
if (this.wan.allowExposure || (zoneId === undefined && networkIds.length === 0))
|
|
69
|
+
return undefined;
|
|
70
|
+
return this.refusal(async () => {
|
|
71
|
+
if (zoneId !== undefined) {
|
|
72
|
+
const zone = await this.zone(zoneId, "the zone");
|
|
73
|
+
if (this.isWan(zone))
|
|
74
|
+
throw new Refusal(`the change would modify the ${label(zone)} zone, which is the WAN`);
|
|
75
|
+
}
|
|
76
|
+
const wanNetworks = new Set((await this.zoneList()).filter((zone) => this.isWan(zone))
|
|
77
|
+
.flatMap((zone) => zone.networkIds ?? []).map((id) => id.toLowerCase()));
|
|
78
|
+
const moved = networkIds.filter((id) => wanNetworks.has(id.toLowerCase()));
|
|
79
|
+
if (moved.length > 0)
|
|
80
|
+
throw new Refusal(`the change would move networks out of the WAN zone: ${moved.join(", ")}`);
|
|
81
|
+
// A new zone has no policies yet; an existing one may be open to the WAN.
|
|
82
|
+
if (zoneId !== undefined) {
|
|
83
|
+
const zone = await this.zone(zoneId, "the zone");
|
|
84
|
+
const present = new Set((zone.networkIds ?? []).map((id) => id.toLowerCase()));
|
|
85
|
+
const added = networkIds.filter((id) => !present.has(id.toLowerCase()));
|
|
86
|
+
if (added.length > 0)
|
|
87
|
+
await this.refuseIfWanTarget(zone, `add networks (${added.join(", ")}) to`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** A zone delete: refused if it is the WAN zone. */
|
|
92
|
+
async zoneDelete(zoneId) {
|
|
93
|
+
if (this.wan.allowExposure)
|
|
94
|
+
return undefined;
|
|
95
|
+
return this.refusal(async () => {
|
|
96
|
+
const zone = await this.zone(zoneId, "the zone");
|
|
97
|
+
if (this.isWan(zone))
|
|
98
|
+
throw new Refusal(`the change would delete the ${label(zone)} zone, which is the WAN`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** The zone a network is put in, and on an update the zone it leaves: refused if either is the WAN zone. */
|
|
102
|
+
async networkZone(zoneId, currentZoneId) {
|
|
103
|
+
if (this.wan.allowExposure)
|
|
104
|
+
return undefined;
|
|
105
|
+
return this.refusal(async () => {
|
|
106
|
+
if (currentZoneId !== undefined) {
|
|
107
|
+
const current = await this.zone(currentZoneId, "the network's current zoneId");
|
|
108
|
+
if (this.isWan(current))
|
|
109
|
+
throw new Refusal(`the change would move the network out of the ${label(current)} zone, which is the WAN`);
|
|
110
|
+
}
|
|
111
|
+
const zone = await this.zone(zoneId, "the network's zoneId");
|
|
112
|
+
if (this.isWan(zone))
|
|
113
|
+
throw new Refusal(`the change would put the network in the ${label(zone)} zone, which is the WAN`);
|
|
114
|
+
await this.refuseIfWanTarget(zone, "put the network in");
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** A policy reorder: refused for policies that apply to traffic from the WAN. */
|
|
118
|
+
async policyOrder(sourceZoneId) {
|
|
119
|
+
if (this.wan.allowExposure)
|
|
120
|
+
return undefined;
|
|
121
|
+
return this.refusal(async () => {
|
|
122
|
+
const zone = await this.zone(sourceZoneId, "the source zone");
|
|
123
|
+
if (this.isWan(zone)) {
|
|
124
|
+
throw new Refusal(`the change would reorder the policies for traffic from the ${label(zone)} zone, which is the WAN, and could put an ALLOW ahead of a BLOCK`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** A traffic matching list update: refused if a policy that ALLOWs traffic from the WAN uses the list. */
|
|
129
|
+
async matchingList(listId) {
|
|
130
|
+
if (this.wan.allowExposure)
|
|
131
|
+
return undefined;
|
|
132
|
+
return this.refusal(async () => {
|
|
133
|
+
// Either lookup failing refuses the write.
|
|
134
|
+
const [zones, policies] = await Promise.all([this.zoneList(), this.policyList()]);
|
|
135
|
+
const user = policies.find((policy) => {
|
|
136
|
+
// IDs are compared without case: the controller may not care about it.
|
|
137
|
+
if (!allowsTraffic(policy) || !JSON.stringify(policy).toLowerCase().includes(JSON.stringify(listId.toLowerCase())))
|
|
138
|
+
return false;
|
|
139
|
+
// A source zone that is not on the list could be the WAN: count it.
|
|
140
|
+
const zone = zones.find((candidate) => sameId(candidate.id, sourceZoneId(policy)));
|
|
141
|
+
return zone === undefined || this.isWan(zone);
|
|
142
|
+
});
|
|
143
|
+
if (user) {
|
|
144
|
+
throw new Refusal(`the list is used by firewall policy ${JSON.stringify(user.name ?? user.id)}, which ALLOWs traffic from the WAN, so changing it changes what the WAN can reach`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Refuse a change that puts networks in zone when a policy ALLOWs traffic
|
|
150
|
+
* from the WAN into it: the networks would be reachable from the WAN with no
|
|
151
|
+
* policy changing. A policy's destination trafficFilter may narrow it to
|
|
152
|
+
* hosts outside the networks, but telling that apart takes more of the
|
|
153
|
+
* filter's shape than the docs pin down, so it counts either way.
|
|
154
|
+
*/
|
|
155
|
+
async refuseIfWanTarget(zone, change) {
|
|
156
|
+
const [zones, policies] = await Promise.all([this.zoneList(), this.policyList()]);
|
|
157
|
+
const exposing = policies.find((policy) => {
|
|
158
|
+
if (!allowsTraffic(policy) || !sameId(zone.id, destinationZoneId(policy)))
|
|
159
|
+
return false;
|
|
160
|
+
// A source zone that is not on the list could be the WAN: count it.
|
|
161
|
+
const source = zones.find((candidate) => sameId(candidate.id, sourceZoneId(policy)));
|
|
162
|
+
return source === undefined || this.isWan(source);
|
|
163
|
+
});
|
|
164
|
+
if (exposing) {
|
|
165
|
+
throw new Refusal(`the change would ${change} the ${label(zone)} zone, which firewall policy ${JSON.stringify(exposing.name ?? exposing.id)} ALLOWs traffic into from the WAN`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
isWan(zone) {
|
|
169
|
+
return this.wan.zoneIds.includes(zone.id.toLowerCase()) ||
|
|
170
|
+
(zone.metadata?.origin === "SYSTEM_DEFINED" && zone.name?.toLowerCase() === "external");
|
|
171
|
+
}
|
|
172
|
+
async zone(zoneId, what) {
|
|
173
|
+
const zone = (await this.zoneList()).find((candidate) => sameId(candidate.id, zoneId));
|
|
174
|
+
if (zone === undefined)
|
|
175
|
+
throw new Refusal(`${what} (${zoneId}) is not one of this site's firewall zones, so it cannot be checked against the WAN zone`);
|
|
176
|
+
return zone;
|
|
177
|
+
}
|
|
178
|
+
zoneList() {
|
|
179
|
+
this.zones ??= listAll(this.client, `/sites/${this.siteId}/firewall/zones`, Zone).catch((err) => {
|
|
180
|
+
throw new Refusal(`the firewall zones could not be read to find the WAN zone (${(0, responses_js_1.errorMessage)(err)})`);
|
|
181
|
+
});
|
|
182
|
+
return this.zones;
|
|
183
|
+
}
|
|
184
|
+
policyList() {
|
|
185
|
+
this.policies ??= listAll(this.client, `/sites/${this.siteId}/firewall/policies`, Policy).catch((err) => {
|
|
186
|
+
throw new Refusal(`the firewall policies that the change depends on could not be read (${(0, responses_js_1.errorMessage)(err)})`);
|
|
187
|
+
});
|
|
188
|
+
return this.policies;
|
|
189
|
+
}
|
|
190
|
+
async refusal(check) {
|
|
191
|
+
try {
|
|
192
|
+
await check();
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
if (err instanceof Refusal)
|
|
197
|
+
return err.message;
|
|
198
|
+
return `the WAN check failed (${(0, responses_js_1.errorMessage)(err)})`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
exports.WanExposure = WanExposure;
|
|
203
|
+
/** Connection states that only answer traffic already let through, so cannot open a new connection. */
|
|
204
|
+
const RETURN_STATES = ["ESTABLISHED", "RELATED"];
|
|
205
|
+
/**
|
|
206
|
+
* Any action but BLOCK or REJECT lets traffic through, including a missing
|
|
207
|
+
* one, unless the policy matches only ESTABLISHED and RELATED connections.
|
|
208
|
+
* A missing, null or empty connectionStateFilter matches every state.
|
|
209
|
+
*/
|
|
210
|
+
function allowsTraffic(policy) {
|
|
211
|
+
const action = policy.action;
|
|
212
|
+
const type = action !== null && typeof action === "object" && "type" in action ? action.type : undefined;
|
|
213
|
+
if (type === "BLOCK" || type === "REJECT")
|
|
214
|
+
return false;
|
|
215
|
+
const states = policy.connectionStateFilter;
|
|
216
|
+
return !(Array.isArray(states) && states.length > 0 && states.every((state) => RETURN_STATES.includes(state)));
|
|
217
|
+
}
|
|
218
|
+
function sourceZoneId(policy) {
|
|
219
|
+
const source = policy.source;
|
|
220
|
+
const zoneId = source !== null && typeof source === "object" && "zoneId" in source ? source.zoneId : undefined;
|
|
221
|
+
return typeof zoneId === "string" ? zoneId : undefined;
|
|
222
|
+
}
|
|
223
|
+
function destinationZoneId(policy) {
|
|
224
|
+
const destination = policy.destination;
|
|
225
|
+
const zoneId = destination !== null && typeof destination === "object" && "zoneId" in destination ? destination.zoneId : undefined;
|
|
226
|
+
return typeof zoneId === "string" ? zoneId : undefined;
|
|
227
|
+
}
|
|
228
|
+
/** IDs are compared without case: the controller may not care about it. */
|
|
229
|
+
function sameId(a, b) {
|
|
230
|
+
return a.toLowerCase() === b?.toLowerCase();
|
|
231
|
+
}
|
|
232
|
+
function label(zone) {
|
|
233
|
+
return zone.name ?? zone.id;
|
|
234
|
+
}
|
|
235
|
+
/** The result of a refused write; method and path are the write it would have sent. */
|
|
236
|
+
function wanRefusal(reason, method, path) {
|
|
237
|
+
(0, write_log_js_1.noteRefusal)("wan_exposure", method, `/integration/v1${path}`);
|
|
238
|
+
return {
|
|
239
|
+
content: [{
|
|
240
|
+
type: "text",
|
|
241
|
+
text: `Refused: ${reason}. This server does not make changes that could let traffic in from the WAN, and confirm does not override this; if the change is intended, make it in the UniFi console.`,
|
|
242
|
+
}],
|
|
243
|
+
isError: true,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UNIFI_NETWORK_WRITE_LOG: one JSON line per write tool call, so an operator
|
|
3
|
+
* can see what an agent changed (or tried to) without trusting its account
|
|
4
|
+
* of it. Lines carry the canonical request path and HTTP status, never the
|
|
5
|
+
* host, the console ID, the API key or a controller response body.
|
|
6
|
+
*/
|
|
7
|
+
export type RefusalReason = "request_guard" | "wan_exposure" | "confirmation_required";
|
|
8
|
+
export interface WriteLogEntry {
|
|
9
|
+
ts: string;
|
|
10
|
+
tsLocal: string;
|
|
11
|
+
tool: string;
|
|
12
|
+
args: unknown;
|
|
13
|
+
method: string | null;
|
|
14
|
+
path: string | null;
|
|
15
|
+
dryRun: boolean;
|
|
16
|
+
outcome: "ok" | "dry_run" | "refused" | "error";
|
|
17
|
+
status?: number;
|
|
18
|
+
reason?: RefusalReason;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
}
|
|
21
|
+
/** NetworkClient: a write request passed the guard and is about to be sent. */
|
|
22
|
+
export declare function noteWriteRequest(method: string, path: string): void;
|
|
23
|
+
/** NetworkClient: the controller answered the write request. */
|
|
24
|
+
export declare function noteWriteStatus(status: number): void;
|
|
25
|
+
/** A check refused the call; the first refusal is the one logged. */
|
|
26
|
+
export declare function noteRefusal(reason: RefusalReason, method?: string, path?: string): void;
|
|
27
|
+
/** "2026-10-18T21:04:05.123+02:00": the local time with its offset. */
|
|
28
|
+
export declare function localTimestamp(date: Date): string;
|
|
29
|
+
export declare class WriteLog {
|
|
30
|
+
private readonly sink;
|
|
31
|
+
private readonly apiKey;
|
|
32
|
+
private failed;
|
|
33
|
+
/** sink appends one line; it may throw, and a failure is reported once and never breaks the call. */
|
|
34
|
+
constructor(sink: (line: string) => void, apiKey: string);
|
|
35
|
+
/**
|
|
36
|
+
* Open path for appending, creating it 0600 (and its directory 0700) if
|
|
37
|
+
* needed. Throws for a symlink, anything but a regular file, or a file
|
|
38
|
+
* others can write to, which could be truncated or filled with lies.
|
|
39
|
+
*
|
|
40
|
+
* On Windows, Node reports every writable file as mode 0666 and a link
|
|
41
|
+
* count that says nothing about access, and O_NOFOLLOW does not exist, so
|
|
42
|
+
* only the symlink and regular-file checks apply there: the file's ACL is
|
|
43
|
+
* the operator's to set.
|
|
44
|
+
*/
|
|
45
|
+
static open(path: string, apiKey: string): WriteLog;
|
|
46
|
+
/** Run one write tool call and append its line. */
|
|
47
|
+
record<T>(tool: string, input: unknown, run: () => T | Promise<T>): Promise<T>;
|
|
48
|
+
private entry;
|
|
49
|
+
private append;
|
|
50
|
+
}
|
|
51
|
+
/** Open the write log at startup, or exit with a configuration error. */
|
|
52
|
+
export declare function openWriteLog(path: string, apiKey: string): WriteLog;
|