Update Reverse proxy and TLS configuration on Caddy
This commit is contained in:
@@ -351,6 +351,10 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
fieldName: "acme_credentials",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "allowed_ips",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
],
|
||||
uniqueConstraints: [
|
||||
{
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ClientRouteFormObject>({
|
||||
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 ? (
|
||||
<InlineAlert tone="error" msg={status.msg} className="mb-6" />
|
||||
<InlineAlert
|
||||
tone="error"
|
||||
msg={status.msg}
|
||||
className="mb-6"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FormSection
|
||||
@@ -161,6 +176,26 @@ export default function ClientRouteForm({
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Access"
|
||||
description="Additional IP addresses or CIDR ranges allowed to reach this route through Caddy. The host's WireGuard subnet and loopback are always included."
|
||||
>
|
||||
<Input
|
||||
name="allowed_ips"
|
||||
title="Allowed IPs"
|
||||
placeholder="203.0.113.0/24, 198.51.100.0/24"
|
||||
defaultValue={form.allowed_ips}
|
||||
onChange={(e) =>
|
||||
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."
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="TLS certificate"
|
||||
description="Manual DNS-01. After saving, the route page shows the TXT record to add at your registrar."
|
||||
|
||||
@@ -50,6 +50,7 @@ export default async function submitClientRouteForm({
|
||||
upstream_tls_verify: form.upstream_tls_verify == 0 ? 0 : 1,
|
||||
enabled: form.enabled == 0 ? 0 : 1,
|
||||
notes: form.notes || "",
|
||||
allowed_ips: form.allowed_ips || "",
|
||||
acme_email: form.acme_email || "",
|
||||
acme_staging: form.acme_staging == 1 ? 1 : 0,
|
||||
};
|
||||
|
||||
@@ -53,6 +53,16 @@ export default function buildClientRoutesTableColumns({
|
||||
header: "Path",
|
||||
render: ({ item }) => <MonoText>{item.path_prefix || "/"}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: "public_url",
|
||||
header: "Public URL",
|
||||
hide_below: "lg",
|
||||
render: ({ item }) => (
|
||||
<MonoText className="text-muted">
|
||||
{item.public_url || "—"}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "upstream",
|
||||
header: "Upstream",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
`}`,
|
||||
|
||||
@@ -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<CaddySettings> {
|
||||
const res = await BunSQLite.select<BUN_SQLITE_WGUI_VARIABLES, TableType>({
|
||||
table: "variables",
|
||||
});
|
||||
|
||||
const values = new Map<string, string>();
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<CaddySyncResult> {
|
||||
: ("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<CaddySyncResult> {
|
||||
|
||||
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);
|
||||
|
||||
+13
@@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
@@ -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<PagePropsType> = 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<PagePropsType> = 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
|
||||
|
||||
@@ -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<PagePropsType> = async ({ query }) => {
|
||||
});
|
||||
|
||||
const route = route_res.singleRes;
|
||||
const settings = await grabCaddySettings();
|
||||
|
||||
return {
|
||||
props: {
|
||||
@@ -40,6 +42,7 @@ const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
||||
certificate: await grabCertificateByHostname({
|
||||
hostname: route.hostname,
|
||||
}),
|
||||
https_port: settings.https_port,
|
||||
})
|
||||
: null,
|
||||
},
|
||||
|
||||
@@ -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<typeof useTlsStatus>;
|
||||
};
|
||||
|
||||
type SettingsForm = {
|
||||
https_port: string;
|
||||
redirect: boolean;
|
||||
};
|
||||
|
||||
export default function TlsSettingsSection({ tls }: Props) {
|
||||
const { toast } = useToast();
|
||||
const [form, setForm] = useState<SettingsForm>({
|
||||
https_port: "",
|
||||
redirect: true,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Panel
|
||||
title="Caddy settings"
|
||||
description="Public HTTPS port and redirect behaviour for the Caddy ingress."
|
||||
>
|
||||
<Form
|
||||
className="w-full gap-0"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<InlineAlert tone="error" msg={error} className="mb-4" />
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
name="https_port"
|
||||
title="HTTPS port"
|
||||
placeholder="8443"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
defaultValue={form.https_port}
|
||||
onChange={(e) =>
|
||||
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."
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
title="Redirect HTTP to HTTPS"
|
||||
label="Enable HTTP to HTTPS redirect"
|
||||
checked={form.redirect}
|
||||
changeHandler={(value) =>
|
||||
setForm((prev) => ({ ...prev, redirect: value }))
|
||||
}
|
||||
info="When enabled, Caddy redirects plain HTTP requests to the HTTPS port. This is the default."
|
||||
/>
|
||||
|
||||
<FormActions
|
||||
submit_title="Save settings"
|
||||
loading={saving}
|
||||
cancel_href={undefined}
|
||||
/>
|
||||
</Form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
>
|
||||
<TlsStatusSection tls={tls} />
|
||||
<TlsSettingsSection tls={tls} />
|
||||
<TlsInstallSection tls={tls} />
|
||||
<TlsSetupSection tls={tls} />
|
||||
</SetupPageFrame>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<APIResponseObject> = 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<BUN_SQLITE_WGUI_VARIABLES, TableType>({
|
||||
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 },
|
||||
};
|
||||
};
|
||||
@@ -17,18 +17,22 @@ export default function LoginForm() {
|
||||
<Form
|
||||
className="w-full items-stretch gap-5"
|
||||
onSubmit={async () => {
|
||||
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 ? <InlineAlert tone="error" msg={status.msg} /> : null}
|
||||
{status?.error ? (
|
||||
<InlineAlert tone="error" msg={status.msg} />
|
||||
) : null}
|
||||
<LoginFormEmailUsername {...init} />
|
||||
<LoginFormPassword {...init} />
|
||||
<LoginFormAction {...init} />
|
||||
|
||||
Reference in New Issue
Block a user