@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.
@@ -0,0 +1,185 @@
1
+ import * as Sdk from "@alchemy-unifi/network-sdk";
2
+ import * as Context from "effect/Context";
3
+ import * as Effect from "effect/Effect";
4
+ import * as HttpClient from "effect/http/HttpClient";
5
+ import * as Layer from "effect/Layer";
6
+ import * as Predicate from "effect/Predicate";
7
+ import * as Schedule from "effect/Schedule";
8
+ import * as RateLimiter from "effect/persistence/RateLimiter";
9
+ import * as Scope from "effect/Scope";
10
+ import { type Connection, UnifiNetworkCredentials } from "./Credentials.ts";
11
+ import * as UnifiHttpClient from "./HttpClient.ts";
12
+
13
+ export const SITE_MANAGER_URL = "https://api.ui.com";
14
+
15
+ /** Integration API base URL through the cloud connector. */
16
+ export const connectorBaseUrl = (consoleId: string) =>
17
+ `${SITE_MANAGER_URL}/v1/connector/consoles/${encodeURIComponent(consoleId)}/proxy/network/integration`;
18
+
19
+ /** Integration API base URLs to try for a connection: a UniFi OS console's, then a standalone controller's. */
20
+ export const candidateBaseUrls = (connection: Connection): ReadonlyArray<string> =>
21
+ connection.type === "remote"
22
+ ? [connectorBaseUrl(connection.consoleId)]
23
+ : [`${connection.url}/proxy/network/integration`, `${connection.url}/integration`];
24
+
25
+ /** The SDK's default retry policy, minus certificate failures, which retrying won't fix. */
26
+ const probeRetry: Sdk.Retry.Policy = (lastError) => {
27
+ const defaults = Sdk.Retry.makeDefault(lastError);
28
+ return {
29
+ ...defaults,
30
+ while: (error) => (defaults.while?.(error) ?? false) && !UnifiHttpClient.isCertificateError(error),
31
+ };
32
+ };
33
+
34
+ /** The connector allows 100 requests/minute per console; stay under it. */
35
+ const REMOTE_REQUESTS_PER_MINUTE = 90;
36
+
37
+ /** A connection whose base URL has been found and whose API key works. */
38
+ export interface Established {
39
+ readonly baseUrl: string;
40
+ readonly applicationVersion: string;
41
+ /** What SDK operations need: credentials for {@link baseUrl} and a client with the connection's TLS settings. */
42
+ readonly services: Context.Context<Sdk.Credentials | HttpClient.HttpClient>;
43
+ }
44
+
45
+ /** Joins an error's message with its causes' (e.g. a transport error and the TLS failure behind it). */
46
+ export const describeError = (error: unknown): string => {
47
+ const messages: string[] = [];
48
+ for (let e: unknown = error, depth = 0; e !== undefined && e !== null && depth < 5; depth++) {
49
+ const message = typeof e === "object" && "message" in e ? String(e.message) : String(e);
50
+ if (message !== "" && !messages.some((m) => m.includes(message))) messages.push(message);
51
+ e = typeof e === "object" && "cause" in e ? e.cause : undefined;
52
+ }
53
+ return messages.join(": ") || "unknown error";
54
+ };
55
+
56
+ const describeTarget = (connection: Connection) =>
57
+ connection.type === "remote" ? `console ${connection.consoleId} via ${SITE_MANAGER_URL}` : connection.url;
58
+
59
+ const rateLimited = (client: HttpClient.HttpClient, key: string) =>
60
+ Effect.gen(function* () {
61
+ const limiter = yield* RateLimiter.make.pipe(Effect.provide(RateLimiter.layerStoreMemory));
62
+ const wait = RateLimiter.sleep(limiter, {
63
+ algorithm: "token-bucket",
64
+ window: "1 minute",
65
+ limit: REMOTE_REQUESTS_PER_MINUTE,
66
+ key,
67
+ }).pipe(Effect.orDie);
68
+ return HttpClient.transform(client, (response) => Effect.andThen(wait, response));
69
+ });
70
+
71
+ /**
72
+ * Builds a client for the connection and finds the Integration API: local
73
+ * connections try the console path, then the standalone controller path.
74
+ * Fails with a `ConfigError` describing what went wrong. `options.client`
75
+ * replaces the TLS-configured client (for tests).
76
+ */
77
+ export const connect = (
78
+ connection: Connection,
79
+ options: { readonly client?: HttpClient.HttpClient } = {},
80
+ ): Effect.Effect<Established, Sdk.ConfigError, Scope.Scope> =>
81
+ Effect.gen(function* () {
82
+ const base = options.client ?? (yield* UnifiHttpClient.make(connection.type === "local" ? connection : {}));
83
+ const client =
84
+ connection.type === "remote" ? yield* rateLimited(base, `connector:${connection.consoleId}`) : base;
85
+ const servicesFor = (baseUrl: string) =>
86
+ Context.make(Sdk.Credentials, Effect.succeed({ baseUrl, apiKey: connection.apiKey })).pipe(
87
+ Context.add(HttpClient.HttpClient, client),
88
+ );
89
+ const candidates = candidateBaseUrls(connection);
90
+ for (const baseUrl of candidates) {
91
+ const services = servicesFor(baseUrl);
92
+ const info = yield* Sdk.getInfo({}).pipe(
93
+ Effect.provide(services),
94
+ Sdk.Retry.policy(probeRetry),
95
+ Effect.map(({ applicationVersion }) => ({ baseUrl, applicationVersion, services })),
96
+ Effect.catchTag("NotFound", () => Effect.undefined),
97
+ Effect.mapError((error) =>
98
+ Sdk.ConfigError.make({
99
+ message:
100
+ error._tag === "Unauthorized" || error._tag === "Forbidden"
101
+ ? `${describeTarget(connection)} rejected the API key (${error._tag}).`
102
+ : `Could not connect to ${describeTarget(connection)}: ${describeError(error)}`,
103
+ }),
104
+ ),
105
+ );
106
+ if (info !== undefined) return info;
107
+ }
108
+ return yield* Sdk.ConfigError.make({
109
+ message:
110
+ connection.type === "remote"
111
+ ? `The cloud connector found no UniFi Network application on console ${connection.consoleId}.`
112
+ : `No UniFi Network Integration API at ${connection.url} (tried ${candidates.join(", ")}). Use the console's root URL, e.g. https://192.168.1.1.`,
113
+ });
114
+ });
115
+
116
+ const majorMinor = (version: string) => version.split(".").slice(0, 2).join(".");
117
+
118
+ /** Patch releases routinely ship without a published spec, so only major.minor drift is worth a warning. */
119
+ export const checkVersion = (applicationVersion: string) =>
120
+ majorMinor(applicationVersion) === majorMinor(Sdk.SPEC_VERSION)
121
+ ? Effect.void
122
+ : Effect.logWarning(
123
+ `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.`,
124
+ );
125
+
126
+ /**
127
+ * The profile's established connection, created on first use and shared for
128
+ * the rest of the run. Provider-private: its `HttpClient` and SDK
129
+ * `Credentials` never enter the stack's context, so they can't replace other
130
+ * providers' clients.
131
+ */
132
+ export class UnifiNetworkConnection extends Context.Service<
133
+ UnifiNetworkConnection,
134
+ Effect.Effect<Established, Sdk.ConfigError>
135
+ >()("UnifiNetwork::Connection") {
136
+ /** Alchemy's tag for environment services, which lets stack bodies (and so lookups) require this one. */
137
+ declare readonly kind: "Environment";
138
+ }
139
+
140
+ export const layer = Layer.effect(
141
+ UnifiNetworkConnection,
142
+ Effect.gen(function* () {
143
+ const credentials = yield* UnifiNetworkCredentials;
144
+ const scope = yield* Effect.scope;
145
+ return yield* Effect.cached(
146
+ credentials.pipe(
147
+ Effect.flatMap(({ connection }) => connect(connection)),
148
+ Effect.tap(({ applicationVersion }) => checkVersion(applicationVersion)),
149
+ Scope.provide(scope),
150
+ ),
151
+ );
152
+ }),
153
+ );
154
+
155
+ /**
156
+ * The 422 "Configured network does not exist" the console answers requests
157
+ * with, on any endpoint, for a moment while networks are created or deleted
158
+ * anywhere on it (seen on the test controller under parallel tests, for
159
+ * network, zone and policy requests alike). It's never a real error, so it's
160
+ * retried; other 422s are.
161
+ */
162
+ const isStaleNetworkState = (error: unknown) =>
163
+ Predicate.hasProperty(error, "_tag") &&
164
+ error._tag === "UnprocessableEntity" &&
165
+ Predicate.hasProperty(error, "message") &&
166
+ typeof error.message === "string" &&
167
+ error.message.includes("Configured network does not exist");
168
+
169
+ /** Runs SDK operations against the profile's console, retrying the console's transient stale-network 422. */
170
+ export const withConnection = <A, E, R>(
171
+ effect: Effect.Effect<A, E, R>,
172
+ ): Effect.Effect<
173
+ A,
174
+ E | Sdk.ConfigError,
175
+ Exclude<R, Sdk.Credentials | HttpClient.HttpClient> | UnifiNetworkConnection
176
+ > =>
177
+ Effect.gen(function* () {
178
+ const { services } = yield* yield* UnifiNetworkConnection;
179
+ return yield* Effect.provide(effect, services).pipe(
180
+ Effect.retry({
181
+ while: isStaleNetworkState,
182
+ schedule: Schedule.spaced("500 millis").pipe(Schedule.upTo({ times: 10 })),
183
+ }),
184
+ );
185
+ });
@@ -0,0 +1,85 @@
1
+ import { ConfigError } from "@alchemy-unifi/network-sdk";
2
+ import { deferUntilFirstUse, orDieCredentialsUnavailable, resolveProviderConfig } from "alchemy/Auth/Resolve";
3
+ import * as Context from "effect/Context";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Layer from "effect/Layer";
6
+ import type * as Redacted from "effect/Redacted";
7
+ import { UNIFI_NETWORK_AUTH_PROVIDER_NAME, type UnifiNetworkAuthConfig } from "./AuthConfig.ts";
8
+
9
+ /** A console or self-hosted controller reached directly, e.g. `https://192.168.1.1`. */
10
+ export interface LocalConnection {
11
+ readonly type: "local";
12
+ /** Origin of the console, without the Integration API path. */
13
+ readonly url: string;
14
+ readonly apiKey: Redacted.Redacted<string>;
15
+ /**
16
+ * Accept the server only if a certificate in its chain has this SHA-256
17
+ * fingerprint (`AB:CD:…`), whether or not Node trusts it.
18
+ */
19
+ readonly certFingerprint?: string | undefined;
20
+ /** Skip TLS verification entirely. */
21
+ readonly insecure: boolean;
22
+ }
23
+
24
+ /** A console reached through the `api.ui.com` cloud connector. */
25
+ export interface RemoteConnection {
26
+ readonly type: "remote";
27
+ readonly consoleId: string;
28
+ readonly apiKey: Redacted.Redacted<string>;
29
+ }
30
+
31
+ export type Connection = LocalConnection | RemoteConnection;
32
+
33
+ /** Where credentials came from: a named Alchemy profile, or `UNIFI_*` environment variables. */
34
+ export type CredentialsSource = { readonly type: "profile"; readonly profile: string } | { readonly type: "env" };
35
+
36
+ export interface ResolvedCredentials {
37
+ readonly connection: Connection;
38
+ /**
39
+ * Site used by resources that don't set `site`, by internal reference
40
+ * (e.g. `default`) or name. Unset means the console's only site; it's only
41
+ * needed on consoles hosting several.
42
+ */
43
+ readonly site?: string | undefined;
44
+ readonly source: CredentialsSource;
45
+ }
46
+
47
+ /**
48
+ * Lazily-resolved credentials for the active profile (or the environment).
49
+ * The service value is an Effect so nothing resolves at Layer construction;
50
+ * handlers run `yield* yield* UnifiNetworkCredentials`.
51
+ */
52
+ export class UnifiNetworkCredentials extends Context.Service<
53
+ UnifiNetworkCredentials,
54
+ Effect.Effect<ResolvedCredentials>
55
+ >()("UnifiNetwork::Credentials") {
56
+ /** Alchemy's tag for credentials services, which lets stack bodies require this one. */
57
+ declare readonly kind: "Credentials";
58
+ }
59
+
60
+ /**
61
+ * Resolves {@link UnifiNetworkCredentials} through the `UnifiNetwork` auth
62
+ * provider: environment variables when they're all set, otherwise the
63
+ * selected Alchemy profile. Resolution is deferred to first use and cached.
64
+ */
65
+ export const fromAuthProvider = () =>
66
+ Layer.effect(
67
+ UnifiNetworkCredentials,
68
+ Effect.gen(function* () {
69
+ const deferred = yield* resolveProviderConfig<UnifiNetworkAuthConfig, ResolvedCredentials>(
70
+ UNIFI_NETWORK_AUTH_PROVIDER_NAME,
71
+ ).pipe(
72
+ Effect.flatMap(({ profileName, resolve }) =>
73
+ resolve.pipe(
74
+ Effect.mapError((e: { readonly message: string }) =>
75
+ ConfigError.make({
76
+ message: `Failed to resolve UniFi Network credentials from ${profileName === undefined ? "the environment" : `profile '${profileName}'`}: ${e.message}`,
77
+ }),
78
+ ),
79
+ ),
80
+ ),
81
+ deferUntilFirstUse,
82
+ );
83
+ return yield* deferred.pipe(orDieCredentialsUnavailable(UNIFI_NETWORK_AUTH_PROVIDER_NAME), Effect.cached);
84
+ }),
85
+ );
@@ -0,0 +1,181 @@
1
+ import * as Sdk from "@alchemy-unifi/network-sdk";
2
+ import { Unowned } from "alchemy/AdoptPolicy";
3
+ import type { Diff } from "alchemy/Diff";
4
+ import * as Provider from "alchemy/Provider";
5
+ import { Resource } from "alchemy/Resource";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Stream from "effect/Stream";
8
+ import { withConnection } from "./Connection.ts";
9
+ import { type DistributiveOmit, isCurrent, omitKeys } from "./internal/Fields.ts";
10
+ import { plannedString, UNKNOWN } from "./internal/Plan.ts";
11
+ import { captureServices } from "./internal/Services.ts";
12
+ import type { Providers } from "./Providers.ts";
13
+ import * as ReadCache from "./ReadCache.ts";
14
+ import { resolveSite } from "./Site.ts";
15
+
16
+ /**
17
+ * Props are the Integration API's DNS policy body plus `site`. `type` picks
18
+ * the kind: a record (`"A_RECORD"`, `"AAAA_RECORD"`, `"CNAME_RECORD"`,
19
+ * `"MX_RECORD"`, `"SRV_RECORD"`, `"TXT_RECORD"`) or `"FORWARD_DOMAIN"`
20
+ * (send a domain's queries to another DNS server).
21
+ */
22
+ export type DnsPolicyProps = Sdk.CreateOrUpdateDNSPolicy & {
23
+ /** Site to create the policy in. @default the profile's site, else the console's only site */
24
+ site?: string;
25
+ };
26
+
27
+ export interface DnsPolicyAttributes {
28
+ siteId: string;
29
+ dnsPolicyId: string;
30
+ type: DnsPolicyProps["type"];
31
+ domain: string;
32
+ /** The whole policy as the console reports it, so `alchemy drift` sees any change. */
33
+ config: DistributiveOmit<Sdk.DNSPolicy, "id" | "metadata">;
34
+ }
35
+
36
+ export interface DnsPolicy extends Resource<
37
+ "UnifiNetwork.DnsPolicy",
38
+ DnsPolicyProps,
39
+ DnsPolicyAttributes,
40
+ never,
41
+ Providers
42
+ > {}
43
+
44
+ const loadPolicies = (siteId: string) => withConnection(Stream.runCollect(Sdk.getDnsPolicyPage.items({ siteId })));
45
+ const policiesKey = ReadCache.key<ReadonlyArray<Sdk.DNSPolicy>, Effect.Error<ReturnType<typeof loadPolicies>>>(
46
+ "dnsPolicies",
47
+ );
48
+
49
+ /** A site's DNS policies, listed once per run. */
50
+ export const listDnsPolicies = (siteId: string) => ReadCache.cached(policiesKey, siteId, loadPolicies(siteId));
51
+
52
+ const toAttributes = (siteId: string, policy: Sdk.DNSPolicy): DnsPolicyAttributes => {
53
+ const { id, metadata: _metadata, ...config } = policy;
54
+ return { siteId, dnsPolicyId: id, type: policy.type, domain: policy.domain, config };
55
+ };
56
+
57
+ /** Fields the console reports but rejects in request bodies. */
58
+ const READ_ONLY = ["id", "metadata"];
59
+
60
+ /** A policy by ID, from the run's cached list, which returns whole policies (one request for every policy in a plan). */
61
+ const getPolicy = (siteId: string, dnsPolicyId: string) =>
62
+ listDnsPolicies(siteId).pipe(Effect.map((policies) => policies.find((p) => p.id === dnsPolicyId)));
63
+
64
+ /**
65
+ * The existing policy `body` describes: the only one with its type and
66
+ * domain, or, when a domain has several records of that type (e.g. two A
67
+ * records), the only one of those whose other fields match too.
68
+ */
69
+ const findExisting = (policies: ReadonlyArray<Sdk.DNSPolicy>, body: Sdk.CreateOrUpdateDNSPolicy) => {
70
+ const sameName = policies.filter((p) => p.type === body.type && p.domain === body.domain);
71
+ const candidates = sameName.length > 1 ? sameName.filter((p) => isCurrent(body, omitKeys(p, READ_ONLY))) : sameName;
72
+ const [policy] = candidates;
73
+ return candidates.length === 1 ? policy : undefined;
74
+ };
75
+
76
+ /**
77
+ * A local DNS record or forwarding rule on the gateway. DNS policies have no
78
+ * name, so existing ones are adopted with `--adopt` by type and domain (and,
79
+ * when a domain has several records of that type, by their values).
80
+ * Changing `type` updates the policy in place.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
85
+ * import * as Alchemy from "alchemy";
86
+ * import * as Effect from "effect/Effect";
87
+ *
88
+ * export default Alchemy.Stack(
89
+ * "HomeNetwork",
90
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
91
+ * Effect.gen(function* () {
92
+ * yield* UnifiNetwork.DnsPolicy("home-assistant", {
93
+ * type: "A_RECORD",
94
+ * enabled: true,
95
+ * domain: "ha.home.arpa",
96
+ * ipv4Address: "192.168.2.20",
97
+ * ttlSeconds: 300,
98
+ * });
99
+ * }),
100
+ * );
101
+ * ```
102
+ */
103
+ export const DnsPolicy = Resource<DnsPolicy>("UnifiNetwork.DnsPolicy");
104
+
105
+ export const DnsPolicyProvider = () =>
106
+ Provider.effect(
107
+ DnsPolicy,
108
+ Effect.gen(function* () {
109
+ const run = yield* captureServices;
110
+
111
+ return DnsPolicy.Provider.of({
112
+ list: () =>
113
+ run(
114
+ Effect.gen(function* () {
115
+ const site = yield* resolveSite();
116
+ return (yield* listDnsPolicies(site.id)).map((policy) => toAttributes(site.id, policy));
117
+ }),
118
+ ),
119
+
120
+ read: ({ olds, output }) =>
121
+ run(
122
+ Effect.gen(function* () {
123
+ if (output !== undefined) {
124
+ const policy = yield* getPolicy(output.siteId, output.dnsPolicyId);
125
+ return policy === undefined ? undefined : toAttributes(output.siteId, policy);
126
+ }
127
+ const site = yield* resolveSite(olds.site);
128
+ const { site: _site, ...body } = olds;
129
+ const policy = findExisting(yield* listDnsPolicies(site.id), body);
130
+ return policy === undefined ? undefined : Unowned(toAttributes(site.id, policy));
131
+ }),
132
+ ),
133
+
134
+ diff: ({ news, output }) =>
135
+ run(
136
+ Effect.gen(function* () {
137
+ const siteProp = plannedString(news, "site");
138
+ if (output === undefined || siteProp === UNKNOWN) return undefined;
139
+ const site = yield* resolveSite(siteProp);
140
+ if (site.id !== output.siteId) return { action: "replace" } satisfies Diff;
141
+ return undefined;
142
+ }),
143
+ ),
144
+
145
+ reconcile: ({ news, output }) =>
146
+ run(
147
+ Effect.gen(function* () {
148
+ const site = yield* resolveSite(news.site);
149
+ const { site: _site, ...body } = news;
150
+ const observed =
151
+ output !== undefined && output.siteId === site.id
152
+ ? yield* getPolicy(site.id, output.dnsPolicyId)
153
+ : undefined;
154
+ const write = ReadCache.invalidating(policiesKey, site.id);
155
+ if (observed === undefined) {
156
+ const created = yield* withConnection(
157
+ Sdk.createDnsPolicy({ siteId: site.id, body }),
158
+ ).pipe(write);
159
+ return toAttributes(site.id, created);
160
+ }
161
+ if (isCurrent(body, omitKeys(observed, READ_ONLY))) return toAttributes(site.id, observed);
162
+ const updated = yield* withConnection(
163
+ Sdk.updateDnsPolicy({ siteId: site.id, dnsPolicyId: observed.id, body }),
164
+ ).pipe(write);
165
+ return toAttributes(site.id, updated);
166
+ }),
167
+ ),
168
+
169
+ delete: ({ output }) =>
170
+ run(
171
+ withConnection(
172
+ Sdk.deleteDnsPolicy({ siteId: output.siteId, dnsPolicyId: output.dnsPolicyId }),
173
+ ).pipe(
174
+ Effect.asVoid,
175
+ Effect.catchTag("NotFound", () => Effect.void),
176
+ ReadCache.invalidating(policiesKey, output.siteId),
177
+ ),
178
+ ),
179
+ });
180
+ }),
181
+ );