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 = { 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(null); const [busy, setBusy] = useState<'check' | 'add' | null>(null); const [error, setError] = useState(null); const run = async () => { setBusy('check'); setError(null); setCheck(null); try { setCheck(await clientRef.current.get(`/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 (
{ 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" /> {/* Adding is only offered AFTER a check, so nothing lands on the list unexamined. */} {check && ( )}
{check && verdict && (

{check.pointsTo === 'here' ? : } {verdict.text} {check.maybeProxied && ' — possibly through its provider'}

{check.dnsProvider ? ( <> DNS is run by {check.dnsProvider}. ) : 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}.}

{/* The registrar/DNS-host distinction, said once and where it will be misread otherwise. */} {check.dnsProvider && (

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.

)}
)} {error &&

{error}

}
); }; export const DomainsSection = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [rows, setRows] = useState(null); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); const [keyFor, setKeyFor] = useState(null); const load = useCallback(async () => { try { setRows(await clientRef.current.get('/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
Loading…
; } return (
void load()} /> {error &&

{error}

} {rows.length === 0 ? (

No domains yet.

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.

) : (
    {rows.map((row) => { const verdict = verdictOf(row.pointsTo); return (
  • {row.domain} {verdict?.text ?? 'Not checked'} {row.resolvedIps.length > 0 && ` · ${row.resolvedIps.join(', ')}`} {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()}`} {/* Offered only when we know WHICH provider to store it against. */} {row.dnsProvider && ( )}
  • ); })}
)} {keyFor?.dnsProvider && ( setKeyFor(null)} onSaved={() => { setKeyFor(null); void load(); }} /> )}
); };