import { db } from 'officerdb/db'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { eq } from 'drizzle-orm'; import { proxyManagerDomains, proxyManagerDnsCredentials, type ProxyManagerDomain, } from '../db/schema'; import { checkDomain, normaliseDomain, looksLikeDomain, type DomainCheck } from './dns'; /** * Domains, and the DNS credentials that go with them. * * The secret rule is the same one `connection.ts` states and for the same reason: it never leaves this * module in plaintext except into the code that uses it. There is no route that returns a DNS key, at any * level, to anybody. A screen that wants to know whether one exists gets a boolean. */ const PURPOSE = 'proxy-manager'; export type DomainView = { id: number; domain: string; dnsProvider: string | null; pointsTo: string | null; resolvedIps: string[]; lastCheckedAt: string | null; /** Whether a credential exists for THIS domain's provider. Never the credential. */ hasCredential: boolean; /** `narrow` | `broad` | null. What was handed over, not what it is. */ credentialScope: string | null; }; const view = (row: ProxyManagerDomain, credentials: Map): DomainView => ({ id: row.id, domain: row.domain, dnsProvider: row.dnsProvider, pointsTo: row.pointsTo, resolvedIps: row.resolvedIps ?? [], lastCheckedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null, hasCredential: !!row.dnsProvider && credentials.has(row.dnsProvider), credentialScope: (row.dnsProvider && credentials.get(row.dnsProvider)) ?? null, }); /** Provider → scope, for every stored credential. The scope is safe to show; the secret is not. */ async function credentialScopes(): Promise> { const rows = await db .select({ provider: proxyManagerDnsCredentials.provider, scope: proxyManagerDnsCredentials.scope }) .from(proxyManagerDnsCredentials); return new Map(rows.map((row) => [row.provider, row.scope])); } export async function listDomains(): Promise { const [rows, scopes] = await Promise.all([ db.select().from(proxyManagerDomains).orderBy(proxyManagerDomains.domain), credentialScopes(), ]); return rows.map((row) => view(row, scopes)); } export class DomainError extends Error { constructor( message: string, readonly status: 400 | 404 | 409, ) { super(message); } } /** * Add a domain, checking it on the way in. * * The check is not optional and not deferred: a row with no provider and no verdict is a row somebody has * to remember to refresh, and the whole value of the list is that it says something. A domain that does * not resolve is still added — "nowhere" is a real and common state for a name bought ten minutes ago — * but it is added with that recorded rather than blank. */ export async function addDomain(input: string): Promise { const domain = normaliseDomain(input); if (!looksLikeDomain(domain)) throw new DomainError(`"${input}" does not look like a domain.`, 400); const existing = await db.select().from(proxyManagerDomains).where(eq(proxyManagerDomains.domain, domain)); if (existing.length > 0) throw new DomainError(`${domain} is already here.`, 409); const checked = await checkDomain(domain); const [row] = await db .insert(proxyManagerDomains) .values({ domain, dnsProvider: checked.dnsProvider, pointsTo: checked.pointsTo, resolvedIps: checked.resolvedIps, lastCheckedAt: new Date(), }) .returning(); return view(row!, await credentialScopes()); } /** Re-run the lookup for a domain already on the list, and keep what it says. */ export async function recheckDomain(id: number): Promise { const [row] = await db.select().from(proxyManagerDomains).where(eq(proxyManagerDomains.id, id)); if (!row) throw new DomainError('No such domain.', 404); const checked = await checkDomain(row.domain); const [updated] = await db .update(proxyManagerDomains) .set({ dnsProvider: checked.dnsProvider, pointsTo: checked.pointsTo, resolvedIps: checked.resolvedIps, lastCheckedAt: new Date(), updatedAt: new Date(), }) .where(eq(proxyManagerDomains.id, id)) .returning(); return view(updated!, await credentialScopes()); } export async function removeDomain(id: number): Promise { await db.delete(proxyManagerDomains).where(eq(proxyManagerDomains.id, id)); } /** A check with nothing stored — what the button does before you commit to adding it. */ export async function dryCheck(input: string): Promise { const domain = normaliseDomain(input); if (!looksLikeDomain(domain)) throw new DomainError(`"${input}" does not look like a domain.`, 400); return checkDomain(domain); } // ── Credentials ───────────────────────────────────────────────────────────────────────────────────── export type CredentialView = { provider: string; scope: string; label: string | null; updatedAt: string }; export async function listCredentials(): Promise { const rows = await db.select().from(proxyManagerDnsCredentials).orderBy(proxyManagerDnsCredentials.provider); return rows.map((row) => ({ provider: row.provider, scope: row.scope, label: row.label, updatedAt: row.updatedAt.toISOString(), // No secret. Deliberately not even a length. })); } export type SaveCredentialParams = { provider: string; /** The provider's own fields — one token, or a key and a secret. Shapes come from NPM's dns-providers list. */ fields: Record; scope: 'narrow' | 'broad'; label?: string; }; export async function saveCredential(params: SaveCredentialParams): Promise { const provider = params.provider.trim().toLowerCase(); if (!provider) throw new DomainError('A provider is required.', 400); const filled = Object.entries(params.fields ?? {}).filter(([, value]) => typeof value === 'string' && value.trim()); if (filled.length === 0) throw new DomainError('The credential is empty.', 400); const secret = encryptSecret(PURPOSE, JSON.stringify(Object.fromEntries(filled))); const existing = await db .select() .from(proxyManagerDnsCredentials) .where(eq(proxyManagerDnsCredentials.provider, provider)); if (existing.length > 0) { await db .update(proxyManagerDnsCredentials) .set({ secret, scope: params.scope, label: params.label ?? null, updatedAt: new Date() }) .where(eq(proxyManagerDnsCredentials.provider, provider)); } else { await db .insert(proxyManagerDnsCredentials) .values({ provider, secret, scope: params.scope, label: params.label ?? null }); } const [row] = await db .select() .from(proxyManagerDnsCredentials) .where(eq(proxyManagerDnsCredentials.provider, provider)); return { provider, scope: row!.scope, label: row!.label, updatedAt: row!.updatedAt.toISOString() }; } export async function removeCredential(provider: string): Promise { await db.delete(proxyManagerDnsCredentials).where(eq(proxyManagerDnsCredentials.provider, provider.toLowerCase())); } /** * Decrypted, for the code that places a challenge record. **Never serialise the result of this.** * * `issueWithDnsChallenge` below is the only caller and the only thing that should be — it is the whole * point of storing these at all. */ export async function readCredentialFields(provider: string): Promise | null> { const [row] = await db .select() .from(proxyManagerDnsCredentials) .where(eq(proxyManagerDnsCredentials.provider, provider.toLowerCase())); if (!row) return null; return JSON.parse(decryptSecret(PURPOSE, row.secret)) as Record; } // ── Issuing a certificate with the stored DNS credential ──────────────────────────────────────────── /** * Which stored domain owns a hostname. * * `private.officer.dev` belongs to the `officer.dev` row. Longest match wins, so a machine holding both * `officer.dev` and `dev.officer.dev` attributes `a.dev.officer.dev` to the more specific one — which is * the one whose provider is likelier to be right if they ever differ. */ export async function ownerOf(hostname: string): Promise { const name = normaliseDomain(hostname); const rows = await listDomains(); return ( rows .filter((row) => name === row.domain || name.endsWith(`.${row.domain}`)) .sort((a, b) => b.domain.length - a.domain.length)[0] ?? null ); } /** * certbot wants an ini file; we hold a JSON object. This is the whole translation. * * Field names came from NPM's own `dns-providers` template when the key was entered, so they are already * the ones certbot expects — nothing is renamed here, and nothing should be. */ const toIni = (fields: Record): string => Object.entries(fields) .map(([key, value]) => `${key} = ${value}`) .join('\n'); export type IssueParams = { domainNames: string[]; /** * How long to wait for the challenge record to be visible before asking Let's Encrypt to look. * * Two minutes by default, which is generous for Cloudflare and barely enough for GoDaddy — their TTLs * are minutes, not seconds. Too short is the failure that wastes a Let's Encrypt attempt and reads as a * credential problem, so the default errs long. */ propagationSeconds?: number; }; /** * Issue a certificate over DNS-01, using the credential Officer holds. * * **This is the one route in this plugin that is deliberately not a pass-through**, and the reason is the * credential: it is never returned across the API, so no caller — agent or human — can assemble this * request themselves. The platform decrypts it, puts it in the request, and hands back what NPM said. * * That is the same arrangement as the NPM token in `create-proxy.ts`, one level up: the thing that wants * the work done is not the thing holding the password. * * ── Why this exists at all ── * * A hostname that only resolves on a VPN cannot be visited by Let's Encrypt, so HTTP-01 is impossible and * a DNS record is the only proof it will accept. Every publicly-pointing name should use the plain * `POST /npm/nginx/certificates` instead — it needs no credential and no waiting. */ export async function issueWithDnsChallenge(params: IssueParams): Promise<{ provider: string; body: unknown }> { const names = params.domainNames.map(normaliseDomain).filter(Boolean); if (names.length === 0) throw new DomainError('At least one domain name is required.', 400); // Every name must belong to the same provider: NPM takes ONE credential per certificate, so a // certificate spanning two providers is not a thing that can be requested, and discovering that // halfway through would leave a challenge record behind. const owners = await Promise.all(names.map((name) => ownerOf(name))); const missing = names.filter((_, index) => !owners[index]); if (missing.length > 0) { throw new DomainError( `Not in the domain list: ${missing.join(', ')}. Add the domain first so Officer knows whose DNS it is.`, 400, ); } const providers = [...new Set(owners.map((owner) => owner!.dnsProvider).filter(Boolean))]; if (providers.length === 0) throw new DomainError('That domain has no recognised DNS provider.', 400); if (providers.length > 1) { throw new DomainError(`One certificate cannot span two DNS providers (${providers.join(', ')}).`, 400); } const provider = providers[0]!; const fields = await readCredentialFields(provider); if (!fields) { throw new DomainError(`No API key stored for ${provider}. Add one from the Domains section.`, 409); } return { provider, body: { provider: 'letsencrypt', domain_names: names, meta: { dns_challenge: true, dns_provider: provider, dns_provider_credentials: toIni(fields), propagation_seconds: params.propagationSeconds ?? 120, }, }, }; }