import type { Domain, Project } from "@repo/db";
import type { ManualCert, SslProvider, SslResult } from "@repo/adapters";
import { ForbiddenError, NotFoundError, SYSTEM, safeErrorMessage } from "@repo/core";
import { repos } from "@repo/db";
import { env } from "../config/env";
import { platform } from "./controller-helpers";
import { createProvisionLock } from "./provision-lock";
import { disposePlatform, resolveDeploymentPlatform, type DeploymentMeta } from "./deployment-runtime";
/**
* The per-domain issuance lock key. EVERY path that can open an ACME order
* (manual Verify, `manageDomainSsl` provision/renew, the ssl:renew scheduler
* which routes through `manageDomainSsl("renew")`) serializes on this exact
* string so two of them can never run certbot for the same hostname at once —
* concurrent HTTP-01 challenges collide and burn Let's Encrypt rate-limit
* budget. In-process mutex (single-process self-hosted) layered over a Postgres
* advisory lock (multi-instance SaaS); see provision-lock.ts.
*/
export function sslIssueLockKey(hostname: string): string {
return `ssl:issue:${hostname.trim().toLowerCase()}`;
}
/**
* The per-BOX ACME lock. `sslIssueLockKey` serializes one hostname against
* itself; this serializes every hostname on a box against every other, because
* certbot's standalone authenticator binds a single loopback port
* (`ACME_HTTP01_PORT`) that the edge proxies the challenge to. Two hostnames
* issuing at once therefore fight over that port and one dies with
* "Could not bind TCP port" — a failure that reads like a DNS problem and isn't.
*
* Unreachable with one custom domain per project, which is why it went unnoticed;
* "Include www" makes two the normal case, so a manual Verify on the apex can now
* collide with the pending-SSL sweep issuing for the www sibling. Taken INSIDE
* the per-hostname lock, so lock ordering is always hostname → box.
*
* `scope` is the serving server's id, or "local" for the box the API runs on —
* different servers keep issuing in parallel.
*/
export function acmeIssueLockKey(scope: string): string {
return `ssl:acme:${scope}`;
}
/**
* A cert is "comfortably valid" for REUSE when it's present, parses, and has
* more life left than the renewal window — i.e. the renewer wouldn't touch it
* yet. Verify reuses such a cert instead of spending a fresh ACME issuance.
* (readCertInfo reports `verified:true` for any parseable cert even if expired,
* so the expiry comparison must live here, not in the adapter.)
*/
function certComfortablyValid(result: SslResult): boolean {
if (!result.verified || !result.expiresAt) return false;
const daysLeft = (new Date(result.expiresAt).getTime() - Date.now()) / 86_400_000;
return daysLeft > SYSTEM.DOMAINS.SSL_RENEW_BEFORE_DAYS;
}
export type DomainSslAction = "provision" | "renew" | "verify";
/** Why a domain's TLS is not certbot's job on the serving box. */
export type TlsIssuedElsewhere = "external_ingress" | "manual_cert" | "managed_edge";
/**
* Is TLS for this domain issued/terminated somewhere OTHER than certbot on the box
* that serves it? Returns the reason, or null when we really do issue it here.
*
* THE one place this question is answered. It used to be answered five times, in
* five vocabularies, by whoever happened to be calling: `skipSsl` in the routing
* planner, `external` in the route planner and in `verifyDomain`, a `skipCert`
* boolean threaded through the self-app edge provisioner, and an inline
* `domainType !== "free"` in the boot reconcile. Each new cert caller had to
* rediscover it, and the one that forgot burned a Let's Encrypt attempt on a
* hostname whose A record points at someone else's edge.
*
* externalIngress → TLS terminates at the operator's own proxy/CDN; ACME can't
* run here (origin :80 may be firewalled to CDN IPs).
* manualSsl → operator-uploaded cert (BYO / Cloudflare Origin CA). certbot
* never issued it, so `renew` would error and flip it to
* "error".
* free → a managed `*.opsh.io` host: Openship Cloud's edge terminates
* TLS and forwards to plain :80 here. The box usually has no
* public A record for the name at all, so HTTP-01 cannot pass.
*/
export function tlsIssuedElsewhere(domain: {
domainType?: string | null;
externalIngress?: boolean | null;
manualSsl?: boolean | null;
}): TlsIssuedElsewhere | null {
if (domain.externalIngress) return "external_ingress";
if (domain.manualSsl) return "manual_cert";
if (domain.domainType === "free") return "managed_edge";
return null;
}
/** Operator-facing one-liner for {@link tlsIssuedElsewhere}. */
export function describeTlsIssuedElsewhere(
where: TlsIssuedElsewhere,
hostname: string,
): string {
switch (where) {
case "external_ingress":
return `TLS for ${hostname} terminates at your own ingress — no certificate is issued here.`;
case "manual_cert":
return `${hostname} serves an uploaded certificate — certbot is not run for it.`;
default:
return `TLS for ${hostname} is handled by Openship Cloud — no local certificate needed.`;
}
}
interface DomainSslOptions {
action: DomainSslAction;
/** Restrict to a specific project (defense-in-depth; route layer
* already verified access). */
projectId?: string;
/** Skip the "must be verified first" guard. Only the ACME-as-verification
* path (self-hosted verifyDomain) sets this — there, issuing the cert IS the
* verification, so it necessarily runs before `verified` is set. */
allowUnverified?: boolean;
}
/** The lock scope for the box the API itself runs on. */
export const LOCAL_ACME_SCOPE = "local";
/** An SSL provider plus the ACME lock scope of the box it drives. */
interface ResolvedSslProvider {
ssl: SslProvider;
/** {@link acmeIssueLockKey} scope — the serving server's id, or "local". */
lockScope: string;
}
/** `domain.ownerType` for the `mail.` host of a mail server. */
export const MAIL_DOMAIN_OWNER = "mail";
/**
* Who owns a domain for SSL purposes.
*
* Almost every row is project-owned and resolves its provider from the project's
* active deployment. A MAIL-owned row has no project at all — the mail wizard's
* `mail.` host is a certificate on a box, not an app — so it resolves from
* the mail server directly.
*/
type SslOwner =
| { kind: "project"; project: Project }
| { kind: "mail"; serverId: string; organizationId: string };
/**
* The mail server behind a `mail.` hostname, or null when there isn't one.
*
* Derived from the hostname rather than an FK on the row: `mail.` is already
* the convention the wizard builds and step 13 symlinks certs by, and
* `mail_servers` is already the canonical base-domain → server record — so a
* fourth owner column on `domain` would only be a second copy of that mapping,
* free to drift.
*/
export async function resolveMailOwner(
hostname: string,
): Promise | null> {
const base = hostname.trim().toLowerCase().replace(/^mail\./, "");
if (base === hostname.trim().toLowerCase()) return null; // not a mail. host
const mail = await repos.mailServer.findByDomain(base).catch(() => undefined);
if (!mail) return null;
const server = await repos.server.get(mail.serverId).catch(() => undefined);
if (!server?.organizationId) return null;
return { kind: "mail", serverId: mail.serverId, organizationId: server.organizationId };
}
/** The "must be verified first" gate. A helper rather than a hoisted check
* because each owner branch runs it at its OWN position relative to the other
* per-branch checks — moving it earlier would change that precedence. */
function assertVerified(domainRecord: Domain, opts: DomainSslOptions): void {
if (!opts.allowUnverified && !domainRecord.verified) {
throw new ForbiddenError("Domain must be verified before SSL can be managed");
}
}
async function resolveAuthorizedDomain(hostname: string, opts: DomainSslOptions) {
const domainRecord = await repos.domain.findByHostname(hostname);
if (!domainRecord) throw new NotFoundError("Domain", hostname);
// A mail-owned row has no project, so the project lookup below would reject it
// outright — which is how the mail certificate ended up with no renewal path.
// It is NOT waved through: it still has to resolve to a real mail server and a
// real organization, and `opts.projectId` (the defence-in-depth project scope)
// can never match a project-less row, so a caller passing one is refused.
if (domainRecord.ownerType === MAIL_DOMAIN_OWNER) {
if (opts.projectId) throw new NotFoundError("Domain", hostname);
assertVerified(domainRecord, opts);
const owner = await resolveMailOwner(domainRecord.hostname);
if (!owner) throw new NotFoundError("Domain", hostname);
return { domainRecord, owner: owner as SslOwner };
}
const project = await repos.project.findById(domainRecord.projectId);
if (!project) throw new NotFoundError("Domain", hostname);
// Access verification is enforced at the route boundary
// (requirePermission middleware checks org membership before the
// controller runs). The optional projectId is a defense-in-depth scope.
if (opts.projectId && domainRecord.projectId !== opts.projectId) {
throw new NotFoundError("Domain", hostname);
}
assertVerified(domainRecord, opts);
return { domainRecord, owner: { kind: "project", project } as SslOwner };
}
/**
* Decide how to persist an SSL outcome WITHOUT clobbering a healthy domain on a
* transient failure. Returns the `updateSsl` patch, or `null` meaning "leave the
* current row untouched". The single source of truth for SSL-status transitions,
* shared by the on-demand path (manageDomainSsl) and the deploy-time tracker
* (createTrackedSslProvider). Rules:
* - verified cert read → "active" (+ expiry, issuer)
* - transient read failure → null (a redeploy that briefly can't read the cert
* must NOT downgrade a live "active" to "provisioning")
* - no cert BY DESIGN → null (TLS is issued elsewhere; "provisioning" would
* overwrite a correct "external" and make the UI show
* a cert lifecycle nobody is driving)
* - cert genuinely missing → "provisioning" (still being issued)
*/
export function resolveSslPatch(
currentStatus: string | null | undefined,
result: SslResult,
): { sslStatus: string; sslIssuer?: string; sslExpiresAt?: Date } | null {
if (result.reason === "not_local") return null;
if (result.verified && result.expiresAt) {
return {
sslStatus: "active",
sslIssuer: result.issuer,
sslExpiresAt: new Date(result.expiresAt),
};
}
if (result.reason === "read_error" && currentStatus === "active") {
return null;
}
return { sslStatus: "provisioning", sslIssuer: result.issuer };
}
/**
* The result for "we didn't issue anything, and that's correct".
*
* `verified: true` because from the caller's point of view TLS for this hostname is
* handled — the wizard should show success, not a failure it can't act on. No
* `expiresAt`, because we don't own that cert's lifecycle and must not claim to.
*/
function notLocalResult(hostname: string): SslResult {
return { domain: hostname, expiresAt: "", issuer: "", verified: true, reason: "not_local" };
}
async function persistSslResult(
domainId: string,
currentStatus: string | null | undefined,
result: SslResult,
) {
const patch = resolveSslPatch(currentStatus, result);
if (patch) await repos.domain.updateSsl(domainId, patch);
}
/**
* Put a mail server's `mail.` certificate on the renewer's list.
*
* The mail certificate is issued through `platform.ssl` like every other one, but a
* mail server has no project and so had no row in `domain` — which is the table
* `renewExpiringCerts` reads. The result was a certificate that worked perfectly
* and then expired ~90 days after install, silently, with nothing to renew it.
* (On a bare box the distro's certbot package shipped a `certbot.timer` that
* covered this by accident; a containerized edge has no such timer, and the host
* certbot it relied on is gone.) This row is what closes that.
*
* `verified: true` because a completed ACME issuance IS proof the hostname resolves
* to this box and :80 is reachable — the same reasoning self-hosted custom-domain
* verification relies on. Idempotent: re-running step 12 refreshes the expiry.
*
* Best-effort by contract. Bookkeeping must never fail an install step that already
* succeeded — the certificate is on disk either way, and a missed row is recoverable
* by re-running the step, whereas a failed step is not.
*/
export async function recordMailCertDomain(
hostname: string,
result: SslResult,
): Promise {
try {
const row = await repos.domain.findOrCreate({
hostname,
ownerType: MAIL_DOMAIN_OWNER,
domainType: "custom",
status: "active",
verified: true,
});
// Someone already owns this hostname as a project/webhook domain. Leave it
// alone: stamping mail's cert onto it would mis-attribute the row, and that
// owner's own SSL lifecycle is already driving it.
if (row.ownerType !== MAIL_DOMAIN_OWNER) {
console.warn(
`[MAIL] ${hostname} is already registered as a ${row.ownerType} domain — ` +
`not recording the mail certificate against it.`,
);
return;
}
await persistSslResult(row.id, row.sslStatus, result);
} catch (err) {
console.warn(
`[MAIL] could not record the certificate for ${hostname} — renewal may not be ` +
`scheduled until step 12 is re-run: ${safeErrorMessage(err)}`,
);
}
}
/**
* Bookkeeping recovery for an issuance attempt that THREW: ask the disk what
* happened instead of believing the exception.
*
* A throw is not proof that no certificate was issued. The ACME order can complete
* and a step AFTER it fail — the vhost rewrite, the reload, or the post-issue read
* of `live/` itself. When that happens the persist is skipped, so the row
* keeps `sslStatus: "provisioning"` and a NULL `sslExpiresAt` — and the renewal
* sweep keys off both (`renewOrgCerts` skips anything not active with an expiry).
* Renewal therefore never fires while a perfectly valid certificate sits on the
* edge counting down: TLS expires ~90 days later, silently, with the UI showing a
* domain that looks like it never got a cert at all.
*
* A read-only re-read settles it. A COMFORTABLY valid cert can only have come from
* the run that just threw — every caller re-checks before issuing and proceeds only
* when the cert is missing, near expiry, or forced — so it IS the outcome: persist
* it and return it. Anything less (nothing on disk, or the near-expiry cert we were
* trying to replace) means the failure was real, so rethrow untouched.
*/
async function recoverIssuedCert(
ssl: SslProvider,
domainRecord: { id: string; hostname: string; sslStatus?: string | null },
err: unknown,
): Promise {
const onDisk = await ssl.verifyCert(domainRecord.hostname).catch(() => null);
if (!onDisk || !certComfortablyValid(onDisk)) throw err;
console.warn(
`[SSL] ${domainRecord.hostname}: issuance reported an error but a valid certificate ` +
`(expires ${onDisk.expiresAt.slice(0, 10)}) is present on the edge — recording it so renewal ` +
`stays scheduled. The error was: ${safeErrorMessage(err)}`,
);
await persistSslResult(domainRecord.id, domainRecord.sslStatus, onDisk);
return onDisk;
}
/**
* `resolveDeploymentPlatform` for a caller that wants ONLY `.ssl`.
*
* The transport goes back before we return. Resolving a platform for a remote server
* eagerly binds a Docker-over-SSH bridge — one loopback listener — and this function
* is reached per issuance AND per renewal, so holding it is a leak on a schedule.
* Releasing it is safe rather than lucky: `createInfraProvider` (platform.ts:293) is
* handed the executor and the edge container and is never given the runtime, so
* `.ssl` cannot hold anything `disposePlatform` closes, and the pooled executor it
* does drive certbot through is explicitly kept (see `PLATFORM_DISPOSAL`).
*
* One function because the resolve/dispose/take-`.ssl` triple was written out at all
* three branches below, and the failure mode of forgetting the middle step there is
* invisible: certs still issue, the box just accumulates listeners until it runs out
* of descriptors.
*/
async function resolveSslOnly(meta: DeploymentMeta, organizationId: string): Promise {
const resolved = await resolveDeploymentPlatform(meta, { organizationId });
disposePlatform(resolved);
return resolved.platform.ssl;
}
/**
* Resolve the SSL provider that runs on the SAME host that serves the domain.
*
* certbot must execute on the box whose OpenResty serves the vhost and whose
* `/var/www/acme` webroot answers the ACME HTTP-01 challenge. For a self-hosted
* deploy targeting a remote SSH server, that box is the DEPLOY TARGET — not the
* orchestrator the API booted on. The global `platform()` is the orchestrator,
* so using it would run certbot on the wrong host (no vhost, no webroot → the
* challenge can never succeed). Resolve the project's active-deployment platform
* instead — the same per-server resolution the deploy itself used.
*
* Falls back to the global platform when the project has no active deployment
* yet (single-box installs resolve to the same local provider either way).
*/
async function resolveSslProvider(owner: SslOwner): Promise {
// A mail server IS the box — no deployment to resolve through, and no
// local-host fallback to want: the cert lives on that server's edge or nowhere.
// `lockScope` is the server id so mail issuance takes the same per-box ACME lock
// as the apps sharing that edge (they contend for one standalone challenge port).
if (owner.kind === "mail") {
const ssl = await resolveSslOnly({ serverId: owner.serverId } as DeploymentMeta, owner.organizationId);
return { ssl, lockScope: owner.serverId };
}
const project = owner.project;
const depId = project.activeDeploymentId;
if (depId) {
const dep = await repos.deployment.findById(depId);
if (dep) {
const meta = (dep.meta ?? {}) as DeploymentMeta;
try {
const ssl = await resolveSslOnly(meta, dep.organizationId);
return { ssl, lockScope: meta.serverId ?? LOCAL_ACME_SCOPE };
} catch (err) {
// Deploy target unresolvable — fall through to the host-anchored fallback.
// But say so with the real cause: for a REMOTE-target project (meta.serverId
// set) the fallbacks below act on a DIFFERENT box than the one whose edge
// serves the domain, so certbot/verify silently misfires there and the
// operator sees an inexplicable "no cert"/525 with nothing to trace it to.
// (For a single-box install the fallback resolves to the same local edge, so
// this is only noise there — hence a warn, not a throw.)
console.warn(
`[domain-ssl] could not resolve the deployment platform for ${owner.kind === "project" ? owner.project.id : "domain"}` +
`${meta.serverId ? ` (server ${meta.serverId})` : ""} — falling back to the host edge: ${safeErrorMessage(err)}`,
);
}
}
}
// Fallback anchor. `platform().ssl` is the API's OWN context — for a
// containerized API that's the API container (no edge/OpenResty there) or a
// DockerEdgeExecutor for a non-existent `openship-edge`, so certbot's HTTP-01
// never resolves and issuance/verify silently fails. Resolve the self-hosted
// instance's LOCAL host-server instead (createHostExecutor → the bare host's
// OpenResty + /etc/letsencrypt), which is where the edge actually lives.
//
// NOT in desktop mode: there the "local host" is the user's LAPTOP, not the
// remote server whose edge serves the domain — a project's edge always lives
// on its deployment server (resolved above via serverId → SSH). The primary
// path handles that; this local anchor is only for a server-host install.
if (!env.CLOUD_MODE && env.DEPLOY_MODE !== "desktop") {
const local = await repos.server.findLocal(project.organizationId).catch(() => null);
if (local) {
try {
const ssl = await resolveSslOnly(
{ serverId: local.id } as DeploymentMeta,
project.organizationId,
);
return { ssl, lockScope: local.id };
} catch (err) {
// Host-server unresolvable — last resort below.
console.warn(
`[domain-ssl] could not resolve the local host-server (${local.id}) edge — ` +
`falling back to the API's own context: ${safeErrorMessage(err)}`,
);
}
}
}
// Last resort: the API's OWN platform. This is correct only for a single-box
// bare install where the API IS the edge host; for a containerized API or a
// takeover'd/remote-target project it points at an edge that does not serve this
// domain, so certbot/verify runs against the wrong (or a non-existent) edge and
// the SSL action fails with no obvious cause. Left non-throwing so the single-box
// case still works, but logged so the misfire is traceable when it isn't that.
console.warn(
`[domain-ssl] resolving SSL via the API's own edge context (last resort) for ` +
`${owner.kind === "project" ? owner.project.id : "domain"} — if this instance's edge runs ` +
`elsewhere (containerized API, remote deploy target, or a post-takeover host), this action ` +
`may not reach the edge that serves the domain.`,
);
return { ssl: platform().ssl, lockScope: LOCAL_ACME_SCOPE };
}
async function executeSslAction(
ssl: SslProvider,
hostname: string,
action: DomainSslAction,
): Promise {
switch (action) {
case "renew":
return ssl.renewCert(hostname);
case "verify":
return ssl.verifyCert(hostname);
default:
return ssl.provisionCert(hostname);
}
}
// NOTE on the toolchain (certbot/OpenResty): we deliberately do NOT install it
// here. Installing certbot can take 30–90s, which blows the renew HTTP request's
// timeout. Toolchain install lives in the DEPLOY step chain instead. The first
// deploy prepares it for every local-certbot custom route, including a pending
// one whose certificate issuance is deferred until verification. This
// on-demand path can therefore issue/renew later without requiring a redeploy.
//
// EXACTLY ONE hostname per call. This used to take `includeWww` and issue the
// sibling's certificate in the same call — unguarded, so a www failure threw
// AFTER the apex had already succeeded and the caller reported the apex as
// broken. `www.` is its own domain row with its own verification, DNS
// record and certificate; callers that want both ask twice and report both.
export async function manageDomainSsl(
hostname: string,
opts: DomainSslOptions,
): Promise {
const { domainRecord, owner } = await resolveAuthorizedDomain(hostname, opts);
// THE gate, and it lives here rather than in each caller: this is the single
// entrypoint that can open an ACME order (manual Provision/Verify, the ssl:renew
// scheduler, the self-app edge provisioner, the boot reconcile), and it used to
// run certbot for whatever hostname it was handed. Every caller was expected to
// know not to ask — so the gate belongs where the row is already loaded.
//
// `verify` is exempt: it's a read-only inspection of whatever cert is on disk,
// which is exactly what you want for an uploaded one.
const elsewhere = opts.action === "verify" ? null : tlsIssuedElsewhere(domainRecord);
if (elsewhere) {
// Deliberately NOT persisted: there is nothing new to record, and writing a
// status here would overwrite a correct `external` with `provisioning`.
return notLocalResult(domainRecord.hostname);
}
const { ssl, lockScope } = await resolveSslProvider(owner);
// `verify` is a read-only cert inspection (no ACME) → no lock. `provision`/
// `renew` can open an ACME order, so serialize them per-hostname on the shared
// issue lock — this is what stops the ssl:renew scheduler (which calls us with
// action:"renew") from racing a manual Verify on the same domain — and then on
// the per-box ACME lock, which stops it racing a DIFFERENT hostname for the
// shared standalone challenge port.
if (opts.action === "verify") {
const result = await executeSslAction(ssl, domainRecord.hostname, opts.action);
await persistSslResult(domainRecord.id, domainRecord.sslStatus, result);
return result;
}
try {
const result = await createProvisionLock(sslIssueLockKey(domainRecord.hostname)).run(() =>
createProvisionLock(acmeIssueLockKey(lockScope)).run(() =>
executeSslAction(ssl, domainRecord.hostname, opts.action),
),
);
await persistSslResult(domainRecord.id, domainRecord.sslStatus, result);
return result;
} catch (err) {
// Issuance may have succeeded and a LATER step failed — never leave a valid
// cert unrecorded. See {@link recoverIssuedCert}. Rethrows when it really failed.
return recoverIssuedCert(ssl, domainRecord, err);
}
}
/**
* ACME-as-verification for a self-hosted custom domain: obtain the cert for a
* NOT-YET-VERIFIED domain. A successful issuance IS the proof that the hostname
* resolves to this box and :80 is reachable — and it holds behind a CDN:
* Cloudflare forwards the HTTP-01 challenge (proxied by the edge to certbot's
* standalone server) to origin. That's why self-hosted verify drives this
* instead of digging DNS, which a proxy in front would answer with the CDN's own
* IP. `onLog` streams certbot's output live to the verify modal. Returns the
* SslResult ({verified} on success); propagates the summarized certbot failure
* so the caller can surface an actionable "not yet" message. Persisted on exit.
*/
export async function provisionDomainCertForVerify(
hostname: string,
opts: { projectId?: string; onLog?: (line: string) => void; force?: boolean } = {},
): Promise {
const { domainRecord, owner } = await resolveAuthorizedDomain(hostname, {
action: "provision",
projectId: opts.projectId,
allowUnverified: true,
});
const { ssl, lockScope } = await resolveSslProvider(owner);
// Serialize issuance per-hostname, and re-check the cert INSIDE the lock.
// This closes the TOCTOU: two concurrent Verify hits (or Verify racing the
// renewal scheduler) both queue on the lock; the first issues, and the second
// — now inside the lock — sees the freshly-issued cert and reuses it instead
// of opening a second ACME order. The service-level fast-path in verifyDomain
// is only a cheap read-only optimization; THIS is the authoritative gate.
//
// The nested per-box lock covers the OTHER collision: a different hostname on
// the same box (the www sibling, the pending-SSL sweep) running certbot at the
// same time and losing the race for the shared standalone port.
try {
const result = await createProvisionLock(sslIssueLockKey(domainRecord.hostname)).run(async () => {
if (!opts.force) {
const existing = await ssl.verifyCert(domainRecord.hostname).catch(() => null);
if (existing && certComfortablyValid(existing)) {
opts.onLog?.(
`A valid certificate is already present for ${domainRecord.hostname}` +
(existing.expiresAt ? ` (expires ${existing.expiresAt.slice(0, 10)})` : "") +
" — reusing it. No new certificate requested.",
);
return existing;
}
}
// Decided to issue (missing / near-expiry / forced): pass `force` so the
// adapter runs certbot even when a stale cert file is present on disk —
// otherwise its file-exists short-circuit would return the old cert and a
// near-expiry renewal would silently no-op.
return createProvisionLock(acmeIssueLockKey(lockScope)).run(() =>
ssl.provisionCert(domainRecord.hostname, { onLog: opts.onLog, force: true }),
);
});
await persistSslResult(domainRecord.id, domainRecord.sslStatus, result);
return result;
} catch (err) {
// This is the path that lost TLS: a cert was issued, the post-issue read threw,
// and the row stayed `provisioning`/null-expiry — invisible to the renewer. See
// {@link recoverIssuedCert}. Rethrows when issuance really failed, so the caller
// still surfaces the summarized certbot cause.
const recovered = await recoverIssuedCert(ssl, domainRecord, err);
opts.onLog?.(
`A valid certificate for ${domainRecord.hostname} is present on the edge (expires ` +
`${recovered.expiresAt.slice(0, 10)}) — recorded it, renewal is scheduled.`,
);
return recovered;
}
}
/**
* Install an operator-supplied certificate on the host that serves the domain
* (resolved the same way as manageDomainSsl). Infra-only — the caller owns the
* domain-row update (manualSsl flag + ssl status). Enforces the same ownership
* + verified guards as the other SSL actions.
*/
export async function installDomainCert(
hostname: string,
cert: ManualCert,
opts: { projectId?: string; allowUnverified?: boolean } = {},
): Promise {
const { domainRecord, owner } = await resolveAuthorizedDomain(hostname, {
action: "provision",
projectId: opts.projectId,
allowUnverified: opts.allowUnverified,
});
const { ssl } = await resolveSslProvider(owner);
return ssl.installCert(domainRecord.hostname, cert);
}
/**
* Read-only check: is a usable cert for this hostname ALREADY present on the
* host that serves it (e.g. left by a prior deploy of the same box)? No
* issuance, no ACME rate-limit cost. Allows a not-yet-verified row — the
* migration/first-publish reuse path (domain.service → reuseServerCertForDomain)
* runs before the domain is verified, so it can't use manageDomainSsl (which
* gates on `verified`). Persists an "active" result via resolveSslPatch.
*/
export async function verifyExistingCert(
hostname: string,
opts: { projectId?: string } = {},
): Promise {
const { domainRecord, owner } = await resolveAuthorizedDomain(hostname, {
action: "verify",
projectId: opts.projectId,
allowUnverified: true,
});
const { ssl } = await resolveSslProvider(owner);
const result = await ssl.verifyCert(domainRecord.hostname);
await persistSslResult(domainRecord.id, domainRecord.sslStatus, result);
return result;
}