Files
fofos-admin/web/ProxiesBlock.tsx
T
pastilhasandClaude Opus 5 9b64933797 proxy hosts get HTTP/2
Created with `http2_support: false`, so the live site runs HTTP/1.1 — six connections and everything
else queued behind them. Lighthouse costs it 900 ms, most of it the gallery images waiting on each
other.

Set in the defaults and again in `attach`, because HTTP/2 only means anything once there is a
certificate. A host created before this — both of them here — is secured but still on 1.1, and the
attach path is the one that can fix it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:12:43 +01:00

417 lines
18 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,
/*
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<Host[] | null>(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<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);
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<number | null> => {
const all = await clientRef.current
.get<Array<{ id: number; domain_names: string[] }>>('/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,
});
};
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 (
<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.
/*
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 (
<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}
<span className="font-mono">
{' · '}
{server.name === 'production'
? staticTarget
? `${staticTarget.host}:${staticTarget.port}`
: 'static, no server yet'
: server.port
? `port ${server.port}`
: 'no port set'}
</span>
</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>
))}
{/*
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 && (
<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="min-w-0 flex-1 font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<Button
variant="outline"
size="sm"
className="w-28 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>
);
};
// 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 (
<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 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 && (
<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>
);
};