@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,250 @@
|
|
|
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 { UserFacingError } from "alchemy/UserFacingError";
|
|
7
|
+
import * as Data from "effect/Data";
|
|
8
|
+
import * as Effect from "effect/Effect";
|
|
9
|
+
import * as Stream from "effect/Stream";
|
|
10
|
+
import { withConnection } from "./Connection.ts";
|
|
11
|
+
import { type DistributiveOmit, isCurrent, omitKeys } from "./internal/Fields.ts";
|
|
12
|
+
import { plannedString, UNKNOWN } from "./internal/Plan.ts";
|
|
13
|
+
import { captureServices } from "./internal/Services.ts";
|
|
14
|
+
import { describeQuery, LookupNotFound } from "./Lookup.ts";
|
|
15
|
+
import type { Providers } from "./Providers.ts";
|
|
16
|
+
import * as ReadCache from "./ReadCache.ts";
|
|
17
|
+
import { checkSameSite, resolveSite, type Site } from "./Site.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Props are the Integration API's list body plus `site`. `type` picks what
|
|
21
|
+
* it holds: `"IPV4_ADDRESSES"`, `"IPV6_ADDRESSES"` or `"PORTS"` (no MACs).
|
|
22
|
+
*/
|
|
23
|
+
export type TrafficMatchingListProps = Sdk.CreateOrUpdateTrafficMatchingList & {
|
|
24
|
+
/** Site to create the list in. @default the profile's site, else the console's only site */
|
|
25
|
+
site?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A list: a `TrafficMatchingList` resource, a {@link TrafficMatchingList.lookup} result, or `{ trafficMatchingListId }`. */
|
|
29
|
+
export interface TrafficMatchingListReference {
|
|
30
|
+
readonly trafficMatchingListId: string;
|
|
31
|
+
readonly siteId?: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TrafficMatchingListAttributes {
|
|
35
|
+
siteId: string;
|
|
36
|
+
trafficMatchingListId: string;
|
|
37
|
+
name: string;
|
|
38
|
+
type: TrafficMatchingListProps["type"];
|
|
39
|
+
/** The whole list as the console reports it, so `alchemy drift` sees any change. */
|
|
40
|
+
config: DistributiveOmit<Sdk.TrafficMatchingList, "id">;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TrafficMatchingList extends Resource<
|
|
44
|
+
"UnifiNetwork.TrafficMatchingList",
|
|
45
|
+
TrafficMatchingListProps,
|
|
46
|
+
TrafficMatchingListAttributes,
|
|
47
|
+
never,
|
|
48
|
+
Providers
|
|
49
|
+
> {}
|
|
50
|
+
|
|
51
|
+
const loadLists = (siteId: string) => withConnection(Stream.runCollect(Sdk.getTrafficMatchingLists.items({ siteId })));
|
|
52
|
+
const listsKey = ReadCache.key<ReadonlyArray<Sdk.TrafficMatchingList>, Effect.Error<ReturnType<typeof loadLists>>>(
|
|
53
|
+
"trafficMatchingLists",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/** A site's traffic-matching lists, listed once per run. */
|
|
57
|
+
export const listTrafficMatchingLists = (siteId: string) => ReadCache.cached(listsKey, siteId, loadLists(siteId));
|
|
58
|
+
|
|
59
|
+
const toAttributes = (siteId: string, list: Sdk.TrafficMatchingList): TrafficMatchingListAttributes => {
|
|
60
|
+
const { id, ...config } = list;
|
|
61
|
+
return { siteId, trafficMatchingListId: id, name: list.name, type: list.type, config };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Fields the console reports but rejects in request bodies. */
|
|
65
|
+
const READ_ONLY = ["id"];
|
|
66
|
+
|
|
67
|
+
/** A list can't be deleted while something references it. */
|
|
68
|
+
export class TrafficMatchingListInUse extends Data.TaggedError("TrafficMatchingListInUse")<{
|
|
69
|
+
readonly listName: string;
|
|
70
|
+
/** Names of the firewall policies referencing it, as far as they could be found. */
|
|
71
|
+
readonly referencedBy: ReadonlyArray<string>;
|
|
72
|
+
readonly detail: string;
|
|
73
|
+
}> {
|
|
74
|
+
readonly [UserFacingError]: true = true;
|
|
75
|
+
override get message() {
|
|
76
|
+
const by =
|
|
77
|
+
this.referencedBy.length > 0
|
|
78
|
+
? `firewall policies ${this.referencedBy.map((name) => `"${name}"`).join(", ")}`
|
|
79
|
+
: `something the provider couldn't identify (${this.detail})`;
|
|
80
|
+
return `Can't delete traffic-matching list "${this.listName}": it's still referenced by ${by}. Those references aren't managed by this stack (e.g. made in the UI); remove them, then deploy again.`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** User-defined firewall policies that mention `trafficMatchingListId` in a filter. */
|
|
85
|
+
const policiesReferencing = (siteId: string, trafficMatchingListId: string) =>
|
|
86
|
+
withConnection(Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId }))).pipe(
|
|
87
|
+
Effect.map((policies) =>
|
|
88
|
+
policies
|
|
89
|
+
.filter(
|
|
90
|
+
(p) =>
|
|
91
|
+
p.metadata.origin === "USER_DEFINED" &&
|
|
92
|
+
JSON.stringify([p.source, p.destination]).includes(trafficMatchingListId),
|
|
93
|
+
)
|
|
94
|
+
.map((p) => p.name),
|
|
95
|
+
),
|
|
96
|
+
Effect.orElseSucceed((): ReadonlyArray<string> => []),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/** The list ID a reference means in `site`. */
|
|
100
|
+
export const resolveTrafficMatchingList = (reference: TrafficMatchingListReference, site: Site) =>
|
|
101
|
+
checkSameSite("traffic-matching list", reference.siteId, site).pipe(Effect.as(reference.trafficMatchingListId));
|
|
102
|
+
|
|
103
|
+
/** A list by ID, from the run's cached lists, which come whole (one request for every list in a plan). */
|
|
104
|
+
const getList = (siteId: string, trafficMatchingListId: string) =>
|
|
105
|
+
listTrafficMatchingLists(siteId).pipe(Effect.map((lists) => lists.find((l) => l.id === trafficMatchingListId)));
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A named list of IPv4 addresses, IPv6 addresses or ports, which firewall
|
|
109
|
+
* policies reference instead of repeating values inline. Names are unique
|
|
110
|
+
* per site, so existing lists are adopted by name with `--adopt`. Changing
|
|
111
|
+
* `type` updates the list in place.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
116
|
+
* import * as Alchemy from "alchemy";
|
|
117
|
+
* import * as Effect from "effect/Effect";
|
|
118
|
+
*
|
|
119
|
+
* export default Alchemy.Stack(
|
|
120
|
+
* "HomeNetwork",
|
|
121
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
122
|
+
* Effect.gen(function* () {
|
|
123
|
+
* const haHosts = yield* UnifiNetwork.TrafficMatchingList("ha-hosts", {
|
|
124
|
+
* type: "IPV4_ADDRESSES",
|
|
125
|
+
* name: "Home Assistant hosts",
|
|
126
|
+
* items: [{ type: "IP_ADDRESS", value: "192.168.2.20" }],
|
|
127
|
+
* });
|
|
128
|
+
* // Reference it from a policy's filter: { type: "TRAFFIC_MATCHING_LIST", matchOpposite: false, trafficMatchingList: haHosts }
|
|
129
|
+
* }),
|
|
130
|
+
* );
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export const TrafficMatchingList = Resource<TrafficMatchingList>("UnifiNetwork.TrafficMatchingList")({
|
|
134
|
+
/**
|
|
135
|
+
* A list in the site by name. Dies with a user-facing `LookupNotFound`:
|
|
136
|
+
* stack bodies can't fail with typed errors.
|
|
137
|
+
*/
|
|
138
|
+
lookup: Effect.fn("UnifiNetwork.TrafficMatchingList.lookup")(function* (query: {
|
|
139
|
+
readonly name: string;
|
|
140
|
+
readonly site?: string;
|
|
141
|
+
}) {
|
|
142
|
+
const site = yield* resolveSite(query.site);
|
|
143
|
+
const list = (yield* listTrafficMatchingLists(site.id)).find((l) => l.name === query.name);
|
|
144
|
+
if (list === undefined) {
|
|
145
|
+
return yield* new LookupNotFound({ resource: "traffic-matching list", query: describeQuery(query), site });
|
|
146
|
+
}
|
|
147
|
+
return toAttributes(site.id, list);
|
|
148
|
+
}, Effect.orDie),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export const TrafficMatchingListProvider = () =>
|
|
152
|
+
Provider.effect(
|
|
153
|
+
TrafficMatchingList,
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
const run = yield* captureServices;
|
|
156
|
+
|
|
157
|
+
return TrafficMatchingList.Provider.of({
|
|
158
|
+
list: () =>
|
|
159
|
+
run(
|
|
160
|
+
Effect.gen(function* () {
|
|
161
|
+
const site = yield* resolveSite();
|
|
162
|
+
return (yield* listTrafficMatchingLists(site.id)).map((list) =>
|
|
163
|
+
toAttributes(site.id, list),
|
|
164
|
+
);
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
|
|
168
|
+
read: ({ olds, output }) =>
|
|
169
|
+
run(
|
|
170
|
+
Effect.gen(function* () {
|
|
171
|
+
if (output !== undefined) {
|
|
172
|
+
const list = yield* getList(output.siteId, output.trafficMatchingListId);
|
|
173
|
+
return list === undefined ? undefined : toAttributes(output.siteId, list);
|
|
174
|
+
}
|
|
175
|
+
const site = yield* resolveSite(olds.site);
|
|
176
|
+
const list = (yield* listTrafficMatchingLists(site.id)).find((l) => l.name === olds.name);
|
|
177
|
+
return list === undefined ? undefined : Unowned(toAttributes(site.id, list));
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
|
|
181
|
+
diff: ({ news, output }) =>
|
|
182
|
+
run(
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const siteProp = plannedString(news, "site");
|
|
185
|
+
if (output === undefined || siteProp === UNKNOWN) return undefined;
|
|
186
|
+
const site = yield* resolveSite(siteProp);
|
|
187
|
+
if (site.id !== output.siteId) return { action: "replace" } satisfies Diff;
|
|
188
|
+
return undefined;
|
|
189
|
+
}),
|
|
190
|
+
),
|
|
191
|
+
|
|
192
|
+
reconcile: ({ news, output }) =>
|
|
193
|
+
run(
|
|
194
|
+
Effect.gen(function* () {
|
|
195
|
+
const site = yield* resolveSite(news.site);
|
|
196
|
+
const { site: _site, ...body } = news;
|
|
197
|
+
const observed =
|
|
198
|
+
output !== undefined && output.siteId === site.id
|
|
199
|
+
? yield* getList(site.id, output.trafficMatchingListId)
|
|
200
|
+
: undefined;
|
|
201
|
+
const write = ReadCache.invalidating(listsKey, site.id);
|
|
202
|
+
if (observed === undefined) {
|
|
203
|
+
const created = yield* withConnection(
|
|
204
|
+
Sdk.createTrafficMatchingList({ siteId: site.id, body }),
|
|
205
|
+
).pipe(write);
|
|
206
|
+
return toAttributes(site.id, created);
|
|
207
|
+
}
|
|
208
|
+
if (isCurrent(body, omitKeys(observed, READ_ONLY))) return toAttributes(site.id, observed);
|
|
209
|
+
const updated = yield* withConnection(
|
|
210
|
+
Sdk.updateTrafficMatchingList({
|
|
211
|
+
siteId: site.id,
|
|
212
|
+
trafficMatchingListId: observed.id,
|
|
213
|
+
body,
|
|
214
|
+
}),
|
|
215
|
+
).pipe(write);
|
|
216
|
+
return toAttributes(site.id, updated);
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
|
|
220
|
+
delete: ({ output }) =>
|
|
221
|
+
run(
|
|
222
|
+
withConnection(
|
|
223
|
+
Sdk.deleteTrafficMatchingList({
|
|
224
|
+
siteId: output.siteId,
|
|
225
|
+
trafficMatchingListId: output.trafficMatchingListId,
|
|
226
|
+
}),
|
|
227
|
+
).pipe(
|
|
228
|
+
Effect.asVoid,
|
|
229
|
+
Effect.catchTags({
|
|
230
|
+
NotFound: () => Effect.void,
|
|
231
|
+
// 422 "Selected traffic matching list is referenced by other resources".
|
|
232
|
+
UnprocessableEntity: (error) =>
|
|
233
|
+
policiesReferencing(output.siteId, output.trafficMatchingListId).pipe(
|
|
234
|
+
Effect.flatMap((referencedBy) =>
|
|
235
|
+
Effect.fail(
|
|
236
|
+
new TrafficMatchingListInUse({
|
|
237
|
+
listName: output.name,
|
|
238
|
+
referencedBy,
|
|
239
|
+
detail: error.message,
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
}),
|
|
245
|
+
ReadCache.invalidating(listsKey, output.siteId),
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
});
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
@@ -0,0 +1,318 @@
|
|
|
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 DistributiveOmit, isCurrent, omitKeys } from "./internal/Fields.ts";
|
|
10
|
+
import { planned, plannedString, UNKNOWN } from "./internal/Plan.ts";
|
|
11
|
+
import { captureServices } from "./internal/Services.ts";
|
|
12
|
+
import { describeQuery, LookupAmbiguous, LookupNotFound } from "./Lookup.ts";
|
|
13
|
+
import { type NetworkReference, resolveNetwork } from "./Network.ts";
|
|
14
|
+
import type { Providers } from "./Providers.ts";
|
|
15
|
+
import * as ReadCache from "./ReadCache.ts";
|
|
16
|
+
import { resolveSite, type Site } from "./Site.ts";
|
|
17
|
+
|
|
18
|
+
/** The site's native (untagged) network. */
|
|
19
|
+
export interface NativeNetwork {
|
|
20
|
+
readonly type: "NATIVE";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CommonProps {
|
|
24
|
+
/** Site to create the broadcast in. @default the profile's site, else the console's only site */
|
|
25
|
+
site?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The network clients join: a network reference, or
|
|
28
|
+
* `WifiBroadcast.NATIVE_NETWORK` for the site's default network (a
|
|
29
|
+
* `Network` resource or lookup for the default network works too).
|
|
30
|
+
*/
|
|
31
|
+
network?: NetworkReference | NativeNetwork;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** WPA security on a standard broadcast must set `fastRoamingEnabled` (the console rejects it otherwise). */
|
|
35
|
+
type WithFastRoaming<S> = S extends { fastRoamingEnabled?: boolean } ? S & { fastRoamingEnabled: boolean } : S;
|
|
36
|
+
|
|
37
|
+
export type StandardWifiSecurityConfiguration = WithFastRoaming<Sdk.WifiSecurityConfigurationDetailObject>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An ordinary SSID. Pass passphrases as `Redacted` (e.g. from
|
|
41
|
+
* `Config.Redacted`) to keep them out of logs.
|
|
42
|
+
*/
|
|
43
|
+
export interface StandardWifiBroadcastProps
|
|
44
|
+
extends
|
|
45
|
+
Omit<Sdk.IntegrationStandardWifiBroadcastCreateUpdateDto, "network" | "securityConfiguration">,
|
|
46
|
+
CommonProps {
|
|
47
|
+
securityConfiguration: StandardWifiSecurityConfiguration;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An SSID tuned for IoT devices (2.4 GHz only, longer DTIM). */
|
|
51
|
+
export interface IotWifiBroadcastProps
|
|
52
|
+
extends Omit<Sdk.IntegrationIotOptimizedWifiBroadcastCreateUpdateDto, "network">, CommonProps {}
|
|
53
|
+
|
|
54
|
+
/** Props are the Integration API's Wi-Fi body, plus `site` and references; `type` picks the kind. */
|
|
55
|
+
export type WifiBroadcastProps = StandardWifiBroadcastProps | IotWifiBroadcastProps;
|
|
56
|
+
|
|
57
|
+
export interface WifiBroadcastAttributes {
|
|
58
|
+
siteId: string;
|
|
59
|
+
wifiBroadcastId: string;
|
|
60
|
+
name: string;
|
|
61
|
+
type: WifiBroadcastProps["type"];
|
|
62
|
+
/** The network clients join, when it isn't the native one. */
|
|
63
|
+
networkId: string | undefined;
|
|
64
|
+
/** The whole broadcast as the console reports it (passphrases `Redacted`), so `alchemy drift` sees any change. */
|
|
65
|
+
config: DistributiveOmit<Sdk.WifiBroadcastDetails, "id" | "metadata">;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface WifiBroadcast extends Resource<
|
|
69
|
+
"UnifiNetwork.WifiBroadcast",
|
|
70
|
+
WifiBroadcastProps,
|
|
71
|
+
WifiBroadcastAttributes,
|
|
72
|
+
never,
|
|
73
|
+
Providers
|
|
74
|
+
> {}
|
|
75
|
+
|
|
76
|
+
const loadBroadcasts = (siteId: string) =>
|
|
77
|
+
withConnection(Stream.runCollect(Sdk.getWifiBroadcastPage.items({ siteId })));
|
|
78
|
+
const broadcastsKey = ReadCache.key<
|
|
79
|
+
ReadonlyArray<Sdk.WifiBroadcastOverview>,
|
|
80
|
+
Effect.Error<ReturnType<typeof loadBroadcasts>>
|
|
81
|
+
>("wifiBroadcasts");
|
|
82
|
+
|
|
83
|
+
/** A site's Wi-Fi broadcasts, listed once per run. */
|
|
84
|
+
export const listWifiBroadcasts = (siteId: string) => ReadCache.cached(broadcastsKey, siteId, loadBroadcasts(siteId));
|
|
85
|
+
|
|
86
|
+
const toAttributes = (siteId: string, broadcast: Sdk.WifiBroadcastDetails): WifiBroadcastAttributes => {
|
|
87
|
+
const { id, metadata: _metadata, ...config } = broadcast;
|
|
88
|
+
return {
|
|
89
|
+
siteId,
|
|
90
|
+
wifiBroadcastId: id,
|
|
91
|
+
name: broadcast.name,
|
|
92
|
+
type: broadcast.type,
|
|
93
|
+
networkId: broadcast.network?.type === "SPECIFIC" ? broadcast.network.networkId : undefined,
|
|
94
|
+
config,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const byFrequency = (bands: ReadonlyArray<number>) => bands.toSorted((a, b) => a - b);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `body` with its bands sorted (their order means nothing; see
|
|
102
|
+
* {@link comparable}), and an explicit value for the one unset field that
|
|
103
|
+
* can't simply be left out (verified): a standard broadcast's
|
|
104
|
+
* `bandSteeringEnabled`, which the console reports as `false` when unset,
|
|
105
|
+
* so without the default the body would never match and every deploy would
|
|
106
|
+
* update it (and drift would report it) forever.
|
|
107
|
+
*/
|
|
108
|
+
const withDefaults = (body: Sdk.WifiBroadcastCreateOrUpdate): Sdk.WifiBroadcastCreateOrUpdate =>
|
|
109
|
+
body.type === "STANDARD"
|
|
110
|
+
? {
|
|
111
|
+
...body,
|
|
112
|
+
bandSteeringEnabled: body.bandSteeringEnabled ?? false,
|
|
113
|
+
broadcastingFrequenciesGHz: byFrequency(body.broadcastingFrequenciesGHz),
|
|
114
|
+
}
|
|
115
|
+
: body;
|
|
116
|
+
|
|
117
|
+
/** Fields the console reports but rejects in request bodies. */
|
|
118
|
+
const READ_ONLY = ["id", "metadata"];
|
|
119
|
+
|
|
120
|
+
const toBody = Effect.fn(function* (props: WifiBroadcastProps, site: Site) {
|
|
121
|
+
const { site: _site, network, ...rest } = props;
|
|
122
|
+
if (network === undefined) return rest satisfies Sdk.WifiBroadcastCreateOrUpdate;
|
|
123
|
+
// The console only accepts the default network as NATIVE; referencing it by ID is rejected.
|
|
124
|
+
if ("type" in network || network.default === true) {
|
|
125
|
+
return { ...rest, network: { type: "NATIVE" } } satisfies Sdk.WifiBroadcastCreateOrUpdate;
|
|
126
|
+
}
|
|
127
|
+
const networkId = yield* resolveNetwork(network, site);
|
|
128
|
+
return { ...rest, network: { type: "SPECIFIC", networkId } } satisfies Sdk.WifiBroadcastCreateOrUpdate;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The console's broadcast in body form, with its bands sorted: consoles keep
|
|
133
|
+
* them in an order of their own (not the one sent; the test controller
|
|
134
|
+
* sorts them), so order mustn't count as a difference.
|
|
135
|
+
*/
|
|
136
|
+
const comparable = (observed: Sdk.WifiBroadcastDetails) => {
|
|
137
|
+
const body = omitKeys(observed, READ_ONLY);
|
|
138
|
+
return observed.type === "STANDARD"
|
|
139
|
+
? { ...body, broadcastingFrequenciesGHz: byFrequency(observed.broadcastingFrequenciesGHz) }
|
|
140
|
+
: body;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const getBroadcast = (siteId: string, wifiBroadcastId: string) =>
|
|
144
|
+
withConnection(Sdk.getWifiBroadcastDetails({ siteId, wifiBroadcastId })).pipe(
|
|
145
|
+
Effect.catchTag("NotFound", () => Effect.undefined),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
/** The attributes of a broadcast known to exist (e.g. from a list). */
|
|
149
|
+
const attributesOf = (siteId: string, wifiBroadcastId: string) =>
|
|
150
|
+
withConnection(Sdk.getWifiBroadcastDetails({ siteId, wifiBroadcastId })).pipe(
|
|
151
|
+
Effect.map((broadcast) => toAttributes(siteId, broadcast)),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const NATIVE_NETWORK: NativeNetwork = { type: "NATIVE" };
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A Wi-Fi network (SSID). `type` picks a standard or an IoT-optimised
|
|
158
|
+
* broadcast; changing it, or the site, replaces the broadcast.
|
|
159
|
+
*
|
|
160
|
+
* Props are authoritative: every update sends the whole broadcast, with
|
|
161
|
+
* explicit defaults for unset fields (a standard broadcast's
|
|
162
|
+
* `bandSteeringEnabled: false`), so changes made in the UI, passphrases
|
|
163
|
+
* included, are put back. The `config` attribute mirrors the console's
|
|
164
|
+
* object, so `alchemy drift` sees them. Existing broadcasts are adopted by name with
|
|
165
|
+
* `--adopt`.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ```ts
|
|
169
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
170
|
+
* import * as Alchemy from "alchemy";
|
|
171
|
+
* import * as Config from "effect/Config";
|
|
172
|
+
* import * as Effect from "effect/Effect";
|
|
173
|
+
*
|
|
174
|
+
* export default Alchemy.Stack(
|
|
175
|
+
* "HomeNetwork",
|
|
176
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
177
|
+
* Effect.gen(function* () {
|
|
178
|
+
* const lan = yield* UnifiNetwork.Network.lookup({ vlanId: 1 });
|
|
179
|
+
* yield* UnifiNetwork.WifiBroadcast("home", {
|
|
180
|
+
* type: "STANDARD",
|
|
181
|
+
* name: "Home",
|
|
182
|
+
* enabled: true,
|
|
183
|
+
* network: lan,
|
|
184
|
+
* securityConfiguration: {
|
|
185
|
+
* type: "WPA2_PERSONAL",
|
|
186
|
+
* passphrase: yield* Config.Redacted("WIFI_PASSPHRASE"),
|
|
187
|
+
* fastRoamingEnabled: false,
|
|
188
|
+
* },
|
|
189
|
+
* broadcastingFrequenciesGHz: [2.4, 5],
|
|
190
|
+
* hideName: false,
|
|
191
|
+
* clientIsolationEnabled: false,
|
|
192
|
+
* multicastToUnicastConversionEnabled: false,
|
|
193
|
+
* uapsdEnabled: false,
|
|
194
|
+
* channel2gLockedTo6: false,
|
|
195
|
+
* dtimPeriod2gLockedTo3: false,
|
|
196
|
+
* advertiseDeviceName: false,
|
|
197
|
+
* arpProxyEnabled: false,
|
|
198
|
+
* bssTransitionEnabled: true,
|
|
199
|
+
* });
|
|
200
|
+
* }),
|
|
201
|
+
* );
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
export const WifiBroadcast = Resource<WifiBroadcast>("UnifiNetwork.WifiBroadcast")({
|
|
205
|
+
NATIVE_NETWORK,
|
|
206
|
+
/**
|
|
207
|
+
* A broadcast in the site by name. Dies with a user-facing
|
|
208
|
+
* `LookupNotFound` or `LookupAmbiguous`: stack bodies can't fail with typed
|
|
209
|
+
* errors.
|
|
210
|
+
*/
|
|
211
|
+
lookup: Effect.fn("UnifiNetwork.WifiBroadcast.lookup")(function* (query: {
|
|
212
|
+
readonly name: string;
|
|
213
|
+
readonly site?: string;
|
|
214
|
+
}) {
|
|
215
|
+
const site = yield* resolveSite(query.site);
|
|
216
|
+
const broadcasts = (yield* listWifiBroadcasts(site.id)).filter((b) => b.name === query.name);
|
|
217
|
+
const [broadcast] = broadcasts;
|
|
218
|
+
if (broadcast === undefined) {
|
|
219
|
+
return yield* new LookupNotFound({ resource: "Wi-Fi broadcast", query: describeQuery(query), site });
|
|
220
|
+
}
|
|
221
|
+
if (broadcasts.length > 1) {
|
|
222
|
+
return yield* new LookupAmbiguous({
|
|
223
|
+
resource: "Wi-Fi broadcast",
|
|
224
|
+
query: describeQuery(query),
|
|
225
|
+
site,
|
|
226
|
+
count: broadcasts.length,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return yield* attributesOf(site.id, broadcast.id);
|
|
230
|
+
}, Effect.orDie),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
export const WifiBroadcastProvider = () =>
|
|
234
|
+
Provider.effect(
|
|
235
|
+
WifiBroadcast,
|
|
236
|
+
Effect.gen(function* () {
|
|
237
|
+
const run = yield* captureServices;
|
|
238
|
+
|
|
239
|
+
return WifiBroadcast.Provider.of({
|
|
240
|
+
list: () =>
|
|
241
|
+
run(
|
|
242
|
+
Effect.gen(function* () {
|
|
243
|
+
const site = yield* resolveSite();
|
|
244
|
+
const broadcasts = yield* listWifiBroadcasts(site.id);
|
|
245
|
+
return yield* Effect.forEach(
|
|
246
|
+
broadcasts.filter((b) => b.metadata.origin === "USER_DEFINED"),
|
|
247
|
+
(b) => attributesOf(site.id, b.id),
|
|
248
|
+
);
|
|
249
|
+
}),
|
|
250
|
+
),
|
|
251
|
+
|
|
252
|
+
read: ({ olds, output }) =>
|
|
253
|
+
run(
|
|
254
|
+
Effect.gen(function* () {
|
|
255
|
+
if (output !== undefined) {
|
|
256
|
+
const broadcast = yield* getBroadcast(output.siteId, output.wifiBroadcastId);
|
|
257
|
+
return broadcast === undefined ? undefined : toAttributes(output.siteId, broadcast);
|
|
258
|
+
}
|
|
259
|
+
const site = yield* resolveSite(olds.site);
|
|
260
|
+
const matching = (yield* listWifiBroadcasts(site.id)).filter((b) => b.name === olds.name);
|
|
261
|
+
const [broadcast] = matching;
|
|
262
|
+
return broadcast === undefined || matching.length > 1
|
|
263
|
+
? undefined
|
|
264
|
+
: Unowned(yield* attributesOf(site.id, broadcast.id));
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
|
|
268
|
+
diff: ({ news, output }) =>
|
|
269
|
+
run(
|
|
270
|
+
Effect.gen(function* () {
|
|
271
|
+
if (output === undefined) return undefined;
|
|
272
|
+
const type = planned(news, "type");
|
|
273
|
+
if (type !== UNKNOWN && type !== output.type) return { action: "replace" } satisfies Diff;
|
|
274
|
+
const siteProp = plannedString(news, "site");
|
|
275
|
+
if (siteProp === UNKNOWN) return undefined;
|
|
276
|
+
const site = yield* resolveSite(siteProp);
|
|
277
|
+
if (site.id !== output.siteId) return { action: "replace" } satisfies Diff;
|
|
278
|
+
return undefined;
|
|
279
|
+
}),
|
|
280
|
+
),
|
|
281
|
+
|
|
282
|
+
reconcile: ({ news, output }) =>
|
|
283
|
+
run(
|
|
284
|
+
Effect.gen(function* () {
|
|
285
|
+
const site = yield* resolveSite(news.site);
|
|
286
|
+
const body = withDefaults(yield* toBody(news, site));
|
|
287
|
+
const observed =
|
|
288
|
+
output !== undefined && output.siteId === site.id
|
|
289
|
+
? yield* getBroadcast(site.id, output.wifiBroadcastId)
|
|
290
|
+
: undefined;
|
|
291
|
+
const write = ReadCache.invalidating(broadcastsKey, site.id);
|
|
292
|
+
if (observed === undefined) {
|
|
293
|
+
const created = yield* withConnection(
|
|
294
|
+
Sdk.createWifiBroadcast({ siteId: site.id, body }),
|
|
295
|
+
).pipe(write);
|
|
296
|
+
return toAttributes(site.id, created);
|
|
297
|
+
}
|
|
298
|
+
if (isCurrent(body, comparable(observed))) return toAttributes(site.id, observed);
|
|
299
|
+
const updated = yield* withConnection(
|
|
300
|
+
Sdk.updateWifiBroadcast({ siteId: site.id, wifiBroadcastId: observed.id, body }),
|
|
301
|
+
).pipe(write);
|
|
302
|
+
return toAttributes(site.id, updated);
|
|
303
|
+
}),
|
|
304
|
+
),
|
|
305
|
+
|
|
306
|
+
delete: ({ output }) =>
|
|
307
|
+
run(
|
|
308
|
+
withConnection(
|
|
309
|
+
Sdk.deleteWifiBroadcast({ siteId: output.siteId, wifiBroadcastId: output.wifiBroadcastId }),
|
|
310
|
+
).pipe(
|
|
311
|
+
Effect.asVoid,
|
|
312
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
313
|
+
ReadCache.invalidating(broadcastsKey, output.siteId),
|
|
314
|
+
),
|
|
315
|
+
),
|
|
316
|
+
});
|
|
317
|
+
}),
|
|
318
|
+
);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from "./AuthConfig.ts";
|
|
2
|
+
export { UnifiNetworkAuth } from "./AuthProvider.ts";
|
|
3
|
+
export { type Established, UnifiNetworkConnection, withConnection } from "./Connection.ts";
|
|
4
|
+
export * from "./Credentials.ts";
|
|
5
|
+
export { Providers, providers } from "./Providers.ts";
|
|
6
|
+
export * as ReadCache from "./ReadCache.ts";
|
|
7
|
+
export { UnifiNetworkReadCache } from "./ReadCache.ts";
|
|
8
|
+
export * from "./Site.ts";
|
|
9
|
+
export { DEFAULT_WARN_AT, state, type UnifiStateOptions } from "./State.ts";
|
|
10
|
+
export * from "./DnsPolicy.ts";
|
|
11
|
+
export * from "./FirewallPolicy.ts";
|
|
12
|
+
export * from "./FirewallPolicyOrdering.ts";
|
|
13
|
+
export * from "./FirewallZone.ts";
|
|
14
|
+
export * from "./Lookup.ts";
|
|
15
|
+
export * from "./Network.ts";
|
|
16
|
+
export * from "./TrafficMatchingList.ts";
|
|
17
|
+
export * from "./WifiBroadcast.ts";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { deepEqual } from "alchemy/Diff";
|
|
2
|
+
import * as Redacted from "effect/Redacted";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Comparison of request bodies with what the console reports. Props are
|
|
6
|
+
* authoritative: a resource's desired body is its props with explicit
|
|
7
|
+
* defaults for anything unset, sent whole on every update, and the console's
|
|
8
|
+
* object must equal it field for field.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** `Omit` applied to each member of a union, keeping the discriminant. */
|
|
12
|
+
export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
13
|
+
|
|
14
|
+
/** `value` with every `Redacted` unwrapped, recursively, so passphrases compare by contents. */
|
|
15
|
+
const unwrap = (value: unknown): unknown => {
|
|
16
|
+
if (Redacted.isRedacted(value)) return Redacted.value(value);
|
|
17
|
+
if (Array.isArray(value)) return value.map(unwrap);
|
|
18
|
+
if (typeof value === "object" && value !== null) {
|
|
19
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, unwrap(nested)]));
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the console's object is exactly the desired body: same fields,
|
|
26
|
+
* same values, recursively (key order and `Redacted` wrappers aside; unset
|
|
27
|
+
* fields must be absent on both sides). Leave read-only fields out of
|
|
28
|
+
* `observed` first.
|
|
29
|
+
*/
|
|
30
|
+
export const isCurrent = (desired: unknown, observed: unknown) => deepEqual(unwrap(desired), unwrap(observed));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `value` with every array sorted, recursively, for fields whose arrays are
|
|
34
|
+
* sets: the console keeps them in an order of its own (e.g. a firewall
|
|
35
|
+
* policy's port list), so order mustn't count as a difference.
|
|
36
|
+
*/
|
|
37
|
+
export const sortArrays = (value: unknown): unknown => {
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return value.map(sortArrays).toSorted((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === "object" && value !== null && !Redacted.isRedacted(value)) {
|
|
42
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, sortArrays(nested)]));
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** `record` without `keys`, e.g. read-only fields the console rejects in request bodies. */
|
|
48
|
+
export const omitKeys = (record: object, keys: ReadonlyArray<string>): Record<string, unknown> =>
|
|
49
|
+
Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key)));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as Sdk from "@alchemy-unifi/network-sdk";
|
|
2
|
+
import * as Stream from "effect/Stream";
|
|
3
|
+
import { withConnection } from "../Connection.ts";
|
|
4
|
+
|
|
5
|
+
/** A site's networks, uncached. */
|
|
6
|
+
export const loadNetworks = (siteId: string) =>
|
|
7
|
+
withConnection(Stream.runCollect(Sdk.getNetworksOverviewPage.items({ siteId })));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isResolved } from "alchemy/Diff";
|
|
2
|
+
import * as Predicate from "effect/Predicate";
|
|
3
|
+
|
|
4
|
+
/** A prop whose value depends on another resource's output, so isn't known while planning. */
|
|
5
|
+
export const UNKNOWN: unique symbol = Symbol("UnifiNetwork.unknown");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A top-level prop of `diff`'s `news` (props that may still hold outputs of
|
|
9
|
+
* resources not yet deployed): its value, `undefined` when unset, or
|
|
10
|
+
* {@link UNKNOWN}.
|
|
11
|
+
*/
|
|
12
|
+
export const planned = (news: unknown, key: string): unknown => {
|
|
13
|
+
if (typeof news !== "object" || news === null) return UNKNOWN;
|
|
14
|
+
const value = Predicate.hasProperty(news, key) ? news[key] : undefined;
|
|
15
|
+
return isResolved(value) ? value : UNKNOWN;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** {@link planned} for string props such as `site`. */
|
|
19
|
+
export const plannedString = (news: unknown, key: string): string | undefined | typeof UNKNOWN => {
|
|
20
|
+
const value = planned(news, key);
|
|
21
|
+
return value === UNKNOWN || value === undefined || typeof value === "string" ? value : UNKNOWN;
|
|
22
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import type { UnifiNetworkConnection } from "../Connection.ts";
|
|
3
|
+
import type { UnifiNetworkCredentials } from "../Credentials.ts";
|
|
4
|
+
import type { UnifiNetworkReadCache } from "../ReadCache.ts";
|
|
5
|
+
|
|
6
|
+
/** What resource lifecycle handlers and lookups need. `providers()` provides all of them. */
|
|
7
|
+
export type Services = UnifiNetworkCredentials | UnifiNetworkConnection | UnifiNetworkReadCache;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The provider's services, captured when the provider is built, as a
|
|
11
|
+
* function that runs a handler with them. Handlers then don't depend on
|
|
12
|
+
* what the stack's merged context happens to hold when they run.
|
|
13
|
+
*/
|
|
14
|
+
export const captureServices = Effect.map(
|
|
15
|
+
Effect.context<Services>(),
|
|
16
|
+
(services) =>
|
|
17
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, Exclude<R, Services>> =>
|
|
18
|
+
Effect.provide(effect, services),
|
|
19
|
+
);
|