@alchemy-unifi/network 0.0.1 → 0.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.
@@ -0,0 +1,456 @@
1
+ import * as Sdk from "@alchemy-unifi/network-sdk";
2
+ import { Unowned } from "alchemy/AdoptPolicy";
3
+ import type { Diff } from "alchemy/Diff";
4
+ import * as Provider from "alchemy/Provider";
5
+ import { Resource } from "alchemy/Resource";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Stream from "effect/Stream";
8
+ import { withConnection } from "./Connection.ts";
9
+ import { type FirewallZoneReference, resolveFirewallZone } from "./FirewallZone.ts";
10
+ import { isCurrent, omitKeys, sortArrays } from "./internal/Fields.ts";
11
+ import { plannedString, UNKNOWN } from "./internal/Plan.ts";
12
+ import { captureServices } from "./internal/Services.ts";
13
+ import { describeQuery, LookupAmbiguous, LookupNotFound } from "./Lookup.ts";
14
+ import { type NetworkReference, resolveNetwork } from "./Network.ts";
15
+ import type { Providers } from "./Providers.ts";
16
+ import * as ReadCache from "./ReadCache.ts";
17
+ import { resolveSite, type Site } from "./Site.ts";
18
+ import { resolveTrafficMatchingList, type TrafficMatchingListReference } from "./TrafficMatchingList.ts";
19
+
20
+ /** Match the addresses or ports in a traffic-matching list (a `TrafficMatchingList` resource or reference). */
21
+ export interface TrafficMatchingListFilter {
22
+ readonly type: "TRAFFIC_MATCHING_LIST";
23
+ /** Match everything except the list's entries. */
24
+ readonly matchOpposite: boolean;
25
+ readonly trafficMatchingList: TrafficMatchingListReference;
26
+ }
27
+
28
+ /** IP addresses, ranges or subnets to match: inline, or from a list. */
29
+ export type IpAddressFilter = Sdk.IntegrationFirewallPolicySpecificIpAddressFilterDto | TrafficMatchingListFilter;
30
+
31
+ /** Ports or port ranges to match: inline, or from a list. */
32
+ export type PortFilter = Sdk.IntegrationFirewallPolicyPortValueFilterDto | TrafficMatchingListFilter;
33
+
34
+ /** Networks to match (`Network` resources or references). */
35
+ export interface NetworkFilter {
36
+ /** Match every network except these. */
37
+ readonly matchOpposite: boolean;
38
+ readonly networks: ReadonlyArray<NetworkReference>;
39
+ }
40
+
41
+ /** An API traffic filter with its ID fields replaced by references. */
42
+ type WithReferences<F> = {
43
+ [K in keyof F]: K extends "ipAddressFilter"
44
+ ? IpAddressFilter
45
+ : K extends "portFilter"
46
+ ? PortFilter
47
+ : K extends "networkFilter"
48
+ ? NetworkFilter
49
+ : F[K];
50
+ };
51
+ type Distribute<F> = F extends unknown ? WithReferences<F> : never;
52
+
53
+ /** What a policy matches at its source, besides the zone. `type` picks the kind. */
54
+ export type FirewallPolicySourceFilter = Distribute<Sdk.FirewallPolicySourceTrafficFilter>;
55
+
56
+ /** What a policy matches at its destination, besides the zone. `type` picks the kind. */
57
+ export type FirewallPolicyDestinationFilter = Distribute<Sdk.FirewallPolicyDestinationTrafficFilter>;
58
+
59
+ export interface FirewallPolicyEndpoint<Filter> {
60
+ /** A `FirewallZone` resource or lookup, `{ firewallZoneId }`, or a built-in constant such as `FirewallZone.INTERNAL`. */
61
+ readonly zone: FirewallZoneReference;
62
+ /** Narrows the zone's traffic. Unset matches all of it. */
63
+ readonly trafficFilter?: Filter;
64
+ }
65
+
66
+ /**
67
+ * Props are the Integration API's policy body, with zones, networks and
68
+ * traffic-matching lists as references, plus `site`.
69
+ *
70
+ * Props are authoritative, like every resource's: leaving out
71
+ * `trafficFilter`, `connectionStateFilter`, `ipsecFilter`, `schedule` or
72
+ * `description` means none, even if the policy has one in the console.
73
+ */
74
+ export interface FirewallPolicyProps extends Omit<
75
+ Sdk.CreateFirewallPolicyRequest,
76
+ "siteId" | "source" | "destination"
77
+ > {
78
+ /** Site to create the policy in. @default the profile's site, else the console's only site */
79
+ site?: string;
80
+ source: FirewallPolicyEndpoint<FirewallPolicySourceFilter>;
81
+ destination: FirewallPolicyEndpoint<FirewallPolicyDestinationFilter>;
82
+ }
83
+
84
+ /** A policy: a `FirewallPolicy` resource, a {@link FirewallPolicy.lookup} result, or `{ firewallPolicyId }`. */
85
+ export interface FirewallPolicyReference {
86
+ readonly firewallPolicyId: string;
87
+ readonly siteId?: string | undefined;
88
+ }
89
+
90
+ export interface FirewallPolicyAttributes {
91
+ siteId: string;
92
+ firewallPolicyId: string;
93
+ name: string;
94
+ sourceFirewallZoneId: string;
95
+ destinationFirewallZoneId: string;
96
+ /**
97
+ * The whole policy as the console reports it, so `alchemy drift` sees any
98
+ * change; without its `index`, which moves whenever its ordering changes.
99
+ */
100
+ config: Omit<Sdk.FirewallPolicy, "id" | "metadata" | "index">;
101
+ }
102
+
103
+ export interface FirewallPolicy extends Resource<
104
+ "UnifiNetwork.FirewallPolicy",
105
+ FirewallPolicyProps,
106
+ FirewallPolicyAttributes,
107
+ never,
108
+ Providers
109
+ > {}
110
+
111
+ const loadPolicies = (siteId: string) => withConnection(Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId })));
112
+ const policiesKey = ReadCache.key<ReadonlyArray<Sdk.FirewallPolicy>, Effect.Error<ReturnType<typeof loadPolicies>>>(
113
+ "firewallPolicies",
114
+ );
115
+
116
+ /**
117
+ * A site's firewall policies, listed once per run: all of them, including
118
+ * system-defined ones and ones UniFi derives from UI Objects.
119
+ */
120
+ export const listFirewallPolicies = (siteId: string) => ReadCache.cached(policiesKey, siteId, loadPolicies(siteId));
121
+
122
+ /** Policies the user made, as opposed to system-defined ones and ones UniFi derives (from UI Objects, say), which the provider never touches. */
123
+ export const isUserDefined = (policy: Sdk.FirewallPolicy) => policy.metadata.origin === "USER_DEFINED";
124
+
125
+ const toAttributes = (siteId: string, policy: Sdk.FirewallPolicy): FirewallPolicyAttributes => {
126
+ const { id, metadata: _metadata, index: _index, ...config } = policy;
127
+ return {
128
+ siteId,
129
+ firewallPolicyId: id,
130
+ name: policy.name,
131
+ sourceFirewallZoneId: policy.source.zoneId,
132
+ destinationFirewallZoneId: policy.destination.zoneId,
133
+ config,
134
+ };
135
+ };
136
+
137
+ /** Fields the console reports but rejects in request bodies, or that change without the policy being edited. */
138
+ const READ_ONLY = ["id", "metadata", "index"];
139
+
140
+ const resolvePortFilter = Effect.fn(function* (filter: PortFilter, site: Site) {
141
+ if (filter.type === "PORTS") return filter satisfies Sdk.FirewallPolicyPortFilter;
142
+ const trafficMatchingListId = yield* resolveTrafficMatchingList(filter.trafficMatchingList, site);
143
+ return {
144
+ type: "TRAFFIC_MATCHING_LIST",
145
+ matchOpposite: filter.matchOpposite,
146
+ trafficMatchingListId,
147
+ } satisfies Sdk.FirewallPolicyPortFilter;
148
+ });
149
+
150
+ const resolveIpAddressFilter = Effect.fn(function* (filter: IpAddressFilter, site: Site) {
151
+ if (filter.type === "IP_ADDRESSES") return filter satisfies Sdk.FirewallPolicyIPAddressFilter;
152
+ const trafficMatchingListId = yield* resolveTrafficMatchingList(filter.trafficMatchingList, site);
153
+ return {
154
+ type: "TRAFFIC_MATCHING_LIST",
155
+ matchOpposite: filter.matchOpposite,
156
+ trafficMatchingListId,
157
+ } satisfies Sdk.FirewallPolicyIPAddressFilter;
158
+ });
159
+
160
+ const resolveNetworkFilter = (filter: NetworkFilter, site: Site) =>
161
+ Effect.forEach(filter.networks, (network) => resolveNetwork(network, site)).pipe(
162
+ Effect.map((networkIds): Sdk.FirewallPolicyNetworkFilter => ({
163
+ matchOpposite: filter.matchOpposite,
164
+ networkIds,
165
+ })),
166
+ );
167
+
168
+ /** `filter` with its optional `portFilter` resolved (left out when unset). */
169
+ const withPortFilter = Effect.fn(function* <F extends { readonly portFilter?: PortFilter }>(filter: F, site: Site) {
170
+ const { portFilter, ...rest } = filter;
171
+ if (portFilter === undefined) return rest;
172
+ return { ...rest, portFilter: yield* resolvePortFilter(portFilter, site) };
173
+ });
174
+
175
+ const resolveSourceFilter = Effect.fn(function* (filter: FirewallPolicySourceFilter, site: Site) {
176
+ switch (filter.type) {
177
+ case "IP_ADDRESS": {
178
+ const { ipAddressFilter, ...rest } = yield* withPortFilter(filter, site);
179
+ return {
180
+ ...rest,
181
+ ipAddressFilter: yield* resolveIpAddressFilter(ipAddressFilter, site),
182
+ } satisfies Sdk.FirewallPolicySourceTrafficFilter;
183
+ }
184
+ case "NETWORK": {
185
+ const { networkFilter, ...rest } = yield* withPortFilter(filter, site);
186
+ return {
187
+ ...rest,
188
+ networkFilter: yield* resolveNetworkFilter(networkFilter, site),
189
+ } satisfies Sdk.FirewallPolicySourceTrafficFilter;
190
+ }
191
+ case "PORT":
192
+ return {
193
+ ...filter,
194
+ portFilter: yield* resolvePortFilter(filter.portFilter, site),
195
+ } satisfies Sdk.FirewallPolicySourceTrafficFilter;
196
+ case "IPV6_IID":
197
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicySourceTrafficFilter;
198
+ case "MAC_ADDRESS":
199
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicySourceTrafficFilter;
200
+ case "REGION":
201
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicySourceTrafficFilter;
202
+ case "SITE_TO_SITE_VPN_TUNNEL":
203
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicySourceTrafficFilter;
204
+ case "VPN_SERVER":
205
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicySourceTrafficFilter;
206
+ }
207
+ });
208
+
209
+ const resolveDestinationFilter = Effect.fn(function* (filter: FirewallPolicyDestinationFilter, site: Site) {
210
+ switch (filter.type) {
211
+ case "IP_ADDRESS": {
212
+ const { ipAddressFilter, ...rest } = yield* withPortFilter(filter, site);
213
+ return {
214
+ ...rest,
215
+ ipAddressFilter: yield* resolveIpAddressFilter(ipAddressFilter, site),
216
+ } satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
217
+ }
218
+ case "NETWORK": {
219
+ const { networkFilter, ...rest } = yield* withPortFilter(filter, site);
220
+ return {
221
+ ...rest,
222
+ networkFilter: yield* resolveNetworkFilter(networkFilter, site),
223
+ } satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
224
+ }
225
+ case "PORT":
226
+ return {
227
+ ...filter,
228
+ portFilter: yield* resolvePortFilter(filter.portFilter, site),
229
+ } satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
230
+ case "APPLICATION":
231
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
232
+ case "APPLICATION_CATEGORY":
233
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
234
+ case "DOMAIN":
235
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
236
+ case "IPV6_IID":
237
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
238
+ case "REGION":
239
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
240
+ case "SITE_TO_SITE_VPN_TUNNEL":
241
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
242
+ case "VPN_SERVER":
243
+ return (yield* withPortFilter(filter, site)) satisfies Sdk.FirewallPolicyDestinationTrafficFilter;
244
+ }
245
+ });
246
+
247
+ /** The request body for `props` (without `siteId`), with references resolved to IDs. */
248
+ const toBody = Effect.fn(function* (props: FirewallPolicyProps, site: Site) {
249
+ const { site: _site, source, destination, ...rest } = props;
250
+ const sourceFilter = source.trafficFilter;
251
+ const destinationFilter = destination.trafficFilter;
252
+ return {
253
+ ...rest,
254
+ source: {
255
+ zoneId: yield* resolveFirewallZone(source.zone, site),
256
+ ...(sourceFilter === undefined ? {} : { trafficFilter: yield* resolveSourceFilter(sourceFilter, site) }),
257
+ },
258
+ destination: {
259
+ zoneId: yield* resolveFirewallZone(destination.zone, site),
260
+ ...(destinationFilter === undefined
261
+ ? {}
262
+ : { trafficFilter: yield* resolveDestinationFilter(destinationFilter, site) }),
263
+ },
264
+ } satisfies Omit<Sdk.CreateFirewallPolicyRequest, "siteId">;
265
+ });
266
+
267
+ /**
268
+ * A policy by ID, from the run's cached list: the list returns whole
269
+ * policies, so a plan reading every policy costs one request, not one each
270
+ * (this matters through the ui.com connector's rate limit).
271
+ */
272
+ const getPolicy = (siteId: string, firewallPolicyId: string) =>
273
+ listFirewallPolicies(siteId).pipe(Effect.map((policies) => policies.find((p) => p.id === firewallPolicyId)));
274
+
275
+ /** User-defined policies named `name` from `source` to `destination`. Names aren't unique, even within a zone pair. */
276
+ const findByName = Effect.fn(function* (
277
+ site: Site,
278
+ name: string,
279
+ source: FirewallZoneReference | undefined,
280
+ destination: FirewallZoneReference | undefined,
281
+ ) {
282
+ const sourceId = source === undefined ? undefined : yield* resolveFirewallZone(source, site);
283
+ const destinationId = destination === undefined ? undefined : yield* resolveFirewallZone(destination, site);
284
+ return (yield* listFirewallPolicies(site.id)).filter(
285
+ (p) =>
286
+ isUserDefined(p) &&
287
+ p.name === name &&
288
+ (sourceId === undefined || p.source.zoneId === sourceId) &&
289
+ (destinationId === undefined || p.destination.zoneId === destinationId),
290
+ );
291
+ });
292
+
293
+ /**
294
+ * A zone-based firewall policy: allow, block or reject traffic from one
295
+ * zone to another, optionally narrowed by traffic filters. Changing its
296
+ * zones updates it in place and moves it to the new zone pair's ordering
297
+ * (see `FirewallPolicyOrdering`).
298
+ *
299
+ * Existing user-defined policies are adopted with `--adopt` by name and zone
300
+ * pair (names aren't unique, so several matches adopt nothing). Policies
301
+ * UniFi derives, e.g. from UI Objects, are invisible: never listed, adopted
302
+ * or ordered.
303
+ *
304
+ * @example
305
+ * ```ts
306
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
307
+ * import * as Alchemy from "alchemy";
308
+ * import * as Effect from "effect/Effect";
309
+ *
310
+ * export default Alchemy.Stack(
311
+ * "HomeNetwork",
312
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
313
+ * Effect.gen(function* () {
314
+ * const iot = yield* UnifiNetwork.FirewallZone("iot-zone", { name: "IoT" });
315
+ * yield* UnifiNetwork.FirewallPolicy("block-iot-to-internal", {
316
+ * name: "Block IoT to Internal",
317
+ * enabled: true,
318
+ * action: { type: "BLOCK" },
319
+ * source: { zone: iot },
320
+ * destination: { zone: UnifiNetwork.FirewallZone.INTERNAL },
321
+ * ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
322
+ * loggingEnabled: false,
323
+ * });
324
+ * }),
325
+ * );
326
+ * ```
327
+ */
328
+ export const FirewallPolicy = Resource<FirewallPolicy>("UnifiNetwork.FirewallPolicy")({
329
+ /**
330
+ * A user-defined policy by name, optionally narrowed to a zone pair. Dies
331
+ * with a user-facing `LookupNotFound` or `LookupAmbiguous`: stack bodies
332
+ * can't fail with typed errors.
333
+ */
334
+ lookup: Effect.fn("UnifiNetwork.FirewallPolicy.lookup")(function* (query: {
335
+ readonly name: string;
336
+ readonly source?: FirewallZoneReference;
337
+ readonly destination?: FirewallZoneReference;
338
+ readonly site?: string;
339
+ }) {
340
+ const site = yield* resolveSite(query.site);
341
+ const policies = yield* findByName(site, query.name, query.source, query.destination);
342
+ const [policy] = policies;
343
+ const described = describeQuery({ name: query.name });
344
+ if (policy === undefined) {
345
+ return yield* new LookupNotFound({ resource: "firewall policy", query: described, site });
346
+ }
347
+ if (policies.length > 1) {
348
+ return yield* new LookupAmbiguous({
349
+ resource: "firewall policy",
350
+ query: described,
351
+ site,
352
+ count: policies.length,
353
+ });
354
+ }
355
+ return toAttributes(site.id, policy);
356
+ }, Effect.orDie),
357
+ });
358
+
359
+ export const FirewallPolicyProvider = () =>
360
+ Provider.effect(
361
+ FirewallPolicy,
362
+ Effect.gen(function* () {
363
+ const run = yield* captureServices;
364
+
365
+ return FirewallPolicy.Provider.of({
366
+ list: () =>
367
+ run(
368
+ Effect.gen(function* () {
369
+ const site = yield* resolveSite();
370
+ const policies = yield* listFirewallPolicies(site.id);
371
+ return policies.filter(isUserDefined).map((policy) => toAttributes(site.id, policy));
372
+ }),
373
+ ),
374
+
375
+ read: ({ olds, output }) =>
376
+ run(
377
+ Effect.gen(function* () {
378
+ if (output !== undefined) {
379
+ const policy = yield* getPolicy(output.siteId, output.firewallPolicyId);
380
+ return policy === undefined || !isUserDefined(policy)
381
+ ? undefined
382
+ : toAttributes(output.siteId, policy);
383
+ }
384
+ const site = yield* resolveSite(olds.site);
385
+ const matching = yield* findByName(
386
+ site,
387
+ olds.name,
388
+ olds.source.zone,
389
+ olds.destination.zone,
390
+ );
391
+ const [policy] = matching;
392
+ return policy === undefined || matching.length > 1
393
+ ? undefined
394
+ : Unowned(toAttributes(site.id, policy));
395
+ }),
396
+ ),
397
+
398
+ diff: ({ news, output }) =>
399
+ run(
400
+ Effect.gen(function* () {
401
+ const siteProp = plannedString(news, "site");
402
+ if (output === undefined || siteProp === UNKNOWN) return undefined;
403
+ const site = yield* resolveSite(siteProp);
404
+ if (site.id !== output.siteId) return { action: "replace" } satisfies Diff;
405
+ return undefined;
406
+ }),
407
+ ),
408
+
409
+ reconcile: ({ news, output }) =>
410
+ run(
411
+ Effect.gen(function* () {
412
+ const site = yield* resolveSite(news.site);
413
+ const body = yield* toBody(news, site);
414
+ const observed =
415
+ output !== undefined && output.siteId === site.id
416
+ ? yield* getPolicy(site.id, output.firewallPolicyId)
417
+ : undefined;
418
+ const write = ReadCache.invalidating(policiesKey, site.id);
419
+ if (observed === undefined) {
420
+ const created = yield* withConnection(
421
+ Sdk.createFirewallPolicy({ siteId: site.id, ...body }),
422
+ ).pipe(write);
423
+ return toAttributes(site.id, created);
424
+ }
425
+ // Every list in a policy is a set (ports, addresses, networks, states), and the console reorders them.
426
+ if (isCurrent(sortArrays(body), sortArrays(omitKeys(observed, READ_ONLY)))) {
427
+ return toAttributes(site.id, observed);
428
+ }
429
+ // A full PUT of the body: optional fields it leaves out are removed.
430
+ const updated = yield* withConnection(
431
+ Sdk.updateFirewallPolicy({
432
+ ...body,
433
+ siteId: site.id,
434
+ firewallPolicyId: observed.id,
435
+ }),
436
+ ).pipe(write);
437
+ return toAttributes(site.id, updated);
438
+ }),
439
+ ),
440
+
441
+ delete: ({ output }) =>
442
+ run(
443
+ withConnection(
444
+ Sdk.deleteFirewallPolicy({
445
+ siteId: output.siteId,
446
+ firewallPolicyId: output.firewallPolicyId,
447
+ }),
448
+ ).pipe(
449
+ Effect.asVoid,
450
+ Effect.catchTag("NotFound", () => Effect.void),
451
+ ReadCache.invalidating(policiesKey, output.siteId),
452
+ ),
453
+ ),
454
+ });
455
+ }),
456
+ );