diff --git a/web/ProxiesBlock.tsx b/web/ProxiesBlock.tsx new file mode 100644 index 0000000..a8e77fc --- /dev/null +++ b/web/ProxiesBlock.tsx @@ -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(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); + } 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 ( + +

+ 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. + const mine = (hosts ?? []).filter((host) => host.forward_port === server.port); + + return ( +
+

+ {label} + {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 + )} +

+ + {!host.certificate_id && ( + + )} +
+ ))} + +
+ 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} + /> + +
+
+ ); + }; + + return ( + + {!hosts ? null : ( + <> + {servers.map(row)} + + + + {/* 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. */} +
+ NPM reaches this machine at + setForwardHost(event.target.value)} + className="w-40 font-mono text-xs" + autoComplete="off" + spellCheck={false} + /> +
+ +

+ Removing or repointing a domain lives in Proxy Manager, where the whole picture is. +

+ + )} +
+ ); +}; diff --git a/web/ServersBlock.tsx b/web/ServersBlock.tsx index 2527a92..95bf2d9 100644 --- a/web/ServersBlock.tsx +++ b/web/ServersBlock.tsx @@ -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 ( + <> { )} + + {/* 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 && ({ name: s.name, port: s.port }))} />} + ); };