@alchemy-unifi/network 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +92 -1
- package/dist/index.d.mts +929 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2372 -0
- package/dist/index.mjs.map +1 -0
- package/dist/rolldown-runtime-D8jQpDG7.mjs +12 -0
- package/package.json +74 -1
- package/src/AuthConfig.ts +23 -0
- package/src/AuthProvider.ts +469 -0
- package/src/Connection.ts +185 -0
- package/src/Credentials.ts +85 -0
- package/src/DnsPolicy.ts +181 -0
- package/src/FirewallPolicy.ts +456 -0
- package/src/FirewallPolicyOrdering.ts +286 -0
- package/src/FirewallZone.ts +247 -0
- package/src/HttpClient.ts +138 -0
- package/src/Lookup.ts +34 -0
- package/src/Network.ts +335 -0
- package/src/Providers.ts +59 -0
- package/src/ReadCache.ts +79 -0
- package/src/Site.ts +100 -0
- package/src/SiteManager.ts +68 -0
- package/src/State.ts +607 -0
- package/src/TrafficMatchingList.ts +250 -0
- package/src/WifiBroadcast.ts +318 -0
- package/src/index.ts +17 -0
- package/src/internal/Fields.ts +49 -0
- package/src/internal/Networks.ts +7 -0
- package/src/internal/Plan.ts +22 -0
- package/src/internal/Services.ts +19 -0
|
@@ -0,0 +1,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
|
+
);
|
package/src/DnsPolicy.ts
ADDED
|
@@ -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
|
+
);
|