@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,286 @@
1
+ import * as Sdk from "@alchemy-unifi/network-sdk";
2
+ import * as Provider from "alchemy/Provider";
3
+ import { Resource } from "alchemy/Resource";
4
+ import { UserFacingError } from "alchemy/UserFacingError";
5
+ import * as Data from "effect/Data";
6
+ import * as Effect from "effect/Effect";
7
+ import { withConnection } from "./Connection.ts";
8
+ import { type FirewallPolicyReference, isUserDefined, listFirewallPolicies } from "./FirewallPolicy.ts";
9
+ import { type FirewallZoneReference, resolveFirewallZone } from "./FirewallZone.ts";
10
+ import { captureServices } from "./internal/Services.ts";
11
+ import type { Providers } from "./Providers.ts";
12
+ import { checkSameSite, resolveSite, type Site } from "./Site.ts";
13
+ import { STATE_POLICY_PREFIX } from "./State.ts";
14
+
15
+ export interface FirewallPolicyOrderingProps {
16
+ /** Site of the zones. @default the profile's site, else the console's only site */
17
+ site?: string;
18
+ source: FirewallZoneReference;
19
+ destination: FirewallZoneReference;
20
+ /** Policies evaluated before the system-defined ones, first match wins. */
21
+ beforeSystemDefined?: ReadonlyArray<FirewallPolicyReference>;
22
+ /** Policies evaluated after the system-defined ones. */
23
+ afterSystemDefined?: ReadonlyArray<FirewallPolicyReference>;
24
+ /**
25
+ * Fail instead of keeping policies in the zone pair that aren't listed
26
+ * (e.g. made in the UI). @default false
27
+ */
28
+ exclusive?: boolean;
29
+ }
30
+
31
+ export interface FirewallPolicyOrderingAttributes {
32
+ siteId: string;
33
+ sourceFirewallZoneId: string;
34
+ destinationFirewallZoneId: string;
35
+ beforeSystemDefined: ReadonlyArray<string>;
36
+ afterSystemDefined: ReadonlyArray<string>;
37
+ /** The policies the props list, as opposed to unlisted ones kept in the order. */
38
+ listedFirewallPolicyIds: ReadonlyArray<string>;
39
+ }
40
+
41
+ export interface FirewallPolicyOrdering extends Resource<
42
+ "UnifiNetwork.FirewallPolicyOrdering",
43
+ FirewallPolicyOrderingProps,
44
+ FirewallPolicyOrderingAttributes,
45
+ never,
46
+ Providers
47
+ > {}
48
+
49
+ /** An ordering that can't be applied as given. */
50
+ export class InvalidFirewallPolicyOrdering extends Data.TaggedError("InvalidFirewallPolicyOrdering")<{
51
+ readonly reason: string;
52
+ }> {
53
+ readonly [UserFacingError]: true = true;
54
+ override get message() {
55
+ return `Can't apply the firewall policy ordering: ${this.reason}`;
56
+ }
57
+ }
58
+
59
+ type Order = Sdk.OrderedFirewallPolicyIDs;
60
+
61
+ const sameOrder = (a: Order, b: Order) =>
62
+ a.beforeSystemDefined.join() === b.beforeSystemDefined.join() &&
63
+ a.afterSystemDefined.join() === b.afterSystemDefined.join();
64
+
65
+ const resolvePair = Effect.fn(function* (props: FirewallPolicyOrderingProps) {
66
+ const site = yield* resolveSite(props.site);
67
+ return {
68
+ site,
69
+ siteId: site.id,
70
+ sourceFirewallZoneId: yield* resolveFirewallZone(props.source, site),
71
+ destinationFirewallZoneId: yield* resolveFirewallZone(props.destination, site),
72
+ };
73
+ });
74
+
75
+ const getOrder = ({ siteId, sourceFirewallZoneId, destinationFirewallZoneId }: Pair) =>
76
+ withConnection(Sdk.getFirewallPolicyOrdering({ siteId, sourceFirewallZoneId, destinationFirewallZoneId })).pipe(
77
+ Effect.map((ordering) => ordering.orderedFirewallPolicyIds),
78
+ );
79
+
80
+ interface Pair {
81
+ readonly siteId: string;
82
+ readonly sourceFirewallZoneId: string;
83
+ readonly destinationFirewallZoneId: string;
84
+ }
85
+
86
+ const toAttributes = (
87
+ pair: Pair,
88
+ order: Order,
89
+ listedFirewallPolicyIds: ReadonlyArray<string>,
90
+ ): FirewallPolicyOrderingAttributes => ({
91
+ siteId: pair.siteId,
92
+ sourceFirewallZoneId: pair.sourceFirewallZoneId,
93
+ destinationFirewallZoneId: pair.destinationFirewallZoneId,
94
+ beforeSystemDefined: order.beforeSystemDefined,
95
+ afterSystemDefined: order.afterSystemDefined,
96
+ listedFirewallPolicyIds,
97
+ });
98
+
99
+ const resolvePolicies = (references: ReadonlyArray<FirewallPolicyReference> | undefined, site: Site) =>
100
+ Effect.forEach(references ?? [], (reference) =>
101
+ checkSameSite("firewall policy", reference.siteId, site).pipe(Effect.as(reference.firewallPolicyId)),
102
+ );
103
+
104
+ /** Why a listed policy isn't in the zone pair's ordering. */
105
+ const explainMissing = Effect.fn(function* (site: Site, firewallPolicyId: string, pair: Pair) {
106
+ const policy = (yield* listFirewallPolicies(site.id)).find((p) => p.id === firewallPolicyId);
107
+ if (policy === undefined) return `policy ${firewallPolicyId} doesn't exist`;
108
+ if (!isUserDefined(policy)) return `"${policy.name}" isn't user-defined, so it can't be ordered`;
109
+ if (
110
+ policy.source.zoneId !== pair.sourceFirewallZoneId ||
111
+ policy.destination.zoneId !== pair.destinationFirewallZoneId
112
+ ) {
113
+ return `"${policy.name}" belongs to another zone pair`;
114
+ }
115
+ return `"${policy.name}" isn't in the zone pair's ordering`;
116
+ });
117
+
118
+ /**
119
+ * The state store's policies (`UnifiNetwork.state()`) among `ids`. The store
120
+ * may have created one since the run's policy list was read, so policies
121
+ * missing from it are fetched.
122
+ */
123
+ const statePolicies = Effect.fn(function* (site: Site, ids: ReadonlyArray<string>) {
124
+ const policies = yield* listFirewallPolicies(site.id);
125
+ const names = yield* Effect.forEach(ids, (id) => {
126
+ const listed = policies.find((p) => p.id === id);
127
+ return listed !== undefined
128
+ ? Effect.succeed(listed.name)
129
+ : withConnection(Sdk.getFirewallPolicy({ siteId: site.id, firewallPolicyId: id })).pipe(
130
+ Effect.map((policy): string | undefined => policy.name),
131
+ Effect.catchTag("NotFound", () => Effect.undefined),
132
+ );
133
+ });
134
+ return ids.filter((_, i) => names[i]?.startsWith(STATE_POLICY_PREFIX) === true);
135
+ });
136
+
137
+ const describePolicies = Effect.fn(function* (site: Site, ids: ReadonlyArray<string>) {
138
+ const policies = yield* listFirewallPolicies(site.id);
139
+ return ids.map((id) => `"${policies.find((p) => p.id === id)?.name ?? id}"`).join(", ");
140
+ });
141
+
142
+ /**
143
+ * The evaluation order of user-defined policies from one zone to another.
144
+ * Listing policies makes the ordering depend on them, so it's applied after
145
+ * they're created or moved, and re-applied when the list changes.
146
+ *
147
+ * UniFi rejects an ordering that leaves out any of the pair's policies, so
148
+ * unlisted ones (e.g. made in the UI) are kept after the listed ones in each
149
+ * bucket, in their current relative order, with a warning; `exclusive: true`
150
+ * fails instead. A policy the ordering listed before but no longer does is
151
+ * kept quietly, even when exclusive: it's usually being deleted in the same
152
+ * deploy, which runs after the ordering is applied. The state store's
153
+ * policies (`UnifiNetwork.state()`, Gateway → Gateway) are kept quietly too.
154
+ * System-defined policies sit between the two buckets and
155
+ * can't be reordered; policies UniFi derives from UI Objects aren't ordered
156
+ * at all.
157
+ *
158
+ * The zone pair is the identity: an existing ordering is adopted without
159
+ * `--adopt`, and deleting the resource leaves the order as it is.
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
164
+ * import * as Alchemy from "alchemy";
165
+ * import * as Effect from "effect/Effect";
166
+ *
167
+ * export default Alchemy.Stack(
168
+ * "HomeNetwork",
169
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
170
+ * Effect.gen(function* () {
171
+ * const { INTERNAL } = UnifiNetwork.FirewallZone;
172
+ * const iot = yield* UnifiNetwork.FirewallZone("iot-zone", { name: "IoT" });
173
+ * const policy = { enabled: true, source: { zone: INTERNAL }, destination: { zone: iot }, loggingEnabled: false } as const;
174
+ * const allowHomeAssistant = yield* UnifiNetwork.FirewallPolicy("allow-ha", {
175
+ * ...policy,
176
+ * name: "Allow Home Assistant",
177
+ * action: { type: "ALLOW", allowReturnTraffic: true },
178
+ * ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
179
+ * });
180
+ * const blockTheRest = yield* UnifiNetwork.FirewallPolicy("block-rest", {
181
+ * ...policy,
182
+ * name: "Block the rest",
183
+ * action: { type: "BLOCK" },
184
+ * ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
185
+ * });
186
+ * yield* UnifiNetwork.FirewallPolicyOrdering("internal-to-iot", {
187
+ * source: INTERNAL,
188
+ * destination: iot,
189
+ * beforeSystemDefined: [allowHomeAssistant, blockTheRest],
190
+ * });
191
+ * }),
192
+ * );
193
+ * ```
194
+ */
195
+ export const FirewallPolicyOrdering = Resource<FirewallPolicyOrdering>("UnifiNetwork.FirewallPolicyOrdering");
196
+
197
+ export const FirewallPolicyOrderingProvider = () =>
198
+ Provider.effect(
199
+ FirewallPolicyOrdering,
200
+ Effect.gen(function* () {
201
+ const run = yield* captureServices;
202
+
203
+ return FirewallPolicyOrdering.Provider.of({
204
+ // Orderings aren't objects of their own: there's nothing to list or nuke.
205
+ list: () => Effect.succeed([]),
206
+
207
+ read: ({ olds, output }) =>
208
+ run(
209
+ Effect.gen(function* () {
210
+ if (output !== undefined) {
211
+ return toAttributes(output, yield* getOrder(output), output.listedFirewallPolicyIds);
212
+ }
213
+ // One ordering per zone pair, so the existing one is this resource's.
214
+ const pair = yield* resolvePair(olds);
215
+ return toAttributes(pair, yield* getOrder(pair), []);
216
+ }),
217
+ ),
218
+
219
+ reconcile: ({ news, output }) =>
220
+ run(
221
+ Effect.gen(function* () {
222
+ const pair = yield* resolvePair(news);
223
+ const { site } = pair;
224
+ const before = yield* resolvePolicies(news.beforeSystemDefined, site);
225
+ const after = yield* resolvePolicies(news.afterSystemDefined, site);
226
+ const listed = [...before, ...after];
227
+ const duplicate = listed.find((id, i) => listed.indexOf(id) !== i);
228
+ if (duplicate !== undefined) {
229
+ const names = yield* describePolicies(site, [duplicate]);
230
+ return yield* new InvalidFirewallPolicyOrdering({
231
+ reason: `${names} is listed twice.`,
232
+ });
233
+ }
234
+
235
+ const current = yield* getOrder(pair);
236
+ const inPair = new Set([...current.beforeSystemDefined, ...current.afterSystemDefined]);
237
+ const missing = listed.find((id) => !inPair.has(id));
238
+ if (missing !== undefined) {
239
+ return yield* new InvalidFirewallPolicyOrdering({
240
+ reason: `${yield* explainMissing(site, missing, pair)}.`,
241
+ });
242
+ }
243
+
244
+ const unlisted = (ids: ReadonlyArray<string>) => ids.filter((id) => !listed.includes(id));
245
+ const formerlyListed = output?.listedFirewallPolicyIds ?? [];
246
+ const unlistedNow = [
247
+ ...unlisted(current.beforeSystemDefined),
248
+ ...unlisted(current.afterSystemDefined),
249
+ ].filter((id) => !formerlyListed.includes(id));
250
+ // The state store's policies are disabled and kept where they are, without a word.
251
+ const stateIds = yield* statePolicies(site, unlistedNow);
252
+ const unmanaged = unlistedNow.filter((id) => !stateIds.includes(id));
253
+ if (unmanaged.length > 0) {
254
+ const names = yield* describePolicies(site, unmanaged);
255
+ if (news.exclusive === true) {
256
+ return yield* new InvalidFirewallPolicyOrdering({
257
+ reason: `the zone pair has policies the ordering doesn't list: ${names}. List them, delete them, or drop \`exclusive\`.`,
258
+ });
259
+ }
260
+ yield* Effect.logWarning(
261
+ `Firewall policy ordering: keeping unlisted policies after the listed ones: ${names}.`,
262
+ );
263
+ }
264
+
265
+ const desired: Order = {
266
+ beforeSystemDefined: [...before, ...unlisted(current.beforeSystemDefined)],
267
+ afterSystemDefined: [...after, ...unlisted(current.afterSystemDefined)],
268
+ };
269
+ if (sameOrder(desired, current)) return toAttributes(pair, current, listed);
270
+ const updated = yield* withConnection(
271
+ Sdk.updateFirewallPolicyOrdering({
272
+ siteId: pair.siteId,
273
+ sourceFirewallZoneId: pair.sourceFirewallZoneId,
274
+ destinationFirewallZoneId: pair.destinationFirewallZoneId,
275
+ orderedFirewallPolicyIds: desired,
276
+ }),
277
+ );
278
+ return toAttributes(pair, updated.orderedFirewallPolicyIds, listed);
279
+ }),
280
+ ),
281
+
282
+ // The order stays as it is; the policies themselves are deleted by their own resources.
283
+ delete: () => Effect.void,
284
+ });
285
+ }),
286
+ );
@@ -0,0 +1,247 @@
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 { loadNetworks } from "./internal/Networks.ts";
10
+ import { plannedString, UNKNOWN } from "./internal/Plan.ts";
11
+ import { captureServices } from "./internal/Services.ts";
12
+ import { describeQuery, LookupNotFound } from "./Lookup.ts";
13
+ import type { Providers } from "./Providers.ts";
14
+ import * as ReadCache from "./ReadCache.ts";
15
+ import { checkSameSite, resolveSite, type Site } from "./Site.ts";
16
+
17
+ /** The zones every zone-based firewall has, by their API names. */
18
+ export type BuiltinFirewallZoneName = "Internal" | "External" | "Gateway" | "Vpn" | "Hotspot" | "Dmz";
19
+
20
+ /** A built-in zone, resolved by name in whichever site the referencing resource is in. */
21
+ export interface BuiltinFirewallZone {
22
+ readonly builtinFirewallZone: BuiltinFirewallZoneName;
23
+ }
24
+
25
+ /**
26
+ * A zone: a `FirewallZone` resource, a {@link FirewallZone.lookup} result,
27
+ * `{ firewallZoneId }`, or a built-in constant such as `FirewallZone.INTERNAL`.
28
+ */
29
+ export type FirewallZoneReference =
30
+ | { readonly firewallZoneId: string; readonly siteId?: string | undefined }
31
+ | BuiltinFirewallZone;
32
+
33
+ export interface FirewallZoneProps {
34
+ /** Site to create the zone in. @default the profile's site, else the console's only site */
35
+ site?: string;
36
+ name: string;
37
+ }
38
+
39
+ export interface FirewallZoneAttributes {
40
+ siteId: string;
41
+ firewallZoneId: string;
42
+ name: string;
43
+ }
44
+
45
+ export interface FirewallZone extends Resource<
46
+ "UnifiNetwork.FirewallZone",
47
+ FirewallZoneProps,
48
+ FirewallZoneAttributes,
49
+ never,
50
+ Providers
51
+ > {}
52
+
53
+ const loadZones = (siteId: string) => withConnection(Stream.runCollect(Sdk.getFirewallZones.items({ siteId })));
54
+ const zonesKey = ReadCache.key<ReadonlyArray<Sdk.FirewallZone>, Effect.Error<ReturnType<typeof loadZones>>>(
55
+ "firewallZones",
56
+ );
57
+
58
+ /** A site's zones, built-in ones included, listed once per run. */
59
+ export const listFirewallZones = (siteId: string) => ReadCache.cached(zonesKey, siteId, loadZones(siteId));
60
+
61
+ const toAttributes = (siteId: string, zone: Sdk.FirewallZone): FirewallZoneAttributes => ({
62
+ siteId,
63
+ firewallZoneId: zone.id,
64
+ name: zone.name,
65
+ });
66
+
67
+ const isBuiltin = (zone: Sdk.FirewallZone) => zone.metadata.origin === "SYSTEM_DEFINED";
68
+
69
+ /** The zone ID a reference means in `site`. */
70
+ export const resolveFirewallZone = Effect.fn("UnifiNetwork.resolveFirewallZone")(function* (
71
+ reference: FirewallZoneReference,
72
+ site: Site,
73
+ ) {
74
+ if ("firewallZoneId" in reference) {
75
+ yield* checkSameSite("firewall zone", reference.siteId, site);
76
+ return reference.firewallZoneId;
77
+ }
78
+ const zones = yield* listFirewallZones(site.id);
79
+ const zone = zones.find((z) => isBuiltin(z) && z.name === reference.builtinFirewallZone);
80
+ if (zone === undefined) {
81
+ return yield* new LookupNotFound({
82
+ resource: "built-in firewall zone",
83
+ query: `name "${reference.builtinFirewallZone}" (has the site been migrated to zone-based firewall?)`,
84
+ site,
85
+ });
86
+ }
87
+ return zone.id;
88
+ });
89
+
90
+ const builtin = (name: BuiltinFirewallZoneName): BuiltinFirewallZone => ({ builtinFirewallZone: name });
91
+
92
+ /**
93
+ * A custom zone in the zone-based firewall. Networks join zones through
94
+ * `Network`'s `zone` prop; the zone doesn't list its networks, so the two
95
+ * resources never fight over membership.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
100
+ * import * as Alchemy from "alchemy";
101
+ * import * as Effect from "effect/Effect";
102
+ *
103
+ * export default Alchemy.Stack(
104
+ * "HomeNetwork",
105
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
106
+ * Effect.gen(function* () {
107
+ * const cameras = yield* UnifiNetwork.FirewallZone("cameras", { name: "Cameras" });
108
+ * // Networks join zones from the network's side.
109
+ * yield* UnifiNetwork.Network("cctv", {
110
+ * management: "GATEWAY",
111
+ * name: "CCTV",
112
+ * vlanId: 30,
113
+ * enabled: true,
114
+ * cellularBackupEnabled: false,
115
+ * internetAccessEnabled: false,
116
+ * isolationEnabled: true,
117
+ * zone: cameras, // or a built-in zone, e.g. UnifiNetwork.FirewallZone.INTERNAL
118
+ * ipv4Configuration: { autoScaleEnabled: false, hostIpAddress: "192.168.30.1", prefixLength: 24 },
119
+ * });
120
+ * }),
121
+ * );
122
+ * ```
123
+ */
124
+ export const FirewallZone = Resource<FirewallZone>("UnifiNetwork.FirewallZone")({
125
+ INTERNAL: builtin("Internal"),
126
+ EXTERNAL: builtin("External"),
127
+ GATEWAY: builtin("Gateway"),
128
+ VPN: builtin("Vpn"),
129
+ HOTSPOT: builtin("Hotspot"),
130
+ DMZ: builtin("Dmz"),
131
+ /**
132
+ * Any zone in the site, built-in or custom, by name. Dies with a
133
+ * user-facing `LookupNotFound` if there's none: stack bodies can't fail
134
+ * with typed errors.
135
+ */
136
+ lookup: Effect.fn("UnifiNetwork.FirewallZone.lookup")(function* (query: {
137
+ readonly name: string;
138
+ readonly site?: string;
139
+ }) {
140
+ const site = yield* resolveSite(query.site);
141
+ const zone = (yield* listFirewallZones(site.id)).find((z) => z.name === query.name);
142
+ if (zone === undefined) {
143
+ return yield* new LookupNotFound({ resource: "firewall zone", query: describeQuery(query), site });
144
+ }
145
+ return toAttributes(site.id, zone);
146
+ }, Effect.orDie),
147
+ });
148
+
149
+ const getZone = (siteId: string, firewallZoneId: string) =>
150
+ withConnection(Sdk.getFirewallZone({ siteId, firewallZoneId })).pipe(
151
+ Effect.catchTag("NotFound", () => Effect.undefined),
152
+ );
153
+
154
+ /**
155
+ * The networks in a zone, from the networks' own `zoneId`s. Updates replace
156
+ * a zone's member list, and the zone's `networkIds` lags a network joining
157
+ * it by a fraction of a second, so sending those back could detach a
158
+ * network that just joined.
159
+ */
160
+ const currentMembers = (siteId: string, firewallZoneId: string) =>
161
+ loadNetworks(siteId).pipe(
162
+ Effect.map((networks) => networks.filter((n) => "zoneId" in n && n.zoneId === firewallZoneId).map((n) => n.id)),
163
+ );
164
+
165
+ export const FirewallZoneProvider = () =>
166
+ Provider.effect(
167
+ FirewallZone,
168
+ Effect.gen(function* () {
169
+ const run = yield* captureServices;
170
+
171
+ return FirewallZone.Provider.of({
172
+ list: () =>
173
+ run(
174
+ Effect.gen(function* () {
175
+ const site = yield* resolveSite();
176
+ const zones = yield* listFirewallZones(site.id);
177
+ return zones.filter((zone) => !isBuiltin(zone)).map((zone) => toAttributes(site.id, zone));
178
+ }),
179
+ ),
180
+
181
+ read: ({ olds, output }) =>
182
+ run(
183
+ Effect.gen(function* () {
184
+ if (output !== undefined) {
185
+ const zone = yield* getZone(output.siteId, output.firewallZoneId);
186
+ return zone === undefined ? undefined : toAttributes(output.siteId, zone);
187
+ }
188
+ const site = yield* resolveSite(olds.site);
189
+ const zone = (yield* listFirewallZones(site.id)).find(
190
+ (z) => !isBuiltin(z) && z.name === olds.name,
191
+ );
192
+ return zone === undefined ? undefined : Unowned(toAttributes(site.id, zone));
193
+ }),
194
+ ),
195
+
196
+ diff: ({ news, output }) =>
197
+ run(
198
+ Effect.gen(function* () {
199
+ const siteProp = plannedString(news, "site");
200
+ if (output === undefined || siteProp === UNKNOWN) return undefined;
201
+ const site = yield* resolveSite(siteProp);
202
+ if (site.id !== output.siteId) return { action: "replace" } satisfies Diff;
203
+ return undefined;
204
+ }),
205
+ ),
206
+
207
+ reconcile: ({ news, output }) =>
208
+ run(
209
+ Effect.gen(function* () {
210
+ const site = yield* resolveSite(news.site);
211
+ const observed =
212
+ output !== undefined && output.siteId === site.id
213
+ ? yield* getZone(site.id, output.firewallZoneId)
214
+ : undefined;
215
+ const write = ReadCache.invalidating(zonesKey, site.id);
216
+ if (observed === undefined) {
217
+ const created = yield* withConnection(
218
+ Sdk.createFirewallZone({ siteId: site.id, name: news.name, networkIds: [] }),
219
+ ).pipe(write);
220
+ return toAttributes(site.id, created);
221
+ }
222
+ if (observed.name === news.name) return toAttributes(site.id, observed);
223
+ const updated = yield* withConnection(
224
+ Sdk.updateFirewallZone({
225
+ siteId: site.id,
226
+ firewallZoneId: observed.id,
227
+ name: news.name,
228
+ networkIds: yield* currentMembers(site.id, observed.id),
229
+ }),
230
+ ).pipe(write);
231
+ return toAttributes(site.id, updated);
232
+ }),
233
+ ),
234
+
235
+ delete: ({ output }) =>
236
+ run(
237
+ withConnection(
238
+ Sdk.deleteFirewallZone({ siteId: output.siteId, firewallZoneId: output.firewallZoneId }),
239
+ ).pipe(
240
+ Effect.asVoid,
241
+ Effect.catchTag("NotFound", () => Effect.void),
242
+ ReadCache.invalidating(zonesKey, output.siteId),
243
+ ),
244
+ ),
245
+ });
246
+ }),
247
+ );
@@ -0,0 +1,138 @@
1
+ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
2
+ import { buildConnector } from "@effect/platform-node/Undici";
3
+ import * as Data from "effect/Data";
4
+ import * as Effect from "effect/Effect";
5
+ import type * as HttpClient from "effect/http/HttpClient";
6
+ import type * as Scope from "effect/Scope";
7
+ import * as net from "node:net";
8
+ import * as tls from "node:tls";
9
+
10
+ /** How the client verifies a console's TLS certificate. */
11
+ export interface TlsOptions {
12
+ /** Accept the server only if a certificate in its chain has this SHA-256 fingerprint. */
13
+ readonly certFingerprint?: string | undefined;
14
+ /** Skip verification entirely. */
15
+ readonly insecure?: boolean | undefined;
16
+ }
17
+
18
+ /** Normalises a SHA-256 fingerprint to Node's `AB:CD:…` form, or returns `undefined` if it isn't one. */
19
+ export const normaliseFingerprint = (value: string): string | undefined => {
20
+ const hex = value.replace(/[\s:]/g, "").toUpperCase();
21
+ return /^[0-9A-F]{64}$/.test(hex) ? (hex.match(/../g) ?? []).join(":") : undefined;
22
+ };
23
+
24
+ /** SHA-256 fingerprints of a peer's certificate chain, leaf first. */
25
+ const chainFingerprints = (socket: tls.TLSSocket): ReadonlyArray<string> => {
26
+ const fingerprints: string[] = [];
27
+ let cert: tls.DetailedPeerCertificate | undefined = socket.getPeerCertificate(true);
28
+ while (cert?.fingerprint256 !== undefined && !fingerprints.includes(cert.fingerprint256)) {
29
+ fingerprints.push(cert.fingerprint256);
30
+ cert = cert.issuerCertificate;
31
+ }
32
+ return fingerprints;
33
+ };
34
+
35
+ const PINNING_ERROR_CODE = "ERR_UNIFI_CERT_PINNING";
36
+
37
+ /**
38
+ * Whether an error (or one of its causes) is a certificate verification or
39
+ * pinning failure. These are deterministic, so they aren't worth retrying.
40
+ */
41
+ export const isCertificateError = (error: unknown): boolean => {
42
+ for (let e: unknown = error, depth = 0; typeof e === "object" && e !== null && depth < 5; depth++) {
43
+ const code = "code" in e ? String(e.code) : "";
44
+ if (code === PINNING_ERROR_CODE || /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(code))
45
+ return true;
46
+ e = "cause" in e ? e.cause : "reason" in e ? e.reason : undefined;
47
+ }
48
+ return false;
49
+ };
50
+
51
+ /**
52
+ * Pinning can't use `checkServerIdentity`: Node skips it when the chain
53
+ * doesn't verify, which is always the case for a console's self-signed
54
+ * certificate. So verification is off, and the connector checks the
55
+ * presented chain itself once the handshake completes. Session resumption
56
+ * is off too, because a resumed session presents no certificate to check.
57
+ */
58
+ const pinnedConnector = (fingerprint: string): buildConnector.connector => {
59
+ const connect = buildConnector({ rejectUnauthorized: false, maxCachedSessions: 0 });
60
+ return (options, callback) =>
61
+ connect(options, (error, socket) => {
62
+ if (error !== null) return callback(error, null);
63
+ if (socket instanceof tls.TLSSocket && !chainFingerprints(socket).includes(fingerprint)) {
64
+ socket.destroy();
65
+ return callback(
66
+ Object.assign(
67
+ new Error(
68
+ `The certificate presented by ${options.hostname} doesn't match the pinned fingerprint ${fingerprint}.`,
69
+ ),
70
+ { code: PINNING_ERROR_CODE },
71
+ ),
72
+ null,
73
+ );
74
+ }
75
+ callback(null, socket);
76
+ });
77
+ };
78
+
79
+ /** An undici-backed `HttpClient` applying {@link TlsOptions}. The dispatcher closes with the scope. */
80
+ export const make = (options: TlsOptions = {}): Effect.Effect<HttpClient.HttpClient, never, Scope.Scope> =>
81
+ NodeHttpClient.makeDispatcherOptions(
82
+ options.certFingerprint !== undefined
83
+ ? { connect: pinnedConnector(options.certFingerprint) }
84
+ : options.insecure
85
+ ? { connect: { rejectUnauthorized: false } }
86
+ : undefined,
87
+ ).pipe(
88
+ Effect.flatMap((dispatcher) =>
89
+ NodeHttpClient.makeUndici.pipe(Effect.provideService(NodeHttpClient.Dispatcher, dispatcher)),
90
+ ),
91
+ );
92
+
93
+ export class CertificateInspectionError extends Data.TaggedError("CertificateInspectionError")<{
94
+ readonly message: string;
95
+ readonly cause?: unknown;
96
+ }> {}
97
+
98
+ export interface PeerCertificate {
99
+ /** Whether Node's default CAs trust the chain for this host. */
100
+ readonly trusted: boolean;
101
+ /** SHA-256 fingerprint of the leaf certificate (`AB:CD:…`). */
102
+ readonly fingerprint: string;
103
+ readonly subject: string;
104
+ readonly validTo: string;
105
+ }
106
+
107
+ /**
108
+ * Connects to an `https:` URL without verifying it and reports the
109
+ * certificate it presents, so `configure` can offer to pin it.
110
+ */
111
+ export const inspectCertificate = (url: string): Effect.Effect<PeerCertificate, CertificateInspectionError> =>
112
+ Effect.callback<PeerCertificate, CertificateInspectionError>((resume) => {
113
+ const { hostname, port } = new URL(url);
114
+ const host = hostname.replace(/^\[|\]$/g, "");
115
+ const socket = tls.connect({
116
+ host,
117
+ port: Number(port || 443),
118
+ rejectUnauthorized: false,
119
+ ...(net.isIP(host) === 0 ? { servername: host } : {}),
120
+ });
121
+ socket.setTimeout(10_000, () => socket.destroy(new Error(`Timed out connecting to ${url}.`)));
122
+ socket.once("secureConnect", () => {
123
+ const cert = socket.getPeerCertificate();
124
+ resume(
125
+ Effect.succeed({
126
+ trusted: socket.authorized,
127
+ fingerprint: cert.fingerprint256,
128
+ subject: String(cert.subject?.CN ?? cert.subjectaltname ?? "unknown"),
129
+ validTo: cert.valid_to,
130
+ }),
131
+ );
132
+ socket.end();
133
+ });
134
+ socket.once("error", (cause) =>
135
+ resume(Effect.fail(new CertificateInspectionError({ message: cause.message, cause }))),
136
+ );
137
+ return Effect.sync(() => socket.destroy());
138
+ });