@coffeerunhobby/mcp-ha-connect 1.3.4 → 1.5.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.
@@ -0,0 +1,69 @@
1
+ name: CI
2
+
3
+ # Advisory only. This workflow REPORTS a pass/fail status on commits and PRs.
4
+ # It does NOT block commits, pushes, or merges. The code builds, publishes, and
5
+ # deploys with zero dependency on this file — delete it and nothing breaks.
6
+ # (Blocking only happens if you separately enable branch-protection in the
7
+ # GitHub web UI, which is off by default and intentionally not set here.)
8
+
9
+ on:
10
+ push:
11
+ branches: [main, dev]
12
+ pull_request:
13
+ branches: [main, dev]
14
+ # Lets you re-run it by hand from the Actions tab.
15
+ workflow_dispatch:
16
+
17
+ # If you push twice quickly, cancel the older run instead of stacking them.
18
+ concurrency:
19
+ group: ci-${{ github.ref }}
20
+ cancel-in-progress: true
21
+
22
+ jobs:
23
+ build-test-lint:
24
+ runs-on: ubuntu-latest
25
+
26
+ strategy:
27
+ # Don't kill the Node 22 run just because Node 20 failed (or vice-versa) —
28
+ # we want to see both results.
29
+ fail-fast: false
30
+ matrix:
31
+ # The two LTS lines we support (package.json engines: ">=20").
32
+ node-version: ['20.x', '22.x']
33
+
34
+ name: node ${{ matrix.node-version }}
35
+
36
+ steps:
37
+ - name: Checkout
38
+ uses: actions/checkout@v4
39
+
40
+ - name: Set up Node ${{ matrix.node-version }}
41
+ uses: actions/setup-node@v4
42
+ with:
43
+ node-version: ${{ matrix.node-version }}
44
+ cache: npm
45
+
46
+ # npm ci installs exactly what's in package-lock.json (reproducible),
47
+ # the same command Docker uses. It never edits the lockfile.
48
+ - name: Install dependencies
49
+ run: npm ci
50
+
51
+ # 1. Compile cleanly (tsc -> dist/). Catches type errors.
52
+ - name: Build
53
+ run: npm run build
54
+
55
+ # 2. Lint src + tests. CLAUDE.md policy: zero errors AND zero warnings.
56
+ # --max-warnings=0 makes a warning fail the job, matching that policy.
57
+ - name: Lint
58
+ run: npm run lint -- --max-warnings=0
59
+
60
+ # 3. Version-sync gate. Fails if package.json / src/version.ts / openapi.json
61
+ # disagree on the version — the exact drift CLAUDE.md warns about.
62
+ - name: Version sync
63
+ run: npm run check:sync
64
+
65
+ # 4. Unit tests WITH coverage gate. test:coverage excludes tests/integration/**
66
+ # (those need a live .env CI must never have) and enforces the regression
67
+ # floor in vitest.config.ts — a coverage DROP fails the build.
68
+ - name: Test + coverage gate
69
+ run: npm run test:coverage
package/Dockerfile CHANGED
@@ -3,7 +3,10 @@
3
3
  FROM node:25-alpine AS deps
4
4
  WORKDIR /app
5
5
  COPY package*.json ./
6
- RUN npm install
6
+ # npm ci installs exactly from the committed package-lock.json (reproducible) and
7
+ # fails if package.json and the lockfile have drifted. The `package*.json` COPY
8
+ # above pulls in package-lock.json, so the lockfile is present in this stage.
9
+ RUN npm ci
7
10
 
8
11
  FROM node:25-alpine AS build
9
12
  WORKDIR /app
@@ -26,7 +29,8 @@ WORKDIR /app
26
29
  ENV NODE_ENV=production
27
30
  RUN apk add --no-cache curl
28
31
  COPY package*.json ./
29
- RUN npm install --omit=dev
32
+ # Reproducible prod-only install from the lockfile (drops devDependencies).
33
+ RUN npm ci --omit=dev
30
34
  COPY --from=build /app/dist ./dist
31
35
 
32
36
  # M10: drop root. The official node image ships a non-root `node` user (uid 1000).
@@ -75,6 +75,14 @@ export declare function resolveUsageWindowSec(params: Record<string, string> | u
75
75
  start: number;
76
76
  end: number;
77
77
  };
78
+ /**
79
+ * Build the optional query filters for the audit-log endpoints from caller params.
80
+ * `startTime`/`endTime` are epoch MILLISECONDS, forwarded as the controller's
81
+ * `filters.startTime`/`filters.endTime`; `searchKey` is fuzzy text. Absent params
82
+ * are omitted so a bare read returns the most recent page unfiltered. Exported for
83
+ * unit testing.
84
+ */
85
+ export declare function auditFilters(params: Record<string, string> | undefined): Record<string, unknown>;
78
86
  /**
79
87
  * The Omada resource graph. Containers have no `fetch` (browse-only); collections
80
88
  * and leaves fetch data. Existing client methods back the well-trodden reads;
@@ -49,6 +49,26 @@ export function resolveUsageWindowSec(params, now = Date.now()) {
49
49
  const start = params?.startTime ? Number(params.startTime) : end - 24 * 60 * 60;
50
50
  return { start, end };
51
51
  }
52
+ /**
53
+ * Build the optional query filters for the audit-log endpoints from caller params.
54
+ * `startTime`/`endTime` are epoch MILLISECONDS, forwarded as the controller's
55
+ * `filters.startTime`/`filters.endTime`; `searchKey` is fuzzy text. Absent params
56
+ * are omitted so a bare read returns the most recent page unfiltered. Exported for
57
+ * unit testing.
58
+ */
59
+ export function auditFilters(params) {
60
+ const query = {};
61
+ if (params?.startTime !== undefined) {
62
+ query['filters.startTime'] = Number(params.startTime);
63
+ }
64
+ if (params?.endTime !== undefined) {
65
+ query['filters.endTime'] = Number(params.endTime);
66
+ }
67
+ if (params?.searchKey) {
68
+ query.searchKey = params.searchKey;
69
+ }
70
+ return query;
71
+ }
52
72
  /**
53
73
  * The Omada resource graph. Containers have no `fetch` (browse-only); collections
54
74
  * and leaves fetch data. Existing client methods back the well-trodden reads;
@@ -116,6 +136,85 @@ export const OMADA_RESOURCES = [
116
136
  params: [{ name: 'stackId', required: true, description: 'Switch stack identifier' }],
117
137
  fetch: (c, a) => c.getSwitchStackDetail(a.params?.stackId ?? '', a.siteId),
118
138
  },
139
+ {
140
+ path: '/devices/search',
141
+ kind: 'collection',
142
+ permission: Q,
143
+ estimatedSize: 'small',
144
+ description: 'Search infrastructure devices across all sites by name/MAC/model. Requires searchKey.',
145
+ params: [{ name: 'searchKey', required: true, description: 'Text to match against device name, MAC, or model' }],
146
+ fetch: (c, a) => c.searchDevices(a.params?.searchKey ?? ''),
147
+ },
148
+ // ---- Cable test (switch port diagnostics) -----------------------------
149
+ {
150
+ path: '/devices/cable-test',
151
+ kind: 'container',
152
+ permission: Q,
153
+ description: 'Switch-port cable diagnostics. Children require a switchMac (find via /devices).',
154
+ },
155
+ {
156
+ path: '/devices/cable-test/ports',
157
+ kind: 'collection',
158
+ permission: Q,
159
+ estimatedSize: 'small',
160
+ description: 'Ports available for cable testing on a switch. Requires switchMac.',
161
+ params: [{ name: 'switchMac', required: true, description: 'Switch MAC address' }],
162
+ fetch: (c, a) => c.readResource({
163
+ pathTemplate: '/sites/{siteId}/cable-test/switches/{switchMac}/ports',
164
+ siteId: a.siteId,
165
+ pathParams: { switchMac: a.params?.switchMac ?? '' },
166
+ }),
167
+ },
168
+ {
169
+ path: '/devices/cable-test/results',
170
+ kind: 'leaf',
171
+ permission: Q,
172
+ estimatedSize: 'small',
173
+ description: 'Full cable-test results (pair status, length, fault distance) for a switch. Requires switchMac.',
174
+ params: [{ name: 'switchMac', required: true, description: 'Switch MAC address' }],
175
+ fetch: (c, a) => c.readResource({
176
+ pathTemplate: '/sites/{siteId}/cable-test/switches/{switchMac}/full-results',
177
+ siteId: a.siteId,
178
+ pathParams: { switchMac: a.params?.switchMac ?? '' },
179
+ }),
180
+ },
181
+ {
182
+ path: '/devices/cable-test/logs',
183
+ kind: 'collection',
184
+ permission: Q,
185
+ estimatedSize: 'small',
186
+ description: 'Cable-test run history for a switch. Requires switchMac.',
187
+ params: [{ name: 'switchMac', required: true, description: 'Switch MAC address' }],
188
+ fetch: (c, a) => c.readResource({
189
+ pathTemplate: '/sites/{siteId}/cable-test/switches/{switchMac}/logs',
190
+ siteId: a.siteId,
191
+ pathParams: { switchMac: a.params?.switchMac ?? '' },
192
+ }),
193
+ },
194
+ {
195
+ path: '/devices/poe',
196
+ kind: 'collection',
197
+ permission: Q,
198
+ paginated: true,
199
+ defaultPageSize: 50,
200
+ estimatedSize: 'small',
201
+ description: 'Per-port PoE status across switches (which ports deliver power, to what, and how much). Paginated.',
202
+ fetch: (c, a) => c.readResource({
203
+ pathTemplate: '/sites/{siteId}/switches/ports/poe-info',
204
+ siteId: a.siteId,
205
+ paginated: true,
206
+ page: a.page,
207
+ pageSize: a.pageSize,
208
+ }),
209
+ },
210
+ {
211
+ path: '/devices/lldp',
212
+ kind: 'collection',
213
+ permission: Q,
214
+ estimatedSize: 'small',
215
+ description: 'Site-wide LLDP neighbor information (physical link/topology discovery between devices).',
216
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/lldp', siteId: a.siteId }),
217
+ },
119
218
  // ---- Clients (connected users) ----------------------------------------
120
219
  {
121
220
  path: '/clients',
@@ -241,6 +340,178 @@ export const OMADA_RESOURCES = [
241
340
  params: [{ name: 'type', required: false, description: "'User' (default) or 'UPnP'" }],
242
341
  fetch: (c, a) => c.getPortForwardingStatus(a.params?.type === 'UPnP' ? 'UPnP' : 'User', a.siteId, a.page ?? 1, a.pageSize ?? 10),
243
342
  },
343
+ {
344
+ path: '/network/port-forwarding/rules',
345
+ kind: 'collection',
346
+ permission: Q,
347
+ paginated: true,
348
+ defaultPageSize: 50,
349
+ estimatedSize: 'small',
350
+ description: 'Configured NAT port-forwarding rules (the full rule list, not just status). Paginated — use page/pageSize.',
351
+ fetch: (c, a) => c.readResource({
352
+ pathTemplate: '/sites/{siteId}/nat/port-forwardings',
353
+ siteId: a.siteId,
354
+ paginated: true,
355
+ page: a.page,
356
+ pageSize: a.pageSize,
357
+ }),
358
+ },
359
+ {
360
+ path: '/network/dhcp-leases',
361
+ kind: 'collection',
362
+ permission: Q,
363
+ paginated: true,
364
+ defaultPageSize: 50,
365
+ estimatedSize: 'medium',
366
+ description: 'Active DHCP leases across all DHCP servers on the site (paginated). Use page/pageSize. See /network/dhcp-reservations for static reservations.',
367
+ fetch: (c, a) => c.readResource({
368
+ pathTemplate: '/sites/{siteId}/setting/service/dhcp/user-list',
369
+ siteId: a.siteId,
370
+ paginated: true,
371
+ page: a.page,
372
+ pageSize: a.pageSize,
373
+ }),
374
+ },
375
+ {
376
+ path: '/network/load-balance',
377
+ kind: 'leaf',
378
+ permission: Q,
379
+ estimatedSize: 'small',
380
+ description: 'Multi-WAN load-balance status for the gateway (which WANs are up / in the balancing pool).',
381
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/internet/load-balance/status', siteId: a.siteId }),
382
+ },
383
+ {
384
+ path: '/network/dhcp-reservations',
385
+ kind: 'collection',
386
+ permission: Q,
387
+ paginated: true,
388
+ defaultPageSize: 50,
389
+ estimatedSize: 'small',
390
+ description: 'Static DHCP reservations (each maps a client MAC to a fixed IP). Paginated. See /network/dhcp-leases for active leases.',
391
+ fetch: (c, a) => c.readResource({
392
+ pathTemplate: '/sites/{siteId}/setting/service/dhcp',
393
+ siteId: a.siteId,
394
+ paginated: true,
395
+ page: a.page,
396
+ pageSize: a.pageSize,
397
+ }),
398
+ },
399
+ {
400
+ path: '/network/static-routes',
401
+ kind: 'collection',
402
+ permission: Q,
403
+ paginated: true,
404
+ defaultPageSize: 50,
405
+ estimatedSize: 'small',
406
+ description: 'Configured static routes for the gateway (destination → next-hop). Paginated.',
407
+ fetch: (c, a) => c.readResource({
408
+ pathTemplate: '/sites/{siteId}/routing/static-routings',
409
+ siteId: a.siteId,
410
+ paginated: true,
411
+ page: a.page,
412
+ pageSize: a.pageSize,
413
+ }),
414
+ },
415
+ {
416
+ path: '/network/ip-mac-binding',
417
+ kind: 'collection',
418
+ permission: Q,
419
+ paginated: true,
420
+ defaultPageSize: 50,
421
+ estimatedSize: 'small',
422
+ description: 'IP↔MAC binding entries (ARP-spoofing protection: which MACs are pinned to which IPs). Paginated.',
423
+ fetch: (c, a) => c.readResource({
424
+ pathTemplate: '/sites/{siteId}/ip-mac-binds',
425
+ siteId: a.siteId,
426
+ paginated: true,
427
+ page: a.page,
428
+ pageSize: a.pageSize,
429
+ }),
430
+ },
431
+ {
432
+ path: '/network/attack-defense',
433
+ kind: 'leaf',
434
+ permission: Q,
435
+ estimatedSize: 'small',
436
+ description: 'Gateway attack-defense settings (flood/scan/spoof protection toggles and thresholds).',
437
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/attack-defense', siteId: a.siteId }),
438
+ },
439
+ // ---- Access-control lists (read-only policy visibility) ---------------
440
+ { path: '/network/acls', kind: 'container', permission: Q, description: 'Access-control lists by enforcement point (gateway, switch, EAP/AP).' },
441
+ {
442
+ path: '/network/acls/gateway',
443
+ kind: 'collection',
444
+ permission: Q,
445
+ paginated: true,
446
+ defaultPageSize: 50,
447
+ estimatedSize: 'small',
448
+ description: 'Gateway ACL rules (osg-acls). Paginated.',
449
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/acls/osg-acls', siteId: a.siteId, paginated: true, page: a.page, pageSize: a.pageSize }),
450
+ },
451
+ {
452
+ path: '/network/acls/switch',
453
+ kind: 'collection',
454
+ permission: Q,
455
+ paginated: true,
456
+ defaultPageSize: 50,
457
+ estimatedSize: 'small',
458
+ description: 'Switch ACL rules (osw-acls). Paginated.',
459
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/acls/osw-acls', siteId: a.siteId, paginated: true, page: a.page, pageSize: a.pageSize }),
460
+ },
461
+ {
462
+ path: '/network/acls/eap',
463
+ kind: 'collection',
464
+ permission: Q,
465
+ paginated: true,
466
+ defaultPageSize: 50,
467
+ estimatedSize: 'small',
468
+ description: 'EAP/AP ACL rules (eap-acls). Paginated.',
469
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/acls/eap-acls', siteId: a.siteId, paginated: true, page: a.page, pageSize: a.pageSize }),
470
+ },
471
+ // ---- URL filtering (content controls) ---------------------------------
472
+ { path: '/network/url-filters', kind: 'container', permission: Q, description: 'URL-filtering rules (content/parental controls) by enforcement point.' },
473
+ {
474
+ path: '/network/url-filters/gateway',
475
+ kind: 'collection',
476
+ permission: Q,
477
+ paginated: true,
478
+ defaultPageSize: 50,
479
+ estimatedSize: 'small',
480
+ description: 'Gateway URL-filter rules. Paginated.',
481
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/url-filters/gateway', siteId: a.siteId, paginated: true, page: a.page, pageSize: a.pageSize }),
482
+ },
483
+ {
484
+ path: '/network/url-filters/eap',
485
+ kind: 'collection',
486
+ permission: Q,
487
+ paginated: true,
488
+ defaultPageSize: 50,
489
+ estimatedSize: 'small',
490
+ description: 'EAP/AP URL-filter rules. Paginated.',
491
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/url-filters/eap', siteId: a.siteId, paginated: true, page: a.page, pageSize: a.pageSize }),
492
+ },
493
+ // ---- MAC filtering -----------------------------------------------------
494
+ { path: '/network/mac-filters', kind: 'container', permission: Q, description: 'Wireless MAC allow/deny filtering lists.' },
495
+ {
496
+ path: '/network/mac-filters/allow',
497
+ kind: 'collection',
498
+ permission: Q,
499
+ paginated: true,
500
+ defaultPageSize: 50,
501
+ estimatedSize: 'small',
502
+ description: 'MAC addresses on the allow list. Paginated.',
503
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/mac-filters/allow', siteId: a.siteId, paginated: true, page: a.page, pageSize: a.pageSize }),
504
+ },
505
+ {
506
+ path: '/network/mac-filters/deny',
507
+ kind: 'collection',
508
+ permission: Q,
509
+ paginated: true,
510
+ defaultPageSize: 50,
511
+ estimatedSize: 'small',
512
+ description: 'MAC addresses on the deny list. Paginated.',
513
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/mac-filters/deny', siteId: a.siteId, paginated: true, page: a.page, pageSize: a.pageSize }),
514
+ },
244
515
  {
245
516
  path: '/network/rate-limit-profiles',
246
517
  kind: 'collection',
@@ -312,6 +583,14 @@ export const OMADA_RESOURCES = [
312
583
  ? c.getSsidDetail(a.params.wlanId ?? '', a.params.ssidId, a.siteId)
313
584
  : c.getSsidList(a.params?.wlanId ?? '', a.siteId),
314
585
  },
586
+ {
587
+ path: '/wifi/band-steering',
588
+ kind: 'leaf',
589
+ permission: Q,
590
+ estimatedSize: 'small',
591
+ description: 'Band-steering settings for the site (steering clients between 2.4/5/6 GHz radios).',
592
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/band-steering', siteId: a.siteId }),
593
+ },
315
594
  // ---- Events / alerts (paginated) --------------------------------------
316
595
  {
317
596
  path: '/events',
@@ -400,6 +679,40 @@ export const OMADA_RESOURCES = [
400
679
  ],
401
680
  fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/dashboard/top-device-memory-usage', siteId: a.siteId, query: resolveUsageWindowSec(a.params) }),
402
681
  },
682
+ {
683
+ path: '/dashboard/client-distribution',
684
+ kind: 'leaf',
685
+ permission: Q,
686
+ estimatedSize: 'small',
687
+ description: 'Breakdown of connected clients by category (wired/wireless, band, SSID). No time window required.',
688
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/dashboard/client-distribution', siteId: a.siteId }),
689
+ },
690
+ {
691
+ path: '/dashboard/traffic-distribution',
692
+ kind: 'leaf',
693
+ permission: Q,
694
+ estimatedSize: 'small',
695
+ description: 'Traffic distribution across the site over a time window (default: last 24h). Override with params.startTime / ' +
696
+ 'params.endTime (epoch SECONDS).',
697
+ params: [
698
+ { name: 'startTime', required: false, description: 'Window start, epoch seconds (default: 24h ago)' },
699
+ { name: 'endTime', required: false, description: 'Window end, epoch seconds (default: now)' },
700
+ ],
701
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/dashboard/traffic-distribution', siteId: a.siteId, query: resolveUsageWindowSec(a.params) }),
702
+ },
703
+ {
704
+ path: '/dashboard/traffic-activities',
705
+ kind: 'leaf',
706
+ permission: Q,
707
+ estimatedSize: 'medium',
708
+ description: 'Traffic activity over time (a time-series for charting) for the site (default window: last 24h). Override with ' +
709
+ 'params.startTime / params.endTime (epoch SECONDS).',
710
+ params: [
711
+ { name: 'startTime', required: false, description: 'Window start, epoch seconds (default: 24h ago)' },
712
+ { name: 'endTime', required: false, description: 'Window end, epoch seconds (default: now)' },
713
+ ],
714
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/dashboard/traffic-activities', siteId: a.siteId, query: resolveUsageWindowSec(a.params) }),
715
+ },
403
716
  // ---- Firmware ----------------------------------------------------------
404
717
  {
405
718
  path: '/firmware',
@@ -422,6 +735,211 @@ export const OMADA_RESOURCES = [
422
735
  description: 'Controller-wide overview of critical firmware upgrades (not site-scoped).',
423
736
  fetch: (c) => c.readResource({ pathTemplate: '/upgrade/overview/critical', siteScoped: false }),
424
737
  },
738
+ // ---- VPN (status & tunnels) -------------------------------------------
739
+ {
740
+ path: '/vpn',
741
+ kind: 'container',
742
+ permission: Q,
743
+ description: 'VPN status: site-to-site, client-to-site, WireGuard, and IPsec tunnel stats.',
744
+ },
745
+ {
746
+ path: '/vpn/site-to-site',
747
+ kind: 'collection',
748
+ permission: Q,
749
+ estimatedSize: 'small',
750
+ description: 'Site-to-site VPN tunnels. Pass id=<vpnId> to fetch a single tunnel.',
751
+ params: [{ name: 'id', required: false, description: 'VPN id to fetch a single site-to-site tunnel' }],
752
+ fetch: (c, a) => a.id
753
+ ? c.readResource({
754
+ pathTemplate: '/sites/{siteId}/vpn/site-to-site-vpns/{vpnId}',
755
+ siteId: a.siteId,
756
+ pathParams: { vpnId: a.id },
757
+ })
758
+ : c.readResource({ pathTemplate: '/sites/{siteId}/vpn/site-to-site-vpns', siteId: a.siteId }),
759
+ },
760
+ {
761
+ path: '/vpn/client-to-site',
762
+ kind: 'container',
763
+ permission: Q,
764
+ description: 'Client-to-site (remote-access) VPN: configured servers and connected clients.',
765
+ },
766
+ {
767
+ path: '/vpn/client-to-site/servers',
768
+ kind: 'collection',
769
+ permission: Q,
770
+ estimatedSize: 'small',
771
+ description: 'Configured client-to-site VPN servers (L2TP/OpenVPN/etc.).',
772
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/vpn/client-to-site-vpn-servers', siteId: a.siteId }),
773
+ },
774
+ {
775
+ path: '/vpn/client-to-site/clients',
776
+ kind: 'collection',
777
+ permission: Q,
778
+ estimatedSize: 'small',
779
+ description: 'Currently connected client-to-site (remote-access) VPN clients.',
780
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/vpn/client-to-site-vpn-clients', siteId: a.siteId }),
781
+ },
782
+ {
783
+ path: '/vpn/wireguard',
784
+ kind: 'collection',
785
+ permission: Q,
786
+ estimatedSize: 'small',
787
+ description: 'Configured WireGuard VPN interfaces on the gateway.',
788
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/vpn/wireguards', siteId: a.siteId }),
789
+ },
790
+ {
791
+ path: '/vpn/ipsec-stats',
792
+ kind: 'collection',
793
+ permission: Q,
794
+ paginated: true,
795
+ defaultPageSize: 50,
796
+ estimatedSize: 'small',
797
+ description: 'IPsec VPN tunnel statistics (paginated — use page/pageSize).',
798
+ fetch: (c, a) => c.readResource({
799
+ pathTemplate: '/sites/{siteId}/setting/vpn/stats/ipsec',
800
+ siteId: a.siteId,
801
+ paginated: true,
802
+ page: a.page,
803
+ pageSize: a.pageSize,
804
+ }),
805
+ },
806
+ // ---- Profiles (reusable network profiles) -----------------------------
807
+ {
808
+ path: '/profiles',
809
+ kind: 'container',
810
+ permission: Q,
811
+ description: 'Reusable network profiles: per-device Wi-Fi keys (PPSK) and time-range schedules.',
812
+ },
813
+ {
814
+ path: '/profiles/ppsk',
815
+ kind: 'collection',
816
+ permission: Q,
817
+ estimatedSize: 'small',
818
+ description: 'PPSK (Private Pre-Shared Key) profiles — unique per-user/per-device Wi-Fi passwords on a shared SSID. ' +
819
+ 'REQUIRES params.type: 0 = PPSK without RADIUS, 1 = PPSK with RADIUS.',
820
+ params: [{ name: 'type', required: true, description: '0 = PPSK without RADIUS, 1 = PPSK with RADIUS' }],
821
+ fetch: (c, a) => c.readResource({
822
+ pathTemplate: '/sites/{siteId}/ppsk-profiles',
823
+ siteId: a.siteId,
824
+ query: { type: Number(a.params?.type) },
825
+ }),
826
+ },
827
+ {
828
+ path: '/profiles/time-range',
829
+ kind: 'collection',
830
+ permission: Q,
831
+ estimatedSize: 'small',
832
+ description: 'Time-range profiles — reusable schedules referenced by ACLs, Wi-Fi, and PoE rules.',
833
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/time-range-profiles', siteId: a.siteId }),
834
+ },
835
+ // ---- Schedules (PoE / port / firmware-upgrade) ------------------------
836
+ {
837
+ path: '/schedules',
838
+ kind: 'container',
839
+ permission: Q,
840
+ description: 'Device schedules: PoE power, switch-port on/off, and firmware-upgrade windows. ' +
841
+ '(Reboot schedules are site-template-scoped enterprise config and are intentionally not exposed here.)',
842
+ },
843
+ {
844
+ path: '/schedules/poe',
845
+ kind: 'collection',
846
+ permission: Q,
847
+ estimatedSize: 'small',
848
+ description: 'PoE schedules — when switch ports deliver power to PoE devices.',
849
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/poe-schedules', siteId: a.siteId }),
850
+ },
851
+ {
852
+ path: '/schedules/port',
853
+ kind: 'collection',
854
+ permission: Q,
855
+ estimatedSize: 'small',
856
+ description: 'Port schedules — when switch ports are administratively up/down.',
857
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/port-schedules', siteId: a.siteId }),
858
+ },
859
+ {
860
+ path: '/schedules/upgrade',
861
+ kind: 'collection',
862
+ permission: Q,
863
+ estimatedSize: 'small',
864
+ description: 'Firmware-upgrade schedules — planned maintenance windows for device upgrades.',
865
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/upgrade-schedules', siteId: a.siteId }),
866
+ },
867
+ // ---- Backup (controller/site backup status) ---------------------------
868
+ {
869
+ path: '/backup',
870
+ kind: 'container',
871
+ permission: Q,
872
+ description: 'Site backup status: available backup files and the most recent backup result.',
873
+ },
874
+ {
875
+ path: '/backup/files',
876
+ kind: 'collection',
877
+ permission: Q,
878
+ estimatedSize: 'small',
879
+ description: 'List of available site backup files.',
880
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/maintenance/backup/files', siteId: a.siteId }),
881
+ },
882
+ {
883
+ path: '/backup/result',
884
+ kind: 'leaf',
885
+ permission: Q,
886
+ estimatedSize: 'small',
887
+ description: 'Result of the most recent site backup operation.',
888
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/backup/result', siteId: a.siteId }),
889
+ },
890
+ // ---- Audit logs (administrative operation history) — ADMIN only -------
891
+ {
892
+ path: '/audit',
893
+ kind: 'container',
894
+ permission: A,
895
+ description: 'Administrative audit logs — who did what on the controller. ADMIN only (reveals full admin activity).',
896
+ },
897
+ {
898
+ path: '/audit/site',
899
+ kind: 'collection',
900
+ permission: A,
901
+ paginated: true,
902
+ defaultPageSize: 10,
903
+ estimatedSize: 'large',
904
+ description: 'Site-scoped administrative audit log (paginated, ADMIN). Optional params: startTime/endTime ' +
905
+ '(epoch MILLISECONDS) to bound the window, and searchKey (fuzzy text).',
906
+ params: [
907
+ { name: 'startTime', required: false, description: 'Window start, epoch milliseconds' },
908
+ { name: 'endTime', required: false, description: 'Window end, epoch milliseconds' },
909
+ { name: 'searchKey', required: false, description: 'Fuzzy search on the log text' },
910
+ ],
911
+ fetch: (c, a) => c.readResource({
912
+ pathTemplate: '/sites/{siteId}/audit-logs',
913
+ siteId: a.siteId,
914
+ paginated: true,
915
+ page: a.page,
916
+ pageSize: a.pageSize,
917
+ query: auditFilters(a.params),
918
+ }),
919
+ },
920
+ {
921
+ path: '/audit/global',
922
+ kind: 'collection',
923
+ permission: A,
924
+ paginated: true,
925
+ defaultPageSize: 10,
926
+ estimatedSize: 'large',
927
+ description: 'Controller-wide administrative audit log across all sites (paginated, ADMIN). Optional params: ' +
928
+ 'startTime/endTime (epoch MILLISECONDS) and searchKey (fuzzy text).',
929
+ params: [
930
+ { name: 'startTime', required: false, description: 'Window start, epoch milliseconds' },
931
+ { name: 'endTime', required: false, description: 'Window end, epoch milliseconds' },
932
+ { name: 'searchKey', required: false, description: 'Fuzzy search on the log text' },
933
+ ],
934
+ fetch: (c, a) => c.readResource({
935
+ pathTemplate: '/audit-logs',
936
+ siteScoped: false,
937
+ paginated: true,
938
+ page: a.page,
939
+ pageSize: a.pageSize,
940
+ query: auditFilters(a.params),
941
+ }),
942
+ },
425
943
  // ---- Security (IDS/IPS) — ADMIN only -----------------------------------
426
944
  {
427
945
  path: '/security',