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
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { CheckCircle2, KeyRound, Loader2, Network, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { readableError } from './errors';
|
|
import { CredentialDialog } from './CredentialDialog';
|
|
|
|
/**
|
|
* Domains, and the DNS credentials that go with them — one screen, deliberately.
|
|
*
|
|
* ── Why not two sections ──
|
|
*
|
|
* A provider serves many domains, so the two are not the same list. But a person thinks in DOMAINS: the
|
|
* provider is something the check discovers, and the key is a property of the provider that only ever
|
|
* matters as an answer to "can Officer do anything for THIS domain". Two tabs would make you hold the join
|
|
* in your head, and the providers tab would be two rows most of the time.
|
|
*
|
|
* So the key is entered from the domain row that needs one, and stored against the provider — add it for
|
|
* `officer.dev` and every other Cloudflare domain in the list turns green at once, which is the correct
|
|
* surprise.
|
|
*
|
|
* ── The check runs before you commit ──
|
|
*
|
|
* Typing a domain and pressing Check tells you who runs its DNS and where it points, without storing
|
|
* anything. That ordering matters: the 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 go back to.
|
|
*/
|
|
|
|
type DomainRow = {
|
|
id: number;
|
|
domain: string;
|
|
dnsProvider: string | null;
|
|
pointsTo: string | null;
|
|
resolvedIps: string[];
|
|
lastCheckedAt: string | null;
|
|
hasCredential: boolean;
|
|
credentialScope: string | null;
|
|
};
|
|
|
|
type Check = {
|
|
domain: string;
|
|
nameservers: string[];
|
|
dnsProvider: string | null;
|
|
resolvedIps: string[];
|
|
pointsTo: 'here' | 'private' | 'elsewhere' | 'nowhere' | 'unknown';
|
|
maybeProxied: boolean;
|
|
machineIp: string | null;
|
|
};
|
|
|
|
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
|
|
|
/**
|
|
* The verdict, in a sentence.
|
|
*
|
|
* Five outcomes rather than two, because "does it point here" has more than two honest answers and the
|
|
* missing ones are the interesting ones: a tailnet name resolving to `100.64.x` is CORRECT and would read
|
|
* as broken against a public-IP comparison, and a proxied domain resolves to the proxy rather than to us.
|
|
*/
|
|
const VERDICT: Record<Check['pointsTo'], { text: string; tone: string }> = {
|
|
here: { text: 'Points at this machine', tone: 'text-emerald-600 dark:text-emerald-400' },
|
|
private: { text: 'Points inside a private network', tone: 'text-sky-600 dark:text-sky-400' },
|
|
elsewhere: { text: 'Points somewhere else', tone: 'text-amber-600 dark:text-amber-400' },
|
|
nowhere: { text: 'Does not resolve yet', tone: 'text-muted-foreground' },
|
|
unknown: { text: 'Resolves — could not tell if it is this machine', tone: 'text-muted-foreground' },
|
|
};
|
|
|
|
const verdictOf = (value: string | null) => (value && value in VERDICT ? VERDICT[value as Check['pointsTo']] : null);
|
|
|
|
const AddDomain = ({ onAdded }: { onAdded: () => void }) => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [domain, setDomain] = useState('');
|
|
const [check, setCheck] = useState<Check | null>(null);
|
|
const [busy, setBusy] = useState<'check' | 'add' | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const run = async () => {
|
|
setBusy('check');
|
|
setError(null);
|
|
setCheck(null);
|
|
try {
|
|
setCheck(await clientRef.current.get<Check>(`/proxy-manager/domains/check?domain=${encodeURIComponent(domain)}`));
|
|
} catch (caught) {
|
|
setError(readableError(caught, 'Could not look that up.'));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const add = async () => {
|
|
setBusy('add');
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.post('/proxy-manager/domains', { domain });
|
|
setDomain('');
|
|
setCheck(null);
|
|
onAdded();
|
|
} catch (caught) {
|
|
setError(readableError(caught, 'Could not add that.'));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const verdict = check && VERDICT[check.pointsTo];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 border-b p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={domain}
|
|
onChange={(ev) => {
|
|
setDomain(ev.target.value);
|
|
setCheck(null);
|
|
}}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Enter' && domain.trim() && busy === null) void run();
|
|
}}
|
|
placeholder="example.com"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="h-8"
|
|
/>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => void run()} disabled={!domain.trim() || busy !== null}>
|
|
{busy === 'check' ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
|
Check
|
|
</Button>
|
|
{/* Adding is only offered AFTER a check, so nothing lands on the list unexamined. */}
|
|
{check && (
|
|
<Button type="button" size="sm" onClick={() => void add()} disabled={busy !== null}>
|
|
{busy === 'add' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
|
Add
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{check && verdict && (
|
|
<div className="rounded-lg border p-2.5 text-xs">
|
|
<p className={`flex items-center gap-1.5 ${verdict.tone}`}>
|
|
{check.pointsTo === 'here' ? <CheckCircle2 className="size-3.5" /> : <TriangleAlert className="size-3.5" />}
|
|
{verdict.text}
|
|
{check.maybeProxied && ' — possibly through its provider'}
|
|
</p>
|
|
<p className={`mt-1 ${HINT}`}>
|
|
{check.dnsProvider ? (
|
|
<>
|
|
DNS is run by <strong>{check.dnsProvider}</strong>.
|
|
</>
|
|
) : check.nameservers.length ? (
|
|
<>Nameservers not recognised: {check.nameservers.join(', ')}.</>
|
|
) : (
|
|
<>No nameservers found.</>
|
|
)}
|
|
{check.resolvedIps.length > 0 && <> Resolves to {check.resolvedIps.join(', ')}.</>}
|
|
{check.machineIp && <> This machine is {check.machineIp}.</>}
|
|
</p>
|
|
{/* The registrar/DNS-host distinction, said once and where it will be misread otherwise. */}
|
|
{check.dnsProvider && (
|
|
<p className={`mt-1 ${HINT}`}>
|
|
That is where the DNS is hosted, which may not be where you bought the domain — it is the one that
|
|
matters, because its API is what can prove ownership for a certificate.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const DomainsSection = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [rows, setRows] = useState<DomainRow[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busyId, setBusyId] = useState<number | null>(null);
|
|
const [keyFor, setKeyFor] = useState<DomainRow | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setRows(await clientRef.current.get<DomainRow[]>('/proxy-manager/domains'));
|
|
setError(null);
|
|
} catch (caught) {
|
|
setError(readableError(caught, 'Could not load domains.'));
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const recheck = async (row: DomainRow) => {
|
|
setBusyId(row.id);
|
|
try {
|
|
await clientRef.current.post(`/proxy-manager/domains/${row.id}/recheck`, {});
|
|
await load();
|
|
} catch (caught) {
|
|
setError(readableError(caught, 'Could not re-check that.'));
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
const remove = async (row: DomainRow) => {
|
|
setBusyId(row.id);
|
|
try {
|
|
await clientRef.current.delete(`/proxy-manager/domains/${row.id}`);
|
|
await load();
|
|
} catch (caught) {
|
|
setError(readableError(caught, 'Could not remove that.'));
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
if (!rows) {
|
|
return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full flex-col">
|
|
<AddDomain onAdded={() => void load()} />
|
|
|
|
{error && <p className="border-b px-3 py-2 text-xs text-destructive">{error}</p>}
|
|
|
|
{rows.length === 0 ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
|
|
<Network className="size-8 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">No domains yet.</p>
|
|
<p className={`max-w-sm ${HINT}`}>
|
|
Add the ones this machine is responsible for. Officer will remember who runs their DNS, which is what
|
|
it needs in order to get certificates for names that are not reachable from the internet.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<ul className="flex-1 overflow-auto">
|
|
{rows.map((row) => {
|
|
const verdict = verdictOf(row.pointsTo);
|
|
return (
|
|
<li key={row.id} className="flex items-start gap-2 border-b px-3 py-2.5">
|
|
<Network className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm">{row.domain}</span>
|
|
<span className={`block truncate text-xs ${verdict?.tone ?? 'text-muted-foreground'}`}>
|
|
{verdict?.text ?? 'Not checked'}
|
|
{row.resolvedIps.length > 0 && ` · ${row.resolvedIps.join(', ')}`}
|
|
</span>
|
|
<span className={`block truncate ${HINT}`}>
|
|
{row.dnsProvider ? `DNS: ${row.dnsProvider}` : 'DNS provider not recognised'}
|
|
{row.dnsProvider &&
|
|
(row.hasCredential ? ` · key stored (${row.credentialScope})` : ' · no key yet')}
|
|
{row.lastCheckedAt && ` · checked ${new Date(row.lastCheckedAt).toLocaleString()}`}
|
|
</span>
|
|
</span>
|
|
|
|
{/* Offered only when we know WHICH provider to store it against. */}
|
|
{row.dnsProvider && (
|
|
<button
|
|
type="button"
|
|
title={row.hasCredential ? 'Replace the API key' : 'Add an API key'}
|
|
onClick={() => setKeyFor(row)}
|
|
className={row.hasCredential ? 'text-emerald-500' : 'text-muted-foreground hover:text-foreground'}
|
|
>
|
|
<KeyRound className="size-4" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
title="Check again"
|
|
onClick={() => void recheck(row)}
|
|
disabled={busyId === row.id}
|
|
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={`size-4 ${busyId === row.id ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
title="Forget this domain — changes nothing at the registrar"
|
|
onClick={() => void remove(row)}
|
|
disabled={busyId === row.id}
|
|
className="text-muted-foreground hover:text-destructive disabled:opacity-50"
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
{keyFor?.dnsProvider && (
|
|
<CredentialDialog
|
|
provider={keyFor.dnsProvider}
|
|
hasExisting={keyFor.hasCredential}
|
|
onClose={() => setKeyFor(null)}
|
|
onSaved={() => {
|
|
setKeyFor(null);
|
|
void load();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|