@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
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2372 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.mjs";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import * as Sdk from "@alchemy-unifi/network-sdk";
|
|
4
|
+
import { ConfigError } from "@alchemy-unifi/network-sdk";
|
|
5
|
+
import { AuthError, AuthProviderLayer } from "alchemy/Auth/AuthProvider";
|
|
6
|
+
import { displayRedacted } from "alchemy/Auth/Credentials";
|
|
7
|
+
import { getEnv, getEnvRedactedRequired, mapPromptCancellation } from "alchemy/Auth/Env";
|
|
8
|
+
import { storedSecret, storedValueText, validateFieldValues } from "alchemy/Auth/StoredAuthProvider";
|
|
9
|
+
import * as Interaction from "alchemy/Interaction";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
12
|
+
import * as Redacted from "effect/Redacted";
|
|
13
|
+
import * as Context from "effect/Context";
|
|
14
|
+
import * as Layer from "effect/Layer";
|
|
15
|
+
import * as Predicate from "effect/Predicate";
|
|
16
|
+
import * as Schedule from "effect/Schedule";
|
|
17
|
+
import * as RateLimiter from "effect/persistence/RateLimiter";
|
|
18
|
+
import * as Scope from "effect/Scope";
|
|
19
|
+
import { deferUntilFirstUse, orDieCredentialsUnavailable, resolveProviderConfig } from "alchemy/Auth/Resolve";
|
|
20
|
+
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
|
|
21
|
+
import { buildConnector } from "@effect/platform-node/Undici";
|
|
22
|
+
import * as Data from "effect/Data";
|
|
23
|
+
import * as net from "node:net";
|
|
24
|
+
import * as tls from "node:tls";
|
|
25
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
26
|
+
import * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
27
|
+
import * as Provider from "alchemy/Provider";
|
|
28
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
29
|
+
import { Resource } from "alchemy/Resource";
|
|
30
|
+
import * as Stream from "effect/Stream";
|
|
31
|
+
import { deepEqual, isResolved } from "alchemy/Diff";
|
|
32
|
+
import * as Deferred from "effect/Deferred";
|
|
33
|
+
import * as Exit from "effect/Exit";
|
|
34
|
+
import { UserFacingError } from "alchemy/UserFacingError";
|
|
35
|
+
import { STATE_STORE_VERSION } from "alchemy/State/HttpStateApi";
|
|
36
|
+
import { State, StateStoreError } from "alchemy/State/State";
|
|
37
|
+
import { encodeState, reviveState } from "alchemy/State/StateEncoding";
|
|
38
|
+
import { recordStateStoreInit } from "alchemy/Telemetry/Metrics";
|
|
39
|
+
import * as Clock from "effect/Clock";
|
|
40
|
+
import * as LogLevel from "effect/LogLevel";
|
|
41
|
+
import * as References from "effect/References";
|
|
42
|
+
import * as Semaphore from "effect/Semaphore";
|
|
43
|
+
import * as zlib from "node:zlib";
|
|
44
|
+
// src/AuthConfig.ts
|
|
45
|
+
const UNIFI_NETWORK_AUTH_PROVIDER_NAME = "UnifiNetwork";
|
|
46
|
+
/** Values stored in a profile for the `UnifiNetwork` auth provider. */
|
|
47
|
+
const UnifiNetworkAuthConfigSchema = Schema.Union([Schema.Struct({
|
|
48
|
+
method: Schema.Literal("local"),
|
|
49
|
+
url: Schema.String,
|
|
50
|
+
apiKey: Schema.String,
|
|
51
|
+
certFingerprint: Schema.optional(Schema.String),
|
|
52
|
+
insecure: Schema.optional(Schema.Boolean),
|
|
53
|
+
site: Schema.optional(Schema.String)
|
|
54
|
+
}), Schema.Struct({
|
|
55
|
+
method: Schema.Literal("remote"),
|
|
56
|
+
consoleId: Schema.String,
|
|
57
|
+
apiKey: Schema.String,
|
|
58
|
+
site: Schema.optional(Schema.String)
|
|
59
|
+
})]);
|
|
60
|
+
// src/Credentials.ts
|
|
61
|
+
/**
|
|
62
|
+
* Lazily-resolved credentials for the active profile (or the environment).
|
|
63
|
+
* The service value is an Effect so nothing resolves at Layer construction;
|
|
64
|
+
* handlers run `yield* yield* UnifiNetworkCredentials`.
|
|
65
|
+
*/
|
|
66
|
+
var UnifiNetworkCredentials = class extends Context.Service()("UnifiNetwork::Credentials") {};
|
|
67
|
+
/**
|
|
68
|
+
* Resolves {@link UnifiNetworkCredentials} through the `UnifiNetwork` auth
|
|
69
|
+
* provider: environment variables when they're all set, otherwise the
|
|
70
|
+
* selected Alchemy profile. Resolution is deferred to first use and cached.
|
|
71
|
+
*/
|
|
72
|
+
const fromAuthProvider = () => Layer.effect(UnifiNetworkCredentials, Effect.gen(function* () {
|
|
73
|
+
return yield* (yield* resolveProviderConfig(UNIFI_NETWORK_AUTH_PROVIDER_NAME).pipe(Effect.flatMap(({ profileName, resolve }) => resolve.pipe(Effect.mapError((e) => ConfigError.make({ message: `Failed to resolve UniFi Network credentials from ${profileName === void 0 ? "the environment" : `profile '${profileName}'`}: ${e.message}` })))), deferUntilFirstUse)).pipe(orDieCredentialsUnavailable(UNIFI_NETWORK_AUTH_PROVIDER_NAME), Effect.cached);
|
|
74
|
+
}));
|
|
75
|
+
// src/HttpClient.ts
|
|
76
|
+
/** Normalises a SHA-256 fingerprint to Node's `AB:CD:…` form, or returns `undefined` if it isn't one. */
|
|
77
|
+
const normaliseFingerprint = (value) => {
|
|
78
|
+
const hex = value.replace(/[\s:]/g, "").toUpperCase();
|
|
79
|
+
return /^[0-9A-F]{64}$/.test(hex) ? (hex.match(/../g) ?? []).join(":") : void 0;
|
|
80
|
+
};
|
|
81
|
+
/** SHA-256 fingerprints of a peer's certificate chain, leaf first. */
|
|
82
|
+
const chainFingerprints = (socket) => {
|
|
83
|
+
const fingerprints = [];
|
|
84
|
+
let cert = socket.getPeerCertificate(true);
|
|
85
|
+
while (cert?.fingerprint256 !== void 0 && !fingerprints.includes(cert.fingerprint256)) {
|
|
86
|
+
fingerprints.push(cert.fingerprint256);
|
|
87
|
+
cert = cert.issuerCertificate;
|
|
88
|
+
}
|
|
89
|
+
return fingerprints;
|
|
90
|
+
};
|
|
91
|
+
const PINNING_ERROR_CODE = "ERR_UNIFI_CERT_PINNING";
|
|
92
|
+
/**
|
|
93
|
+
* Whether an error (or one of its causes) is a certificate verification or
|
|
94
|
+
* pinning failure. These are deterministic, so they aren't worth retrying.
|
|
95
|
+
*/
|
|
96
|
+
const isCertificateError = (error) => {
|
|
97
|
+
for (let e = error, depth = 0; typeof e === "object" && e !== null && depth < 5; depth++) {
|
|
98
|
+
const code = "code" in e ? String(e.code) : "";
|
|
99
|
+
if (code === PINNING_ERROR_CODE || /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(code)) return true;
|
|
100
|
+
e = "cause" in e ? e.cause : "reason" in e ? e.reason : void 0;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Pinning can't use `checkServerIdentity`: Node skips it when the chain
|
|
106
|
+
* doesn't verify, which is always the case for a console's self-signed
|
|
107
|
+
* certificate. So verification is off, and the connector checks the
|
|
108
|
+
* presented chain itself once the handshake completes. Session resumption
|
|
109
|
+
* is off too, because a resumed session presents no certificate to check.
|
|
110
|
+
*/
|
|
111
|
+
const pinnedConnector = (fingerprint) => {
|
|
112
|
+
const connect = buildConnector({
|
|
113
|
+
rejectUnauthorized: false,
|
|
114
|
+
maxCachedSessions: 0
|
|
115
|
+
});
|
|
116
|
+
return (options, callback) => connect(options, (error, socket) => {
|
|
117
|
+
if (error !== null) return callback(error, null);
|
|
118
|
+
if (socket instanceof tls.TLSSocket && !chainFingerprints(socket).includes(fingerprint)) {
|
|
119
|
+
socket.destroy();
|
|
120
|
+
return callback(Object.assign(/* @__PURE__ */ new Error(`The certificate presented by ${options.hostname} doesn't match the pinned fingerprint ${fingerprint}.`), { code: PINNING_ERROR_CODE }), null);
|
|
121
|
+
}
|
|
122
|
+
callback(null, socket);
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
/** An undici-backed `HttpClient` applying {@link TlsOptions}. The dispatcher closes with the scope. */
|
|
126
|
+
const make = (options = {}) => NodeHttpClient.makeDispatcherOptions(options.certFingerprint !== void 0 ? { connect: pinnedConnector(options.certFingerprint) } : options.insecure ? { connect: { rejectUnauthorized: false } } : void 0).pipe(Effect.flatMap((dispatcher) => NodeHttpClient.makeUndici.pipe(Effect.provideService(NodeHttpClient.Dispatcher, dispatcher))));
|
|
127
|
+
var CertificateInspectionError = class extends Data.TaggedError("CertificateInspectionError") {};
|
|
128
|
+
/**
|
|
129
|
+
* Connects to an `https:` URL without verifying it and reports the
|
|
130
|
+
* certificate it presents, so `configure` can offer to pin it.
|
|
131
|
+
*/
|
|
132
|
+
const inspectCertificate = (url) => Effect.callback((resume) => {
|
|
133
|
+
const { hostname, port } = new URL(url);
|
|
134
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
135
|
+
const socket = tls.connect({
|
|
136
|
+
host,
|
|
137
|
+
port: Number(port || 443),
|
|
138
|
+
rejectUnauthorized: false,
|
|
139
|
+
...net.isIP(host) === 0 ? { servername: host } : {}
|
|
140
|
+
});
|
|
141
|
+
socket.setTimeout(1e4, () => socket.destroy(/* @__PURE__ */ new Error(`Timed out connecting to ${url}.`)));
|
|
142
|
+
socket.once("secureConnect", () => {
|
|
143
|
+
const cert = socket.getPeerCertificate();
|
|
144
|
+
resume(Effect.succeed({
|
|
145
|
+
trusted: socket.authorized,
|
|
146
|
+
fingerprint: cert.fingerprint256,
|
|
147
|
+
subject: String(cert.subject?.CN ?? cert.subjectaltname ?? "unknown"),
|
|
148
|
+
validTo: cert.valid_to
|
|
149
|
+
}));
|
|
150
|
+
socket.end();
|
|
151
|
+
});
|
|
152
|
+
socket.once("error", (cause) => resume(Effect.fail(new CertificateInspectionError({
|
|
153
|
+
message: cause.message,
|
|
154
|
+
cause
|
|
155
|
+
}))));
|
|
156
|
+
return Effect.sync(() => socket.destroy());
|
|
157
|
+
});
|
|
158
|
+
// src/Connection.ts
|
|
159
|
+
const SITE_MANAGER_URL = "https://api.ui.com";
|
|
160
|
+
/** Integration API base URL through the cloud connector. */
|
|
161
|
+
const connectorBaseUrl = (consoleId) => `${SITE_MANAGER_URL}/v1/connector/consoles/${encodeURIComponent(consoleId)}/proxy/network/integration`;
|
|
162
|
+
/** Integration API base URLs to try for a connection: a UniFi OS console's, then a standalone controller's. */
|
|
163
|
+
const candidateBaseUrls = (connection) => connection.type === "remote" ? [connectorBaseUrl(connection.consoleId)] : [`${connection.url}/proxy/network/integration`, `${connection.url}/integration`];
|
|
164
|
+
/** The SDK's default retry policy, minus certificate failures, which retrying won't fix. */
|
|
165
|
+
const probeRetry = (lastError) => {
|
|
166
|
+
const defaults = Sdk.Retry.makeDefault(lastError);
|
|
167
|
+
return {
|
|
168
|
+
...defaults,
|
|
169
|
+
while: (error) => (defaults.while?.(error) ?? false) && !isCertificateError(error)
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
/** The connector allows 100 requests/minute per console; stay under it. */
|
|
173
|
+
const REMOTE_REQUESTS_PER_MINUTE = 90;
|
|
174
|
+
/** Joins an error's message with its causes' (e.g. a transport error and the TLS failure behind it). */
|
|
175
|
+
const describeError = (error) => {
|
|
176
|
+
const messages = [];
|
|
177
|
+
for (let e = error, depth = 0; e !== void 0 && e !== null && depth < 5; depth++) {
|
|
178
|
+
const message = typeof e === "object" && "message" in e ? String(e.message) : String(e);
|
|
179
|
+
if (message !== "" && !messages.some((m) => m.includes(message))) messages.push(message);
|
|
180
|
+
e = typeof e === "object" && "cause" in e ? e.cause : void 0;
|
|
181
|
+
}
|
|
182
|
+
return messages.join(": ") || "unknown error";
|
|
183
|
+
};
|
|
184
|
+
const describeTarget = (connection) => connection.type === "remote" ? `console ${connection.consoleId} via ${SITE_MANAGER_URL}` : connection.url;
|
|
185
|
+
const rateLimited = (client, key) => Effect.gen(function* () {
|
|
186
|
+
const limiter = yield* RateLimiter.make.pipe(Effect.provide(RateLimiter.layerStoreMemory));
|
|
187
|
+
const wait = RateLimiter.sleep(limiter, {
|
|
188
|
+
algorithm: "token-bucket",
|
|
189
|
+
window: "1 minute",
|
|
190
|
+
limit: REMOTE_REQUESTS_PER_MINUTE,
|
|
191
|
+
key
|
|
192
|
+
}).pipe(Effect.orDie);
|
|
193
|
+
return HttpClient.transform(client, (response) => Effect.andThen(wait, response));
|
|
194
|
+
});
|
|
195
|
+
/**
|
|
196
|
+
* Builds a client for the connection and finds the Integration API: local
|
|
197
|
+
* connections try the console path, then the standalone controller path.
|
|
198
|
+
* Fails with a `ConfigError` describing what went wrong. `options.client`
|
|
199
|
+
* replaces the TLS-configured client (for tests).
|
|
200
|
+
*/
|
|
201
|
+
const connect = (connection, options = {}) => Effect.gen(function* () {
|
|
202
|
+
const base = options.client ?? (yield* make(connection.type === "local" ? connection : {}));
|
|
203
|
+
const client = connection.type === "remote" ? yield* rateLimited(base, `connector:${connection.consoleId}`) : base;
|
|
204
|
+
const servicesFor = (baseUrl) => Context.make(Sdk.Credentials, Effect.succeed({
|
|
205
|
+
baseUrl,
|
|
206
|
+
apiKey: connection.apiKey
|
|
207
|
+
})).pipe(Context.add(HttpClient.HttpClient, client));
|
|
208
|
+
const candidates = candidateBaseUrls(connection);
|
|
209
|
+
for (const baseUrl of candidates) {
|
|
210
|
+
const services = servicesFor(baseUrl);
|
|
211
|
+
const info = yield* Sdk.getInfo({}).pipe(Effect.provide(services), Sdk.Retry.policy(probeRetry), Effect.map(({ applicationVersion }) => ({
|
|
212
|
+
baseUrl,
|
|
213
|
+
applicationVersion,
|
|
214
|
+
services
|
|
215
|
+
})), Effect.catchTag("NotFound", () => Effect.undefined), Effect.mapError((error) => Sdk.ConfigError.make({ message: error._tag === "Unauthorized" || error._tag === "Forbidden" ? `${describeTarget(connection)} rejected the API key (${error._tag}).` : `Could not connect to ${describeTarget(connection)}: ${describeError(error)}` })));
|
|
216
|
+
if (info !== void 0) return info;
|
|
217
|
+
}
|
|
218
|
+
return yield* Sdk.ConfigError.make({ message: connection.type === "remote" ? `The cloud connector found no UniFi Network application on console ${connection.consoleId}.` : `No UniFi Network Integration API at ${connection.url} (tried ${candidates.join(", ")}). Use the console's root URL, e.g. https://192.168.1.1.` });
|
|
219
|
+
});
|
|
220
|
+
const majorMinor = (version) => version.split(".").slice(0, 2).join(".");
|
|
221
|
+
/** Patch releases routinely ship without a published spec, so only major.minor drift is worth a warning. */
|
|
222
|
+
const checkVersion = (applicationVersion) => majorMinor(applicationVersion) === majorMinor(Sdk.SPEC_VERSION) ? Effect.void : Effect.logWarning(`UniFi Network ${applicationVersion} is newer or older than the API spec this provider was built from (${Sdk.SPEC_VERSION}). Resources may not behave as documented.`);
|
|
223
|
+
/**
|
|
224
|
+
* The profile's established connection, created on first use and shared for
|
|
225
|
+
* the rest of the run. Provider-private: its `HttpClient` and SDK
|
|
226
|
+
* `Credentials` never enter the stack's context, so they can't replace other
|
|
227
|
+
* providers' clients.
|
|
228
|
+
*/
|
|
229
|
+
var UnifiNetworkConnection = class extends Context.Service()("UnifiNetwork::Connection") {};
|
|
230
|
+
const layer$1 = Layer.effect(UnifiNetworkConnection, Effect.gen(function* () {
|
|
231
|
+
const credentials = yield* UnifiNetworkCredentials;
|
|
232
|
+
const scope = yield* Effect.scope;
|
|
233
|
+
return yield* Effect.cached(credentials.pipe(Effect.flatMap(({ connection }) => connect(connection)), Effect.tap(({ applicationVersion }) => checkVersion(applicationVersion)), Scope.provide(scope)));
|
|
234
|
+
}));
|
|
235
|
+
/**
|
|
236
|
+
* The 422 "Configured network does not exist" the console answers requests
|
|
237
|
+
* with, on any endpoint, for a moment while networks are created or deleted
|
|
238
|
+
* anywhere on it (seen on the test controller under parallel tests, for
|
|
239
|
+
* network, zone and policy requests alike). It's never a real error, so it's
|
|
240
|
+
* retried; other 422s are.
|
|
241
|
+
*/
|
|
242
|
+
const isStaleNetworkState = (error) => Predicate.hasProperty(error, "_tag") && error._tag === "UnprocessableEntity" && Predicate.hasProperty(error, "message") && typeof error.message === "string" && error.message.includes("Configured network does not exist");
|
|
243
|
+
/** Runs SDK operations against the profile's console, retrying the console's transient stale-network 422. */
|
|
244
|
+
const withConnection = (effect) => Effect.gen(function* () {
|
|
245
|
+
const { services } = yield* yield* UnifiNetworkConnection;
|
|
246
|
+
return yield* Effect.provide(effect, services).pipe(Effect.retry({
|
|
247
|
+
while: isStaleNetworkState,
|
|
248
|
+
schedule: Schedule.spaced("500 millis").pipe(Schedule.upTo({ times: 10 }))
|
|
249
|
+
}));
|
|
250
|
+
});
|
|
251
|
+
// src/SiteManager.ts
|
|
252
|
+
/**
|
|
253
|
+
* Just enough of the Site Manager API (`GET /v1/hosts`) for `configure` to
|
|
254
|
+
* list the consoles a key can reach. A full SDK belongs in its own package.
|
|
255
|
+
*/
|
|
256
|
+
const Host = Schema.Struct({
|
|
257
|
+
id: Schema.String,
|
|
258
|
+
type: Schema.optional(Schema.NullOr(Schema.String)),
|
|
259
|
+
reportedState: Schema.optional(Schema.NullOr(Schema.Struct({
|
|
260
|
+
name: Schema.optional(Schema.NullOr(Schema.String)),
|
|
261
|
+
hostname: Schema.optional(Schema.NullOr(Schema.String))
|
|
262
|
+
})))
|
|
263
|
+
});
|
|
264
|
+
const HostsPage = Schema.Struct({
|
|
265
|
+
data: Schema.Array(Host),
|
|
266
|
+
nextToken: Schema.optional(Schema.NullOr(Schema.String))
|
|
267
|
+
});
|
|
268
|
+
const listConsoles = (apiKey) => Effect.gen(function* () {
|
|
269
|
+
const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
|
|
270
|
+
const consoles = [];
|
|
271
|
+
let nextToken;
|
|
272
|
+
do {
|
|
273
|
+
const page = yield* client.execute(HttpClientRequest.get(`${SITE_MANAGER_URL}/v1/hosts`).pipe(HttpClientRequest.setHeaders({
|
|
274
|
+
"X-API-KEY": Redacted.value(apiKey),
|
|
275
|
+
Accept: "application/json"
|
|
276
|
+
}), HttpClientRequest.setUrlParams(nextToken === void 0 ? {} : { nextToken }))).pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(HostsPage)));
|
|
277
|
+
for (const host of page.data) consoles.push({
|
|
278
|
+
id: host.id,
|
|
279
|
+
name: host.reportedState?.name ?? host.reportedState?.hostname ?? host.id,
|
|
280
|
+
type: host.type ?? void 0
|
|
281
|
+
});
|
|
282
|
+
nextToken = page.nextToken ?? void 0;
|
|
283
|
+
} while (nextToken !== void 0);
|
|
284
|
+
return consoles;
|
|
285
|
+
});
|
|
286
|
+
// src/AuthProvider.ts
|
|
287
|
+
const NAME = UNIFI_NETWORK_AUTH_PROVIDER_NAME;
|
|
288
|
+
const authError = (message, cause) => AuthError.make({
|
|
289
|
+
message: `${NAME}: ${message}`,
|
|
290
|
+
...cause === void 0 ? {} : { cause }
|
|
291
|
+
});
|
|
292
|
+
/** Normalises a console URL (no trailing slash), or returns `undefined` if it isn't `http(s):`. */
|
|
293
|
+
const parseConsoleUrl = (value) => {
|
|
294
|
+
try {
|
|
295
|
+
const url = new URL(value.trim());
|
|
296
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
297
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
const parseBoolean = (value) => /^(true|1|yes)$/i.test(value) ? true : /^(false|0|no|)$/i.test(value) ? false : void 0;
|
|
303
|
+
const validateUrl = (value) => parseConsoleUrl(value) === void 0 ? "Expected an http(s) URL such as https://192.168.1.1" : void 0;
|
|
304
|
+
const validateFingerprint = (value) => normaliseFingerprint(value) === void 0 ? "Expected a SHA-256 fingerprint: 64 hex digits, optionally colon-separated" : void 0;
|
|
305
|
+
const validateBoolean = (value) => parseBoolean(value) === void 0 ? "Expected true or false" : void 0;
|
|
306
|
+
const required = (value) => value.length === 0 ? "Required" : void 0;
|
|
307
|
+
/** Validates stored, entered or environment values. `source` names them in errors. */
|
|
308
|
+
const resolve = (input, source) => Effect.gen(function* () {
|
|
309
|
+
const site = input.site || void 0;
|
|
310
|
+
if (input.method === "remote") {
|
|
311
|
+
if (!input.consoleId) return yield* authError(`no console ID (${source}).`);
|
|
312
|
+
return {
|
|
313
|
+
connection: {
|
|
314
|
+
type: "remote",
|
|
315
|
+
consoleId: input.consoleId,
|
|
316
|
+
apiKey: input.apiKey
|
|
317
|
+
},
|
|
318
|
+
site
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const url = parseConsoleUrl(input.url ?? "");
|
|
322
|
+
if (url === void 0) return yield* authError(`'${input.url ?? ""}' is not an http(s) URL (${source}).`);
|
|
323
|
+
const certFingerprint = input.certFingerprint === void 0 ? void 0 : normaliseFingerprint(input.certFingerprint);
|
|
324
|
+
if (input.certFingerprint !== void 0 && certFingerprint === void 0) return yield* authError(`'${input.certFingerprint}' is not a SHA-256 fingerprint (${source}).`);
|
|
325
|
+
if (certFingerprint !== void 0 && input.insecure) return yield* authError(`set a certificate fingerprint or insecure, not both (${source}).`);
|
|
326
|
+
if (certFingerprint !== void 0 && url.startsWith("http:")) return yield* authError(`a certificate fingerprint needs an https: URL (${source}).`);
|
|
327
|
+
return {
|
|
328
|
+
connection: {
|
|
329
|
+
type: "local",
|
|
330
|
+
url,
|
|
331
|
+
apiKey: input.apiKey,
|
|
332
|
+
certFingerprint,
|
|
333
|
+
insecure: input.insecure ?? false
|
|
334
|
+
},
|
|
335
|
+
site
|
|
336
|
+
};
|
|
337
|
+
});
|
|
338
|
+
const fromConfig = (profileName, config) => resolve({
|
|
339
|
+
...config,
|
|
340
|
+
apiKey: Redacted.make(config.apiKey)
|
|
341
|
+
}, `profile '${profileName}'`).pipe(Effect.map((settings) => ({
|
|
342
|
+
...settings,
|
|
343
|
+
source: {
|
|
344
|
+
type: "profile",
|
|
345
|
+
profile: profileName
|
|
346
|
+
}
|
|
347
|
+
})));
|
|
348
|
+
const toConfig = ({ connection, site }) => connection.type === "remote" ? {
|
|
349
|
+
method: "remote",
|
|
350
|
+
consoleId: connection.consoleId,
|
|
351
|
+
apiKey: Redacted.value(connection.apiKey),
|
|
352
|
+
...site === void 0 ? {} : { site }
|
|
353
|
+
} : {
|
|
354
|
+
method: "local",
|
|
355
|
+
url: connection.url,
|
|
356
|
+
apiKey: Redacted.value(connection.apiKey),
|
|
357
|
+
...connection.certFingerprint === void 0 ? {} : { certFingerprint: connection.certFingerprint },
|
|
358
|
+
...connection.insecure ? { insecure: true } : {},
|
|
359
|
+
...site === void 0 ? {} : { site }
|
|
360
|
+
};
|
|
361
|
+
const siteField = {
|
|
362
|
+
name: "site",
|
|
363
|
+
label: "Default site",
|
|
364
|
+
description: "Only for consoles hosting several sites: internal reference (e.g. default) or name of the site resources use when they don't set one.",
|
|
365
|
+
optional: true
|
|
366
|
+
};
|
|
367
|
+
const configureMethods = [{
|
|
368
|
+
method: "local",
|
|
369
|
+
fields: [
|
|
370
|
+
{
|
|
371
|
+
name: "url",
|
|
372
|
+
label: "Console URL",
|
|
373
|
+
placeholder: "https://192.168.1.1",
|
|
374
|
+
validate: validateUrl
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: "apiKey",
|
|
378
|
+
label: "API key",
|
|
379
|
+
secret: true
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: "certFingerprint",
|
|
383
|
+
label: "Certificate SHA-256 fingerprint",
|
|
384
|
+
description: "Pin the console's certificate (or a CA in its chain) instead of verifying it.",
|
|
385
|
+
optional: true,
|
|
386
|
+
validate: validateFingerprint
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "insecure",
|
|
390
|
+
label: "Skip TLS verification",
|
|
391
|
+
optional: true,
|
|
392
|
+
validate: validateBoolean
|
|
393
|
+
},
|
|
394
|
+
siteField
|
|
395
|
+
]
|
|
396
|
+
}, {
|
|
397
|
+
method: "remote",
|
|
398
|
+
fields: [
|
|
399
|
+
{
|
|
400
|
+
name: "consoleId",
|
|
401
|
+
label: "Console ID",
|
|
402
|
+
description: "The console's host ID in Site Manager."
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: "apiKey",
|
|
406
|
+
label: "Site Manager API key",
|
|
407
|
+
secret: true
|
|
408
|
+
},
|
|
409
|
+
siteField
|
|
410
|
+
]
|
|
411
|
+
}];
|
|
412
|
+
const environment = [
|
|
413
|
+
{
|
|
414
|
+
name: "UNIFI_URL",
|
|
415
|
+
required: true,
|
|
416
|
+
alternatives: ["UNIFI_CONSOLE_ID"],
|
|
417
|
+
description: "URL of the console or controller, e.g. https://192.168.1.1. To go through the ui.com cloud connector instead, set UNIFI_CONSOLE_ID to the console's host ID."
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
name: "UNIFI_API_KEY",
|
|
421
|
+
required: true,
|
|
422
|
+
secret: true,
|
|
423
|
+
description: "A Network API key (local) or a Site Manager API key (remote)."
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
name: "UNIFI_CERT_FINGERPRINT",
|
|
427
|
+
required: false,
|
|
428
|
+
description: "Local only: SHA-256 fingerprint of the console's certificate (or a CA in its chain) to pin."
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: "UNIFI_INSECURE",
|
|
432
|
+
required: false,
|
|
433
|
+
description: "Local only: true to skip TLS verification."
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: "UNIFI_SITE",
|
|
437
|
+
required: false,
|
|
438
|
+
description: "Default site, for consoles hosting several. Unset means the console's only site."
|
|
439
|
+
}
|
|
440
|
+
];
|
|
441
|
+
const readEnvironment = Effect.gen(function* () {
|
|
442
|
+
const apiKey = yield* getEnvRedactedRequired("UNIFI_API_KEY");
|
|
443
|
+
const url = yield* getEnv("UNIFI_URL");
|
|
444
|
+
const consoleId = yield* getEnv("UNIFI_CONSOLE_ID");
|
|
445
|
+
const site = yield* getEnv("UNIFI_SITE");
|
|
446
|
+
if (url && consoleId) return yield* authError("set UNIFI_URL or UNIFI_CONSOLE_ID, not both.");
|
|
447
|
+
if (consoleId) return yield* resolve({
|
|
448
|
+
method: "remote",
|
|
449
|
+
consoleId,
|
|
450
|
+
apiKey,
|
|
451
|
+
site
|
|
452
|
+
}, "UNIFI_* environment variables");
|
|
453
|
+
if (!url) return yield* authError("set UNIFI_URL (local) or UNIFI_CONSOLE_ID (remote) along with UNIFI_API_KEY.");
|
|
454
|
+
const insecureValue = (yield* getEnv("UNIFI_INSECURE")) ?? "";
|
|
455
|
+
const insecure = parseBoolean(insecureValue);
|
|
456
|
+
if (insecure === void 0) return yield* authError(`UNIFI_INSECURE must be true or false, not '${insecureValue}'.`);
|
|
457
|
+
const certFingerprint = (yield* getEnv("UNIFI_CERT_FINGERPRINT")) || void 0;
|
|
458
|
+
return yield* resolve({
|
|
459
|
+
method: "local",
|
|
460
|
+
url,
|
|
461
|
+
apiKey,
|
|
462
|
+
certFingerprint,
|
|
463
|
+
insecure,
|
|
464
|
+
site
|
|
465
|
+
}, "UNIFI_* environment variables");
|
|
466
|
+
}).pipe(Effect.map((settings) => ({
|
|
467
|
+
...settings,
|
|
468
|
+
source: { type: "env" }
|
|
469
|
+
})));
|
|
470
|
+
/** Runs `f` against a freshly established connection, mapping connection failures to `AuthError`. */
|
|
471
|
+
const withEstablished = (connection, f) => Effect.scoped(Effect.flatMap(connect(connection), f)).pipe(Effect.mapError((error) => error instanceof AuthError ? error : authError(describeError(error), error)));
|
|
472
|
+
const configureWith = (_profileName, input) => Effect.gen(function* () {
|
|
473
|
+
const method = configureMethods.find((m) => m.method === input.method);
|
|
474
|
+
if (method === void 0) return yield* authError(`unknown method '${input.method}'. Valid methods: local, remote.`);
|
|
475
|
+
const values = yield* validateFieldValues(NAME, method.fields, input.values);
|
|
476
|
+
const insecure = storedValueText(values["insecure"]);
|
|
477
|
+
const resolved = yield* resolve({
|
|
478
|
+
method: method.method === "remote" ? "remote" : "local",
|
|
479
|
+
url: storedValueText(values["url"]),
|
|
480
|
+
consoleId: storedValueText(values["consoleId"]),
|
|
481
|
+
apiKey: storedSecret(values["apiKey"]) ?? Redacted.make(""),
|
|
482
|
+
certFingerprint: storedValueText(values["certFingerprint"]),
|
|
483
|
+
insecure: insecure === void 0 ? void 0 : parseBoolean(insecure),
|
|
484
|
+
site: storedValueText(values["site"])
|
|
485
|
+
}, "--set values");
|
|
486
|
+
return toConfig(resolved);
|
|
487
|
+
});
|
|
488
|
+
const details = (profileName, config) => Effect.gen(function* () {
|
|
489
|
+
const { connection, site } = yield* fromConfig(profileName, config);
|
|
490
|
+
const lines = connection.type === "remote" ? [{
|
|
491
|
+
key: "method",
|
|
492
|
+
value: "remote (ui.com cloud connector)"
|
|
493
|
+
}, {
|
|
494
|
+
key: "consoleId",
|
|
495
|
+
value: connection.consoleId
|
|
496
|
+
}] : [
|
|
497
|
+
{
|
|
498
|
+
key: "method",
|
|
499
|
+
value: "local"
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
key: "url",
|
|
503
|
+
value: connection.url
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
key: "tls",
|
|
507
|
+
value: connection.url.startsWith("http:") ? "none (http)" : connection.certFingerprint !== void 0 ? `pinned ${connection.certFingerprint}` : connection.insecure ? "not verified" : "verified"
|
|
508
|
+
}
|
|
509
|
+
];
|
|
510
|
+
lines.push({
|
|
511
|
+
key: "apiKey",
|
|
512
|
+
value: displayRedacted(connection.apiKey)
|
|
513
|
+
});
|
|
514
|
+
if (site !== void 0) lines.push({
|
|
515
|
+
key: "site",
|
|
516
|
+
value: site
|
|
517
|
+
});
|
|
518
|
+
const network = yield* withEstablished(connection, (established) => Effect.succeed(`UniFi Network ${established.applicationVersion}`)).pipe(Effect.catch((error) => Effect.succeed(`unreachable (${error.message})`)));
|
|
519
|
+
lines.push({
|
|
520
|
+
key: "network",
|
|
521
|
+
value: network
|
|
522
|
+
});
|
|
523
|
+
return { lines };
|
|
524
|
+
});
|
|
525
|
+
const UnifiNetworkAuth = AuthProviderLayer()(NAME, Effect.gen(function* () {
|
|
526
|
+
const interaction = Interaction.accessors;
|
|
527
|
+
/**
|
|
528
|
+
* Lists the console's sites and, if there are several, lets the user
|
|
529
|
+
* pick the default (remembering `current`). A single site isn't stored.
|
|
530
|
+
*/
|
|
531
|
+
const pickSite = (connection, current) => Effect.gen(function* () {
|
|
532
|
+
const { applicationVersion, sites } = yield* withEstablished(connection, (established) => Sdk.getSiteOverviewPage({ limit: 200 }).pipe(Effect.provide(established.services), Effect.map((page) => ({
|
|
533
|
+
applicationVersion: established.applicationVersion,
|
|
534
|
+
sites: page.data
|
|
535
|
+
}))));
|
|
536
|
+
yield* interaction.output.info(`Connected to UniFi Network ${applicationVersion}.`);
|
|
537
|
+
const [only] = sites;
|
|
538
|
+
if (only === void 0) return yield* authError("the console has no sites.");
|
|
539
|
+
if (sites.length === 1) {
|
|
540
|
+
yield* interaction.output.info(`The console has one site, ${only.name}; resources will use it.`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
return yield* interaction.prompt.select({
|
|
544
|
+
message: "Default site",
|
|
545
|
+
searchable: true,
|
|
546
|
+
...current !== void 0 && sites.some((s) => s.internalReference === current) ? { initialValue: current } : {},
|
|
547
|
+
options: sites.map((s) => ({
|
|
548
|
+
value: s.internalReference,
|
|
549
|
+
label: s.name,
|
|
550
|
+
description: s.internalReference
|
|
551
|
+
}))
|
|
552
|
+
}).pipe(mapPromptCancellation);
|
|
553
|
+
});
|
|
554
|
+
const promptApiKey = (message, description) => interaction.prompt.password({
|
|
555
|
+
message,
|
|
556
|
+
description,
|
|
557
|
+
validate: required
|
|
558
|
+
}).pipe(mapPromptCancellation, Effect.map(Redacted.make));
|
|
559
|
+
const configureLocal = (current) => Effect.gen(function* () {
|
|
560
|
+
const url = yield* interaction.prompt.text({
|
|
561
|
+
message: "Console URL",
|
|
562
|
+
description: "The console's or self-hosted controller's address on your network.",
|
|
563
|
+
placeholder: "https://192.168.1.1",
|
|
564
|
+
...current?.method === "local" ? { defaultValue: current.url } : {},
|
|
565
|
+
validate: validateUrl
|
|
566
|
+
}).pipe(mapPromptCancellation, Effect.map((value) => parseConsoleUrl(value) ?? value));
|
|
567
|
+
const apiKey = yield* promptApiKey("API key", "Create one in UniFi Network under Settings → Integrations.");
|
|
568
|
+
let tls = {};
|
|
569
|
+
if (url.startsWith("https:")) {
|
|
570
|
+
const cert = yield* inspectCertificate(url).pipe(Effect.mapError((error) => authError(`could not connect to ${url}: ${describeError(error)}`, error)));
|
|
571
|
+
if (cert.trusted) yield* interaction.output.info("The console's certificate is publicly trusted; it will be verified normally.");
|
|
572
|
+
else tls = (yield* interaction.prompt.select({
|
|
573
|
+
message: `${url} presents an untrusted certificate (${cert.subject}, valid until ${cert.validTo})`,
|
|
574
|
+
options: [{
|
|
575
|
+
value: "pin",
|
|
576
|
+
label: "Pin this certificate",
|
|
577
|
+
description: `SHA-256 ${cert.fingerprint}. Check it matches the console's before choosing this.`
|
|
578
|
+
}, {
|
|
579
|
+
value: "insecure",
|
|
580
|
+
label: "Skip verification",
|
|
581
|
+
description: "Accept any certificate. Anyone on the network path could intercept the API key."
|
|
582
|
+
}]
|
|
583
|
+
}).pipe(mapPromptCancellation)) === "pin" ? { certFingerprint: cert.fingerprint } : { insecure: true };
|
|
584
|
+
}
|
|
585
|
+
const resolved = yield* resolve({
|
|
586
|
+
method: "local",
|
|
587
|
+
url,
|
|
588
|
+
apiKey,
|
|
589
|
+
...tls
|
|
590
|
+
}, "entered values");
|
|
591
|
+
const site = yield* pickSite(resolved.connection, current?.site);
|
|
592
|
+
return toConfig({
|
|
593
|
+
...resolved,
|
|
594
|
+
site
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
const configureRemote = (current) => Effect.gen(function* () {
|
|
598
|
+
const apiKey = yield* promptApiKey("Site Manager API key", "Create one at unifi.ui.com under API.");
|
|
599
|
+
const consoles = yield* Effect.scoped(make().pipe(Effect.flatMap((client) => listConsoles(apiKey).pipe(Effect.provideService(HttpClient.HttpClient, client))))).pipe(Effect.mapError((error) => authError(`could not list consoles: ${describeError(error)}`, error)));
|
|
600
|
+
const [only] = consoles;
|
|
601
|
+
if (only === void 0) return yield* authError("this API key can't see any consoles.");
|
|
602
|
+
const consoleId = consoles.length === 1 ? only.id : yield* interaction.prompt.select({
|
|
603
|
+
message: "Console",
|
|
604
|
+
searchable: true,
|
|
605
|
+
...current?.method === "remote" && consoles.some((c) => c.id === current.consoleId) ? { initialValue: current.consoleId } : {},
|
|
606
|
+
options: consoles.map((c) => ({
|
|
607
|
+
value: c.id,
|
|
608
|
+
label: c.name,
|
|
609
|
+
description: c.type === void 0 ? c.id : `${c.type} · ${c.id}`
|
|
610
|
+
}))
|
|
611
|
+
}).pipe(mapPromptCancellation);
|
|
612
|
+
if (consoles.length === 1) yield* interaction.output.info(`Using console ${only.name}.`);
|
|
613
|
+
const resolved = yield* resolve({
|
|
614
|
+
method: "remote",
|
|
615
|
+
consoleId,
|
|
616
|
+
apiKey
|
|
617
|
+
}, "entered values");
|
|
618
|
+
const site = yield* pickSite(resolved.connection, current?.site);
|
|
619
|
+
return toConfig({
|
|
620
|
+
...resolved,
|
|
621
|
+
site
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
const configure = (_profileName, current) => Effect.gen(function* () {
|
|
625
|
+
const config = yield* (yield* interaction.prompt.select({
|
|
626
|
+
message: "How should Alchemy reach the console?",
|
|
627
|
+
initialValue: current?.method ?? "local",
|
|
628
|
+
options: [{
|
|
629
|
+
value: "local",
|
|
630
|
+
label: "Directly",
|
|
631
|
+
description: "the console's or controller's URL on your network"
|
|
632
|
+
}, {
|
|
633
|
+
value: "remote",
|
|
634
|
+
label: "Through ui.com",
|
|
635
|
+
description: "the api.ui.com cloud connector, from anywhere"
|
|
636
|
+
}]
|
|
637
|
+
}).pipe(mapPromptCancellation)) === "local" ? configureLocal(current) : configureRemote(current);
|
|
638
|
+
yield* interaction.output.success(`${NAME}: credentials saved.`);
|
|
639
|
+
return config;
|
|
640
|
+
});
|
|
641
|
+
return {
|
|
642
|
+
configSchema: UnifiNetworkAuthConfigSchema,
|
|
643
|
+
configure,
|
|
644
|
+
configureWith,
|
|
645
|
+
configureMethods,
|
|
646
|
+
login: (_profileName, config) => Effect.succeed(config),
|
|
647
|
+
logout: () => Effect.void,
|
|
648
|
+
details,
|
|
649
|
+
read: fromConfig,
|
|
650
|
+
readEnvironment,
|
|
651
|
+
environment
|
|
652
|
+
};
|
|
653
|
+
}));
|
|
654
|
+
// src/internal/Fields.ts
|
|
655
|
+
/** `value` with every `Redacted` unwrapped, recursively, so passphrases compare by contents. */
|
|
656
|
+
const unwrap = (value) => {
|
|
657
|
+
if (Redacted.isRedacted(value)) return Redacted.value(value);
|
|
658
|
+
if (Array.isArray(value)) return value.map(unwrap);
|
|
659
|
+
if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, unwrap(nested)]));
|
|
660
|
+
return value;
|
|
661
|
+
};
|
|
662
|
+
/**
|
|
663
|
+
* Whether the console's object is exactly the desired body: same fields,
|
|
664
|
+
* same values, recursively (key order and `Redacted` wrappers aside; unset
|
|
665
|
+
* fields must be absent on both sides). Leave read-only fields out of
|
|
666
|
+
* `observed` first.
|
|
667
|
+
*/
|
|
668
|
+
const isCurrent = (desired, observed) => deepEqual(unwrap(desired), unwrap(observed));
|
|
669
|
+
/**
|
|
670
|
+
* `value` with every array sorted, recursively, for fields whose arrays are
|
|
671
|
+
* sets: the console keeps them in an order of its own (e.g. a firewall
|
|
672
|
+
* policy's port list), so order mustn't count as a difference.
|
|
673
|
+
*/
|
|
674
|
+
const sortArrays = (value) => {
|
|
675
|
+
if (Array.isArray(value)) return value.map(sortArrays).toSorted((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
676
|
+
if (typeof value === "object" && value !== null && !Redacted.isRedacted(value)) return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, sortArrays(nested)]));
|
|
677
|
+
return value;
|
|
678
|
+
};
|
|
679
|
+
/** `record` without `keys`, e.g. read-only fields the console rejects in request bodies. */
|
|
680
|
+
const omitKeys = (record, keys) => Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key)));
|
|
681
|
+
// src/internal/Plan.ts
|
|
682
|
+
/** A prop whose value depends on another resource's output, so isn't known while planning. */
|
|
683
|
+
const UNKNOWN = Symbol("UnifiNetwork.unknown");
|
|
684
|
+
/**
|
|
685
|
+
* A top-level prop of `diff`'s `news` (props that may still hold outputs of
|
|
686
|
+
* resources not yet deployed): its value, `undefined` when unset, or
|
|
687
|
+
* {@link UNKNOWN}.
|
|
688
|
+
*/
|
|
689
|
+
const planned = (news, key) => {
|
|
690
|
+
if (typeof news !== "object" || news === null) return UNKNOWN;
|
|
691
|
+
const value = Predicate.hasProperty(news, key) ? news[key] : void 0;
|
|
692
|
+
return isResolved(value) ? value : UNKNOWN;
|
|
693
|
+
};
|
|
694
|
+
/** {@link planned} for string props such as `site`. */
|
|
695
|
+
const plannedString = (news, key) => {
|
|
696
|
+
const value = planned(news, key);
|
|
697
|
+
return value === UNKNOWN || value === void 0 || typeof value === "string" ? value : UNKNOWN;
|
|
698
|
+
};
|
|
699
|
+
// src/internal/Services.ts
|
|
700
|
+
/**
|
|
701
|
+
* The provider's services, captured when the provider is built, as a
|
|
702
|
+
* function that runs a handler with them. Handlers then don't depend on
|
|
703
|
+
* what the stack's merged context happens to hold when they run.
|
|
704
|
+
*/
|
|
705
|
+
const captureServices = Effect.map(Effect.context(), (services) => (effect) => Effect.provide(effect, services));
|
|
706
|
+
// src/ReadCache.ts
|
|
707
|
+
var ReadCache_exports = /* @__PURE__ */ __exportAll({
|
|
708
|
+
UnifiNetworkReadCache: () => UnifiNetworkReadCache,
|
|
709
|
+
cached: () => cached,
|
|
710
|
+
invalidate: () => invalidate,
|
|
711
|
+
invalidating: () => invalidating,
|
|
712
|
+
key: () => key,
|
|
713
|
+
layer: () => layer
|
|
714
|
+
});
|
|
715
|
+
/**
|
|
716
|
+
* Per-run memo of API reads, so a plan touching many resources lists each
|
|
717
|
+
* collection once instead of once per resource. One instance per
|
|
718
|
+
* `providers()` build, i.e. per CLI run or test file. The value is only an
|
|
719
|
+
* identity: entries live on each {@link Key}, keyed by it.
|
|
720
|
+
*/
|
|
721
|
+
var UnifiNetworkReadCache = class extends Context.Service()("UnifiNetwork::ReadCache") {};
|
|
722
|
+
const layer = Layer.sync(UnifiNetworkReadCache, () => ({ run: {} }));
|
|
723
|
+
const key = (name) => ({
|
|
724
|
+
name,
|
|
725
|
+
entries: /* @__PURE__ */ new WeakMap()
|
|
726
|
+
});
|
|
727
|
+
const entriesFor = (cacheKey, run) => {
|
|
728
|
+
const existing = cacheKey.entries.get(run);
|
|
729
|
+
if (existing !== void 0) return existing;
|
|
730
|
+
const entries = /* @__PURE__ */ new Map();
|
|
731
|
+
cacheKey.entries.set(run, entries);
|
|
732
|
+
return entries;
|
|
733
|
+
};
|
|
734
|
+
/**
|
|
735
|
+
* `load`'s result, shared by every caller in this run until invalidated.
|
|
736
|
+
* Concurrent callers wait for one load. Failures aren't cached.
|
|
737
|
+
*/
|
|
738
|
+
const cached = (cacheKey, scope, load) => Effect.gen(function* () {
|
|
739
|
+
const { run } = yield* UnifiNetworkReadCache;
|
|
740
|
+
const entries = entriesFor(cacheKey, run);
|
|
741
|
+
const existing = entries.get(scope);
|
|
742
|
+
if (existing !== void 0) return yield* Deferred.await(existing);
|
|
743
|
+
const deferred = Deferred.makeUnsafe();
|
|
744
|
+
entries.set(scope, deferred);
|
|
745
|
+
return yield* load.pipe(Effect.onExit((exit) => {
|
|
746
|
+
if (Exit.isFailure(exit) && entries.get(scope) === deferred) entries.delete(scope);
|
|
747
|
+
return Deferred.done(deferred, exit);
|
|
748
|
+
}));
|
|
749
|
+
});
|
|
750
|
+
/** Drops an entry, so the next read loads afresh. */
|
|
751
|
+
const invalidate = (cacheKey, scope) => Effect.gen(function* () {
|
|
752
|
+
const { run } = yield* UnifiNetworkReadCache;
|
|
753
|
+
cacheKey.entries.get(run)?.delete(scope);
|
|
754
|
+
});
|
|
755
|
+
/** Runs a write, then invalidates the entry it affects, whether or not the write succeeded. */
|
|
756
|
+
const invalidating = (cacheKey, scope) => (write) => Effect.ensuring(write, invalidate(cacheKey, scope));
|
|
757
|
+
// src/Site.ts
|
|
758
|
+
const describeSites = (sites) => sites.map((site) => `${site.internalReference} (${site.name})`).join(", ") || "none";
|
|
759
|
+
var SiteNotFound = class extends Data.TaggedError("SiteNotFound") {
|
|
760
|
+
[UserFacingError] = true;
|
|
761
|
+
get message() {
|
|
762
|
+
return `The console has no site "${this.site}". Its sites: ${describeSites(this.available)}. Point the profile's site, or the resource's site prop, at one of them.`;
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
var SiteRequired = class extends Data.TaggedError("SiteRequired") {
|
|
766
|
+
[UserFacingError] = true;
|
|
767
|
+
get message() {
|
|
768
|
+
return `The console hosts several sites (${describeSites(this.available)}), so resources need one: set site in the profile (alchemy profile edit --reconfigure UnifiNetwork), or the resource's site prop.`;
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
var AmbiguousSite = class extends Data.TaggedError("AmbiguousSite") {
|
|
772
|
+
[UserFacingError] = true;
|
|
773
|
+
get message() {
|
|
774
|
+
return `Several sites are named "${this.site}" (${describeSites(this.matches)}). Use the internal reference instead.`;
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
/** A resource referenced another that lives in a different site. */
|
|
778
|
+
var CrossSiteReference = class extends Data.TaggedError("CrossSiteReference") {
|
|
779
|
+
[UserFacingError] = true;
|
|
780
|
+
get message() {
|
|
781
|
+
return `The referenced ${this.what} is in another site (${this.referencedSiteId}), not ${this.site.internalReference} (${this.site.name}). UniFi objects can only reference objects in their own site.`;
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
/** Fails if a reference carries a site ID (resources and lookups do) other than `site`'s. */
|
|
785
|
+
const checkSameSite = (what, referencedSiteId, site) => referencedSiteId === void 0 || referencedSiteId === site.id ? Effect.void : Effect.fail(new CrossSiteReference({
|
|
786
|
+
what,
|
|
787
|
+
referencedSiteId,
|
|
788
|
+
site
|
|
789
|
+
}));
|
|
790
|
+
const loadSites = withConnection(Sdk.getSiteOverviewPage.items({}).pipe(Stream.map(({ id, internalReference, name }) => ({
|
|
791
|
+
id,
|
|
792
|
+
internalReference,
|
|
793
|
+
name
|
|
794
|
+
})), Stream.runCollect));
|
|
795
|
+
const sitesKey = key("sites");
|
|
796
|
+
/** The console's sites, listed once per run. */
|
|
797
|
+
const listSites = cached(sitesKey, "", loadSites);
|
|
798
|
+
/**
|
|
799
|
+
* The site a resource belongs to: `site` (an ID, internal reference or
|
|
800
|
+
* name), else the profile's site, else the console's only site. Fails,
|
|
801
|
+
* listing the console's sites, when it doesn't exist or is ambiguous, so a
|
|
802
|
+
* plan stops before changing anything.
|
|
803
|
+
*/
|
|
804
|
+
const resolveSite = Effect.fn("UnifiNetwork.resolveSite")(function* (site) {
|
|
805
|
+
const requested = site ?? (yield* yield* UnifiNetworkCredentials).site;
|
|
806
|
+
const sites = yield* listSites;
|
|
807
|
+
if (requested === void 0) {
|
|
808
|
+
const [only] = sites;
|
|
809
|
+
if (only !== void 0 && sites.length === 1) return only;
|
|
810
|
+
return yield* new SiteRequired({ available: sites });
|
|
811
|
+
}
|
|
812
|
+
const byReference = sites.find((s) => s.id === requested || s.internalReference === requested);
|
|
813
|
+
if (byReference !== void 0) return byReference;
|
|
814
|
+
const byName = sites.filter((s) => s.name === requested);
|
|
815
|
+
const [match] = byName;
|
|
816
|
+
if (match !== void 0 && byName.length === 1) return match;
|
|
817
|
+
if (byName.length > 1) return yield* new AmbiguousSite({
|
|
818
|
+
site: requested,
|
|
819
|
+
matches: byName
|
|
820
|
+
});
|
|
821
|
+
return yield* new SiteNotFound({
|
|
822
|
+
site: requested,
|
|
823
|
+
available: sites
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
// src/DnsPolicy.ts
|
|
827
|
+
const loadPolicies$1 = (siteId) => withConnection(Stream.runCollect(Sdk.getDnsPolicyPage.items({ siteId })));
|
|
828
|
+
const policiesKey$1 = key("dnsPolicies");
|
|
829
|
+
/** A site's DNS policies, listed once per run. */
|
|
830
|
+
const listDnsPolicies = (siteId) => cached(policiesKey$1, siteId, loadPolicies$1(siteId));
|
|
831
|
+
const toAttributes$6 = (siteId, policy) => {
|
|
832
|
+
const { id, metadata: _metadata, ...config } = policy;
|
|
833
|
+
return {
|
|
834
|
+
siteId,
|
|
835
|
+
dnsPolicyId: id,
|
|
836
|
+
type: policy.type,
|
|
837
|
+
domain: policy.domain,
|
|
838
|
+
config
|
|
839
|
+
};
|
|
840
|
+
};
|
|
841
|
+
/** Fields the console reports but rejects in request bodies. */
|
|
842
|
+
const READ_ONLY$4 = ["id", "metadata"];
|
|
843
|
+
/** A policy by ID, from the run's cached list, which returns whole policies (one request for every policy in a plan). */
|
|
844
|
+
const getPolicy$1 = (siteId, dnsPolicyId) => listDnsPolicies(siteId).pipe(Effect.map((policies) => policies.find((p) => p.id === dnsPolicyId)));
|
|
845
|
+
/**
|
|
846
|
+
* The existing policy `body` describes: the only one with its type and
|
|
847
|
+
* domain, or, when a domain has several records of that type (e.g. two A
|
|
848
|
+
* records), the only one of those whose other fields match too.
|
|
849
|
+
*/
|
|
850
|
+
const findExisting = (policies, body) => {
|
|
851
|
+
const sameName = policies.filter((p) => p.type === body.type && p.domain === body.domain);
|
|
852
|
+
const candidates = sameName.length > 1 ? sameName.filter((p) => isCurrent(body, omitKeys(p, READ_ONLY$4))) : sameName;
|
|
853
|
+
const [policy] = candidates;
|
|
854
|
+
return candidates.length === 1 ? policy : void 0;
|
|
855
|
+
};
|
|
856
|
+
/**
|
|
857
|
+
* A local DNS record or forwarding rule on the gateway. DNS policies have no
|
|
858
|
+
* name, so existing ones are adopted with `--adopt` by type and domain (and,
|
|
859
|
+
* when a domain has several records of that type, by their values).
|
|
860
|
+
* Changing `type` updates the policy in place.
|
|
861
|
+
*
|
|
862
|
+
* @example
|
|
863
|
+
* ```ts
|
|
864
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
865
|
+
* import * as Alchemy from "alchemy";
|
|
866
|
+
* import * as Effect from "effect/Effect";
|
|
867
|
+
*
|
|
868
|
+
* export default Alchemy.Stack(
|
|
869
|
+
* "HomeNetwork",
|
|
870
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
871
|
+
* Effect.gen(function* () {
|
|
872
|
+
* yield* UnifiNetwork.DnsPolicy("home-assistant", {
|
|
873
|
+
* type: "A_RECORD",
|
|
874
|
+
* enabled: true,
|
|
875
|
+
* domain: "ha.home.arpa",
|
|
876
|
+
* ipv4Address: "192.168.2.20",
|
|
877
|
+
* ttlSeconds: 300,
|
|
878
|
+
* });
|
|
879
|
+
* }),
|
|
880
|
+
* );
|
|
881
|
+
* ```
|
|
882
|
+
*/
|
|
883
|
+
const DnsPolicy = Resource("UnifiNetwork.DnsPolicy");
|
|
884
|
+
const DnsPolicyProvider = () => Provider.effect(DnsPolicy, Effect.gen(function* () {
|
|
885
|
+
const run = yield* captureServices;
|
|
886
|
+
return DnsPolicy.Provider.of({
|
|
887
|
+
list: () => run(Effect.gen(function* () {
|
|
888
|
+
const site = yield* resolveSite();
|
|
889
|
+
return (yield* listDnsPolicies(site.id)).map((policy) => toAttributes$6(site.id, policy));
|
|
890
|
+
})),
|
|
891
|
+
read: ({ olds, output }) => run(Effect.gen(function* () {
|
|
892
|
+
if (output !== void 0) {
|
|
893
|
+
const policy = yield* getPolicy$1(output.siteId, output.dnsPolicyId);
|
|
894
|
+
return policy === void 0 ? void 0 : toAttributes$6(output.siteId, policy);
|
|
895
|
+
}
|
|
896
|
+
const site = yield* resolveSite(olds.site);
|
|
897
|
+
const { site: _site, ...body } = olds;
|
|
898
|
+
const policy = findExisting(yield* listDnsPolicies(site.id), body);
|
|
899
|
+
return policy === void 0 ? void 0 : Unowned(toAttributes$6(site.id, policy));
|
|
900
|
+
})),
|
|
901
|
+
diff: ({ news, output }) => run(Effect.gen(function* () {
|
|
902
|
+
const siteProp = plannedString(news, "site");
|
|
903
|
+
if (output === void 0 || siteProp === UNKNOWN) return void 0;
|
|
904
|
+
if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
|
|
905
|
+
})),
|
|
906
|
+
reconcile: ({ news, output }) => run(Effect.gen(function* () {
|
|
907
|
+
const site = yield* resolveSite(news.site);
|
|
908
|
+
const { site: _site, ...body } = news;
|
|
909
|
+
const observed = output !== void 0 && output.siteId === site.id ? yield* getPolicy$1(site.id, output.dnsPolicyId) : void 0;
|
|
910
|
+
const write = invalidating(policiesKey$1, site.id);
|
|
911
|
+
if (observed === void 0) {
|
|
912
|
+
const created = yield* withConnection(Sdk.createDnsPolicy({
|
|
913
|
+
siteId: site.id,
|
|
914
|
+
body
|
|
915
|
+
})).pipe(write);
|
|
916
|
+
return toAttributes$6(site.id, created);
|
|
917
|
+
}
|
|
918
|
+
if (isCurrent(body, omitKeys(observed, READ_ONLY$4))) return toAttributes$6(site.id, observed);
|
|
919
|
+
const updated = yield* withConnection(Sdk.updateDnsPolicy({
|
|
920
|
+
siteId: site.id,
|
|
921
|
+
dnsPolicyId: observed.id,
|
|
922
|
+
body
|
|
923
|
+
})).pipe(write);
|
|
924
|
+
return toAttributes$6(site.id, updated);
|
|
925
|
+
})),
|
|
926
|
+
delete: ({ output }) => run(withConnection(Sdk.deleteDnsPolicy({
|
|
927
|
+
siteId: output.siteId,
|
|
928
|
+
dnsPolicyId: output.dnsPolicyId
|
|
929
|
+
})).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(policiesKey$1, output.siteId)))
|
|
930
|
+
});
|
|
931
|
+
}));
|
|
932
|
+
// src/internal/Networks.ts
|
|
933
|
+
/** A site's networks, uncached. */
|
|
934
|
+
const loadNetworks = (siteId) => withConnection(Stream.runCollect(Sdk.getNetworksOverviewPage.items({ siteId })));
|
|
935
|
+
// src/Lookup.ts
|
|
936
|
+
/** A `lookup` found nothing matching its query. */
|
|
937
|
+
var LookupNotFound = class extends Data.TaggedError("LookupNotFound") {
|
|
938
|
+
[UserFacingError] = true;
|
|
939
|
+
get message() {
|
|
940
|
+
return `No ${this.resource} matches ${this.query} in site ${this.site.internalReference} (${this.site.name}).`;
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
/** A `lookup` matched several objects, e.g. networks sharing a name. */
|
|
944
|
+
var LookupAmbiguous = class extends Data.TaggedError("LookupAmbiguous") {
|
|
945
|
+
[UserFacingError] = true;
|
|
946
|
+
get message() {
|
|
947
|
+
return `${this.count} ${this.resource}s match ${this.query} in site ${this.site.internalReference} (${this.site.name}). Use a more specific query.`;
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
const describeQuery = (query) => Object.entries(query).filter(([key, value]) => key !== "site" && value !== void 0).map(([key, value]) => `${key} ${JSON.stringify(value)}`).join(" and ");
|
|
951
|
+
// src/FirewallZone.ts
|
|
952
|
+
const loadZones = (siteId) => withConnection(Stream.runCollect(Sdk.getFirewallZones.items({ siteId })));
|
|
953
|
+
const zonesKey = key("firewallZones");
|
|
954
|
+
/** A site's zones, built-in ones included, listed once per run. */
|
|
955
|
+
const listFirewallZones = (siteId) => cached(zonesKey, siteId, loadZones(siteId));
|
|
956
|
+
const toAttributes$5 = (siteId, zone) => ({
|
|
957
|
+
siteId,
|
|
958
|
+
firewallZoneId: zone.id,
|
|
959
|
+
name: zone.name
|
|
960
|
+
});
|
|
961
|
+
const isBuiltin = (zone) => zone.metadata.origin === "SYSTEM_DEFINED";
|
|
962
|
+
/** The zone ID a reference means in `site`. */
|
|
963
|
+
const resolveFirewallZone = Effect.fn("UnifiNetwork.resolveFirewallZone")(function* (reference, site) {
|
|
964
|
+
if ("firewallZoneId" in reference) {
|
|
965
|
+
yield* checkSameSite("firewall zone", reference.siteId, site);
|
|
966
|
+
return reference.firewallZoneId;
|
|
967
|
+
}
|
|
968
|
+
const zone = (yield* listFirewallZones(site.id)).find((z) => isBuiltin(z) && z.name === reference.builtinFirewallZone);
|
|
969
|
+
if (zone === void 0) return yield* new LookupNotFound({
|
|
970
|
+
resource: "built-in firewall zone",
|
|
971
|
+
query: `name "${reference.builtinFirewallZone}" (has the site been migrated to zone-based firewall?)`,
|
|
972
|
+
site
|
|
973
|
+
});
|
|
974
|
+
return zone.id;
|
|
975
|
+
});
|
|
976
|
+
const builtin = (name) => ({ builtinFirewallZone: name });
|
|
977
|
+
/**
|
|
978
|
+
* A custom zone in the zone-based firewall. Networks join zones through
|
|
979
|
+
* `Network`'s `zone` prop; the zone doesn't list its networks, so the two
|
|
980
|
+
* resources never fight over membership.
|
|
981
|
+
*
|
|
982
|
+
* @example
|
|
983
|
+
* ```ts
|
|
984
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
985
|
+
* import * as Alchemy from "alchemy";
|
|
986
|
+
* import * as Effect from "effect/Effect";
|
|
987
|
+
*
|
|
988
|
+
* export default Alchemy.Stack(
|
|
989
|
+
* "HomeNetwork",
|
|
990
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
991
|
+
* Effect.gen(function* () {
|
|
992
|
+
* const cameras = yield* UnifiNetwork.FirewallZone("cameras", { name: "Cameras" });
|
|
993
|
+
* // Networks join zones from the network's side.
|
|
994
|
+
* yield* UnifiNetwork.Network("cctv", {
|
|
995
|
+
* management: "GATEWAY",
|
|
996
|
+
* name: "CCTV",
|
|
997
|
+
* vlanId: 30,
|
|
998
|
+
* enabled: true,
|
|
999
|
+
* cellularBackupEnabled: false,
|
|
1000
|
+
* internetAccessEnabled: false,
|
|
1001
|
+
* isolationEnabled: true,
|
|
1002
|
+
* zone: cameras, // or a built-in zone, e.g. UnifiNetwork.FirewallZone.INTERNAL
|
|
1003
|
+
* ipv4Configuration: { autoScaleEnabled: false, hostIpAddress: "192.168.30.1", prefixLength: 24 },
|
|
1004
|
+
* });
|
|
1005
|
+
* }),
|
|
1006
|
+
* );
|
|
1007
|
+
* ```
|
|
1008
|
+
*/
|
|
1009
|
+
const FirewallZone = Resource("UnifiNetwork.FirewallZone")({
|
|
1010
|
+
INTERNAL: builtin("Internal"),
|
|
1011
|
+
EXTERNAL: builtin("External"),
|
|
1012
|
+
GATEWAY: builtin("Gateway"),
|
|
1013
|
+
VPN: builtin("Vpn"),
|
|
1014
|
+
HOTSPOT: builtin("Hotspot"),
|
|
1015
|
+
DMZ: builtin("Dmz"),
|
|
1016
|
+
/**
|
|
1017
|
+
* Any zone in the site, built-in or custom, by name. Dies with a
|
|
1018
|
+
* user-facing `LookupNotFound` if there's none: stack bodies can't fail
|
|
1019
|
+
* with typed errors.
|
|
1020
|
+
*/
|
|
1021
|
+
lookup: Effect.fn("UnifiNetwork.FirewallZone.lookup")(function* (query) {
|
|
1022
|
+
const site = yield* resolveSite(query.site);
|
|
1023
|
+
const zone = (yield* listFirewallZones(site.id)).find((z) => z.name === query.name);
|
|
1024
|
+
if (zone === void 0) return yield* new LookupNotFound({
|
|
1025
|
+
resource: "firewall zone",
|
|
1026
|
+
query: describeQuery(query),
|
|
1027
|
+
site
|
|
1028
|
+
});
|
|
1029
|
+
return toAttributes$5(site.id, zone);
|
|
1030
|
+
}, Effect.orDie)
|
|
1031
|
+
});
|
|
1032
|
+
const getZone = (siteId, firewallZoneId) => withConnection(Sdk.getFirewallZone({
|
|
1033
|
+
siteId,
|
|
1034
|
+
firewallZoneId
|
|
1035
|
+
})).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
|
|
1036
|
+
/**
|
|
1037
|
+
* The networks in a zone, from the networks' own `zoneId`s. Updates replace
|
|
1038
|
+
* a zone's member list, and the zone's `networkIds` lags a network joining
|
|
1039
|
+
* it by a fraction of a second, so sending those back could detach a
|
|
1040
|
+
* network that just joined.
|
|
1041
|
+
*/
|
|
1042
|
+
const currentMembers = (siteId, firewallZoneId) => loadNetworks(siteId).pipe(Effect.map((networks) => networks.filter((n) => "zoneId" in n && n.zoneId === firewallZoneId).map((n) => n.id)));
|
|
1043
|
+
const FirewallZoneProvider = () => Provider.effect(FirewallZone, Effect.gen(function* () {
|
|
1044
|
+
const run = yield* captureServices;
|
|
1045
|
+
return FirewallZone.Provider.of({
|
|
1046
|
+
list: () => run(Effect.gen(function* () {
|
|
1047
|
+
const site = yield* resolveSite();
|
|
1048
|
+
return (yield* listFirewallZones(site.id)).filter((zone) => !isBuiltin(zone)).map((zone) => toAttributes$5(site.id, zone));
|
|
1049
|
+
})),
|
|
1050
|
+
read: ({ olds, output }) => run(Effect.gen(function* () {
|
|
1051
|
+
if (output !== void 0) {
|
|
1052
|
+
const zone = yield* getZone(output.siteId, output.firewallZoneId);
|
|
1053
|
+
return zone === void 0 ? void 0 : toAttributes$5(output.siteId, zone);
|
|
1054
|
+
}
|
|
1055
|
+
const site = yield* resolveSite(olds.site);
|
|
1056
|
+
const zone = (yield* listFirewallZones(site.id)).find((z) => !isBuiltin(z) && z.name === olds.name);
|
|
1057
|
+
return zone === void 0 ? void 0 : Unowned(toAttributes$5(site.id, zone));
|
|
1058
|
+
})),
|
|
1059
|
+
diff: ({ news, output }) => run(Effect.gen(function* () {
|
|
1060
|
+
const siteProp = plannedString(news, "site");
|
|
1061
|
+
if (output === void 0 || siteProp === UNKNOWN) return void 0;
|
|
1062
|
+
if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
|
|
1063
|
+
})),
|
|
1064
|
+
reconcile: ({ news, output }) => run(Effect.gen(function* () {
|
|
1065
|
+
const site = yield* resolveSite(news.site);
|
|
1066
|
+
const observed = output !== void 0 && output.siteId === site.id ? yield* getZone(site.id, output.firewallZoneId) : void 0;
|
|
1067
|
+
const write = invalidating(zonesKey, site.id);
|
|
1068
|
+
if (observed === void 0) {
|
|
1069
|
+
const created = yield* withConnection(Sdk.createFirewallZone({
|
|
1070
|
+
siteId: site.id,
|
|
1071
|
+
name: news.name,
|
|
1072
|
+
networkIds: []
|
|
1073
|
+
})).pipe(write);
|
|
1074
|
+
return toAttributes$5(site.id, created);
|
|
1075
|
+
}
|
|
1076
|
+
if (observed.name === news.name) return toAttributes$5(site.id, observed);
|
|
1077
|
+
const updated = yield* withConnection(Sdk.updateFirewallZone({
|
|
1078
|
+
siteId: site.id,
|
|
1079
|
+
firewallZoneId: observed.id,
|
|
1080
|
+
name: news.name,
|
|
1081
|
+
networkIds: yield* currentMembers(site.id, observed.id)
|
|
1082
|
+
})).pipe(write);
|
|
1083
|
+
return toAttributes$5(site.id, updated);
|
|
1084
|
+
})),
|
|
1085
|
+
delete: ({ output }) => run(withConnection(Sdk.deleteFirewallZone({
|
|
1086
|
+
siteId: output.siteId,
|
|
1087
|
+
firewallZoneId: output.firewallZoneId
|
|
1088
|
+
})).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(zonesKey, output.siteId)))
|
|
1089
|
+
});
|
|
1090
|
+
}));
|
|
1091
|
+
// src/Network.ts
|
|
1092
|
+
/** A change that would replace the site's default network, which can't be deleted. */
|
|
1093
|
+
var DefaultNetworkReplacement = class extends Data.TaggedError("DefaultNetworkReplacement") {
|
|
1094
|
+
[UserFacingError] = true;
|
|
1095
|
+
get message() {
|
|
1096
|
+
return `${this.networkName} is the site's default network, which can't be replaced (${this.reason}). It must stay gateway-managed in its site.`;
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
const networksKey = key("networks");
|
|
1100
|
+
/** A site's networks, listed once per run. */
|
|
1101
|
+
const listNetworks = (siteId) => cached(networksKey, siteId, loadNetworks(siteId));
|
|
1102
|
+
const toAttributes$4 = (siteId, network) => {
|
|
1103
|
+
const { id, metadata: _metadata, default: isDefault, ...config } = network;
|
|
1104
|
+
return {
|
|
1105
|
+
siteId,
|
|
1106
|
+
networkId: id,
|
|
1107
|
+
name: network.name,
|
|
1108
|
+
vlanId: network.vlanId,
|
|
1109
|
+
management: network.management,
|
|
1110
|
+
zoneId: "zoneId" in network ? network.zoneId : void 0,
|
|
1111
|
+
default: isDefault,
|
|
1112
|
+
config
|
|
1113
|
+
};
|
|
1114
|
+
};
|
|
1115
|
+
/** Fields the console reports but rejects in request bodies. */
|
|
1116
|
+
const READ_ONLY$3 = [
|
|
1117
|
+
"id",
|
|
1118
|
+
"metadata",
|
|
1119
|
+
"default"
|
|
1120
|
+
];
|
|
1121
|
+
/**
|
|
1122
|
+
* `body` with explicit values for the two unset fields that can't simply be
|
|
1123
|
+
* left out (both verified):
|
|
1124
|
+
* - `mdnsForwardingEnabled`: a `PUT` without it keeps the console's value
|
|
1125
|
+
* instead of resetting it, so the default (`true`, what the console sets on
|
|
1126
|
+
* create) is always sent;
|
|
1127
|
+
* - a DHCP server's `domainName`: the console reports `""` when it's unset,
|
|
1128
|
+
* so without the default the body would never match and every deploy
|
|
1129
|
+
* would update it (and drift would report it) forever.
|
|
1130
|
+
*/
|
|
1131
|
+
const withDefaults$1 = (body) => {
|
|
1132
|
+
if (body.management !== "GATEWAY") return body;
|
|
1133
|
+
const dhcp = body.ipv4Configuration.dhcpConfiguration;
|
|
1134
|
+
return {
|
|
1135
|
+
...body,
|
|
1136
|
+
mdnsForwardingEnabled: body.mdnsForwardingEnabled ?? true,
|
|
1137
|
+
ipv4Configuration: {
|
|
1138
|
+
...body.ipv4Configuration,
|
|
1139
|
+
...dhcp?.mode === "SERVER" ? { dhcpConfiguration: {
|
|
1140
|
+
...dhcp,
|
|
1141
|
+
domainName: dhcp.domainName ?? ""
|
|
1142
|
+
} } : {}
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
};
|
|
1146
|
+
/** The request body for `props`, with references resolved to IDs. */
|
|
1147
|
+
const toBody$2 = Effect.fn(function* (props, site) {
|
|
1148
|
+
switch (props.management) {
|
|
1149
|
+
case "GATEWAY": {
|
|
1150
|
+
const { site: _site, zone, ...rest } = props;
|
|
1151
|
+
if (zone === void 0) return rest;
|
|
1152
|
+
const zoneId = yield* resolveFirewallZone(zone, site);
|
|
1153
|
+
return {
|
|
1154
|
+
...rest,
|
|
1155
|
+
zoneId
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
case "SWITCH": {
|
|
1159
|
+
const { site: _site, device, ...rest } = props;
|
|
1160
|
+
return {
|
|
1161
|
+
...rest,
|
|
1162
|
+
deviceId: device.deviceId
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
case "UNMANAGED": {
|
|
1166
|
+
const { site: _site, ...rest } = props;
|
|
1167
|
+
return rest;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
/** The network ID a reference means in `site`. */
|
|
1172
|
+
const resolveNetwork = (reference, site) => checkSameSite("network", reference.siteId, site).pipe(Effect.as(reference.networkId));
|
|
1173
|
+
const getNetwork = (siteId, networkId) => withConnection(Sdk.getNetworkDetails({
|
|
1174
|
+
siteId,
|
|
1175
|
+
networkId
|
|
1176
|
+
})).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
|
|
1177
|
+
/** The attributes of a network known to exist (e.g. from a list). */
|
|
1178
|
+
const attributesOf$1 = (siteId, networkId) => withConnection(Sdk.getNetworkDetails({
|
|
1179
|
+
siteId,
|
|
1180
|
+
networkId
|
|
1181
|
+
})).pipe(Effect.map((network) => toAttributes$4(siteId, network)));
|
|
1182
|
+
const isUserDefined$1 = (network) => network.metadata.origin === "USER_DEFINED";
|
|
1183
|
+
/**
|
|
1184
|
+
* A network (VLAN). `management` picks the kind: `"GATEWAY"` (routed by the
|
|
1185
|
+
* gateway), `"SWITCH"` (routed by a layer-3 switch) or `"UNMANAGED"`.
|
|
1186
|
+
* Changing it, or the site, replaces the network: the old one is deleted
|
|
1187
|
+
* first, since VLAN IDs must be unique.
|
|
1188
|
+
*
|
|
1189
|
+
* Props are authoritative: every update sends the whole network, with
|
|
1190
|
+
* explicit defaults for unset fields (`mdnsForwardingEnabled: true`, and a
|
|
1191
|
+
* DHCP server's `domainName: ""`), so changes made in the UI are put back.
|
|
1192
|
+
* The `config` attribute mirrors the console's object, so `alchemy drift`
|
|
1193
|
+
* sees them.
|
|
1194
|
+
* Existing networks are adopted by VLAN ID
|
|
1195
|
+
* with `--adopt` (and renamed to match), including the site's default
|
|
1196
|
+
* network (VLAN 1), which is never deleted or replaced.
|
|
1197
|
+
*
|
|
1198
|
+
* @example
|
|
1199
|
+
* ```ts
|
|
1200
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
1201
|
+
* import * as Alchemy from "alchemy";
|
|
1202
|
+
* import * as Effect from "effect/Effect";
|
|
1203
|
+
*
|
|
1204
|
+
* export default Alchemy.Stack(
|
|
1205
|
+
* "HomeNetwork",
|
|
1206
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
1207
|
+
* Effect.gen(function* () {
|
|
1208
|
+
* yield* UnifiNetwork.Network("guest", {
|
|
1209
|
+
* management: "GATEWAY",
|
|
1210
|
+
* name: "Guest",
|
|
1211
|
+
* vlanId: 12,
|
|
1212
|
+
* enabled: true,
|
|
1213
|
+
* cellularBackupEnabled: false,
|
|
1214
|
+
* internetAccessEnabled: true,
|
|
1215
|
+
* isolationEnabled: true,
|
|
1216
|
+
* zone: UnifiNetwork.FirewallZone.HOTSPOT,
|
|
1217
|
+
* ipv4Configuration: { autoScaleEnabled: false, hostIpAddress: "192.168.12.1", prefixLength: 24 },
|
|
1218
|
+
* });
|
|
1219
|
+
* }),
|
|
1220
|
+
* );
|
|
1221
|
+
* ```
|
|
1222
|
+
*/
|
|
1223
|
+
const Network = Resource("UnifiNetwork.Network")({
|
|
1224
|
+
/**
|
|
1225
|
+
* A network in the site by name or VLAN ID. Dies with a user-facing
|
|
1226
|
+
* `LookupNotFound` or `LookupAmbiguous`: stack bodies can't fail with typed
|
|
1227
|
+
* errors.
|
|
1228
|
+
*/
|
|
1229
|
+
lookup: Effect.fn("UnifiNetwork.Network.lookup")(function* (query) {
|
|
1230
|
+
const site = yield* resolveSite(query.site);
|
|
1231
|
+
const networks = (yield* listNetworks(site.id)).filter((n) => "vlanId" in query ? n.vlanId === query.vlanId : n.name === query.name);
|
|
1232
|
+
const [network] = networks;
|
|
1233
|
+
if (network === void 0) return yield* new LookupNotFound({
|
|
1234
|
+
resource: "network",
|
|
1235
|
+
query: describeQuery(query),
|
|
1236
|
+
site
|
|
1237
|
+
});
|
|
1238
|
+
if (networks.length > 1) return yield* new LookupAmbiguous({
|
|
1239
|
+
resource: "network",
|
|
1240
|
+
query: describeQuery(query),
|
|
1241
|
+
site,
|
|
1242
|
+
count: networks.length
|
|
1243
|
+
});
|
|
1244
|
+
return yield* attributesOf$1(site.id, network.id);
|
|
1245
|
+
}, Effect.orDie) });
|
|
1246
|
+
const NetworkProvider = () => Provider.effect(Network, Effect.gen(function* () {
|
|
1247
|
+
const run = yield* captureServices;
|
|
1248
|
+
return Network.Provider.of({
|
|
1249
|
+
list: () => run(Effect.gen(function* () {
|
|
1250
|
+
const site = yield* resolveSite();
|
|
1251
|
+
const networks = yield* listNetworks(site.id);
|
|
1252
|
+
return yield* Effect.forEach(networks.filter(isUserDefined$1), (network) => attributesOf$1(site.id, network.id));
|
|
1253
|
+
})),
|
|
1254
|
+
read: ({ olds, output }) => run(Effect.gen(function* () {
|
|
1255
|
+
if (output !== void 0) {
|
|
1256
|
+
const network = yield* getNetwork(output.siteId, output.networkId);
|
|
1257
|
+
return network === void 0 ? void 0 : toAttributes$4(output.siteId, network);
|
|
1258
|
+
}
|
|
1259
|
+
const site = yield* resolveSite(olds.site);
|
|
1260
|
+
const network = (yield* listNetworks(site.id)).find((n) => n.vlanId === olds.vlanId);
|
|
1261
|
+
return network === void 0 ? void 0 : Unowned(yield* attributesOf$1(site.id, network.id));
|
|
1262
|
+
})),
|
|
1263
|
+
diff: ({ news, output }) => run(Effect.gen(function* () {
|
|
1264
|
+
if (output === void 0) return void 0;
|
|
1265
|
+
const replace = (reason) => output.default ? Effect.fail(new DefaultNetworkReplacement({
|
|
1266
|
+
networkName: output.name,
|
|
1267
|
+
reason
|
|
1268
|
+
})) : Effect.succeed({
|
|
1269
|
+
action: "replace",
|
|
1270
|
+
deleteFirst: true
|
|
1271
|
+
});
|
|
1272
|
+
const management = planned(news, "management");
|
|
1273
|
+
if (management !== UNKNOWN && management !== output.management) return yield* replace(`its management would change to ${String(management)}`);
|
|
1274
|
+
const siteProp = plannedString(news, "site");
|
|
1275
|
+
if (siteProp === UNKNOWN) return void 0;
|
|
1276
|
+
const site = yield* resolveSite(siteProp);
|
|
1277
|
+
if (site.id !== output.siteId) return yield* replace(`it would move to site ${site.internalReference}`);
|
|
1278
|
+
})),
|
|
1279
|
+
reconcile: ({ news, output }) => run(Effect.gen(function* () {
|
|
1280
|
+
const site = yield* resolveSite(news.site);
|
|
1281
|
+
const body = withDefaults$1(yield* toBody$2(news, site));
|
|
1282
|
+
const observed = output !== void 0 && output.siteId === site.id ? yield* getNetwork(site.id, output.networkId) : void 0;
|
|
1283
|
+
const write = invalidating(networksKey, site.id);
|
|
1284
|
+
if (observed === void 0) {
|
|
1285
|
+
const created = yield* withConnection(Sdk.createNetwork({
|
|
1286
|
+
siteId: site.id,
|
|
1287
|
+
body
|
|
1288
|
+
})).pipe(write);
|
|
1289
|
+
return toAttributes$4(site.id, created);
|
|
1290
|
+
}
|
|
1291
|
+
if (isCurrent(body, omitKeys(observed, READ_ONLY$3))) return toAttributes$4(site.id, observed);
|
|
1292
|
+
const updated = yield* withConnection(Sdk.updateNetwork({
|
|
1293
|
+
siteId: site.id,
|
|
1294
|
+
networkId: observed.id,
|
|
1295
|
+
body
|
|
1296
|
+
})).pipe(write);
|
|
1297
|
+
return toAttributes$4(site.id, updated);
|
|
1298
|
+
})),
|
|
1299
|
+
delete: ({ output }) => output.default ? Effect.logInfo(`Leaving ${output.name}, the site's default network, in place.`) : run(withConnection(Sdk.deleteNetwork({
|
|
1300
|
+
siteId: output.siteId,
|
|
1301
|
+
networkId: output.networkId
|
|
1302
|
+
})).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(networksKey, output.siteId)))
|
|
1303
|
+
});
|
|
1304
|
+
}));
|
|
1305
|
+
// src/TrafficMatchingList.ts
|
|
1306
|
+
const loadLists = (siteId) => withConnection(Stream.runCollect(Sdk.getTrafficMatchingLists.items({ siteId })));
|
|
1307
|
+
const listsKey = key("trafficMatchingLists");
|
|
1308
|
+
/** A site's traffic-matching lists, listed once per run. */
|
|
1309
|
+
const listTrafficMatchingLists = (siteId) => cached(listsKey, siteId, loadLists(siteId));
|
|
1310
|
+
const toAttributes$3 = (siteId, list) => {
|
|
1311
|
+
const { id, ...config } = list;
|
|
1312
|
+
return {
|
|
1313
|
+
siteId,
|
|
1314
|
+
trafficMatchingListId: id,
|
|
1315
|
+
name: list.name,
|
|
1316
|
+
type: list.type,
|
|
1317
|
+
config
|
|
1318
|
+
};
|
|
1319
|
+
};
|
|
1320
|
+
/** Fields the console reports but rejects in request bodies. */
|
|
1321
|
+
const READ_ONLY$2 = ["id"];
|
|
1322
|
+
/** A list can't be deleted while something references it. */
|
|
1323
|
+
var TrafficMatchingListInUse = class extends Data.TaggedError("TrafficMatchingListInUse") {
|
|
1324
|
+
[UserFacingError] = true;
|
|
1325
|
+
get message() {
|
|
1326
|
+
const by = this.referencedBy.length > 0 ? `firewall policies ${this.referencedBy.map((name) => `"${name}"`).join(", ")}` : `something the provider couldn't identify (${this.detail})`;
|
|
1327
|
+
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.`;
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
/** User-defined firewall policies that mention `trafficMatchingListId` in a filter. */
|
|
1331
|
+
const policiesReferencing = (siteId, trafficMatchingListId) => withConnection(Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId }))).pipe(Effect.map((policies) => policies.filter((p) => p.metadata.origin === "USER_DEFINED" && JSON.stringify([p.source, p.destination]).includes(trafficMatchingListId)).map((p) => p.name)), Effect.orElseSucceed(() => []));
|
|
1332
|
+
/** The list ID a reference means in `site`. */
|
|
1333
|
+
const resolveTrafficMatchingList = (reference, site) => checkSameSite("traffic-matching list", reference.siteId, site).pipe(Effect.as(reference.trafficMatchingListId));
|
|
1334
|
+
/** A list by ID, from the run's cached lists, which come whole (one request for every list in a plan). */
|
|
1335
|
+
const getList = (siteId, trafficMatchingListId) => listTrafficMatchingLists(siteId).pipe(Effect.map((lists) => lists.find((l) => l.id === trafficMatchingListId)));
|
|
1336
|
+
/**
|
|
1337
|
+
* A named list of IPv4 addresses, IPv6 addresses or ports, which firewall
|
|
1338
|
+
* policies reference instead of repeating values inline. Names are unique
|
|
1339
|
+
* per site, so existing lists are adopted by name with `--adopt`. Changing
|
|
1340
|
+
* `type` updates the list in place.
|
|
1341
|
+
*
|
|
1342
|
+
* @example
|
|
1343
|
+
* ```ts
|
|
1344
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
1345
|
+
* import * as Alchemy from "alchemy";
|
|
1346
|
+
* import * as Effect from "effect/Effect";
|
|
1347
|
+
*
|
|
1348
|
+
* export default Alchemy.Stack(
|
|
1349
|
+
* "HomeNetwork",
|
|
1350
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
1351
|
+
* Effect.gen(function* () {
|
|
1352
|
+
* const haHosts = yield* UnifiNetwork.TrafficMatchingList("ha-hosts", {
|
|
1353
|
+
* type: "IPV4_ADDRESSES",
|
|
1354
|
+
* name: "Home Assistant hosts",
|
|
1355
|
+
* items: [{ type: "IP_ADDRESS", value: "192.168.2.20" }],
|
|
1356
|
+
* });
|
|
1357
|
+
* // Reference it from a policy's filter: { type: "TRAFFIC_MATCHING_LIST", matchOpposite: false, trafficMatchingList: haHosts }
|
|
1358
|
+
* }),
|
|
1359
|
+
* );
|
|
1360
|
+
* ```
|
|
1361
|
+
*/
|
|
1362
|
+
const TrafficMatchingList = Resource("UnifiNetwork.TrafficMatchingList")({
|
|
1363
|
+
/**
|
|
1364
|
+
* A list in the site by name. Dies with a user-facing `LookupNotFound`:
|
|
1365
|
+
* stack bodies can't fail with typed errors.
|
|
1366
|
+
*/
|
|
1367
|
+
lookup: Effect.fn("UnifiNetwork.TrafficMatchingList.lookup")(function* (query) {
|
|
1368
|
+
const site = yield* resolveSite(query.site);
|
|
1369
|
+
const list = (yield* listTrafficMatchingLists(site.id)).find((l) => l.name === query.name);
|
|
1370
|
+
if (list === void 0) return yield* new LookupNotFound({
|
|
1371
|
+
resource: "traffic-matching list",
|
|
1372
|
+
query: describeQuery(query),
|
|
1373
|
+
site
|
|
1374
|
+
});
|
|
1375
|
+
return toAttributes$3(site.id, list);
|
|
1376
|
+
}, Effect.orDie) });
|
|
1377
|
+
const TrafficMatchingListProvider = () => Provider.effect(TrafficMatchingList, Effect.gen(function* () {
|
|
1378
|
+
const run = yield* captureServices;
|
|
1379
|
+
return TrafficMatchingList.Provider.of({
|
|
1380
|
+
list: () => run(Effect.gen(function* () {
|
|
1381
|
+
const site = yield* resolveSite();
|
|
1382
|
+
return (yield* listTrafficMatchingLists(site.id)).map((list) => toAttributes$3(site.id, list));
|
|
1383
|
+
})),
|
|
1384
|
+
read: ({ olds, output }) => run(Effect.gen(function* () {
|
|
1385
|
+
if (output !== void 0) {
|
|
1386
|
+
const list = yield* getList(output.siteId, output.trafficMatchingListId);
|
|
1387
|
+
return list === void 0 ? void 0 : toAttributes$3(output.siteId, list);
|
|
1388
|
+
}
|
|
1389
|
+
const site = yield* resolveSite(olds.site);
|
|
1390
|
+
const list = (yield* listTrafficMatchingLists(site.id)).find((l) => l.name === olds.name);
|
|
1391
|
+
return list === void 0 ? void 0 : Unowned(toAttributes$3(site.id, list));
|
|
1392
|
+
})),
|
|
1393
|
+
diff: ({ news, output }) => run(Effect.gen(function* () {
|
|
1394
|
+
const siteProp = plannedString(news, "site");
|
|
1395
|
+
if (output === void 0 || siteProp === UNKNOWN) return void 0;
|
|
1396
|
+
if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
|
|
1397
|
+
})),
|
|
1398
|
+
reconcile: ({ news, output }) => run(Effect.gen(function* () {
|
|
1399
|
+
const site = yield* resolveSite(news.site);
|
|
1400
|
+
const { site: _site, ...body } = news;
|
|
1401
|
+
const observed = output !== void 0 && output.siteId === site.id ? yield* getList(site.id, output.trafficMatchingListId) : void 0;
|
|
1402
|
+
const write = invalidating(listsKey, site.id);
|
|
1403
|
+
if (observed === void 0) {
|
|
1404
|
+
const created = yield* withConnection(Sdk.createTrafficMatchingList({
|
|
1405
|
+
siteId: site.id,
|
|
1406
|
+
body
|
|
1407
|
+
})).pipe(write);
|
|
1408
|
+
return toAttributes$3(site.id, created);
|
|
1409
|
+
}
|
|
1410
|
+
if (isCurrent(body, omitKeys(observed, READ_ONLY$2))) return toAttributes$3(site.id, observed);
|
|
1411
|
+
const updated = yield* withConnection(Sdk.updateTrafficMatchingList({
|
|
1412
|
+
siteId: site.id,
|
|
1413
|
+
trafficMatchingListId: observed.id,
|
|
1414
|
+
body
|
|
1415
|
+
})).pipe(write);
|
|
1416
|
+
return toAttributes$3(site.id, updated);
|
|
1417
|
+
})),
|
|
1418
|
+
delete: ({ output }) => run(withConnection(Sdk.deleteTrafficMatchingList({
|
|
1419
|
+
siteId: output.siteId,
|
|
1420
|
+
trafficMatchingListId: output.trafficMatchingListId
|
|
1421
|
+
})).pipe(Effect.asVoid, Effect.catchTags({
|
|
1422
|
+
NotFound: () => Effect.void,
|
|
1423
|
+
UnprocessableEntity: (error) => policiesReferencing(output.siteId, output.trafficMatchingListId).pipe(Effect.flatMap((referencedBy) => Effect.fail(new TrafficMatchingListInUse({
|
|
1424
|
+
listName: output.name,
|
|
1425
|
+
referencedBy,
|
|
1426
|
+
detail: error.message
|
|
1427
|
+
}))))
|
|
1428
|
+
}), invalidating(listsKey, output.siteId)))
|
|
1429
|
+
});
|
|
1430
|
+
}));
|
|
1431
|
+
// src/FirewallPolicy.ts
|
|
1432
|
+
const loadPolicies = (siteId) => withConnection(Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId })));
|
|
1433
|
+
const policiesKey = key("firewallPolicies");
|
|
1434
|
+
/**
|
|
1435
|
+
* A site's firewall policies, listed once per run: all of them, including
|
|
1436
|
+
* system-defined ones and ones UniFi derives from UI Objects.
|
|
1437
|
+
*/
|
|
1438
|
+
const listFirewallPolicies = (siteId) => cached(policiesKey, siteId, loadPolicies(siteId));
|
|
1439
|
+
/** Policies the user made, as opposed to system-defined ones and ones UniFi derives (from UI Objects, say), which the provider never touches. */
|
|
1440
|
+
const isUserDefined = (policy) => policy.metadata.origin === "USER_DEFINED";
|
|
1441
|
+
const toAttributes$2 = (siteId, policy) => {
|
|
1442
|
+
const { id, metadata: _metadata, index: _index, ...config } = policy;
|
|
1443
|
+
return {
|
|
1444
|
+
siteId,
|
|
1445
|
+
firewallPolicyId: id,
|
|
1446
|
+
name: policy.name,
|
|
1447
|
+
sourceFirewallZoneId: policy.source.zoneId,
|
|
1448
|
+
destinationFirewallZoneId: policy.destination.zoneId,
|
|
1449
|
+
config
|
|
1450
|
+
};
|
|
1451
|
+
};
|
|
1452
|
+
/** Fields the console reports but rejects in request bodies, or that change without the policy being edited. */
|
|
1453
|
+
const READ_ONLY$1 = [
|
|
1454
|
+
"id",
|
|
1455
|
+
"metadata",
|
|
1456
|
+
"index"
|
|
1457
|
+
];
|
|
1458
|
+
const resolvePortFilter = Effect.fn(function* (filter, site) {
|
|
1459
|
+
if (filter.type === "PORTS") return filter;
|
|
1460
|
+
const trafficMatchingListId = yield* resolveTrafficMatchingList(filter.trafficMatchingList, site);
|
|
1461
|
+
return {
|
|
1462
|
+
type: "TRAFFIC_MATCHING_LIST",
|
|
1463
|
+
matchOpposite: filter.matchOpposite,
|
|
1464
|
+
trafficMatchingListId
|
|
1465
|
+
};
|
|
1466
|
+
});
|
|
1467
|
+
const resolveIpAddressFilter = Effect.fn(function* (filter, site) {
|
|
1468
|
+
if (filter.type === "IP_ADDRESSES") return filter;
|
|
1469
|
+
const trafficMatchingListId = yield* resolveTrafficMatchingList(filter.trafficMatchingList, site);
|
|
1470
|
+
return {
|
|
1471
|
+
type: "TRAFFIC_MATCHING_LIST",
|
|
1472
|
+
matchOpposite: filter.matchOpposite,
|
|
1473
|
+
trafficMatchingListId
|
|
1474
|
+
};
|
|
1475
|
+
});
|
|
1476
|
+
const resolveNetworkFilter = (filter, site) => Effect.forEach(filter.networks, (network) => resolveNetwork(network, site)).pipe(Effect.map((networkIds) => ({
|
|
1477
|
+
matchOpposite: filter.matchOpposite,
|
|
1478
|
+
networkIds
|
|
1479
|
+
})));
|
|
1480
|
+
/** `filter` with its optional `portFilter` resolved (left out when unset). */
|
|
1481
|
+
const withPortFilter = Effect.fn(function* (filter, site) {
|
|
1482
|
+
const { portFilter, ...rest } = filter;
|
|
1483
|
+
if (portFilter === void 0) return rest;
|
|
1484
|
+
return {
|
|
1485
|
+
...rest,
|
|
1486
|
+
portFilter: yield* resolvePortFilter(portFilter, site)
|
|
1487
|
+
};
|
|
1488
|
+
});
|
|
1489
|
+
const resolveSourceFilter = Effect.fn(function* (filter, site) {
|
|
1490
|
+
switch (filter.type) {
|
|
1491
|
+
case "IP_ADDRESS": {
|
|
1492
|
+
const { ipAddressFilter, ...rest } = yield* withPortFilter(filter, site);
|
|
1493
|
+
return {
|
|
1494
|
+
...rest,
|
|
1495
|
+
ipAddressFilter: yield* resolveIpAddressFilter(ipAddressFilter, site)
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
case "NETWORK": {
|
|
1499
|
+
const { networkFilter, ...rest } = yield* withPortFilter(filter, site);
|
|
1500
|
+
return {
|
|
1501
|
+
...rest,
|
|
1502
|
+
networkFilter: yield* resolveNetworkFilter(networkFilter, site)
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
case "PORT": return {
|
|
1506
|
+
...filter,
|
|
1507
|
+
portFilter: yield* resolvePortFilter(filter.portFilter, site)
|
|
1508
|
+
};
|
|
1509
|
+
case "IPV6_IID": return yield* withPortFilter(filter, site);
|
|
1510
|
+
case "MAC_ADDRESS": return yield* withPortFilter(filter, site);
|
|
1511
|
+
case "REGION": return yield* withPortFilter(filter, site);
|
|
1512
|
+
case "SITE_TO_SITE_VPN_TUNNEL": return yield* withPortFilter(filter, site);
|
|
1513
|
+
case "VPN_SERVER": return yield* withPortFilter(filter, site);
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
const resolveDestinationFilter = Effect.fn(function* (filter, site) {
|
|
1517
|
+
switch (filter.type) {
|
|
1518
|
+
case "IP_ADDRESS": {
|
|
1519
|
+
const { ipAddressFilter, ...rest } = yield* withPortFilter(filter, site);
|
|
1520
|
+
return {
|
|
1521
|
+
...rest,
|
|
1522
|
+
ipAddressFilter: yield* resolveIpAddressFilter(ipAddressFilter, site)
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
case "NETWORK": {
|
|
1526
|
+
const { networkFilter, ...rest } = yield* withPortFilter(filter, site);
|
|
1527
|
+
return {
|
|
1528
|
+
...rest,
|
|
1529
|
+
networkFilter: yield* resolveNetworkFilter(networkFilter, site)
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
case "PORT": return {
|
|
1533
|
+
...filter,
|
|
1534
|
+
portFilter: yield* resolvePortFilter(filter.portFilter, site)
|
|
1535
|
+
};
|
|
1536
|
+
case "APPLICATION": return yield* withPortFilter(filter, site);
|
|
1537
|
+
case "APPLICATION_CATEGORY": return yield* withPortFilter(filter, site);
|
|
1538
|
+
case "DOMAIN": return yield* withPortFilter(filter, site);
|
|
1539
|
+
case "IPV6_IID": return yield* withPortFilter(filter, site);
|
|
1540
|
+
case "REGION": return yield* withPortFilter(filter, site);
|
|
1541
|
+
case "SITE_TO_SITE_VPN_TUNNEL": return yield* withPortFilter(filter, site);
|
|
1542
|
+
case "VPN_SERVER": return yield* withPortFilter(filter, site);
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
/** The request body for `props` (without `siteId`), with references resolved to IDs. */
|
|
1546
|
+
const toBody$1 = Effect.fn(function* (props, site) {
|
|
1547
|
+
const { site: _site, source, destination, ...rest } = props;
|
|
1548
|
+
const sourceFilter = source.trafficFilter;
|
|
1549
|
+
const destinationFilter = destination.trafficFilter;
|
|
1550
|
+
return {
|
|
1551
|
+
...rest,
|
|
1552
|
+
source: {
|
|
1553
|
+
zoneId: yield* resolveFirewallZone(source.zone, site),
|
|
1554
|
+
...sourceFilter === void 0 ? {} : { trafficFilter: yield* resolveSourceFilter(sourceFilter, site) }
|
|
1555
|
+
},
|
|
1556
|
+
destination: {
|
|
1557
|
+
zoneId: yield* resolveFirewallZone(destination.zone, site),
|
|
1558
|
+
...destinationFilter === void 0 ? {} : { trafficFilter: yield* resolveDestinationFilter(destinationFilter, site) }
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
});
|
|
1562
|
+
/**
|
|
1563
|
+
* A policy by ID, from the run's cached list: the list returns whole
|
|
1564
|
+
* policies, so a plan reading every policy costs one request, not one each
|
|
1565
|
+
* (this matters through the ui.com connector's rate limit).
|
|
1566
|
+
*/
|
|
1567
|
+
const getPolicy = (siteId, firewallPolicyId) => listFirewallPolicies(siteId).pipe(Effect.map((policies) => policies.find((p) => p.id === firewallPolicyId)));
|
|
1568
|
+
/** User-defined policies named `name` from `source` to `destination`. Names aren't unique, even within a zone pair. */
|
|
1569
|
+
const findByName = Effect.fn(function* (site, name, source, destination) {
|
|
1570
|
+
const sourceId = source === void 0 ? void 0 : yield* resolveFirewallZone(source, site);
|
|
1571
|
+
const destinationId = destination === void 0 ? void 0 : yield* resolveFirewallZone(destination, site);
|
|
1572
|
+
return (yield* listFirewallPolicies(site.id)).filter((p) => isUserDefined(p) && p.name === name && (sourceId === void 0 || p.source.zoneId === sourceId) && (destinationId === void 0 || p.destination.zoneId === destinationId));
|
|
1573
|
+
});
|
|
1574
|
+
/**
|
|
1575
|
+
* A zone-based firewall policy: allow, block or reject traffic from one
|
|
1576
|
+
* zone to another, optionally narrowed by traffic filters. Changing its
|
|
1577
|
+
* zones updates it in place and moves it to the new zone pair's ordering
|
|
1578
|
+
* (see `FirewallPolicyOrdering`).
|
|
1579
|
+
*
|
|
1580
|
+
* Existing user-defined policies are adopted with `--adopt` by name and zone
|
|
1581
|
+
* pair (names aren't unique, so several matches adopt nothing). Policies
|
|
1582
|
+
* UniFi derives, e.g. from UI Objects, are invisible: never listed, adopted
|
|
1583
|
+
* or ordered.
|
|
1584
|
+
*
|
|
1585
|
+
* @example
|
|
1586
|
+
* ```ts
|
|
1587
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
1588
|
+
* import * as Alchemy from "alchemy";
|
|
1589
|
+
* import * as Effect from "effect/Effect";
|
|
1590
|
+
*
|
|
1591
|
+
* export default Alchemy.Stack(
|
|
1592
|
+
* "HomeNetwork",
|
|
1593
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
1594
|
+
* Effect.gen(function* () {
|
|
1595
|
+
* const iot = yield* UnifiNetwork.FirewallZone("iot-zone", { name: "IoT" });
|
|
1596
|
+
* yield* UnifiNetwork.FirewallPolicy("block-iot-to-internal", {
|
|
1597
|
+
* name: "Block IoT to Internal",
|
|
1598
|
+
* enabled: true,
|
|
1599
|
+
* action: { type: "BLOCK" },
|
|
1600
|
+
* source: { zone: iot },
|
|
1601
|
+
* destination: { zone: UnifiNetwork.FirewallZone.INTERNAL },
|
|
1602
|
+
* ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
|
|
1603
|
+
* loggingEnabled: false,
|
|
1604
|
+
* });
|
|
1605
|
+
* }),
|
|
1606
|
+
* );
|
|
1607
|
+
* ```
|
|
1608
|
+
*/
|
|
1609
|
+
const FirewallPolicy = Resource("UnifiNetwork.FirewallPolicy")({
|
|
1610
|
+
/**
|
|
1611
|
+
* A user-defined policy by name, optionally narrowed to a zone pair. Dies
|
|
1612
|
+
* with a user-facing `LookupNotFound` or `LookupAmbiguous`: stack bodies
|
|
1613
|
+
* can't fail with typed errors.
|
|
1614
|
+
*/
|
|
1615
|
+
lookup: Effect.fn("UnifiNetwork.FirewallPolicy.lookup")(function* (query) {
|
|
1616
|
+
const site = yield* resolveSite(query.site);
|
|
1617
|
+
const policies = yield* findByName(site, query.name, query.source, query.destination);
|
|
1618
|
+
const [policy] = policies;
|
|
1619
|
+
const described = describeQuery({ name: query.name });
|
|
1620
|
+
if (policy === void 0) return yield* new LookupNotFound({
|
|
1621
|
+
resource: "firewall policy",
|
|
1622
|
+
query: described,
|
|
1623
|
+
site
|
|
1624
|
+
});
|
|
1625
|
+
if (policies.length > 1) return yield* new LookupAmbiguous({
|
|
1626
|
+
resource: "firewall policy",
|
|
1627
|
+
query: described,
|
|
1628
|
+
site,
|
|
1629
|
+
count: policies.length
|
|
1630
|
+
});
|
|
1631
|
+
return toAttributes$2(site.id, policy);
|
|
1632
|
+
}, Effect.orDie) });
|
|
1633
|
+
const FirewallPolicyProvider = () => Provider.effect(FirewallPolicy, Effect.gen(function* () {
|
|
1634
|
+
const run = yield* captureServices;
|
|
1635
|
+
return FirewallPolicy.Provider.of({
|
|
1636
|
+
list: () => run(Effect.gen(function* () {
|
|
1637
|
+
const site = yield* resolveSite();
|
|
1638
|
+
return (yield* listFirewallPolicies(site.id)).filter(isUserDefined).map((policy) => toAttributes$2(site.id, policy));
|
|
1639
|
+
})),
|
|
1640
|
+
read: ({ olds, output }) => run(Effect.gen(function* () {
|
|
1641
|
+
if (output !== void 0) {
|
|
1642
|
+
const policy = yield* getPolicy(output.siteId, output.firewallPolicyId);
|
|
1643
|
+
return policy === void 0 || !isUserDefined(policy) ? void 0 : toAttributes$2(output.siteId, policy);
|
|
1644
|
+
}
|
|
1645
|
+
const site = yield* resolveSite(olds.site);
|
|
1646
|
+
const matching = yield* findByName(site, olds.name, olds.source.zone, olds.destination.zone);
|
|
1647
|
+
const [policy] = matching;
|
|
1648
|
+
return policy === void 0 || matching.length > 1 ? void 0 : Unowned(toAttributes$2(site.id, policy));
|
|
1649
|
+
})),
|
|
1650
|
+
diff: ({ news, output }) => run(Effect.gen(function* () {
|
|
1651
|
+
const siteProp = plannedString(news, "site");
|
|
1652
|
+
if (output === void 0 || siteProp === UNKNOWN) return void 0;
|
|
1653
|
+
if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
|
|
1654
|
+
})),
|
|
1655
|
+
reconcile: ({ news, output }) => run(Effect.gen(function* () {
|
|
1656
|
+
const site = yield* resolveSite(news.site);
|
|
1657
|
+
const body = yield* toBody$1(news, site);
|
|
1658
|
+
const observed = output !== void 0 && output.siteId === site.id ? yield* getPolicy(site.id, output.firewallPolicyId) : void 0;
|
|
1659
|
+
const write = invalidating(policiesKey, site.id);
|
|
1660
|
+
if (observed === void 0) {
|
|
1661
|
+
const created = yield* withConnection(Sdk.createFirewallPolicy({
|
|
1662
|
+
siteId: site.id,
|
|
1663
|
+
...body
|
|
1664
|
+
})).pipe(write);
|
|
1665
|
+
return toAttributes$2(site.id, created);
|
|
1666
|
+
}
|
|
1667
|
+
if (isCurrent(sortArrays(body), sortArrays(omitKeys(observed, READ_ONLY$1)))) return toAttributes$2(site.id, observed);
|
|
1668
|
+
const updated = yield* withConnection(Sdk.updateFirewallPolicy({
|
|
1669
|
+
...body,
|
|
1670
|
+
siteId: site.id,
|
|
1671
|
+
firewallPolicyId: observed.id
|
|
1672
|
+
})).pipe(write);
|
|
1673
|
+
return toAttributes$2(site.id, updated);
|
|
1674
|
+
})),
|
|
1675
|
+
delete: ({ output }) => run(withConnection(Sdk.deleteFirewallPolicy({
|
|
1676
|
+
siteId: output.siteId,
|
|
1677
|
+
firewallPolicyId: output.firewallPolicyId
|
|
1678
|
+
})).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(policiesKey, output.siteId)))
|
|
1679
|
+
});
|
|
1680
|
+
}));
|
|
1681
|
+
// src/State.ts
|
|
1682
|
+
/**
|
|
1683
|
+
* State store on the console itself (see `docs/development/state-store.md`). Each stack and
|
|
1684
|
+
* stage is one disabled Gateway → Gateway firewall policy whose description
|
|
1685
|
+
* holds the stage's state, and a traffic-matching list (whose names, unlike
|
|
1686
|
+
* policies', are unique) is its lock.
|
|
1687
|
+
*/
|
|
1688
|
+
/** Name prefix of the policies holding state. Other code leaves these alone. */
|
|
1689
|
+
const STATE_POLICY_PREFIX = "alchemy-state/";
|
|
1690
|
+
/** Name prefix of the traffic-matching lists that lock a stage. */
|
|
1691
|
+
const STATE_LOCK_PREFIX = "alchemy-lock/";
|
|
1692
|
+
/** Prefix of a state policy's description, ahead of the base64 of the Brotli-compressed JSON. */
|
|
1693
|
+
const FORMAT = "alchemy-state:v1:";
|
|
1694
|
+
const DEFAULT_WARN_AT = 4e6;
|
|
1695
|
+
/** How long a lock stays valid without a renewal. */
|
|
1696
|
+
const LOCK_TTL_MS = 12e4;
|
|
1697
|
+
const RENEW_EVERY_MS = 3e4;
|
|
1698
|
+
/** How long a passing check that the lock still exists is trusted. */
|
|
1699
|
+
const LOCK_CHECK_TTL_MS = 5e3;
|
|
1700
|
+
const policyName = (stack, stage) => `${STATE_POLICY_PREFIX}${stack}/${stage}`;
|
|
1701
|
+
const lockName = (stack, stage) => `${STATE_LOCK_PREFIX}${stack}/${stage}`;
|
|
1702
|
+
const parsePolicyName = (name) => {
|
|
1703
|
+
const [stack, stage, ...rest] = name.slice(14).split("/");
|
|
1704
|
+
return stack === void 0 || stage === void 0 || rest.length > 0 ? void 0 : {
|
|
1705
|
+
stack,
|
|
1706
|
+
stage
|
|
1707
|
+
};
|
|
1708
|
+
};
|
|
1709
|
+
const encodeSnapshot = (snapshot) => FORMAT + zlib.brotliCompressSync(JSON.stringify(snapshot), { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 9 } }).toString("base64");
|
|
1710
|
+
const isLockRecord = (value) => Predicate.hasProperty(value, "listId") && typeof value.listId === "string" && Predicate.hasProperty(value, "expiresAt") && typeof value.expiresAt === "number";
|
|
1711
|
+
const decodeSnapshot = (policy) => Effect.try({
|
|
1712
|
+
try: () => {
|
|
1713
|
+
if (!policy.description.startsWith(FORMAT)) throw new Error("its description isn't in a known format");
|
|
1714
|
+
const parsed = JSON.parse(zlib.brotliDecompressSync(Buffer.from(policy.description.slice(17), "base64")).toString());
|
|
1715
|
+
if (!Predicate.isObject(parsed) || !Predicate.isObject(parsed["resources"])) throw new Error("it has no resources");
|
|
1716
|
+
return {
|
|
1717
|
+
lock: isLockRecord(parsed["lock"]) ? parsed["lock"] : void 0,
|
|
1718
|
+
resources: { ...parsed["resources"] },
|
|
1719
|
+
output: parsed["output"]
|
|
1720
|
+
};
|
|
1721
|
+
},
|
|
1722
|
+
catch: (cause) => new StateStoreError({
|
|
1723
|
+
message: `UniFi state policy '${policy.name}' can't be read: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1724
|
+
...cause instanceof Error ? { cause } : {}
|
|
1725
|
+
})
|
|
1726
|
+
});
|
|
1727
|
+
/** A copy of a stored value, revived (e.g. `Redacted` values rebuilt). */
|
|
1728
|
+
const revive = (encoded) => encoded === void 0 ? void 0 : JSON.parse(JSON.stringify(encoded), reviveState);
|
|
1729
|
+
const isRecord = (value) => Predicate.isObject(value);
|
|
1730
|
+
const isReplaced = (value) => isRecord(value) && "status" in value && value.status === "replaced";
|
|
1731
|
+
const heldError = (stack, stage, detail) => new StateStoreError({ message: `Another run holds the UniFi state lock on '${stack}/${stage}': ${detail}` });
|
|
1732
|
+
/**
|
|
1733
|
+
* A {@link StateService} over `backend`. The first operation on a stage
|
|
1734
|
+
* locks it and loads its state, and the lock is released when `Scope`
|
|
1735
|
+
* closes.
|
|
1736
|
+
*/
|
|
1737
|
+
const makeUnifiState = (backend, options = {}) => Effect.gen(function* () {
|
|
1738
|
+
const scope = yield* Effect.scope;
|
|
1739
|
+
const warnAt = options.warnAt ?? 4e6;
|
|
1740
|
+
let warned = false;
|
|
1741
|
+
const findPolicy = Effect.fn(function* (stack, stage) {
|
|
1742
|
+
const name = policyName(stack, stage);
|
|
1743
|
+
const found = (yield* backend.listPolicies).filter((p) => p.name === name);
|
|
1744
|
+
if (found.length > 1) return yield* new StateStoreError({ message: `Several firewall policies are named '${name}'. Only one may hold this stage's state: delete the others (keep the one with the newest state).` });
|
|
1745
|
+
return found[0];
|
|
1746
|
+
});
|
|
1747
|
+
const takeLock = Effect.fn(function* (stack, stage) {
|
|
1748
|
+
const name = lockName(stack, stage);
|
|
1749
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1750
|
+
const created = yield* backend.createLock(name);
|
|
1751
|
+
if (created !== void 0) return created;
|
|
1752
|
+
const holder = yield* backend.findLock(name);
|
|
1753
|
+
if (holder === void 0) continue;
|
|
1754
|
+
const policy = yield* findPolicy(stack, stage);
|
|
1755
|
+
const record = policy === void 0 ? void 0 : (yield* decodeSnapshot(policy)).lock;
|
|
1756
|
+
if (record === void 0 || record.listId !== holder) return yield* heldError(stack, stage, `the traffic-matching list '${name}' exists, but no run has recorded holding it. A run may be starting, or one crashed before recording it. If nothing is using this stage, delete the list in the UniFi UI.`);
|
|
1757
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1758
|
+
if (record.expiresAt > now) return yield* heldError(stack, stage, `it expires at ${new Date(record.expiresAt).toISOString()} unless renewed.`);
|
|
1759
|
+
yield* backend.deleteLock(holder);
|
|
1760
|
+
}
|
|
1761
|
+
return yield* heldError(stack, stage, `the traffic-matching list '${name}' keeps reappearing.`);
|
|
1762
|
+
});
|
|
1763
|
+
const checkLock = (s) => Effect.gen(function* () {
|
|
1764
|
+
const lostError = new StateStoreError({ message: `The UniFi state lock on '${s.stack}/${s.stage}' was lost mid-run (its traffic-matching list was deleted); refusing to continue unlocked.` });
|
|
1765
|
+
if (s.lost) return yield* lostError;
|
|
1766
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1767
|
+
if (s.lockCheckedAt !== void 0 && now - s.lockCheckedAt < LOCK_CHECK_TTL_MS) return;
|
|
1768
|
+
if (!(yield* backend.lockExists(s.listId))) {
|
|
1769
|
+
s.lost = true;
|
|
1770
|
+
return yield* lostError;
|
|
1771
|
+
}
|
|
1772
|
+
s.lockCheckedAt = now;
|
|
1773
|
+
});
|
|
1774
|
+
/**
|
|
1775
|
+
* Writes the stage unless `upTo` is already on the console. Without
|
|
1776
|
+
* `upTo` (a renewal) it writes only an existing policy.
|
|
1777
|
+
*/
|
|
1778
|
+
const flush = (s, upTo) => Semaphore.withPermits(s.writer, 1)(Effect.gen(function* () {
|
|
1779
|
+
if (s.closed) return yield* new StateStoreError({ message: `The UniFi state store for '${s.stack}/${s.stage}' is closed.` });
|
|
1780
|
+
if (upTo === void 0 ? s.policyId === void 0 : s.written >= upTo) return;
|
|
1781
|
+
yield* checkLock(s);
|
|
1782
|
+
const version = s.version;
|
|
1783
|
+
const expiresAt = (yield* Clock.currentTimeMillis) + LOCK_TTL_MS;
|
|
1784
|
+
const description = encodeSnapshot({
|
|
1785
|
+
lock: {
|
|
1786
|
+
listId: s.listId,
|
|
1787
|
+
expiresAt
|
|
1788
|
+
},
|
|
1789
|
+
resources: s.resources,
|
|
1790
|
+
output: s.output
|
|
1791
|
+
});
|
|
1792
|
+
if (description.length > warnAt && !warned) {
|
|
1793
|
+
warned = true;
|
|
1794
|
+
yield* Effect.logWarning(`UniFi state for '${s.stack}/${s.stage}' is ${description.length} bytes, over the ${warnAt}-byte warning size. The console stores up to about 16 MiB directly and 7.5 MiB through ui.com: consider moving this stack to another state store.`);
|
|
1795
|
+
}
|
|
1796
|
+
const name = policyName(s.stack, s.stage);
|
|
1797
|
+
if (s.policyId === void 0) s.policyId = yield* backend.createPolicy(name, description);
|
|
1798
|
+
else yield* backend.updatePolicy(s.policyId, name, description);
|
|
1799
|
+
s.written = version;
|
|
1800
|
+
}));
|
|
1801
|
+
const openStage = Effect.fn(function* (stack, stage) {
|
|
1802
|
+
const listId = yield* takeLock(stack, stage);
|
|
1803
|
+
const s = {
|
|
1804
|
+
stack,
|
|
1805
|
+
stage,
|
|
1806
|
+
listId,
|
|
1807
|
+
policyId: void 0,
|
|
1808
|
+
resources: {},
|
|
1809
|
+
output: void 0,
|
|
1810
|
+
version: 0,
|
|
1811
|
+
written: 0,
|
|
1812
|
+
lockCheckedAt: yield* Clock.currentTimeMillis,
|
|
1813
|
+
lost: false,
|
|
1814
|
+
closed: false,
|
|
1815
|
+
writer: Semaphore.makeUnsafe(1)
|
|
1816
|
+
};
|
|
1817
|
+
yield* Scope.addFinalizer(scope, Semaphore.withPermits(s.writer, 1)(Effect.suspend(() => {
|
|
1818
|
+
s.closed = true;
|
|
1819
|
+
return backend.deleteLock(listId);
|
|
1820
|
+
}).pipe(Effect.ignore)));
|
|
1821
|
+
const policy = yield* findPolicy(stack, stage);
|
|
1822
|
+
if (policy !== void 0) {
|
|
1823
|
+
const snapshot = yield* decodeSnapshot(policy);
|
|
1824
|
+
s.policyId = policy.id;
|
|
1825
|
+
s.resources = { ...snapshot.resources };
|
|
1826
|
+
s.output = snapshot.output;
|
|
1827
|
+
yield* flush(s);
|
|
1828
|
+
}
|
|
1829
|
+
yield* flush(s).pipe(Effect.ignore, Effect.delay(RENEW_EVERY_MS), Effect.forever, Effect.interruptible, Effect.forkIn(scope));
|
|
1830
|
+
return s;
|
|
1831
|
+
});
|
|
1832
|
+
const stages = /* @__PURE__ */ new Map();
|
|
1833
|
+
const opening = Semaphore.makeUnsafe(1);
|
|
1834
|
+
const stageFor = (request) => Semaphore.withPermits(opening, 1)(Effect.gen(function* () {
|
|
1835
|
+
const key = `${request.stack}/${request.stage}`;
|
|
1836
|
+
const existing = stages.get(key);
|
|
1837
|
+
if (existing !== void 0) return existing;
|
|
1838
|
+
const opened = yield* openStage(request.stack, request.stage);
|
|
1839
|
+
stages.set(key, opened);
|
|
1840
|
+
return opened;
|
|
1841
|
+
}));
|
|
1842
|
+
const change = (request, apply) => stageFor(request).pipe(Effect.flatMap((s) => {
|
|
1843
|
+
apply(s);
|
|
1844
|
+
s.version++;
|
|
1845
|
+
return flush(s, s.version);
|
|
1846
|
+
}));
|
|
1847
|
+
const deleteStage = (stack, stage) => stageFor({
|
|
1848
|
+
stack,
|
|
1849
|
+
stage
|
|
1850
|
+
}).pipe(Effect.flatMap((s) => Semaphore.withPermits(s.writer, 1)(Effect.gen(function* () {
|
|
1851
|
+
yield* checkLock(s);
|
|
1852
|
+
if (s.policyId !== void 0) yield* backend.deletePolicy(s.policyId);
|
|
1853
|
+
s.policyId = void 0;
|
|
1854
|
+
s.resources = {};
|
|
1855
|
+
s.output = void 0;
|
|
1856
|
+
s.written = s.version;
|
|
1857
|
+
}))));
|
|
1858
|
+
const stored = Effect.map(backend.listPolicies, (policies) => policies.flatMap((p) => {
|
|
1859
|
+
const parsed = parsePolicyName(p.name);
|
|
1860
|
+
return parsed === void 0 ? [] : [parsed];
|
|
1861
|
+
}));
|
|
1862
|
+
const listStages = (stack) => Effect.map(stored, (all) => [...new Set(all.filter((s) => s.stack === stack).map((s) => s.stage))].toSorted());
|
|
1863
|
+
return {
|
|
1864
|
+
id: "unifi-network",
|
|
1865
|
+
getVersion: () => Effect.succeed(STATE_STORE_VERSION),
|
|
1866
|
+
listStacks: () => Effect.map(stored, (all) => [...new Set(all.map((s) => s.stack))].toSorted()),
|
|
1867
|
+
listStages,
|
|
1868
|
+
get: (request) => Effect.map(stageFor(request), (s) => {
|
|
1869
|
+
const record = revive(s.resources[request.fqn]);
|
|
1870
|
+
return isRecord(record) ? record : void 0;
|
|
1871
|
+
}),
|
|
1872
|
+
getReplacedResources: (request) => Effect.map(stageFor(request), (s) => Object.values(s.resources).map(revive).filter(isReplaced)),
|
|
1873
|
+
list: (request) => Effect.map(stageFor(request), (s) => Object.keys(s.resources).toSorted()),
|
|
1874
|
+
set: (request) => change(request, (s) => {
|
|
1875
|
+
s.resources[request.fqn] = encodeState(request.value);
|
|
1876
|
+
}).pipe(Effect.as(request.value)),
|
|
1877
|
+
delete: (request) => change(request, (s) => {
|
|
1878
|
+
delete s.resources[request.fqn];
|
|
1879
|
+
}),
|
|
1880
|
+
deleteStack: ({ stack, stage }) => stage === void 0 ? Effect.flatMap(listStages(stack), (found) => Effect.forEach(found, (each) => deleteStage(stack, each), { discard: true })) : deleteStage(stack, stage),
|
|
1881
|
+
getOutput: (request) => Effect.map(stageFor(request), (s) => revive(s.output)),
|
|
1882
|
+
setOutput: (request) => change(request, (s) => {
|
|
1883
|
+
s.output = encodeState(request.value);
|
|
1884
|
+
}).pipe(Effect.as(request.value))
|
|
1885
|
+
};
|
|
1886
|
+
});
|
|
1887
|
+
const toStateStoreError = (what) => (cause) => new StateStoreError({
|
|
1888
|
+
message: `UniFi state store: ${what} failed: ${describeError(cause)}`,
|
|
1889
|
+
...cause instanceof Error ? { cause } : {}
|
|
1890
|
+
});
|
|
1891
|
+
const isAlreadyExists = (error) => Predicate.hasProperty(error, "_tag") && error._tag === "UnprocessableEntity" && Predicate.hasProperty(error, "message") && typeof error.message === "string" && error.message.includes("already exist");
|
|
1892
|
+
const policyBody = (zoneId, name, description) => ({
|
|
1893
|
+
enabled: false,
|
|
1894
|
+
name,
|
|
1895
|
+
description,
|
|
1896
|
+
action: { type: "BLOCK" },
|
|
1897
|
+
source: { zoneId },
|
|
1898
|
+
destination: { zoneId },
|
|
1899
|
+
ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
|
|
1900
|
+
loggingEnabled: false
|
|
1901
|
+
});
|
|
1902
|
+
const describeWrite = (description) => `writing ${description.length} bytes of state (a console stores up to about 16 MiB directly, 7.5 MiB through ui.com)`;
|
|
1903
|
+
/**
|
|
1904
|
+
* Runs with Debug and Trace records off: request bodies are the state,
|
|
1905
|
+
* which holds secrets (e.g. Wi-Fi passphrases). Floors already above Info
|
|
1906
|
+
* are left alone.
|
|
1907
|
+
*/
|
|
1908
|
+
const withoutDebugLogs = Effect.updateService(References.MinimumLogLevel, (level) => LogLevel.isLessThan(level, "Info") ? "Info" : level);
|
|
1909
|
+
/** The {@link StateBackend} of the profile's console and site. */
|
|
1910
|
+
const consoleBackend = Effect.gen(function* () {
|
|
1911
|
+
const { id: siteId } = yield* resolveSite();
|
|
1912
|
+
const context = yield* Effect.context();
|
|
1913
|
+
const run = (what, effect) => withConnection(effect).pipe(Effect.mapError(toStateStoreError(what)), Effect.provideContext(context), withoutDebugLogs);
|
|
1914
|
+
const gatewayZoneId = yield* Effect.cached(run("finding the Gateway zone", Stream.runCollect(Sdk.getFirewallZones.items({ siteId }))).pipe(Effect.flatMap((zones) => {
|
|
1915
|
+
const zone = zones.find((z) => z.metadata.origin === "SYSTEM_DEFINED" && z.name === "Gateway");
|
|
1916
|
+
return zone === void 0 ? Effect.fail(new StateStoreError({ message: "UniFi state store: the site has no Gateway zone." })) : Effect.succeed(zone.id);
|
|
1917
|
+
})));
|
|
1918
|
+
return {
|
|
1919
|
+
listPolicies: run("listing state policies", Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId })).pipe(Effect.map((policies) => policies.flatMap((p) => p.metadata.origin === "USER_DEFINED" && p.name.startsWith("alchemy-state/") ? [{
|
|
1920
|
+
id: p.id,
|
|
1921
|
+
name: p.name,
|
|
1922
|
+
description: p.description ?? ""
|
|
1923
|
+
}] : [])))),
|
|
1924
|
+
createPolicy: (name, description) => Effect.flatMap(gatewayZoneId, (zoneId) => run(describeWrite(description), Sdk.createFirewallPolicy({
|
|
1925
|
+
siteId,
|
|
1926
|
+
...policyBody(zoneId, name, description)
|
|
1927
|
+
}))).pipe(Effect.map((created) => created.id)),
|
|
1928
|
+
updatePolicy: (id, name, description) => Effect.flatMap(gatewayZoneId, (zoneId) => run(describeWrite(description), Sdk.updateFirewallPolicy({
|
|
1929
|
+
siteId,
|
|
1930
|
+
firewallPolicyId: id,
|
|
1931
|
+
...policyBody(zoneId, name, description)
|
|
1932
|
+
}))).pipe(Effect.asVoid),
|
|
1933
|
+
deletePolicy: (id) => run("deleting a state policy", Sdk.deleteFirewallPolicy({
|
|
1934
|
+
siteId,
|
|
1935
|
+
firewallPolicyId: id
|
|
1936
|
+
}).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void))),
|
|
1937
|
+
createLock: (name) => run("taking the lock", Sdk.createTrafficMatchingList({
|
|
1938
|
+
siteId,
|
|
1939
|
+
body: {
|
|
1940
|
+
type: "PORTS",
|
|
1941
|
+
name,
|
|
1942
|
+
items: [{
|
|
1943
|
+
type: "PORT_NUMBER",
|
|
1944
|
+
value: 1
|
|
1945
|
+
}]
|
|
1946
|
+
}
|
|
1947
|
+
}).pipe(Effect.map((created) => created.id), Effect.catchIf(isAlreadyExists, () => Effect.undefined))),
|
|
1948
|
+
findLock: (name) => run("finding the lock", Stream.runCollect(Sdk.getTrafficMatchingLists.items({ siteId })).pipe(Effect.map((lists) => lists.find((l) => l.name === name)?.id))),
|
|
1949
|
+
lockExists: (id) => run("checking the lock", Sdk.getTrafficMatchingList({
|
|
1950
|
+
siteId,
|
|
1951
|
+
trafficMatchingListId: id
|
|
1952
|
+
}).pipe(Effect.as(true), Effect.catchTag("NotFound", () => Effect.succeed(false)))),
|
|
1953
|
+
deleteLock: (id) => run("releasing the lock", Sdk.deleteTrafficMatchingList({
|
|
1954
|
+
siteId,
|
|
1955
|
+
trafficMatchingListId: id
|
|
1956
|
+
}).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void)))
|
|
1957
|
+
};
|
|
1958
|
+
}).pipe(Effect.mapError((error) => error instanceof StateStoreError ? error : toStateStoreError("finding the site")(error)));
|
|
1959
|
+
/**
|
|
1960
|
+
* State store on the console the profile points at: each stack and stage
|
|
1961
|
+
* is a disabled Gateway → Gateway firewall policy holding the stage's
|
|
1962
|
+
* compressed state, locked with a traffic-matching list while a run uses
|
|
1963
|
+
* it. Nothing connects until the first state operation.
|
|
1964
|
+
*
|
|
1965
|
+
* The state includes secrets in plaintext, as in every Alchemy store: a
|
|
1966
|
+
* UniFi stack's are already on the console, but another provider's
|
|
1967
|
+
* resources in the same stack would put theirs there too.
|
|
1968
|
+
*
|
|
1969
|
+
* @example
|
|
1970
|
+
* ```ts
|
|
1971
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
1972
|
+
* import * as Alchemy from "alchemy";
|
|
1973
|
+
* import * as Effect from "effect/Effect";
|
|
1974
|
+
*
|
|
1975
|
+
* export default Alchemy.Stack(
|
|
1976
|
+
* "HomeNetwork",
|
|
1977
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
1978
|
+
* Effect.gen(function* () {
|
|
1979
|
+
* yield* UnifiNetwork.FirewallZone("iot-zone", { name: "IoT" });
|
|
1980
|
+
* }),
|
|
1981
|
+
* );
|
|
1982
|
+
* ```
|
|
1983
|
+
*/
|
|
1984
|
+
const state = (options = {}) => Layer.effect(State, Effect.gen(function* () {
|
|
1985
|
+
const scope = yield* Effect.scope;
|
|
1986
|
+
const context = yield* Effect.context();
|
|
1987
|
+
const make = Effect.flatMap(consoleBackend, (backend) => makeUnifiState(backend, options)).pipe(Scope.provide(scope), recordStateStoreInit, Effect.orDie, Effect.provideContext(context));
|
|
1988
|
+
return yield* Effect.cached(make);
|
|
1989
|
+
})).pipe(Layer.provide(Layer.merge(layer$1, layer).pipe(Layer.provideMerge(fromAuthProvider()), Layer.provideMerge(UnifiNetworkAuth))), Layer.orDie);
|
|
1990
|
+
// src/FirewallPolicyOrdering.ts
|
|
1991
|
+
/** An ordering that can't be applied as given. */
|
|
1992
|
+
var InvalidFirewallPolicyOrdering = class extends Data.TaggedError("InvalidFirewallPolicyOrdering") {
|
|
1993
|
+
[UserFacingError] = true;
|
|
1994
|
+
get message() {
|
|
1995
|
+
return `Can't apply the firewall policy ordering: ${this.reason}`;
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1998
|
+
const sameOrder = (a, b) => a.beforeSystemDefined.join() === b.beforeSystemDefined.join() && a.afterSystemDefined.join() === b.afterSystemDefined.join();
|
|
1999
|
+
const resolvePair = Effect.fn(function* (props) {
|
|
2000
|
+
const site = yield* resolveSite(props.site);
|
|
2001
|
+
return {
|
|
2002
|
+
site,
|
|
2003
|
+
siteId: site.id,
|
|
2004
|
+
sourceFirewallZoneId: yield* resolveFirewallZone(props.source, site),
|
|
2005
|
+
destinationFirewallZoneId: yield* resolveFirewallZone(props.destination, site)
|
|
2006
|
+
};
|
|
2007
|
+
});
|
|
2008
|
+
const getOrder = ({ siteId, sourceFirewallZoneId, destinationFirewallZoneId }) => withConnection(Sdk.getFirewallPolicyOrdering({
|
|
2009
|
+
siteId,
|
|
2010
|
+
sourceFirewallZoneId,
|
|
2011
|
+
destinationFirewallZoneId
|
|
2012
|
+
})).pipe(Effect.map((ordering) => ordering.orderedFirewallPolicyIds));
|
|
2013
|
+
const toAttributes$1 = (pair, order, listedFirewallPolicyIds) => ({
|
|
2014
|
+
siteId: pair.siteId,
|
|
2015
|
+
sourceFirewallZoneId: pair.sourceFirewallZoneId,
|
|
2016
|
+
destinationFirewallZoneId: pair.destinationFirewallZoneId,
|
|
2017
|
+
beforeSystemDefined: order.beforeSystemDefined,
|
|
2018
|
+
afterSystemDefined: order.afterSystemDefined,
|
|
2019
|
+
listedFirewallPolicyIds
|
|
2020
|
+
});
|
|
2021
|
+
const resolvePolicies = (references, site) => Effect.forEach(references ?? [], (reference) => checkSameSite("firewall policy", reference.siteId, site).pipe(Effect.as(reference.firewallPolicyId)));
|
|
2022
|
+
/** Why a listed policy isn't in the zone pair's ordering. */
|
|
2023
|
+
const explainMissing = Effect.fn(function* (site, firewallPolicyId, pair) {
|
|
2024
|
+
const policy = (yield* listFirewallPolicies(site.id)).find((p) => p.id === firewallPolicyId);
|
|
2025
|
+
if (policy === void 0) return `policy ${firewallPolicyId} doesn't exist`;
|
|
2026
|
+
if (!isUserDefined(policy)) return `"${policy.name}" isn't user-defined, so it can't be ordered`;
|
|
2027
|
+
if (policy.source.zoneId !== pair.sourceFirewallZoneId || policy.destination.zoneId !== pair.destinationFirewallZoneId) return `"${policy.name}" belongs to another zone pair`;
|
|
2028
|
+
return `"${policy.name}" isn't in the zone pair's ordering`;
|
|
2029
|
+
});
|
|
2030
|
+
/**
|
|
2031
|
+
* The state store's policies (`UnifiNetwork.state()`) among `ids`. The store
|
|
2032
|
+
* may have created one since the run's policy list was read, so policies
|
|
2033
|
+
* missing from it are fetched.
|
|
2034
|
+
*/
|
|
2035
|
+
const statePolicies = Effect.fn(function* (site, ids) {
|
|
2036
|
+
const policies = yield* listFirewallPolicies(site.id);
|
|
2037
|
+
const names = yield* Effect.forEach(ids, (id) => {
|
|
2038
|
+
const listed = policies.find((p) => p.id === id);
|
|
2039
|
+
return listed !== void 0 ? Effect.succeed(listed.name) : withConnection(Sdk.getFirewallPolicy({
|
|
2040
|
+
siteId: site.id,
|
|
2041
|
+
firewallPolicyId: id
|
|
2042
|
+
})).pipe(Effect.map((policy) => policy.name), Effect.catchTag("NotFound", () => Effect.undefined));
|
|
2043
|
+
});
|
|
2044
|
+
return ids.filter((_, i) => names[i]?.startsWith(STATE_POLICY_PREFIX) === true);
|
|
2045
|
+
});
|
|
2046
|
+
const describePolicies = Effect.fn(function* (site, ids) {
|
|
2047
|
+
const policies = yield* listFirewallPolicies(site.id);
|
|
2048
|
+
return ids.map((id) => `"${policies.find((p) => p.id === id)?.name ?? id}"`).join(", ");
|
|
2049
|
+
});
|
|
2050
|
+
/**
|
|
2051
|
+
* The evaluation order of user-defined policies from one zone to another.
|
|
2052
|
+
* Listing policies makes the ordering depend on them, so it's applied after
|
|
2053
|
+
* they're created or moved, and re-applied when the list changes.
|
|
2054
|
+
*
|
|
2055
|
+
* UniFi rejects an ordering that leaves out any of the pair's policies, so
|
|
2056
|
+
* unlisted ones (e.g. made in the UI) are kept after the listed ones in each
|
|
2057
|
+
* bucket, in their current relative order, with a warning; `exclusive: true`
|
|
2058
|
+
* fails instead. A policy the ordering listed before but no longer does is
|
|
2059
|
+
* kept quietly, even when exclusive: it's usually being deleted in the same
|
|
2060
|
+
* deploy, which runs after the ordering is applied. The state store's
|
|
2061
|
+
* policies (`UnifiNetwork.state()`, Gateway → Gateway) are kept quietly too.
|
|
2062
|
+
* System-defined policies sit between the two buckets and
|
|
2063
|
+
* can't be reordered; policies UniFi derives from UI Objects aren't ordered
|
|
2064
|
+
* at all.
|
|
2065
|
+
*
|
|
2066
|
+
* The zone pair is the identity: an existing ordering is adopted without
|
|
2067
|
+
* `--adopt`, and deleting the resource leaves the order as it is.
|
|
2068
|
+
*
|
|
2069
|
+
* @example
|
|
2070
|
+
* ```ts
|
|
2071
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
2072
|
+
* import * as Alchemy from "alchemy";
|
|
2073
|
+
* import * as Effect from "effect/Effect";
|
|
2074
|
+
*
|
|
2075
|
+
* export default Alchemy.Stack(
|
|
2076
|
+
* "HomeNetwork",
|
|
2077
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
2078
|
+
* Effect.gen(function* () {
|
|
2079
|
+
* const { INTERNAL } = UnifiNetwork.FirewallZone;
|
|
2080
|
+
* const iot = yield* UnifiNetwork.FirewallZone("iot-zone", { name: "IoT" });
|
|
2081
|
+
* const policy = { enabled: true, source: { zone: INTERNAL }, destination: { zone: iot }, loggingEnabled: false } as const;
|
|
2082
|
+
* const allowHomeAssistant = yield* UnifiNetwork.FirewallPolicy("allow-ha", {
|
|
2083
|
+
* ...policy,
|
|
2084
|
+
* name: "Allow Home Assistant",
|
|
2085
|
+
* action: { type: "ALLOW", allowReturnTraffic: true },
|
|
2086
|
+
* ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
|
|
2087
|
+
* });
|
|
2088
|
+
* const blockTheRest = yield* UnifiNetwork.FirewallPolicy("block-rest", {
|
|
2089
|
+
* ...policy,
|
|
2090
|
+
* name: "Block the rest",
|
|
2091
|
+
* action: { type: "BLOCK" },
|
|
2092
|
+
* ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
|
|
2093
|
+
* });
|
|
2094
|
+
* yield* UnifiNetwork.FirewallPolicyOrdering("internal-to-iot", {
|
|
2095
|
+
* source: INTERNAL,
|
|
2096
|
+
* destination: iot,
|
|
2097
|
+
* beforeSystemDefined: [allowHomeAssistant, blockTheRest],
|
|
2098
|
+
* });
|
|
2099
|
+
* }),
|
|
2100
|
+
* );
|
|
2101
|
+
* ```
|
|
2102
|
+
*/
|
|
2103
|
+
const FirewallPolicyOrdering = Resource("UnifiNetwork.FirewallPolicyOrdering");
|
|
2104
|
+
const FirewallPolicyOrderingProvider = () => Provider.effect(FirewallPolicyOrdering, Effect.gen(function* () {
|
|
2105
|
+
const run = yield* captureServices;
|
|
2106
|
+
return FirewallPolicyOrdering.Provider.of({
|
|
2107
|
+
list: () => Effect.succeed([]),
|
|
2108
|
+
read: ({ olds, output }) => run(Effect.gen(function* () {
|
|
2109
|
+
if (output !== void 0) return toAttributes$1(output, yield* getOrder(output), output.listedFirewallPolicyIds);
|
|
2110
|
+
const pair = yield* resolvePair(olds);
|
|
2111
|
+
return toAttributes$1(pair, yield* getOrder(pair), []);
|
|
2112
|
+
})),
|
|
2113
|
+
reconcile: ({ news, output }) => run(Effect.gen(function* () {
|
|
2114
|
+
const pair = yield* resolvePair(news);
|
|
2115
|
+
const { site } = pair;
|
|
2116
|
+
const before = yield* resolvePolicies(news.beforeSystemDefined, site);
|
|
2117
|
+
const after = yield* resolvePolicies(news.afterSystemDefined, site);
|
|
2118
|
+
const listed = [...before, ...after];
|
|
2119
|
+
const duplicate = listed.find((id, i) => listed.indexOf(id) !== i);
|
|
2120
|
+
if (duplicate !== void 0) return yield* new InvalidFirewallPolicyOrdering({ reason: `${yield* describePolicies(site, [duplicate])} is listed twice.` });
|
|
2121
|
+
const current = yield* getOrder(pair);
|
|
2122
|
+
const inPair = /* @__PURE__ */ new Set([...current.beforeSystemDefined, ...current.afterSystemDefined]);
|
|
2123
|
+
const missing = listed.find((id) => !inPair.has(id));
|
|
2124
|
+
if (missing !== void 0) return yield* new InvalidFirewallPolicyOrdering({ reason: `${yield* explainMissing(site, missing, pair)}.` });
|
|
2125
|
+
const unlisted = (ids) => ids.filter((id) => !listed.includes(id));
|
|
2126
|
+
const formerlyListed = output?.listedFirewallPolicyIds ?? [];
|
|
2127
|
+
const unlistedNow = [...unlisted(current.beforeSystemDefined), ...unlisted(current.afterSystemDefined)].filter((id) => !formerlyListed.includes(id));
|
|
2128
|
+
const stateIds = yield* statePolicies(site, unlistedNow);
|
|
2129
|
+
const unmanaged = unlistedNow.filter((id) => !stateIds.includes(id));
|
|
2130
|
+
if (unmanaged.length > 0) {
|
|
2131
|
+
const names = yield* describePolicies(site, unmanaged);
|
|
2132
|
+
if (news.exclusive === true) return yield* new InvalidFirewallPolicyOrdering({ reason: `the zone pair has policies the ordering doesn't list: ${names}. List them, delete them, or drop \`exclusive\`.` });
|
|
2133
|
+
yield* Effect.logWarning(`Firewall policy ordering: keeping unlisted policies after the listed ones: ${names}.`);
|
|
2134
|
+
}
|
|
2135
|
+
const desired = {
|
|
2136
|
+
beforeSystemDefined: [...before, ...unlisted(current.beforeSystemDefined)],
|
|
2137
|
+
afterSystemDefined: [...after, ...unlisted(current.afterSystemDefined)]
|
|
2138
|
+
};
|
|
2139
|
+
if (sameOrder(desired, current)) return toAttributes$1(pair, current, listed);
|
|
2140
|
+
const updated = yield* withConnection(Sdk.updateFirewallPolicyOrdering({
|
|
2141
|
+
siteId: pair.siteId,
|
|
2142
|
+
sourceFirewallZoneId: pair.sourceFirewallZoneId,
|
|
2143
|
+
destinationFirewallZoneId: pair.destinationFirewallZoneId,
|
|
2144
|
+
orderedFirewallPolicyIds: desired
|
|
2145
|
+
}));
|
|
2146
|
+
return toAttributes$1(pair, updated.orderedFirewallPolicyIds, listed);
|
|
2147
|
+
})),
|
|
2148
|
+
delete: () => Effect.void
|
|
2149
|
+
});
|
|
2150
|
+
}));
|
|
2151
|
+
// src/WifiBroadcast.ts
|
|
2152
|
+
const loadBroadcasts = (siteId) => withConnection(Stream.runCollect(Sdk.getWifiBroadcastPage.items({ siteId })));
|
|
2153
|
+
const broadcastsKey = key("wifiBroadcasts");
|
|
2154
|
+
/** A site's Wi-Fi broadcasts, listed once per run. */
|
|
2155
|
+
const listWifiBroadcasts = (siteId) => cached(broadcastsKey, siteId, loadBroadcasts(siteId));
|
|
2156
|
+
const toAttributes = (siteId, broadcast) => {
|
|
2157
|
+
const { id, metadata: _metadata, ...config } = broadcast;
|
|
2158
|
+
return {
|
|
2159
|
+
siteId,
|
|
2160
|
+
wifiBroadcastId: id,
|
|
2161
|
+
name: broadcast.name,
|
|
2162
|
+
type: broadcast.type,
|
|
2163
|
+
networkId: broadcast.network?.type === "SPECIFIC" ? broadcast.network.networkId : void 0,
|
|
2164
|
+
config
|
|
2165
|
+
};
|
|
2166
|
+
};
|
|
2167
|
+
const byFrequency = (bands) => bands.toSorted((a, b) => a - b);
|
|
2168
|
+
/**
|
|
2169
|
+
* `body` with its bands sorted (their order means nothing; see
|
|
2170
|
+
* {@link comparable}), and an explicit value for the one unset field that
|
|
2171
|
+
* can't simply be left out (verified): a standard broadcast's
|
|
2172
|
+
* `bandSteeringEnabled`, which the console reports as `false` when unset,
|
|
2173
|
+
* so without the default the body would never match and every deploy would
|
|
2174
|
+
* update it (and drift would report it) forever.
|
|
2175
|
+
*/
|
|
2176
|
+
const withDefaults = (body) => body.type === "STANDARD" ? {
|
|
2177
|
+
...body,
|
|
2178
|
+
bandSteeringEnabled: body.bandSteeringEnabled ?? false,
|
|
2179
|
+
broadcastingFrequenciesGHz: byFrequency(body.broadcastingFrequenciesGHz)
|
|
2180
|
+
} : body;
|
|
2181
|
+
/** Fields the console reports but rejects in request bodies. */
|
|
2182
|
+
const READ_ONLY = ["id", "metadata"];
|
|
2183
|
+
const toBody = Effect.fn(function* (props, site) {
|
|
2184
|
+
const { site: _site, network, ...rest } = props;
|
|
2185
|
+
if (network === void 0) return rest;
|
|
2186
|
+
if ("type" in network || network.default === true) return {
|
|
2187
|
+
...rest,
|
|
2188
|
+
network: { type: "NATIVE" }
|
|
2189
|
+
};
|
|
2190
|
+
const networkId = yield* resolveNetwork(network, site);
|
|
2191
|
+
return {
|
|
2192
|
+
...rest,
|
|
2193
|
+
network: {
|
|
2194
|
+
type: "SPECIFIC",
|
|
2195
|
+
networkId
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
});
|
|
2199
|
+
/**
|
|
2200
|
+
* The console's broadcast in body form, with its bands sorted: consoles keep
|
|
2201
|
+
* them in an order of their own (not the one sent; the test controller
|
|
2202
|
+
* sorts them), so order mustn't count as a difference.
|
|
2203
|
+
*/
|
|
2204
|
+
const comparable = (observed) => {
|
|
2205
|
+
const body = omitKeys(observed, READ_ONLY);
|
|
2206
|
+
return observed.type === "STANDARD" ? {
|
|
2207
|
+
...body,
|
|
2208
|
+
broadcastingFrequenciesGHz: byFrequency(observed.broadcastingFrequenciesGHz)
|
|
2209
|
+
} : body;
|
|
2210
|
+
};
|
|
2211
|
+
const getBroadcast = (siteId, wifiBroadcastId) => withConnection(Sdk.getWifiBroadcastDetails({
|
|
2212
|
+
siteId,
|
|
2213
|
+
wifiBroadcastId
|
|
2214
|
+
})).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
|
|
2215
|
+
/** The attributes of a broadcast known to exist (e.g. from a list). */
|
|
2216
|
+
const attributesOf = (siteId, wifiBroadcastId) => withConnection(Sdk.getWifiBroadcastDetails({
|
|
2217
|
+
siteId,
|
|
2218
|
+
wifiBroadcastId
|
|
2219
|
+
})).pipe(Effect.map((broadcast) => toAttributes(siteId, broadcast)));
|
|
2220
|
+
/**
|
|
2221
|
+
* A Wi-Fi network (SSID). `type` picks a standard or an IoT-optimised
|
|
2222
|
+
* broadcast; changing it, or the site, replaces the broadcast.
|
|
2223
|
+
*
|
|
2224
|
+
* Props are authoritative: every update sends the whole broadcast, with
|
|
2225
|
+
* explicit defaults for unset fields (a standard broadcast's
|
|
2226
|
+
* `bandSteeringEnabled: false`), so changes made in the UI, passphrases
|
|
2227
|
+
* included, are put back. The `config` attribute mirrors the console's
|
|
2228
|
+
* object, so `alchemy drift` sees them. Existing broadcasts are adopted by name with
|
|
2229
|
+
* `--adopt`.
|
|
2230
|
+
*
|
|
2231
|
+
* @example
|
|
2232
|
+
* ```ts
|
|
2233
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
2234
|
+
* import * as Alchemy from "alchemy";
|
|
2235
|
+
* import * as Config from "effect/Config";
|
|
2236
|
+
* import * as Effect from "effect/Effect";
|
|
2237
|
+
*
|
|
2238
|
+
* export default Alchemy.Stack(
|
|
2239
|
+
* "HomeNetwork",
|
|
2240
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
2241
|
+
* Effect.gen(function* () {
|
|
2242
|
+
* const lan = yield* UnifiNetwork.Network.lookup({ vlanId: 1 });
|
|
2243
|
+
* yield* UnifiNetwork.WifiBroadcast("home", {
|
|
2244
|
+
* type: "STANDARD",
|
|
2245
|
+
* name: "Home",
|
|
2246
|
+
* enabled: true,
|
|
2247
|
+
* network: lan,
|
|
2248
|
+
* securityConfiguration: {
|
|
2249
|
+
* type: "WPA2_PERSONAL",
|
|
2250
|
+
* passphrase: yield* Config.Redacted("WIFI_PASSPHRASE"),
|
|
2251
|
+
* fastRoamingEnabled: false,
|
|
2252
|
+
* },
|
|
2253
|
+
* broadcastingFrequenciesGHz: [2.4, 5],
|
|
2254
|
+
* hideName: false,
|
|
2255
|
+
* clientIsolationEnabled: false,
|
|
2256
|
+
* multicastToUnicastConversionEnabled: false,
|
|
2257
|
+
* uapsdEnabled: false,
|
|
2258
|
+
* channel2gLockedTo6: false,
|
|
2259
|
+
* dtimPeriod2gLockedTo3: false,
|
|
2260
|
+
* advertiseDeviceName: false,
|
|
2261
|
+
* arpProxyEnabled: false,
|
|
2262
|
+
* bssTransitionEnabled: true,
|
|
2263
|
+
* });
|
|
2264
|
+
* }),
|
|
2265
|
+
* );
|
|
2266
|
+
* ```
|
|
2267
|
+
*/
|
|
2268
|
+
const WifiBroadcast = Resource("UnifiNetwork.WifiBroadcast")({
|
|
2269
|
+
NATIVE_NETWORK: { type: "NATIVE" },
|
|
2270
|
+
/**
|
|
2271
|
+
* A broadcast in the site by name. Dies with a user-facing
|
|
2272
|
+
* `LookupNotFound` or `LookupAmbiguous`: stack bodies can't fail with typed
|
|
2273
|
+
* errors.
|
|
2274
|
+
*/
|
|
2275
|
+
lookup: Effect.fn("UnifiNetwork.WifiBroadcast.lookup")(function* (query) {
|
|
2276
|
+
const site = yield* resolveSite(query.site);
|
|
2277
|
+
const broadcasts = (yield* listWifiBroadcasts(site.id)).filter((b) => b.name === query.name);
|
|
2278
|
+
const [broadcast] = broadcasts;
|
|
2279
|
+
if (broadcast === void 0) return yield* new LookupNotFound({
|
|
2280
|
+
resource: "Wi-Fi broadcast",
|
|
2281
|
+
query: describeQuery(query),
|
|
2282
|
+
site
|
|
2283
|
+
});
|
|
2284
|
+
if (broadcasts.length > 1) return yield* new LookupAmbiguous({
|
|
2285
|
+
resource: "Wi-Fi broadcast",
|
|
2286
|
+
query: describeQuery(query),
|
|
2287
|
+
site,
|
|
2288
|
+
count: broadcasts.length
|
|
2289
|
+
});
|
|
2290
|
+
return yield* attributesOf(site.id, broadcast.id);
|
|
2291
|
+
}, Effect.orDie)
|
|
2292
|
+
});
|
|
2293
|
+
const WifiBroadcastProvider = () => Provider.effect(WifiBroadcast, Effect.gen(function* () {
|
|
2294
|
+
const run = yield* captureServices;
|
|
2295
|
+
return WifiBroadcast.Provider.of({
|
|
2296
|
+
list: () => run(Effect.gen(function* () {
|
|
2297
|
+
const site = yield* resolveSite();
|
|
2298
|
+
const broadcasts = yield* listWifiBroadcasts(site.id);
|
|
2299
|
+
return yield* Effect.forEach(broadcasts.filter((b) => b.metadata.origin === "USER_DEFINED"), (b) => attributesOf(site.id, b.id));
|
|
2300
|
+
})),
|
|
2301
|
+
read: ({ olds, output }) => run(Effect.gen(function* () {
|
|
2302
|
+
if (output !== void 0) {
|
|
2303
|
+
const broadcast = yield* getBroadcast(output.siteId, output.wifiBroadcastId);
|
|
2304
|
+
return broadcast === void 0 ? void 0 : toAttributes(output.siteId, broadcast);
|
|
2305
|
+
}
|
|
2306
|
+
const site = yield* resolveSite(olds.site);
|
|
2307
|
+
const matching = (yield* listWifiBroadcasts(site.id)).filter((b) => b.name === olds.name);
|
|
2308
|
+
const [broadcast] = matching;
|
|
2309
|
+
return broadcast === void 0 || matching.length > 1 ? void 0 : Unowned(yield* attributesOf(site.id, broadcast.id));
|
|
2310
|
+
})),
|
|
2311
|
+
diff: ({ news, output }) => run(Effect.gen(function* () {
|
|
2312
|
+
if (output === void 0) return void 0;
|
|
2313
|
+
const type = planned(news, "type");
|
|
2314
|
+
if (type !== UNKNOWN && type !== output.type) return { action: "replace" };
|
|
2315
|
+
const siteProp = plannedString(news, "site");
|
|
2316
|
+
if (siteProp === UNKNOWN) return void 0;
|
|
2317
|
+
if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
|
|
2318
|
+
})),
|
|
2319
|
+
reconcile: ({ news, output }) => run(Effect.gen(function* () {
|
|
2320
|
+
const site = yield* resolveSite(news.site);
|
|
2321
|
+
const body = withDefaults(yield* toBody(news, site));
|
|
2322
|
+
const observed = output !== void 0 && output.siteId === site.id ? yield* getBroadcast(site.id, output.wifiBroadcastId) : void 0;
|
|
2323
|
+
const write = invalidating(broadcastsKey, site.id);
|
|
2324
|
+
if (observed === void 0) {
|
|
2325
|
+
const created = yield* withConnection(Sdk.createWifiBroadcast({
|
|
2326
|
+
siteId: site.id,
|
|
2327
|
+
body
|
|
2328
|
+
})).pipe(write);
|
|
2329
|
+
return toAttributes(site.id, created);
|
|
2330
|
+
}
|
|
2331
|
+
if (isCurrent(body, comparable(observed))) return toAttributes(site.id, observed);
|
|
2332
|
+
const updated = yield* withConnection(Sdk.updateWifiBroadcast({
|
|
2333
|
+
siteId: site.id,
|
|
2334
|
+
wifiBroadcastId: observed.id,
|
|
2335
|
+
body
|
|
2336
|
+
})).pipe(write);
|
|
2337
|
+
return toAttributes(site.id, updated);
|
|
2338
|
+
})),
|
|
2339
|
+
delete: ({ output }) => run(withConnection(Sdk.deleteWifiBroadcast({
|
|
2340
|
+
siteId: output.siteId,
|
|
2341
|
+
wifiBroadcastId: output.wifiBroadcastId
|
|
2342
|
+
})).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(broadcastsKey, output.siteId)))
|
|
2343
|
+
});
|
|
2344
|
+
}));
|
|
2345
|
+
// src/Providers.ts
|
|
2346
|
+
var Providers = class extends Provider.ProviderCollection()("UnifiNetwork") {};
|
|
2347
|
+
/**
|
|
2348
|
+
* Registers every UniFi Network resource provider, the `UnifiNetwork` auth
|
|
2349
|
+
* provider, the lazily-resolved credentials and connection, and the per-run
|
|
2350
|
+
* read cache. Merge
|
|
2351
|
+
* alongside other cloud `providers()` layers in a Stack.
|
|
2352
|
+
*
|
|
2353
|
+
* Its requirements (`AuthProviders`, `ProfileStore`, `FileSystem`, `Path`)
|
|
2354
|
+
* come from Alchemy's runtime; it provides only its own services.
|
|
2355
|
+
*
|
|
2356
|
+
* Unlike most providers this doesn't provide an `HttpClient`: ours carries
|
|
2357
|
+
* the console's TLS settings, and lifecycle operations run in the stack's
|
|
2358
|
+
* merged context, so it would replace other providers' clients. SDK calls
|
|
2359
|
+
* get it through `UnifiNetworkConnection` instead.
|
|
2360
|
+
*/
|
|
2361
|
+
const providers = () => Layer.effect(Providers, Provider.collection([
|
|
2362
|
+
FirewallZone,
|
|
2363
|
+
Network,
|
|
2364
|
+
WifiBroadcast,
|
|
2365
|
+
TrafficMatchingList,
|
|
2366
|
+
FirewallPolicy,
|
|
2367
|
+
FirewallPolicyOrdering,
|
|
2368
|
+
DnsPolicy
|
|
2369
|
+
])).pipe(Layer.provide(Layer.mergeAll(FirewallZoneProvider(), NetworkProvider(), WifiBroadcastProvider(), TrafficMatchingListProvider(), FirewallPolicyProvider(), FirewallPolicyOrderingProvider(), DnsPolicyProvider())), Layer.provideMerge(Layer.merge(layer$1, layer)), Layer.provideMerge(fromAuthProvider()), Layer.provideMerge(UnifiNetworkAuth), Layer.orDie);
|
|
2370
|
+
export { AmbiguousSite, CrossSiteReference, DEFAULT_WARN_AT, DefaultNetworkReplacement, DnsPolicy, DnsPolicyProvider, FirewallPolicy, FirewallPolicyOrdering, FirewallPolicyOrderingProvider, FirewallPolicyProvider, FirewallZone, FirewallZoneProvider, InvalidFirewallPolicyOrdering, LookupAmbiguous, LookupNotFound, Network, NetworkProvider, Providers, ReadCache_exports as ReadCache, SiteNotFound, SiteRequired, TrafficMatchingList, TrafficMatchingListInUse, TrafficMatchingListProvider, UNIFI_NETWORK_AUTH_PROVIDER_NAME, UnifiNetworkAuth, UnifiNetworkAuthConfigSchema, UnifiNetworkConnection, UnifiNetworkCredentials, UnifiNetworkReadCache, WifiBroadcast, WifiBroadcastProvider, checkSameSite, describeQuery, fromAuthProvider, isUserDefined, listDnsPolicies, listFirewallPolicies, listFirewallZones, listNetworks, listSites, listTrafficMatchingLists, listWifiBroadcasts, providers, resolveFirewallZone, resolveNetwork, resolveSite, resolveTrafficMatchingList, sitesKey, state, withConnection };
|
|
2371
|
+
|
|
2372
|
+
//# sourceMappingURL=index.mjs.map
|