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(null); const [unavailable, setUnavailable] = useState(false); const [domains, setDomains] = useState>({}); const [forwardHost, setForwardHost] = useState(''); const [busy, setBusy] = useState(null); const [output, setOutput] = useState(null); const [failed, setFailed] = useState(false); const load = useCallback(async () => { try { const found = await clientRef.current.get('/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(); 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); } }; /** * Give a host a certificate: find one, or ask for one, then attach it. * * ── The request outlives the browser, and this has to cope ── * * Issuing waits for a DNS record to propagate — two minutes by default, before certbot even looks. * The proxy in front of Officer gives up at sixty seconds and answers 504, while certbot carries on * and succeeds. So the failure everybody sees is a timeout, and underneath it the certificate exists * and is simply not attached to anything: exactly what happened to dev.fofosfluffybastards.com. * * So a timeout is not treated as a failure. Afterwards — whether the call returned, failed, or timed * out — this looks for a certificate covering these names and attaches the one it finds. * * ── It never issues a second certificate for a name that already has one ── * * Checked FIRST, before asking for anything. A retry after a timeout would otherwise burn a Let's * Encrypt rate limit to obtain a duplicate of a certificate already sitting on the disk, and rate * limits are per-week: the cost of getting that wrong is not being able to fix it today. */ const findCertificate = async (names: string[]): Promise => { const all = await clientRef.current .get>('/proxy-manager/npm/nginx/certificates') .catch(() => [] as Array<{ id: number; domain_names: string[] }>); // Covers every name this host serves. A certificate for one of two domains is not this host's. const match = all.find((cert) => names.every((name) => cert.domain_names?.includes(name))); return match?.id ?? null; }; const attach = async (host: Host, certificateId: number) => { await clientRef.current.put(`/proxy-manager/npm/nginx/proxy-hosts/${host.id}`, { certificate_id: certificateId, ssl_forced: true, }); }; const secure = async (host: Host) => { const names = host.domain_names; setBusy(`cert:${host.id}`); setFailed(false); try { // Already issued — from an earlier attempt that timed out, or done by hand in Proxy Manager. const existing = await findCertificate(names); if (existing) { setOutput(`A certificate for ${names.join(', ')} already exists. Attaching it.`); await attach(host, existing); setOutput(`${names.join(', ')} now uses its certificate, and http redirects to https.`); await load(); return; } setOutput( `Asking for a certificate for ${names.join(', ')}.\n` + 'This waits for DNS to propagate and takes a few minutes. A timeout here is expected and is not a failure.', ); /* Deliberately not awaited on its own. The proxy will very likely cut this off before it returns, and the polling below is what actually establishes whether it worked — so a rejection here is recorded and then ignored in favour of asking the far side what exists. */ const requested = clientRef.current .post<{ id: number }>('/proxy-manager/domains/certificate', { domain_names: names }) .then((cert) => cert.id) .catch(() => null); // Up to five minutes, asking every ten seconds. Longer than the default propagation wait, because // a certificate that arrives at three minutes should still be attached. let certificateId = await requested; for (let attempt = 0; attempt < 30 && !certificateId; attempt++) { await new Promise((resolve) => setTimeout(resolve, 10_000)); certificateId = await findCertificate(names); } if (!certificateId) { setOutput( `No certificate for ${names.join(', ')} appeared within five minutes. It may still arrive — press this again and it will attach rather than ask for another.`, ); setFailed(true); return; } await attach(host, certificateId); setOutput(`Certificate issued for ${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 = 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 (

The Proxy Manager plugin is not available here, so there is nothing to show.

); } 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 (

{label} {server.port ? · port {server.port} : ' · no port set'}

{mine.map((host) => (

{/* `nginx_online` is what "is the site up" means here — not whether the row exists. */} {host.domain_names.join(', ')} {host.certificate_id ? ( ) : ( no certificate )}

{!host.certificate_id && ( )}
))} {/* Only while there is nothing here. A server with a domain has been set up, and the field asking for another is a form offering work nobody came to do — the second address for one site is the rare case, and it lives in Proxy Manager with the rest of the picture. `min-w-0` on the input: without it a flex child refuses to shrink below its content, and the button beside it gets pushed off the edge of the card. */} {mine.length === 0 && (
setDomains((current) => ({ ...current, [server.name]: event.target.value }))} placeholder={server.name === 'production' ? 'www.example.com' : 'dev.example.com'} className="min-w-0 flex-1 font-mono text-xs" autoComplete="off" spellCheck={false} />
)}
); }; // Nothing left to point anywhere: every server already has a domain. const anythingToCreate = servers.some((server) => !(hosts ?? []).some((host) => host.forward_port === server.port)); return ( {!hosts ? null : ( <> {servers.map(row)} {/* Shown while it is still needed, because 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. Once both servers have a domain it configures nothing, so it goes. */} {anythingToCreate && (
NPM reaches this machine at setForwardHost(event.target.value)} className="w-40 font-mono text-xs" autoComplete="off" spellCheck={false} />
)}

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.

)}
); };