@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.
- package/LICENSE +21 -0
- package/README.md +92 -1
- package/dist/index.d.mts +929 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2372 -0
- package/dist/index.mjs.map +1 -0
- package/dist/rolldown-runtime-D8jQpDG7.mjs +12 -0
- package/package.json +74 -1
- package/src/AuthConfig.ts +23 -0
- package/src/AuthProvider.ts +469 -0
- package/src/Connection.ts +185 -0
- package/src/Credentials.ts +85 -0
- package/src/DnsPolicy.ts +181 -0
- package/src/FirewallPolicy.ts +456 -0
- package/src/FirewallPolicyOrdering.ts +286 -0
- package/src/FirewallZone.ts +247 -0
- package/src/HttpClient.ts +138 -0
- package/src/Lookup.ts +34 -0
- package/src/Network.ts +335 -0
- package/src/Providers.ts +59 -0
- package/src/ReadCache.ts +79 -0
- package/src/Site.ts +100 -0
- package/src/SiteManager.ts +68 -0
- package/src/State.ts +607 -0
- package/src/TrafficMatchingList.ts +250 -0
- package/src/WifiBroadcast.ts +318 -0
- package/src/index.ts +17 -0
- package/src/internal/Fields.ts +49 -0
- package/src/internal/Networks.ts +7 -0
- package/src/internal/Plan.ts +22 -0
- package/src/internal/Services.ts +19 -0
|
@@ -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
|
+
});
|