@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/package.json
CHANGED
|
@@ -1 +1,74 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@alchemy-unifi/network",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Alchemy provider for the UniFi Network application.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"alchemy",
|
|
7
|
+
"effect",
|
|
8
|
+
"infrastructure-as-code",
|
|
9
|
+
"ubiquiti",
|
|
10
|
+
"unifi"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/HamishWHC/alchemy-unifi/tree/main/packages/network#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/HamishWHC/alchemy-unifi/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "HamishWHC",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/HamishWHC/alchemy-unifi.git",
|
|
21
|
+
"directory": "packages/network"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"@alchemy-unifi/source": "./src/index.ts",
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"default": "./dist/index.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@alchemy-unifi/network-sdk": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@alchemy-unifi/test-controller": "0.0.0",
|
|
45
|
+
"@effect/platform-node": "^4.0.6",
|
|
46
|
+
"@effect/vitest": "^4.0.6",
|
|
47
|
+
"@total-typescript/ts-reset": "^0.6.1",
|
|
48
|
+
"@types/node": "^24.0.0",
|
|
49
|
+
"alchemy": "^2.0.0-beta.84",
|
|
50
|
+
"effect": "^4.0.6",
|
|
51
|
+
"tsdown": "^0.23.2",
|
|
52
|
+
"typescript": "7.1.0-beta",
|
|
53
|
+
"vitest": "^5.0.5"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@effect/platform-node": "^4.0.6",
|
|
57
|
+
"alchemy": "^2.0.0-beta.84",
|
|
58
|
+
"effect": "^4.0.6"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=22"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsdown",
|
|
65
|
+
"check": "pnpm run typecheck && pnpm run lint && pnpm run fmt:check",
|
|
66
|
+
"typecheck": "tsc --noEmit",
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"test:unit": "vitest run --project unit",
|
|
69
|
+
"lint": "oxlint",
|
|
70
|
+
"lint:fix": "oxlint --fix",
|
|
71
|
+
"fmt": "oxfmt",
|
|
72
|
+
"fmt:check": "oxfmt --check"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
|
|
3
|
+
export const UNIFI_NETWORK_AUTH_PROVIDER_NAME = "UnifiNetwork";
|
|
4
|
+
|
|
5
|
+
/** Values stored in a profile for the `UnifiNetwork` auth provider. */
|
|
6
|
+
export const UnifiNetworkAuthConfigSchema = Schema.Union([
|
|
7
|
+
Schema.Struct({
|
|
8
|
+
method: Schema.Literal("local"),
|
|
9
|
+
url: Schema.String,
|
|
10
|
+
apiKey: Schema.String,
|
|
11
|
+
certFingerprint: Schema.optional(Schema.String),
|
|
12
|
+
insecure: Schema.optional(Schema.Boolean),
|
|
13
|
+
site: Schema.optional(Schema.String),
|
|
14
|
+
}),
|
|
15
|
+
Schema.Struct({
|
|
16
|
+
method: Schema.Literal("remote"),
|
|
17
|
+
consoleId: Schema.String,
|
|
18
|
+
apiKey: Schema.String,
|
|
19
|
+
site: Schema.optional(Schema.String),
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export type UnifiNetworkAuthConfig = typeof UnifiNetworkAuthConfigSchema.Type;
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import * as Sdk from "@alchemy-unifi/network-sdk";
|
|
2
|
+
import {
|
|
3
|
+
AuthError,
|
|
4
|
+
AuthProviderLayer,
|
|
5
|
+
type ConfigureField,
|
|
6
|
+
type ConfigureMethod,
|
|
7
|
+
type EnvironmentVariable,
|
|
8
|
+
type ProviderDetailLine,
|
|
9
|
+
} from "alchemy/Auth/AuthProvider";
|
|
10
|
+
import { displayRedacted } from "alchemy/Auth/Credentials";
|
|
11
|
+
import { getEnv, getEnvRedactedRequired, mapPromptCancellation } from "alchemy/Auth/Env";
|
|
12
|
+
import { storedSecret, storedValueText, validateFieldValues } from "alchemy/Auth/StoredAuthProvider";
|
|
13
|
+
import * as Interaction from "alchemy/Interaction";
|
|
14
|
+
import * as Effect from "effect/Effect";
|
|
15
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
16
|
+
import * as Redacted from "effect/Redacted";
|
|
17
|
+
import {
|
|
18
|
+
UNIFI_NETWORK_AUTH_PROVIDER_NAME,
|
|
19
|
+
type UnifiNetworkAuthConfig,
|
|
20
|
+
UnifiNetworkAuthConfigSchema,
|
|
21
|
+
} from "./AuthConfig.ts";
|
|
22
|
+
import { connect, describeError } from "./Connection.ts";
|
|
23
|
+
import type { Connection, ResolvedCredentials } from "./Credentials.ts";
|
|
24
|
+
import * as UnifiHttpClient from "./HttpClient.ts";
|
|
25
|
+
import * as SiteManager from "./SiteManager.ts";
|
|
26
|
+
|
|
27
|
+
export { UNIFI_NETWORK_AUTH_PROVIDER_NAME, type UnifiNetworkAuthConfig, UnifiNetworkAuthConfigSchema };
|
|
28
|
+
|
|
29
|
+
const NAME = UNIFI_NETWORK_AUTH_PROVIDER_NAME;
|
|
30
|
+
|
|
31
|
+
const authError = (message: string, cause?: unknown) =>
|
|
32
|
+
AuthError.make({ message: `${NAME}: ${message}`, ...(cause === undefined ? {} : { cause }) });
|
|
33
|
+
|
|
34
|
+
/** Normalises a console URL (no trailing slash), or returns `undefined` if it isn't `http(s):`. */
|
|
35
|
+
export const parseConsoleUrl = (value: string): string | undefined => {
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(value.trim());
|
|
38
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
39
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const parseBoolean = (value: string): boolean | undefined =>
|
|
46
|
+
/^(true|1|yes)$/i.test(value) ? true : /^(false|0|no|)$/i.test(value) ? false : undefined;
|
|
47
|
+
|
|
48
|
+
const validateUrl = (value: string) =>
|
|
49
|
+
parseConsoleUrl(value) === undefined ? "Expected an http(s) URL such as https://192.168.1.1" : undefined;
|
|
50
|
+
const validateFingerprint = (value: string) =>
|
|
51
|
+
UnifiHttpClient.normaliseFingerprint(value) === undefined
|
|
52
|
+
? "Expected a SHA-256 fingerprint: 64 hex digits, optionally colon-separated"
|
|
53
|
+
: undefined;
|
|
54
|
+
const validateBoolean = (value: string) => (parseBoolean(value) === undefined ? "Expected true or false" : undefined);
|
|
55
|
+
const required = (value: string) => (value.length === 0 ? "Required" : undefined);
|
|
56
|
+
|
|
57
|
+
interface Input {
|
|
58
|
+
readonly method: "local" | "remote";
|
|
59
|
+
readonly url?: string | undefined;
|
|
60
|
+
readonly consoleId?: string | undefined;
|
|
61
|
+
readonly apiKey: Redacted.Redacted<string>;
|
|
62
|
+
readonly certFingerprint?: string | undefined;
|
|
63
|
+
readonly insecure?: boolean | undefined;
|
|
64
|
+
readonly site?: string | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Credentials minus their {@link ResolvedCredentials.source}: what profile and environment values both describe. */
|
|
68
|
+
type Settings = Omit<ResolvedCredentials, "source">;
|
|
69
|
+
|
|
70
|
+
/** Validates stored, entered or environment values. `source` names them in errors. */
|
|
71
|
+
const resolve = (input: Input, source: string): Effect.Effect<Settings, AuthError> =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const site = input.site || undefined;
|
|
74
|
+
if (input.method === "remote") {
|
|
75
|
+
if (!input.consoleId) return yield* authError(`no console ID (${source}).`);
|
|
76
|
+
return { connection: { type: "remote", consoleId: input.consoleId, apiKey: input.apiKey }, site };
|
|
77
|
+
}
|
|
78
|
+
const url = parseConsoleUrl(input.url ?? "");
|
|
79
|
+
if (url === undefined) return yield* authError(`'${input.url ?? ""}' is not an http(s) URL (${source}).`);
|
|
80
|
+
const certFingerprint =
|
|
81
|
+
input.certFingerprint === undefined
|
|
82
|
+
? undefined
|
|
83
|
+
: UnifiHttpClient.normaliseFingerprint(input.certFingerprint);
|
|
84
|
+
if (input.certFingerprint !== undefined && certFingerprint === undefined) {
|
|
85
|
+
return yield* authError(`'${input.certFingerprint}' is not a SHA-256 fingerprint (${source}).`);
|
|
86
|
+
}
|
|
87
|
+
if (certFingerprint !== undefined && input.insecure) {
|
|
88
|
+
return yield* authError(`set a certificate fingerprint or insecure, not both (${source}).`);
|
|
89
|
+
}
|
|
90
|
+
if (certFingerprint !== undefined && url.startsWith("http:")) {
|
|
91
|
+
return yield* authError(`a certificate fingerprint needs an https: URL (${source}).`);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
connection: {
|
|
95
|
+
type: "local",
|
|
96
|
+
url,
|
|
97
|
+
apiKey: input.apiKey,
|
|
98
|
+
certFingerprint,
|
|
99
|
+
insecure: input.insecure ?? false,
|
|
100
|
+
},
|
|
101
|
+
site,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const fromConfig = (
|
|
106
|
+
profileName: string,
|
|
107
|
+
config: UnifiNetworkAuthConfig,
|
|
108
|
+
): Effect.Effect<ResolvedCredentials, AuthError> =>
|
|
109
|
+
resolve({ ...config, apiKey: Redacted.make(config.apiKey) }, `profile '${profileName}'`).pipe(
|
|
110
|
+
Effect.map((settings) => ({ ...settings, source: { type: "profile", profile: profileName } })),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const toConfig = ({ connection, site }: Settings): UnifiNetworkAuthConfig =>
|
|
114
|
+
connection.type === "remote"
|
|
115
|
+
? {
|
|
116
|
+
method: "remote",
|
|
117
|
+
consoleId: connection.consoleId,
|
|
118
|
+
apiKey: Redacted.value(connection.apiKey),
|
|
119
|
+
...(site === undefined ? {} : { site }),
|
|
120
|
+
}
|
|
121
|
+
: {
|
|
122
|
+
method: "local",
|
|
123
|
+
url: connection.url,
|
|
124
|
+
apiKey: Redacted.value(connection.apiKey),
|
|
125
|
+
...(connection.certFingerprint === undefined ? {} : { certFingerprint: connection.certFingerprint }),
|
|
126
|
+
...(connection.insecure ? { insecure: true } : {}),
|
|
127
|
+
...(site === undefined ? {} : { site }),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const siteField: ConfigureField = {
|
|
131
|
+
name: "site",
|
|
132
|
+
label: "Default site",
|
|
133
|
+
description:
|
|
134
|
+
"Only for consoles hosting several sites: internal reference (e.g. default) or name of the site resources use when they don't set one.",
|
|
135
|
+
optional: true,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const configureMethods: ReadonlyArray<ConfigureMethod> = [
|
|
139
|
+
{
|
|
140
|
+
method: "local",
|
|
141
|
+
fields: [
|
|
142
|
+
{ name: "url", label: "Console URL", placeholder: "https://192.168.1.1", validate: validateUrl },
|
|
143
|
+
{ name: "apiKey", label: "API key", secret: true },
|
|
144
|
+
{
|
|
145
|
+
name: "certFingerprint",
|
|
146
|
+
label: "Certificate SHA-256 fingerprint",
|
|
147
|
+
description: "Pin the console's certificate (or a CA in its chain) instead of verifying it.",
|
|
148
|
+
optional: true,
|
|
149
|
+
validate: validateFingerprint,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "insecure",
|
|
153
|
+
label: "Skip TLS verification",
|
|
154
|
+
optional: true,
|
|
155
|
+
validate: validateBoolean,
|
|
156
|
+
},
|
|
157
|
+
siteField,
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
method: "remote",
|
|
162
|
+
fields: [
|
|
163
|
+
{ name: "consoleId", label: "Console ID", description: "The console's host ID in Site Manager." },
|
|
164
|
+
{ name: "apiKey", label: "Site Manager API key", secret: true },
|
|
165
|
+
siteField,
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
const environment: ReadonlyArray<EnvironmentVariable> = [
|
|
171
|
+
{
|
|
172
|
+
name: "UNIFI_URL",
|
|
173
|
+
required: true,
|
|
174
|
+
alternatives: ["UNIFI_CONSOLE_ID"],
|
|
175
|
+
description:
|
|
176
|
+
"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.",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "UNIFI_API_KEY",
|
|
180
|
+
required: true,
|
|
181
|
+
secret: true,
|
|
182
|
+
description: "A Network API key (local) or a Site Manager API key (remote).",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "UNIFI_CERT_FINGERPRINT",
|
|
186
|
+
required: false,
|
|
187
|
+
description: "Local only: SHA-256 fingerprint of the console's certificate (or a CA in its chain) to pin.",
|
|
188
|
+
},
|
|
189
|
+
{ name: "UNIFI_INSECURE", required: false, description: "Local only: true to skip TLS verification." },
|
|
190
|
+
{
|
|
191
|
+
name: "UNIFI_SITE",
|
|
192
|
+
required: false,
|
|
193
|
+
description: "Default site, for consoles hosting several. Unset means the console's only site.",
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
const readEnvironmentSettings = Effect.gen(function* () {
|
|
198
|
+
const apiKey = yield* getEnvRedactedRequired("UNIFI_API_KEY");
|
|
199
|
+
const url = yield* getEnv("UNIFI_URL");
|
|
200
|
+
const consoleId = yield* getEnv("UNIFI_CONSOLE_ID");
|
|
201
|
+
const site = yield* getEnv("UNIFI_SITE");
|
|
202
|
+
if (url && consoleId) return yield* authError("set UNIFI_URL or UNIFI_CONSOLE_ID, not both.");
|
|
203
|
+
if (consoleId)
|
|
204
|
+
return yield* resolve({ method: "remote", consoleId, apiKey, site }, "UNIFI_* environment variables");
|
|
205
|
+
if (!url) return yield* authError("set UNIFI_URL (local) or UNIFI_CONSOLE_ID (remote) along with UNIFI_API_KEY.");
|
|
206
|
+
const insecureValue = (yield* getEnv("UNIFI_INSECURE")) ?? "";
|
|
207
|
+
const insecure = parseBoolean(insecureValue);
|
|
208
|
+
if (insecure === undefined)
|
|
209
|
+
return yield* authError(`UNIFI_INSECURE must be true or false, not '${insecureValue}'.`);
|
|
210
|
+
const certFingerprint = (yield* getEnv("UNIFI_CERT_FINGERPRINT")) || undefined;
|
|
211
|
+
return yield* resolve(
|
|
212
|
+
{ method: "local", url, apiKey, certFingerprint, insecure, site },
|
|
213
|
+
"UNIFI_* environment variables",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const readEnvironment: Effect.Effect<ResolvedCredentials, AuthError> = readEnvironmentSettings.pipe(
|
|
218
|
+
Effect.map((settings) => ({ ...settings, source: { type: "env" } })),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
/** Runs `f` against a freshly established connection, mapping connection failures to `AuthError`. */
|
|
222
|
+
const withEstablished = <A, E>(
|
|
223
|
+
connection: Connection,
|
|
224
|
+
f: (established: Effect.Success<ReturnType<typeof connect>>) => Effect.Effect<A, E>,
|
|
225
|
+
) =>
|
|
226
|
+
Effect.scoped(Effect.flatMap(connect(connection), f)).pipe(
|
|
227
|
+
Effect.mapError((error) => (error instanceof AuthError ? error : authError(describeError(error), error))),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const configureWith = (
|
|
231
|
+
_profileName: string,
|
|
232
|
+
input: { readonly method: string; readonly values: Record<string, string> },
|
|
233
|
+
): Effect.Effect<UnifiNetworkAuthConfig, AuthError> =>
|
|
234
|
+
Effect.gen(function* () {
|
|
235
|
+
const method = configureMethods.find((m) => m.method === input.method);
|
|
236
|
+
if (method === undefined) {
|
|
237
|
+
return yield* authError(`unknown method '${input.method}'. Valid methods: local, remote.`);
|
|
238
|
+
}
|
|
239
|
+
const values = yield* validateFieldValues(NAME, method.fields, input.values);
|
|
240
|
+
const insecure = storedValueText(values["insecure"]);
|
|
241
|
+
const resolved = yield* resolve(
|
|
242
|
+
{
|
|
243
|
+
method: method.method === "remote" ? "remote" : "local",
|
|
244
|
+
url: storedValueText(values["url"]),
|
|
245
|
+
consoleId: storedValueText(values["consoleId"]),
|
|
246
|
+
apiKey: storedSecret(values["apiKey"]) ?? Redacted.make(""),
|
|
247
|
+
certFingerprint: storedValueText(values["certFingerprint"]),
|
|
248
|
+
insecure: insecure === undefined ? undefined : parseBoolean(insecure),
|
|
249
|
+
site: storedValueText(values["site"]),
|
|
250
|
+
},
|
|
251
|
+
"--set values",
|
|
252
|
+
);
|
|
253
|
+
return toConfig(resolved);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const details = (profileName: string, config: UnifiNetworkAuthConfig) =>
|
|
257
|
+
Effect.gen(function* () {
|
|
258
|
+
const { connection, site } = yield* fromConfig(profileName, config);
|
|
259
|
+
const lines: ProviderDetailLine[] =
|
|
260
|
+
connection.type === "remote"
|
|
261
|
+
? [
|
|
262
|
+
{ key: "method", value: "remote (ui.com cloud connector)" },
|
|
263
|
+
{ key: "consoleId", value: connection.consoleId },
|
|
264
|
+
]
|
|
265
|
+
: [
|
|
266
|
+
{ key: "method", value: "local" },
|
|
267
|
+
{ key: "url", value: connection.url },
|
|
268
|
+
{
|
|
269
|
+
key: "tls",
|
|
270
|
+
value: connection.url.startsWith("http:")
|
|
271
|
+
? "none (http)"
|
|
272
|
+
: connection.certFingerprint !== undefined
|
|
273
|
+
? `pinned ${connection.certFingerprint}`
|
|
274
|
+
: connection.insecure
|
|
275
|
+
? "not verified"
|
|
276
|
+
: "verified",
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
lines.push({ key: "apiKey", value: displayRedacted(connection.apiKey) });
|
|
280
|
+
if (site !== undefined) lines.push({ key: "site", value: site });
|
|
281
|
+
const network = yield* withEstablished(connection, (established) =>
|
|
282
|
+
Effect.succeed(`UniFi Network ${established.applicationVersion}`),
|
|
283
|
+
).pipe(Effect.catch((error) => Effect.succeed(`unreachable (${error.message})`)));
|
|
284
|
+
lines.push({ key: "network", value: network });
|
|
285
|
+
return { lines };
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
export const UnifiNetworkAuth = AuthProviderLayer<UnifiNetworkAuthConfig, ResolvedCredentials>()(
|
|
289
|
+
NAME,
|
|
290
|
+
Effect.gen(function* () {
|
|
291
|
+
const interaction = Interaction.accessors;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Lists the console's sites and, if there are several, lets the user
|
|
295
|
+
* pick the default (remembering `current`). A single site isn't stored.
|
|
296
|
+
*/
|
|
297
|
+
const pickSite = (connection: Connection, current: string | undefined) =>
|
|
298
|
+
Effect.gen(function* () {
|
|
299
|
+
const { applicationVersion, sites } = yield* withEstablished(connection, (established) =>
|
|
300
|
+
Sdk.getSiteOverviewPage({ limit: 200 }).pipe(
|
|
301
|
+
Effect.provide(established.services),
|
|
302
|
+
Effect.map((page) => ({
|
|
303
|
+
applicationVersion: established.applicationVersion,
|
|
304
|
+
sites: page.data,
|
|
305
|
+
})),
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
yield* interaction.output.info(`Connected to UniFi Network ${applicationVersion}.`);
|
|
309
|
+
const [only] = sites;
|
|
310
|
+
if (only === undefined) return yield* authError("the console has no sites.");
|
|
311
|
+
if (sites.length === 1) {
|
|
312
|
+
yield* interaction.output.info(`The console has one site, ${only.name}; resources will use it.`);
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
return yield* interaction.prompt
|
|
316
|
+
.select({
|
|
317
|
+
message: "Default site",
|
|
318
|
+
searchable: true,
|
|
319
|
+
...(current !== undefined && sites.some((s) => s.internalReference === current)
|
|
320
|
+
? { initialValue: current }
|
|
321
|
+
: {}),
|
|
322
|
+
options: sites.map((s) => ({
|
|
323
|
+
value: s.internalReference,
|
|
324
|
+
label: s.name,
|
|
325
|
+
description: s.internalReference,
|
|
326
|
+
})),
|
|
327
|
+
})
|
|
328
|
+
.pipe(mapPromptCancellation);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const promptApiKey = (message: string, description: string) =>
|
|
332
|
+
interaction.prompt
|
|
333
|
+
.password({ message, description, validate: required })
|
|
334
|
+
.pipe(mapPromptCancellation, Effect.map(Redacted.make));
|
|
335
|
+
|
|
336
|
+
const configureLocal = (current: UnifiNetworkAuthConfig | undefined) =>
|
|
337
|
+
Effect.gen(function* () {
|
|
338
|
+
const url = yield* interaction.prompt
|
|
339
|
+
.text({
|
|
340
|
+
message: "Console URL",
|
|
341
|
+
description: "The console's or self-hosted controller's address on your network.",
|
|
342
|
+
placeholder: "https://192.168.1.1",
|
|
343
|
+
...(current?.method === "local" ? { defaultValue: current.url } : {}),
|
|
344
|
+
validate: validateUrl,
|
|
345
|
+
})
|
|
346
|
+
.pipe(
|
|
347
|
+
mapPromptCancellation,
|
|
348
|
+
Effect.map((value) => parseConsoleUrl(value) ?? value),
|
|
349
|
+
);
|
|
350
|
+
const apiKey = yield* promptApiKey(
|
|
351
|
+
"API key",
|
|
352
|
+
"Create one in UniFi Network under Settings → Integrations.",
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
let tls: { certFingerprint?: string; insecure?: boolean } = {};
|
|
356
|
+
if (url.startsWith("https:")) {
|
|
357
|
+
const cert = yield* UnifiHttpClient.inspectCertificate(url).pipe(
|
|
358
|
+
Effect.mapError((error) =>
|
|
359
|
+
authError(`could not connect to ${url}: ${describeError(error)}`, error),
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
if (cert.trusted) {
|
|
363
|
+
yield* interaction.output.info(
|
|
364
|
+
"The console's certificate is publicly trusted; it will be verified normally.",
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
const choice = yield* interaction.prompt
|
|
368
|
+
.select({
|
|
369
|
+
message: `${url} presents an untrusted certificate (${cert.subject}, valid until ${cert.validTo})`,
|
|
370
|
+
options: [
|
|
371
|
+
{
|
|
372
|
+
value: "pin" as const,
|
|
373
|
+
label: "Pin this certificate",
|
|
374
|
+
description: `SHA-256 ${cert.fingerprint}. Check it matches the console's before choosing this.`,
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
value: "insecure" as const,
|
|
378
|
+
label: "Skip verification",
|
|
379
|
+
description:
|
|
380
|
+
"Accept any certificate. Anyone on the network path could intercept the API key.",
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
})
|
|
384
|
+
.pipe(mapPromptCancellation);
|
|
385
|
+
tls = choice === "pin" ? { certFingerprint: cert.fingerprint } : { insecure: true };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const resolved = yield* resolve({ method: "local", url, apiKey, ...tls }, "entered values");
|
|
390
|
+
const site = yield* pickSite(resolved.connection, current?.site);
|
|
391
|
+
return toConfig({ ...resolved, site });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const configureRemote = (current: UnifiNetworkAuthConfig | undefined) =>
|
|
395
|
+
Effect.gen(function* () {
|
|
396
|
+
const apiKey = yield* promptApiKey("Site Manager API key", "Create one at unifi.ui.com under API.");
|
|
397
|
+
const consoles = yield* Effect.scoped(
|
|
398
|
+
UnifiHttpClient.make().pipe(
|
|
399
|
+
Effect.flatMap((client) =>
|
|
400
|
+
SiteManager.listConsoles(apiKey).pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
).pipe(
|
|
404
|
+
Effect.mapError((error) => authError(`could not list consoles: ${describeError(error)}`, error)),
|
|
405
|
+
);
|
|
406
|
+
const [only] = consoles;
|
|
407
|
+
if (only === undefined) return yield* authError("this API key can't see any consoles.");
|
|
408
|
+
const consoleId =
|
|
409
|
+
consoles.length === 1
|
|
410
|
+
? only.id
|
|
411
|
+
: yield* interaction.prompt
|
|
412
|
+
.select({
|
|
413
|
+
message: "Console",
|
|
414
|
+
searchable: true,
|
|
415
|
+
...(current?.method === "remote" && consoles.some((c) => c.id === current.consoleId)
|
|
416
|
+
? { initialValue: current.consoleId }
|
|
417
|
+
: {}),
|
|
418
|
+
options: consoles.map((c) => ({
|
|
419
|
+
value: c.id,
|
|
420
|
+
label: c.name,
|
|
421
|
+
description: c.type === undefined ? c.id : `${c.type} · ${c.id}`,
|
|
422
|
+
})),
|
|
423
|
+
})
|
|
424
|
+
.pipe(mapPromptCancellation);
|
|
425
|
+
if (consoles.length === 1) yield* interaction.output.info(`Using console ${only.name}.`);
|
|
426
|
+
const resolved = yield* resolve({ method: "remote", consoleId, apiKey }, "entered values");
|
|
427
|
+
const site = yield* pickSite(resolved.connection, current?.site);
|
|
428
|
+
return toConfig({ ...resolved, site });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const configure = (_profileName: string, current?: UnifiNetworkAuthConfig) =>
|
|
432
|
+
Effect.gen(function* () {
|
|
433
|
+
const method = yield* interaction.prompt
|
|
434
|
+
.select({
|
|
435
|
+
message: "How should Alchemy reach the console?",
|
|
436
|
+
initialValue: current?.method ?? ("local" as const),
|
|
437
|
+
options: [
|
|
438
|
+
{
|
|
439
|
+
value: "local" as const,
|
|
440
|
+
label: "Directly",
|
|
441
|
+
description: "the console's or controller's URL on your network",
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
value: "remote" as const,
|
|
445
|
+
label: "Through ui.com",
|
|
446
|
+
description: "the api.ui.com cloud connector, from anywhere",
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
})
|
|
450
|
+
.pipe(mapPromptCancellation);
|
|
451
|
+
const config = yield* method === "local" ? configureLocal(current) : configureRemote(current);
|
|
452
|
+
yield* interaction.output.success(`${NAME}: credentials saved.`);
|
|
453
|
+
return config;
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
configSchema: UnifiNetworkAuthConfigSchema,
|
|
458
|
+
configure,
|
|
459
|
+
configureWith,
|
|
460
|
+
configureMethods,
|
|
461
|
+
login: (_profileName: string, config: UnifiNetworkAuthConfig) => Effect.succeed(config),
|
|
462
|
+
logout: () => Effect.void,
|
|
463
|
+
details,
|
|
464
|
+
read: fromConfig,
|
|
465
|
+
readEnvironment,
|
|
466
|
+
environment,
|
|
467
|
+
};
|
|
468
|
+
}),
|
|
469
|
+
);
|