domains: what you own, who runs its DNS, and the keys

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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-25 19:27:06 +00:00
co-authored by Claude Opus 5
parent 219625ec82
commit 07cf3317f8
10 changed files with 1280 additions and 24 deletions
+248
View File
@@ -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 = <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<string[] | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [raw, setRaw] = useState('');
const [scope, setScope] = useState<'narrow' | 'broad'>('narrow');
const [label, setLabel] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [forbidden, setForbidden] = useState(false);
const loadTemplate = useCallback(async () => {
try {
const list = await clientRef.current.get<NpmProvider[]>(
'/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 (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
className="flex max-h-full w-full max-w-md flex-col gap-3 overflow-auto rounded-lg border bg-background p-4"
onClick={(ev) => ev.stopPropagation()}
>
<div className="flex items-center gap-2">
<KeyRound className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
{hasExisting ? 'Replace' : 'Add'} the {provider} API key
</span>
</div>
{forbidden ? (
<>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldAlert className="size-4" /> Only the server owner can store a DNS credential.
</p>
<p className="text-[11px] leading-relaxed text-muted-foreground">
It can rewrite every record for a domain, which is more than the proxy login already kept here.
</p>
</>
) : (
<>
{fields === null ? (
<p className="text-xs text-muted-foreground">Looking up what {provider} needs…</p>
) : generated ? (
fields.map((field) => (
<label key={field} className="flex flex-col gap-1">
<span className="text-xs font-medium">{labelFor(field, provider)}</span>
<Input
value={values[field] ?? ''}
onChange={(ev) => setValues((prev) => ({ ...prev, [field]: ev.target.value }))}
placeholder={hasExisting ? 'unchanged' : field}
type="password"
autoComplete="off"
spellCheck={false}
/>
</label>
))
) : (
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Credential</span>
<textarea
value={raw}
onChange={(ev) => setRaw(ev.target.value)}
rows={4}
spellCheck={false}
placeholder={`dns_${provider}_api_key = …`}
className="rounded-md border bg-transparent p-2 font-mono text-xs"
/>
<span className="text-[11px] leading-relaxed text-muted-foreground">
One `name = value` per line. Officer could not fetch this provider's field names from Nginx Proxy
Manager, so they are yours to supply — its own documentation lists them.
</span>
</label>
)}
<fieldset className="flex flex-col gap-1.5 rounded-lg border p-2.5">
<legend className="px-1 text-xs font-medium">What this key can do</legend>
{(
[
{
value: 'narrow' as const,
title: 'Certificates only',
hint: 'Scoped by the provider to DNS edits, ideally on just these zones. Enough to prove ownership and get a certificate. Not every provider can scope a key — some only issue account-wide ones.',
},
{
value: 'broad' as const,
title: 'Full DNS access',
hint: 'Also lets an agent create and change records for you when asked. Everything the narrow key does, plus everything else that key can reach if it leaks.',
},
]
).map((option) => (
<label key={option.value} className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="scope"
className="mt-1"
checked={scope === option.value}
onChange={() => setScope(option.value)}
/>
<span className="min-w-0">
<span className="block text-xs">{option.title}</span>
<span className="block text-[11px] leading-relaxed text-muted-foreground">{option.hint}</span>
</span>
</label>
))}
{/* The correction that stops somebody creating a read-only key and finding out in sixty days. */}
<p className="px-1 text-[11px] leading-relaxed text-muted-foreground">
There is no read-only option, and that is not an omission: proving you own a domain means writing a
record into it, so any key that can get a certificate can write.
</p>
</fieldset>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Label (optional)</span>
<Input
value={label}
onChange={(ev) => setLabel(ev.target.value)}
placeholder="which key this is, for when you rotate it"
autoComplete="off"
/>
</label>
{error && <p className="text-xs text-destructive">{error}</p>}
<p className="text-[11px] leading-relaxed text-muted-foreground">
Stored encrypted under this plugin's own key and never returned by the API — not to a member, not to
you. To change it, enter a new one.
</p>
</>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
{forbidden ? 'Close' : 'Cancel'}
</Button>
{!forbidden && (
<Button type="button" size="sm" onClick={() => void save()} disabled={!filled || saving}>
{saving ? <Loader2 className="size-3.5 animate-spin" /> : null} Save
</Button>
)}
</div>
</div>
</div>
);
};