@alchemy.run/acme 0.0.0 → 2.0.0-beta.85
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 +201 -0
- package/README.md +48 -4
- package/lib/Account.d.ts +121 -0
- package/lib/Account.d.ts.map +1 -0
- package/lib/Account.js +163 -0
- package/lib/Account.js.map +1 -0
- package/lib/Certificate.d.ts +138 -0
- package/lib/Certificate.d.ts.map +1 -0
- package/lib/Certificate.js +157 -0
- package/lib/Certificate.js.map +1 -0
- package/lib/CertificateAuthority.d.ts +25 -0
- package/lib/CertificateAuthority.d.ts.map +1 -0
- package/lib/CertificateAuthority.js +22 -0
- package/lib/CertificateAuthority.js.map +1 -0
- package/lib/Client.d.ts +89 -0
- package/lib/Client.d.ts.map +1 -0
- package/lib/Client.js +237 -0
- package/lib/Client.js.map +1 -0
- package/lib/Der.d.ts +43 -0
- package/lib/Der.d.ts.map +1 -0
- package/lib/Der.js +138 -0
- package/lib/Der.js.map +1 -0
- package/lib/Dns.d.ts +59 -0
- package/lib/Dns.d.ts.map +1 -0
- package/lib/Dns.js +104 -0
- package/lib/Dns.js.map +1 -0
- package/lib/DnsSolver.d.ts +60 -0
- package/lib/DnsSolver.d.ts.map +1 -0
- package/lib/DnsSolver.js +48 -0
- package/lib/DnsSolver.js.map +1 -0
- package/lib/Errors.d.ts +93 -0
- package/lib/Errors.d.ts.map +1 -0
- package/lib/Errors.js +51 -0
- package/lib/Errors.js.map +1 -0
- package/lib/IssueCertificate.d.ts +76 -0
- package/lib/IssueCertificate.d.ts.map +1 -0
- package/lib/IssueCertificate.js +3 -0
- package/lib/IssueCertificate.js.map +1 -0
- package/lib/IssueCertificateHttp.d.ts +24 -0
- package/lib/IssueCertificateHttp.d.ts.map +1 -0
- package/lib/IssueCertificateHttp.js +56 -0
- package/lib/IssueCertificateHttp.js.map +1 -0
- package/lib/Pki.d.ts +56 -0
- package/lib/Pki.d.ts.map +1 -0
- package/lib/Pki.js +182 -0
- package/lib/Pki.js.map +1 -0
- package/lib/Providers.d.ts +30 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +31 -0
- package/lib/Providers.js.map +1 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/package.json +47 -3
- package/src/Account.ts +254 -0
- package/src/Certificate.ts +272 -0
- package/src/CertificateAuthority.ts +41 -0
- package/src/Client.ts +405 -0
- package/src/Der.ts +157 -0
- package/src/Dns.ts +227 -0
- package/src/DnsSolver.ts +109 -0
- package/src/Errors.ts +90 -0
- package/src/IssueCertificate.ts +89 -0
- package/src/IssueCertificateHttp.ts +67 -0
- package/src/Pki.ts +275 -0
- package/src/Providers.ts +39 -0
- package/src/index.ts +11 -0
package/src/Dns.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TXT propagation checks.
|
|
3
|
+
*
|
|
4
|
+
* Public recursive resolvers are a poor oracle: they cache negative
|
|
5
|
+
* answers for the zone's SOA minimum (30 minutes is common) and old
|
|
6
|
+
* record sets past their TTL, so a challenge value published a moment ago
|
|
7
|
+
* can stay invisible for far longer than the CA takes to validate. Where
|
|
8
|
+
* `node:dns` is available (deploy time, Fly Machines) the check therefore
|
|
9
|
+
* asks the zone's **authoritative** nameservers directly — the same
|
|
10
|
+
* servers the CA asks. Networks can intercept port 53, so DNS over HTTPS
|
|
11
|
+
* also checks for the exact challenge value. A stale recursive cache must
|
|
12
|
+
* not veto a positive answer; the CA performs its own validation.
|
|
13
|
+
*/
|
|
14
|
+
import * as Duration from "effect/Duration";
|
|
15
|
+
import * as Effect from "effect/Effect";
|
|
16
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
17
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
18
|
+
import * as Schedule from "effect/Schedule";
|
|
19
|
+
import { DnsPropagationTimeout } from "./Errors.ts";
|
|
20
|
+
|
|
21
|
+
export interface PropagationOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Wait before the first lookup to avoid caching an unpublished record.
|
|
24
|
+
* Outside the polling budget. Cloudflare defaults to 60 seconds.
|
|
25
|
+
* @default "0 seconds"
|
|
26
|
+
*/
|
|
27
|
+
readonly initialDelay?: Duration.Input | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Extra wait after the record is seen (or, for solvers that skip the
|
|
30
|
+
* check, the whole wait): anycast edges lag the first one that answers.
|
|
31
|
+
* @default "5 seconds"
|
|
32
|
+
*/
|
|
33
|
+
readonly delay?: Duration.Input | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* DNS-over-HTTPS JSON endpoints to use instead of automatic discovery.
|
|
36
|
+
* When supplied, every resolver must return the record. Automatic lookup
|
|
37
|
+
* accepts all discovered nameservers or any default DoH resolver.
|
|
38
|
+
* @default Cloudflare (1.1.1.1) and Google public DNS
|
|
39
|
+
*/
|
|
40
|
+
readonly resolvers?: ReadonlyArray<string> | undefined;
|
|
41
|
+
/** Polling budget. @default "45 seconds" */
|
|
42
|
+
readonly timeout?: Duration.Input | undefined;
|
|
43
|
+
/** Polling interval. @default "3 seconds" */
|
|
44
|
+
readonly interval?: Duration.Input | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_PROPAGATION_DELAY: Duration.Input = "5 seconds";
|
|
48
|
+
|
|
49
|
+
/** Sleep `options.delay` (default 5 seconds). */
|
|
50
|
+
export const propagationDelay = (options: PropagationOptions = {}): Effect.Effect<void> =>
|
|
51
|
+
Effect.sleep(options.delay ?? DEFAULT_PROPAGATION_DELAY);
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Authoritative lookup (node:dns where available)
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
interface NodeDns {
|
|
58
|
+
readonly resolveNs: (name: string) => Promise<string[]>;
|
|
59
|
+
readonly resolve4: (name: string) => Promise<string[]>;
|
|
60
|
+
readonly Resolver: new () => {
|
|
61
|
+
setServers(servers: string[]): void;
|
|
62
|
+
resolveTxt(name: string): Promise<string[][]>;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const nodeDns: Effect.Effect<NodeDns | undefined> = Effect.tryPromise(
|
|
67
|
+
() => import("node:dns/promises") as unknown as Promise<NodeDns>,
|
|
68
|
+
).pipe(
|
|
69
|
+
Effect.map((mod) =>
|
|
70
|
+
typeof mod?.Resolver === "function" && typeof mod.resolveNs === "function" ? mod : undefined,
|
|
71
|
+
),
|
|
72
|
+
Effect.orElseSucceed(() => undefined),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/** The zone apex's nameserver IPv4s for `fqdn`, or `[]` when unknown. */
|
|
76
|
+
const authoritativeServers = (dns: NodeDns, fqdn: string): Effect.Effect<ReadonlyArray<string>> =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const labels = fqdn.replace(/\.$/, "").split(".");
|
|
79
|
+
for (let i = 0; i < labels.length - 1; i++) {
|
|
80
|
+
const candidate = labels.slice(i).join(".");
|
|
81
|
+
const names = yield* Effect.tryPromise(() => dns.resolveNs(candidate)).pipe(
|
|
82
|
+
Effect.orElseSucceed(() => [] as string[]),
|
|
83
|
+
);
|
|
84
|
+
if (names.length === 0) continue;
|
|
85
|
+
const addresses = yield* Effect.forEach(
|
|
86
|
+
names,
|
|
87
|
+
(name) =>
|
|
88
|
+
Effect.tryPromise(() => dns.resolve4(name)).pipe(
|
|
89
|
+
Effect.orElseSucceed(() => [] as string[]),
|
|
90
|
+
),
|
|
91
|
+
{ concurrency: "unbounded" },
|
|
92
|
+
);
|
|
93
|
+
return addresses.flat();
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** TXT values requested from one nameserver (port 53 may be intercepted). */
|
|
99
|
+
const resolveTxtAt = (
|
|
100
|
+
dns: NodeDns,
|
|
101
|
+
server: string,
|
|
102
|
+
fqdn: string,
|
|
103
|
+
): Effect.Effect<ReadonlyArray<string>> =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const resolver = yield* Effect.sync(() => new dns.Resolver());
|
|
106
|
+
yield* Effect.sync(() => resolver.setServers([server]));
|
|
107
|
+
const records = yield* Effect.tryPromise(() => resolver.resolveTxt(fqdn));
|
|
108
|
+
return records.map((chunks) => chunks.join(""));
|
|
109
|
+
}).pipe(
|
|
110
|
+
Effect.timeout("3 seconds"),
|
|
111
|
+
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
export const DEFAULT_RESOLVERS: ReadonlyArray<string> = [
|
|
115
|
+
"https://cloudflare-dns.com/dns-query",
|
|
116
|
+
"https://dns.google/resolve",
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
interface DohAnswer {
|
|
120
|
+
readonly type?: number;
|
|
121
|
+
readonly data?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const unquote = (data: string): string =>
|
|
125
|
+
(data.match(/"((?:[^"\\]|\\.)*)"/g) ?? [data])
|
|
126
|
+
.map((part) => part.replace(/^"|"$/g, "").replace(/\\"/g, '"'))
|
|
127
|
+
.join("");
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* TXT values for `fqdn` from one DoH resolver. Any transport or parse
|
|
131
|
+
* failure reads as "no records yet".
|
|
132
|
+
*/
|
|
133
|
+
export const resolveTxt = (
|
|
134
|
+
resolver: string,
|
|
135
|
+
fqdn: string,
|
|
136
|
+
): Effect.Effect<ReadonlyArray<string>, never, HttpClient.HttpClient> =>
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
const client = yield* HttpClient.HttpClient;
|
|
139
|
+
const request = HttpClientRequest.get(resolver).pipe(
|
|
140
|
+
HttpClientRequest.setUrlParams({ name: fqdn, type: "TXT" }),
|
|
141
|
+
HttpClientRequest.setHeader("Accept", "application/dns-json"),
|
|
142
|
+
);
|
|
143
|
+
const response = yield* client.execute(request);
|
|
144
|
+
const body = (yield* response.json) as { Answer?: DohAnswer[] };
|
|
145
|
+
return (body.Answer ?? [])
|
|
146
|
+
.filter((answer) => answer.type === 16 && typeof answer.data === "string")
|
|
147
|
+
.map((answer) => unquote(answer.data!));
|
|
148
|
+
}).pipe(
|
|
149
|
+
Effect.timeout("3 seconds"),
|
|
150
|
+
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Wait `initialDelay`, then poll for the exact challenge value and wait
|
|
155
|
+
* `delay`. Explicit resolvers must all agree; automatic lookup accepts all
|
|
156
|
+
* discovered nameservers or a positive default DoH answer. Discovery and
|
|
157
|
+
* polling share `timeout`; the delays before and after are outside it.
|
|
158
|
+
*/
|
|
159
|
+
export const waitForTxt = (
|
|
160
|
+
fqdn: string,
|
|
161
|
+
value: string,
|
|
162
|
+
options: PropagationOptions = {},
|
|
163
|
+
): Effect.Effect<void, DnsPropagationTimeout, HttpClient.HttpClient> =>
|
|
164
|
+
Effect.gen(function* () {
|
|
165
|
+
const interval = options.interval ?? "3 seconds";
|
|
166
|
+
const timeout = options.timeout ?? "45 seconds";
|
|
167
|
+
// Bounded by `timeout` below, so poll for the caller's whole budget.
|
|
168
|
+
const attempts = Math.max(
|
|
169
|
+
1,
|
|
170
|
+
Math.ceil(Duration.toMillis(timeout) / Duration.toMillis(interval)),
|
|
171
|
+
);
|
|
172
|
+
yield* Effect.sleep(options.initialDelay ?? "0 seconds");
|
|
173
|
+
const propagated = yield* Effect.gen(function* () {
|
|
174
|
+
// Explicit resolvers must work even when node:dns is available.
|
|
175
|
+
const dns = options.resolvers === undefined ? yield* nodeDns : undefined;
|
|
176
|
+
const servers =
|
|
177
|
+
dns === undefined
|
|
178
|
+
? []
|
|
179
|
+
: yield* authoritativeServers(dns, fqdn).pipe(
|
|
180
|
+
Effect.timeout("5 seconds"),
|
|
181
|
+
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
|
|
182
|
+
);
|
|
183
|
+
const check = Effect.all(
|
|
184
|
+
{
|
|
185
|
+
direct: Effect.all(
|
|
186
|
+
dns === undefined
|
|
187
|
+
? []
|
|
188
|
+
: servers.map((server) =>
|
|
189
|
+
resolveTxtAt(dns, server, fqdn).pipe(
|
|
190
|
+
Effect.map((values) => values.includes(value)),
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
{ concurrency: "unbounded" },
|
|
194
|
+
),
|
|
195
|
+
doh: Effect.forEach(
|
|
196
|
+
options.resolvers ?? DEFAULT_RESOLVERS,
|
|
197
|
+
(resolver) =>
|
|
198
|
+
resolveTxt(resolver, fqdn).pipe(Effect.map((values) => values.includes(value))),
|
|
199
|
+
{ concurrency: "unbounded" },
|
|
200
|
+
),
|
|
201
|
+
},
|
|
202
|
+
{ concurrency: "unbounded" },
|
|
203
|
+
).pipe(
|
|
204
|
+
Effect.map(({ direct, doh }) =>
|
|
205
|
+
options.resolvers !== undefined
|
|
206
|
+
? doh.length > 0 && doh.every(Boolean)
|
|
207
|
+
: (direct.length > 0 && direct.every(Boolean)) || doh.some(Boolean),
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
return yield* check.pipe(
|
|
211
|
+
Effect.repeat({
|
|
212
|
+
schedule: Schedule.spaced(interval),
|
|
213
|
+
until: (seen) => seen,
|
|
214
|
+
times: attempts,
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
}).pipe(
|
|
218
|
+
Effect.timeoutOrElse({
|
|
219
|
+
duration: timeout,
|
|
220
|
+
orElse: () => Effect.fail(new DnsPropagationTimeout({ fqdn, value })),
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
if (!propagated) {
|
|
224
|
+
return yield* new DnsPropagationTimeout({ fqdn, value });
|
|
225
|
+
}
|
|
226
|
+
yield* propagationDelay(options);
|
|
227
|
+
});
|
package/src/DnsSolver.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNS-01 solvers: how the ACME flow publishes `_acme-challenge` TXT
|
|
3
|
+
* records.
|
|
4
|
+
*
|
|
5
|
+
* A solver has two forms:
|
|
6
|
+
*
|
|
7
|
+
* - a **descriptor** — plain, serializable data in a resource's props
|
|
8
|
+
* (`{ type: "Cloudflare.DNS", zoneId }`) that survives state and the RPC
|
|
9
|
+
* boundary. Each DNS solver has a constructor
|
|
10
|
+
* (`Cloudflare.ACME.ZoneSolver(zone)`) and a layer registering the
|
|
11
|
+
* matching implementation, so the ACME provider never imports a DNS SDK
|
|
12
|
+
* itself.
|
|
13
|
+
* - a **runtime solver** — the {@link DnsSolver} interface a running
|
|
14
|
+
* Worker or Service hands to `IssueCertificate.issue`, built from a
|
|
15
|
+
* runtime DNS write client (`Cloudflare.ACME.zoneSolver(dns)`).
|
|
16
|
+
*/
|
|
17
|
+
import * as Context from "effect/Context";
|
|
18
|
+
import * as Effect from "effect/Effect";
|
|
19
|
+
import type * as HttpClient from "effect/http/HttpClient";
|
|
20
|
+
import * as Layer from "effect/Layer";
|
|
21
|
+
import * as Option from "effect/Option";
|
|
22
|
+
import type { PropagationOptions } from "./Dns.ts";
|
|
23
|
+
import {
|
|
24
|
+
DnsSolverNotRegistered,
|
|
25
|
+
type DnsPropagationTimeout,
|
|
26
|
+
type DnsSolverError,
|
|
27
|
+
} from "./Errors.ts";
|
|
28
|
+
|
|
29
|
+
/** A challenge record to publish: the `_acme-challenge.<name>` FQDN and its TXT value. */
|
|
30
|
+
export interface DnsChallengeRecord {
|
|
31
|
+
readonly fqdn: string;
|
|
32
|
+
readonly value: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Runtime DNS-01 solver. `R` is whatever context the underlying client needs. */
|
|
36
|
+
export interface DnsSolver<R = never> {
|
|
37
|
+
/** Publish `value` as a TXT record at `fqdn`. Idempotent. */
|
|
38
|
+
readonly present: (record: DnsChallengeRecord) => Effect.Effect<void, DnsSolverError, R>;
|
|
39
|
+
/** Remove the record published by `present`. Idempotent; runs in a finalizer. */
|
|
40
|
+
readonly cleanup: (record: DnsChallengeRecord) => Effect.Effect<void, DnsSolverError, R>;
|
|
41
|
+
/**
|
|
42
|
+
* Wait until the record is visible. Defaults to polling public DNS over
|
|
43
|
+
* HTTPS; a solver for a private DNS (tests, split-horizon zones) overrides it.
|
|
44
|
+
*/
|
|
45
|
+
readonly propagated?:
|
|
46
|
+
| ((
|
|
47
|
+
record: DnsChallengeRecord,
|
|
48
|
+
options: PropagationOptions,
|
|
49
|
+
) => Effect.Effect<void, DnsSolverError | DnsPropagationTimeout, R | HttpClient.HttpClient>)
|
|
50
|
+
| undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Serializable solver description stored in `ACME.Certificate` props.
|
|
55
|
+
* `type` selects the registered implementation; the rest is provider data.
|
|
56
|
+
*/
|
|
57
|
+
export interface DnsSolverDescriptor {
|
|
58
|
+
readonly type: string;
|
|
59
|
+
readonly [key: string]: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Builds a runtime solver from a descriptor at reconcile time. */
|
|
63
|
+
export type DnsSolverFactory = (
|
|
64
|
+
descriptor: DnsSolverDescriptor,
|
|
65
|
+
) => Effect.Effect<DnsSolver, DnsSolverError>;
|
|
66
|
+
|
|
67
|
+
const solverService = (type: string) =>
|
|
68
|
+
Context.Service<DnsSolverFactory>(`alchemy/ACME/DnsSolver/${type}`);
|
|
69
|
+
|
|
70
|
+
/** Resolve the registered implementation for a descriptor. */
|
|
71
|
+
export const resolveDnsSolver = (
|
|
72
|
+
descriptor: DnsSolverDescriptor,
|
|
73
|
+
): Effect.Effect<DnsSolver, DnsSolverNotRegistered | DnsSolverError> =>
|
|
74
|
+
Effect.gen(function* () {
|
|
75
|
+
const factory = yield* Effect.serviceOption(solverService(descriptor.type));
|
|
76
|
+
if (Option.isNone(factory)) {
|
|
77
|
+
return yield* new DnsSolverNotRegistered({ type: descriptor.type });
|
|
78
|
+
}
|
|
79
|
+
return yield* factory.value(descriptor);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** A solver whose methods run with `context` provided (drops `R`). */
|
|
83
|
+
export const provideSolver = <R>(solver: DnsSolver<R>, context: Context.Context<R>): DnsSolver => ({
|
|
84
|
+
present: (record) => solver.present(record).pipe(Effect.provideContext(context)),
|
|
85
|
+
cleanup: (record) => solver.cleanup(record).pipe(Effect.provideContext(context)),
|
|
86
|
+
propagated:
|
|
87
|
+
solver.propagated === undefined
|
|
88
|
+
? undefined
|
|
89
|
+
: (record, options) =>
|
|
90
|
+
solver.propagated!(record, options).pipe(Effect.provideContext(context)),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Register a solver implementation from a `providers()` layer, capturing
|
|
95
|
+
* whatever services `R` the solver needs from that layer's own context so
|
|
96
|
+
* the ACME provider can run it without knowing about the DNS provider.
|
|
97
|
+
*/
|
|
98
|
+
export const dnsSolverLayer = <R>(
|
|
99
|
+
type: string,
|
|
100
|
+
make: (descriptor: DnsSolverDescriptor) => Effect.Effect<DnsSolver<R>, DnsSolverError>,
|
|
101
|
+
): Layer.Layer<DnsSolverFactory, never, R> =>
|
|
102
|
+
Layer.effect(
|
|
103
|
+
solverService(type),
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const context = yield* Effect.context<R>();
|
|
106
|
+
return (descriptor: DnsSolverDescriptor) =>
|
|
107
|
+
make(descriptor).pipe(Effect.map((solver) => provideSolver(solver, context)));
|
|
108
|
+
}),
|
|
109
|
+
);
|
package/src/Errors.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as Data from "effect/Data";
|
|
2
|
+
|
|
3
|
+
/** A problem the CA attached to an order or one of its identifiers. */
|
|
4
|
+
export interface IdentifierProblem {
|
|
5
|
+
/** The identifier the problem concerns, when the CA said. */
|
|
6
|
+
readonly identifier?: string | undefined;
|
|
7
|
+
/** The `urn:ietf:params:acme:error:*` type. */
|
|
8
|
+
readonly type?: string | undefined;
|
|
9
|
+
/** Human-readable detail from the CA. */
|
|
10
|
+
readonly detail?: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The CA moved the order to `invalid`. Carries the problem documents the
|
|
15
|
+
* order and its authorizations reported, one per failed identifier where
|
|
16
|
+
* the CA said which.
|
|
17
|
+
*/
|
|
18
|
+
export class OrderInvalid extends Data.TaggedError("ACME.OrderInvalid")<{
|
|
19
|
+
readonly orderUrl: string;
|
|
20
|
+
readonly problems: ReadonlyArray<IdentifierProblem>;
|
|
21
|
+
}> {
|
|
22
|
+
override get message() {
|
|
23
|
+
const details = this.problems
|
|
24
|
+
.map((p) => [p.identifier, p.type, p.detail].filter(Boolean).join(": "))
|
|
25
|
+
.join("; ");
|
|
26
|
+
return `ACME order ${this.orderUrl} is invalid${details ? `: ${details}` : ""}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The CA rejected the DNS-01 challenge for one identifier. */
|
|
31
|
+
export class ChallengeFailed extends Data.TaggedError("ACME.ChallengeFailed")<{
|
|
32
|
+
readonly identifier: string;
|
|
33
|
+
readonly type?: string | undefined;
|
|
34
|
+
readonly detail?: string | undefined;
|
|
35
|
+
}> {
|
|
36
|
+
override get message() {
|
|
37
|
+
return `DNS-01 challenge for ${this.identifier} failed${this.detail ? `: ${this.detail}` : ""}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The CA offered no `dns-01` challenge for an identifier. */
|
|
42
|
+
export class ChallengeUnsupported extends Data.TaggedError("ACME.ChallengeUnsupported")<{
|
|
43
|
+
readonly identifier: string;
|
|
44
|
+
readonly offered: ReadonlyArray<string>;
|
|
45
|
+
}> {
|
|
46
|
+
override get message() {
|
|
47
|
+
return `The CA offered no dns-01 challenge for ${this.identifier} (offered: ${this.offered.join(", ") || "none"})`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The order never reached a terminal state within the polling budget. */
|
|
52
|
+
export class OrderTimeout extends Data.TaggedError("ACME.OrderTimeout")<{
|
|
53
|
+
readonly orderUrl: string;
|
|
54
|
+
readonly status: string;
|
|
55
|
+
}> {
|
|
56
|
+
override get message() {
|
|
57
|
+
return `ACME order ${this.orderUrl} is still "${this.status}" after the polling budget`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `_acme-challenge` TXT record never became visible to public resolvers. */
|
|
62
|
+
export class DnsPropagationTimeout extends Data.TaggedError("ACME.DnsPropagationTimeout")<{
|
|
63
|
+
readonly fqdn: string;
|
|
64
|
+
readonly value: string;
|
|
65
|
+
}> {
|
|
66
|
+
override get message() {
|
|
67
|
+
return `TXT ${this.fqdn} did not propagate within the wait budget`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A DNS solver could not publish or remove a challenge record. */
|
|
72
|
+
export class DnsSolverError extends Data.TaggedError("ACME.DnsSolverError")<{
|
|
73
|
+
readonly message: string;
|
|
74
|
+
readonly cause?: unknown;
|
|
75
|
+
}> {}
|
|
76
|
+
|
|
77
|
+
/** A solver descriptor names a `type` no registered DNS provider handles. */
|
|
78
|
+
export class DnsSolverNotRegistered extends Data.TaggedError("ACME.DnsSolverNotRegistered")<{
|
|
79
|
+
readonly type: string;
|
|
80
|
+
}> {
|
|
81
|
+
override get message() {
|
|
82
|
+
return `No DNS-01 solver is registered for "${this.type}". Include ACME.providers() in the stack.`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Key generation, CSR encoding or certificate parsing failed. */
|
|
87
|
+
export class PkiError extends Data.TaggedError("ACME.PkiError")<{
|
|
88
|
+
readonly message: string;
|
|
89
|
+
readonly cause?: unknown;
|
|
90
|
+
}> {}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as Binding from "alchemy/Binding";
|
|
2
|
+
import type { RuntimeContext } from "alchemy/RuntimeContext";
|
|
3
|
+
import type * as Effect from "effect/Effect";
|
|
4
|
+
import type { Account } from "./Account.ts";
|
|
5
|
+
import type {
|
|
6
|
+
IssueError,
|
|
7
|
+
IssueRequest,
|
|
8
|
+
IssuedCertificate,
|
|
9
|
+
RevokeError,
|
|
10
|
+
RevokeRequest,
|
|
11
|
+
} from "./Client.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Issue (and revoke) certificates at runtime as an {@link Account} — for
|
|
15
|
+
* services that terminate TLS themselves and need certificates on demand,
|
|
16
|
+
* like a relay minting `*.<tenant>.example.com` when a tenant first
|
|
17
|
+
* connects.
|
|
18
|
+
*
|
|
19
|
+
* Init transports the account's directory URL, account URL and redacted
|
|
20
|
+
* private key through resource Outputs. Protect the runtime and state store
|
|
21
|
+
* as secret material. The caller chooses where to store the issued PEMs.
|
|
22
|
+
*
|
|
23
|
+
* ### Issue on demand
|
|
24
|
+
* Publish the DNS-01 record through any runtime DNS write client wrapped
|
|
25
|
+
* as a solver (`Cloudflare.ACME.zoneSolver(dns)`).
|
|
26
|
+
*
|
|
27
|
+
* **Example:** Mint a wildcard from a Fly Service and upload it to its App
|
|
28
|
+
* ```typescript
|
|
29
|
+
* export default class Relay extends Fly.Service<Relay>()(
|
|
30
|
+
* "Relay",
|
|
31
|
+
* { app: RelayApp, main: import.meta.url },
|
|
32
|
+
* Effect.gen(function* () {
|
|
33
|
+
* const acme = yield* ACME.IssueCertificate(ZeroSsl);
|
|
34
|
+
* const dns = yield* Cloudflare.DNS.WriteDns(Zone);
|
|
35
|
+
* const certs = yield* Fly.WriteCertificates(RelayApp);
|
|
36
|
+
* return {
|
|
37
|
+
* fetch: Effect.gen(function* () {
|
|
38
|
+
* const issued = yield* acme.issue({
|
|
39
|
+
* identifiers: ["*.tenant.example.com"],
|
|
40
|
+
* solver: Cloudflare.ACME.zoneSolver(dns),
|
|
41
|
+
* });
|
|
42
|
+
* yield* certs.upload({
|
|
43
|
+
* hostname: "*.tenant.example.com",
|
|
44
|
+
* fullchain: issued.chain,
|
|
45
|
+
* privateKey: issued.privateKey,
|
|
46
|
+
* });
|
|
47
|
+
* return HttpServerResponse.text("ok");
|
|
48
|
+
* }),
|
|
49
|
+
* };
|
|
50
|
+
* }).pipe(
|
|
51
|
+
* Effect.provide(ACME.IssueCertificateHttp),
|
|
52
|
+
* Effect.provide(Cloudflare.DNS.WriteDnsHttp),
|
|
53
|
+
* Effect.provide(Fly.WriteCertificatesHttp),
|
|
54
|
+
* ),
|
|
55
|
+
* ) {}
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* ### Rate limits
|
|
59
|
+
* The SDK retry policy applies to individual requests. Apply
|
|
60
|
+
* `Acme.Retry.none` from `@distilled.cloud/acme` to surface failures
|
|
61
|
+
* immediately; `AcmeRateLimited.retryAfter` carries the CA's retry hint.
|
|
62
|
+
*
|
|
63
|
+
* ### Which CA from a Cloudflare Worker
|
|
64
|
+
* CA reachability depends on the host's egress. Let's Encrypt has returned
|
|
65
|
+
* TLS failures from deployed Cloudflare Workers in live testing; the
|
|
66
|
+
* deployed-Worker example uses ZeroSSL instead. Verify the selected CA
|
|
67
|
+
* from the runtime where issuance will run.
|
|
68
|
+
*
|
|
69
|
+
* @binding
|
|
70
|
+
* @product Certificate
|
|
71
|
+
* @category Certificates
|
|
72
|
+
*/
|
|
73
|
+
export interface IssueCertificate extends Binding.Service<
|
|
74
|
+
IssueCertificate,
|
|
75
|
+
"ACME.IssueCertificate",
|
|
76
|
+
(account: Account) => Effect.Effect<IssueCertificateClient>
|
|
77
|
+
> {}
|
|
78
|
+
|
|
79
|
+
export const IssueCertificate = Binding.Service<IssueCertificate>("ACME.IssueCertificate");
|
|
80
|
+
|
|
81
|
+
/** Runtime issuance client bound to one account. */
|
|
82
|
+
export interface IssueCertificateClient {
|
|
83
|
+
/** Order, solve DNS-01, finalize and download a certificate. */
|
|
84
|
+
issue<R = never>(
|
|
85
|
+
request: IssueRequest<R>,
|
|
86
|
+
): Effect.Effect<IssuedCertificate, IssueError, RuntimeContext | R>;
|
|
87
|
+
/** Revoke a certificate (`AcmeAlreadyRevoked` counts as success). */
|
|
88
|
+
revoke(request: RevokeRequest): Effect.Effect<void, RevokeError, RuntimeContext>;
|
|
89
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as Output from "alchemy/Output";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Layer from "effect/Layer";
|
|
4
|
+
import type { Account } from "./Account.ts";
|
|
5
|
+
import {
|
|
6
|
+
accountLayer,
|
|
7
|
+
issueCertificate,
|
|
8
|
+
revokeCertificate,
|
|
9
|
+
type AccountCredentials,
|
|
10
|
+
} from "./Client.ts";
|
|
11
|
+
import { IssueCertificate, type IssueCertificateClient } from "./IssueCertificate.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Implementation of {@link IssueCertificate}: the account's directory
|
|
15
|
+
* URL, account URL and private key are bound into the host at deploy time
|
|
16
|
+
* and read back at runtime; every call signs with them over HTTPS.
|
|
17
|
+
*
|
|
18
|
+
* Provide it on the Worker / Service / Action Effect.
|
|
19
|
+
*
|
|
20
|
+
* ### Provide the layer
|
|
21
|
+
* **Example:** On a Fly Service
|
|
22
|
+
* ```typescript
|
|
23
|
+
* Effect.gen(function* () {
|
|
24
|
+
* const acme = yield* ACME.IssueCertificate(LetsEncrypt);
|
|
25
|
+
* // ...
|
|
26
|
+
* }).pipe(Effect.provide(ACME.IssueCertificateHttp))
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @layer
|
|
30
|
+
* @product Certificate
|
|
31
|
+
* @provides ACME.IssueCertificate
|
|
32
|
+
*/
|
|
33
|
+
export const IssueCertificateHttp = Layer.succeed(
|
|
34
|
+
IssueCertificate,
|
|
35
|
+
Effect.fn(function* (account: Account) {
|
|
36
|
+
const directoryUrl = yield* account.directoryUrl;
|
|
37
|
+
const accountUrl = yield* account.accountUrl;
|
|
38
|
+
const trustedRoot = yield* account.trustedRoot.pipe(Output.map((root) => root ?? ""));
|
|
39
|
+
const privateKey = yield* account.privateKey;
|
|
40
|
+
const credentials = Effect.all({
|
|
41
|
+
directoryUrl,
|
|
42
|
+
trustedRoot,
|
|
43
|
+
accountUrl,
|
|
44
|
+
privateKey,
|
|
45
|
+
}).pipe(
|
|
46
|
+
Effect.map((bound): AccountCredentials => ({
|
|
47
|
+
ca: {
|
|
48
|
+
directoryUrl: bound.directoryUrl,
|
|
49
|
+
trustedRoot: bound.trustedRoot || undefined,
|
|
50
|
+
},
|
|
51
|
+
accountKey: bound.privateKey,
|
|
52
|
+
accountUrl: bound.accountUrl,
|
|
53
|
+
})),
|
|
54
|
+
);
|
|
55
|
+
const client: IssueCertificateClient = {
|
|
56
|
+
issue: Effect.fn("ACME.IssueCertificate.issue")(function* (request) {
|
|
57
|
+
const creds = yield* credentials;
|
|
58
|
+
return yield* issueCertificate(request).pipe(Effect.provide(accountLayer(creds)));
|
|
59
|
+
}),
|
|
60
|
+
revoke: Effect.fn("ACME.IssueCertificate.revoke")(function* (request) {
|
|
61
|
+
const creds = yield* credentials;
|
|
62
|
+
return yield* revokeCertificate(request).pipe(Effect.provide(accountLayer(creds)));
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
return client;
|
|
66
|
+
}),
|
|
67
|
+
);
|