From 07cf3317f8347163d5690a440e15f81fc4ab112f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 19:27:06 +0000 Subject: [PATCH] domains: what you own, who runs its DNS, and the keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit one section, not two. a provider serves many domains so the lists differ, but a person thinks in DOMAINS — the provider is something the check discovers, and a key only matters as the answer to "can officer do anything for THIS domain". two tabs would make somebody hold that join in their head, and the providers tab would be two rows most of the time. so the key is entered from the row that needs one and stored against its provider: add it for one cloudflare domain and every other cloudflare domain turns green at once. THE CHECK runs before you commit, because its answer is usually the reason you would or would not add it, and a list of unchecked rows is a list somebody has to remember to revisit. it reports five verdicts, not two, and the extra three are the interesting ones. `private` — atelier.officer.dev resolves to 100.64.x, which is CORRECT and reads as broken against any public-IP comparison. `unknown` — we could not establish our own address, so "is that us" has no answer and saying so beats a confident wrong one. `nowhere` — a name bought ten minutes ago. plus maybeProxied, because a cloudflare domain resolving to cloudflare is a working setup rather than a misconfiguration. NS gives the DNS HOST, not the registrar. officer.dev is godaddy on both counts here, but a domain bought at godaddy and delegated to cloudflare answers cloudflare — and that is the one that matters, since its API is what places an ACME challenge. the UI says so where it would otherwise be read as wrong. only an apex has NS records, so the lookup walks up to find them. verified live: platform.officer.dev now answers godaddy instead of nothing. CREDENTIALS are keyed on the provider, encrypted under this plugin's purpose key, never returned by any route. scope is narrow|broad and NOT read|write — DNS-01 works by WRITING a TXT record, so no read-only key can ever issue a certificate, and offering one would produce a setup that looks configured and fails at the first renewal sixty days later. the dialog says that out loud. the form is generated from NPM's own dns-providers template, so the field names are the provider's and it stays right for providers we have never heard of. the routes live under /_config, which the manifest already marks ownerOnly: a DNS key can rewrite every record for a domain, which is strictly more than the NPM login kept there, and declaring it once beats checking it twice. tables verified by query rather than by db:push's exit code. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- api/dns.test.ts | 57 +++++++ api/dns.ts | 219 +++++++++++++++++++++++++++ api/domains.ts | 203 +++++++++++++++++++++++++ api/router.ts | 112 ++++++++++++++ db/schema.ts | 107 +++++++++++++- web/CredentialDialog.tsx | 248 +++++++++++++++++++++++++++++++ web/DetailPanel.tsx | 5 + web/DomainsSection.tsx | 311 +++++++++++++++++++++++++++++++++++++++ web/PlannedSection.tsx | 18 +-- web/sections.ts | 24 ++- 10 files changed, 1280 insertions(+), 24 deletions(-) create mode 100644 api/dns.test.ts create mode 100644 api/dns.ts create mode 100644 api/domains.ts create mode 100644 web/CredentialDialog.tsx create mode 100644 web/DomainsSection.tsx 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) => ( + + )) + ) : ( +