Files
fofos-admin/web/ProxiesBlock.tsx
T
pastilhasandClaude Opus 5 26b3ef2ce4 certificates go over the DNS challenge, using the key Officer already holds
The HTTP challenge proves a domain by being visited on port 80, which requires
the name to already resolve to this machine — which is exactly what is not true
at the moment somebody is setting one up. certbot then fails with "Some
challenges have failed" and a 500 that says nothing about why.

The DNS challenge has no such requirement: it proves ownership by writing a
record. That needs the registrar's API key, and this must not go looking for
one — Officer stores DNS provider keys encrypted and never returns them, to
anybody. /domains/certificate exists for precisely this: it works out which
provider owns the name from the Domains list, decrypts the key itself, and hands
back NPM's answer. The key is used and never seen.

Its refusals are translated rather than shown raw. "Not in the domain list" is a
true sentence that tells nobody what to do; which list, and who can add to it,
is the part that matters.

And the button says it is issuing, because a DNS record has to propagate before
Let's Encrypt will look for it — the wait is minutes, and a spinner sitting for
three of them with no explanation is indistinguishable from one that has hung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:02:36 +01:00

293 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { CircleDot, Globe, Loader2, Lock, Plus, ShieldCheck } 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 { Block } from './Block';
import { Console } from './Console';
/**
* The domains that reach the site, through Nginx Proxy Manager.
*
* ── This talks to the proxy-manager PLUGIN, from the browser ──
*
* Not to NPM, and not from this plugin's server. NPM's credential belongs to proxy-manager and to
* nothing else; its skill is explicit that the sanctioned path is its own pass-through route and that
* going around it is unauthorised. Calling it from here, in the browser, with the user's own session,
* means this plugin never holds that credential and there is no server-to-server coupling between two
* plugins that would each then need to know when the other was installed.
*
* ── `forward_host` is copied from what already works ──
*
* NPM runs in a container, so `127.0.0.1` there is NPM itself, not this machine — a host pointed at it
* resolves, returns nothing, and looks like the site being down. The right value is the Docker
* gateway, which is a fact about this machine's networking that neither this plugin nor a person
* should have to know.
*
* So the default is taken from the hosts NPM ALREADY serves: whatever address they forward to is, by
* construction, one that works here. It is shown in a field, because a guess made from other people's
* configuration should be visible before it is used.
*
* ── Creating is offered; deleting is not ──
*
* The skill draws that line and it is the right one. A new host is additive and reversible. Removing or
* repointing one is a live domain changing where it goes, which belongs in the Proxy Manager screen
* where the full picture is — this panel would be offering a destructive action with a fraction of the
* context.
*/
type Host = {
id: number;
domain_names: string[];
forward_scheme: string;
forward_host: string;
forward_port: number;
enabled: boolean;
ssl_forced: boolean;
certificate_id: number;
meta?: { nginx_online?: boolean };
};
type Server = { name: 'development' | 'production'; port: number | null };
/** NPM rejects a partial body — these are its own defaults, stated rather than omitted. */
const NEW_HOST = {
block_exploits: true,
allow_websocket_upgrade: true,
caching_enabled: false,
access_list_id: 0,
certificate_id: 0,
ssl_forced: false,
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
advanced_config: '',
locations: [],
meta: {},
};
export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [hosts, setHosts] = useState<Host[] | null>(null);
const [unavailable, setUnavailable] = useState(false);
const [domains, setDomains] = useState<Record<string, string>>({});
const [forwardHost, setForwardHost] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [output, setOutput] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const load = useCallback(async () => {
try {
const found = await clientRef.current.get<Host[]>('/proxy-manager/hosts');
setHosts(found);
// The address NPM already uses to reach this machine, taken by majority from its own hosts.
const counts = new Map<string, number>();
for (const host of found) counts.set(host.forward_host, (counts.get(host.forward_host) ?? 0) + 1);
const common = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
if (common) setForwardHost((typed) => typed || common);
} catch {
// Not installed, not running, or not permitted. Reported as absent rather than as an error: this
// block is an integration with another plugin, and its absence is a fact rather than a fault.
setUnavailable(true);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const create = async (server: Server) => {
const domain = (domains[server.name] ?? '').trim();
if (!domain || !server.port) return;
setBusy(`${server.name}:create`);
setFailed(false);
setOutput(null);
try {
await clientRef.current.post('/proxy-manager/npm/nginx/proxy-hosts', {
...NEW_HOST,
domain_names: [domain],
forward_scheme: 'http',
forward_host: forwardHost.trim(),
forward_port: server.port,
});
setOutput(`${domain} → ${forwardHost.trim()}:${server.port}`);
setDomains((current) => ({ ...current, [server.name]: '' }));
await load();
} catch (failure) {
setOutput(readableError(failure));
setFailed(true);
} finally {
setBusy(null);
}
};
/**
* Ask for a certificate over the DNS challenge, then attach it.
*
* ── Why not the ordinary NPM certificates route ──
*
* That one proves the domain over HTTP, which needs the name to already resolve to this machine and
* port 80 to be reachable from the internet. For a name that does not yet point here — which is every
* name at the moment somebody is setting it up — certbot fails with "Some challenges have failed" and
* a 500 that says nothing about why.
*
* The DNS challenge has no such requirement: it proves ownership by writing a record. That needs the
* registrar's API key, and this must NOT go looking for one. Officer stores DNS provider keys
* encrypted and never returns them, to anybody. `/domains/certificate` is the route that exists for
* exactly this — it works out which provider owns the name from the Domains list, decrypts the key
* itself, and hands back NPM's answer. The key is used and never seen.
*
* ── It takes minutes ──
*
* A DNS record has to propagate before Let's Encrypt will look for it, and the default wait is two
* minutes. Said out loud in the button, because a spinner that sits for three minutes with no
* explanation is indistinguishable from one that has hung.
*/
const secure = async (host: Host) => {
setBusy(`cert:${host.id}`);
setFailed(false);
setOutput(`Asking for a certificate for ${host.domain_names.join(', ')}.\nThis waits for DNS to propagate and usually takes a few minutes.`);
try {
const certificate = await clientRef.current.post<{ id: number }>('/proxy-manager/domains/certificate', {
domain_names: host.domain_names,
});
await clientRef.current.put(`/proxy-manager/npm/nginx/proxy-hosts/${host.id}`, {
certificate_id: certificate.id,
ssl_forced: true,
});
setOutput(`Certificate issued for ${host.domain_names.join(', ')}. http now redirects to https.`);
await load();
} catch (failure) {
/*
Its refusals are specific and each one is somebody's to fix, so they are translated rather than
shown raw — "Not in the domain list" is a true sentence that tells nobody what to do next.
*/
const message = readableError(failure);
const apex = host.domain_names[0]?.split('.').slice(-2).join('.') ?? 'the domain';
setOutput(
message.includes('Not in the domain list')
? `${apex} is not in Officer's Domains list, so it does not know whose DNS it is. Add it there first.`
: message.includes('No API key')
? `${apex} is known but has no DNS API key stored. Add it in the Domains section.`
: message.includes('two DNS providers')
? 'One certificate can only cover names from a single DNS provider. Ask for them separately.'
: message,
);
setFailed(true);
} finally {
setBusy(null);
}
};
if (unavailable) {
return (
<Block icon={Globe} title="Domains" hint="Which addresses reach the site, through Nginx Proxy Manager.">
<p className="text-xs text-muted-foreground">
The Proxy Manager plugin is not available here, so there is nothing to show.
</p>
</Block>
);
}
const row = (server: Server) => {
const label = server.name === 'development' ? 'Development' : 'Production';
// Everything NPM already points at this server's port, whoever made it.
const mine = (hosts ?? []).filter((host) => host.forward_port === server.port);
return (
<div key={server.name} className="flex flex-col gap-2 border-t border-border pt-3 first:border-0 first:pt-0">
<p className="text-xs text-muted-foreground">
{label}
{server.port ? <span className="font-mono"> · port {server.port}</span> : ' · no port set'}
</p>
{mine.map((host) => (
<div key={host.id} className="flex flex-wrap items-center gap-3">
<p className="flex min-w-0 flex-1 items-center gap-1.5 text-xs">
{/* `nginx_online` is what "is the site up" means here — not whether the row exists. */}
<CircleDot
className={`size-3 ${host.meta?.nginx_online ? 'text-success' : host.enabled ? 'text-destructive' : 'text-muted-foreground/40'}`}
/>
<span className="truncate font-mono">{host.domain_names.join(', ')}</span>
{host.certificate_id ? (
<Lock className="size-3 shrink-0 text-success" />
) : (
<span className="shrink-0 text-muted-foreground/60">no certificate</span>
)}
</p>
{!host.certificate_id && (
<Button variant="outline" size="sm" onClick={() => void secure(host)} disabled={busy !== null}>
{busy === `cert:${host.id}` ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<ShieldCheck className="size-3.5" />
)}
{busy === `cert:${host.id}` ? 'Issuing…' : 'Add certificate'}
</Button>
)}
</div>
))}
<div className="flex gap-2">
<Input
value={domains[server.name] ?? ''}
onChange={(event) => setDomains((current) => ({ ...current, [server.name]: event.target.value }))}
placeholder={server.name === 'production' ? 'www.example.com' : 'dev.example.com'}
className="w-full font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<Button
variant="outline"
size="sm"
className="w-24 shrink-0 justify-center"
onClick={() => void create(server)}
disabled={busy !== null || !server.port || !forwardHost.trim() || !(domains[server.name] ?? '').trim()}
title={server.port ? undefined : 'Set a port for this server first'}
>
{busy === `${server.name}:create` ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Point here
</Button>
</div>
</div>
);
};
return (
<Block icon={Globe} title="Domains" hint="Which addresses reach the site, through Nginx Proxy Manager.">
{!hosts ? null : (
<>
{servers.map(row)}
<Console output={output} failed={failed} />
{/* Shown, not hidden: it is a guess made from other people's configuration, and NPM being in a
container is exactly why `127.0.0.1` would fail in a way that looks like the site is down. */}
<div className="flex items-center gap-2 border-t border-border pt-3">
<span className="text-xs text-muted-foreground/70">NPM reaches this machine at</span>
<Input
value={forwardHost}
onChange={(event) => setForwardHost(event.target.value)}
className="w-40 font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
</div>
<p className="text-xs text-muted-foreground/60">
Certificates are proved over DNS with the key Officer holds for the domain, so a name works
before it points here — and takes a few minutes. Removing or repointing a domain lives in
Proxy Manager, where the whole picture is.
</p>
</>
)}
</Block>
);
};