@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/src/State.ts
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import * as Sdk from "@alchemy-unifi/network-sdk";
|
|
2
|
+
import { STATE_STORE_VERSION } from "alchemy/State/HttpStateApi";
|
|
3
|
+
import type { ReplacedResourceState } from "alchemy/State/ResourceState";
|
|
4
|
+
import { type PersistedState, State, type StateService, StateStoreError } from "alchemy/State/State";
|
|
5
|
+
import { encodeState, reviveState } from "alchemy/State/StateEncoding";
|
|
6
|
+
import { recordStateStoreInit } from "alchemy/Telemetry/Metrics";
|
|
7
|
+
import * as Clock from "effect/Clock";
|
|
8
|
+
import * as Effect from "effect/Effect";
|
|
9
|
+
import type * as HttpClient from "effect/http/HttpClient";
|
|
10
|
+
import * as Layer from "effect/Layer";
|
|
11
|
+
import * as LogLevel from "effect/LogLevel";
|
|
12
|
+
import * as Predicate from "effect/Predicate";
|
|
13
|
+
import * as References from "effect/References";
|
|
14
|
+
import * as Scope from "effect/Scope";
|
|
15
|
+
import * as Semaphore from "effect/Semaphore";
|
|
16
|
+
import * as Stream from "effect/Stream";
|
|
17
|
+
import * as zlib from "node:zlib";
|
|
18
|
+
import { UnifiNetworkAuth } from "./AuthProvider.ts";
|
|
19
|
+
import * as Connection from "./Connection.ts";
|
|
20
|
+
import * as Credentials from "./Credentials.ts";
|
|
21
|
+
import * as ReadCache from "./ReadCache.ts";
|
|
22
|
+
import { resolveSite } from "./Site.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* State store on the console itself (see `docs/development/state-store.md`). Each stack and
|
|
26
|
+
* stage is one disabled Gateway → Gateway firewall policy whose description
|
|
27
|
+
* holds the stage's state, and a traffic-matching list (whose names, unlike
|
|
28
|
+
* policies', are unique) is its lock.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Name prefix of the policies holding state. Other code leaves these alone. */
|
|
32
|
+
export const STATE_POLICY_PREFIX = "alchemy-state/";
|
|
33
|
+
/** Name prefix of the traffic-matching lists that lock a stage. */
|
|
34
|
+
export const STATE_LOCK_PREFIX = "alchemy-lock/";
|
|
35
|
+
|
|
36
|
+
/** Prefix of a state policy's description, ahead of the base64 of the Brotli-compressed JSON. */
|
|
37
|
+
const FORMAT = "alchemy-state:v1:";
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_WARN_AT = 4_000_000;
|
|
40
|
+
|
|
41
|
+
/** How long a lock stays valid without a renewal. */
|
|
42
|
+
const LOCK_TTL_MS = 120_000;
|
|
43
|
+
const RENEW_EVERY_MS = 30_000;
|
|
44
|
+
/** How long a passing check that the lock still exists is trusted. */
|
|
45
|
+
const LOCK_CHECK_TTL_MS = 5_000;
|
|
46
|
+
|
|
47
|
+
export interface UnifiStateOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Warn once a stage's stored state passes this many bytes (compressed and
|
|
50
|
+
* base64-encoded), as a prompt to move to a store without the console's
|
|
51
|
+
* limits. A console stores up to about 16 MiB directly and 7.5 MiB through
|
|
52
|
+
* ui.com.
|
|
53
|
+
*
|
|
54
|
+
* @default 4_000_000
|
|
55
|
+
*/
|
|
56
|
+
readonly warnAt?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A state policy as the store sees it. */
|
|
60
|
+
export interface StatePolicy {
|
|
61
|
+
readonly id: string;
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly description: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The console operations the store needs. Every operation fails with
|
|
68
|
+
* {@link StateStoreError}; deletes of missing objects succeed.
|
|
69
|
+
*/
|
|
70
|
+
export interface StateBackend {
|
|
71
|
+
/** Every policy whose name starts with {@link STATE_POLICY_PREFIX}. */
|
|
72
|
+
readonly listPolicies: Effect.Effect<ReadonlyArray<StatePolicy>, StateStoreError>;
|
|
73
|
+
readonly createPolicy: (name: string, description: string) => Effect.Effect<string, StateStoreError>;
|
|
74
|
+
readonly updatePolicy: (id: string, name: string, description: string) => Effect.Effect<void, StateStoreError>;
|
|
75
|
+
readonly deletePolicy: (id: string) => Effect.Effect<void, StateStoreError>;
|
|
76
|
+
/** Creates the lock list, returning its ID, or `undefined` when one with the name exists. */
|
|
77
|
+
readonly createLock: (name: string) => Effect.Effect<string | undefined, StateStoreError>;
|
|
78
|
+
readonly findLock: (name: string) => Effect.Effect<string | undefined, StateStoreError>;
|
|
79
|
+
readonly lockExists: (id: string) => Effect.Effect<boolean, StateStoreError>;
|
|
80
|
+
readonly deleteLock: (id: string) => Effect.Effect<void, StateStoreError>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface LockRecord {
|
|
84
|
+
readonly listId: string;
|
|
85
|
+
readonly expiresAt: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What a state policy's description holds. Records are kept as `encodeState` output. */
|
|
89
|
+
interface Snapshot {
|
|
90
|
+
readonly lock?: LockRecord | undefined;
|
|
91
|
+
readonly resources: Readonly<Record<string, unknown>>;
|
|
92
|
+
readonly output?: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const policyName = (stack: string, stage: string) => `${STATE_POLICY_PREFIX}${stack}/${stage}`;
|
|
96
|
+
export const lockName = (stack: string, stage: string) => `${STATE_LOCK_PREFIX}${stack}/${stage}`;
|
|
97
|
+
|
|
98
|
+
const parsePolicyName = (name: string) => {
|
|
99
|
+
const [stack, stage, ...rest] = name.slice(STATE_POLICY_PREFIX.length).split("/");
|
|
100
|
+
return stack === undefined || stage === undefined || rest.length > 0 ? undefined : { stack, stage };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const encodeSnapshot = (snapshot: Snapshot): string =>
|
|
104
|
+
FORMAT +
|
|
105
|
+
zlib
|
|
106
|
+
.brotliCompressSync(JSON.stringify(snapshot), {
|
|
107
|
+
// Quality 11 is several times slower on a large state, for a few percent.
|
|
108
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 9 },
|
|
109
|
+
})
|
|
110
|
+
.toString("base64");
|
|
111
|
+
|
|
112
|
+
const isLockRecord = (value: unknown): value is LockRecord =>
|
|
113
|
+
Predicate.hasProperty(value, "listId") &&
|
|
114
|
+
typeof value.listId === "string" &&
|
|
115
|
+
Predicate.hasProperty(value, "expiresAt") &&
|
|
116
|
+
typeof value.expiresAt === "number";
|
|
117
|
+
|
|
118
|
+
export const decodeSnapshot = (policy: StatePolicy): Effect.Effect<Snapshot, StateStoreError> =>
|
|
119
|
+
Effect.try({
|
|
120
|
+
try: (): Snapshot => {
|
|
121
|
+
if (!policy.description.startsWith(FORMAT)) throw new Error("its description isn't in a known format");
|
|
122
|
+
const parsed: unknown = JSON.parse(
|
|
123
|
+
zlib.brotliDecompressSync(Buffer.from(policy.description.slice(FORMAT.length), "base64")).toString(),
|
|
124
|
+
);
|
|
125
|
+
if (!Predicate.isObject(parsed) || !Predicate.isObject(parsed["resources"])) {
|
|
126
|
+
throw new Error("it has no resources");
|
|
127
|
+
}
|
|
128
|
+
const lock = isLockRecord(parsed["lock"]) ? parsed["lock"] : undefined;
|
|
129
|
+
return { lock, resources: { ...parsed["resources"] }, output: parsed["output"] };
|
|
130
|
+
},
|
|
131
|
+
catch: (cause) =>
|
|
132
|
+
new StateStoreError({
|
|
133
|
+
message: `UniFi state policy '${policy.name}' can't be read: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
134
|
+
...(cause instanceof Error ? { cause } : {}),
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** A copy of a stored value, revived (e.g. `Redacted` values rebuilt). */
|
|
139
|
+
const revive = (encoded: unknown): unknown =>
|
|
140
|
+
encoded === undefined ? undefined : JSON.parse(JSON.stringify(encoded), reviveState);
|
|
141
|
+
|
|
142
|
+
// Records are only ever written by `set`, so a stored object is a record.
|
|
143
|
+
const isRecord = (value: unknown): value is PersistedState => Predicate.isObject(value);
|
|
144
|
+
const isReplaced = (value: unknown): value is ReplacedResourceState =>
|
|
145
|
+
isRecord(value) && "status" in value && value.status === "replaced";
|
|
146
|
+
|
|
147
|
+
const heldError = (stack: string, stage: string, detail: string) =>
|
|
148
|
+
new StateStoreError({ message: `Another run holds the UniFi state lock on '${stack}/${stage}': ${detail}` });
|
|
149
|
+
|
|
150
|
+
/** A stage this store has locked and loaded. */
|
|
151
|
+
interface OpenStage {
|
|
152
|
+
readonly stack: string;
|
|
153
|
+
readonly stage: string;
|
|
154
|
+
readonly listId: string;
|
|
155
|
+
policyId: string | undefined;
|
|
156
|
+
resources: Record<string, unknown>;
|
|
157
|
+
output: unknown;
|
|
158
|
+
/** Bumped by every change; `written` is the latest one on the console. */
|
|
159
|
+
version: number;
|
|
160
|
+
written: number;
|
|
161
|
+
lockCheckedAt: number | undefined;
|
|
162
|
+
lost: boolean;
|
|
163
|
+
closed: boolean;
|
|
164
|
+
/** One write at a time. Changes that arrive during a write share the next one. */
|
|
165
|
+
readonly writer: Semaphore.Semaphore;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A {@link StateService} over `backend`. The first operation on a stage
|
|
170
|
+
* locks it and loads its state, and the lock is released when `Scope`
|
|
171
|
+
* closes.
|
|
172
|
+
*/
|
|
173
|
+
export const makeUnifiState = (backend: StateBackend, options: UnifiStateOptions = {}) =>
|
|
174
|
+
Effect.gen(function* () {
|
|
175
|
+
const scope = yield* Effect.scope;
|
|
176
|
+
const warnAt = options.warnAt ?? DEFAULT_WARN_AT;
|
|
177
|
+
let warned = false;
|
|
178
|
+
|
|
179
|
+
const findPolicy = Effect.fn(function* (stack: string, stage: string) {
|
|
180
|
+
const name = policyName(stack, stage);
|
|
181
|
+
const found = (yield* backend.listPolicies).filter((p) => p.name === name);
|
|
182
|
+
if (found.length > 1) {
|
|
183
|
+
return yield* new StateStoreError({
|
|
184
|
+
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).`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return found[0];
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const takeLock = Effect.fn(function* (stack: string, stage: string) {
|
|
191
|
+
const name = lockName(stack, stage);
|
|
192
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
193
|
+
const created = yield* backend.createLock(name);
|
|
194
|
+
if (created !== undefined) return created;
|
|
195
|
+
const holder = yield* backend.findLock(name);
|
|
196
|
+
// Released between the create and the find: try again.
|
|
197
|
+
if (holder === undefined) continue;
|
|
198
|
+
const policy = yield* findPolicy(stack, stage);
|
|
199
|
+
const record = policy === undefined ? undefined : (yield* decodeSnapshot(policy)).lock;
|
|
200
|
+
if (record === undefined || record.listId !== holder) {
|
|
201
|
+
return yield* heldError(
|
|
202
|
+
stack,
|
|
203
|
+
stage,
|
|
204
|
+
`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.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const now = yield* Clock.currentTimeMillis;
|
|
208
|
+
if (record.expiresAt > now) {
|
|
209
|
+
return yield* heldError(
|
|
210
|
+
stack,
|
|
211
|
+
stage,
|
|
212
|
+
`it expires at ${new Date(record.expiresAt).toISOString()} unless renewed.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
// The holder stopped renewing. Deleting by ID means only one run can break it:
|
|
216
|
+
// another breaker's delete misses, or its create finds the new list.
|
|
217
|
+
yield* backend.deleteLock(holder);
|
|
218
|
+
}
|
|
219
|
+
return yield* heldError(stack, stage, `the traffic-matching list '${name}' keeps reappearing.`);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const checkLock = (s: OpenStage) =>
|
|
223
|
+
Effect.gen(function* () {
|
|
224
|
+
const lostError = new StateStoreError({
|
|
225
|
+
message: `The UniFi state lock on '${s.stack}/${s.stage}' was lost mid-run (its traffic-matching list was deleted); refusing to continue unlocked.`,
|
|
226
|
+
});
|
|
227
|
+
if (s.lost) return yield* lostError;
|
|
228
|
+
const now = yield* Clock.currentTimeMillis;
|
|
229
|
+
if (s.lockCheckedAt !== undefined && now - s.lockCheckedAt < LOCK_CHECK_TTL_MS) return;
|
|
230
|
+
if (!(yield* backend.lockExists(s.listId))) {
|
|
231
|
+
s.lost = true;
|
|
232
|
+
return yield* lostError;
|
|
233
|
+
}
|
|
234
|
+
s.lockCheckedAt = now;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Writes the stage unless `upTo` is already on the console. Without
|
|
239
|
+
* `upTo` (a renewal) it writes only an existing policy.
|
|
240
|
+
*/
|
|
241
|
+
const flush = (s: OpenStage, upTo?: number) =>
|
|
242
|
+
Semaphore.withPermits(
|
|
243
|
+
s.writer,
|
|
244
|
+
1,
|
|
245
|
+
)(
|
|
246
|
+
Effect.gen(function* () {
|
|
247
|
+
if (s.closed) {
|
|
248
|
+
return yield* new StateStoreError({
|
|
249
|
+
message: `The UniFi state store for '${s.stack}/${s.stage}' is closed.`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (upTo === undefined ? s.policyId === undefined : s.written >= upTo) return;
|
|
253
|
+
yield* checkLock(s);
|
|
254
|
+
const version = s.version;
|
|
255
|
+
const expiresAt = (yield* Clock.currentTimeMillis) + LOCK_TTL_MS;
|
|
256
|
+
const description = encodeSnapshot({
|
|
257
|
+
lock: { listId: s.listId, expiresAt },
|
|
258
|
+
resources: s.resources,
|
|
259
|
+
output: s.output,
|
|
260
|
+
});
|
|
261
|
+
if (description.length > warnAt && !warned) {
|
|
262
|
+
warned = true;
|
|
263
|
+
yield* Effect.logWarning(
|
|
264
|
+
`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.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const name = policyName(s.stack, s.stage);
|
|
268
|
+
if (s.policyId === undefined) s.policyId = yield* backend.createPolicy(name, description);
|
|
269
|
+
else yield* backend.updatePolicy(s.policyId, name, description);
|
|
270
|
+
s.written = version;
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const openStage = Effect.fn(function* (stack: string, stage: string) {
|
|
275
|
+
const listId = yield* takeLock(stack, stage);
|
|
276
|
+
const s: OpenStage = {
|
|
277
|
+
stack,
|
|
278
|
+
stage,
|
|
279
|
+
listId,
|
|
280
|
+
policyId: undefined,
|
|
281
|
+
resources: {},
|
|
282
|
+
output: undefined,
|
|
283
|
+
version: 0,
|
|
284
|
+
written: 0,
|
|
285
|
+
lockCheckedAt: yield* Clock.currentTimeMillis,
|
|
286
|
+
lost: false,
|
|
287
|
+
closed: false,
|
|
288
|
+
writer: Semaphore.makeUnsafe(1),
|
|
289
|
+
};
|
|
290
|
+
// Added before the renewal is forked, so it runs after the renewal is interrupted.
|
|
291
|
+
yield* Scope.addFinalizer(
|
|
292
|
+
scope,
|
|
293
|
+
Semaphore.withPermits(
|
|
294
|
+
s.writer,
|
|
295
|
+
1,
|
|
296
|
+
)(
|
|
297
|
+
Effect.suspend(() => {
|
|
298
|
+
s.closed = true;
|
|
299
|
+
return backend.deleteLock(listId);
|
|
300
|
+
}).pipe(Effect.ignore),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
const policy = yield* findPolicy(stack, stage);
|
|
304
|
+
if (policy !== undefined) {
|
|
305
|
+
const snapshot = yield* decodeSnapshot(policy);
|
|
306
|
+
s.policyId = policy.id;
|
|
307
|
+
s.resources = { ...snapshot.resources };
|
|
308
|
+
s.output = snapshot.output;
|
|
309
|
+
// Record the lock, so another run can tell when it goes stale.
|
|
310
|
+
yield* flush(s);
|
|
311
|
+
}
|
|
312
|
+
yield* flush(s).pipe(
|
|
313
|
+
Effect.ignore,
|
|
314
|
+
Effect.delay(RENEW_EVERY_MS),
|
|
315
|
+
Effect.forever,
|
|
316
|
+
Effect.interruptible,
|
|
317
|
+
Effect.forkIn(scope),
|
|
318
|
+
);
|
|
319
|
+
return s;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// One open stage per (stack, stage), opened one at a time; a failed open isn't kept.
|
|
323
|
+
const stages = new Map<string, OpenStage>();
|
|
324
|
+
const opening = Semaphore.makeUnsafe(1);
|
|
325
|
+
const stageFor = (request: { readonly stack: string; readonly stage: string }) =>
|
|
326
|
+
Semaphore.withPermits(
|
|
327
|
+
opening,
|
|
328
|
+
1,
|
|
329
|
+
)(
|
|
330
|
+
Effect.gen(function* () {
|
|
331
|
+
const key = `${request.stack}/${request.stage}`;
|
|
332
|
+
const existing = stages.get(key);
|
|
333
|
+
if (existing !== undefined) return existing;
|
|
334
|
+
const opened = yield* openStage(request.stack, request.stage);
|
|
335
|
+
stages.set(key, opened);
|
|
336
|
+
return opened;
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const change = (request: { readonly stack: string; readonly stage: string }, apply: (s: OpenStage) => void) =>
|
|
341
|
+
stageFor(request).pipe(
|
|
342
|
+
Effect.flatMap((s) => {
|
|
343
|
+
apply(s);
|
|
344
|
+
s.version++;
|
|
345
|
+
return flush(s, s.version);
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const deleteStage = (stack: string, stage: string) =>
|
|
350
|
+
stageFor({ stack, stage }).pipe(
|
|
351
|
+
Effect.flatMap((s) =>
|
|
352
|
+
Semaphore.withPermits(
|
|
353
|
+
s.writer,
|
|
354
|
+
1,
|
|
355
|
+
)(
|
|
356
|
+
Effect.gen(function* () {
|
|
357
|
+
yield* checkLock(s);
|
|
358
|
+
if (s.policyId !== undefined) yield* backend.deletePolicy(s.policyId);
|
|
359
|
+
s.policyId = undefined;
|
|
360
|
+
s.resources = {};
|
|
361
|
+
s.output = undefined;
|
|
362
|
+
s.written = s.version;
|
|
363
|
+
}),
|
|
364
|
+
),
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const stored = Effect.map(backend.listPolicies, (policies) =>
|
|
369
|
+
policies.flatMap((p) => {
|
|
370
|
+
const parsed = parsePolicyName(p.name);
|
|
371
|
+
return parsed === undefined ? [] : [parsed];
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const listStages = (stack: string) =>
|
|
376
|
+
Effect.map(stored, (all) =>
|
|
377
|
+
[...new Set(all.filter((s) => s.stack === stack).map((s) => s.stage))].toSorted(),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const service: StateService = {
|
|
381
|
+
id: "unifi-network",
|
|
382
|
+
getVersion: () => Effect.succeed(STATE_STORE_VERSION),
|
|
383
|
+
listStacks: () => Effect.map(stored, (all) => [...new Set(all.map((s) => s.stack))].toSorted()),
|
|
384
|
+
listStages,
|
|
385
|
+
get: (request) =>
|
|
386
|
+
Effect.map(stageFor(request), (s) => {
|
|
387
|
+
const record = revive(s.resources[request.fqn]);
|
|
388
|
+
return isRecord(record) ? record : undefined;
|
|
389
|
+
}),
|
|
390
|
+
getReplacedResources: (request) =>
|
|
391
|
+
Effect.map(stageFor(request), (s) => Object.values(s.resources).map(revive).filter(isReplaced)),
|
|
392
|
+
list: (request) => Effect.map(stageFor(request), (s) => Object.keys(s.resources).toSorted()),
|
|
393
|
+
set: <V extends PersistedState>(request: { stack: string; stage: string; fqn: string; value: V }) =>
|
|
394
|
+
change(request, (s) => {
|
|
395
|
+
s.resources[request.fqn] = encodeState(request.value);
|
|
396
|
+
}).pipe(Effect.as(request.value)),
|
|
397
|
+
delete: (request) =>
|
|
398
|
+
change(request, (s) => {
|
|
399
|
+
delete s.resources[request.fqn];
|
|
400
|
+
}),
|
|
401
|
+
deleteStack: ({ stack, stage }) =>
|
|
402
|
+
stage === undefined
|
|
403
|
+
? Effect.flatMap(listStages(stack), (found) =>
|
|
404
|
+
Effect.forEach(found, (each) => deleteStage(stack, each), { discard: true }),
|
|
405
|
+
)
|
|
406
|
+
: deleteStage(stack, stage),
|
|
407
|
+
getOutput: (request) => Effect.map(stageFor(request), (s) => revive(s.output)),
|
|
408
|
+
setOutput: (request) =>
|
|
409
|
+
change(request, (s) => {
|
|
410
|
+
s.output = encodeState(request.value);
|
|
411
|
+
}).pipe(Effect.as(request.value)),
|
|
412
|
+
};
|
|
413
|
+
return service;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const toStateStoreError = (what: string) => (cause: unknown) =>
|
|
417
|
+
new StateStoreError({
|
|
418
|
+
message: `UniFi state store: ${what} failed: ${Connection.describeError(cause)}`,
|
|
419
|
+
...(cause instanceof Error ? { cause } : {}),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const isAlreadyExists = (error: unknown) =>
|
|
423
|
+
Predicate.hasProperty(error, "_tag") &&
|
|
424
|
+
error._tag === "UnprocessableEntity" &&
|
|
425
|
+
Predicate.hasProperty(error, "message") &&
|
|
426
|
+
typeof error.message === "string" &&
|
|
427
|
+
error.message.includes("already exist");
|
|
428
|
+
|
|
429
|
+
const policyBody = (zoneId: string, name: string, description: string) => ({
|
|
430
|
+
enabled: false,
|
|
431
|
+
name,
|
|
432
|
+
description,
|
|
433
|
+
action: { type: "BLOCK" as const },
|
|
434
|
+
source: { zoneId },
|
|
435
|
+
destination: { zoneId },
|
|
436
|
+
ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" as const },
|
|
437
|
+
loggingEnabled: false,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const describeWrite = (description: string) =>
|
|
441
|
+
`writing ${description.length} bytes of state (a console stores up to about 16 MiB directly, 7.5 MiB through ui.com)`;
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Runs with Debug and Trace records off: request bodies are the state,
|
|
445
|
+
* which holds secrets (e.g. Wi-Fi passphrases). Floors already above Info
|
|
446
|
+
* are left alone.
|
|
447
|
+
*/
|
|
448
|
+
const withoutDebugLogs = Effect.updateService(References.MinimumLogLevel, (level) =>
|
|
449
|
+
LogLevel.isLessThan(level, "Info") ? "Info" : level,
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
/** The {@link StateBackend} of the profile's console and site. */
|
|
453
|
+
const consoleBackend = Effect.gen(function* () {
|
|
454
|
+
const { id: siteId } = yield* resolveSite();
|
|
455
|
+
// Operations run later, from wherever Alchemy calls the store.
|
|
456
|
+
const context = yield* Effect.context<Connection.UnifiNetworkConnection>();
|
|
457
|
+
const run = <A, E>(what: string, effect: Effect.Effect<A, E, Sdk.Credentials | HttpClient.HttpClient>) =>
|
|
458
|
+
Connection.withConnection(effect).pipe(
|
|
459
|
+
Effect.mapError(toStateStoreError(what)),
|
|
460
|
+
Effect.provideContext(context),
|
|
461
|
+
withoutDebugLogs,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const gatewayZoneId = yield* Effect.cached(
|
|
465
|
+
run("finding the Gateway zone", Stream.runCollect(Sdk.getFirewallZones.items({ siteId }))).pipe(
|
|
466
|
+
Effect.flatMap((zones) => {
|
|
467
|
+
const zone = zones.find((z) => z.metadata.origin === "SYSTEM_DEFINED" && z.name === "Gateway");
|
|
468
|
+
return zone === undefined
|
|
469
|
+
? Effect.fail(new StateStoreError({ message: "UniFi state store: the site has no Gateway zone." }))
|
|
470
|
+
: Effect.succeed(zone.id);
|
|
471
|
+
}),
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
listPolicies: run(
|
|
477
|
+
"listing state policies",
|
|
478
|
+
Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId })).pipe(
|
|
479
|
+
Effect.map((policies) =>
|
|
480
|
+
// User-defined first: some system-defined policies have no name, whatever the spec says.
|
|
481
|
+
policies.flatMap((p) =>
|
|
482
|
+
p.metadata.origin === "USER_DEFINED" && p.name.startsWith(STATE_POLICY_PREFIX)
|
|
483
|
+
? [{ id: p.id, name: p.name, description: p.description ?? "" }]
|
|
484
|
+
: [],
|
|
485
|
+
),
|
|
486
|
+
),
|
|
487
|
+
),
|
|
488
|
+
),
|
|
489
|
+
createPolicy: (name, description) =>
|
|
490
|
+
Effect.flatMap(gatewayZoneId, (zoneId) =>
|
|
491
|
+
run(
|
|
492
|
+
describeWrite(description),
|
|
493
|
+
Sdk.createFirewallPolicy({ siteId, ...policyBody(zoneId, name, description) }),
|
|
494
|
+
),
|
|
495
|
+
).pipe(Effect.map((created) => created.id)),
|
|
496
|
+
updatePolicy: (id, name, description) =>
|
|
497
|
+
Effect.flatMap(gatewayZoneId, (zoneId) =>
|
|
498
|
+
run(
|
|
499
|
+
describeWrite(description),
|
|
500
|
+
Sdk.updateFirewallPolicy({
|
|
501
|
+
siteId,
|
|
502
|
+
firewallPolicyId: id,
|
|
503
|
+
...policyBody(zoneId, name, description),
|
|
504
|
+
}),
|
|
505
|
+
),
|
|
506
|
+
).pipe(Effect.asVoid),
|
|
507
|
+
deletePolicy: (id) =>
|
|
508
|
+
run(
|
|
509
|
+
"deleting a state policy",
|
|
510
|
+
Sdk.deleteFirewallPolicy({ siteId, firewallPolicyId: id }).pipe(
|
|
511
|
+
Effect.asVoid,
|
|
512
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
513
|
+
),
|
|
514
|
+
),
|
|
515
|
+
createLock: (name) =>
|
|
516
|
+
run(
|
|
517
|
+
"taking the lock",
|
|
518
|
+
Sdk.createTrafficMatchingList({
|
|
519
|
+
siteId,
|
|
520
|
+
body: { type: "PORTS", name, items: [{ type: "PORT_NUMBER", value: 1 }] },
|
|
521
|
+
}).pipe(
|
|
522
|
+
Effect.map((created): string | undefined => created.id),
|
|
523
|
+
Effect.catchIf(isAlreadyExists, () => Effect.undefined),
|
|
524
|
+
),
|
|
525
|
+
),
|
|
526
|
+
findLock: (name) =>
|
|
527
|
+
run(
|
|
528
|
+
"finding the lock",
|
|
529
|
+
Stream.runCollect(Sdk.getTrafficMatchingLists.items({ siteId })).pipe(
|
|
530
|
+
Effect.map((lists) => lists.find((l) => l.name === name)?.id),
|
|
531
|
+
),
|
|
532
|
+
),
|
|
533
|
+
lockExists: (id) =>
|
|
534
|
+
run(
|
|
535
|
+
"checking the lock",
|
|
536
|
+
Sdk.getTrafficMatchingList({ siteId, trafficMatchingListId: id }).pipe(
|
|
537
|
+
Effect.as(true),
|
|
538
|
+
Effect.catchTag("NotFound", () => Effect.succeed(false)),
|
|
539
|
+
),
|
|
540
|
+
),
|
|
541
|
+
deleteLock: (id) =>
|
|
542
|
+
run(
|
|
543
|
+
"releasing the lock",
|
|
544
|
+
Sdk.deleteTrafficMatchingList({ siteId, trafficMatchingListId: id }).pipe(
|
|
545
|
+
Effect.asVoid,
|
|
546
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
547
|
+
),
|
|
548
|
+
),
|
|
549
|
+
} satisfies StateBackend;
|
|
550
|
+
}).pipe(
|
|
551
|
+
Effect.mapError((error) =>
|
|
552
|
+
error instanceof StateStoreError ? error : toStateStoreError("finding the site")(error),
|
|
553
|
+
),
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* State store on the console the profile points at: each stack and stage
|
|
558
|
+
* is a disabled Gateway → Gateway firewall policy holding the stage's
|
|
559
|
+
* compressed state, locked with a traffic-matching list while a run uses
|
|
560
|
+
* it. Nothing connects until the first state operation.
|
|
561
|
+
*
|
|
562
|
+
* The state includes secrets in plaintext, as in every Alchemy store: a
|
|
563
|
+
* UniFi stack's are already on the console, but another provider's
|
|
564
|
+
* resources in the same stack would put theirs there too.
|
|
565
|
+
*
|
|
566
|
+
* @example
|
|
567
|
+
* ```ts
|
|
568
|
+
* import * as UnifiNetwork from "@alchemy-unifi/network";
|
|
569
|
+
* import * as Alchemy from "alchemy";
|
|
570
|
+
* import * as Effect from "effect/Effect";
|
|
571
|
+
*
|
|
572
|
+
* export default Alchemy.Stack(
|
|
573
|
+
* "HomeNetwork",
|
|
574
|
+
* { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
|
|
575
|
+
* Effect.gen(function* () {
|
|
576
|
+
* yield* UnifiNetwork.FirewallZone("iot-zone", { name: "IoT" });
|
|
577
|
+
* }),
|
|
578
|
+
* );
|
|
579
|
+
* ```
|
|
580
|
+
*/
|
|
581
|
+
export const state = (options: UnifiStateOptions = {}) =>
|
|
582
|
+
Layer.effect(
|
|
583
|
+
State,
|
|
584
|
+
Effect.gen(function* () {
|
|
585
|
+
const scope = yield* Effect.scope;
|
|
586
|
+
const context = yield* Effect.context<
|
|
587
|
+
| Connection.UnifiNetworkConnection
|
|
588
|
+
| Credentials.UnifiNetworkCredentials
|
|
589
|
+
| ReadCache.UnifiNetworkReadCache
|
|
590
|
+
>();
|
|
591
|
+
const make = Effect.flatMap(consoleBackend, (backend) => makeUnifiState(backend, options)).pipe(
|
|
592
|
+
Scope.provide(scope),
|
|
593
|
+
recordStateStoreInit,
|
|
594
|
+
Effect.orDie,
|
|
595
|
+
Effect.provideContext(context),
|
|
596
|
+
);
|
|
597
|
+
return yield* Effect.cached(make);
|
|
598
|
+
}),
|
|
599
|
+
).pipe(
|
|
600
|
+
Layer.provide(
|
|
601
|
+
Layer.merge(Connection.layer, ReadCache.layer).pipe(
|
|
602
|
+
Layer.provideMerge(Credentials.fromAuthProvider()),
|
|
603
|
+
Layer.provideMerge(UnifiNetworkAuth),
|
|
604
|
+
),
|
|
605
|
+
),
|
|
606
|
+
Layer.orDie,
|
|
607
|
+
);
|