diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1d57d14b8..b6a45eb12 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -30,6 +30,7 @@ import { projectConnectionRoutes } from "./modules/projects/project-connection.r import { projectStorageRoutes } from "./modules/projects/project-storage.routes"; import { deploymentRoutes } from "./modules/deployments/deployment.routes"; import { domainRoutes } from "./modules/domains/domain.routes"; +import { dnsRoutes } from "./modules/dns/dns.routes"; import { issuesRoutes } from "./modules/issues/issues.routes"; import { jobRoutes } from "./modules/jobs/job.routes"; import { noticeRoutes } from "./modules/notices/notice.routes"; @@ -137,6 +138,7 @@ app.route("/api/projects/:id/connections", projectConnectionRoutes); app.route("/api/projects/:id/storage", projectStorageRoutes); app.route("/api/deployments", deploymentRoutes); app.route("/api/domains", domainRoutes); +app.route("/api/dns", dnsRoutes); app.route("/api/webhooks", webhookRoutes); app.route("/api/github", githubRoutes); app.route("/api/analytics", analyticsRoutes); diff --git a/apps/api/src/modules/dns/dns-credential.service.ts b/apps/api/src/modules/dns/dns-credential.service.ts new file mode 100644 index 000000000..7855efde9 --- /dev/null +++ b/apps/api/src/modules/dns/dns-credential.service.ts @@ -0,0 +1,325 @@ +/** + * DNS credential storage + the two operations the domains module needs from it: + * write a domain's records, and take them back down again. + * + * Read paths NEVER decrypt. The list endpoint returns the same constant mask the + * env-var endpoints use, so "show me my credentials" can't be turned into a + * partial token disclosure, and a rotated `BETTER_AUTH_SECRET` can't 500 the + * list (`decryptSecretField` throws on a key mismatch — it does not return null). + * A token that stopped working surfaces through `status`, set by the + * provisioning path, which is the only place we legitimately hold plaintext. + */ + +import { ConflictError, ENV_MASK, NotFoundError, safeErrorMessage } from "@repo/core"; +import { repos, type DnsCredential } from "@repo/db"; +import { encryptSecretField, decryptSecretField } from "../../lib/credential-encryption"; +import { resolveDnsProvider } from "./registry"; +import { + DnsApiError, + DnsProviderNotReadyError, + isOpenshipManaged, + type DnsProvider, + type DnsRecordInput, + type DnsZone, +} from "./types"; + +export interface SanitizedDnsCredential { + id: string; + organizationId: string; + provider: string; + name: string; + /** "active" | "invalid" */ + status: string; + /** Always the constant mask. There is no endpoint that reveals the token. */ + tokenMasked: string; + lastVerifiedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export function sanitizeCredential(cred: DnsCredential): SanitizedDnsCredential { + return { + id: cred.id, + organizationId: cred.organizationId, + provider: cred.provider, + name: cred.name, + status: cred.status, + tokenMasked: ENV_MASK, + lastVerifiedAt: cred.lastVerifiedAt, + createdAt: cred.createdAt, + updatedAt: cred.updatedAt, + }; +} + +export async function listCredentials(organizationId: string): Promise { + const rows = await repos.dnsCredential.listByOrg(organizationId); + return rows.map(sanitizeCredential); +} + +export async function getCredential( + organizationId: string, + id: string, +): Promise { + const row = await repos.dnsCredential.findById(organizationId, id); + return row ? sanitizeCredential(row) : null; +} + +export async function addCredential( + organizationId: string, + input: { provider: string; name: string; apiToken: string }, +): Promise { + const provider = resolveDnsProvider(input.provider); + const name = input.name.trim(); + + // Reject the duplicate here rather than letting the unique index raise: the + // operator gets "you already have one called that", not a constraint name. + const existing = await repos.dnsCredential.findByName(organizationId, input.provider, name); + if (existing) { + throw new ConflictError(`A ${provider.name} credential named "${name}" already exists.`); + } + + // Prove the token works before storing it. A credential that was never valid + // is worse than none: it silently owns the zone lookup for every domain add. + const pre = await provider.preflight({ apiToken: input.apiToken }); + if (!pre.ok) throw new DnsProviderNotReadyError(provider.name, pre.reason); + + const apiTokenEnc = encryptSecretField(input.apiToken); + if (!apiTokenEnc) throw new Error("Failed to encrypt the DNS API token."); + + const row = await repos.dnsCredential.create({ + organizationId, + provider: input.provider, + name, + apiTokenEnc, + status: "active", + lastVerifiedAt: new Date(), + }); + + return sanitizeCredential(row); +} + +export async function removeCredential(organizationId: string, id: string): Promise { + const existing = await repos.dnsCredential.findById(organizationId, id); + if (!existing) throw new NotFoundError("DNS credential", id); + await repos.dnsCredential.delete(organizationId, id); +} + +/* ────── Zone resolution ─────────────────────────────────────────── */ + +export interface MatchedDnsManager { + credentialId: string; + provider: DnsProvider; + zone: DnsZone; + credentials: { apiToken: string }; +} + +/** + * Why "no manager" happened, because the three cases need different words. + * + * none → asked, and no connected provider hosts this zone. Normal: + * the operator just hasn't delegated this domain to us. + * unauthorized → a stored token was rejected (or can't be decrypted). The + * operator has to act; provisioning marks it invalid. + * unavailable → the provider was rate-limited or down. Nothing is wrong with + * the configuration and nothing should be marked invalid. + */ +export type DnsManagerLookup = + | { status: "matched"; manager: MatchedDnsManager } + | { status: "none" } + | { status: "unavailable"; reason: string } + | { status: "unauthorized"; credentialId: string; reason: string }; + +export async function resolveDnsManager( + organizationId: string, + hostname: string, +): Promise { + const credentials = await repos.dnsCredential.findActiveByOrg(organizationId); + if (credentials.length === 0) return { status: "none" }; + + // Remembered, not returned immediately: a second credential may still own this + // zone, and one broken token shouldn't hide a working one. + let unauthorized: { credentialId: string; reason: string } | null = null; + let unavailable: string | null = null; + + for (const cred of credentials) { + let apiToken: string; + try { + const plain = decryptSecretField(cred.apiTokenEnc); + if (!plain) throw new Error("stored token is empty"); + apiToken = plain; + } catch (err) { + // Almost always a rotated BETTER_AUTH_SECRET. Same operator action as a + // revoked token: re-paste it. + unauthorized ??= { + credentialId: cred.id, + reason: `Stored token could not be decrypted (${safeErrorMessage(err)}). Re-connect the credential.`, + }; + continue; + } + + try { + const provider = resolveDnsProvider(cred.provider); + const zone = await provider.findZone({ apiToken }, hostname); + if (zone) { + return { + status: "matched", + manager: { credentialId: cred.id, provider, zone, credentials: { apiToken } }, + }; + } + } catch (err) { + if (err instanceof DnsApiError && err.isAuthFailure) { + unauthorized ??= { credentialId: cred.id, reason: safeErrorMessage(err) }; + continue; + } + if (err instanceof DnsApiError) { + unavailable ??= safeErrorMessage(err); + continue; + } + throw err; + } + } + + if (unauthorized) return { status: "unauthorized", ...unauthorized }; + if (unavailable) return { status: "unavailable", reason: unavailable }; + return { status: "none" }; +} + +/** Flag a credential the provider rejected so the UI can show it needs attention. */ +export async function markCredentialInvalid( + organizationId: string, + credentialId: string, +): Promise { + await repos.dnsCredential + .update(organizationId, credentialId, { status: "invalid" }) + .catch((err: unknown) => + console.warn("[dns] could not mark credential invalid:", safeErrorMessage(err)), + ); +} + +/* ────── Record provisioning ─────────────────────────────────────── */ + +export interface DnsRecordOutcome { + name: string; + type: string; + /** applied = created or updated; skipped = nothing to write; failed = see error. */ + outcome: "applied" | "skipped" | "failed"; + error?: string; +} + +export interface DnsProvisionResult { + /** True only when a provider managed the zone AND every record is in place. */ + provisioned: boolean; + /** Present when we did not (or could not) act — safe to show an operator. */ + reason?: string; + records: DnsRecordOutcome[]; +} + +/** + * Write `desired` into whichever connected provider hosts the zone. + * + * Every record is attempted: one rejected value (a CNAME whose target we + * couldn't resolve) must not suppress the ownership TXT that would have let the + * domain verify. Failures are returned, not thrown — a domain add does not fail + * because DNS automation did. + */ +export async function provisionRecords( + organizationId: string, + hostname: string, + desired: DnsRecordInput[], +): Promise { + // No reason attached: "there was nothing to write" is not something to report + // to an operator, and a reason here would make `autoDns` appear on the response + // for a domain nobody is automating (external ingress returns no records). + if (desired.length === 0) return { provisioned: false, records: [] }; + + const lookup = await resolveDnsManager(organizationId, hostname); + if (lookup.status === "unauthorized") { + await markCredentialInvalid(organizationId, lookup.credentialId); + return { provisioned: false, reason: lookup.reason, records: [] }; + } + if (lookup.status === "unavailable") { + return { provisioned: false, reason: lookup.reason, records: [] }; + } + if (lookup.status === "none") { + return { provisioned: false, records: [] }; + } + + const { provider, zone, credentials } = lookup.manager; + const records: DnsRecordOutcome[] = []; + + for (const input of desired) { + // `buildRecords` uses "" for a value it could not determine ("unknown — show + // a placeholder"). Writing that is a guaranteed provider rejection. + if (!input.content) { + records.push({ name: input.name, type: input.type, outcome: "skipped" }); + continue; + } + + try { + await provider.upsertRecord(credentials, zone.id, input); + records.push({ name: input.name, type: input.type, outcome: "applied" }); + } catch (err) { + if (err instanceof DnsApiError && err.isAuthFailure) { + await markCredentialInvalid(organizationId, lookup.manager.credentialId); + } + records.push({ + name: input.name, + type: input.type, + outcome: "failed", + error: safeErrorMessage(err), + }); + } + } + + const applied = records.filter((r) => r.outcome === "applied").length; + const failed = records.filter((r) => r.outcome === "failed"); + + return { + provisioned: failed.length === 0 && applied > 0, + ...(failed.length > 0 + ? { reason: `${failed.length} of ${records.length} records could not be written.` } + : {}), + records, + }; +} + +/** + * Delete the records Openship created for `names`, and only those. + * + * Ownership is read off the provider-side comment marker. Name matching alone is + * not enough: for an apex domain these names ARE the zone apex, where the + * operator's MX, SPF TXT and CAA also live. + */ +export async function releaseRecords( + organizationId: string, + hostname: string, + names: string[], +): Promise<{ deleted: number; reason?: string }> { + const lookup = await resolveDnsManager(organizationId, hostname); + if (lookup.status !== "matched") { + return { + deleted: 0, + ...(lookup.status === "none" ? {} : { reason: lookup.reason }), + }; + } + + const { provider, zone, credentials } = lookup.manager; + let deleted = 0; + + for (const name of new Set(names)) { + try { + const found = await provider.listRecords(credentials, zone.id, { name }); + for (const record of found.filter(isOpenshipManaged)) { + await provider.deleteRecord(credentials, zone.id, record.id); + deleted++; + } + } catch (err) { + // Best effort by design: a domain must still be removable from Openship + // when the provider is unreachable. The leftover record is visible in the + // operator's zone; a blocked delete is not. + return { deleted, reason: safeErrorMessage(err) }; + } + } + + return { deleted }; +} diff --git a/apps/api/src/modules/dns/dns.controller.ts b/apps/api/src/modules/dns/dns.controller.ts new file mode 100644 index 000000000..7447cd84e --- /dev/null +++ b/apps/api/src/modules/dns/dns.controller.ts @@ -0,0 +1,120 @@ +/** + * DNS provider credential endpoints. + * + * No try/catch: every failure the service raises is an `AppError` subclass, so + * `handleApiError` maps status + code centrally. A local `catch → 400` here would + * flatten "Cloudflare is rate-limiting us" (502) and "you already have one of + * those" (409) into the same unactionable response. + */ + +import type { Context } from "hono"; +import { NotFoundError } from "@repo/core"; +import { param } from "../../lib/controller-helpers"; +import { getRequestContext } from "../../lib/request-context"; +import { audit, auditContextFrom } from "../../lib/audit"; +import { describeDnsProviders } from "./registry"; +import * as dnsService from "./dns-credential.service"; +import type { TAddDnsCredentialBody, TVerifyZoneBody } from "./dns.schema"; + +/** GET /dns/providers — supported providers and the token scopes they need. */ +export async function listProviders(c: Context) { + return c.json({ data: describeDnsProviders() }); +} + +/** GET /dns/credentials — connected credentials for the active organization. */ +export async function listCredentials(c: Context) { + const ctx = getRequestContext(c); + return c.json({ data: await dnsService.listCredentials(ctx.organizationId) }); +} + +/** GET /dns/credentials/:id — one connected credential. */ +export async function getCredential(c: Context) { + const ctx = getRequestContext(c); + const id = param(c, "id"); + const cred = await dnsService.getCredential(ctx.organizationId, id); + if (!cred) throw new NotFoundError("DNS credential", id); + return c.json({ data: cred }); +} + +/** POST /dns/credentials — connect a provider credential. */ +export async function addCredential(c: Context) { + const ctx = getRequestContext(c); + const body = await c.req.json(); + + const cred = await dnsService.addCredential(ctx.organizationId, body); + + // Label and provider only — the token is the thing being protected here, and + // an audit row is exactly the sort of long-lived record it must never reach. + audit.recordAsync(auditContextFrom(c, ctx.organizationId, ctx.userId), { + eventType: "dns_credential.connected", + resourceType: "dns_credential", + resourceId: cred.id, + after: { provider: cred.provider, name: cred.name }, + }); + + return c.json({ data: cred }, 201); +} + +/** DELETE /dns/credentials/:id — disconnect a credential. */ +export async function removeCredential(c: Context) { + const ctx = getRequestContext(c); + const id = param(c, "id"); + + // Read first so the audit row can name what was removed. + const existing = await dnsService.getCredential(ctx.organizationId, id); + if (!existing) throw new NotFoundError("DNS credential", id); + + await dnsService.removeCredential(ctx.organizationId, id); + + audit.recordAsync(auditContextFrom(c, ctx.organizationId, ctx.userId), { + eventType: "dns_credential.disconnected", + resourceType: "dns_credential", + resourceId: id, + before: { provider: existing.provider, name: existing.name }, + }); + + return c.json({ success: true }); +} + +/** + * POST /dns/verify-zone — can a connected provider manage this hostname? + * + * Read-only on purpose. The credential-invalidating write that used to sit on + * this path belongs to provisioning: a check an operator runs to answer "will + * this work" must not be able to disable their credential, and a route that + * mutates can't honestly be declared `readOnly`. + */ +export async function verifyZone(c: Context) { + const ctx = getRequestContext(c); + const { hostname } = await c.req.json(); + + const lookup = await dnsService.resolveDnsManager(ctx.organizationId, hostname); + + switch (lookup.status) { + case "matched": + return c.json({ + matched: true, + status: "matched", + provider: lookup.manager.provider.name, + credentialId: lookup.manager.credentialId, + zoneName: lookup.manager.zone.name, + zoneId: lookup.manager.zone.id, + }); + case "unauthorized": + return c.json({ + matched: false, + status: "unauthorized", + credentialId: lookup.credentialId, + message: lookup.reason, + }); + case "unavailable": + // Deliberately not "no provider manages this" — we could not ask. + return c.json({ matched: false, status: "unavailable", message: lookup.reason }); + default: + return c.json({ + matched: false, + status: "none", + message: "No connected DNS provider manages this domain's zone.", + }); + } +} diff --git a/apps/api/src/modules/dns/dns.routes.ts b/apps/api/src/modules/dns/dns.routes.ts new file mode 100644 index 000000000..4f18962c2 --- /dev/null +++ b/apps/api/src/modules/dns/dns.routes.ts @@ -0,0 +1,74 @@ +/** + * DNS provider routes. + * + * Gated on `settings:*` — a DNS credential is one org-wide infrastructure record, + * the same shape as the edge and email settings next to it, and it is read and + * written from the Settings page. `domain:*` is the wrong root: those tags are + * per-domain-resource (`domain:read` on `/domains/:id`), and one token is not + * scoped to one domain. + * + * The tag is a TOKEN scope, not an org role — a session user carries whatever + * their membership allows, so the two writes also take `requireRole("admin")`, + * matching the sidebar's `requiresRole: "admin"` on this tab. Without it any + * member could delete the org-wide credential and silently revert every later + * domain add to manual records. Attached per-route rather than via `r.use("*")`, + * which would run before auth and read an empty context (see + * permissions.routes.ts for the same note). + */ + +import { Hono } from "hono"; +import { secureRouter } from "../../lib/secure-router"; +import { requireRole } from "../../middleware"; +import * as ctrl from "./dns.controller"; +import { AddDnsCredentialBody, VerifyZoneBody } from "./dns.schema"; + +const r = secureRouter(new Hono(), { + module: "dns", + basePath: "/api/dns", +}); + +r.get( + "/providers", + { tag: "settings:read", mcp: { description: "List supported DNS providers and the token scopes they need." } }, + ctrl.listProviders, +); +r.get( + "/credentials", + { tag: "settings:read", mcp: { description: "List connected DNS provider credentials for the org." } }, + ctrl.listCredentials, +); +r.get( + "/credentials/:id", + { tag: "settings:read", mcp: { description: "Get one connected DNS provider credential." } }, + ctrl.getCredential, +); +r.post( + "/credentials", + { + tag: "settings:admin", + body: AddDnsCredentialBody, + mcp: { description: "Connect a DNS provider credential (Cloudflare API token)." }, + }, + requireRole("admin"), + ctrl.addCredential, +); +r.delete( + "/credentials/:id", + { tag: "settings:admin", mcp: { description: "Disconnect a DNS provider credential." } }, + requireRole("admin"), + ctrl.removeCredential, +); +r.post( + // POST to carry a hostname body, but genuinely side-effect free — see the + // handler's note on why the credential-invalidating write moved out. + "/verify-zone", + { + tag: "settings:read", + readOnly: true, + body: VerifyZoneBody, + mcp: { description: "Check whether a connected DNS provider manages a hostname's zone." }, + }, + ctrl.verifyZone, +); + +export const dnsRoutes = r.hono; diff --git a/apps/api/src/modules/dns/dns.schema.ts b/apps/api/src/modules/dns/dns.schema.ts new file mode 100644 index 000000000..55f58e993 --- /dev/null +++ b/apps/api/src/modules/dns/dns.schema.ts @@ -0,0 +1,36 @@ +/** + * DNS validation schemas — TypeBox for Hono route validation. + * + * `secureRouter` auto-wires `tbValidator("json", body)` from the route's `body` + * field, so these must be TypeBox schemas: a Zod object reaches `Value.Check` + * without a `[Kind]` symbol and throws on every request, valid or not. + */ + +import { Type, type Static } from "@sinclair/typebox"; + +/** Same shape the domains module accepts, so "a hostname" means one thing. */ +const Hostname = Type.String({ + minLength: 1, + maxLength: 253, + pattern: "^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$", +}); + +export const AddDnsCredentialBody = Type.Object({ + /** Literal union, not a free string: the registry is the source of truth and + * an unknown value should be rejected at the edge, not deep in a resolver. */ + provider: Type.Union([Type.Literal("cloudflare")]), + name: Type.String({ minLength: 1, maxLength: 100 }), + /** Bounded because it is stored encrypted — an unbounded body would be a + * cheap way to write megabytes of ciphertext per row. */ + apiToken: Type.String({ minLength: 1, maxLength: 500 }), +}); + +export type TAddDnsCredentialBody = Static; + +export const VerifyZoneBody = Type.Object({ + /** Bounded on purpose: zone discovery walks this name's suffixes and spends an + * outbound provider call per candidate. */ + hostname: Hostname, +}); + +export type TVerifyZoneBody = Static; diff --git a/apps/api/src/modules/dns/index.ts b/apps/api/src/modules/dns/index.ts new file mode 100644 index 000000000..619a350db --- /dev/null +++ b/apps/api/src/modules/dns/index.ts @@ -0,0 +1,14 @@ +/** + * DNS module surface for other modules (the domains module, mainly). + * + * Deliberately does NOT re-export `dns.routes`. `secureRouter` registers routes + * at module-eval time and drags the auth/permission/rate-limit graph in with it, + * so a service that imports this barrel would mount the HTTP route table as a + * side effect of being imported — which is how a unit test that partially mocks + * `@repo/db` starts failing in a file that never mentions DNS. `app.ts` imports + * `./dns.routes` directly, which is the only place that should. + */ + +export * from "./types"; +export { resolveDnsProvider, listDnsProviders, describeDnsProviders } from "./registry"; +export * from "./dns-credential.service"; diff --git a/apps/api/src/modules/dns/providers/cloudflare.provider.ts b/apps/api/src/modules/dns/providers/cloudflare.provider.ts new file mode 100644 index 000000000..9b85086cd --- /dev/null +++ b/apps/api/src/modules/dns/providers/cloudflare.provider.ts @@ -0,0 +1,365 @@ +/** + * Cloudflare DNS provider. + * + * Talks to the v4 REST API with a scoped API token (Zone:Read + DNS:Edit). No + * account ID needed: zone discovery is a name lookup, and every record call is + * zone-scoped. + */ + +import type { + DnsProvider, + DnsProviderCredentials, + DnsRecord, + DnsRecordInput, + DnsRecordType, + DnsZone, +} from "../types"; +import { + DnsApiError, + DnsRecordConflictError, + OPENSHIP_RECORD_COMMENT, + isOpenshipManaged, +} from "../types"; + +const CF_API_BASE = "https://api.cloudflare.com/client/v4"; + +/** Cloudflare's max page size for DNS record lists. */ +const PER_PAGE = 100; + +/** + * Hard stop on the pagination loop. A zone with more than 5 000 records at one + * name does not exist; this exists so a provider that keeps reporting + * `total_pages` can't spin us forever. + */ +const MAX_PAGES = 50; + +/** + * How many domain suffixes we'll probe when looking for the zone. + * + * The walk is most-specific-first so a Cloudflare subdomain zone wins over its + * parent. The cap bounds outbound calls per lookup — real hostnames are 2-5 + * labels, so it never truncates a genuine candidate, and it stops a long + * caller-supplied name from turning one request into hundreds. + */ +const MAX_ZONE_CANDIDATES = 8; + +interface CfResponse { + success: boolean; + errors: Array<{ code: number; message: string }>; + messages: string[]; + result: T; + result_info?: { page: number; per_page: number; count: number; total_count: number; total_pages: number }; +} + +interface CfTokenVerifyResult { + id: string; + status: string; +} + +interface CfZoneItem { + id: string; + name: string; + status: string; +} + +interface CfDnsRecordItem { + id: string; + zone_id: string; + type: string; + name: string; + content: string; + ttl: number; + proxied: boolean; + comment?: string | null; +} + +/** api.cloudflare.com should never hang. This runs inline on `POST /domains`: + * zone discovery walks up to MAX_ZONE_CANDIDATES suffixes per credential and + * then lists+writes once per record, so an unbounded call holds the operator's + * request open long past the point their client gave up on it. */ +const CF_FETCH_TIMEOUT_MS = 15_000; + +/** Call the Cloudflare REST API, returning the whole envelope. */ +async function cfRequest( + apiToken: string, + path: string, + options: RequestInit = {}, +): Promise> { + const headers: Record = { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json", + ...(options.headers as Record | undefined), + }; + + // The deadline covers reading the body, not just the handshake: a response that + // never finishes streaming stalls the caller exactly like one that never arrives. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CF_FETCH_TIMEOUT_MS); + + let res: Response; + let text: string; + try { + res = await fetch(`${CF_API_BASE}${path}`, { + ...options, + headers, + signal: controller.signal, + }); + text = await res.text(); + } catch (err) { + // DNS failure, TLS error, connection reset, or our own timeout — + // indistinguishable from a 5xx to the caller, and just as transient. 503 keeps + // `isTransient` true so the caller reports "couldn't check" rather than "not + // managed here". + throw new DnsApiError("cloudflare", 503, err instanceof Error ? err.message : String(err)); + } finally { + clearTimeout(timer); + } + + let body: CfResponse; + try { + body = JSON.parse(text) as CfResponse; + } catch { + throw new DnsApiError( + "cloudflare", + res.status, + `non-JSON response: ${text.slice(0, 150)}`, + ); + } + + if (!res.ok || !body.success) { + const detail = body.errors?.map((e) => e.message).filter(Boolean).join(", "); + throw new DnsApiError("cloudflare", res.status, detail || `HTTP ${res.status}`); + } + + return body; +} + +/** Call the API and return just the result payload. */ +async function cfFetch( + apiToken: string, + path: string, + options: RequestInit = {}, +): Promise { + return (await cfRequest(apiToken, path, options)).result; +} + +/** Trailing dots and case are not part of a name's identity. */ +function normalizeName(name: string): string { + return name.trim().toLowerCase().replace(/\.+$/, ""); +} + +function toRecord(r: CfDnsRecordItem): DnsRecord { + return { + id: r.id, + zoneId: r.zone_id, + type: r.type as DnsRecordType, + name: r.name, + content: r.content, + ttl: r.ttl, + proxied: r.proxied ?? false, + ...(r.comment ? { comment: r.comment } : {}), + }; +} + +/** + * Suffixes to probe for `hostname`, most specific first, bounded. + * + * "a.b.example.com" → ["a.b.example.com", "b.example.com", "example.com"]. + * Single-label inputs yield nothing: there is no zone to find for "localhost". + */ +function zoneCandidates(hostname: string): string[] { + const parts = normalizeName(hostname).split(".").filter(Boolean); + const out: string[] = []; + for (let i = 0; i <= parts.length - 2; i++) { + out.push(parts.slice(i).join(".")); + } + // Keep the LAST N — the apex end. A deep name's zone is near the apex, so + // truncating from the specific end is what preserves the real candidate. + return out.slice(-MAX_ZONE_CANDIDATES); +} + +export const cloudflareDnsProvider: DnsProvider = { + name: "cloudflare", + + descriptor: { + name: "cloudflare", + displayName: "Cloudflare", + description: + "Openship writes your domain's DNS records for you, so a new custom domain verifies and gets its certificate without you leaving the page.", + requiredScopes: ["Zone:Zone:Read", "Zone:DNS:Edit"], + tokenUrl: "https://dash.cloudflare.com/profile/api-tokens", + }, + + async preflight(credentials: DnsProviderCredentials) { + if (!credentials.apiToken.trim()) { + return { ok: false, reason: "Cloudflare API token is missing." }; + } + + try { + const result = await cfFetch( + credentials.apiToken, + "/user/tokens/verify", + { method: "GET" }, + ); + if (result.status === "active") { + return { ok: true, detail: "Cloudflare API token is active." }; + } + return { ok: false, reason: `Cloudflare reports this token as "${result.status}".` }; + } catch (err) { + const reason = + err instanceof DnsApiError && err.isAuthFailure + ? "Cloudflare rejected this token. Check it was copied in full and has Zone:Read + DNS:Edit." + : err instanceof Error + ? err.message + : String(err); + return { ok: false, reason }; + } + }, + + async findZone(credentials: DnsProviderCredentials, hostname: string): Promise { + for (const candidate of zoneCandidates(hostname)) { + // Not caught: an auth failure or a rate limit must reach the caller. A + // swallowed 429 here becomes "no provider manages this domain", which + // reads as a configuration mistake the operator did not make. + const zones = await cfFetch( + credentials.apiToken, + `/zones?name=${encodeURIComponent(candidate)}&status=active`, + { method: "GET" }, + ); + + const zone = zones?.[0]; + if (zone) { + return { id: zone.id, name: zone.name, status: zone.status }; + } + } + + return null; + }, + + async listRecords( + credentials: DnsProviderCredentials, + zoneId: string, + filter?: { name?: string; type?: DnsRecordType }, + ): Promise { + const records: DnsRecord[] = []; + + for (let page = 1; page <= MAX_PAGES; page++) { + const params = new URLSearchParams({ per_page: String(PER_PAGE), page: String(page) }); + if (filter?.name) params.set("name", normalizeName(filter.name)); + if (filter?.type) params.set("type", filter.type); + + const body = await cfRequest( + credentials.apiToken, + `/zones/${encodeURIComponent(zoneId)}/dns_records?${params.toString()}`, + { method: "GET" }, + ); + + records.push(...(body.result ?? []).map(toRecord)); + + // Absent result_info means the provider returned everything at once. + const totalPages = body.result_info?.total_pages ?? 1; + if (page >= totalPages) break; + } + + return records; + }, + + async upsertRecord( + credentials: DnsProviderCredentials, + zoneId: string, + input: DnsRecordInput, + ): Promise { + const name = normalizeName(input.name); + const desiredTtl = input.ttl ?? 1; // 1 = "automatic" in Cloudflare + const ownMarker = input.comment ?? OPENSHIP_RECORD_COMMENT; + + const existing = await this.listRecords(credentials, zoneId, { name, type: input.type }); + + // More than one record at this name+type is a SET the operator maintains + // (round-robin origins, multiple MX). Rewriting one member leaves the + // hostname answering with a mix of their origin and ours — worse than not + // acting. Ours-if-present, otherwise refuse and say so. + const target = + existing.length > 1 ? existing.find(isOpenshipManaged) : existing[0]; + + if (existing.length > 1 && !target) { + throw new DnsRecordConflictError(name, input.type, existing.length); + } + + if (target) { + // Preserve what we don't manage. Cloudflare proxying in particular is the + // operator's choice: defaulting it to false silently takes a zone off the + // orange cloud the first time a domain is re-added. + const proxied = input.proxied ?? target.proxied; + + // Repointing a record the operator wrote is the point of connecting a domain; + // CLAIMING it is not. `releaseRecords` deletes on our marker, so stamping it + // here would make "remove this domain" destroy a record we never created and + // whose original value we don't keep. Adopt the value, leave the ownership. + const comment = isOpenshipManaged(target) ? ownMarker : target.comment; + + const unchanged = + target.content === input.content && + target.proxied === proxied && + target.ttl === desiredTtl && + target.comment === comment; + if (unchanged) return target; + + const updated = await cfFetch( + credentials.apiToken, + `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(target.id)}`, + { + method: "PUT", + body: JSON.stringify({ + type: input.type, + name, + content: input.content, + ttl: desiredTtl, + proxied, + comment, + }), + }, + ); + return toRecord(updated); + } + + // Nothing was there, so this record IS ours — mark it, and only this branch + // does, which is what makes the marker mean "Openship created this". + const created = await cfFetch( + credentials.apiToken, + `/zones/${encodeURIComponent(zoneId)}/dns_records`, + { + method: "POST", + body: JSON.stringify({ + type: input.type, + name, + content: input.content, + ttl: desiredTtl, + proxied: input.proxied ?? false, + comment: ownMarker, + }), + }, + ); + return toRecord(created); + }, + + async deleteRecord( + credentials: DnsProviderCredentials, + zoneId: string, + recordId: string, + ): Promise { + try { + await cfFetch<{ id: string }>( + credentials.apiToken, + `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, + { method: "DELETE" }, + ); + } catch (err) { + // Already gone is the outcome we wanted. Idempotent so a retried cleanup + // (or two overlapping ones) doesn't fail the caller. + if (err instanceof DnsApiError && err.providerStatus === 404) return; + throw err; + } + }, +}; diff --git a/apps/api/src/modules/dns/registry.ts b/apps/api/src/modules/dns/registry.ts new file mode 100644 index 000000000..cc7da9eb9 --- /dev/null +++ b/apps/api/src/modules/dns/registry.ts @@ -0,0 +1,29 @@ +/** + * DNS provider registry. + * + * One map is the single source of truth. Descriptors are read OFF the providers + * rather than kept in a parallel literal — a hand-maintained second list is how + * a registered provider ends up invisible in the dashboard picker. + */ + +import type { DnsProvider, DnsProviderDescriptor, DnsProviderName } from "./types"; +import { UnknownDnsProviderError } from "./types"; +import { cloudflareDnsProvider } from "./providers/cloudflare.provider"; + +const PROVIDERS: Record = { + cloudflare: cloudflareDnsProvider, +}; + +export function resolveDnsProvider(name: string): DnsProvider { + const hit = PROVIDERS[name as DnsProviderName]; + if (!hit) throw new UnknownDnsProviderError(name); + return hit; +} + +export function listDnsProviders(): DnsProviderName[] { + return Object.keys(PROVIDERS) as DnsProviderName[]; +} + +export function describeDnsProviders(): DnsProviderDescriptor[] { + return listDnsProviders().map((name) => PROVIDERS[name].descriptor); +} diff --git a/apps/api/src/modules/dns/types.ts b/apps/api/src/modules/dns/types.ts new file mode 100644 index 000000000..bbc0a4a2b --- /dev/null +++ b/apps/api/src/modules/dns/types.ts @@ -0,0 +1,225 @@ +/** + * DNS provider abstraction — automated record management for custom domains. + * + * Openship already computes the exact records a domain needs (`buildRecords` in + * the domains module). A connected provider turns that list from instructions + * into an action: we write the A/CNAME and `_openship-challenge` TXT ourselves, + * and the domain verifies without the operator leaving the page. + * + * The authority involved is real — a Zone:Read + DNS:Edit token can rewrite + * every record in every zone it can see — so two rules hold throughout: + * + * 1. We only ever DELETE records we created. Ownership is proven by the + * provider-side comment marker (`OPENSHIP_RECORD_COMMENT`), never by name + * matching: a custom domain's apex IS the zone apex, and "delete every + * record at the apex" takes the operator's MX and SPF with it. Repointing an + * existing record is allowed — that is what connecting a domain means — but + * it does not transfer ownership: an ADOPTED record keeps whatever comment it + * already had, so it is not ours to delete and survives domain removal. + * 2. "No zone matched" and "could not ask" are different answers. Collapsing a + * 429 or a 5xx into "not managed here" tells the operator their token is + * wrong when it is fine, and silently skips provisioning. + */ + +import { AppError } from "@repo/core"; + +export type DnsProviderName = "cloudflare"; + +export type DnsRecordType = "A" | "AAAA" | "CNAME" | "TXT" | "MX" | "NS" | "SRV"; + +/** + * Written into the provider's record comment so cleanup can tell "Openship put + * this here" from "the operator has run their mail on this zone for six years". + * Changing this string orphans every record written under the old one. + */ +export const OPENSHIP_RECORD_COMMENT = "Managed by Openship"; + +export interface DnsRecordInput { + /** Record type: A, CNAME, TXT, etc. */ + type: DnsRecordType; + /** Fully-qualified name (e.g. "app.example.com", "_openship-challenge.example.com"). */ + name: string; + /** Value / target (IPv4 address, target hostname, TXT string). */ + content: string; + /** TTL in seconds; omit for provider-automatic. */ + ttl?: number; + /** Provider-specific proxying (Cloudflare's orange cloud). */ + proxied?: boolean; + /** Ownership marker. Defaults to `OPENSHIP_RECORD_COMMENT`. */ + comment?: string; +} + +export interface DnsRecord { + /** Provider-assigned record ID. */ + id: string; + /** Zone ID the record belongs to. */ + zoneId: string; + type: DnsRecordType; + name: string; + content: string; + ttl: number; + proxied: boolean; + /** Provider-side comment, when the provider supports one. */ + comment?: string; +} + +export interface DnsZone { + /** Provider-assigned zone ID. */ + id: string; + /** Apex zone name (e.g. "example.com"). */ + name: string; + /** Zone status: "active", "pending", etc. */ + status: string; +} + +export interface DnsProviderCredentials { + /** Decrypted API token. */ + apiToken: string; + /** Optional provider account ID. */ + accountId?: string; +} + +/** Operator-facing description of a provider, surfaced by GET /dns/providers. */ +export interface DnsProviderDescriptor { + name: DnsProviderName; + displayName: string; + /** One line explaining what connecting this provider buys you. */ + description: string; + /** The exact token scopes we need, so the operator can mint a minimal token. */ + requiredScopes: string[]; + /** Where to create the token — linked from the connect form. */ + tokenUrl?: string; +} + +export interface DnsProvider { + readonly name: DnsProviderName; + + /** Rendered by the dashboard's provider picker. Lives on the provider so a new + * provider cannot be added without also describing itself. */ + readonly descriptor: DnsProviderDescriptor; + + /** Validate credentials against the provider API before we store them. */ + preflight( + credentials: DnsProviderCredentials, + ): Promise<{ ok: true; detail?: string } | { ok: false; reason: string }>; + + /** + * Find the zone that manages `hostname` by walking up its labels. + * + * Returns null ONLY for "this provider definitively does not host it". A + * transport failure, rate limit or 5xx must THROW (`DnsApiError`) so the + * caller can say "couldn't check" instead of "not yours". + */ + findZone( + credentials: DnsProviderCredentials, + hostname: string, + ): Promise; + + /** List records in a zone, optionally filtered by name or type. Paginated. */ + listRecords( + credentials: DnsProviderCredentials, + zoneId: string, + filter?: { name?: string; type?: DnsRecordType }, + ): Promise; + + /** + * Create or update a record idempotently. When a record of the same name and + * type exists it is updated in place; provider-specific settings we do not + * manage (e.g. Cloudflare proxying) are preserved rather than reset. + */ + upsertRecord( + credentials: DnsProviderCredentials, + zoneId: string, + input: DnsRecordInput, + ): Promise; + + /** Delete a record by ID. Idempotent — an already-gone record is a success. */ + deleteRecord( + credentials: DnsProviderCredentials, + zoneId: string, + recordId: string, + ): Promise; +} + +/** True when this record carries our ownership marker, i.e. we wrote it. */ +export function isOpenshipManaged(record: DnsRecord): boolean { + return record.comment?.trim() === OPENSHIP_RECORD_COMMENT; +} + +/* ────── Typed errors ───────────────────────────────────────────── */ +/* All extend AppError so `handleApiError` maps status + code centrally and no + * handler needs its own try/catch. */ + +export class UnknownDnsProviderError extends AppError { + constructor(name: string) { + super(`Unknown DNS provider: ${name}`, 400, "DNS_UNKNOWN_PROVIDER"); + this.name = "UnknownDnsProviderError"; + } +} + +export class DnsProviderNotReadyError extends AppError { + constructor( + public readonly provider: DnsProviderName, + reason: string, + ) { + super(`DNS provider "${provider}" is not ready: ${reason}`, 400, "DNS_PROVIDER_NOT_READY"); + this.name = "DnsProviderNotReadyError"; + } +} + +/** + * Several records already answer for this name+type and none of them are ours. + * + * 409 rather than a silent overwrite: a round-robin A set or a multi-host MX is + * deliberate configuration, and rewriting one member of it leaves the hostname + * answering with a mix of the operator's origin and ours. + */ +export class DnsRecordConflictError extends AppError { + constructor( + public readonly recordName: string, + public readonly recordType: DnsRecordType, + public readonly existingCount: number, + ) { + super( + `${existingCount} existing ${recordType} records already answer for "${recordName}". ` + + `Openship won't rewrite records it didn't create — remove or consolidate them first.`, + 409, + "DNS_RECORD_CONFLICT", + ); + this.name = "DnsRecordConflictError"; + } +} + +/** + * The provider's API said no. 502, because the failure is upstream of us rather + * than the caller's fault. + * + * `providerStatus` is the PROVIDER's HTTP status and is deliberately not named + * `statusCode` — that name belongs to AppError and drives OUR response status. + * Conflating the two turns "Cloudflare returned 404" into "this endpoint does + * not exist". + */ +export class DnsApiError extends AppError { + constructor( + public readonly provider: DnsProviderName, + public readonly providerStatus: number, + message: string, + ) { + super( + `DNS provider "${provider}" API error (${providerStatus}): ${message}`, + 502, + "DNS_API_ERROR", + ); + this.name = "DnsApiError"; + } + + /** The stored token was rejected — revoked, expired or under-scoped. */ + get isAuthFailure(): boolean { + return this.providerStatus === 401 || this.providerStatus === 403; + } + + /** Transient: report as "couldn't check", never as "not managed here". */ + get isTransient(): boolean { + return this.providerStatus === 429 || this.providerStatus >= 500; + } +} diff --git a/apps/api/src/modules/domains/domain.controller.ts b/apps/api/src/modules/domains/domain.controller.ts index 27c85bea1..997084d1f 100644 --- a/apps/api/src/modules/domains/domain.controller.ts +++ b/apps/api/src/modules/domains/domain.controller.ts @@ -57,6 +57,9 @@ export async function add(c: Context) { ...(result.preexistingEdgeSite ? { preexistingEdgeSite: result.preexistingEdgeSite } : {}), + // Present only when a connected DNS provider manages the zone. Its absence + // is what tells the client "show the records for the operator to paste". + ...(result.autoDns ? { autoDns: result.autoDns } : {}), }, 201, ); diff --git a/apps/api/src/modules/domains/domain.service.ts b/apps/api/src/modules/domains/domain.service.ts index cddfde0bb..055da7b40 100644 --- a/apps/api/src/modules/domains/domain.service.ts +++ b/apps/api/src/modules/domains/domain.service.ts @@ -31,6 +31,7 @@ import { resolveProjectServerHost, resolveLocalServerHost, resolveInstancePublic import { reconcileProjectRoutes } from "../../lib/route-apply.service"; import { generateToken } from "../../lib/domain-token"; import { untrackedSiteFor } from "../../lib/edge-orphans.service"; +import type { UntrackedEdgeSite } from "@repo/core"; import { publicEndpointHostname, resolveServicePublicEndpoints } from "../../lib/public-endpoints"; import { sshManager } from "../../lib/ssh-manager"; import { @@ -46,6 +47,10 @@ import { import type { TAddDomainBody } from "./domain.schema"; import { edgeProxy, readEdgeFile, validateCertFor } from "@repo/adapters"; import type { AdoptedCert, CloudRuntime, CommandExecutor, ManualCert } from "@repo/adapters"; +// Concrete modules, not the `../dns` barrel: importing a barrel that reaches a +// routes file mounts the HTTP route table as a side effect of importing a service. +import { provisionRecords, releaseRecords, type DnsProvisionResult } from "../dns/dns-credential.service"; +import type { DnsRecordType } from "../dns/types"; // ─── List ──────────────────────────────────────────────────────────────────── @@ -89,6 +94,30 @@ export async function setPrimaryDomain(ctx: RequestContext, domainId: string) { // ─── Add ───────────────────────────────────────────────────────────────────── +/** + * Stated rather than inferred, because `addDomain` returns from two places (the + * interrupted-connect resave path and the normal path) and the optional keys + * differ between them. Left to inference, adding a key to one branch silently + * changes what callers can read off the other — which is how `preexistingEdgeSite` + * stopped resolving in the controller the moment an unrelated field was added. + */ +export interface AddDomainResult { + domain: Domain; + records: { mode: "cloud" | "selfhosted" | "external"; records: DnsRecord[] }; + /** The `www.` sibling, when `includeWww` asked for one and it was created. */ + www?: { id: string; hostname: string }; + /** Why the `www.` sibling could not be created. */ + wwwError?: string; + /** Present only when the edge was ALREADY serving this hostname untracked. */ + preexistingEdgeSite?: UntrackedEdgeSite; + /** + * Present only when a connected DNS provider manages the zone: what we wrote, + * per record. Absent means "nobody is automating this domain's DNS", which is + * the normal case and reads as "show the operator the records to paste". + */ + autoDns?: DnsProvisionResult; +} + export async function addDomain( ctx: RequestContext, data: TAddDomainBody, @@ -106,7 +135,7 @@ export async function addDomain( */ extraOwnedHostnames?: string[]; } = {}, -) { +): Promise { const project = await repos.project.findById(data.projectId); assertResourceInOrg(project, "Project", ctx.organizationId, data.projectId); @@ -270,9 +299,55 @@ export async function addDomain( ctx.organizationId, !!data.includeWww, ); + + // ── Automatic DNS: write the records instead of printing them ──────────────── + // Best-effort by the same rule routing/edge/SSL follow: a domain add does not + // fail because the automation did. The per-record outcome rides back on the + // response so "we wrote them" and "we tried and 2 were rejected" are different + // answers — a bare boolean made a half-written zone look like nothing happened. + // + // No verify is kicked off here on purpose. The records are seconds old, the + // resolver a verify would ask may still hold a negative answer for the name, + // and a failed check burns one of Let's Encrypt's per-hostname validation + // failures. The row stays pending and the `domains:verify-pending` cron picks + // it up after its 10-minute grace window, which is what that window is for. + + // Only hostnames this call CLAIMED get written: addWwwSibling swallows a + // cross-project conflict and returns `wwwError`, so provisioning off the + // display list would point `www.` at this box while another project owns + // it — and removeDomain releases only the apex, so nothing here takes it back. + // Names via dnsRecordHosts so they can't drift from buildRecords. + const provisionable = new Set( + [domain.hostname, ...(www?.www ? [www.www.hostname] : [])].flatMap((h) => { + const { routeName, txtName } = dnsRecordHosts(h); + return [routeName.toLowerCase(), txtName.toLowerCase()]; + }), + ); + + const autoDns = await provisionRecords( + ctx.organizationId, + domain.hostname, + records.records + .filter((rec) => provisionable.has(rec.name.toLowerCase())) + .map((rec) => ({ + type: rec.type as DnsRecordType, + name: rec.name, + content: rec.value, + })), + ).catch((err: unknown) => { + console.warn( + `[dns] auto-provision failed for ${domain.hostname}:`, + safeErrorMessage(err), + ); + return null; + }); + return { domain, records, + // Only when a provider actually manages the zone — absence is the signal to + // show the operator the records to paste. + ...(autoDns && (autoDns.records.length > 0 || autoDns.reason) ? { autoDns } : {}), ...www, // Only present when there IS something to say, so callers can spread it and // clients can treat its absence as "nothing was already serving this". @@ -937,6 +1012,28 @@ export async function removeDomain(ctx: RequestContext, domainId: string) { console.error(`[DOMAIN] Failed to remove route for ${domain.hostname}:`, err); } + // ── Take back the DNS records we wrote, and only those ─────────────────────── + // Above the service-scoped branch below on purpose: that branch returns early, + // so cleanup placed after it never runs for a per-service domain and leaves the + // records pointing at this box forever. + // + // `releaseRecords` deletes only records carrying our ownership marker. Matching + // on the name alone is what makes this dangerous: for an apex custom domain + // these names ARE the zone apex, where the operator's MX, SPF TXT and CAA live, + // and "remove this domain from Openship" must never take their mail with it. + const released = await releaseRecords(ctx.organizationId, domain.hostname, [ + domain.hostname, + `_openship-challenge.${domain.hostname}`, + ]).catch((err: unknown) => ({ deleted: 0, reason: safeErrorMessage(err) })); + if (released.reason) { + // Not fatal: a domain must stay removable from Openship when the provider is + // unreachable. An orphan record is visible in the zone; a blocked delete isn't. + console.warn( + `[dns] could not fully clean up records for ${domain.hostname}:`, + released.reason, + ); + } + // A service-scoped row is only HALF the routing config — the owning SERVICE row // also carries `exposed`/`domain`/`customDomain`/`publicEndpoints`. Deleting // just the domain row left the service still configured for that hostname, diff --git a/apps/api/src/modules/system/data-transfer/secret-registry.ts b/apps/api/src/modules/system/data-transfer/secret-registry.ts index 76996264d..79c70178d 100644 --- a/apps/api/src/modules/system/data-transfer/secret-registry.ts +++ b/apps/api/src/modules/system/data-transfer/secret-registry.ts @@ -42,6 +42,7 @@ const SCHEME_BY_KEY: Record = "backup_destination.sftpPasswordEnc": { table: schema.backupDestination, scheme: "enc1" }, "backup_destination.sftpPrivateKeyEnc": { table: schema.backupDestination, scheme: "enc1" }, "backup_destination.sftpKeyPassphraseEnc": { table: schema.backupDestination, scheme: "enc1" }, + "dns_credential.apiTokenEnc": { table: schema.dnsCredential, scheme: "enc1" }, "servers.sshPassword": { table: schema.servers, scheme: "enc1" }, "servers.sshPrivateKey": { table: schema.servers, scheme: "enc1" }, "servers.sshKeyPassphrase": { table: schema.servers, scheme: "enc1" }, diff --git a/apps/api/test/modules/dns/cloudflare-dns-provider.test.ts b/apps/api/test/modules/dns/cloudflare-dns-provider.test.ts new file mode 100644 index 000000000..f920ecc31 --- /dev/null +++ b/apps/api/test/modules/dns/cloudflare-dns-provider.test.ts @@ -0,0 +1,471 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cloudflareDnsProvider } from "../../../src/modules/dns/providers/cloudflare.provider"; +import { + DnsApiError, + DnsRecordConflictError, + OPENSHIP_RECORD_COMMENT, + isOpenshipManaged, +} from "../../../src/modules/dns/types"; + +/** Shape a Cloudflare v4 envelope the way the real API does. */ +function cfOk(result: unknown, resultInfo?: Record) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + success: true, + errors: [], + messages: [], + result, + ...(resultInfo ? { result_info: resultInfo } : {}), + }), + } as Response; +} + +function cfErr(status: number, message: string) { + return { + ok: false, + status, + text: async () => + JSON.stringify({ + success: false, + errors: [{ code: 1000, message }], + messages: [], + result: null, + }), + } as Response; +} + +const record = (over: Record = {}) => ({ + id: "rec_1", + zone_id: "zone_123", + type: "A", + name: "app.example.com", + content: "198.51.100.1", + ttl: 1, + proxied: false, + comment: OPENSHIP_RECORD_COMMENT, + ...over, +}); + +describe("cloudflareDnsProvider", () => { + const originalFetch = global.fetch; + + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => { + global.fetch = originalFetch; + }); + + describe("preflight", () => { + it("fails when the token is blank", async () => { + const result = await cloudflareDnsProvider.preflight({ apiToken: " " }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("missing"); + }); + + it("accepts an active token", async () => { + global.fetch = vi.fn().mockResolvedValue(cfOk({ id: "tok_1", status: "active" })); + const result = await cloudflareDnsProvider.preflight({ apiToken: "valid" }); + expect(result.ok).toBe(true); + }); + + it("reports the status back when the token is not active", async () => { + global.fetch = vi.fn().mockResolvedValue(cfOk({ id: "tok_1", status: "disabled" })); + const result = await cloudflareDnsProvider.preflight({ apiToken: "disabled" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("disabled"); + }); + + it("turns a 403 into scope guidance rather than a raw API string", async () => { + global.fetch = vi.fn().mockResolvedValue(cfErr(403, "Invalid request headers")); + const result = await cloudflareDnsProvider.preflight({ apiToken: "underscoped" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("DNS:Edit"); + }); + + it("does not throw when the network is down", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + const result = await cloudflareDnsProvider.preflight({ apiToken: "x" }); + expect(result.ok).toBe(false); + }); + }); + + describe("findZone", () => { + it("walks labels from subdomain to apex", async () => { + const fetchMock = vi.fn().mockImplementation(async (url: string) => + url.includes("name=example.com") + ? cfOk([{ id: "zone_cf", name: "example.com", status: "active" }]) + : cfOk([]), + ); + global.fetch = fetchMock; + + const zone = await cloudflareDnsProvider.findZone({ apiToken: "t" }, "app.sub.example.com"); + + expect(zone).toEqual({ id: "zone_cf", name: "example.com", status: "active" }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("prefers the most specific zone when a subdomain is delegated on its own", async () => { + // Both zones exist in the account. Exact-match the `name` param — a + // substring check would let "app.sub.example.com" fall through to the apex. + const zones: Record = { + "sub.example.com": { id: "zone_sub", name: "sub.example.com", status: "active" }, + "example.com": { id: "zone_apex", name: "example.com", status: "active" }, + }; + global.fetch = vi.fn().mockImplementation(async (url: string) => { + const name = new URL(url).searchParams.get("name") ?? ""; + const hit = zones[name]; + return cfOk(hit ? [hit] : []); + }); + + const zone = await cloudflareDnsProvider.findZone({ apiToken: "t" }, "app.sub.example.com"); + expect(zone?.id).toBe("zone_sub"); + }); + + it("returns null when nothing matches", async () => { + global.fetch = vi.fn().mockResolvedValue(cfOk([])); + expect( + await cloudflareDnsProvider.findZone({ apiToken: "t" }, "nope.example.org"), + ).toBeNull(); + }); + + it("never probes a single-label host", async () => { + const fetchMock = vi.fn().mockResolvedValue(cfOk([])); + global.fetch = fetchMock; + expect(await cloudflareDnsProvider.findZone({ apiToken: "t" }, "localhost")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("bounds the walk so a long hostname can't fan out unbounded", async () => { + const fetchMock = vi.fn().mockResolvedValue(cfOk([])); + global.fetch = fetchMock; + const deep = `${Array.from({ length: 18 }, (_, i) => `l${i}`).join(".")}.example.com`; + await cloudflareDnsProvider.findZone({ apiToken: "t" }, deep); + expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(8); + }); + + it("PROPAGATES a rate limit instead of reporting 'no zone'", async () => { + // The whole point: a swallowed 429 tells the operator their token is wrong + // when it is fine, and silently skips provisioning. + global.fetch = vi.fn().mockResolvedValue(cfErr(429, "Rate limited")); + await expect( + cloudflareDnsProvider.findZone({ apiToken: "t" }, "app.example.com"), + ).rejects.toBeInstanceOf(DnsApiError); + }); + + it("propagates an auth failure with isAuthFailure set", async () => { + global.fetch = vi.fn().mockResolvedValue(cfErr(401, "Invalid API token")); + const err = await cloudflareDnsProvider + .findZone({ apiToken: "t" }, "app.example.com") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(DnsApiError); + expect((err as DnsApiError).isAuthFailure).toBe(true); + }); + }); + + describe("listRecords", () => { + it("follows pagination past the first page", async () => { + const fetchMock = vi.fn().mockImplementation(async (url: string) => { + const page = new URL(url).searchParams.get("page"); + return cfOk([record({ id: `rec_p${page}` })], { + total_pages: 3, + page: Number(page), + }); + }); + global.fetch = fetchMock; + + const records = await cloudflareDnsProvider.listRecords({ apiToken: "t" }, "zone_123"); + + expect(records.map((r) => r.id)).toEqual(["rec_p1", "rec_p2", "rec_p3"]); + }); + + it("stops after one page when the provider reports no pagination", async () => { + const fetchMock = vi.fn().mockResolvedValue(cfOk([record()])); + global.fetch = fetchMock; + await cloudflareDnsProvider.listRecords({ apiToken: "t" }, "zone_123"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("carries the provider comment through so ownership is checkable", async () => { + global.fetch = vi + .fn() + .mockResolvedValue(cfOk([record({ comment: OPENSHIP_RECORD_COMMENT })])); + const [r] = await cloudflareDnsProvider.listRecords({ apiToken: "t" }, "zone_123"); + expect(r?.comment).toBe(OPENSHIP_RECORD_COMMENT); + }); + }); + + describe("upsertRecord", () => { + it("creates when nothing exists, stamping the ownership marker", async () => { + let posted: Record | null = null; + global.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + posted = JSON.parse(init.body as string) as Record; + return cfOk(record({ id: "rec_new", content: "192.0.2.1" })); + } + return cfOk([]); + }); + + const result = await cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "app.example.com", + content: "192.0.2.1", + }); + + expect(result.id).toBe("rec_new"); + expect(posted).toMatchObject({ comment: OPENSHIP_RECORD_COMMENT, ttl: 1, proxied: false }); + }); + + it("updates in place when one record already matches", async () => { + let method = ""; + global.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + method = init?.method ?? "GET"; + if (init?.method === "PUT") return cfOk(record({ content: "203.0.113.5" })); + return cfOk([record({ content: "198.51.100.1" })]); + }); + + const result = await cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "app.example.com", + content: "203.0.113.5", + }); + + expect(method).toBe("PUT"); + expect(result.content).toBe("203.0.113.5"); + }); + + it("PRESERVES the operator's Cloudflare proxying instead of resetting it", async () => { + let put: Record | null = null; + global.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + put = JSON.parse(init.body as string) as Record; + return cfOk(record({ proxied: true, content: "203.0.113.5" })); + } + return cfOk([record({ proxied: true })]); + }); + + await cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "app.example.com", + content: "203.0.113.5", + }); + + expect(put).toMatchObject({ proxied: true }); + }); + + it("makes no write when the record already says exactly what we want", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(cfOk([record({ content: "192.0.2.1", ttl: 1, proxied: false })])); + global.fetch = fetchMock; + + await cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "app.example.com", + content: "192.0.2.1", + }); + + // The list call and nothing else — no PUT. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("still writes when only the TTL differs from the effective desired value", async () => { + // The old short-circuit ignored ttl whenever the caller passed none, so a + // stale 86400 survived forever although our model says "automatic". + let sawPut = false; + global.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + sawPut = true; + return cfOk(record({ ttl: 1 })); + } + return cfOk([record({ ttl: 86400, content: "192.0.2.1" })]); + }); + + await cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "app.example.com", + content: "192.0.2.1", + }); + + expect(sawPut).toBe(true); + }); + + // A single pre-existing record is the normal state of an apex being connected, + // and it is the operator's. Repointing it is the feature; claiming it is not — + // the marker is what releaseRecords deletes on, so stamping it here would make + // "remove this domain" destroy a record Openship never created. + it("REPOINTS a single unmarked operator record without claiming ownership", async () => { + let put: Record | null = null; + global.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + put = JSON.parse(init.body as string) as Record; + return cfOk(record({ comment: "prod origin", content: "203.0.113.5" })); + } + return cfOk([record({ comment: "prod origin" })]); + }); + + const result = await cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "app.example.com", + content: "203.0.113.5", + }); + + expect(put).toMatchObject({ content: "203.0.113.5", comment: "prod origin" }); + expect(put?.comment).not.toBe(OPENSHIP_RECORD_COMMENT); + expect(isOpenshipManaged(result)).toBe(false); + }); + + it("leaves a comment-less operator record unmarked when repointing it", async () => { + let put: Record = {}; + global.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + put = JSON.parse(init.body as string) as Record; + return cfOk(record({ comment: null, content: "203.0.113.5" })); + } + return cfOk([record({ comment: null })]); + }); + + await cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "app.example.com", + content: "203.0.113.5", + }); + + expect(put.content).toBe("203.0.113.5"); + expect("comment" in put).toBe(false); + }); + + it("REFUSES to rewrite a record set it does not own", async () => { + // Two operator-owned A records = a round-robin. Rewriting one member leaves + // the hostname answering with a mix of their origin and ours. + global.fetch = vi.fn().mockResolvedValue( + cfOk([ + record({ id: "rec_a", content: "203.0.113.10", comment: null }), + record({ id: "rec_b", content: "203.0.113.11", comment: null }), + ]), + ); + + await expect( + cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "app.example.com", + content: "192.0.2.1", + }), + ).rejects.toBeInstanceOf(DnsRecordConflictError); + }); + + it("updates OUR record when it sits alongside the operator's", async () => { + let putUrl = ""; + global.fetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + putUrl = url; + return cfOk(record({ id: "rec_ours" })); + } + return cfOk([ + record({ id: "rec_theirs", comment: null, content: "203.0.113.10" }), + record({ id: "rec_ours", comment: OPENSHIP_RECORD_COMMENT, content: "203.0.113.11" }), + ]); + }); + + await cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "app.example.com", + content: "192.0.2.1", + }); + + expect(putUrl).toContain("rec_ours"); + }); + + it("normalizes a trailing dot and uppercase in the name", async () => { + let posted: Record | null = null; + global.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + posted = JSON.parse(init.body as string) as Record; + return cfOk(record()); + } + return cfOk([]); + }); + + await cloudflareDnsProvider.upsertRecord({ apiToken: "t" }, "zone_123", { + type: "A", + name: "App.Example.COM.", + content: "192.0.2.1", + }); + + expect(posted).toMatchObject({ name: "app.example.com" }); + }); + }); + + describe("deleteRecord", () => { + it("issues a DELETE for the record id", async () => { + const fetchMock = vi.fn().mockResolvedValue(cfOk({ id: "rec_1" })); + global.fetch = fetchMock; + + await cloudflareDnsProvider.deleteRecord({ apiToken: "t" }, "zone_123", "rec_1"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe("DELETE"); + expect(url).toContain("/zones/zone_123/dns_records/rec_1"); + }); + + it("treats an already-deleted record as success", async () => { + global.fetch = vi.fn().mockResolvedValue(cfErr(404, "Record not found")); + await expect( + cloudflareDnsProvider.deleteRecord({ apiToken: "t" }, "zone_123", "rec_gone"), + ).resolves.toBeUndefined(); + }); + + it("still surfaces a real failure", async () => { + global.fetch = vi.fn().mockResolvedValue(cfErr(403, "Forbidden")); + await expect( + cloudflareDnsProvider.deleteRecord({ apiToken: "t" }, "zone_123", "rec_1"), + ).rejects.toBeInstanceOf(DnsApiError); + }); + }); + + describe("error mapping", () => { + it("keeps the provider status separate from the response status", async () => { + // A Cloudflare 404 must not become OUR 404 — the endpoint exists. + global.fetch = vi.fn().mockResolvedValue(cfErr(404, "not found")); + const err = await cloudflareDnsProvider + .listRecords({ apiToken: "t" }, "zone_123") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(DnsApiError); + expect((err as DnsApiError).providerStatus).toBe(404); + expect((err as DnsApiError).statusCode).toBe(502); + }); + + it("classifies a transport failure as transient", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("socket hang up")); + const err = await cloudflareDnsProvider + .listRecords({ apiToken: "t" }, "zone_123") + .catch((e: unknown) => e); + expect((err as DnsApiError).isTransient).toBe(true); + }); + + // This runs inline on POST /domains, so an unbounded call outlives the + // operator's own client deadline while it keeps writing records. + it("bounds every call with an abort signal", async () => { + const fetchMock = vi.fn().mockResolvedValue(cfOk([record()])); + global.fetch = fetchMock; + await cloudflareDnsProvider.listRecords({ apiToken: "t" }, "zone_123"); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.signal?.aborted).toBe(false); + }); + + it("reports its own timeout as transient, not as 'no zone'", async () => { + global.fetch = vi + .fn() + .mockRejectedValue(Object.assign(new Error("This operation was aborted"), { + name: "AbortError", + })); + const err = await cloudflareDnsProvider + .findZone({ apiToken: "t" }, "app.example.com") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(DnsApiError); + expect((err as DnsApiError).isTransient).toBe(true); + }); + }); +}); diff --git a/apps/api/test/modules/dns/dns-credential.service.test.ts b/apps/api/test/modules/dns/dns-credential.service.test.ts new file mode 100644 index 000000000..75c319b41 --- /dev/null +++ b/apps/api/test/modules/dns/dns-credential.service.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ENV_MASK } from "@repo/core"; + +const dnsCredentialRepo = vi.hoisted(() => ({ + listByOrg: vi.fn(), + findById: vi.fn(), + findByName: vi.fn(), + findActiveByOrg: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +})); + +vi.mock("@repo/db", () => ({ repos: { dnsCredential: dnsCredentialRepo } })); + +const decrypt = vi.hoisted(() => vi.fn()); +vi.mock("../../../src/lib/credential-encryption", () => ({ + encryptSecretField: (v: string | null | undefined) => (v ? `enc1:${v}` : null), + decryptSecretField: decrypt, +})); + +const provider = vi.hoisted(() => ({ + name: "cloudflare" as const, + descriptor: { + name: "cloudflare" as const, + displayName: "Cloudflare", + description: "", + requiredScopes: [], + }, + preflight: vi.fn(), + findZone: vi.fn(), + listRecords: vi.fn(), + upsertRecord: vi.fn(), + deleteRecord: vi.fn(), +})); + +vi.mock("../../../src/modules/dns/registry", () => ({ + resolveDnsProvider: () => provider, + listDnsProviders: () => ["cloudflare"], + describeDnsProviders: () => [provider.descriptor], +})); + +const { + sanitizeCredential, + listCredentials, + resolveDnsManager, + provisionRecords, + releaseRecords, +} = await import("../../../src/modules/dns/dns-credential.service"); +const { DnsApiError, OPENSHIP_RECORD_COMMENT } = await import("../../../src/modules/dns/types"); + +const row = (over: Record = {}) => ({ + id: "dns_1", + organizationId: "org_1", + provider: "cloudflare", + name: "Cloudflare production", + apiTokenEnc: "enc1:cf-token", + status: "active", + lastVerifiedAt: new Date("2026-01-01"), + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + ...over, +}); + +const rec = (over: Record = {}) => ({ + id: "rec_1", + zoneId: "zone_1", + type: "A" as const, + name: "app.example.com", + content: "192.0.2.1", + ttl: 1, + proxied: false, + comment: OPENSHIP_RECORD_COMMENT, + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + decrypt.mockImplementation((v: string) => String(v).replace(/^enc1:/, "")); + provider.findZone.mockResolvedValue({ id: "zone_1", name: "example.com", status: "active" }); +}); + +describe("credential sanitization", () => { + it("returns a constant mask and never decrypts on a read path", async () => { + dnsCredentialRepo.listByOrg.mockResolvedValue([row()]); + + const [out] = await listCredentials("org_1"); + + expect(out?.tokenMasked).toBe(ENV_MASK); + // The whole reason the mask is a constant: a read endpoint must not be a + // partial-token disclosure, and it must not be able to throw on a rotated key. + expect(decrypt).not.toHaveBeenCalled(); + }); + + it("does not leak the ciphertext either", () => { + const out = sanitizeCredential(row() as never) as unknown as Record; + expect(JSON.stringify(out)).not.toContain("cf-token"); + expect(out.apiTokenEnc).toBeUndefined(); + }); + + it("survives a rotated encryption key instead of 500ing the list", async () => { + decrypt.mockImplementation(() => { + throw new Error("Unsupported state or unable to authenticate data"); + }); + dnsCredentialRepo.listByOrg.mockResolvedValue([row()]); + + await expect(listCredentials("org_1")).resolves.toHaveLength(1); + }); +}); + +describe("resolveDnsManager", () => { + it("reports 'none' when the org has no credentials", async () => { + dnsCredentialRepo.findActiveByOrg.mockResolvedValue([]); + expect(await resolveDnsManager("org_1", "app.example.com")).toEqual({ status: "none" }); + }); + + it("matches the credential whose zone hosts the name", async () => { + dnsCredentialRepo.findActiveByOrg.mockResolvedValue([row()]); + const out = await resolveDnsManager("org_1", "app.example.com"); + expect(out.status).toBe("matched"); + if (out.status === "matched") expect(out.manager.zone.name).toBe("example.com"); + }); + + it("distinguishes 'unavailable' from 'none' on a rate limit", async () => { + // A 429 reported as "no provider manages this" reads as a config mistake the + // operator did not make, and silently skips provisioning. + dnsCredentialRepo.findActiveByOrg.mockResolvedValue([row()]); + provider.findZone.mockRejectedValue(new DnsApiError("cloudflare", 429, "Rate limited")); + + const out = await resolveDnsManager("org_1", "app.example.com"); + expect(out.status).toBe("unavailable"); + }); + + it("reports 'unauthorized' with the credential id on a 401", async () => { + dnsCredentialRepo.findActiveByOrg.mockResolvedValue([row()]); + provider.findZone.mockRejectedValue(new DnsApiError("cloudflare", 401, "Invalid token")); + + const out = await resolveDnsManager("org_1", "app.example.com"); + expect(out.status).toBe("unauthorized"); + if (out.status === "unauthorized") expect(out.credentialId).toBe("dns_1"); + }); + + it("treats an undecryptable token as unauthorized, not as a crash", async () => { + dnsCredentialRepo.findActiveByOrg.mockResolvedValue([row()]); + decrypt.mockImplementation(() => { + throw new Error("bad key"); + }); + + const out = await resolveDnsManager("org_1", "app.example.com"); + expect(out.status).toBe("unauthorized"); + }); + + it("keeps looking past one broken credential to a working one", async () => { + dnsCredentialRepo.findActiveByOrg.mockResolvedValue([ + row({ id: "dns_broken" }), + row({ id: "dns_good" }), + ]); + provider.findZone + .mockRejectedValueOnce(new DnsApiError("cloudflare", 401, "Invalid token")) + .mockResolvedValueOnce({ id: "zone_1", name: "example.com", status: "active" }); + + const out = await resolveDnsManager("org_1", "app.example.com"); + expect(out.status).toBe("matched"); + if (out.status === "matched") expect(out.manager.credentialId).toBe("dns_good"); + }); +}); + +describe("provisionRecords", () => { + beforeEach(() => dnsCredentialRepo.findActiveByOrg.mockResolvedValue([row()])); + + it("writes every desired record and reports provisioned", async () => { + provider.upsertRecord.mockResolvedValue(rec()); + + const out = await provisionRecords("org_1", "app.example.com", [ + { type: "A", name: "app.example.com", content: "192.0.2.1" }, + { type: "TXT", name: "_openship-challenge.app.example.com", content: "tok" }, + ]); + + expect(provider.upsertRecord).toHaveBeenCalledTimes(2); + expect(out.provisioned).toBe(true); + expect(out.records.every((r) => r.outcome === "applied")).toBe(true); + }); + + it("skips a record whose value is unknown rather than writing an empty one", async () => { + provider.upsertRecord.mockResolvedValue(rec()); + + const out = await provisionRecords("org_1", "app.example.com", [ + { type: "CNAME", name: "app.example.com", content: "" }, + { type: "TXT", name: "_openship-challenge.app.example.com", content: "tok" }, + ]); + + expect(provider.upsertRecord).toHaveBeenCalledTimes(1); + expect(out.records[0]?.outcome).toBe("skipped"); + }); + + it("ATTEMPTS every record even after one is rejected", async () => { + // One bad value must not suppress the ownership TXT that would let the + // domain verify. + provider.upsertRecord + .mockRejectedValueOnce(new DnsApiError("cloudflare", 400, "Content invalid")) + .mockResolvedValueOnce(rec()); + + const out = await provisionRecords("org_1", "app.example.com", [ + { type: "A", name: "app.example.com", content: "not-an-ip" }, + { type: "TXT", name: "_openship-challenge.app.example.com", content: "tok" }, + ]); + + expect(provider.upsertRecord).toHaveBeenCalledTimes(2); + expect(out.provisioned).toBe(false); + expect(out.records.map((r) => r.outcome)).toEqual(["failed", "applied"]); + // A partial write is reportable, not indistinguishable from "nothing happened". + expect(out.reason).toContain("1 of 2"); + }); + + it("marks the credential invalid when the provider rejects the token", async () => { + provider.findZone.mockRejectedValue(new DnsApiError("cloudflare", 403, "Forbidden")); + dnsCredentialRepo.update.mockResolvedValue(row({ status: "invalid" })); + + const out = await provisionRecords("org_1", "app.example.com", [ + { type: "A", name: "app.example.com", content: "192.0.2.1" }, + ]); + + expect(dnsCredentialRepo.update).toHaveBeenCalledWith("org_1", "dns_1", { status: "invalid" }); + expect(out.provisioned).toBe(false); + }); + + it("does NOT mark a credential invalid when the provider was merely unreachable", async () => { + provider.findZone.mockRejectedValue(new DnsApiError("cloudflare", 503, "Bad gateway")); + + await provisionRecords("org_1", "app.example.com", [ + { type: "A", name: "app.example.com", content: "192.0.2.1" }, + ]); + + expect(dnsCredentialRepo.update).not.toHaveBeenCalled(); + }); + + it("does not reject when marking the credential invalid fails", async () => { + provider.findZone.mockRejectedValue(new DnsApiError("cloudflare", 401, "nope")); + dnsCredentialRepo.update.mockRejectedValue(new Error("db gone")); + + await expect( + provisionRecords("org_1", "app.example.com", [ + { type: "A", name: "app.example.com", content: "192.0.2.1" }, + ]), + ).resolves.toMatchObject({ provisioned: false }); + }); +}); + +describe("releaseRecords", () => { + beforeEach(() => dnsCredentialRepo.findActiveByOrg.mockResolvedValue([row()])); + + it("deletes ONLY records carrying our ownership marker", async () => { + // The apex of a custom domain IS the zone apex. Name-matching alone would + // take the operator's MX and SPF with it. + provider.listRecords.mockResolvedValue([ + rec({ id: "rec_ours", type: "A", comment: OPENSHIP_RECORD_COMMENT }), + rec({ id: "rec_mx", type: "MX", comment: undefined, content: "mail.example.com" }), + rec({ id: "rec_spf", type: "TXT", comment: "operator's SPF", content: "v=spf1 -all" }), + ]); + provider.deleteRecord.mockResolvedValue(undefined); + + const out = await releaseRecords("org_1", "example.com", ["example.com"]); + + expect(out.deleted).toBe(1); + expect(provider.deleteRecord).toHaveBeenCalledTimes(1); + expect(provider.deleteRecord).toHaveBeenCalledWith(expect.anything(), "zone_1", "rec_ours"); + }); + + it("deletes nothing when the zone holds only the operator's records", async () => { + provider.listRecords.mockResolvedValue([rec({ id: "rec_mx", comment: undefined })]); + + const out = await releaseRecords("org_1", "example.com", ["example.com"]); + + expect(out.deleted).toBe(0); + expect(provider.deleteRecord).not.toHaveBeenCalled(); + }); + + it("de-duplicates the requested names", async () => { + provider.listRecords.mockResolvedValue([]); + await releaseRecords("org_1", "example.com", ["example.com", "example.com"]); + expect(provider.listRecords).toHaveBeenCalledTimes(1); + }); + + it("reports a reason instead of throwing when the provider is unreachable", async () => { + // A domain must stay removable from Openship when Cloudflare is down. + provider.listRecords.mockRejectedValue(new DnsApiError("cloudflare", 503, "unavailable")); + + const out = await releaseRecords("org_1", "example.com", ["example.com"]); + + expect(out.deleted).toBe(0); + expect(out.reason).toContain("503"); + }); + + it("is a no-op when no connected provider hosts the zone", async () => { + provider.findZone.mockResolvedValue(null); + + const out = await releaseRecords("org_1", "example.com", ["example.com"]); + + expect(out).toEqual({ deleted: 0 }); + expect(provider.listRecords).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/test/modules/dns/dns-registry.test.ts b/apps/api/test/modules/dns/dns-registry.test.ts new file mode 100644 index 000000000..8ab080189 --- /dev/null +++ b/apps/api/test/modules/dns/dns-registry.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { + resolveDnsProvider, + listDnsProviders, + describeDnsProviders, +} from "../../../src/modules/dns/registry"; +import { UnknownDnsProviderError } from "../../../src/modules/dns/types"; + +describe("dns registry", () => { + it("resolves the registered cloudflare provider", () => { + expect(resolveDnsProvider("cloudflare").name).toBe("cloudflare"); + }); + + it("throws UnknownDnsProviderError for an unknown provider", () => { + expect(() => resolveDnsProvider("unsupported")).toThrow(UnknownDnsProviderError); + }); + + it("maps an unknown provider to a 400, not a 500", () => { + // It's a bad request, not a server fault — handleApiError reads statusCode. + const err = new UnknownDnsProviderError("route53"); + expect(err.statusCode).toBe(400); + expect(err.code).toBe("DNS_UNKNOWN_PROVIDER"); + }); + + it("lists every registered provider", () => { + expect(listDnsProviders()).toContain("cloudflare"); + }); + + it("DERIVES descriptors from the registry, so a new provider cannot be invisible", () => { + // The dashboard picker renders this list. A hand-maintained second literal is + // how a registered provider ends up unusable from the UI. + const descriptors = describeDnsProviders(); + expect(descriptors.map((d) => d.name).sort()).toEqual([...listDnsProviders()].sort()); + }); + + it("describes the exact token scopes needed, so a minimal token can be minted", () => { + const cf = describeDnsProviders().find((d) => d.name === "cloudflare"); + expect(cf?.requiredScopes).toEqual(["Zone:Zone:Read", "Zone:DNS:Edit"]); + expect(cf?.displayName).toBe("Cloudflare"); + expect(cf?.tokenUrl).toContain("dash.cloudflare.com"); + }); +}); diff --git a/apps/api/test/modules/domains/domain-add.test.ts b/apps/api/test/modules/domains/domain-add.test.ts index 5abdb1959..4c971e675 100644 --- a/apps/api/test/modules/domains/domain-add.test.ts +++ b/apps/api/test/modules/domains/domain-add.test.ts @@ -46,6 +46,14 @@ vi.mock("../../../src/lib/domain-ssl", () => ({ manageDomainSsl: vi.fn(), })); +// Stubbed so the provisioning ARGUMENTS are assertable: which hostnames Openship +// actually writes records for is the thing that matters, not what Cloudflare says. +const provisionRecords = vi.fn().mockResolvedValue({ provisioned: true, records: [] }); +vi.mock("../../../src/modules/dns/dns-credential.service", () => ({ + provisionRecords: (...args: unknown[]) => provisionRecords(...args), + releaseRecords: vi.fn().mockResolvedValue({ deleted: 0 }), +})); + vi.mock("../../../src/lib/dns-resolver", () => ({ resolveRecords: vi.fn(), })); @@ -213,6 +221,15 @@ describe("addDomain retries", () => { expect(result.domain.hostname).toBe("example.com"); expect(result.www).toBeUndefined(); expect(result.wwwError).toContain("www.example.com"); + + // The panel still LISTS www so the operator knows what it would need... + expect(result.records.records.some((r: any) => r.name === "www.example.com")).toBe(true); + // ...but nothing is WRITTEN for a hostname another project owns. removeDomain + // releases only the apex and its challenge name, so such a record would be + // orphaned with nothing on this side able to take it back. + const written = (provisionRecords.mock.calls.at(-1)?.[2] ?? []) as { name: string }[]; + expect(written.map((r) => r.name)).not.toContain("www.example.com"); + expect(written.map((r) => r.name)).toContain("example.com"); }); it("never stacks www on www", async () => { diff --git a/apps/dashboard/src/app/(dashboard)/settings/_components/DnsProviders.tsx b/apps/dashboard/src/app/(dashboard)/settings/_components/DnsProviders.tsx new file mode 100644 index 000000000..5af75a465 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/_components/DnsProviders.tsx @@ -0,0 +1,328 @@ +"use client"; + +/** + * Settings → DNS. Connect the provider token that lets Openship write a domain's + * DNS records instead of printing them for the operator to paste. + * + * The token is write-only from here on: the list shows the same `••••••••` mask + * the env-var views use, and there is no endpoint that reveals it. "Is it still + * working" is answered by the credential's status (flipped to `invalid` the first + * time the provider rejects it during provisioning) and by the zone check below, + * which is a read — running it can't disable anything. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + ExternalLink, + Globe, + Loader2, + Plus, + Search, + ShieldCheck, + Trash2, +} from "lucide-react"; + +import { SettingsSection } from "./SettingsSection"; +import { + dnsApi, + getApiErrorMessage, + type DnsProviderDescriptor, + type SanitizedDnsCredential, + type VerifyZoneResult, +} from "@/lib/api"; +import { useToast } from "@/context/ToastContext"; +import { useI18n, interpolate } from "@/components/i18n-provider"; + +function fmtDate(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function DnsProviders() { + const { showToast } = useToast(); + const { t } = useI18n(); + const copy = t.settings.dns; + const toastTitle = t.settings.common.toast.dns; + + const [providers, setProviders] = useState([]); + const [credentials, setCredentials] = useState([]); + const [loading, setLoading] = useState(true); + + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(""); + const [apiToken, setApiToken] = useState(""); + const [connecting, setConnecting] = useState(false); + + const [zoneHost, setZoneHost] = useState(""); + const [zoneChecking, setZoneChecking] = useState(false); + const [zoneResult, setZoneResult] = useState(null); + + const refresh = useCallback(async () => { + try { + const [provRes, credRes] = await Promise.all([ + dnsApi.listProviders(), + dnsApi.listCredentials(), + ]); + setProviders(provRes?.data ?? []); + setCredentials(credRes?.data ?? []); + } catch (err) { + showToast(getApiErrorMessage(err, copy.toast.loadFailed), "error", toastTitle); + } finally { + setLoading(false); + } + }, [showToast, copy.toast.loadFailed, toastTitle]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const cloudflare = providers.find((p) => p.name === "cloudflare"); + + const handleConnect = async () => { + if (!name.trim() || !apiToken.trim()) { + showToast(copy.toast.needBoth, "error", toastTitle); + return; + } + setConnecting(true); + try { + // The API preflights the token against Cloudflare before storing it, so a + // success here means it actually works — not just that it was saved. + await dnsApi.addCredential({ + provider: "cloudflare", + name: name.trim(), + apiToken: apiToken.trim(), + }); + showToast(interpolate(copy.toast.connected, { name: name.trim() }), "success", toastTitle); + setShowForm(false); + setName(""); + setApiToken(""); + await refresh(); + } catch (err) { + showToast(getApiErrorMessage(err, copy.toast.connectFailed), "error", toastTitle); + } finally { + setConnecting(false); + } + }; + + const handleRemove = async (cred: SanitizedDnsCredential) => { + try { + await dnsApi.removeCredential(cred.id); + showToast(interpolate(copy.toast.disconnected, { name: cred.name }), "success", toastTitle); + await refresh(); + } catch (err) { + showToast(getApiErrorMessage(err, copy.toast.disconnectFailed), "error", toastTitle); + } + }; + + const handleZoneCheck = async () => { + if (!zoneHost.trim()) return; + setZoneChecking(true); + setZoneResult(null); + try { + setZoneResult(await dnsApi.verifyZone(zoneHost.trim())); + } catch (err) { + showToast(getApiErrorMessage(err, copy.toast.checkFailed), "error", toastTitle); + } finally { + setZoneChecking(false); + } + }; + + /** Zone-check verdicts read differently on purpose — see VerifyZoneResult. */ + const zoneMessage = (result: VerifyZoneResult): { text: string; tone: "success" | "warning" | "muted" } => { + switch (result.status) { + case "matched": + return { + text: interpolate(copy.zoneCheck.matched, { zone: result.zoneName ?? "" }), + tone: "success", + }; + case "unauthorized": + return { text: result.message ?? copy.zoneCheck.unauthorized, tone: "warning" }; + case "unavailable": + return { text: result.message ?? copy.zoneCheck.unavailable, tone: "warning" }; + default: + return { text: copy.zoneCheck.none, tone: "muted" }; + } + }; + + return ( + + {/* What connecting actually buys, and what it costs in authority. */} +
+ +
+ {copy.explainer}{" "} + {copy.explainerOwnership} +
+
+ + {/* Connect form */} + {showForm ? ( +
+ {cloudflare && ( +

+ {interpolate(copy.scopesHint, { scopes: cloudflare.requiredScopes.join(", ") })}{" "} + {cloudflare.tokenUrl && ( + + {copy.createTokenLink} + + + )} +

+ )} + setName(e.target.value)} + placeholder={copy.namePlaceholder} + className="w-full rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20" + /> + setApiToken(e.target.value)} + placeholder={copy.tokenPlaceholder} + autoComplete="off" + className="w-full rounded-lg border border-border/50 bg-muted/30 px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ + +
+
+ ) : ( + + )} + + {/* Connected credentials */} + {loading ? ( +
+ {t.settings.common.loading} +
+ ) : credentials.length === 0 ? ( +

{copy.noneConnected}

+ ) : ( +
+ {credentials.map((cred) => ( +
+ +
+
+

{cred.name}

+ {cred.status === "invalid" ? ( + + {copy.badgeInvalid} + + ) : ( + + {copy.badgeActive} + + )} +
+

+ {cred.tokenMasked} + + {interpolate(copy.metaLine, { + provider: cred.provider, + verified: fmtDate(cred.lastVerifiedAt), + })} + +

+ {cred.status === "invalid" && ( +

{copy.invalidHint}

+ )} +
+ +
+ ))} +
+ )} + + {/* Zone check — "will this domain be automated?" answered before adding it. */} + {credentials.length > 0 && ( +
+

{copy.zoneCheck.title}

+

{copy.zoneCheck.description}

+
+ setZoneHost(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleZoneCheck(); + }} + placeholder={copy.zoneCheck.placeholder} + className="min-w-0 flex-1 rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20" + /> + +
+ {zoneResult && + (() => { + const { text, tone } = zoneMessage(zoneResult); + const toneClass = + tone === "success" + ? "text-success" + : tone === "warning" + ? "text-warning" + : "text-muted-foreground"; + return ( +

+ {tone === "success" ? ( + + ) : tone === "warning" ? ( + + ) : null} + {text} +

+ ); + })()} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/_components/SettingsSidebar.tsx b/apps/dashboard/src/app/(dashboard)/settings/_components/SettingsSidebar.tsx index cdf4ab5e4..b78b473a8 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/_components/SettingsSidebar.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/_components/SettingsSidebar.tsx @@ -17,7 +17,7 @@ import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Settings as SettingsIcon, Users, ClipboardList, Cloud, Server, Bell, KeyRound, Boxes, Mail } from "lucide-react"; +import { Settings as SettingsIcon, Users, ClipboardList, Cloud, Server, Bell, KeyRound, Boxes, Mail, Globe } from "lucide-react"; import { usePlatform } from "@/context/PlatformContext"; import { useSession, authClient } from "@/lib/auth-client"; import { useI18n } from "@/components/i18n-provider"; @@ -49,7 +49,7 @@ function useInfraIssuesCount(): number { return enabled ? count : 0; } -export type SettingsTabId = "general" | "tokens" | "mcp" | "team" | "notifications" | "email" | "audit" | "cloud" | "infrastructure" | "instance"; +export type SettingsTabId = "general" | "tokens" | "mcp" | "team" | "notifications" | "email" | "dns" | "audit" | "cloud" | "infrastructure" | "instance"; export interface SettingsTab { id: SettingsTabId; @@ -66,7 +66,7 @@ export function useSettingsTabs(): { tabs: SettingsTab[]; activeTab: SettingsTab const { t } = useI18n(); const searchParams = useSearchParams(); const raw = (searchParams.get("tab") ?? "general") as SettingsTabId; - const allowedTabs: SettingsTabId[] = ["general", "tokens", "mcp", "team", "notifications", "email", "audit", "cloud", "infrastructure", "instance"]; + const allowedTabs: SettingsTabId[] = ["general", "tokens", "mcp", "team", "notifications", "email", "dns", "audit", "cloud", "infrastructure", "instance"]; const activeTab: SettingsTabId = allowedTabs.includes(raw) ? raw : "general"; const tabs: SettingsTab[] = [ @@ -88,6 +88,10 @@ export function useSettingsTabs(): { tabs: SettingsTab[]; activeTab: SettingsTab visible: selfHosted, requiresRole: "admin", }, + // DNS provider credentials — one org-wide record that lets Openship write a + // domain's records. Every mode: the cloud target automates its CNAME + TXT + // exactly like a self-hosted box automates its A record. + { id: "dns", label: t.settings.sidebar.tabs.dns, icon: Globe, visible: true, requiresRole: "admin" }, { id: "audit", label: t.settings.sidebar.tabs.audit, icon: ClipboardList, visible: true, requiresRole: "admin" }, { id: "cloud", label: t.settings.sidebar.tabs.cloud, icon: Cloud, visible: selfHosted }, // The servers this install runs — edge/mail container versions + global scan diff --git a/apps/dashboard/src/app/(dashboard)/settings/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/page.tsx index 0ffe2a716..f1eb1ff38 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/page.tsx @@ -9,6 +9,7 @@ * - tokens → clone credentials, API access tokens * - mcp → MCP connection (endpoint + client config) * - team → organization members + invitations (moved from /members) + * - dns → DNS provider credentials for automatic records, admin+ only * - audit → audit log feed (moved from /audit), admin+ only * - cloud → cloud connection (self-hosted only) * - infrastructure → edge/mail container versions + scan, untracked edge @@ -42,6 +43,7 @@ import { InfrastructureTab } from "./_components/InfrastructureTab"; import { TeamTab } from "./_components/TeamTab"; import { NotificationsTab } from "./_components/NotificationsTab"; import { EmailSettings } from "./_components/EmailSettings"; +import { DnsProviders } from "./_components/DnsProviders"; import { AuditTab } from "./_components/AuditTab"; import { DataTransferTab } from "./_components/DataTransferTab"; import { @@ -159,6 +161,8 @@ function SettingsPageInner() { {activeTab === "email" && selfHosted && } + {activeTab === "dns" && } + {activeTab === "audit" && } {activeTab === "cloud" && selfHosted && } diff --git a/apps/dashboard/src/i18n/locales/ar/settings.json b/apps/dashboard/src/i18n/locales/ar/settings.json index f1f48bbde..c23920645 100644 --- a/apps/dashboard/src/i18n/locales/ar/settings.json +++ b/apps/dashboard/src/i18n/locales/ar/settings.json @@ -21,6 +21,7 @@ "cloud": "السحابة", "defaults": "الإعدادات الافتراضية", "settings": "الإعدادات", + "dns": "DNS", "notifications": "الإشعارات", "team": "الفريق", "members": "الأعضاء", @@ -51,6 +52,7 @@ "mcp": "MCP", "team": "الفريق", "notifications": "الإشعارات", + "dns": "DNS", "audit": "سجل التدقيق", "cloud": "السحابة", "infrastructure": "البنية التحتية", @@ -775,5 +777,41 @@ "setTo": "تم ضبط أسلوب التوجيه إلى {mode}", "failed": "تعذّر تحديث أسلوب التوجيه" } + }, + "dns": { + "title": "مزوّدو DNS", + "description": "اترك Openship يكتب سجلات DNS لنطاقاتك", + "explainer": "مع وجود مزوّد متصل، تُكتب سجلات النطاق المخصّص فور إضافته، فيتم التحقق من النطاق ويحصل على شهادته دون أن تلصق أي شيء.", + "explainerOwnership": "لا يحذف Openship سوى السجلات التي أنشأها هو.", + "addProvider": "ربط مزوّد", + "connect": "ربط", + "namePlaceholder": "التسمية، مثل Cloudflare للإنتاج", + "tokenPlaceholder": "رمز Cloudflare API", + "scopesHint": "يتطلب رمزًا بصلاحيات {scopes}.", + "createTokenLink": "أنشئ واحدًا", + "noneConnected": "لا يوجد مزوّد DNS متصل. ستعرض النطاقات السجلات لتضيفها يدويًا.", + "badgeActive": "نشِط", + "badgeInvalid": "يحتاج إلى انتباه", + "invalidHint": "رفض المزوّد هذا الرمز. أعد ربطه لاستئناف كتابة السجلات تلقائيًا.", + "metaLine": " · {provider} · آخر تحقق {verified}", + "zoneCheck": { + "title": "تحقّق من نطاق", + "description": "اطّلع على ما إذا كان مزوّد متصل يدير منطقة اسم المضيف قبل إضافته.", + "placeholder": "app.example.com", + "check": "تحقّق", + "matched": "مُدار هنا — المنطقة {zone}. ستُكتب سجلات هذا النطاق تلقائيًا.", + "none": "لا يدير أي مزوّد متصل منطقة هذا النطاق. ستضيف سجلاته يدويًا.", + "unauthorized": "تم رفض رمز محفوظ. أعد ربطه ثم حاول مرة أخرى.", + "unavailable": "لم نتمكن من الوصول إلى مزوّد DNS، لذا فالنتيجة غير معروفة — وهذا لا يعني وجود خطأ في الإعداد." + }, + "toast": { + "loadFailed": "تعذّر تحميل مزوّدي DNS", + "needBoth": "أدخل تسمية ورمز API", + "connected": "تم ربط {name}", + "connectFailed": "تعذّر ربط المزوّد", + "disconnected": "تم فصل {name}", + "disconnectFailed": "تعذّر فصل المزوّد", + "checkFailed": "تعذّر التحقق من هذا النطاق" + } } } diff --git a/apps/dashboard/src/i18n/locales/de/settings.json b/apps/dashboard/src/i18n/locales/de/settings.json index 8186fdd6e..74ab2c466 100644 --- a/apps/dashboard/src/i18n/locales/de/settings.json +++ b/apps/dashboard/src/i18n/locales/de/settings.json @@ -21,6 +21,7 @@ "cloud": "Cloud", "defaults": "Standardwerte", "settings": "Einstellungen", + "dns": "DNS", "notifications": "Benachrichtigungen", "team": "Team", "members": "Mitglieder", @@ -51,6 +52,7 @@ "mcp": "MCP", "team": "Team", "notifications": "Benachrichtigungen", + "dns": "DNS", "audit": "Audit-Protokoll", "cloud": "Cloud", "infrastructure": "Infrastruktur", @@ -775,5 +777,41 @@ "setTo": "Routing-Strategie auf {mode} gesetzt", "failed": "Routing-Strategie konnte nicht aktualisiert werden" } + }, + "dns": { + "title": "DNS-Anbieter", + "description": "Openship die DNS-Einträge deiner Domains schreiben lassen", + "explainer": "Ist ein Anbieter verbunden, werden die Einträge einer neuen eigenen Domain sofort geschrieben — die Domain wird verifiziert und erhält ihr Zertifikat, ohne dass du etwas einfügen musst.", + "explainerOwnership": "Openship entfernt ausschließlich Einträge, die es selbst erstellt hat.", + "addProvider": "Anbieter verbinden", + "connect": "Verbinden", + "namePlaceholder": "Bezeichnung, z. B. Cloudflare Produktion", + "tokenPlaceholder": "Cloudflare-API-Token", + "scopesHint": "Benötigt ein Token mit den Berechtigungen {scopes}.", + "createTokenLink": "Token erstellen", + "noneConnected": "Kein DNS-Anbieter verbunden. Domains zeigen die Einträge an, die du selbst hinzufügen musst.", + "badgeActive": "Aktiv", + "badgeInvalid": "Erfordert Aufmerksamkeit", + "invalidHint": "Der Anbieter hat dieses Token abgelehnt. Verbinde es neu, um automatische Einträge fortzusetzen.", + "metaLine": " · {provider} · zuletzt geprüft {verified}", + "zoneCheck": { + "title": "Domain prüfen", + "description": "Prüfe vor dem Hinzufügen, ob ein verbundener Anbieter die Zone eines Hostnamens verwaltet.", + "placeholder": "app.example.com", + "check": "Prüfen", + "matched": "Hier verwaltet — Zone {zone}. Die Einträge dieser Domain werden automatisch geschrieben.", + "none": "Kein verbundener Anbieter verwaltet die Zone dieser Domain. Du fügst die Einträge selbst hinzu.", + "unauthorized": "Ein gespeichertes Token wurde abgelehnt. Verbinde es neu und versuche es erneut.", + "unavailable": "Der DNS-Anbieter war nicht erreichbar, daher ist das unbekannt — es bedeutet nicht, dass etwas falsch konfiguriert ist." + }, + "toast": { + "loadFailed": "DNS-Anbieter konnten nicht geladen werden", + "needBoth": "Bezeichnung und API-Token eingeben", + "connected": "{name} verbunden", + "connectFailed": "Anbieter konnte nicht verbunden werden", + "disconnected": "{name} getrennt", + "disconnectFailed": "Anbieter konnte nicht getrennt werden", + "checkFailed": "Domain konnte nicht geprüft werden" + } } } diff --git a/apps/dashboard/src/i18n/locales/en/settings.json b/apps/dashboard/src/i18n/locales/en/settings.json index 74e6a2e62..09b5b9921 100644 --- a/apps/dashboard/src/i18n/locales/en/settings.json +++ b/apps/dashboard/src/i18n/locales/en/settings.json @@ -42,6 +42,7 @@ "cloud": "Cloud", "defaults": "Defaults", "settings": "Settings", + "dns": "DNS", "notifications": "Notifications", "team": "Team", "members": "Members", @@ -73,6 +74,7 @@ "team": "Team", "notifications": "Notifications", "email": "Email", + "dns": "DNS", "systemSender": "System sender", "audit": "Audit log", "cloud": "Cloud", @@ -119,6 +121,42 @@ "disabled": "SMTP disabled", "toastTitle": "Email" }, + "dns": { + "title": "DNS providers", + "description": "Let Openship write your domains' DNS records for you", + "explainer": "With a provider connected, adding a custom domain writes its records straight away, so the domain verifies and gets its certificate without you pasting anything.", + "explainerOwnership": "Openship only ever removes records it created.", + "addProvider": "Connect a provider", + "connect": "Connect", + "namePlaceholder": "Label, e.g. Cloudflare production", + "tokenPlaceholder": "Cloudflare API token", + "scopesHint": "Needs a token scoped to {scopes}.", + "createTokenLink": "Create one", + "noneConnected": "No DNS provider connected. Domains will show the records for you to add by hand.", + "badgeActive": "Active", + "badgeInvalid": "Needs attention", + "invalidHint": "The provider rejected this token. Reconnect it to resume automatic records.", + "metaLine": " · {provider} · last verified {verified}", + "zoneCheck": { + "title": "Check a domain", + "description": "See whether a connected provider manages a hostname's zone before you add it.", + "placeholder": "app.example.com", + "check": "Check", + "matched": "Managed here — zone {zone}. This domain's records will be written automatically.", + "none": "No connected provider manages this domain's zone. You'll add its records by hand.", + "unauthorized": "A stored token was rejected. Reconnect it and try again.", + "unavailable": "Couldn't reach the DNS provider, so this is unknown — it doesn't mean anything is misconfigured." + }, + "toast": { + "loadFailed": "Couldn't load DNS providers", + "needBoth": "Enter a label and an API token", + "connected": "Connected {name}", + "connectFailed": "Couldn't connect the provider", + "disconnected": "Disconnected {name}", + "disconnectFailed": "Couldn't disconnect the provider", + "checkFailed": "Couldn't check that domain" + } + }, "github": { "title": "GitHub", "titleWithLogin": "GitHub · @{login}", diff --git a/apps/dashboard/src/i18n/locales/es/settings.json b/apps/dashboard/src/i18n/locales/es/settings.json index 00757d2d5..cfc51c3f3 100644 --- a/apps/dashboard/src/i18n/locales/es/settings.json +++ b/apps/dashboard/src/i18n/locales/es/settings.json @@ -21,6 +21,7 @@ "cloud": "Cloud", "defaults": "Valores predeterminados", "settings": "Ajustes", + "dns": "DNS", "notifications": "Notificaciones", "team": "Equipo", "members": "Miembros", @@ -51,6 +52,7 @@ "mcp": "MCP", "team": "Equipo", "notifications": "Notificaciones", + "dns": "DNS", "audit": "Registro de auditoría", "cloud": "Cloud", "infrastructure": "Infraestructura", @@ -775,5 +777,41 @@ "setTo": "Estrategia de enrutamiento establecida en {mode}", "failed": "No se pudo actualizar la estrategia de enrutamiento" } + }, + "dns": { + "title": "Proveedores de DNS", + "description": "Deja que Openship escriba los registros DNS de tus dominios", + "explainer": "Con un proveedor conectado, al añadir un dominio propio sus registros se escriben al instante, así que el dominio se verifica y obtiene su certificado sin que pegues nada.", + "explainerOwnership": "Openship solo elimina registros que él mismo creó.", + "addProvider": "Conectar un proveedor", + "connect": "Conectar", + "namePlaceholder": "Etiqueta, p. ej. Cloudflare producción", + "tokenPlaceholder": "Token de API de Cloudflare", + "scopesHint": "Necesita un token con los permisos {scopes}.", + "createTokenLink": "Crear uno", + "noneConnected": "Ningún proveedor de DNS conectado. Los dominios mostrarán los registros para que los añadas a mano.", + "badgeActive": "Activo", + "badgeInvalid": "Requiere atención", + "invalidHint": "El proveedor rechazó este token. Vuelve a conectarlo para reanudar los registros automáticos.", + "metaLine": " · {provider} · verificado por última vez {verified}", + "zoneCheck": { + "title": "Comprobar un dominio", + "description": "Comprueba si un proveedor conectado gestiona la zona de un host antes de añadirlo.", + "placeholder": "app.example.com", + "check": "Comprobar", + "matched": "Gestionado aquí — zona {zone}. Los registros de este dominio se escribirán automáticamente.", + "none": "Ningún proveedor conectado gestiona la zona de este dominio. Añadirás sus registros a mano.", + "unauthorized": "Se rechazó un token guardado. Vuelve a conectarlo e inténtalo de nuevo.", + "unavailable": "No se pudo contactar con el proveedor de DNS, así que esto es desconocido; no significa que algo esté mal configurado." + }, + "toast": { + "loadFailed": "No se pudieron cargar los proveedores de DNS", + "needBoth": "Introduce una etiqueta y un token de API", + "connected": "{name} conectado", + "connectFailed": "No se pudo conectar el proveedor", + "disconnected": "{name} desconectado", + "disconnectFailed": "No se pudo desconectar el proveedor", + "checkFailed": "No se pudo comprobar ese dominio" + } } } diff --git a/apps/dashboard/src/i18n/locales/fr/settings.json b/apps/dashboard/src/i18n/locales/fr/settings.json index 123977950..7fa456b6c 100644 --- a/apps/dashboard/src/i18n/locales/fr/settings.json +++ b/apps/dashboard/src/i18n/locales/fr/settings.json @@ -21,6 +21,7 @@ "cloud": "Cloud", "defaults": "Valeurs par défaut", "settings": "Paramètres", + "dns": "DNS", "notifications": "Notifications", "team": "Équipe", "members": "Membres", @@ -51,6 +52,7 @@ "mcp": "MCP", "team": "Équipe", "notifications": "Notifications", + "dns": "DNS", "audit": "Journal d'audit", "cloud": "Cloud", "infrastructure": "Infrastructure", @@ -799,5 +801,41 @@ "setTo": "Stratégie de routage définie sur {mode}", "failed": "Échec de la mise à jour de la stratégie de routage" } + }, + "dns": { + "title": "Fournisseurs DNS", + "description": "Laissez Openship écrire les enregistrements DNS de vos domaines", + "explainer": "Avec un fournisseur connecté, l'ajout d'un domaine personnalisé écrit ses enregistrements immédiatement : le domaine est vérifié et obtient son certificat sans que vous ayez à coller quoi que ce soit.", + "explainerOwnership": "Openship ne supprime que les enregistrements qu'il a créés.", + "addProvider": "Connecter un fournisseur", + "connect": "Connecter", + "namePlaceholder": "Libellé, par ex. Cloudflare production", + "tokenPlaceholder": "Jeton d'API Cloudflare", + "scopesHint": "Nécessite un jeton avec les autorisations {scopes}.", + "createTokenLink": "En créer un", + "noneConnected": "Aucun fournisseur DNS connecté. Les domaines afficheront les enregistrements à ajouter vous-même.", + "badgeActive": "Actif", + "badgeInvalid": "Nécessite une action", + "invalidHint": "Le fournisseur a rejeté ce jeton. Reconnectez-le pour reprendre les enregistrements automatiques.", + "metaLine": " · {provider} · dernière vérification {verified}", + "zoneCheck": { + "title": "Vérifier un domaine", + "description": "Voyez si un fournisseur connecté gère la zone d'un nom d'hôte avant de l'ajouter.", + "placeholder": "app.example.com", + "check": "Vérifier", + "matched": "Géré ici — zone {zone}. Les enregistrements de ce domaine seront écrits automatiquement.", + "none": "Aucun fournisseur connecté ne gère la zone de ce domaine. Vous ajouterez ses enregistrements vous-même.", + "unauthorized": "Un jeton enregistré a été rejeté. Reconnectez-le puis réessayez.", + "unavailable": "Le fournisseur DNS était injoignable, donc le résultat est inconnu — cela ne signifie pas qu'une configuration est erronée." + }, + "toast": { + "loadFailed": "Impossible de charger les fournisseurs DNS", + "needBoth": "Saisissez un libellé et un jeton d'API", + "connected": "{name} connecté", + "connectFailed": "Impossible de connecter le fournisseur", + "disconnected": "{name} déconnecté", + "disconnectFailed": "Impossible de déconnecter le fournisseur", + "checkFailed": "Impossible de vérifier ce domaine" + } } } diff --git a/apps/dashboard/src/i18n/locales/ja/settings.json b/apps/dashboard/src/i18n/locales/ja/settings.json index 61aa1853b..5b6353c96 100644 --- a/apps/dashboard/src/i18n/locales/ja/settings.json +++ b/apps/dashboard/src/i18n/locales/ja/settings.json @@ -21,6 +21,7 @@ "cloud": "クラウド", "defaults": "デフォルト", "settings": "設定", + "dns": "DNS", "notifications": "通知", "team": "チーム", "members": "メンバー", @@ -51,6 +52,7 @@ "mcp": "MCP", "team": "チーム", "notifications": "通知", + "dns": "DNS", "audit": "監査ログ", "cloud": "クラウド", "infrastructure": "インフラ", @@ -775,5 +777,41 @@ "setTo": "ルーティング方式を {mode} に設定しました", "failed": "ルーティング方式の更新に失敗しました" } + }, + "dns": { + "title": "DNS プロバイダー", + "description": "ドメインの DNS レコードを Openship に書き込ませます", + "explainer": "プロバイダーを接続しておくと、カスタムドメインを追加した時点でレコードが書き込まれます。何も貼り付けなくてもドメインが検証され、証明書が発行されます。", + "explainerOwnership": "Openship が削除するのは、自身が作成したレコードだけです。", + "addProvider": "プロバイダーを接続", + "connect": "接続", + "namePlaceholder": "ラベル(例: Cloudflare 本番)", + "tokenPlaceholder": "Cloudflare API トークン", + "scopesHint": "{scopes} の権限を持つトークンが必要です。", + "createTokenLink": "作成する", + "noneConnected": "接続済みの DNS プロバイダーはありません。ドメインには手動で追加するレコードが表示されます。", + "badgeActive": "有効", + "badgeInvalid": "対応が必要", + "invalidHint": "プロバイダーがこのトークンを拒否しました。自動レコードを再開するには再接続してください。", + "metaLine": " · {provider} · 最終確認 {verified}", + "zoneCheck": { + "title": "ドメインを確認", + "description": "追加する前に、接続済みのプロバイダーがそのホスト名のゾーンを管理しているか確認します。", + "placeholder": "app.example.com", + "check": "確認", + "matched": "ここで管理されています — ゾーン {zone}。このドメインのレコードは自動で書き込まれます。", + "none": "このドメインのゾーンを管理している接続済みプロバイダーはありません。レコードは手動で追加します。", + "unauthorized": "保存されているトークンが拒否されました。再接続してからもう一度お試しください。", + "unavailable": "DNS プロバイダーに接続できなかったため不明です。設定に問題があるという意味ではありません。" + }, + "toast": { + "loadFailed": "DNS プロバイダーを読み込めませんでした", + "needBoth": "ラベルと API トークンを入力してください", + "connected": "{name} を接続しました", + "connectFailed": "プロバイダーを接続できませんでした", + "disconnected": "{name} を切断しました", + "disconnectFailed": "プロバイダーを切断できませんでした", + "checkFailed": "そのドメインを確認できませんでした" + } } } diff --git a/apps/dashboard/src/i18n/locales/pt/settings.json b/apps/dashboard/src/i18n/locales/pt/settings.json index fcbebdfdd..b54647da3 100644 --- a/apps/dashboard/src/i18n/locales/pt/settings.json +++ b/apps/dashboard/src/i18n/locales/pt/settings.json @@ -21,6 +21,7 @@ "cloud": "Cloud", "defaults": "Padrões", "settings": "Configurações", + "dns": "DNS", "notifications": "Notificações", "team": "Equipe", "members": "Membros", @@ -51,6 +52,7 @@ "mcp": "MCP", "team": "Equipe", "notifications": "Notificações", + "dns": "DNS", "audit": "Registro de auditoria", "cloud": "Cloud", "infrastructure": "Infraestrutura", @@ -775,5 +777,41 @@ "setTo": "Estratégia de roteamento definida como {mode}", "failed": "Falha ao atualizar a estratégia de roteamento" } + }, + "dns": { + "title": "Provedores de DNS", + "description": "Deixe o Openship escrever os registos DNS dos seus domínios", + "explainer": "Com um provedor ligado, adicionar um domínio próprio escreve os registos de imediato, pelo que o domínio é verificado e recebe o certificado sem que cole nada.", + "explainerOwnership": "O Openship só remove registos que ele próprio criou.", + "addProvider": "Ligar um provedor", + "connect": "Ligar", + "namePlaceholder": "Etiqueta, p. ex. Cloudflare produção", + "tokenPlaceholder": "Token de API da Cloudflare", + "scopesHint": "Precisa de um token com as permissões {scopes}.", + "createTokenLink": "Criar um", + "noneConnected": "Nenhum provedor de DNS ligado. Os domínios mostrarão os registos para adicionar manualmente.", + "badgeActive": "Ativo", + "badgeInvalid": "Requer atenção", + "invalidHint": "O provedor rejeitou este token. Ligue-o novamente para retomar os registos automáticos.", + "metaLine": " · {provider} · verificado por último {verified}", + "zoneCheck": { + "title": "Verificar um domínio", + "description": "Veja se um provedor ligado gere a zona de um nome de host antes de o adicionar.", + "placeholder": "app.example.com", + "check": "Verificar", + "matched": "Gerido aqui — zona {zone}. Os registos deste domínio serão escritos automaticamente.", + "none": "Nenhum provedor ligado gere a zona deste domínio. Vai adicionar os registos manualmente.", + "unauthorized": "Um token guardado foi rejeitado. Ligue-o novamente e tente outra vez.", + "unavailable": "Não foi possível contactar o provedor de DNS, por isso isto é desconhecido — não significa que algo esteja mal configurado." + }, + "toast": { + "loadFailed": "Não foi possível carregar os provedores de DNS", + "needBoth": "Introduza uma etiqueta e um token de API", + "connected": "{name} ligado", + "connectFailed": "Não foi possível ligar o provedor", + "disconnected": "{name} desligado", + "disconnectFailed": "Não foi possível desligar o provedor", + "checkFailed": "Não foi possível verificar esse domínio" + } } } diff --git a/apps/dashboard/src/i18n/locales/tr/settings.json b/apps/dashboard/src/i18n/locales/tr/settings.json index ed9ecf0f1..2959d2448 100644 --- a/apps/dashboard/src/i18n/locales/tr/settings.json +++ b/apps/dashboard/src/i18n/locales/tr/settings.json @@ -21,6 +21,7 @@ "cloud": "Bulut", "defaults": "Varsayılanlar", "settings": "Ayarlar", + "dns": "DNS", "notifications": "Bildirimler", "team": "Ekip", "members": "Üyeler", @@ -52,6 +53,7 @@ "team": "Ekip", "notifications": "Bildirimler", "email": "E-posta", + "dns": "DNS", "audit": "Denetim günlüğü", "cloud": "Bulut", "infrastructure": "Altyapı", @@ -97,6 +99,42 @@ "disabled": "SMTP devre dışı bırakıldı", "toastTitle": "E-posta" }, + "dns": { + "title": "DNS sağlayıcıları", + "description": "Alan adlarınızın DNS kayıtlarını Openship yazsın", + "explainer": "Bir sağlayıcı bağlıyken, özel bir alan adı eklediğinizde kayıtları hemen yazılır; böylece alan adı hiçbir şey yapıştırmanıza gerek kalmadan doğrulanır ve sertifikasını alır.", + "explainerOwnership": "Openship yalnızca kendi oluşturduğu kayıtları siler.", + "addProvider": "Sağlayıcı bağla", + "connect": "Bağla", + "namePlaceholder": "Etiket, örn. Cloudflare üretim", + "tokenPlaceholder": "Cloudflare API belirteci", + "scopesHint": "{scopes} izinlerine sahip bir belirteç gerekir.", + "createTokenLink": "Bir tane oluştur", + "noneConnected": "Bağlı DNS sağlayıcısı yok. Alan adları, elle eklemeniz için kayıtları gösterecek.", + "badgeActive": "Etkin", + "badgeInvalid": "İlgilenilmesi gerekiyor", + "invalidHint": "Sağlayıcı bu belirteci reddetti. Otomatik kayıtları sürdürmek için yeniden bağlayın.", + "metaLine": " · {provider} · son doğrulama {verified}", + "zoneCheck": { + "title": "Bir alan adını denetle", + "description": "Eklemeden önce bağlı bir sağlayıcının o ana makine adının bölgesini yönetip yönetmediğine bakın.", + "placeholder": "app.example.com", + "check": "Denetle", + "matched": "Burada yönetiliyor — {zone} bölgesi. Bu alan adının kayıtları otomatik yazılacak.", + "none": "Bağlı hiçbir sağlayıcı bu alan adının bölgesini yönetmiyor. Kayıtlarını elle ekleyeceksiniz.", + "unauthorized": "Kayıtlı bir belirteç reddedildi. Yeniden bağlayıp tekrar deneyin.", + "unavailable": "DNS sağlayıcısına ulaşılamadı, bu yüzden sonuç bilinmiyor — bir şeyin yanlış yapılandırıldığı anlamına gelmez." + }, + "toast": { + "loadFailed": "DNS sağlayıcıları yüklenemedi", + "needBoth": "Bir etiket ve bir API belirteci girin", + "connected": "{name} bağlandı", + "connectFailed": "Sağlayıcı bağlanamadı", + "disconnected": "{name} bağlantısı kesildi", + "disconnectFailed": "Sağlayıcı bağlantısı kesilemedi", + "checkFailed": "Bu alan adı denetlenemedi" + } + }, "github": { "title": "GitHub", "titleWithLogin": "GitHub · @{login}", diff --git a/apps/dashboard/src/i18n/locales/zh/settings.json b/apps/dashboard/src/i18n/locales/zh/settings.json index 675019c2f..90c7ab40e 100644 --- a/apps/dashboard/src/i18n/locales/zh/settings.json +++ b/apps/dashboard/src/i18n/locales/zh/settings.json @@ -21,6 +21,7 @@ "cloud": "云", "defaults": "默认设置", "settings": "设置", + "dns": "DNS", "notifications": "通知", "team": "团队", "members": "成员", @@ -51,6 +52,7 @@ "mcp": "MCP", "team": "团队", "notifications": "通知", + "dns": "DNS", "audit": "审计日志", "cloud": "云", "infrastructure": "基础设施", @@ -775,5 +777,41 @@ "setTo": "路由策略已设为 {mode}", "failed": "更新路由策略失败" } + }, + "dns": { + "title": "DNS 提供商", + "description": "让 Openship 为你的域名写入 DNS 记录", + "explainer": "连接提供商后,添加自定义域名时会立即写入其记录,域名无需你粘贴任何内容即可完成验证并获得证书。", + "explainerOwnership": "Openship 只会删除它自己创建的记录。", + "addProvider": "连接提供商", + "connect": "连接", + "namePlaceholder": "标签,例如 Cloudflare 生产", + "tokenPlaceholder": "Cloudflare API 令牌", + "scopesHint": "需要具有 {scopes} 权限的令牌。", + "createTokenLink": "创建一个", + "noneConnected": "尚未连接 DNS 提供商。域名将显示需要你手动添加的记录。", + "badgeActive": "有效", + "badgeInvalid": "需要处理", + "invalidHint": "提供商拒绝了此令牌。重新连接即可恢复自动写入记录。", + "metaLine": " · {provider} · 最后验证 {verified}", + "zoneCheck": { + "title": "检查域名", + "description": "在添加之前,查看已连接的提供商是否管理该主机名所属的区域。", + "placeholder": "app.example.com", + "check": "检查", + "matched": "由此处管理 — 区域 {zone}。该域名的记录将自动写入。", + "none": "没有已连接的提供商管理该域名所属的区域。你需要手动添加其记录。", + "unauthorized": "已保存的令牌被拒绝。请重新连接后再试。", + "unavailable": "无法连接 DNS 提供商,因此结果未知 — 这并不表示有任何配置错误。" + }, + "toast": { + "loadFailed": "无法加载 DNS 提供商", + "needBoth": "请输入标签和 API 令牌", + "connected": "已连接 {name}", + "connectFailed": "无法连接提供商", + "disconnected": "已断开 {name}", + "disconnectFailed": "无法断开提供商", + "checkFailed": "无法检查该域名" + } } } diff --git a/apps/dashboard/src/lib/api/dns.ts b/apps/dashboard/src/lib/api/dns.ts new file mode 100644 index 000000000..969072530 --- /dev/null +++ b/apps/dashboard/src/lib/api/dns.ts @@ -0,0 +1,70 @@ +import { api } from "./client"; +import { endpoints } from "./endpoints"; + +export interface DnsProviderDescriptor { + name: "cloudflare"; + displayName: string; + description: string; + /** The exact token scopes to grant, shown on the connect form. */ + requiredScopes: string[]; + /** Where to mint the token. */ + tokenUrl?: string; +} + +export interface SanitizedDnsCredential { + id: string; + organizationId: string; + provider: string; + name: string; + /** "active" | "invalid" — "invalid" means the provider rejected the token. */ + status: string; + /** Always the constant mask. No endpoint returns the token or a prefix of it. */ + tokenMasked: string; + lastVerifiedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AddDnsCredentialInput { + provider: "cloudflare"; + name: string; + apiToken: string; +} + +/** + * Four outcomes, not two. "We asked and nobody hosts this zone" and "we could + * not ask" need different words — telling an operator their domain isn't managed + * because Cloudflare rate-limited us sends them to fix a token that is fine. + */ +export interface VerifyZoneResult { + matched: boolean; + status: "matched" | "none" | "unauthorized" | "unavailable"; + provider?: string; + credentialId?: string; + zoneName?: string; + zoneId?: string; + message?: string; +} + +export const dnsApi = { + /** List supported DNS providers and the token scopes they need. */ + listProviders: () => api.get<{ data: DnsProviderDescriptor[] }>(endpoints.dns.providers), + + /** List connected DNS credentials for the active organization. */ + listCredentials: () => api.get<{ data: SanitizedDnsCredential[] }>(endpoints.dns.credentials), + + /** Get a single connected DNS credential. */ + getCredential: (id: string) => + api.get<{ data: SanitizedDnsCredential }>(endpoints.dns.credentialById(id)), + + /** Connect a credential. The token is verified against the provider first. */ + addCredential: (input: AddDnsCredentialInput) => + api.post<{ data: SanitizedDnsCredential }>(endpoints.dns.credentials, input), + + /** Disconnect a credential. Records already written are left in place. */ + removeCredential: (id: string) => api.delete(endpoints.dns.credentialById(id)), + + /** Check whether a connected provider manages a hostname's zone. */ + verifyZone: (hostname: string) => + api.post(endpoints.dns.verifyZone, { hostname }), +}; diff --git a/apps/dashboard/src/lib/api/endpoints.ts b/apps/dashboard/src/lib/api/endpoints.ts index 354d5e285..c5a80458f 100644 --- a/apps/dashboard/src/lib/api/endpoints.ts +++ b/apps/dashboard/src/lib/api/endpoints.ts @@ -156,6 +156,16 @@ export const endpoints = { records: (id: string) => `domains/${encodeURIComponent(id)}/records`, }, + /* ---------------------------------------------------------------- */ + /* DNS (Provider credentials & zones) */ + /* ---------------------------------------------------------------- */ + dns: { + providers: "dns/providers", + credentials: "dns/credentials", + credentialById: (id: string) => `dns/credentials/${encodeURIComponent(id)}`, + verifyZone: "dns/verify-zone", + }, + /* ---------------------------------------------------------------- */ /* Jobs (self-hosted scheduled tasks) */ /* ---------------------------------------------------------------- */ diff --git a/apps/dashboard/src/lib/api/index.ts b/apps/dashboard/src/lib/api/index.ts index 64330b2b7..748ecd6c8 100644 --- a/apps/dashboard/src/lib/api/index.ts +++ b/apps/dashboard/src/lib/api/index.ts @@ -40,6 +40,7 @@ export type { AppCatalogEntry, AppCatalogField, InstallAppResult } from "./apps" export { deployApi } from "./deploy"; export type { RestorePlanUI } from "./deploy"; export { domainsApi } from "./domains"; +export { dnsApi, type DnsProviderDescriptor, type SanitizedDnsCredential, type AddDnsCredentialInput, type VerifyZoneResult } from "./dns"; export { jobsApi, type JobView, diff --git a/apps/web/content/docs/api/dns.mdx b/apps/web/content/docs/api/dns.mdx new file mode 100644 index 000000000..0321fe298 --- /dev/null +++ b/apps/web/content/docs/api/dns.mdx @@ -0,0 +1,143 @@ +--- +title: DNS API +description: Connect a DNS provider token so Openship writes your domains' records itself, instead of printing them for you to paste. +--- + +import { TypeTable } from 'fumadocs-ui/components/type-table'; + +Adding a custom domain normally ends with Openship telling you which records to create and waiting for you +to go and create them. Connect a DNS provider here and it writes them itself: the domain verifies on its own +and gets its certificate without you leaving the page. In the dashboard this is **Settings → DNS**. + +Cloudflare is the only provider today. The provider list is served by the API rather than hard-coded in the +dashboard, so `GET /api/dns/providers` is the authoritative answer to "what can I connect?". + + +All paths are relative to your instance, under **`/api`** — e.g. `https://your-host/api/dns/credentials`. +Send a personal access token as a bearer header (`Authorization: Bearer `), created with +[`openship token create`](/docs/cli/access). The dashboard uses your session cookie instead. See the +[API overview](/docs/api) for the full auth model. + + +## Endpoints + +| Method & path | Permission | What it does | +|---|---|---| +| `GET /api/dns/providers` | `settings:read` | List supported providers and the token scopes each needs. | +| `GET /api/dns/credentials` | `settings:read` | List connected credentials for the organization. | +| `GET /api/dns/credentials/:id` | `settings:read` | Read one connected credential. | +| `POST /api/dns/credentials` | `settings:admin` | Connect a credential. The token is verified before it is stored. | +| `DELETE /api/dns/credentials/:id` | `settings:admin` | Disconnect a credential. Records already written are left alone. | +| `POST /api/dns/verify-zone` | `settings:read` | Check whether a connected provider manages a hostname's zone. Creates and changes nothing. | + +## Connect a provider + +``` +POST /api/dns/credentials +``` + + + +```bash +curl -X POST https://your-host/api/dns/credentials \ + -H "Authorization: Bearer $OPENSHIP_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"provider":"cloudflare","name":"Cloudflare production","apiToken":"cf-..."}' +``` + +For Cloudflare, create a token at +[dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) scoped to +**Zone:Zone:Read** and **Zone:DNS:Edit**, limited to the zones you want Openship to manage. + + +It is encrypted at rest (AES-256-GCM, key derived from `BETTER_AUTH_SECRET`) and no endpoint returns it — +not in full and not as a prefix. `tokenMasked` is a fixed `••••••••`. There is no "reveal"; to change a +token, disconnect the credential and connect a new one. + +Rotating `BETTER_AUTH_SECRET` makes stored tokens undecryptable. The credential then reports +`status: "invalid"` the next time Openship tries to use it, and you re-connect it. + + +## Check a zone + +``` +POST /api/dns/verify-zone +``` + + + +The response separates four outcomes, because they call for different actions: + +| `status` | `matched` | Meaning | +|---|---|---| +| `matched` | `true` | A connected provider hosts this zone; records for this domain will be written automatically. | +| `none` | `false` | We asked, and no connected provider hosts it. You add the records yourself. | +| `unauthorized` | `false` | A stored token was rejected. Re-connect it — `credentialId` says which. | +| `unavailable` | `false` | The provider could not be reached (rate limit, outage). Unknown, **not** a sign of misconfiguration. | + +```bash +curl -X POST https://your-host/api/dns/verify-zone \ + -H "Authorization: Bearer $OPENSHIP_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"hostname":"app.example.com"}' +``` + +## What happens when you add a domain + +With a provider connected, `POST /api/domains` writes the records it would otherwise have only shown you, +and the response carries an extra `autoDns` object reporting what happened per record: + +```json +{ + "data": { "id": "dom_123", "hostname": "app.example.com" }, + "records": { "mode": "selfhosted", "records": [{ "type": "A", "name": "app.example.com", "value": "203.0.113.10" }] }, + "autoDns": { + "provisioned": true, + "records": [{ "name": "app.example.com", "type": "A", "outcome": "applied" }] + } +} +``` + +`autoDns` is absent when no connected provider manages the zone — that absence is the signal to show the +records for the operator to add by hand. When it is present, `provisioned` is `true` only if every record +landed; a partial write reports `provisioned: false` with a `reason` and the per-record `outcome`, so +"we wrote them" and "we wrote some of them" are never the same answer. + +No verification is triggered at that moment. The records are seconds old, a resolver may still be holding a +negative answer for the name, and a failed check burns one of Let's Encrypt's per-hostname validation +failures. The domain stays pending and the `domains:verify-pending` sweep picks it up after its +ten-minute grace window. + +## Removing records + +Disconnecting a credential does not touch DNS. Removing a *domain* does — but only records Openship created, +identified by the `Managed by Openship` comment it writes on every record. + + +For an apex domain the record name *is* the zone apex, where your `MX`, SPF `TXT` and `CAA` records also +live. Deleting "every record at this name" would take your mail with it, so Openship deletes only what it +can prove it wrote. + +Connecting a domain does repoint an existing record at that name — that is what connecting it means — but +repointing is not adoption: Openship leaves such a record's comment untouched, so it never carries the +`Managed by Openship` marker and removing the domain later will not delete it. + + +## Errors + +| Code | Status | Meaning | +|---|---|---| +| `DNS_UNKNOWN_PROVIDER` | 400 | `provider` is not a name from `GET /api/dns/providers`. | +| `DNS_PROVIDER_NOT_READY` | 400 | The provider rejected the token during the pre-store check. | +| `DNS_RECORD_CONFLICT` | 409 | Several records already answer for that name and type and none are Openship's — a round-robin or multi-host set it will not rewrite. | +| `DNS_API_ERROR` | 502 | The provider's API failed. The upstream status is in the message. | diff --git a/apps/web/content/docs/api/meta.json b/apps/web/content/docs/api/meta.json index bc522e9ea..2469d224f 100644 --- a/apps/web/content/docs/api/meta.json +++ b/apps/web/content/docs/api/meta.json @@ -8,6 +8,7 @@ "services", "apps", "domains", + "dns", "github", "analytics", "---Access & org---", diff --git a/packages/core/src/audit-taxonomy.ts b/packages/core/src/audit-taxonomy.ts index af4f42eb1..c0eb26977 100644 --- a/packages/core/src/audit-taxonomy.ts +++ b/packages/core/src/audit-taxonomy.ts @@ -286,6 +286,22 @@ export const AUDIT_EVENTS: Record = { tone: "danger", description: "Automatic renewal failed — the certificate will expire unless this is fixed.", }, + "dns_credential.connected": { + category: "domains", + action: "connected the DNS provider", + label: "DNS provider connected", + tone: "info", + description: + "An API token that lets Openship write this organization's DNS records was stored. The token itself is never recorded.", + }, + "dns_credential.disconnected": { + category: "domains", + action: "disconnected the DNS provider", + label: "DNS provider disconnected", + tone: "warning", + description: + "Openship can no longer write DNS records for domains in that provider's zones; records already written are left in place.", + }, "domain:write": { category: "domains", action: "changed the domain", @@ -838,6 +854,7 @@ export const AUDIT_RESOURCE_LABELS: Record = { container: "a container", deployment: "a deployment", domain: "a domain", + dns_credential: "a DNS provider", server: "a server", mail_server: "the mail server", job: "a job", diff --git a/packages/db/drizzle/0103_dns_credential.sql b/packages/db/drizzle/0103_dns_credential.sql new file mode 100644 index 000000000..bca350fc3 --- /dev/null +++ b/packages/db/drizzle/0103_dns_credential.sql @@ -0,0 +1,41 @@ +-- DNS provider credentials — the token that lets Openship write a domain's records. +-- +-- Adding a custom domain has always ended the same way: Openship computes the exact +-- A/CNAME + `_openship-challenge` TXT records and then asks the operator to go paste +-- them somewhere else. Every minute between "added" and "pasted" is a domain that +-- doesn't resolve, can't pass ACME, and looks broken. With a scoped provider token we +-- write those records ourselves and the domain verifies on its own. +-- +-- The token is the sensitive part: Zone:Read + DNS:Edit can rewrite every record in +-- every zone it can see, so it is stored ONLY as an `enc1:` AES-256-GCM envelope +-- (`encryptSecretField`) and is never returned by the API — not in full, not as a +-- prefix. `status` exists so a token that gets revoked upstream becomes visible in the +-- UI instead of silently turning auto-provisioning off forever. +CREATE TABLE IF NOT EXISTS "dns_credential" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL REFERENCES "organization"("id") ON DELETE CASCADE, + -- Provider name ("cloudflare"). Text, not an enum: adding a provider should be a + -- registry entry, not a migration. + "provider" text NOT NULL, + -- Operator-facing label, e.g. "Cloudflare production". + "name" text NOT NULL, + "api_token_enc" text NOT NULL, + -- active | invalid. Flipped to 'invalid' by the provisioning path when the provider + -- answers 401/403, which is the only moment we learn a stored token stopped working. + "status" text NOT NULL DEFAULT 'active', + "last_verified_at" timestamp, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now() +); +--> statement-breakpoint +-- Zone resolution runs on EVERY domain add and remove and asks one question: "this +-- org's active credentials". Leading org column means the plain org-wide listing rides +-- the same index as a prefix, so this is the only one the table needs. +CREATE INDEX IF NOT EXISTS "idx_dns_credential_org_status" + ON "dns_credential" ("organization_id", "status"); +--> statement-breakpoint +-- One label per provider per org. A second "Cloudflare production" is a double-submit, +-- and two credentials that resolve the same zone makes "which token wrote this record" +-- unanswerable at cleanup time. +CREATE UNIQUE INDEX IF NOT EXISTS "uq_dns_credential_org_provider_name" + ON "dns_credential" ("organization_id", "provider", "name"); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 2c52ee56d..b178e1bb0 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -722,6 +722,13 @@ "when": 1787782907325, "tag": "0102_project_workload_axes", "breakpoints": true + }, + { + "idx": 103, + "version": "7", + "when": 1787869307325, + "tag": "0103_dns_credential", + "breakpoints": true } ] } diff --git a/packages/db/src/dump.ts b/packages/db/src/dump.ts index 2cf90a93b..b6db92dc2 100644 --- a/packages/db/src/dump.ts +++ b/packages/db/src/dump.ts @@ -350,6 +350,22 @@ const TABLES: ReadonlyArray = [ hasOrganizationId: true, }, + // DNS + // Carried by an org transfer so the receiving instance keeps writing that org's + // domain records instead of silently reverting them to manual. Paired with its + // ENCRYPTED_COLUMNS spec below: catalogued without one, the restore-side null + // pass skips api_token_enc and a crafted ingest could plant ciphertext for a + // zone the tenant does not own. + { + sqlName: "dns_credential", + table: schema.dnsCredential, + scopes: [ + { in: "instance", via: "all-rows" }, + { in: "organization", via: "organizationId" }, + ], + hasOrganizationId: true, + }, + // Notifications { sqlName: "notification_channel", @@ -464,6 +480,7 @@ export const ENCRYPTED_COLUMNS: ReadonlyArray = [ { table: "backup_destination", column: "sftpPasswordEnc" }, { table: "backup_destination", column: "sftpPrivateKeyEnc" }, { table: "backup_destination", column: "sftpKeyPassphraseEnc" }, + { table: "dns_credential", column: "apiTokenEnc" }, { table: "servers", column: "sshPassword" }, { table: "servers", column: "sshPrivateKey" }, { table: "servers", column: "sshKeyPassphrase" }, diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f827903c8..85270b77f 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -88,6 +88,8 @@ export { type NewBuildSession, type Domain, type NewDomain, + type DnsCredential, + type NewDnsCredential, type Service, type NewService, type ServiceDeployment, diff --git a/packages/db/src/repos/dns-credential.repo.ts b/packages/db/src/repos/dns-credential.repo.ts new file mode 100644 index 000000000..48eb67993 --- /dev/null +++ b/packages/db/src/repos/dns-credential.repo.ts @@ -0,0 +1,131 @@ +import { and, eq } from "drizzle-orm"; +import { generateId } from "@repo/core"; +import type { Database } from "../client"; +import { dnsCredential } from "../schema"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type DnsCredential = typeof dnsCredential.$inferSelect; +export type NewDnsCredential = typeof dnsCredential.$inferInsert; + +// ─── Repository ────────────────────────────────────────────────────────────── + +export function createDnsCredentialRepo(db: Database) { + return { + /** List all DNS credentials for an organization. */ + async listByOrg(organizationId: string): Promise { + return db.query.dnsCredential.findMany({ + where: eq(dnsCredential.organizationId, organizationId), + }); + }, + + /** Find a specific DNS credential by org and ID. */ + async findById( + organizationId: string, + id: string, + ): Promise { + return db.query.dnsCredential.findFirst({ + where: and( + eq(dnsCredential.organizationId, organizationId), + eq(dnsCredential.id, id), + ), + }); + }, + + /** Find one credential by its operator-facing label (the unique key). */ + async findByName( + organizationId: string, + provider: string, + name: string, + ): Promise { + return db.query.dnsCredential.findFirst({ + where: and( + eq(dnsCredential.organizationId, organizationId), + eq(dnsCredential.provider, provider), + eq(dnsCredential.name, name), + ), + }); + }, + + /** Find active DNS credentials for an org, optionally filtered by provider. */ + async findActiveByOrg( + organizationId: string, + provider?: string, + ): Promise { + const conditions = [ + eq(dnsCredential.organizationId, organizationId), + eq(dnsCredential.status, "active"), + ]; + if (provider) { + conditions.push(eq(dnsCredential.provider, provider)); + } + return db.query.dnsCredential.findMany({ + where: and(...conditions), + }); + }, + + /** Create a new DNS credential row. */ + async create(data: { + id?: string; + organizationId: string; + provider: string; + name: string; + apiTokenEnc: string; + status?: string; + lastVerifiedAt?: Date | null; + }): Promise { + const [row] = await db + .insert(dnsCredential) + .values({ + id: data.id ?? generateId("dns"), + organizationId: data.organizationId, + provider: data.provider, + name: data.name, + apiTokenEnc: data.apiTokenEnc, + status: data.status ?? "active", + lastVerifiedAt: data.lastVerifiedAt ?? new Date(), + }) + .returning(); + return row!; + }, + + /** Update an existing DNS credential row. */ + async update( + organizationId: string, + id: string, + data: Partial<{ + name: string; + apiTokenEnc: string; + status: string; + lastVerifiedAt: Date | null; + }>, + ): Promise { + const [updated] = await db + .update(dnsCredential) + .set({ + ...data, + updatedAt: new Date(), + }) + .where( + and( + eq(dnsCredential.organizationId, organizationId), + eq(dnsCredential.id, id), + ), + ) + .returning(); + return updated; + }, + + /** Delete a DNS credential by org and ID. */ + async delete(organizationId: string, id: string): Promise { + await db + .delete(dnsCredential) + .where( + and( + eq(dnsCredential.organizationId, organizationId), + eq(dnsCredential.id, id), + ), + ); + }, + }; +} diff --git a/packages/db/src/repos/index.ts b/packages/db/src/repos/index.ts index 162a609c8..e036ad288 100644 --- a/packages/db/src/repos/index.ts +++ b/packages/db/src/repos/index.ts @@ -36,6 +36,11 @@ export { type NewBuildSession, } from "./deployment.repo"; export { createDomainRepo, type Domain, type NewDomain } from "./domain.repo"; +export { + createDnsCredentialRepo, + type DnsCredential, + type NewDnsCredential, +} from "./dns-credential.repo"; export { createRouteRuleRepo, type RouteRule, type NewRouteRule } from "./route-rule.repo"; export { createWebhookSourceRepo, type WebhookSource, type NewWebhookSource } from "./webhook-source.repo"; export { createIncomingWebhookRepo, type IncomingWebhook, type NewIncomingWebhook } from "./incoming-webhook.repo"; @@ -234,6 +239,7 @@ import { createProjectGroupRepo } from "./project-group.repo"; import { createProjectRepo } from "./project.repo"; import { createDeploymentRepo } from "./deployment.repo"; import { createDomainRepo } from "./domain.repo"; +import { createDnsCredentialRepo } from "./dns-credential.repo"; import { createRouteRuleRepo } from "./route-rule.repo"; import { createWebhookSourceRepo } from "./webhook-source.repo"; import { createIncomingWebhookRepo } from "./incoming-webhook.repo"; @@ -314,6 +320,7 @@ export const repos = { project: createProjectRepo(db), deployment: createDeploymentRepo(db), domain: createDomainRepo(db), + dnsCredential: createDnsCredentialRepo(db), routeRule: createRouteRuleRepo(db), webhookSource: createWebhookSourceRepo(db), incomingWebhook: createIncomingWebhookRepo(db), diff --git a/packages/db/src/schema/dns-credential.ts b/packages/db/src/schema/dns-credential.ts new file mode 100644 index 000000000..ee20e306f --- /dev/null +++ b/packages/db/src/schema/dns-credential.ts @@ -0,0 +1,56 @@ +import { pgTable, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { organization } from "./organization"; + +// ─── dns_credential ────────────────────────────────────────────────────────── + +/** + * Organization-scoped DNS provider credentials (Cloudflare today). + * + * Holding one of these lets Openship write the A/CNAME + `_openship-challenge` + * TXT records a custom domain needs, instead of printing them for the operator + * to paste. That is a lot of authority — the token can rewrite every record in + * every zone it can see — so: + * - the token is encrypted at rest with the standard `enc1:` AES-256-GCM + * envelope (`encryptSecretField`), decrypted only for the provider call; + * - the API returns a constant mask, never plaintext and never a prefix of it; + * - `status` is how a token that stopped working becomes visible — the + * provisioning path flips it to "invalid" on a 401/403 instead of failing + * silently forever. + */ +export const dnsCredential = pgTable( + "dns_credential", + { + id: text("id").primaryKey(), // "dns_..." + /** Organization that owns this credential. */ + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + + /** Provider name ("cloudflare"). Text, not an enum, so adding a provider is + * a registry entry rather than a migration. */ + provider: text("provider").notNull(), + /** Operator-facing label, e.g. "Cloudflare production". */ + name: text("name").notNull(), + /** Encrypted API token (`enc1:...`). Never leaves the process in plaintext. */ + apiTokenEnc: text("api_token_enc").notNull(), + /** "active" | "invalid" — "invalid" means the provider rejected the token. */ + status: text("status").notNull().default("active"), + /** Last time the provider accepted this token. */ + lastVerifiedAt: timestamp("last_verified_at"), + + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (table) => [ + // Zone resolution reads "this org's ACTIVE credentials" on every domain add + // and remove; the org-only listing rides the same index as a prefix. + index("idx_dns_credential_org_status").on(table.organizationId, table.status), + // One label per provider per org — a second "Cloudflare production" is a + // double-submit, not a second credential. + uniqueIndex("uq_dns_credential_org_provider_name").on( + table.organizationId, + table.provider, + table.name, + ), + ], +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index d07bcfeb2..c60812107 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -72,3 +72,4 @@ export { billingUsageSnapshot, } from "./billing"; export { customAppTemplate } from "./custom-app-template"; +export { dnsCredential } from "./dns-credential";