From 42feb7326427ec88729313cf5e9f36f8f1573205 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 21 Oct 2026 13:01:33 +0100 Subject: [PATCH] Update Reverse proxy and TLS configuration on Caddy --- db/schema.ts | 4 + db/types/db.ts | 3 +- .../general/client-route-form/index.tsx | 37 +++++- .../submit-client-route-form.ts | 1 + .../client-routes-table-columns.tsx | 10 ++ src/dict/variables-dict.ts | 10 ++ .../backend/acme/decorate-client-route.ts | 26 ++++ src/functions/backend/acme/grab-tls-status.ts | 9 +- .../backend/caddy/build-caddyfile.test.ts | 50 +++++++- .../backend/caddy/build-caddyfile.ts | 67 +++++++--- .../backend/caddy/grab-caddy-settings.ts | 53 ++++++++ .../backend/caddy/grab-caddy-status.ts | 11 +- .../backend/caddy/sanitize-client-route.ts | 4 + .../backend/caddy/sync-caddy-ingress.ts | 13 +- .../(sections)/route-overview-section.tsx | 13 ++ .../routes/[route_id]/index.server.ts | 8 +- .../routes/edit/[route_id]/index.server.ts | 3 + .../tls/(sections)/tls-settings-section.tsx | 119 ++++++++++++++++++ src/pages/admin/setup/tls/index.tsx | 2 + .../admin/crud/(tables)/client_routes/get.ts | 4 + src/pages/api/admin/tls/settings.ts | 107 ++++++++++++++++ .../login/(partials)/login-form/index.tsx | 22 ++-- 22 files changed, 539 insertions(+), 37 deletions(-) create mode 100644 src/functions/backend/caddy/grab-caddy-settings.ts create mode 100644 src/pages/admin/setup/tls/(sections)/tls-settings-section.tsx create mode 100644 src/pages/api/admin/tls/settings.ts diff --git a/db/schema.ts b/db/schema.ts index 25de3f5..997bdda 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -351,6 +351,10 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { fieldName: "acme_credentials", dataType: "TEXT", }, + { + fieldName: "allowed_ips", + dataType: "TEXT", + }, ], uniqueConstraints: [ { diff --git a/db/types/db.ts b/db/types/db.ts index 0e1570e..9dfe629 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -191,6 +191,7 @@ export type BUN_SQLITE_WGUI_CLIENT_ROUTES = { acme_dns_provider?: string; acme_staging?: 0 | 1 | ""; acme_credentials?: string; + allowed_ips?: string; } export type BUN_SQLITE_WGUI_TLS_CERTIFICATES = { @@ -232,7 +233,7 @@ export type BUN_SQLITE_WGUI_VARIABLES = { * The time when the record was updated. (Unix Timestamp) */ updated_at?: number | ""; - key?: "test_var" | ""; + key?: "test_var" | "caddy_https_port" | "caddy_https_redirect" | ""; value?: string; } diff --git a/src/components/general/client-route-form/index.tsx b/src/components/general/client-route-form/index.tsx index a2478ef..689a8f7 100644 --- a/src/components/general/client-route-form/index.tsx +++ b/src/components/general/client-route-form/index.tsx @@ -12,6 +12,8 @@ import FormSection from "../form-section"; import FormActions from "../form-actions"; import InlineAlert from "../inline-alert"; import submitClientRouteForm from "./submit-client-route-form"; +import { useContext } from "react"; +import { AppContext } from "@/src/pages/__root"; type Props = { host_id: number; @@ -25,6 +27,7 @@ export default function ClientRouteForm({ cancel_href, }: Props) { const { confirm } = useConfirm(); + const { user } = useContext(AppContext); const init = useFormInit({ existing: existing_route, @@ -33,6 +36,14 @@ export default function ClientRouteForm({ ? `Edit route v2 ${existing_route.id}` : `Add client route v2 ${host_id}`, local_storage_exclude_keys: ["acme_credentials"], + async before_ready_function(params) { + if (!params.form.acme_email) { + params.setForm((prev) => ({ + ...prev, + acme_email: user?.email, + })); + } + }, }); const { form, setForm, loading, status, ready } = init; @@ -56,7 +67,11 @@ export default function ClientRouteForm({ } > {status?.error ? ( - + ) : null} + + + setForm((prev) => ({ + ...prev, + allowed_ips: e.target.value, + })) + } + showLabel + info="Comma-separated IPv4 CIDR ranges. These are appended to the Caddy remote_ip matcher for this route." + /> + + {item.path_prefix || "/"}, }, + { + key: "public_url", + header: "Public URL", + hide_below: "lg", + render: ({ item }) => ( + + {item.public_url || "—"} + + ), + }, { key: "upstream", header: "Upstream", diff --git a/src/dict/variables-dict.ts b/src/dict/variables-dict.ts index aaee859..673d70d 100644 --- a/src/dict/variables-dict.ts +++ b/src/dict/variables-dict.ts @@ -4,6 +4,16 @@ export const Variables = [ value: "test_var", description: `Just a test variable to define the schema`, }, + { + title: `Caddy HTTPS Port`, + value: "caddy_https_port", + description: `Public HTTPS port Caddy listens on. Defaults to 8443.`, + }, + { + title: `Caddy HTTPS Redirect`, + value: "caddy_https_redirect", + description: `Redirect HTTP requests to HTTPS. Set to 0 to disable. Defaults to 1.`, + }, // { // title: `Main Host Wireguard IP Address`, // value: "main_host_wg_ip_address", diff --git a/src/functions/backend/acme/decorate-client-route.ts b/src/functions/backend/acme/decorate-client-route.ts index 9f2f53c..3c7f1b6 100644 --- a/src/functions/backend/acme/decorate-client-route.ts +++ b/src/functions/backend/acme/decorate-client-route.ts @@ -2,6 +2,7 @@ import type { BUN_SQLITE_WGUI_CLIENT_ROUTES, BUN_SQLITE_WGUI_TLS_CERTIFICATES, } from "@/db/types/db"; +import { TlsData } from "@/src/data/tls-data"; import readCertificateExpiry from "./read-certificate-expiry"; export type ClientRouteView = BUN_SQLITE_WGUI_CLIENT_ROUTES & { @@ -9,16 +10,40 @@ export type ClientRouteView = BUN_SQLITE_WGUI_CLIENT_ROUTES & { acme_credentials_configured?: boolean; certificate_status?: string; certificate_expires_at?: number | null; + public_url?: string; }; type Params = { route: BUN_SQLITE_WGUI_CLIENT_ROUTES; certificate?: BUN_SQLITE_WGUI_TLS_CERTIFICATES | null; + https_port?: number; }; +function buildPublicUrl({ + route, + https_port, +}: { + route: BUN_SQLITE_WGUI_CLIENT_ROUTES; + https_port?: number; +}) { + const hostname = route.hostname?.trim(); + + if (!hostname) { + return undefined; + } + + const port = https_port || TlsData.CaddyHttpsPort; + const port_part = port == 443 ? "" : `:${port}`; + const path_prefix = route.path_prefix?.trim() || "/"; + const path = path_prefix == "/" ? "/" : path_prefix; + + return `https://${hostname}${port_part}${path}`; +} + export default function decorateClientRoute({ route, certificate, + https_port, }: Params): ClientRouteView { const { acme_credentials, ...safe_route } = route; const expires_at = readCertificateExpiry({ @@ -45,5 +70,6 @@ export default function decorateClientRoute({ acme_credentials_configured: Boolean(acme_credentials), certificate_status, certificate_expires_at: expires_at || null, + public_url: buildPublicUrl({ route, https_port }), }; } diff --git a/src/functions/backend/acme/grab-tls-status.ts b/src/functions/backend/acme/grab-tls-status.ts index 9d18586..7af3209 100644 --- a/src/functions/backend/acme/grab-tls-status.ts +++ b/src/functions/backend/acme/grab-tls-status.ts @@ -6,6 +6,7 @@ import BunSQLite from "@moduletrace/bun-sqlite"; import type { TableType } from "@/src/types"; import { TlsData } from "@/src/data/tls-data"; import grabCaddyStatus from "../caddy/grab-caddy-status"; +import grabCaddySettings from "../caddy/grab-caddy-settings"; import grabCertDirNames from "../caddy/grab-cert-dir-names"; import readAcmeEnv from "./read-acme-env"; import readCertificateExpiry from "./read-certificate-expiry"; @@ -95,8 +96,10 @@ export default async function grabTlsStatus() { (a.hostname || "").localeCompare(b.hostname || ""), ); + const settings = await grabCaddySettings(); + return { - caddy: grabCaddyStatus(), + caddy: grabCaddyStatus(settings), certificates, acme: { env_path: acme_env.exists ? `/etc/wgui/acme.env` : null, @@ -112,6 +115,8 @@ export default async function grabTlsStatus() { ), staging: TlsData.AcmeStaging, }, - https_port: TlsData.CaddyHttpsPort, + https_port: settings.https_port, + http_port: settings.http_port, + redirect: settings.redirect, }; } diff --git a/src/functions/backend/caddy/build-caddyfile.test.ts b/src/functions/backend/caddy/build-caddyfile.test.ts index 0727d53..5798c0f 100644 --- a/src/functions/backend/caddy/build-caddyfile.test.ts +++ b/src/functions/backend/caddy/build-caddyfile.test.ts @@ -37,11 +37,22 @@ describe("buildCaddyfile", () => { expect(res.config).toContain("https_port 8443"); expect(res.config).toContain("http_port 8081"); - expect(res.config).toContain("auto_https disable_redirects"); + expect(res.config).not.toContain("auto_https disable_redirects"); expect(res.config).not.toContain("https_port 443"); expect(res.config).not.toContain("http_port 80\n"); }); + test("can disable automatic HTTPS redirects", () => { + const res = buildCaddyfile({ + ...base, + redirect: false, + routes: [], + certificates: [], + }); + + expect(res.config).toContain("auto_https disable_redirects"); + }); + test("orders longer prefixes before the catch-all", () => { const res = buildCaddyfile({ ...base, @@ -88,6 +99,25 @@ describe("buildCaddyfile", () => { expect(res.config).toContain("remote_ip 10.5.0.0/24"); }); + test("appends route allowed_ips to the remote_ip matcher", () => { + const res = buildCaddyfile({ + ...base, + certificates: [certificate], + hosts: [{ id: 0, wg_ip_address: "10.5.0.1" }], + routes: [ + route({ + host_id: 0, + path_prefix: "/app", + allowed_ips: "203.0.113.0/24, 198.51.100.0/24", + }), + ], + }); + + expect(res.config).toContain( + "remote_ip 10.5.0.0/24 127.0.0.1/32 ::1/128 203.0.113.0/24 198.51.100.0/24", + ); + }); + test("maps insecure upstream TLS to Caddy", () => { const res = buildCaddyfile({ ...base, @@ -102,4 +132,22 @@ describe("buildCaddyfile", () => { expect(res.config).toContain("tls_insecure_skip_verify"); }); + + test("allows a catch-all route with allowed_ips to have a matcher", () => { + const res = buildCaddyfile({ + ...base, + certificates: [certificate], + hosts: [{ id: 0, wg_ip_address: "10.5.0.1" }], + routes: [ + route({ + host_id: 0, + path_prefix: "/", + allowed_ips: "203.0.113.0/24", + }), + ], + }); + + expect(res.config).toContain("@route_1 remote_ip"); + expect(res.config).toContain("203.0.113.0/24"); + }); }); diff --git a/src/functions/backend/caddy/build-caddyfile.ts b/src/functions/backend/caddy/build-caddyfile.ts index 8c70141..31e5e2f 100644 --- a/src/functions/backend/caddy/build-caddyfile.ts +++ b/src/functions/backend/caddy/build-caddyfile.ts @@ -20,6 +20,8 @@ type Params = { hosts?: BUN_SQLITE_WGUI_HOSTS[]; https_port: number; http_port: number; + /** Redirect plain HTTP requests to HTTPS. Enabled by default. */ + redirect?: boolean; admin_socket: string; storage_dir: string; }; @@ -47,6 +49,33 @@ function reverseProxyBlock({ return lines; } +function buildRemoteIpValue({ + subnet, + route, +}: { + subnet?: string; + route: BUN_SQLITE_WGUI_CLIENT_ROUTES; +}) { + const parts: string[] = []; + + if (subnet) { + parts.push(subnet as string); + } + + parts.push("127.0.0.1/32", "::1/128"); + + const allowed = (route.allowed_ips || "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + for (const ip of allowed) { + parts.push(ip); + } + + return parts.join(" "); +} + function routeBlock({ route, index, @@ -59,8 +88,9 @@ function routeBlock({ const path_prefix = route.path_prefix || "/"; const matcher = `route_${index}`; const lines: string[] = []; + const remote_ip_value = buildRemoteIpValue({ subnet, route }); - if (path_prefix == "/" && !subnet) { + if (path_prefix == "/" && !subnet && !route.allowed_ips) { lines.push(` handle {`); lines.push(...reverseProxyBlock({ route, indent: " " })); lines.push(` }`); @@ -68,17 +98,13 @@ function routeBlock({ } if (path_prefix == "/") { - lines.push( - ` @${matcher} remote_ip ${subnet} 127.0.0.1/32 ::1/128`, - ); + lines.push(` @${matcher} remote_ip ${remote_ip_value}`); } else { lines.push(` @${matcher} {`); lines.push(` path ${path_prefix} ${path_prefix}/*`); - if (subnet) { - lines.push( - ` remote_ip ${subnet} 127.0.0.1/32 ::1/128`, - ); + if (remote_ip_value.trim()) { + lines.push(` remote_ip ${remote_ip_value}`); } lines.push(` }`); @@ -107,6 +133,7 @@ export default function buildCaddyfile({ hosts, https_port, http_port, + redirect = true, admin_socket, storage_dir, }: Params): CaddyBuildResult { @@ -117,16 +144,25 @@ export default function buildCaddyfile({ for (let i = 0; i < (hosts || []).length; i++) { const host = hosts?.[i]; const parts = (host?.wg_ip_address || "").trim().split("."); + const matchers: string[] = []; if ( - host?.id !== undefined && parts.length == 4 && parts.every((part) => /^\d{1,3}$/.test(part)) ) { - subnet_by_host_id.set( - Number(host.id), - `${parts.slice(0, 3).join(".")}.0/24`, - ); + matchers.push(`${parts.slice(0, 3).join(".")}.0/24`); + } + + // Requests made to the host's own WireGuard address are masqueraded to + // its LAN/public interface, so Caddy sees that address as the source. + // Include it explicitly or those requests fall through to an empty 200. + const public_ip = (host?.public_ip_address || "").trim(); + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(public_ip)) { + matchers.push(`${public_ip}/32`); + } + + if (host?.id !== undefined && matchers.length) { + subnet_by_host_id.set(Number(host.id), matchers.join(" ")); } } const certificates_by_hostname = new Map< @@ -142,8 +178,7 @@ export default function buildCaddyfile({ hostname && certificate?.cert_path && certificate.key_path && - (certificate.status == "active" || - certificate.status == "expiring") + (certificate.status == "active" || certificate.status == "expiring") ) { certificates_by_hostname.set(hostname, { ...certificate, @@ -215,7 +250,7 @@ export default function buildCaddyfile({ `{`, ` https_port ${https_port}`, ` http_port ${http_port}`, - ` auto_https disable_redirects`, + ...(redirect ? [] : [` auto_https disable_redirects`]), ` admin unix/${admin_socket}`, ` storage file_system ${storage_dir}`, `}`, diff --git a/src/functions/backend/caddy/grab-caddy-settings.ts b/src/functions/backend/caddy/grab-caddy-settings.ts new file mode 100644 index 0000000..91a30aa --- /dev/null +++ b/src/functions/backend/caddy/grab-caddy-settings.ts @@ -0,0 +1,53 @@ +import type { BUN_SQLITE_WGUI_VARIABLES } from "@/db/types/db"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { TableType } from "@/src/types"; +import { TlsData } from "@/src/data/tls-data"; + +export type CaddySettings = { + https_port: number; + http_port: number; + redirect: boolean; +}; + +function parseHttpsPort(value: unknown, fallback: number) { + const parsed = Number(value); + + if ( + !Number.isInteger(parsed) || + parsed < 1 || + parsed > 65535 || + parsed == 80 || + parsed == 443 + ) { + return fallback; + } + + return parsed; +} + +/** + * Caddy settings are stored in the `variables` table so the port and redirect + * behaviour can be changed from the UI without restarting the app. + */ +export default async function grabCaddySettings(): Promise { + const res = await BunSQLite.select({ + table: "variables", + }); + + const values = new Map(); + + for (const variable of res.payload || []) { + if (variable?.key) { + values.set(variable.key, variable.value || ""); + } + } + + return { + https_port: parseHttpsPort( + values.get("caddy_https_port"), + TlsData.CaddyHttpsPort, + ), + http_port: TlsData.CaddyHttpPort, + redirect: values.get("caddy_https_redirect") !== "0", + }; +} diff --git a/src/functions/backend/caddy/grab-caddy-status.ts b/src/functions/backend/caddy/grab-caddy-status.ts index 9f5a91c..a5600b0 100644 --- a/src/functions/backend/caddy/grab-caddy-status.ts +++ b/src/functions/backend/caddy/grab-caddy-status.ts @@ -76,7 +76,12 @@ function serviceActive() { return null; } -export default function grabCaddyStatus() { +type Settings = { + https_port?: number; + http_port?: number; +}; + +export default function grabCaddyStatus(settings?: Settings) { const state = readState(); return { @@ -85,8 +90,8 @@ export default function grabCaddyStatus() { acme_installed: existsSync(WGUI_ACME_SCRIPT), acme_env_exists: existsSync(WGUI_ACME_ENV_FILE), version: version(), - https_port: TlsData.CaddyHttpsPort, - http_port: TlsData.CaddyHttpPort, + https_port: settings?.https_port || TlsData.CaddyHttpsPort, + http_port: settings?.http_port || TlsData.CaddyHttpPort, enabled: TlsData.IngressEnabled, config_path: WGUI_LIB_CADDY_CONFIG_FILE, state, diff --git a/src/functions/backend/caddy/sanitize-client-route.ts b/src/functions/backend/caddy/sanitize-client-route.ts index 94cf58a..d33258c 100644 --- a/src/functions/backend/caddy/sanitize-client-route.ts +++ b/src/functions/backend/caddy/sanitize-client-route.ts @@ -93,6 +93,10 @@ export default function sanitizeClientRoute({ typeof route.acme_credentials == "string" ? route.acme_credentials : "", + allowed_ips: + typeof route.allowed_ips == "string" + ? route.allowed_ips.trim() + : "", } satisfies BUN_SQLITE_WGUI_CLIENT_ROUTES, }; } diff --git a/src/functions/backend/caddy/sync-caddy-ingress.ts b/src/functions/backend/caddy/sync-caddy-ingress.ts index 7d2f39a..8ed45a8 100644 --- a/src/functions/backend/caddy/sync-caddy-ingress.ts +++ b/src/functions/backend/caddy/sync-caddy-ingress.ts @@ -20,6 +20,7 @@ import { TlsData } from "@/src/data/tls-data"; import buildCaddyfile from "./build-caddyfile"; import reloadCaddyService from "./reload-caddy-service"; import applyCaddyIngressFirewallRule from "./apply-caddy-ingress-firewall-rule"; +import grabCaddySettings from "./grab-caddy-settings"; export type CaddySyncResult = { success: boolean; @@ -120,12 +121,15 @@ async function doSync({ reason }: Params): Promise { : ("active" as const), })); + const settings = await grabCaddySettings(); + const built = buildCaddyfile({ routes, certificates, hosts, - https_port: TlsData.CaddyHttpsPort, - http_port: TlsData.CaddyHttpPort, + https_port: settings.https_port, + http_port: settings.http_port, + redirect: settings.redirect, admin_socket: WGUI_LIB_CADDY_ADMIN_SOCKET, storage_dir: WGUI_LIB_CADDY_DATA_DIR, }); @@ -218,7 +222,10 @@ async function doSync({ reason }: Params): Promise { if (typeof host_id != "number") continue; - const firewall = applyCaddyIngressFirewallRule({ host_id }); + const firewall = applyCaddyIngressFirewallRule({ + host_id, + port: settings.https_port, + }); if (!firewall.success && firewall.msg) { warnings.push(firewall.msg); diff --git a/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-overview-section.tsx b/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-overview-section.tsx index d577fe4..04271ef 100644 --- a/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-overview-section.tsx +++ b/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-overview-section.tsx @@ -45,6 +45,13 @@ export default function RouteOverviewSection({ route, host }: Props) { mono: true, copy_value: route.hostname || "", }, + { + key: "public_url", + label: "Public URL", + value: route.public_url || "—", + mono: true, + copy_value: route.public_url || "", + }, { key: "path", label: "Path prefix", @@ -89,6 +96,12 @@ export default function RouteOverviewSection({ route, host }: Props) { label: "Staging CA", value: route.acme_staging == 1 ? "Yes" : "No", }, + { + key: "allowed_ips", + label: "Allowed IPs", + value: route.allowed_ips || "All from host subnet", + mono: true, + }, ]} /> diff --git a/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.server.ts b/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.server.ts index 51406ef..f29bb04 100644 --- a/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.server.ts +++ b/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.server.ts @@ -3,6 +3,7 @@ import type { BUN_SQLITE_WGUI_HOSTS, } from "@/db/types/db"; import decorateClientRoute from "@/src/functions/backend/acme/decorate-client-route"; +import grabCaddySettings from "@/src/functions/backend/caddy/grab-caddy-settings"; import grabCertificateByHostname from "@/src/functions/backend/acme/grab-certificate-by-hostname"; import { grabManualDnsChallenge } from "@/src/functions/backend/acme/manual-dns-challenges"; import type { PagePropsType, PageQueryObject, TableType } from "@/src/types"; @@ -31,6 +32,7 @@ const server: BunextPageServerFn = async ({ query }) => { }); const route = route_res.singleRes; + const settings = await grabCaddySettings(); const certificate = route ? await grabCertificateByHostname({ hostname: route.hostname }) : null; @@ -42,7 +44,11 @@ const server: BunextPageServerFn = async ({ query }) => { props: { host: host_res.singleRes || null, client_route: route - ? decorateClientRoute({ route, certificate }) + ? decorateClientRoute({ + route, + certificate, + https_port: settings.https_port, + }) : null, client_certificate: certificate, client_challenge: pending diff --git a/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.server.ts b/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.server.ts index e226685..bce64e9 100644 --- a/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.server.ts +++ b/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.server.ts @@ -3,6 +3,7 @@ import type { BUN_SQLITE_WGUI_HOSTS, } from "@/db/types/db"; import decorateClientRoute from "@/src/functions/backend/acme/decorate-client-route"; +import grabCaddySettings from "@/src/functions/backend/caddy/grab-caddy-settings"; import grabCertificateByHostname from "@/src/functions/backend/acme/grab-certificate-by-hostname"; import type { PagePropsType, PageQueryObject, TableType } from "@/src/types"; import BunSQLite from "@moduletrace/bun-sqlite"; @@ -30,6 +31,7 @@ const server: BunextPageServerFn = async ({ query }) => { }); const route = route_res.singleRes; + const settings = await grabCaddySettings(); return { props: { @@ -40,6 +42,7 @@ const server: BunextPageServerFn = async ({ query }) => { certificate: await grabCertificateByHostname({ hostname: route.hostname, }), + https_port: settings.https_port, }) : null, }, diff --git a/src/pages/admin/setup/tls/(sections)/tls-settings-section.tsx b/src/pages/admin/setup/tls/(sections)/tls-settings-section.tsx new file mode 100644 index 0000000..5cb5188 --- /dev/null +++ b/src/pages/admin/setup/tls/(sections)/tls-settings-section.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; +import type { BunextPageModuleMeta } from "@moduletrace/bunext/types"; +import Panel from "@/src/components/general/panel"; +import Form from "@/src/components/twui/form/Form"; +import Input from "@/src/components/twui/form/Input"; +import Checkbox from "@/src/components/twui/form/Checkbox"; +import FormActions from "@/src/components/general/form-actions"; +import InlineAlert from "@/src/components/general/inline-alert"; +import useToast from "@/src/hooks/use-toast"; +import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; +import type useTlsStatus from "../../(hooks)/use-tls-status"; + +type Props = { + tls: ReturnType; +}; + +type SettingsForm = { + https_port: string; + redirect: boolean; +}; + +export default function TlsSettingsSection({ tls }: Props) { + const { toast } = useToast(); + const [form, setForm] = useState({ + https_port: "", + redirect: true, + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (tls.tls_status) { + setForm({ + https_port: String(tls.tls_status.https_port || 8443), + redirect: tls.tls_status.redirect !== false, + }); + } + }, [tls.tls_status]); + + const submit = async () => { + setSaving(true); + setError(null); + + const res = await fetchApi(`/api/admin/tls/settings`, { + method: "POST", + body: { + https_port: Number(form.https_port), + redirect: form.redirect, + }, + }); + + setSaving(false); + + if (res.success) { + toast({ msg: "TLS settings saved", color: "success" }); + await tls.refresh(); + } else { + setError(res.msg || "Could not save TLS settings"); + } + }; + + if (!tls.tls_status) { + return null; + } + + return ( + +
{ + e.preventDefault(); + submit(); + }} + > + {error ? ( + + ) : null} + + + setForm((prev) => ({ + ...prev, + https_port: e.target.value, + })) + } + showLabel + required + info="Caddy listens on this port for HTTPS. Ports 80 and 443 are not allowed." + /> + + + setForm((prev) => ({ ...prev, redirect: value })) + } + info="When enabled, Caddy redirects plain HTTP requests to the HTTPS port. This is the default." + /> + + + +
+ ); +} diff --git a/src/pages/admin/setup/tls/index.tsx b/src/pages/admin/setup/tls/index.tsx index 2986e24..4625928 100644 --- a/src/pages/admin/setup/tls/index.tsx +++ b/src/pages/admin/setup/tls/index.tsx @@ -3,6 +3,7 @@ import { SiteData } from "@/src/data/site-data"; import useTlsStatus from "../(hooks)/use-tls-status"; import SetupPageFrame from "../(sections)/setup-page-frame"; import TlsStatusSection from "./(sections)/tls-status-section"; +import TlsSettingsSection from "./(sections)/tls-settings-section"; import TlsInstallSection from "./(sections)/tls-install-section"; import TlsSetupSection from "./(sections)/tls-setup-section"; @@ -15,6 +16,7 @@ export default function SetupTlsPage() { description="Serve configured client routes over HTTPS without touching the host's ports 80 or 443." > + diff --git a/src/pages/api/admin/crud/(tables)/client_routes/get.ts b/src/pages/api/admin/crud/(tables)/client_routes/get.ts index dfdef4f..0982d84 100644 --- a/src/pages/api/admin/crud/(tables)/client_routes/get.ts +++ b/src/pages/api/admin/crud/(tables)/client_routes/get.ts @@ -4,6 +4,7 @@ import type { } from "@/db/types/db"; import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; import decorateClientRoute from "@/src/functions/backend/acme/decorate-client-route"; +import grabCaddySettings from "@/src/functions/backend/caddy/grab-caddy-settings"; import { grabManualDnsChallenge } from "@/src/functions/backend/acme/manual-dns-challenges"; import type { AdminCrudAPIParams, TableType } from "@/src/types"; import BunSQLite from "@moduletrace/bun-sqlite"; @@ -51,10 +52,13 @@ export default async function ( ]), ); + const settings = await grabCaddySettings(); + const decorate = (route: BUN_SQLITE_WGUI_CLIENT_ROUTES) => { const view = decorateClientRoute({ route, certificate: certificates_by_hostname.get(route.hostname || ""), + https_port: settings.https_port, }); if ( diff --git a/src/pages/api/admin/tls/settings.ts b/src/pages/api/admin/tls/settings.ts new file mode 100644 index 0000000..22ee3e9 --- /dev/null +++ b/src/pages/api/admin/tls/settings.ts @@ -0,0 +1,107 @@ +import adminApiGuard from "@/src/functions/backend/auth/admin-api-guard"; +import grabCaddySettings from "@/src/functions/backend/caddy/grab-caddy-settings"; +import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; +import type { BUN_SQLITE_WGUI_VARIABLES } from "@/db/types/db"; +import type { ApiReqParams, TableType } from "@/src/types"; + +type SettingsBody = { + https_port?: number; + redirect?: boolean; +}; + +export const handler: BunextAPIRouteHandler = async ({ + req, + body, +}) => { + const unauthorized = await adminApiGuard({ req }); + + if (unauthorized) { + return unauthorized; + } + + if (req.method === "GET") { + const settings = await grabCaddySettings(); + + return { + success: true, + singleRes: settings, + payload: [settings], + }; + } + + if (req.method !== "POST") { + return { success: false, msg: `Method not allowed` }; + } + + const input = (body || {}) as ApiReqParams & SettingsBody; + const updates: { key: string; value: string }[] = []; + + if (input.https_port !== undefined) { + const port = Number(input.https_port); + + if ( + !Number.isInteger(port) || + port < 1 || + port > 65535 || + port == 80 || + port == 443 + ) { + return { + success: false, + msg: `HTTPS port must be between 1 and 65535 and cannot be 80 or 443`, + }; + } + + updates.push({ key: "caddy_https_port", value: String(port) }); + } + + if (input.redirect !== undefined) { + updates.push({ + key: "caddy_https_redirect", + value: input.redirect ? "1" : "0", + }); + } + + if (!updates.length) { + return { success: false, msg: `No settings to update` }; + } + + for (const update of updates) { + const existing = await BunSQLite.select< + BUN_SQLITE_WGUI_VARIABLES, + TableType + >({ + table: "variables", + }); + + const row = existing.payload?.find( + (variable) => variable.key === update.key, + ); + + if (row) { + await BunSQLite.update({ + table: "variables", + targetId: row.id, + data: { value: update.value }, + }); + } else { + await BunSQLite.insert({ + table: "variables", + data: [{ key: update.key, value: update.value }], + }); + } + } + + const sync = await syncCaddyIngress({ reason: "settings updated" }); + + return { + success: sync.success, + msg: sync.msg, + debug: { warnings: sync.warnings }, + }; +}; diff --git a/src/pages/auth/login/(partials)/login-form/index.tsx b/src/pages/auth/login/(partials)/login-form/index.tsx index bea4b77..b58d850 100644 --- a/src/pages/auth/login/(partials)/login-form/index.tsx +++ b/src/pages/auth/login/(partials)/login-form/index.tsx @@ -17,18 +17,22 @@ export default function LoginForm() {
{ - const confirmed = await confirm({ - title: "Sign in?", - message: "Use these credentials to sign in to the dashboard.", - confirm_label: "Sign in", - }); + // const confirmed = await confirm({ + // title: "Sign in?", + // message: "Use these credentials to sign in to the dashboard.", + // confirm_label: "Sign in", + // }); - if (confirmed) { - submitLoginForm(init); - } + // if (confirmed) { + // submitLoginForm(init); + // } + + submitLoginForm(init); }} > - {status?.error ? : null} + {status?.error ? ( + + ) : null}