Update Reverse proxy and TLS configuration on Caddy

This commit is contained in:
2026-10-21 13:01:33 +01:00
parent ed4a6f3ec8
commit 42feb73264
22 changed files with 539 additions and 37 deletions
@@ -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");
});
});
+51 -16
View File
@@ -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);