import { useCallback, useEffect, useRef, useState } from 'react'; import { CircleDot, Gauge, 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; http2_support: boolean; 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, /* HTTP/2. Only takes effect once a certificate is attached, which is why it is set here AND in `attach` — a host created before it was secured would otherwise keep HTTP/1.1 forever, and nothing in the panel would say so. On HTTP/1.1 a browser opens six connections and queues everything else behind them, which on this site meant the gallery images waited on each other. Lighthouse put it at 900 ms. */ http2_support: true, 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); /* Production is not a port on this machine. It is a container on the same docker network as NPM, so the forward target is a NAME — `fofos-web:80` — and the gateway address that reaches a host process would reach nothing. Read here rather than passed down, because the shape of production's target is this block's business. */ const [staticTarget, setStaticTarget] = useState<{ host: string; port: number } | null>(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); const web = await clientRef.current .get<{ container: { name: string } }>('/fofos-admin/_config/static') .catch(() => null); if (web?.container?.name) setStaticTarget({ host: web.container.name, port: 80 }); } 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 { // Production goes to the container by name; development to the host, at its port. const target = server.name === 'production' && staticTarget ? { host: staticTarget.host, port: staticTarget.port } : { host: forwardHost.trim(), port: server.port! }; await clientRef.current.post('/proxy-manager/npm/nginx/proxy-hosts', { ...NEW_HOST, domain_names: [domain], forward_scheme: 'http', forward_host: target.host, forward_port: target.port, }); setOutput(`${domain} → ${target.host}:${target.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, // The moment HTTP/2 becomes possible at all, so this is where it is turned on for a host that // predates the default above. http2_support: true, }); }; /** * Turn HTTP/2 on for a host that predates it being the default. * * Offered rather than done on load, even though it is one field and always the right answer: a * panel that silently rewrites the proxy while you are reading it is not something to build, and * the change momentarily reloads nginx for every site behind it. * * Only shown for a host that HAS a certificate — HTTP/2 is negotiated during the TLS handshake, so * on a plain host the setting is stored and never used, and offering it there would be offering a * button that does nothing visible. */ const upgrade = async (host: Host) => { setBusy(`http2:${host.id}`); setFailed(false); setOutput(null); try { await clientRef.current.put(`/proxy-manager/npm/nginx/proxy-hosts/${host.id}`, { http2_support: true }); setOutput(`${host.domain_names.join(', ')} → HTTP/2`); await load(); } catch (failure) { setOutput(readableError(failure)); setFailed(true); } finally { setBusy(null); } }; 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. /* What already points at this server. Matched on the FULL target rather than the port alone: production forwards to a container on port 80, and every other site behind this NPM that happens to use port 80 would otherwise be listed here as though it belonged to fofos. */ const mine = (hosts ?? []).filter((host) => server.name === 'production' && staticTarget ? host.forward_host === staticTarget.host && host.forward_port === staticTarget.port : host.forward_port === server.port && server.port !== null, ); return (

{label} {' · '} {server.name === 'production' ? staticTarget ? `${staticTarget.host}:${staticTarget.port}` : 'static, no server yet' : 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 )}

{/* Only when it is both possible and missing. A host created since http2_support became the default never shows this, which is the point — it is a repair, not a setting. */} {host.certificate_id > 0 && !host.http2_support && ( )} {!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. // Development only: production's target is a container name, not the gateway address this field holds. const anythingToCreate = servers.some( (server) => server.name === 'development' && !(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.

)}
); };