@owine/unifi-network-mcp 2.15.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -77
  3. package/dist/client.d.ts +26 -1
  4. package/dist/client.js +199 -24
  5. package/dist/config.d.ts +11 -0
  6. package/dist/config.js +102 -3
  7. package/dist/guard.d.ts +46 -0
  8. package/dist/guard.js +180 -0
  9. package/dist/index.js +17 -4
  10. package/dist/instructions.d.ts +9 -0
  11. package/dist/instructions.js +32 -0
  12. package/dist/tls.d.ts +14 -0
  13. package/dist/tls.js +58 -0
  14. package/dist/tools/acl.js +123 -50
  15. package/dist/tools/classic.d.ts +3 -0
  16. package/dist/tools/classic.js +54 -0
  17. package/dist/tools/client-history.js +22 -12
  18. package/dist/tools/clients.js +10 -9
  19. package/dist/tools/devices.js +21 -20
  20. package/dist/tools/dns-policies.js +27 -27
  21. package/dist/tools/exposure.d.ts +3 -0
  22. package/dist/tools/exposure.js +118 -0
  23. package/dist/tools/firewall.d.ts +2 -1
  24. package/dist/tools/firewall.js +200 -75
  25. package/dist/tools/hotspot.js +35 -28
  26. package/dist/tools/index.d.ts +13 -1
  27. package/dist/tools/index.js +67 -4
  28. package/dist/tools/networks.d.ts +2 -1
  29. package/dist/tools/networks.js +44 -31
  30. package/dist/tools/sites.js +2 -2
  31. package/dist/tools/supporting.js +22 -21
  32. package/dist/tools/switching.js +16 -15
  33. package/dist/tools/system.js +1 -1
  34. package/dist/tools/traffic-matching.d.ts +2 -1
  35. package/dist/tools/traffic-matching.js +22 -20
  36. package/dist/tools/wifi.js +76 -30
  37. package/dist/utils/full-put.d.ts +11 -2
  38. package/dist/utils/full-put.js +25 -2
  39. package/dist/utils/output-schemas.d.ts +241 -4
  40. package/dist/utils/output-schemas.js +225 -49
  41. package/dist/utils/params.d.ts +12 -0
  42. package/dist/utils/params.js +21 -0
  43. package/dist/utils/pick.d.ts +6 -0
  44. package/dist/utils/pick.js +11 -0
  45. package/dist/utils/responses.d.ts +2 -0
  46. package/dist/utils/responses.js +28 -2
  47. package/dist/utils/safety.d.ts +35 -1
  48. package/dist/utils/safety.js +53 -3
  49. package/dist/utils/secrets.js +3 -0
  50. package/dist/utils/wan-exposure.d.ts +69 -0
  51. package/dist/utils/wan-exposure.js +245 -0
  52. package/dist/write-log.d.ts +52 -0
  53. package/dist/write-log.js +178 -0
  54. package/dist/write-tools.d.ts +27 -0
  55. package/dist/write-tools.js +50 -0
  56. package/package.json +19 -15
@@ -1,12 +1,15 @@
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");
8
9
  const full_put_js_1 = require("../utils/full-put.js");
9
10
  const output_schemas_js_1 = require("../utils/output-schemas.js");
11
+ const params_js_1 = require("../utils/params.js");
12
+ const wan_exposure_js_1 = require("../utils/wan-exposure.js");
10
13
  // Firewall policy request-body fields, from the 11.0.83 POST/PUT schemas.
11
14
  const FIREWALL_POLICY_FIELDS = [
12
15
  "enabled",
@@ -31,27 +34,79 @@ const FIREWALL_POLICY_REQUIRED = [
31
34
  "ipProtocolScope",
32
35
  "loggingEnabled",
33
36
  ];
37
+ // PATCH fields that change what is logged or reported, never what passes.
38
+ const REPORTING_FIELDS = ["loggingEnabled", "reportFlowEnabled"];
34
39
  // Present in responses but not part of the request body.
35
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");
36
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).";
37
- function registerFirewallTools(server, client, readOnly = false) {
92
+ function registerFirewallTools(server, client, readOnly = false, wan = wan_exposure_js_1.DEFAULT_WAN_POLICY) {
38
93
  server.registerTool("unifi_list_firewall_zones", {
39
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.",
40
95
  inputSchema: {
41
- siteId: zod_1.z.string().describe("Site ID"),
96
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
42
97
  offset: zod_1.z
43
98
  .number()
44
99
  .int()
45
100
  .nonnegative()
46
101
  .optional()
47
- .describe("Number of records to skip (default: 0)"),
102
+ .describe("Number of records to skip"),
48
103
  limit: zod_1.z
49
104
  .number()
50
105
  .int()
51
106
  .min(0)
52
107
  .max(200)
53
108
  .optional()
54
- .describe("Number of records to return (default: 25, max: 200)"),
109
+ .describe("Number of records to return (API default: 25)"),
55
110
  filter: zod_1.z
56
111
  .string()
57
112
  .optional()
@@ -72,8 +127,8 @@ function registerFirewallTools(server, client, readOnly = false) {
72
127
  server.registerTool("unifi_get_firewall_zone", {
73
128
  description: "Get a firewall zone by ID (same fields as the list entry).",
74
129
  inputSchema: {
75
- siteId: zod_1.z.string().describe("Site ID"),
76
- 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"),
77
132
  },
78
133
  outputSchema: output_schemas_js_1.firewallZoneOutputSchema,
79
134
  annotations: safety_js_1.READ_ONLY,
@@ -89,20 +144,20 @@ function registerFirewallTools(server, client, readOnly = false) {
89
144
  server.registerTool("unifi_list_firewall_policies", {
90
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.",
91
146
  inputSchema: {
92
- siteId: zod_1.z.string().describe("Site ID"),
147
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
93
148
  offset: zod_1.z
94
149
  .number()
95
150
  .int()
96
151
  .nonnegative()
97
152
  .optional()
98
- .describe("Number of records to skip (default: 0)"),
153
+ .describe("Number of records to skip"),
99
154
  limit: zod_1.z
100
155
  .number()
101
156
  .int()
102
157
  .min(0)
103
158
  .max(200)
104
159
  .optional()
105
- .describe("Number of records to return (default: 25, max: 200)"),
160
+ .describe("Number of records to return (API default: 25)"),
106
161
  filter: zod_1.z
107
162
  .string()
108
163
  .optional()
@@ -123,8 +178,8 @@ function registerFirewallTools(server, client, readOnly = false) {
123
178
  server.registerTool("unifi_get_firewall_policy", {
124
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.",
125
180
  inputSchema: {
126
- siteId: zod_1.z.string().describe("Site ID"),
127
- 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"),
128
183
  },
129
184
  outputSchema: output_schemas_js_1.firewallPolicyOutputSchema,
130
185
  annotations: safety_js_1.READ_ONLY,
@@ -140,12 +195,10 @@ function registerFirewallTools(server, client, readOnly = false) {
140
195
  server.registerTool("unifi_get_firewall_policy_ordering", {
141
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.",
142
197
  inputSchema: {
143
- siteId: zod_1.z.string().describe("Site ID"),
144
- sourceFirewallZoneId: zod_1.z
145
- .string()
198
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
199
+ sourceFirewallZoneId: (0, params_js_1.resourceId)()
146
200
  .describe("Source firewall zone ID"),
147
- destinationFirewallZoneId: zod_1.z
148
- .string()
201
+ destinationFirewallZoneId: (0, params_js_1.resourceId)()
149
202
  .describe("Destination firewall zone ID"),
150
203
  },
151
204
  outputSchema: output_schemas_js_1.firewallPolicyOrderingOutputSchema,
@@ -165,7 +218,7 @@ function registerFirewallTools(server, client, readOnly = false) {
165
218
  server.registerTool("unifi_create_firewall_zone", {
166
219
  description: "Create a new custom firewall zone",
167
220
  inputSchema: {
168
- siteId: zod_1.z.string().describe("Site ID"),
221
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
169
222
  name: zod_1.z.string().describe("Zone name"),
170
223
  networkIds: zod_1.z
171
224
  .array(zod_1.z.string())
@@ -175,11 +228,14 @@ function registerFirewallTools(server, client, readOnly = false) {
175
228
  .optional()
176
229
  .describe("Preview this action without executing it"),
177
230
  },
178
- outputSchema: output_schemas_js_1.firewallZoneOutputSchema,
231
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallZoneOutputSchema),
179
232
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
180
233
  }, async ({ siteId, name, networkIds, dryRun }) => {
181
234
  try {
182
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`);
183
239
  if (dryRun)
184
240
  return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/firewall/zones`, body);
185
241
  const data = await client.post(`/sites/${siteId}/firewall/zones`, body);
@@ -190,10 +246,10 @@ function registerFirewallTools(server, client, readOnly = false) {
190
246
  }
191
247
  });
192
248
  server.registerTool("unifi_update_firewall_zone", {
193
- 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).",
194
250
  inputSchema: {
195
- siteId: zod_1.z.string().describe("Site ID"),
196
- 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"),
197
253
  name: zod_1.z.string().describe("Zone name"),
198
254
  networkIds: zod_1.z
199
255
  .array(zod_1.z.string())
@@ -203,11 +259,14 @@ function registerFirewallTools(server, client, readOnly = false) {
203
259
  .optional()
204
260
  .describe("Preview this action without executing it"),
205
261
  },
206
- outputSchema: output_schemas_js_1.firewallZoneOutputSchema,
262
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallZoneOutputSchema),
207
263
  annotations: safety_js_1.WRITE,
208
264
  }, async ({ siteId, firewallZoneId, name, networkIds, dryRun }) => {
209
265
  try {
210
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}`);
211
270
  if (dryRun)
212
271
  return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/firewall/zones/${firewallZoneId}`, body);
213
272
  const data = await client.put(`/sites/${siteId}/firewall/zones/${firewallZoneId}`, body);
@@ -220,12 +279,9 @@ function registerFirewallTools(server, client, readOnly = false) {
220
279
  server.registerTool("unifi_delete_firewall_zone", {
221
280
  description: "DESTRUCTIVE: Delete a custom firewall zone",
222
281
  inputSchema: {
223
- siteId: zod_1.z.string().describe("Site ID"),
224
- firewallZoneId: zod_1.z.string().describe("Firewall zone ID"),
225
- confirm: zod_1.z
226
- .boolean()
227
- .optional()
228
- .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)(),
229
285
  dryRun: zod_1.z
230
286
  .boolean()
231
287
  .optional()
@@ -233,13 +289,17 @@ function registerFirewallTools(server, client, readOnly = false) {
233
289
  },
234
290
  annotations: safety_js_1.DESTRUCTIVE,
235
291
  }, async ({ siteId, firewallZoneId, confirm, dryRun }) => {
236
- const guard = (0, safety_js_1.requireConfirmation)(confirm, "This will delete the firewall zone");
237
- if (guard)
238
- return guard;
239
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;
240
300
  if (dryRun)
241
- return (0, safety_js_1.formatDryRun)("DELETE", `/sites/${siteId}/firewall/zones/${firewallZoneId}`, {});
242
- 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);
243
303
  return (0, responses_js_1.formatSuccess)(data);
244
304
  }
245
305
  catch (err) {
@@ -249,21 +309,29 @@ function registerFirewallTools(server, client, readOnly = false) {
249
309
  server.registerTool("unifi_create_firewall_policy", {
250
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.`,
251
311
  inputSchema: {
252
- siteId: zod_1.z.string().describe("Site ID"),
312
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
253
313
  policy: zod_1.z
254
314
  .record(zod_1.z.string(), zod_1.z.unknown())
255
315
  .describe("Firewall policy configuration (JSON object)"),
316
+ confirm: confirmOpening,
256
317
  dryRun: zod_1.z
257
318
  .boolean()
258
319
  .optional()
259
320
  .describe("Preview this action without executing it"),
260
321
  },
261
- outputSchema: output_schemas_js_1.firewallPolicyOutputSchema,
322
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallPolicyOutputSchema),
262
323
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
263
- }, async ({ siteId, policy, dryRun }) => {
324
+ }, async ({ siteId, policy, confirm, dryRun }) => {
264
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;
265
333
  if (dryRun)
266
- 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);
267
335
  const data = await client.post(`/sites/${siteId}/firewall/policies`, policy);
268
336
  return (0, responses_js_1.formatSuccess)(data, { structured: true });
269
337
  }
@@ -274,19 +342,20 @@ function registerFirewallTools(server, client, readOnly = false) {
274
342
  server.registerTool("unifi_update_firewall_policy", {
275
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.`,
276
344
  inputSchema: {
277
- siteId: zod_1.z.string().describe("Site ID"),
278
- 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"),
279
347
  policy: zod_1.z
280
348
  .record(zod_1.z.string(), zod_1.z.unknown())
281
349
  .describe("Firewall policy fields to change (JSON object); null clears an optional field such as schedule"),
350
+ confirm: confirmOpening,
282
351
  dryRun: zod_1.z
283
352
  .boolean()
284
353
  .optional()
285
354
  .describe("Preview the merged PUT body without executing it (still reads the current policy)"),
286
355
  },
287
- outputSchema: output_schemas_js_1.firewallPolicyOutputSchema,
356
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallPolicyOutputSchema),
288
357
  annotations: safety_js_1.WRITE,
289
- }, async ({ siteId, firewallPolicyId, policy, dryRun }) => {
358
+ }, async ({ siteId, firewallPolicyId, policy, confirm, dryRun }) => {
290
359
  try {
291
360
  const path = `/sites/${siteId}/firewall/policies/${firewallPolicyId}`;
292
361
  const updates = Object.fromEntries(Object.entries(policy).filter(([field]) => !FIREWALL_POLICY_READ_ONLY.includes(field)));
@@ -294,16 +363,25 @@ function registerFirewallTools(server, client, readOnly = false) {
294
363
  if (rejected.length > 0) {
295
364
  throw new Error(`Fields not accepted by the firewall policy PUT body: ${rejected.join(", ")}`);
296
365
  }
297
- const current = (0, full_put_js_1.asObject)(await client.get(path), "the current firewall policy");
298
- const body = (0, full_put_js_1.mergeForPut)(current, updates, FIREWALL_POLICY_FIELDS);
299
- const missing = (0, full_put_js_1.missingFields)(body, FIREWALL_POLICY_REQUIRED);
300
- if (missing.length > 0) {
301
- throw new Error(`Missing required fields: ${missing.join(", ")}`);
302
- }
303
- if (dryRun)
304
- return (0, safety_js_1.formatDryRun)("PUT", path, body);
305
- const data = await client.put(path, body);
306
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
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
+ });
307
385
  }
308
386
  catch (err) {
309
387
  return (0, responses_js_1.formatError)(err);
@@ -312,24 +390,53 @@ function registerFirewallTools(server, client, readOnly = false) {
312
390
  server.registerTool("unifi_patch_firewall_policy", {
313
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.",
314
392
  inputSchema: {
315
- siteId: zod_1.z.string().describe("Site ID"),
316
- 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"),
317
395
  policy: zod_1.z
318
396
  .record(zod_1.z.string(), zod_1.z.unknown())
319
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"),
320
402
  dryRun: zod_1.z
321
403
  .boolean()
322
404
  .optional()
323
405
  .describe("Preview this action without executing it"),
324
406
  },
325
- outputSchema: output_schemas_js_1.firewallPolicyOutputSchema,
407
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.firewallPolicyOutputSchema),
326
408
  annotations: safety_js_1.WRITE,
327
- }, async ({ siteId, firewallPolicyId, policy, dryRun }) => {
409
+ }, async ({ siteId, firewallPolicyId, policy, confirm, dryRun }) => {
328
410
  try {
329
- if (dryRun)
330
- return (0, safety_js_1.formatDryRun)("PATCH", `/sites/${siteId}/firewall/policies/${firewallPolicyId}`, policy);
331
- const data = await client.patch(`/sites/${siteId}/firewall/policies/${firewallPolicyId}`, policy);
332
- 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
+ });
333
440
  }
334
441
  catch (err) {
335
442
  return (0, responses_js_1.formatError)(err);
@@ -338,12 +445,9 @@ function registerFirewallTools(server, client, readOnly = false) {
338
445
  server.registerTool("unifi_delete_firewall_policy", {
339
446
  description: "DESTRUCTIVE: Delete a firewall policy",
340
447
  inputSchema: {
341
- siteId: zod_1.z.string().describe("Site ID"),
342
- firewallPolicyId: zod_1.z.string().describe("Firewall policy ID"),
343
- confirm: zod_1.z
344
- .boolean()
345
- .optional()
346
- .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)(),
347
451
  dryRun: zod_1.z
348
452
  .boolean()
349
453
  .optional()
@@ -351,12 +455,12 @@ function registerFirewallTools(server, client, readOnly = false) {
351
455
  },
352
456
  annotations: safety_js_1.DESTRUCTIVE,
353
457
  }, async ({ siteId, firewallPolicyId, confirm, dryRun }) => {
354
- 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");
355
459
  if (guard)
356
460
  return guard;
357
461
  try {
358
462
  if (dryRun)
359
- 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}`);
360
464
  const data = await client.delete(`/sites/${siteId}/firewall/policies/${firewallPolicyId}`);
361
465
  return (0, responses_js_1.formatSuccess)(data);
362
466
  }
@@ -365,29 +469,50 @@ function registerFirewallTools(server, client, readOnly = false) {
365
469
  }
366
470
  });
367
471
  server.registerTool("unifi_reorder_firewall_policies", {
368
- 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.",
369
473
  inputSchema: {
370
- siteId: zod_1.z.string().describe("Site ID"),
371
- sourceFirewallZoneId: zod_1.z.string().describe("Source firewall zone ID"),
372
- destinationFirewallZoneId: zod_1.z
373
- .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)()
374
477
  .describe("Destination firewall zone ID"),
375
478
  orderedFirewallPolicyIds: zod_1.z
376
479
  .record(zod_1.z.string(), zod_1.z.unknown())
377
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"),
378
485
  dryRun: zod_1.z
379
486
  .boolean()
380
487
  .optional()
381
488
  .describe("Preview this action without executing it"),
382
489
  },
383
490
  annotations: safety_js_1.WRITE,
384
- }, async ({ siteId, sourceFirewallZoneId, destinationFirewallZoneId, orderedFirewallPolicyIds, dryRun, }) => {
491
+ }, async ({ siteId, sourceFirewallZoneId, destinationFirewallZoneId, orderedFirewallPolicyIds, confirm, dryRun, }) => {
385
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`);
386
496
  const query = `?sourceFirewallZoneId=${encodeURIComponent(sourceFirewallZoneId)}&destinationFirewallZoneId=${encodeURIComponent(destinationFirewallZoneId)}`;
497
+ const path = `/sites/${siteId}/firewall/policies/ordering${query}`;
387
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;
388
513
  if (dryRun)
389
- return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/firewall/policies/ordering${query}`, body);
390
- 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);
391
516
  return (0, responses_js_1.formatSuccess)(data);
392
517
  }
393
518
  catch (err) {