diff --git a/api/dns.test.ts b/api/dns.test.ts new file mode 100644 index 0000000..91f1bd9 --- /dev/null +++ b/api/dns.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import { providerFromNameservers, normaliseDomain, looksLikeDomain } from './dns'; + +/** + * The pure half of the DNS check. + * + * `checkDomain` itself talks to a resolver and to a public-IP service, so a test of it would be a test of + * this machine's network. What can actually be WRONG without anybody noticing is in here: which provider a + * set of nameservers means, and what counts as a domain. + */ + +describe('providerFromNameservers', () => { + test('the common ones', () => { + expect(providerFromNameservers(['ana.ns.cloudflare.com', 'bob.ns.cloudflare.com'])).toBe('cloudflare'); + expect(providerFromNameservers(['ns01.domaincontrol.com'])).toBe('godaddy'); + expect(providerFromNameservers(['ns-1234.awsdns.org'])).toBe('route53'); + expect(providerFromNameservers(['ns1.digitalocean.com'])).toBe('digitalocean'); + }); + + test('a trailing dot and capitals, which is how resolvers actually answer', () => { + expect(providerFromNameservers(['ANA.NS.CLOUDFLARE.COM.'])).toBe('cloudflare'); + }); + + // `ns-cloud-a1.googledomains.com` ends with `google.com`? No — but `ns1.google.com` does, and a shorter + // entry must not win over a longer one that also matches. Longest suffix, always. + test('the longest matching suffix wins', () => { + expect(providerFromNameservers(['ns-cloud-a1.googledomains.com'])).toBe('google'); + }); + + test('an unrecognised nameserver is null rather than a guess', () => { + expect(providerFromNameservers(['ns1.some-small-host.example'])).toBeNull(); + expect(providerFromNameservers([])).toBeNull(); + }); +}); + +describe('normaliseDomain', () => { + test('strips what people actually paste', () => { + expect(normaliseDomain(' https://Officer.dev/some/path ')).toBe('officer.dev'); + expect(normaliseDomain('officer.dev.')).toBe('officer.dev'); + expect(normaliseDomain('HTTP://OFFICER.DEV')).toBe('officer.dev'); + }); +}); + +describe('looksLikeDomain', () => { + test('accepts real ones, including a subdomain and a long TLD', () => { + expect(looksLikeDomain('officer.dev')).toBe(true); + expect(looksLikeDomain('sub.officer.dev')).toBe(true); + expect(looksLikeDomain('a-b.example.museum')).toBe(true); + }); + + test('rejects the obvious nonsense, and leaves the rest to DNS', () => { + expect(looksLikeDomain('officer')).toBe(false); + expect(looksLikeDomain('-officer.dev')).toBe(false); + expect(looksLikeDomain('officer .dev')).toBe(false); + expect(looksLikeDomain('')).toBe(false); + }); +}); diff --git a/api/dns.ts b/api/dns.ts new file mode 100644 index 0000000..a8d754c --- /dev/null +++ b/api/dns.ts @@ -0,0 +1,219 @@ +import { promises as dns } from 'node:dns'; + +/** + * What DNS can tell us about a domain, and what it cannot. + * + * Two questions, asked by the check button before a domain is saved and again whenever it is refreshed: + * **who answers for this name**, and **where does it point**. Both are cheap, both are observations rather + * than settings, and neither is stored as authority — see `proxyManagerDomains`. + */ + +/** Never let a slow or missing resolver hold a request open. */ +const TIMEOUT_MS = 5_000; + +const withTimeout = async (work: Promise, fallback: T): Promise => { + try { + return await Promise.race([ + work, + new Promise((resolve) => setTimeout(() => resolve(fallback), TIMEOUT_MS)), + ]); + } catch { + // NXDOMAIN, SERVFAIL, no records of that type — all ordinary answers to "does this exist", and none + // of them is an error worth propagating. The caller distinguishes them by what came back empty. + return fallback; + } +}; + +/** + * Nameserver suffix → the provider that runs it. + * + * **This identifies the DNS HOST, which is the one that matters here**: it is whose API can place an + * `_acme-challenge` record. It is frequently NOT the registrar — a domain bought at GoDaddy and delegated + * to Cloudflare answers `cloudflare` — and the UI says "DNS provider" for that reason. + * + * Longest suffix wins, so `ns-cloud-a1.googledomains.com` cannot be caught by a shorter entry first. + * An unknown nameserver is reported as `null` with the raw records kept: "we do not recognise this" is a + * better answer than a wrong guess, and the owner can still read the nameservers themselves. + */ +const NS_PROVIDERS: { suffix: string; provider: string }[] = [ + { suffix: 'cloudflare.com', provider: 'cloudflare' }, + { suffix: 'domaincontrol.com', provider: 'godaddy' }, + { suffix: 'godaddy.com', provider: 'godaddy' }, + { suffix: 'awsdns-00.org', provider: 'route53' }, + { suffix: 'awsdns.com', provider: 'route53' }, + { suffix: 'awsdns.net', provider: 'route53' }, + { suffix: 'awsdns.org', provider: 'route53' }, + { suffix: 'awsdns.co.uk', provider: 'route53' }, + { suffix: 'digitalocean.com', provider: 'digitalocean' }, + { suffix: 'registrar-servers.com', provider: 'namecheap' }, + { suffix: 'googledomains.com', provider: 'google' }, + { suffix: 'google.com', provider: 'google' }, + { suffix: 'azure-dns.com', provider: 'azure' }, + { suffix: 'azure-dns.net', provider: 'azure' }, + { suffix: 'azure-dns.org', provider: 'azure' }, + { suffix: 'linode.com', provider: 'linode' }, + { suffix: 'vultr.com', provider: 'vultr' }, + { suffix: 'hetzner.com', provider: 'hetzner' }, + { suffix: 'hetzner.de', provider: 'hetzner' }, + { suffix: 'ovh.net', provider: 'ovh' }, + { suffix: 'gandi.net', provider: 'gandi' }, + { suffix: 'porkbun.com', provider: 'porkbun' }, + { suffix: 'dnsimple.com', provider: 'dnsimple' }, + { suffix: 'desec.io', provider: 'desec' }, + { suffix: 'njal.la', provider: 'njalla' }, + { suffix: 'name.com', provider: 'namedotcom' }, + { suffix: 'dnsmadeeasy.com', provider: 'dnsmadeeasy' }, + { suffix: 'nsone.net', provider: 'ns1' }, + { suffix: 'hover.com', provider: 'hover' }, + { suffix: 'dreamhost.com', provider: 'dreamhost' }, + { suffix: 'bunny.net', provider: 'bunny' }, +]; + +/** Providers that commonly sit IN FRONT of an origin, so an address that is not ours may still be right. */ +const PROXYING_PROVIDERS = new Set(['cloudflare', 'bunny']); + +export const providerFromNameservers = (nameservers: string[]): string | null => { + const found = nameservers + .map((ns) => ns.toLowerCase().replace(/\.$/, '')) + .flatMap((ns) => NS_PROVIDERS.filter((entry) => ns.endsWith(entry.suffix))) + .sort((a, b) => b.suffix.length - a.suffix.length); + return found[0]?.provider ?? null; +}; + +/** + * Where a domain points, as a verdict rather than an address. + * + * `here` at least one address is this machine's public IP. + * `private` it resolves inside a VPN range — correct for a tailnet-only name, and invisible to any + * check that only compares against a public address. + * `elsewhere` it resolves, and not to us. Which may be wrong, or may be a proxy — see `maybeProxied`. + * `nowhere` it does not resolve at all. A new domain, or a typo. + * `unknown` we could not establish our own public address, so "is that us" has no answer. Saying so is + * the point: reporting `elsewhere` here would be a confident wrong answer. + */ +export type PointsTo = 'here' | 'private' | 'elsewhere' | 'nowhere' | 'unknown'; + +/** RFC1918, plus the CGNAT block Tailscale uses (100.64.0.0/10). */ +const isPrivateIp = (ip: string): boolean => { + const [a = 0, b = 0] = ip.split('.').map(Number); + if (a === 10 || a === 127) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + return false; +}; + +/** + * This machine's public address, cached. + * + * Asked of an outside service because there is no reliable local answer: the host may be behind NAT, and + * the interface addresses are not what the internet sees. Cached for an hour — a residential address can + * change, and re-asking on every check would put a third-party request behind a button. + * + * Failure is `null` and that is a real state, not an error. It means the "does it point here" question + * cannot be answered, which the verdict reports as `unknown` rather than guessing. + */ +let publicIpCache: { ip: string | null; at: number } | null = null; +const PUBLIC_IP_TTL_MS = 60 * 60 * 1000; + +export async function publicIp(): Promise { + if (publicIpCache && Date.now() - publicIpCache.at < PUBLIC_IP_TTL_MS) return publicIpCache.ip; + + let ip: string | null = null; + try { + const response = await fetch('https://api.ipify.org', { signal: AbortSignal.timeout(TIMEOUT_MS) }); + if (response.ok) { + const text = (await response.text()).trim(); + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(text)) ip = text; + } + } catch { + // Offline, blocked, or the service is down. All the same answer here. + } + + publicIpCache = { ip, at: Date.now() }; + return ip; +} + +export type DomainCheck = { + domain: string; + nameservers: string[]; + dnsProvider: string | null; + resolvedIps: string[]; + pointsTo: PointsTo; + /** + * `elsewhere`, but the DNS provider is one that proxies — so this is plausibly a working setup with the + * provider in front, rather than a misconfiguration. Stated as a maybe because from a DNS lookup alone + * it genuinely is one. + */ + maybeProxied: boolean; + /** Our own public address at the time of the check, so the verdict can be read rather than trusted. */ + machineIp: string | null; +}; + +/** Lowercased, trailing dot and any scheme or path removed. `https://Foo.Dev/` → `foo.dev`. */ +export const normaliseDomain = (input: string): string => + input + .trim() + .toLowerCase() + .replace(/^[a-z]+:\/\//, '') + .replace(/\/.*$/, '') + .replace(/\.$/, ''); + +/** Loose on purpose: this rejects obvious nonsense, and DNS itself is the real test. */ +export const looksLikeDomain = (domain: string): boolean => + /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(domain) && domain.length <= 253; + +/** + * Nameservers for a name, falling back to its parent. + * + * **Only an apex has NS records.** `platform.officer.dev` answers nothing, which would report "provider not + * recognised" for a name whose DNS is perfectly well known — and people paste subdomains, whatever the + * list is nominally for. So walk up a label at a time until something answers. + * + * Bounded at three steps, which reaches the apex of anything real (`a.b.c.example.co.uk`) without turning + * a typo into a queue of lookups. + */ +async function nameserversFor(domain: string): Promise { + let name = domain; + for (let step = 0; step < 4; step += 1) { + const found = await withTimeout(dns.resolveNs(name), [] as string[]); + if (found.length > 0) return found; + const dot = name.indexOf('.'); + // Two labels left means we are at the apex already; there is no parent worth asking. + if (dot === -1 || name.split('.').length <= 2) return []; + name = name.slice(dot + 1); + } + return []; +} + +export async function checkDomain(input: string): Promise { + const domain = normaliseDomain(input); + + // Asked together: they are independent, and a slow NS lookup should not delay the address one. + const [nameservers, v4, v6, machineIp] = await Promise.all([ + nameserversFor(domain), + withTimeout(dns.resolve4(domain), [] as string[]), + withTimeout(dns.resolve6(domain), [] as string[]), + publicIp(), + ]); + + const resolvedIps = [...v4, ...v6]; + const dnsProvider = providerFromNameservers(nameservers); + + const pointsTo: PointsTo = (() => { + if (resolvedIps.length === 0) return 'nowhere'; + if (v4.some(isPrivateIp)) return 'private'; + if (!machineIp) return 'unknown'; + return resolvedIps.includes(machineIp) ? 'here' : 'elsewhere'; + })(); + + return { + domain, + nameservers: nameservers.map((ns) => ns.toLowerCase().replace(/\.$/, '')), + dnsProvider, + resolvedIps, + pointsTo, + maybeProxied: pointsTo === 'elsewhere' && !!dnsProvider && PROXYING_PROVIDERS.has(dnsProvider), + machineIp, + }; +} diff --git a/api/domains.ts b/api/domains.ts new file mode 100644 index 0000000..2d7eacf --- /dev/null +++ b/api/domains.ts @@ -0,0 +1,203 @@ +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.** + * + * Nothing calls it yet — issuing a certificate through DNS-01 is the next piece, and it is the only thing + * that should. It exists now so the storage above is provably usable rather than write-only. + */ +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; +} diff --git a/api/router.ts b/api/router.ts index 148c436..6de53c9 100644 --- a/api/router.ts +++ b/api/router.ts @@ -2,6 +2,17 @@ import type { Context } from 'hono'; import { createRouter, type HonoVariables } from '@@/create-router'; import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; import { NpmError, npmRequest, npmProxy, probe, forgetToken } from './npm-client'; +import { + addDomain, + dryCheck, + listCredentials, + listDomains, + recheckDomain, + removeCredential, + removeDomain, + saveCredential, + DomainError, +} from './domains'; /** * Mounted at `/api/proxy-manager`. @@ -241,6 +252,107 @@ router.post('/hosts/:id/:action', async (ctx) => { ); }); +// ── Domains ───────────────────────────────────────────────────────────────────────────────────────── + +/** + * Officer's own record of which domains this machine is responsible for, and what DNS says about them. + * + * **Not NPM's.** NPM knows about hosts it serves; it has no idea which domains you own, and cannot — a + * name is only knowable if you already have it. So this is a list the owner curates, with a cached + * observation attached to each entry. + * + * ── The credential half is under `/_config` on purpose ── + * + * A DNS provider API key can rewrite every record for a domain, which is a strictly larger power than the + * NPM login already kept there. Putting those routes under the prefix the manifest marks `ownerOnly` means + * the rule is declared once and enforced by the platform, with nothing in this file deciding it twice. + * + * Reading the domain LIST is not owner-only. Which names this machine serves is the same class of fact as + * which proxy hosts exist, and `hasCredential` is a boolean rather than a secret. + */ + +// `Context`, imported — NOT derived from `router.get`. That trick resolves hono's first overload and +// yields `never`, which is the twelve-error bug this same file was fixed for earlier today. Twice is +// enough to say it out loud. +const domainFailure = (ctx: Context<{ Variables: HonoVariables }>, error: unknown) => { + if (error instanceof DomainError) return ctx.json({ error: error.message }, error.status); + return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 500); +}; + +router.get('/domains', async (ctx) => ctx.json(await listDomains())); + +/** + * Look a domain up without storing anything — the check button, before you commit to adding it. + * + * A GET with the domain in the query rather than a POST, because it changes nothing. That also means a + * read-level account can use it, which is right: this asks public DNS a question anybody could ask. + */ +router.get('/domains/check', async (ctx) => { + const domain = ctx.req.query('domain') ?? ''; + try { + return ctx.json(await dryCheck(domain)); + } catch (error) { + return domainFailure(ctx, error); + } +}); + +router.post('/domains', async (ctx) => { + const body = (await ctx.req.json().catch(() => null)) as { domain?: string } | null; + try { + return ctx.json(await addDomain(body?.domain ?? '')); + } catch (error) { + return domainFailure(ctx, error); + } +}); + +router.post('/domains/:id/recheck', async (ctx) => { + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) return ctx.json({ error: 'Expected a domain id.' }, 400); + try { + return ctx.json(await recheckDomain(id)); + } catch (error) { + return domainFailure(ctx, error); + } +}); + +router.delete('/domains/:id', async (ctx) => { + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) return ctx.json({ error: 'Expected a domain id.' }, 400); + // Removing a domain forgets Officer's note about it. It changes nothing at the registrar, nothing in + // NPM, and nothing about whether the site works — worth saying, because "delete domain" reads much + // larger than it is. + await removeDomain(id); + return ctx.json({ ok: true }); +}); + +// ── DNS credentials — owner only, by the manifest's `/_config` prefix ──────────────────────────────── + +router.get('/_config/dns-providers', async (ctx) => ctx.json(await listCredentials())); + +router.put('/_config/dns-providers/:provider', async (ctx) => { + const body = (await ctx.req.json().catch(() => null)) as + | { fields?: Record; scope?: string; label?: string } + | null; + const scope = body?.scope === 'broad' ? 'broad' : 'narrow'; + try { + return ctx.json( + await saveCredential({ + provider: ctx.req.param('provider'), + fields: body?.fields ?? {}, + scope, + label: body?.label, + }), + ); + } catch (error) { + return domainFailure(ctx, error); + } +}); + +router.delete('/_config/dns-providers/:provider', async (ctx) => { + await removeCredential(ctx.req.param('provider')); + return ctx.json({ ok: true }); +}); + // ── The pass-through ──────────────────────────────────────────────────────────────────────────────── /** diff --git a/db/schema.ts b/db/schema.ts index 3807db4..78a26b3 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'; +import { pgTable, serial, text, timestamp, integer, uniqueIndex } from 'drizzle-orm/pg-core'; /** * The connection to Nginx Proxy Manager. @@ -46,3 +46,108 @@ export const proxyManagerConnection = pgTable('proxy_manager_connection', { }); export type ProxyManagerConnection = typeof proxyManagerConnection.$inferSelect; + +/** + * A domain this machine is responsible for. + * + * ── Why Officer stores this at all, when DNS already knows ── + * + * Because "which domains are mine" is not a question DNS can answer — you can only ask about a name you + * already have. Today that list lives in a document somebody edits by hand, which is correct on the day it + * is written. A row here is the owner saying "this one is mine", and everything else on it is a cached + * observation that can be refreshed. + * + * ── Everything except `domain` is a CACHE ── + * + * `dnsProvider`, `pointsTo` and `resolvedIps` are what a lookup said at `lastCheckedAt`. They are stored so + * a list renders without doing nine DNS queries, and they are never treated as authority: the check can be + * re-run, and a stale answer is labelled with its age rather than presented as current. + */ +export const proxyManagerDomains = pgTable( + 'proxy_manager_domains', + { + id: serial('id').primaryKey(), + + /** The apex, lowercased, no trailing dot — `officer.dev`. Subdomains are not rows; they belong to one. */ + domain: text('domain').notNull(), + + /** + * Who answers for it, derived from its NS records — `cloudflare`, `godaddy`, `route53`. + * + * **This is the DNS HOST, not the registrar**, and they are frequently different: a domain bought at + * GoDaddy with nameservers pointed at Cloudflare answers `cloudflare` here. That is the useful one — + * it is whose API can place an ACME challenge record — but it is worth the sentence, because somebody + * who knows where they bought a domain will otherwise read this as wrong. + */ + dnsProvider: text('dns_provider'), + + /** What the last check made of where it points — see `PointsTo` in `api/dns.ts`. */ + pointsTo: text('points_to'), + + /** The addresses it resolved to, so a person can see the actual answer rather than our verdict. */ + resolvedIps: text('resolved_ips').array(), + + lastCheckedAt: timestamp('last_checked_at', { withTimezone: true }), + + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex('uq_proxy_manager_domains_domain').on(table.domain)], +); + +export type ProxyManagerDomain = typeof proxyManagerDomains.$inferSelect; + +/** + * One DNS provider's API credential. + * + * ── Keyed on the PROVIDER, not the domain ── + * + * One Cloudflare token serves every domain whose DNS is on Cloudflare. Storing a copy per domain would + * mean rotating it in six places and getting five of them. + * + * ── `scope` is narrow or broad, and it is NOT read or write ── + * + * A DNS-01 challenge works by CREATING a `_acme-challenge` TXT record, so issuing a certificate needs + * write access. There is no read-only option that can do it, and offering one would produce a setup that + * looks configured and fails at the first renewal — sixty days later, silently. + * + * What actually differs is blast radius. `narrow` is a token the provider scoped to DNS edits on the zones + * that need it; `broad` is a full-account key that can do anything. Both issue certificates. Only the + * second lets an agent manage records generally, which is a thing the owner may well want — but should + * choose knowing which one they handed over. + * + * Whether `narrow` is even available is the provider's business: Cloudflare scopes tokens per zone, + * GoDaddy's key and secret are account-wide with no scoping at all. + */ +export const proxyManagerDnsCredentials = pgTable( + 'proxy_manager_dns_credentials', + { + id: serial('id').primaryKey(), + + /** Matches `proxyManagerDomains.dnsProvider` — `cloudflare`, `godaddy`. */ + provider: text('provider').notNull(), + + /** + * The credential itself, encrypted with `encryptSecret('proxy-manager', …)`. + * + * A JSON object rather than a string, because providers want different fields — Cloudflare one token, + * GoDaddy a key AND a secret, Route53 three. NPM already describes those field names per provider + * (`GET /nginx/certificates/dns-providers`), so the shape is theirs and this only carries it. + * + * **Never returned across the API boundary**, at any level, to anybody, including the owner. + */ + secret: text('secret').notNull(), + + /** `narrow` | `broad`. See the note above — this is blast radius, not read versus write. */ + scope: text('scope').notNull().default('narrow'), + + /** What the owner called it, so a rotated key is recognisable. Optional. */ + label: text('label'), + + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex('uq_proxy_manager_dns_credentials_provider').on(table.provider)], +); + +export type ProxyManagerDnsCredential = typeof proxyManagerDnsCredentials.$inferSelect; diff --git a/web/CredentialDialog.tsx b/web/CredentialDialog.tsx new file mode 100644 index 0000000..38a213d --- /dev/null +++ b/web/CredentialDialog.tsx @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { KeyRound, Loader2, ShieldAlert } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { readableError } from './errors'; + +/** + * Entering a DNS provider's API credential. + * + * ── The fields are NPM's, not ours ── + * + * `GET /nginx/certificates/dns-providers` returns a `credentials` TEMPLATE per provider, carrying that + * provider's own field names — one token for Cloudflare, a key and a secret for others. So the form is + * GENERATED rather than written per provider, and it stays right for providers nobody here has heard of. + * + * When the template cannot be fetched — no NPM connection yet, or a provider it does not support — the + * fallback is a single box in `key = value` form, which is the shape certbot wants anyway. Worse to use + * and not wrong, which is the correct direction for a fallback. + * + * ── Narrow versus broad, and why it is not read versus write ── + * + * Both can issue certificates, because a DNS-01 challenge WRITES a TXT record — there is no read-only + * credential that works, and offering one would produce a setup that looks configured and fails at the + * first renewal, sixty days later. What differs is what else the key can do if it leaks. + */ + +type NpmProvider = { id: string; name: string; credentials: string }; + +/** + * Pull field names out of certbot's ini template. + * + * `dns_cloudflare_api_token = ` → `dns_cloudflare_api_token`. Anything that is not a `key = value` + * line is skipped rather than guessed at. + */ +const fieldsFromTemplate = (template: string): string[] => + template + .split('\n') + .map((line) => line.split('=')[0]?.trim()) + .filter((key): key is string => !!key && /^[a-z0-9_]+$/i.test(key)); + +/** `dns_cloudflare_api_token` → `Api token`. The prefix is noise once the provider is named above. */ +const labelFor = (field: string, provider: string): string => { + const stripped = field.replace(new RegExp(`^dns_${provider}_`, 'i'), '').replace(/^dns_/, ''); + const words = stripped.replace(/_/g, ' ').trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +}; + +type Props = { + provider: string; + hasExisting: boolean; + onClose: () => void; + onSaved: () => void; +}; + +export const CredentialDialog = ({ provider, hasExisting, onClose, onSaved }: Props) => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [fields, setFields] = useState(null); + const [values, setValues] = useState>({}); + const [raw, setRaw] = useState(''); + const [scope, setScope] = useState<'narrow' | 'broad'>('narrow'); + const [label, setLabel] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [forbidden, setForbidden] = useState(false); + + const loadTemplate = useCallback(async () => { + try { + const list = await clientRef.current.get( + '/proxy-manager/npm/nginx/certificates/dns-providers', + ); + const match = list.find((entry) => entry.id.toLowerCase() === provider.toLowerCase()); + setFields(match ? fieldsFromTemplate(match.credentials) : []); + } catch { + // No connection, or NPM does not know this provider. The free-form box covers both. + setFields([]); + } + }, [provider]); + + useEffect(() => { + void loadTemplate(); + }, [loadTemplate]); + + const save = async () => { + setSaving(true); + setError(null); + try { + const entered = + fields && fields.length > 0 + ? values + : Object.fromEntries( + raw + .split('\n') + .map((line) => { + const index = line.indexOf('='); + return index === -1 ? null : [line.slice(0, index).trim(), line.slice(index + 1).trim()]; + }) + .filter((pair): pair is [string, string] => !!pair && !!pair[0] && !!pair[1]), + ); + + await clientRef.current.put(`/proxy-manager/_config/dns-providers/${encodeURIComponent(provider)}`, { + fields: entered, + scope, + label: label.trim() || undefined, + }); + onSaved(); + } catch (caught) { + if ((caught as { status?: number })?.status === 403) setForbidden(true); + else setError(readableError(caught, 'Could not save that.')); + } finally { + setSaving(false); + } + }; + + const generated = fields && fields.length > 0; + const filled = generated ? fields.some((f) => values[f]?.trim()) : raw.trim().length > 0; + + return ( +
+
ev.stopPropagation()} + > +
+ + + {hasExisting ? 'Replace' : 'Add'} the {provider} API key + +
+ + {forbidden ? ( + <> +

+ Only the server owner can store a DNS credential. +

+

+ It can rewrite every record for a domain, which is more than the proxy login already kept here. +

+ + ) : ( + <> + {fields === null ? ( +

Looking up what {provider} needs…

+ ) : generated ? ( + fields.map((field) => ( + + )) + ) : ( +