Files
pastilhasandClaude Opus 5 867226d897 a certificate for a name Let's Encrypt cannot visit
the whole reason the credential store exists. a tailnet-only hostname cannot be
reached on port 80, so HTTP-01 is impossible and a DNS record is the only proof
Let's Encrypt accepts — which needs the provider's API key.

POST /domains/certificate is the one route here that is deliberately not a
pass-through, and it has to be: the request needs dns_provider_credentials, and
officer never returns a stored key to any caller. so the platform resolves
which provider owns the name from the Domains list, decrypts, builds the
request and returns NPM's own answer unchanged. same arrangement as the NPM
token one level up — the thing that wants the work done is not the thing
holding the password.

NOT under /_config. reading and writing a DNS key stays owner-only; USING it on
the owner's behalf is the feature, and gating this to the owner would defeat
it. a read-level account still cannot reach it, because it is a POST.

three refusals, all before anything is placed at the registrar: a name whose
apex is not in the domain list, a provider with no key stored, and a
certificate spanning two providers — NPM takes one credential per certificate,
and discovering that halfway through leaves a challenge record behind. tested,
because each one otherwise fails deep inside certbot with an error about
something else.

ten minute timeout, 120s default propagation. both err long on purpose: NPM
waits for the record to spread before asking Let's Encrypt to look, and
abandoning the request does not stop it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 20:07:09 +00:00

305 lines
12 KiB
TypeScript

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<string, string>): 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<Map<string, string>> {
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<DomainView[]> {
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<DomainView> {
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<DomainView> {
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<void> {
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<DomainCheck> {
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<CredentialView[]> {
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<string, string>;
scope: 'narrow' | 'broad';
label?: string;
};
export async function saveCredential(params: SaveCredentialParams): Promise<CredentialView> {
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<void> {
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<Record<string, string> | 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<string, string>;
}
// ── 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<DomainView | null> {
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, string>): 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,
},
},
};
}