`http2_support: true` is the default now and is set when a host is secured, but both hosts here were created and secured before either change, so nothing re-runs and they stay on HTTP/1.1 forever. Lighthouse costs that 670 ms. An explicit action rather than a fix applied on load: a panel that silently rewrites the proxy while somebody is reading it is not a thing to build, and the change reloads nginx for every site behind it. Only offered where it can do something — a host with a certificate that lacks it. HTTP/2 is negotiated during the TLS handshake, so on a plain host the field is stored and never used, and the button would do nothing visible. A host created since the default never shows it: this is a repair, not a setting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
460 lines
19 KiB
TypeScript
460 lines
19 KiB
TypeScript
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<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,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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 (
|
|
<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>
|
|
|
|
{/*
|
|
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 && (
|
|
<Button variant="outline" size="sm" onClick={() => void upgrade(host)} disabled={busy !== null}>
|
|
{busy === `http2:${host.id}` ? (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
) : (
|
|
<Gauge className="size-3.5" />
|
|
)}
|
|
{busy === `http2:${host.id}` ? 'Enabling…' : 'Enable HTTP/2'}
|
|
</Button>
|
|
)}
|
|
|
|
{!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>
|
|
);
|
|
};
|