a Domains block, pointing NPM at the two servers

It talks to the proxy-manager PLUGIN, from the browser, with the user's own
session. Not to NPM, and not from this plugin's server: NPM's credential belongs
to proxy-manager and its skill is explicit that the pass-through route is the
sanctioned path. Calling it this way means this plugin never holds that
credential, and two plugins do not acquire a server-to-server dependency that
each would then have to know the installed state of.

forward_host is copied from what already works here. NPM runs in a container, so
127.0.0.1 there is NPM itself rather than this machine — a host pointed at it
resolves, serves nothing, and looks exactly like the site being down. The
correct 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 by majority from the hosts NPM already serves, since those are
by construction addresses that work. It is shown in an editable field, because a
guess made from other people's configuration should be visible before it is used.

Creating is offered and deleting is not. A new host is additive and reversible;
removing or repointing one is a live domain changing where it goes, and this
panel would be offering that with a fraction of the context the Proxy Manager
screen has.

Certificates are two calls, because NPM issues one as its own object and a host
merely references it. The body is exactly {provider, domain_names} —
letsencrypt_email and letsencrypt_agree are what every tutorial shows and NPM
2.15 rejects the request for including them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:58:50 +01:00
co-authored by Claude Opus 5
parent b27969ba57
commit a064a3d0d6
2 changed files with 270 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
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);
}
};
/**
* Ask Let's Encrypt for a certificate, then attach it.
*
* Two calls, because NPM issues a certificate as its own object and a host merely references one. The
* body is exactly `{provider, domain_names}`: `letsencrypt_email` and `letsencrypt_agree` are what
* every tutorial shows and NPM 2.15 rejects the request outright for including them.
*/
const secure = async (host: Host) => {
setBusy(`cert:${host.id}`);
setFailed(false);
setOutput(null);
try {
const certificate = await clientRef.current.post<{ id: number }>('/proxy-manager/npm/nginx/certificates', {
provider: 'letsencrypt',
domain_names: host.domain_names,
});
await clientRef.current.put(`/proxy-manager/npm/nginx/proxy-hosts/${host.id}`, {
certificate_id: certificate.id,
ssl_forced: true,
});
setOutput(`Certificate issued for ${host.domain_names.join(', ')} and http now redirects to https.`);
await load();
} catch (failure) {
setOutput(readableError(failure));
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" />
)}
Add certificate
</Button>
)}
</div>
))}
<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="w-full font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<Button
variant="outline"
size="sm"
className="w-24 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>
);
};
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, not hidden: 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. */}
<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">
Removing or repointing a domain lives in Proxy Manager, where the whole picture is.
</p>
</>
)}
</Block>
);
};
+8
View File
@@ -7,6 +7,7 @@ import { readableError } from './errors';
import { Block } from './Block';
import { Console } from './Console';
import { useFlash } from './useFlash';
import { ProxiesBlock } from './ProxiesBlock';
/**
* The two servers that serve the site, under pm2.
@@ -197,6 +198,7 @@ export const ServersBlock = () => {
};
return (
<>
<Block
icon={Server}
title="Servers"
@@ -230,5 +232,11 @@ export const ServersBlock = () => {
</>
)}
</Block>
{/* Directly under the servers, and given their ports rather than fetching them again: a domain is
only meaningful as the front door to one of these two, and the block above already knows which
port each is on. */}
{servers && <ProxiesBlock servers={servers.map((s) => ({ name: s.name, port: s.port }))} />}
</>
);
};