@owine/unifi-network-mcp 2.14.2 → 3.0.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +235 -80
  3. package/dist/client.d.ts +13 -1
  4. package/dist/client.js +142 -16
  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 +171 -0
  9. package/dist/index.js +15 -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 +126 -53
  15. package/dist/tools/client-history.js +20 -10
  16. package/dist/tools/clients.js +14 -13
  17. package/dist/tools/devices.js +30 -29
  18. package/dist/tools/dns-policies.js +116 -69
  19. package/dist/tools/firewall.d.ts +2 -1
  20. package/dist/tools/firewall.js +245 -80
  21. package/dist/tools/hotspot.js +35 -28
  22. package/dist/tools/index.d.ts +13 -1
  23. package/dist/tools/index.js +63 -4
  24. package/dist/tools/networks.d.ts +2 -1
  25. package/dist/tools/networks.js +152 -58
  26. package/dist/tools/sites.js +3 -3
  27. package/dist/tools/supporting.js +31 -30
  28. package/dist/tools/switching.js +22 -21
  29. package/dist/tools/system.js +1 -1
  30. package/dist/tools/traffic-matching.d.ts +2 -1
  31. package/dist/tools/traffic-matching.js +31 -28
  32. package/dist/tools/wifi.js +228 -311
  33. package/dist/utils/full-put.d.ts +32 -0
  34. package/dist/utils/full-put.js +74 -0
  35. package/dist/utils/output-schemas.d.ts +77 -9
  36. package/dist/utils/output-schemas.js +106 -54
  37. package/dist/utils/params.d.ts +7 -0
  38. package/dist/utils/params.js +12 -0
  39. package/dist/utils/responses.d.ts +2 -0
  40. package/dist/utils/responses.js +28 -2
  41. package/dist/utils/safety.d.ts +35 -1
  42. package/dist/utils/safety.js +53 -3
  43. package/dist/utils/secrets.d.ts +24 -0
  44. package/dist/utils/secrets.js +92 -0
  45. package/dist/utils/wan-exposure.d.ts +69 -0
  46. package/dist/utils/wan-exposure.js +245 -0
  47. package/dist/write-log.d.ts +52 -0
  48. package/dist/write-log.js +178 -0
  49. package/dist/write-tools.d.ts +27 -0
  50. package/dist/write-tools.js +50 -0
  51. package/package.json +19 -15
@@ -1,33 +1,116 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerFirewallTools = registerFirewallTools;
4
+ const node_util_1 = require("node:util");
4
5
  const zod_1 = require("zod");
5
6
  const responses_js_1 = require("../utils/responses.js");
6
7
  const query_js_1 = require("../utils/query.js");
7
8
  const safety_js_1 = require("../utils/safety.js");
9
+ const full_put_js_1 = require("../utils/full-put.js");
8
10
  const output_schemas_js_1 = require("../utils/output-schemas.js");
9
- function registerFirewallTools(server, client, readOnly = false) {
11
+ const params_js_1 = require("../utils/params.js");
12
+ const wan_exposure_js_1 = require("../utils/wan-exposure.js");
13
+ // Firewall policy request-body fields, from the 11.0.83 POST/PUT schemas.
14
+ const FIREWALL_POLICY_FIELDS = [
15
+ "enabled",
16
+ "name",
17
+ "description",
18
+ "action",
19
+ "source",
20
+ "destination",
21
+ "ipProtocolScope",
22
+ "connectionStateFilter",
23
+ "ipsecFilter",
24
+ "loggingEnabled",
25
+ "reportFlowEnabled",
26
+ "schedule",
27
+ ];
28
+ const FIREWALL_POLICY_REQUIRED = [
29
+ "enabled",
30
+ "name",
31
+ "action",
32
+ "source",
33
+ "destination",
34
+ "ipProtocolScope",
35
+ "loggingEnabled",
36
+ ];
37
+ // PATCH fields that change what is logged or reported, never what passes.
38
+ const REPORTING_FIELDS = ["loggingEnabled", "reportFlowEnabled"];
39
+ // Present in responses but not part of the request body.
40
+ const FIREWALL_POLICY_READ_ONLY = ["id", "index", "metadata"];
41
+ // The fields that decide which traffic a policy applies to, and how.
42
+ const FIREWALL_POLICY_MATCH_FIELDS = [
43
+ "action", "source", "destination", "ipProtocolScope", "connectionStateFilter", "ipsecFilter", "schedule",
44
+ ];
45
+ /**
46
+ * Whether a policy lets traffic through: any action but BLOCK or REJECT,
47
+ * including a missing one. Compared without case, since policy is a free-form
48
+ * object the controller may accept "allow" in.
49
+ */
50
+ function letsThrough(policy) {
51
+ const action = policy.action;
52
+ const type = action !== null && typeof action === "object" && "type" in action ? action.type : undefined;
53
+ return typeof type !== "string" || !["BLOCK", "REJECT"].includes(type.toUpperCase());
54
+ }
55
+ /**
56
+ * What a firewall policy write would open, if anything. before is undefined
57
+ * on create. Changing what an enabled policy matches counts whatever its
58
+ * action: widening an ALLOW and narrowing a BLOCK both let more through, and
59
+ * which of the two a change does cannot be told from here.
60
+ */
61
+ function firewallOpening(before, after) {
62
+ if (before === undefined) {
63
+ return letsThrough(after) ? "This creates a firewall policy that ALLOWs traffic" : undefined;
64
+ }
65
+ if (letsThrough(after) && !letsThrough(before))
66
+ return "This changes the firewall policy's action to ALLOW";
67
+ if (after.enabled === false) {
68
+ return before.enabled !== false && !letsThrough(before) ? "This disables a firewall policy that blocks traffic" : undefined;
69
+ }
70
+ if (before.enabled === false) {
71
+ return letsThrough(after) ? "This enables a firewall policy that ALLOWs traffic" : undefined;
72
+ }
73
+ const changed = FIREWALL_POLICY_MATCH_FIELDS.filter((field) => !(0, node_util_1.isDeepStrictEqual)(before[field], after[field]));
74
+ return changed.length > 0
75
+ ? `This changes what an enabled firewall policy matches (${changed.join(", ")}), which can let more traffic through`
76
+ : undefined;
77
+ }
78
+ // Stands for the system-defined policies in an evaluation order; not an ID
79
+ // the API can return, since IDs never contain spaces.
80
+ const SYSTEM_DEFINED = "system defined";
81
+ /** A zone pair's policies in evaluation order, the system-defined ones as a single entry. */
82
+ function evaluationOrder(ordering) {
83
+ const ids = (list) => Array.isArray(list) ? list.filter((id) => typeof id === "string") : [];
84
+ const lists = ordering !== null && typeof ordering === "object" ? ordering : {};
85
+ return [...ids(lists.beforeSystemDefined), SYSTEM_DEFINED, ...ids(lists.afterSystemDefined)];
86
+ }
87
+ const confirmOpening = zod_1.z
88
+ .boolean()
89
+ .optional()
90
+ .describe("Set true to confirm a change that can let traffic through: an ALLOW action, enabling an ALLOW policy, disabling a policy that blocks, or changing what an enabled policy matches; not needed with dryRun: true");
91
+ const FIREWALL_POLICY_BODY_DOC = "required enabled, name (non-empty), action ({type: ALLOW|BLOCK, allowReturnTraffic? on ALLOW}), source and destination ({zoneId, trafficFilter?}), ipProtocolScope ({ipVersion, protocolFilter?}), loggingEnabled; optional description, connectionStateFilter (array of NEW/INVALID/ESTABLISHED/RELATED), ipsecFilter (MATCH_ENCRYPTED/MATCH_NOT_ENCRYPTED), schedule (null = always active), reportFlowEnabled (report matches in Insights Flows; defaults to true when omitted).";
92
+ function registerFirewallTools(server, client, readOnly = false, wan = wan_exposure_js_1.DEFAULT_WAN_POLICY) {
10
93
  server.registerTool("unifi_list_firewall_zones", {
11
94
  description: "List firewall zones (groupings of networks for zone-based firewalling) at a site. Returns: id, name, networkIds[], metadata.origin (indicates system-defined vs user-defined). Use for: zone inventory; pair with unifi_list_firewall_policies to see rules between zones.",
12
95
  inputSchema: {
13
- siteId: zod_1.z.string().describe("Site ID"),
96
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
14
97
  offset: zod_1.z
15
98
  .number()
16
99
  .int()
17
100
  .nonnegative()
18
101
  .optional()
19
- .describe("Number of records to skip (default: 0)"),
102
+ .describe("Number of records to skip"),
20
103
  limit: zod_1.z
21
104
  .number()
22
105
  .int()
23
106
  .min(0)
24
107
  .max(200)
25
108
  .optional()
26
- .describe("Number of records to return (default: 25, max: 200)"),
109
+ .describe("Number of records to return (API default: 25)"),
27
110
  filter: zod_1.z
28
111
  .string()
29
112
  .optional()
30
- .describe("Filter expression"),
113
+ .describe("Filter expression, e.g. name.eq('Internal'). NOTE: totalCount ignores the filter on this endpoint (observed 11.0.83) — page by count"),
31
114
  },
32
115
  outputSchema: output_schemas_js_1.listFirewallZonesOutputSchema,
33
116
  annotations: safety_js_1.READ_ONLY,
@@ -44,8 +127,8 @@ function registerFirewallTools(server, client, readOnly = false) {
44
127
  server.registerTool("unifi_get_firewall_zone", {
45
128
  description: "Get a firewall zone by ID (same fields as the list entry).",
46
129
  inputSchema: {
47
- siteId: zod_1.z.string().describe("Site ID"),
48
- firewallZoneId: zod_1.z.string().describe("Firewall zone ID"),
130
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
131
+ firewallZoneId: (0, params_js_1.resourceId)().describe("Firewall zone ID"),
49
132
  },
50
133
  outputSchema: output_schemas_js_1.firewallZoneOutputSchema,
51
134
  annotations: safety_js_1.READ_ONLY,
@@ -59,26 +142,26 @@ function registerFirewallTools(server, client, readOnly = false) {
59
142
  }
60
143
  });
61
144
  server.registerTool("unifi_list_firewall_policies", {
62
- description: "List firewall policies (zone-based rules) at a site. Returns: id, name, enabled, action (object with type field), source/destination (zone reference + trafficFilter), ipProtocolScope, connectionStateFilter, ipsecFilter, schedule, loggingEnabled, index, description, metadata.origin. Protocols/ports are encoded inside source/destination.trafficFilter, not as top-level fields. Evaluation order within a zone pair comes from unifi_get_firewall_policy_ordering.",
145
+ description: "List firewall policies (zone-based rules) at a site. Returns: id, name, enabled, action (object with type field), source/destination (zone reference + trafficFilter), ipProtocolScope, connectionStateFilter, ipsecFilter, schedule, loggingEnabled, reportFlowEnabled (Insights Flows reporting; false on the predefined Allow Return Traffic and Block policies), index, description, metadata (origin SYSTEM_DEFINED/USER_DEFINED/DERIVED, configurable on system policies). action is {type: ALLOW|BLOCK, allowReturnTraffic on ALLOW}. The protocol lives in ipProtocolScope.protocolFilter (NAMED_PROTOCOL or PRESET); ports and addresses live in source/destination.trafficFilter, which is omitted when a side matches the whole zone. Unset optional fields (ipsecFilter, schedule, connectionStateFilter) are omitted, not null. Evaluation order within a zone pair comes from unifi_get_firewall_policy_ordering.",
63
146
  inputSchema: {
64
- siteId: zod_1.z.string().describe("Site ID"),
147
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
65
148
  offset: zod_1.z
66
149
  .number()
67
150
  .int()
68
151
  .nonnegative()
69
152
  .optional()
70
- .describe("Number of records to skip (default: 0)"),
153
+ .describe("Number of records to skip"),
71
154
  limit: zod_1.z
72
155
  .number()
73
156
  .int()
74
157
  .min(0)
75
158
  .max(200)
76
159
  .optional()
77
- .describe("Number of records to return (default: 25, max: 200)"),
160
+ .describe("Number of records to return (API default: 25)"),
78
161
  filter: zod_1.z
79
162
  .string()
80
163
  .optional()
81
- .describe("Filter expression"),
164
+ .describe("Filter expression, e.g. metadata.origin.eq('USER_DEFINED') or name.eq('Allow Return Traffic'). Boolean fields (enabled, loggingEnabled, reportFlowEnabled) are not filterable"),
82
165
  },
83
166
  outputSchema: output_schemas_js_1.listFirewallPoliciesOutputSchema,
84
167
  annotations: safety_js_1.READ_ONLY,
@@ -93,10 +176,10 @@ function registerFirewallTools(server, client, readOnly = false) {
93
176
  }
94
177
  });
95
178
  server.registerTool("unifi_get_firewall_policy", {
96
- description: "Get a firewall policy by ID with full match criteria and action.",
179
+ description: "Get a firewall policy by ID with full match criteria and action. Returns the same fields as a unifi_list_firewall_policies entry, including loggingEnabled and reportFlowEnabled.",
97
180
  inputSchema: {
98
- siteId: zod_1.z.string().describe("Site ID"),
99
- firewallPolicyId: zod_1.z.string().describe("Firewall policy ID"),
181
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
182
+ firewallPolicyId: (0, params_js_1.resourceId)().describe("Firewall policy ID"),
100
183
  },
101
184
  outputSchema: output_schemas_js_1.firewallPolicyOutputSchema,
102
185
  annotations: safety_js_1.READ_ONLY,
@@ -110,14 +193,12 @@ function registerFirewallTools(server, client, readOnly = false) {
110
193
  }
111
194
  });
112
195
  server.registerTool("unifi_get_firewall_policy_ordering", {
113
- description: "Get the evaluation order of user-defined firewall policies for a specific (source zone, destination zone) pair. Returns: beforeSystemDefined[] and afterSystemDefined[] arrays of policy IDs. System-defined rules sit between these two arrays.",
196
+ description: "Get the evaluation order of user-defined firewall policies for a specific (source zone, destination zone) pair. Returns: orderedFirewallPolicyIds.beforeSystemDefined[] and orderedFirewallPolicyIds.afterSystemDefined[] arrays of policy IDs. System-defined rules sit between these two arrays.",
114
197
  inputSchema: {
115
- siteId: zod_1.z.string().describe("Site ID"),
116
- sourceFirewallZoneId: zod_1.z
117
- .string()
198
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
199
+ sourceFirewallZoneId: (0, params_js_1.resourceId)()
118
200
  .describe("Source firewall zone ID"),
119
- destinationFirewallZoneId: zod_1.z
120
- .string()
201
+ destinationFirewallZoneId: (0, params_js_1.resourceId)()
121
202
  .describe("Destination firewall zone ID"),
122
203
  },
123
204
  outputSchema: output_schemas_js_1.firewallPolicyOrderingOutputSchema,
@@ -137,7 +218,7 @@ function registerFirewallTools(server, client, readOnly = false) {
137
218
  server.registerTool("unifi_create_firewall_zone", {
138
219
  description: "Create a new custom firewall zone",
139
220
  inputSchema: {
140
- siteId: zod_1.z.string().describe("Site ID"),
221
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
141
222
  name: zod_1.z.string().describe("Zone name"),
142
223
  networkIds: zod_1.z
143
224
  .array(zod_1.z.string())
@@ -147,11 +228,14 @@ function registerFirewallTools(server, client, readOnly = false) {
147
228
  .optional()
148
229
  .describe("Preview this action without executing it"),
149
230
  },
150
- outputSchema: output_schemas_js_1.firewallZoneOutputSchema,
231
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallZoneOutputSchema),
151
232
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
152
233
  }, async ({ siteId, name, networkIds, dryRun }) => {
153
234
  try {
154
235
  const body = { name, networkIds };
236
+ const exposure = await new wan_exposure_js_1.WanExposure(client, wan, siteId).firewallZone(undefined, networkIds);
237
+ if (exposure)
238
+ return (0, wan_exposure_js_1.wanRefusal)(exposure, "POST", `/sites/${siteId}/firewall/zones`);
155
239
  if (dryRun)
156
240
  return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/firewall/zones`, body);
157
241
  const data = await client.post(`/sites/${siteId}/firewall/zones`, body);
@@ -162,10 +246,10 @@ function registerFirewallTools(server, client, readOnly = false) {
162
246
  }
163
247
  });
164
248
  server.registerTool("unifi_update_firewall_zone", {
165
- description: "Update a firewall zone",
249
+ description: "Replace a firewall zone's name and networkIds (full PUT: both are required, and networkIds replaces the zone's whole network list, so start from unifi_get_firewall_zone).",
166
250
  inputSchema: {
167
- siteId: zod_1.z.string().describe("Site ID"),
168
- firewallZoneId: zod_1.z.string().describe("Firewall zone ID"),
251
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
252
+ firewallZoneId: (0, params_js_1.resourceId)().describe("Firewall zone ID"),
169
253
  name: zod_1.z.string().describe("Zone name"),
170
254
  networkIds: zod_1.z
171
255
  .array(zod_1.z.string())
@@ -175,11 +259,14 @@ function registerFirewallTools(server, client, readOnly = false) {
175
259
  .optional()
176
260
  .describe("Preview this action without executing it"),
177
261
  },
178
- outputSchema: output_schemas_js_1.firewallZoneOutputSchema,
262
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallZoneOutputSchema),
179
263
  annotations: safety_js_1.WRITE,
180
264
  }, async ({ siteId, firewallZoneId, name, networkIds, dryRun }) => {
181
265
  try {
182
266
  const body = { name, networkIds };
267
+ const exposure = await new wan_exposure_js_1.WanExposure(client, wan, siteId).firewallZone(firewallZoneId, networkIds);
268
+ if (exposure)
269
+ return (0, wan_exposure_js_1.wanRefusal)(exposure, "PUT", `/sites/${siteId}/firewall/zones/${firewallZoneId}`);
183
270
  if (dryRun)
184
271
  return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/firewall/zones/${firewallZoneId}`, body);
185
272
  const data = await client.put(`/sites/${siteId}/firewall/zones/${firewallZoneId}`, body);
@@ -192,12 +279,9 @@ function registerFirewallTools(server, client, readOnly = false) {
192
279
  server.registerTool("unifi_delete_firewall_zone", {
193
280
  description: "DESTRUCTIVE: Delete a custom firewall zone",
194
281
  inputSchema: {
195
- siteId: zod_1.z.string().describe("Site ID"),
196
- firewallZoneId: zod_1.z.string().describe("Firewall zone ID"),
197
- confirm: zod_1.z
198
- .boolean()
199
- .optional()
200
- .describe("Must be true to execute this destructive action"),
282
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
283
+ firewallZoneId: (0, params_js_1.resourceId)().describe("Firewall zone ID"),
284
+ confirm: (0, safety_js_1.confirmDestructive)(),
201
285
  dryRun: zod_1.z
202
286
  .boolean()
203
287
  .optional()
@@ -205,13 +289,17 @@ function registerFirewallTools(server, client, readOnly = false) {
205
289
  },
206
290
  annotations: safety_js_1.DESTRUCTIVE,
207
291
  }, async ({ siteId, firewallZoneId, confirm, dryRun }) => {
208
- const guard = (0, safety_js_1.requireConfirmation)(confirm, "This will delete the firewall zone");
209
- if (guard)
210
- return guard;
211
292
  try {
293
+ const path = `/sites/${siteId}/firewall/zones/${firewallZoneId}`;
294
+ const exposure = await new wan_exposure_js_1.WanExposure(client, wan, siteId).zoneDelete(firewallZoneId);
295
+ if (exposure)
296
+ return (0, wan_exposure_js_1.wanRefusal)(exposure, "DELETE", path);
297
+ const guard = dryRun ? null : (0, safety_js_1.requireConfirmation)(confirm, "This will delete the firewall zone");
298
+ if (guard)
299
+ return guard;
212
300
  if (dryRun)
213
- return (0, safety_js_1.formatDryRun)("DELETE", `/sites/${siteId}/firewall/zones/${firewallZoneId}`, {});
214
- const data = await client.delete(`/sites/${siteId}/firewall/zones/${firewallZoneId}`);
301
+ return (0, safety_js_1.formatDryRun)("DELETE", path);
302
+ const data = await client.delete(path);
215
303
  return (0, responses_js_1.formatSuccess)(data);
216
304
  }
217
305
  catch (err) {
@@ -219,23 +307,31 @@ function registerFirewallTools(server, client, readOnly = false) {
219
307
  }
220
308
  });
221
309
  server.registerTool("unifi_create_firewall_policy", {
222
- description: "Create a new firewall policy",
310
+ description: `Create a new firewall policy. Body fields (per 11.0.83 docs): ${FIREWALL_POLICY_BODY_DOC} Copy nested shapes (trafficFilter, protocolFilter) from an existing policy via unifi_get_firewall_policy. Idempotency: not safe to retry — re-running creates a duplicate policy.`,
223
311
  inputSchema: {
224
- siteId: zod_1.z.string().describe("Site ID"),
312
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
225
313
  policy: zod_1.z
226
314
  .record(zod_1.z.string(), zod_1.z.unknown())
227
315
  .describe("Firewall policy configuration (JSON object)"),
316
+ confirm: confirmOpening,
228
317
  dryRun: zod_1.z
229
318
  .boolean()
230
319
  .optional()
231
320
  .describe("Preview this action without executing it"),
232
321
  },
233
- outputSchema: output_schemas_js_1.firewallPolicyOutputSchema,
322
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallPolicyOutputSchema),
234
323
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
235
- }, async ({ siteId, policy, dryRun }) => {
324
+ }, async ({ siteId, policy, confirm, dryRun }) => {
236
325
  try {
326
+ const exposure = await new wan_exposure_js_1.WanExposure(client, wan, siteId).firewallPolicy(policy);
327
+ if (exposure)
328
+ return (0, wan_exposure_js_1.wanRefusal)(exposure, "POST", `/sites/${siteId}/firewall/policies`);
329
+ const opening = firewallOpening(undefined, policy);
330
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
331
+ if (guard)
332
+ return guard;
237
333
  if (dryRun)
238
- return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/firewall/policies`, policy);
334
+ return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/firewall/policies`, policy, opening);
239
335
  const data = await client.post(`/sites/${siteId}/firewall/policies`, policy);
240
336
  return (0, responses_js_1.formatSuccess)(data, { structured: true });
241
337
  }
@@ -244,52 +340,103 @@ function registerFirewallTools(server, client, readOnly = false) {
244
340
  }
245
341
  });
246
342
  server.registerTool("unifi_update_firewall_policy", {
247
- description: "Update a firewall policy",
343
+ description: `Update a firewall policy. Pass only the fields to change in policy. The API's PUT replaces the whole policy — and an omitted reportFlowEnabled reverts to true — so this tool reads the current policy, keeps every documented body field, overlays your fields and sends the full body. Top-level fields are replaced whole (e.g. send the complete source object). Read-only response fields (id, index, metadata) are ignored. Body fields: ${FIREWALL_POLICY_BODY_DOC} For just logging/flow-reporting toggles, prefer unifi_patch_firewall_policy. dryRun shows the merged body.`,
248
344
  inputSchema: {
249
- siteId: zod_1.z.string().describe("Site ID"),
250
- firewallPolicyId: zod_1.z.string().describe("Firewall policy ID"),
345
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
346
+ firewallPolicyId: (0, params_js_1.resourceId)().describe("Firewall policy ID"),
251
347
  policy: zod_1.z
252
348
  .record(zod_1.z.string(), zod_1.z.unknown())
253
- .describe("Firewall policy configuration (JSON object)"),
349
+ .describe("Firewall policy fields to change (JSON object); null clears an optional field such as schedule"),
350
+ confirm: confirmOpening,
254
351
  dryRun: zod_1.z
255
352
  .boolean()
256
353
  .optional()
257
- .describe("Preview this action without executing it"),
354
+ .describe("Preview the merged PUT body without executing it (still reads the current policy)"),
258
355
  },
259
- outputSchema: output_schemas_js_1.firewallPolicyOutputSchema,
356
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallPolicyOutputSchema),
260
357
  annotations: safety_js_1.WRITE,
261
- }, async ({ siteId, firewallPolicyId, policy, dryRun }) => {
358
+ }, async ({ siteId, firewallPolicyId, policy, confirm, dryRun }) => {
262
359
  try {
263
- if (dryRun)
264
- return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/firewall/policies/${firewallPolicyId}`, policy);
265
- const data = await client.put(`/sites/${siteId}/firewall/policies/${firewallPolicyId}`, policy);
266
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
360
+ const path = `/sites/${siteId}/firewall/policies/${firewallPolicyId}`;
361
+ const updates = Object.fromEntries(Object.entries(policy).filter(([field]) => !FIREWALL_POLICY_READ_ONLY.includes(field)));
362
+ const rejected = (0, full_put_js_1.unsupportedFields)(updates, FIREWALL_POLICY_FIELDS);
363
+ if (rejected.length > 0) {
364
+ throw new Error(`Fields not accepted by the firewall policy PUT body: ${rejected.join(", ")}`);
365
+ }
366
+ return await (0, full_put_js_1.withResourceLock)(path, async () => {
367
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current firewall policy");
368
+ const body = (0, full_put_js_1.mergeForPut)(current, updates, FIREWALL_POLICY_FIELDS);
369
+ const missing = (0, full_put_js_1.missingFields)(body, FIREWALL_POLICY_REQUIRED);
370
+ if (missing.length > 0) {
371
+ throw new Error(`Missing required fields: ${missing.join(", ")}`);
372
+ }
373
+ const exposure = await new wan_exposure_js_1.WanExposure(client, wan, siteId).firewallPolicy(body);
374
+ if (exposure)
375
+ return (0, wan_exposure_js_1.wanRefusal)(exposure, "PUT", path);
376
+ const opening = firewallOpening(current, body);
377
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
378
+ if (guard)
379
+ return guard;
380
+ if (dryRun)
381
+ return (0, safety_js_1.formatDryRun)("PUT", path, body, opening);
382
+ const data = await client.put(path, body);
383
+ return (0, responses_js_1.formatSuccess)(data, { structured: true });
384
+ });
267
385
  }
268
386
  catch (err) {
269
387
  return (0, responses_js_1.formatError)(err);
270
388
  }
271
389
  });
272
390
  server.registerTool("unifi_patch_firewall_policy", {
273
- description: "Partially update a firewall policy without resending all fields. loggingEnabled is the only field documented as patchable in API 10.6.106; other fields may be rejected — use unifi_update_firewall_policy for a full update. Idempotent for fields supplied.",
391
+ description: "Partially update a firewall policy without resending all fields. loggingEnabled and reportFlowEnabled are the only fields documented as patchable in API 11.0.83 (reportFlowEnabled is ignored for the predefined Allow Return Traffic policies, which are never reported); other fields may be rejected — use unifi_update_firewall_policy for a full update. Idempotent for fields supplied.",
274
392
  inputSchema: {
275
- siteId: zod_1.z.string().describe("Site ID"),
276
- firewallPolicyId: zod_1.z.string().describe("Firewall policy ID"),
393
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
394
+ firewallPolicyId: (0, params_js_1.resourceId)().describe("Firewall policy ID"),
277
395
  policy: zod_1.z
278
396
  .record(zod_1.z.string(), zod_1.z.unknown())
279
- .describe("Partial firewall policy fields to update (e.g. { loggingEnabled: true })"),
397
+ .describe("Partial firewall policy fields to update (e.g. { loggingEnabled: true } or { reportFlowEnabled: false })"),
398
+ confirm: zod_1.z
399
+ .boolean()
400
+ .optional()
401
+ .describe("Set true to confirm a patch that can let traffic through, judged on the current policy with the patch applied: an ALLOW action, enabling an ALLOW policy, disabling a policy that blocks, or changing what an enabled policy matches. Logging and flow-reporting toggles never need it. A patch that could let traffic in from the WAN is refused, and confirm does not override that. Not needed with dryRun: true"),
280
402
  dryRun: zod_1.z
281
403
  .boolean()
282
404
  .optional()
283
405
  .describe("Preview this action without executing it"),
284
406
  },
285
- outputSchema: output_schemas_js_1.firewallPolicyOutputSchema,
407
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallPolicyOutputSchema),
286
408
  annotations: safety_js_1.WRITE,
287
- }, async ({ siteId, firewallPolicyId, policy, dryRun }) => {
409
+ }, async ({ siteId, firewallPolicyId, policy, confirm, dryRun }) => {
288
410
  try {
289
- if (dryRun)
290
- return (0, safety_js_1.formatDryRun)("PATCH", `/sites/${siteId}/firewall/policies/${firewallPolicyId}`, policy);
291
- const data = await client.patch(`/sites/${siteId}/firewall/policies/${firewallPolicyId}`, policy);
292
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
411
+ const path = `/sites/${siteId}/firewall/policies/${firewallPolicyId}`;
412
+ // Queue behind any in-flight update of this policy, whose PUT would
413
+ // otherwise overwrite this change with the values it read earlier.
414
+ return await (0, full_put_js_1.withResourceLock)(path, async () => {
415
+ // Toggling logging or flow reporting changes what is recorded, never
416
+ // what passes. Anything else is judged on the policy as the PATCH
417
+ // would leave it, which takes reading it first.
418
+ const reportingOnly = Object.keys(policy).every((field) => REPORTING_FIELDS.includes(field));
419
+ let opening;
420
+ if (!reportingOnly) {
421
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current firewall policy");
422
+ const after = { ...current, ...policy };
423
+ // Disabling cannot let traffic in from the WAN.
424
+ const disablesOnly = Object.entries(policy).every(([field, value]) => REPORTING_FIELDS.includes(field) || (field === "enabled" && value === false));
425
+ if (!wan.allowExposure && !disablesOnly) {
426
+ const exposure = await new wan_exposure_js_1.WanExposure(client, wan, siteId).firewallPolicy(after);
427
+ if (exposure)
428
+ return (0, wan_exposure_js_1.wanRefusal)(exposure, "PATCH", path);
429
+ }
430
+ opening = firewallOpening(current, after);
431
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
432
+ if (guard)
433
+ return guard;
434
+ }
435
+ if (dryRun)
436
+ return (0, safety_js_1.formatDryRun)("PATCH", path, policy, opening);
437
+ const data = await client.patch(path, policy);
438
+ return (0, responses_js_1.formatSuccess)(data, { structured: true });
439
+ });
293
440
  }
294
441
  catch (err) {
295
442
  return (0, responses_js_1.formatError)(err);
@@ -298,12 +445,9 @@ function registerFirewallTools(server, client, readOnly = false) {
298
445
  server.registerTool("unifi_delete_firewall_policy", {
299
446
  description: "DESTRUCTIVE: Delete a firewall policy",
300
447
  inputSchema: {
301
- siteId: zod_1.z.string().describe("Site ID"),
302
- firewallPolicyId: zod_1.z.string().describe("Firewall policy ID"),
303
- confirm: zod_1.z
304
- .boolean()
305
- .optional()
306
- .describe("Must be true to execute this destructive action"),
448
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
449
+ firewallPolicyId: (0, params_js_1.resourceId)().describe("Firewall policy ID"),
450
+ confirm: (0, safety_js_1.confirmDestructive)(),
307
451
  dryRun: zod_1.z
308
452
  .boolean()
309
453
  .optional()
@@ -311,12 +455,12 @@ function registerFirewallTools(server, client, readOnly = false) {
311
455
  },
312
456
  annotations: safety_js_1.DESTRUCTIVE,
313
457
  }, async ({ siteId, firewallPolicyId, confirm, dryRun }) => {
314
- const guard = (0, safety_js_1.requireConfirmation)(confirm, "This will delete the firewall policy");
458
+ const guard = dryRun ? null : (0, safety_js_1.requireConfirmation)(confirm, "This will delete the firewall policy");
315
459
  if (guard)
316
460
  return guard;
317
461
  try {
318
462
  if (dryRun)
319
- return (0, safety_js_1.formatDryRun)("DELETE", `/sites/${siteId}/firewall/policies/${firewallPolicyId}`, {});
463
+ return (0, safety_js_1.formatDryRun)("DELETE", `/sites/${siteId}/firewall/policies/${firewallPolicyId}`);
320
464
  const data = await client.delete(`/sites/${siteId}/firewall/policies/${firewallPolicyId}`);
321
465
  return (0, responses_js_1.formatSuccess)(data);
322
466
  }
@@ -325,29 +469,50 @@ function registerFirewallTools(server, client, readOnly = false) {
325
469
  }
326
470
  });
327
471
  server.registerTool("unifi_reorder_firewall_policies", {
328
- description: "Reorder user-defined firewall policies for a zone pair",
472
+ description: "Reorder user-defined firewall policies for a zone pair. Policies are evaluated in order, user-defined ones in beforeSystemDefined ahead of the system-defined policies and those in afterSystemDefined after them. Moving a policy that lets traffic through ahead of one that blocks it, or across the system-defined policies, needs confirm. Reads the current order and the policies it moves past one another, on a dry run too.",
329
473
  inputSchema: {
330
- siteId: zod_1.z.string().describe("Site ID"),
331
- sourceFirewallZoneId: zod_1.z.string().describe("Source firewall zone ID"),
332
- destinationFirewallZoneId: zod_1.z
333
- .string()
474
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
475
+ sourceFirewallZoneId: (0, params_js_1.resourceId)().describe("Source firewall zone ID"),
476
+ destinationFirewallZoneId: (0, params_js_1.resourceId)()
334
477
  .describe("Destination firewall zone ID"),
335
478
  orderedFirewallPolicyIds: zod_1.z
336
479
  .record(zod_1.z.string(), zod_1.z.unknown())
337
480
  .describe("Ordered policy IDs object with beforeSystemDefined and afterSystemDefined arrays"),
481
+ confirm: zod_1.z
482
+ .boolean()
483
+ .optional()
484
+ .describe("Set true to confirm a reorder that moves a policy that lets traffic through ahead of one that blocks it, or across the system-defined policies; not needed with dryRun: true"),
338
485
  dryRun: zod_1.z
339
486
  .boolean()
340
487
  .optional()
341
488
  .describe("Preview this action without executing it"),
342
489
  },
343
490
  annotations: safety_js_1.WRITE,
344
- }, async ({ siteId, sourceFirewallZoneId, destinationFirewallZoneId, orderedFirewallPolicyIds, dryRun, }) => {
491
+ }, async ({ siteId, sourceFirewallZoneId, destinationFirewallZoneId, orderedFirewallPolicyIds, confirm, dryRun, }) => {
345
492
  try {
493
+ const exposure = await new wan_exposure_js_1.WanExposure(client, wan, siteId).policyOrder(sourceFirewallZoneId);
494
+ if (exposure)
495
+ return (0, wan_exposure_js_1.wanRefusal)(exposure, "PUT", `/sites/${siteId}/firewall/policies/ordering`);
346
496
  const query = `?sourceFirewallZoneId=${encodeURIComponent(sourceFirewallZoneId)}&destinationFirewallZoneId=${encodeURIComponent(destinationFirewallZoneId)}`;
497
+ const path = `/sites/${siteId}/firewall/policies/ordering${query}`;
347
498
  const body = { orderedFirewallPolicyIds };
499
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current firewall policy ordering");
500
+ const opens = await (0, safety_js_1.reorderOpens)(evaluationOrder(current.orderedFirewallPolicyIds), evaluationOrder(orderedFirewallPolicyIds), async (id) => {
501
+ // The system-defined policies are not listed, so their actions are
502
+ // unknown: moving anything across them counts both ways.
503
+ if (id === SYSTEM_DEFINED)
504
+ return { letsThrough: true, blocks: true };
505
+ const policy = (0, full_put_js_1.asObject)(await client.get(`/sites/${siteId}/firewall/policies/${id}`), "a firewall policy");
506
+ const active = policy.enabled !== false;
507
+ return { letsThrough: active && letsThrough(policy), blocks: active && !letsThrough(policy) };
508
+ });
509
+ const opening = opens ? "This moves a firewall policy that lets traffic through ahead of one that blocks it" : undefined;
510
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
511
+ if (guard)
512
+ return guard;
348
513
  if (dryRun)
349
- return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/firewall/policies/ordering${query}`, body);
350
- const data = await client.put(`/sites/${siteId}/firewall/policies/ordering${query}`, body);
514
+ return (0, safety_js_1.formatDryRun)("PUT", path, body, opening);
515
+ const data = await client.put(path, body);
351
516
  return (0, responses_js_1.formatSuccess)(data);
352
517
  }
353
518
  catch (err) {