A form offering work nobody came to do. Both servers were pointed at, both had certificates, and the block still showed two empty fields and two buttons — the second address for one site is the rare case, and it lives in Proxy Manager with the rest of the picture. So the field appears only while that server has nothing, and the forward address goes with them: once both are pointed somewhere it configures nothing. `min-w-0` on the input, because a flex child refuses to shrink below its content by default, which is what was pushing "Point here" off the edge of the card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
364 lines
15 KiB
TypeScript
364 lines
15 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);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
};
|
|
|
|
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.
|
|
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>
|
|
))}
|
|
|
|
{/*
|
|
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.
|
|
const anythingToCreate = servers.some((server) => !(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>
|
|
);
|
|
};
|