1import type {
2 CertificateCheckResponse,
3 CertificateDetail,
4 CertificateSummary,
5 CertificateValidation as FlyCertificateValidation,
6 DNSRequirements,
7} from "@distilled.cloud/fly-io/machines";
8import * as machines from "@distilled.cloud/fly-io/machines";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Redacted from "effect/Redacted";
12import * as Schedule from "effect/Schedule";
13import { isResolved } from "../Diff.ts";
14import * as Provider from "../Provider.ts";
15import { Resource } from "../Resource.ts";
16import { listOwnedApps } from "./App.ts";
17import type { App } from "./App.ts";
18import type { Providers } from "./Providers.ts";
19
20/**
21 * A resource-valued prop: the resource itself, or an Effect that produces
22 * it (so `yield* App(...)` and `App(...)` both type-check).
23 */
24type Ref<T> = T | Effect.Effect<T, never, Providers>;
25
26export type CertificateKind = "acme" | "custom";
27
28export type CertificateSource = "custom" | "fly";
29
30export type CertificateDnsRequirements = {
31 a?: string[];
32 aaaa?: string[];
33 acmeChallenge?: { name?: string; target?: string };
34 cname?: string;
35 ownership?: { appValue?: string; name?: string; orgValue?: string };
36};
37
38export type CertificateValidationState = {
39 alpnConfigured?: boolean;
40 dnsConfigured?: boolean;
41 httpConfigured?: boolean;
42 ownershipTxtConfigured?: boolean;
43};
44
45export interface CertificateProps {
46 /**
47 * Parent Fly App. Accepts a `Fly.App` resource or an Effect that
48 * produces one. Changing the App replaces the Certificate.
49 */
50 app: Ref<App>;
51 /**
52 * Hostname this Certificate covers. Identity of the resource.
53 * Changing it replaces the Certificate.
54 */
55 hostname: string;
56 /**
57 * How the Certificate is issued. `"acme"` requests a Let's Encrypt
58 * certificate (`createAppAcmeCertificate`). `"custom"` uploads a PEM
59 * (`createAppCustomCertificate`). Changing kind replaces.
60 *
61 * @default "acme"
62 */
63 kind?: CertificateKind;
64 /**
65 * PEM-encoded certificate chain. Required when `kind` is `"custom"`.
66 * Updating it re-uploads in place (custom create upsert, or
67 * delete+create if the API conflicts).
68 */
69 fullchain?: string;
70 /**
71 * PEM-encoded private key. Required when `kind` is `"custom"`. Wrap
72 * with `Redacted.make` to keep it out of logs. Never stored in
73 * attributes.
74 */
75 privateKey?: Redacted.Redacted<string> | string;
76}
77
78export type Certificate = Resource<
79 "Fly.Certificate",
80 CertificateProps,
81 {
82 /** Physical Fly App name the Certificate is attached to. */
83 appName: string;
84 /** Hostname this Certificate covers. Identity of the resource. */
85 hostname: string;
86 /** Observed status (`active`, `pending_validation`, …). */
87 status: string | undefined;
88 /** Whether DNS/ownership validation has completed. */
89 configured: boolean | undefined;
90 /** Whether ACME issuance has been requested for this hostname. */
91 acmeRequested: boolean | undefined;
92 /** DNS records Fly expects for validation. */
93 dnsRequirements: CertificateDnsRequirements | undefined;
94 /** Per-challenge validation flags. */
95 validation: CertificateValidationState | undefined;
96 /** Observed source: `"custom"` for uploaded PEMs, `"fly"` for ACME. */
97 source: CertificateSource;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * A Fly.Certificate covers a hostname on an {@link App}. Default
105 * `kind` is `"acme"` (Let's Encrypt). `"custom"` uploads a PEM.
106 *
107 * IPs and certificates attach to the App. A {@link Service} publishes
108 * ports. Fly's proxy terminates TLS on 443 once the certificate is
109 * `configured`.
110 *
111 * @see https://fly.io/docs/machines/api/certificates-resource/
112 *
113 * ### ACME certificates
114 * Request Let's Encrypt for a hostname. The Service does not change.
115 * Yield the certificate in the Stack. Point DNS at the App.
116 *
117 * **Example:** Let's Encrypt
118 * ```typescript
119 * export const Www = Fly.Certificate("Www", {
120 * app: Site,
121 * hostname: "www.example.com",
122 * });
123 * ```
124 *
125 * :::caution[Changing `hostname` replaces the Certificate]
126 * Hostname is the identity. The old hostname is deleted, then the new
127 * one is created.
128 * :::
129 *
130 * :::caution[Changing `app` or `kind` replaces the Certificate]
131 * The certificate is created on the new App or with the new issuer.
132 * :::
133 *
134 * ### DNS
135 * Point an A record at a `shared_v4` {@link IpAssignment} and an AAAA
136 * at `v6`. Plus whatever `dnsRequirements` lists for the ACME
137 * challenge. Alchemy re-checks via `checkAppCertificate` while
138 * `configured` is false.
139 *
140 * Observed attrs include `status`, `configured`, `acmeRequested`,
141 * `dnsRequirements`, and `validation`.
142 *
143 * **Example:** Yield next to a Service
144 * ```typescript
145 * export default Alchemy.Stack(
146 * "MyApp",
147 * { providers: Fly.providers(), state: Alchemy.localState() },
148 * Effect.gen(function* () {
149 * const api = yield* Api;
150 * const ip = yield* PublicIp;
151 * const v6 = yield* V6;
152 * const www = yield* Www;
153 * return {
154 * url: api.url,
155 * ip: ip.ip,
156 * v6: v6.ip,
157 * dns: www.dnsRequirements,
158 * };
159 * }),
160 * );
161 * ```
162 *
163 * ### Custom certificates
164 * `"custom"` uploads `fullchain` and `privateKey`. Wrap the key with
165 * `Redacted.make` so it never logs. Never stored in attributes.
166 * Updating the PEM re-uploads in place.
167 *
168 * **Example:** Upload a PEM
169 * ```typescript
170 * export const Www = Fly.Certificate("Www", {
171 * app: Site,
172 * hostname: "www.example.com",
173 * kind: "custom",
174 * fullchain: pem,
175 * privateKey: Redacted.make(key),
176 * });
177 * ```
178 *
179 * @resource
180 * @product Certificate
181 */
182export const Certificate = Resource<Certificate>("Fly.Certificate");
183
184export class CertificateNotCreated extends Data.TaggedError(
185 "Fly.CertificateNotCreated",
186)<{
187 appName: string;
188 hostname: string;
189}> {}
190
191export class CertificateAppMissing extends Data.TaggedError(
192 "Fly.CertificateAppMissing",
193)<{
194 hostname: string;
195}> {}
196
197export class CertificateMaterialRequired extends Data.TaggedError(
198 "Fly.CertificateMaterialRequired",
199)<{
200 hostname: string;
201}> {}
202
203type ObservedCert = CertificateDetail | CertificateCheckResponse;
204
205const appNameOf = (value: unknown): string | undefined => {
206 if (value === null || typeof value !== "object") return undefined;
207 const rec = value as { appName?: unknown; name?: unknown };
208 if (typeof rec.appName === "string" && rec.appName.length > 0) {
209 return rec.appName;
210 }
211 if (typeof rec.name === "string" && rec.name.length > 0) {
212 return rec.name;
213 }
214 return undefined;
215};
216
217const unwrapSecret = (
218 value: Redacted.Redacted<string> | string | undefined,
219): string | undefined => {
220 if (value === undefined) return undefined;
221 return Redacted.isRedacted(value) ? Redacted.value(value) : value;
222};
223
224const normalizePem = (pem: string | undefined): string =>
225 (pem ?? "").replace(/\r\n/g, "\n").trim();
226
227const desiredKind = (props: Pick<CertificateProps, "kind">): CertificateKind =>
228 props.kind ?? "acme";
229
230const kindOf = (source: CertificateSource): CertificateKind =>
231 source === "custom" ? "custom" : "acme";
232
233const sourceOf = (detail: ObservedCert): CertificateSource => {
234 const entries = detail.certificates ?? [];
235 if (entries.some((entry) => entry.source === "custom")) return "custom";
236 if (entries.some((entry) => entry.source === "fly")) return "fly";
237 return detail.acme_requested === true ? "fly" : "custom";
238};
239
240const sourceFromSummary = (summary: CertificateSummary): CertificateSource =>
241 summary.has_custom_certificate === true ? "custom" : "fly";
242
243const toDnsRequirements = (
244 req: DNSRequirements | undefined,
245): CertificateDnsRequirements | undefined => {
246 if (req === undefined) return undefined;
247 return {
248 a: req.a,
249 aaaa: req.aaaa,
250 acmeChallenge:
251 req.acme_challenge === undefined
252 ? undefined
253 : {
254 name: req.acme_challenge.name,
255 target: req.acme_challenge.target,
256 },
257 cname: req.cname,
258 ownership:
259 req.ownership === undefined
260 ? undefined
261 : {
262 appValue: req.ownership.app_value,
263 name: req.ownership.name,
264 orgValue: req.ownership.org_value,
265 },
266 };
267};
268
269const toValidation = (
270 validation: FlyCertificateValidation | undefined,
271): CertificateValidationState | undefined => {
272 if (validation === undefined) return undefined;
273 return {
274 alpnConfigured: validation.alpn_configured,
275 dnsConfigured: validation.dns_configured,
276 httpConfigured: validation.http_configured,
277 ownershipTxtConfigured: validation.ownership_txt_configured,
278 };
279};
280
281const toAttrs = (
282 appName: string,
283 detail: ObservedCert,
284 fallbackHostname?: string,
285): Certificate["Attributes"] => ({
286 appName,
287 hostname: detail.hostname ?? fallbackHostname ?? "",
288 status: detail.status,
289 configured: detail.configured,
290 acmeRequested: detail.acme_requested,
291 dnsRequirements: toDnsRequirements(detail.dns_requirements),
292 validation: toValidation(detail.validation),
293 source: sourceOf(detail),
294});
295
296const toAttrsFromSummary = (
297 appName: string,
298 summary: CertificateSummary,
299): Certificate["Attributes"] | undefined => {
300 const hostname = summary.hostname;
301 if (hostname === undefined || hostname.length === 0) return undefined;
302 return {
303 appName,
304 hostname,
305 status: summary.status,
306 configured: summary.configured,
307 acmeRequested: summary.acme_requested,
308 dnsRequirements: undefined,
309 validation: {
310 alpnConfigured: summary.acme_alpn_configured,
311 dnsConfigured: summary.acme_dns_configured,
312 httpConfigured: summary.acme_http_configured,
313 ownershipTxtConfigured: summary.ownership_txt_configured,
314 },
315 source: sourceFromSummary(summary),
316 };
317};
318
319const getByHostname = (appName: string, hostname: string) =>
320 machines
321 .getAppCertificate({ app_name: appName, hostname })
322 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
323
324const listAppCertificates = (appName: string) =>
325 Effect.gen(function* () {
326 const all: CertificateSummary[] = [];
327 let cursor: string | undefined;
328 for (let i = 0; i < 8; i++) {
329 const page = yield* machines
330 .listAppCertificates({
331 app_name: appName,
332 cursor,
333 limit: 100,
334 })
335 .pipe(
336 Effect.catchTag(["NotFound", "Forbidden"], () =>
337 Effect.succeed({
338 certificates: [] as CertificateSummary[],
339 next_cursor: undefined,
340 }),
341 ),
342 );
343 all.push(...(page.certificates ?? []));
344 if (page.next_cursor === undefined || page.next_cursor.length === 0) {
345 break;
346 }
347 cursor = page.next_cursor;
348 }
349 return all;
350 });
351
352const waitUntilGone = (appName: string, hostname: string) =>
353 getByHostname(appName, hostname).pipe(
354 Effect.map((found) => found === undefined),
355 Effect.repeat({
356 schedule: Schedule.spaced("1 second"),
357 until: (gone) => gone,
358 times: 8,
359 }),
360 );
361
362const requireCustomMaterial = (
363 hostname: string,
364 fullchain: string | undefined,
365 privateKey: string | undefined,
366) =>
367 fullchain === undefined ||
368 fullchain.length === 0 ||
369 privateKey === undefined ||
370 privateKey.length === 0
371 ? new CertificateMaterialRequired({ hostname })
372 : undefined;
373
374const createAcme = (appName: string, hostname: string) =>
375 machines
376 .createAppAcmeCertificate({
377 app_name: appName,
378 hostname,
379 })
380 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
381
382const createCustom = (
383 appName: string,
384 hostname: string,
385 fullchain: string,
386 privateKey: string,
387) =>
388 machines
389 .createAppCustomCertificate({
390 app_name: appName,
391 hostname,
392 fullchain,
393 private_key: privateKey,
394 })
395 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
396
397const replaceCustom = (
398 appName: string,
399 hostname: string,
400 fullchain: string,
401 privateKey: string,
402) =>
403 Effect.gen(function* () {
404 yield* machines
405 .deleteAppCustomCertificate({
406 app_name: appName,
407 hostname,
408 })
409 .pipe(Effect.catchTag("NotFound", () => Effect.void));
410 yield* machines.createAppCustomCertificate({
411 app_name: appName,
412 hostname,
413 fullchain,
414 private_key: privateKey,
415 });
416 });
417
418export const CertificateProvider = () =>
419 Provider.succeed(Certificate, {
420 stables: ["appName", "hostname", "source"],
421 nuke: { dependsOn: ["Fly.App"] },
422
423 diff: Effect.fn(function* ({ news, output }) {
424 if (news === undefined || !isResolved(news)) return undefined;
425 if (output === undefined) return undefined;
426 const appName = appNameOf(news.app);
427 const appChanged = appName !== undefined && appName !== output.appName;
428 const hostnameChanged = news.hostname !== output.hostname;
429 const nextKind = desiredKind(news);
430 const kindChanged = nextKind !== kindOf(output.source);
431 if (appChanged || hostnameChanged || kindChanged) {
432 return {
433 action: "replace" as const,
434 // Same hostname cannot exist twice — delete the old kind first.
435 deleteFirst: !hostnameChanged && !appChanged,
436 };
437 }
438 return undefined;
439 }),
440
441 read: Effect.fn(function* ({ olds, output }) {
442 const appName = output?.appName ?? appNameOf(olds?.app);
443 const hostname = output?.hostname ?? olds?.hostname;
444 if (appName === undefined || hostname === undefined) return undefined;
445 const found = yield* getByHostname(appName, hostname);
446 if (found === undefined) return undefined;
447 return toAttrs(appName, found, hostname);
448 }),
449
450 list: Effect.fn(function* () {
451 const apps = yield* listOwnedApps();
452 const rows = yield* Effect.forEach(
453 apps,
454 (app) =>
455 listAppCertificates(app.appName).pipe(
456 Effect.map((certs) =>
457 certs.flatMap((item) => {
458 const attrs = toAttrsFromSummary(app.appName, item);
459 return attrs === undefined ? [] : [attrs];
460 }),
461 ),
462 ),
463 { concurrency: 8 },
464 );
465 return rows.flat();
466 }),
467
468 reconcile: Effect.fn(function* ({ news, olds, output }) {
469 const props = news ?? ({} as CertificateProps);
470 const appName = appNameOf(props.app) ?? output?.appName;
471 const hostname = props.hostname ?? output?.hostname;
472 if (appName === undefined) {
473 return yield* new CertificateAppMissing({
474 hostname: hostname ?? "",
475 });
476 }
477 if (hostname === undefined || hostname.length === 0) {
478 return yield* new CertificateNotCreated({
479 appName,
480 hostname: "",
481 });
482 }
483
484 const kind = desiredKind(props);
485
486 // Observe by cached identity, then by desired hostname.
487 let current =
488 output?.hostname !== undefined
489 ? yield* getByHostname(
490 output.appName.length > 0 ? output.appName : appName,
491 output.hostname,
492 )
493 : undefined;
494 if (
495 current === undefined &&
496 (output?.hostname !== hostname || output?.appName !== appName)
497 ) {
498 current = yield* getByHostname(appName, hostname);
499 }
500
501 let createdThisPass = false;
502 if (current === undefined) {
503 if (kind === "custom") {
504 const fullchain = props.fullchain;
505 const privateKey = unwrapSecret(props.privateKey);
506 const missing = requireCustomMaterial(
507 hostname,
508 fullchain,
509 privateKey,
510 );
511 if (missing !== undefined) return yield* missing;
512 const created = yield* createCustom(
513 appName,
514 hostname,
515 fullchain!,
516 privateKey!,
517 );
518 current = created ?? (yield* getByHostname(appName, hostname));
519 } else {
520 const created = yield* createAcme(appName, hostname);
521 current = created ?? (yield* getByHostname(appName, hostname));
522 }
523 createdThisPass = true;
524 }
525
526 if (current === undefined) {
527 return yield* new CertificateNotCreated({ appName, hostname });
528 }
529
530 if (kind === "custom") {
531 const fullchain = props.fullchain;
532 const privateKey = unwrapSecret(props.privateKey);
533 const missing = requireCustomMaterial(hostname, fullchain, privateKey);
534 if (missing !== undefined) return yield* missing;
535 const previousChain = olds?.fullchain;
536 const previousKey = unwrapSecret(olds?.privateKey);
537 const materialChanged =
538 olds === undefined ||
539 normalizePem(fullchain) !== normalizePem(previousChain) ||
540 normalizePem(privateKey) !== normalizePem(previousKey);
541 if (!createdThisPass && materialChanged) {
542 const upserted = yield* createCustom(
543 appName,
544 hostname,
545 fullchain!,
546 privateKey!,
547 );
548 if (upserted === undefined) {
549 yield* replaceCustom(appName, hostname, fullchain!, privateKey!);
550 }
551 current =
552 upserted ?? (yield* getByHostname(appName, hostname)) ?? current;
553 }
554 } else if (current.configured !== true) {
555 const checked = yield* machines
556 .checkAppCertificate({
557 app_name: appName,
558 hostname,
559 })
560 .pipe(
561 Effect.catchTag(["NotFound", "BadRequest"], () =>
562 Effect.succeed(undefined),
563 ),
564 );
565 if (checked !== undefined) {
566 current = checked;
567 }
568 }
569
570 return toAttrs(appName, current, hostname);
571 }),
572
573 delete: Effect.fn(function* ({ output }) {
574 const appName = output.appName;
575 const hostname = output.hostname;
576 if (appName.length === 0 || hostname.length === 0) return;
577 yield* machines
578 .deleteAppCertificate({
579 app_name: appName,
580 hostname,
581 })
582 .pipe(Effect.catchTag("NotFound", () => Effect.void));
583 yield* waitUntilGone(appName, hostname);
584 }),
585 });