Add TLS feature with Caddy

This commit is contained in:
2026-10-21 09:13:59 +01:00
parent 1462067e72
commit ed4a6f3ec8
109 changed files with 6374 additions and 156 deletions
@@ -0,0 +1,141 @@
import * as acme from "acme-client";
import { mkdirSync, writeFileSync } from "node:fs";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import grabCertDirNames from "@/src/functions/backend/caddy/grab-cert-dir-names";
import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress";
import grabCertificateByHostname from "./grab-certificate-by-hostname";
import fixCertificatePermissions from "./fix-certificate-permissions";
import {
grabManualDnsChallenge,
removeManualDnsChallenge,
} from "./manual-dns-challenges";
import readCertificateExpiry from "./read-certificate-expiry";
import recordCertificate from "./record-certificate";
type Params = {
route_id?: number | "";
user_id?: number | "";
};
export default async function completeManualCertificate({
route_id,
user_id,
}: Params): Promise<APIResponseObject> {
const final_route_id = Number(route_id);
if (!Number.isSafeInteger(final_route_id) || final_route_id < 1) {
return { success: false, msg: `Invalid route` };
}
const challenge_state = grabManualDnsChallenge(final_route_id);
if (!challenge_state) {
return {
success: false,
msg: `No pending certificate request — click Issue/Renew again`,
};
}
const {
client,
order,
challenge,
authz,
hostname,
staging,
} = challenge_state;
const { CERT_DIR, CERT_FILE, KEY_FILE } = grabCertDirNames({ hostname });
try {
await client.completeChallenge(challenge);
await client.waitForValidStatus(challenge);
const [key, csr] = await acme.crypto.createCsr({
commonName: hostname,
altNames: [hostname],
});
const finalized = await client.finalizeOrder(order, csr);
const certificate = await client.getCertificate(finalized);
mkdirSync(CERT_DIR, { recursive: true, mode: 0o750 });
writeFileSync(KEY_FILE, key, { mode: 0o600 });
writeFileSync(CERT_FILE, certificate, { mode: 0o644 });
fixCertificatePermissions({
cert_dir: CERT_DIR,
cert_file: CERT_FILE,
key_file: KEY_FILE,
});
const expires_at = readCertificateExpiry({ cert_path: CERT_FILE });
await recordCertificate({
certificate: {
user_id: user_id,
hostname,
provider: "letsencrypt",
challenge: "dns-01",
dns_provider: "manual",
cert_path: CERT_FILE,
key_path: KEY_FILE,
expires_at: expires_at || undefined,
status: "active",
last_error: "",
auto_renew: 0,
},
});
removeManualDnsChallenge(final_route_id);
const sync = await syncCaddyIngress({
reason: "manual certificate issued",
});
return {
success: true,
msg: `Certificate issued for ${hostname}`,
stringRes: hostname,
singleRes: await grabCertificateByHostname({ hostname }),
debug: { warnings: sync.warnings },
};
} catch (error: any) {
const msg = error.message || `Certificate validation failed`;
const existing_expiry = readCertificateExpiry({
cert_path: CERT_FILE,
});
await recordCertificate({
certificate: {
user_id: user_id,
hostname,
provider: "letsencrypt",
challenge: "dns-01",
dns_provider: "manual",
cert_path: CERT_FILE,
key_path: KEY_FILE,
expires_at: existing_expiry || undefined,
status: existing_expiry ? "active" : "error",
last_error: msg,
auto_renew: 0,
},
});
removeManualDnsChallenge(final_route_id);
// The authorization is usually invalid after a failed validation.
try {
await client.deactivateAuthorization(authz);
} catch {
// Best-effort cleanup.
}
return {
success: false,
msg,
stringRes: hostname,
singleRes: await grabCertificateByHostname({ hostname }),
debug: { staging },
};
}
}
@@ -0,0 +1,46 @@
import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db";
import readCertificateExpiry from "./read-certificate-expiry";
export type ClientCertificateView = BUN_SQLITE_WGUI_TLS_CERTIFICATES & {
expires_at?: number | null;
has_certificate?: boolean;
};
type Params = {
certificate?: BUN_SQLITE_WGUI_TLS_CERTIFICATES | null;
hostname?: string;
};
export default function decorateCertificate({
certificate,
hostname,
}: Params): ClientCertificateView {
const expires_at = readCertificateExpiry({
cert_path: certificate?.cert_path,
});
const now = Date.now();
const renew_before = 30 * 24 * 60 * 60 * 1000;
let status = certificate?.status || "missing";
if (expires_at) {
status =
expires_at <= now
? "error"
: expires_at - now <= renew_before
? "expiring"
: certificate?.status == "issuing"
? "issuing"
: "active";
} else if (status == "active") {
status = "missing";
}
return {
...(certificate || {}),
hostname: certificate?.hostname || hostname,
expires_at: expires_at || undefined,
status,
has_certificate: Boolean(expires_at),
};
}
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import decorateClientRoute from "./decorate-client-route";
describe("decorateClientRoute", () => {
test("never exposes encrypted credentials", () => {
const route = decorateClientRoute({
route: {
id: 1,
host_id: 0,
hostname: "app.example.com",
acme_credentials: "encrypted-value",
},
});
expect("acme_credentials" in route).toBe(false);
expect(route.acme_credentials_configured).toBe(true);
expect(route.certificate_status).toBe("missing");
});
});
@@ -0,0 +1,49 @@
import type {
BUN_SQLITE_WGUI_CLIENT_ROUTES,
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
} from "@/db/types/db";
import readCertificateExpiry from "./read-certificate-expiry";
export type ClientRouteView = BUN_SQLITE_WGUI_CLIENT_ROUTES & {
acme_credentials?: never;
acme_credentials_configured?: boolean;
certificate_status?: string;
certificate_expires_at?: number | null;
};
type Params = {
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
certificate?: BUN_SQLITE_WGUI_TLS_CERTIFICATES | null;
};
export default function decorateClientRoute({
route,
certificate,
}: Params): ClientRouteView {
const { acme_credentials, ...safe_route } = route;
const expires_at = readCertificateExpiry({
cert_path: certificate?.cert_path,
});
const now = Date.now();
const renew_before = 30 * 24 * 60 * 60 * 1000;
let certificate_status = certificate?.status || "missing";
if (expires_at) {
certificate_status =
expires_at <= now
? "error"
: expires_at - now <= renew_before
? "expiring"
: "active";
} else if (certificate_status == "active") {
certificate_status = "missing";
}
return {
...safe_route,
acme_credentials_configured: Boolean(acme_credentials),
certificate_status,
certificate_expires_at: expires_at || null,
};
}
@@ -0,0 +1,29 @@
import { chmodSync } from "node:fs";
import { execFileSync } from "node:child_process";
type Params = {
cert_dir: string;
cert_file: string;
key_file: string;
};
export default function fixCertificatePermissions({
cert_dir,
cert_file,
key_file,
}: Params) {
try {
chmodSync(cert_dir, 0o750);
chmodSync(cert_file, 0o640);
chmodSync(key_file, 0o640);
for (const target of [cert_dir, cert_file, key_file]) {
execFileSync("chown", ["root:wgui-caddy", target], {
stdio: "pipe",
timeout: 5000,
});
}
} catch {
// The group may not exist in development; Caddy runs as root there.
}
}
@@ -0,0 +1,34 @@
import * as acme from "acme-client";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import grabDirNames from "@/src/utils/grab-dir-names";
const { WGUI_LIB_ACME_DIR, WGUI_ACME_ACCOUNT_KEY_FILE } = grabDirNames();
let cached_key: string | null = null;
/**
* A single ACME account key for the host. It is created on first use and
* reused for every certificate/account registration.
*/
export default async function grabAcmeAccountKey(): Promise<string> {
if (cached_key) {
return cached_key;
}
mkdirSync(WGUI_LIB_ACME_DIR, { recursive: true, mode: 0o700 });
chmodSync(WGUI_LIB_ACME_DIR, 0o700);
if (existsSync(WGUI_ACME_ACCOUNT_KEY_FILE)) {
cached_key = readFileSync(WGUI_ACME_ACCOUNT_KEY_FILE, "utf-8");
return cached_key;
}
const key = await acme.crypto.createPrivateKey();
const pem = key.toString();
writeFileSync(WGUI_ACME_ACCOUNT_KEY_FILE, pem, { mode: 0o600 });
chmodSync(WGUI_ACME_ACCOUNT_KEY_FILE, 0o600);
cached_key = pem;
return pem;
}
@@ -0,0 +1,35 @@
import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TableType } from "@/src/types";
import decorateCertificate, { type ClientCertificateView } from "./decorate-certificate";
type Params = {
hostname?: string | null;
};
export default async function grabCertificateByHostname({
hostname,
}: Params): Promise<ClientCertificateView | null> {
if (!hostname) {
return null;
}
const certificates_res = await BunSQLite.select<
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
TableType
>({
table: "tls_certificates",
query: {
query: {
hostname: {
value: hostname,
},
},
},
});
return decorateCertificate({
certificate: certificates_res.singleRes || null,
hostname,
});
}
@@ -0,0 +1,117 @@
import type {
BUN_SQLITE_WGUI_CLIENT_ROUTES,
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
} from "@/db/types/db";
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 grabCertDirNames from "../caddy/grab-cert-dir-names";
import readAcmeEnv from "./read-acme-env";
import readCertificateExpiry from "./read-certificate-expiry";
export default async function grabTlsStatus() {
const certificates_res = await BunSQLite.select<
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
TableType
>({
table: "tls_certificates",
query: {
order: { field: "hostname", strategy: "ASC" },
},
});
const routes_res = await BunSQLite.select<
BUN_SQLITE_WGUI_CLIENT_ROUTES,
TableType
>({
table: "client_routes",
});
const acme_env = readAcmeEnv();
const now = Date.now();
const renew_before = TlsData.RenewBeforeDays * 24 * 60 * 60 * 1000;
const certificates = (certificates_res.payload || []).map(
(certificate) => {
const expires_at = readCertificateExpiry({
cert_path: certificate.cert_path,
});
let status = certificate.status || "missing";
if (expires_at) {
if (expires_at <= now) {
status = "error";
} else if (expires_at - now <= renew_before) {
status = "expiring";
} else if (status != "issuing") {
status = "active";
}
}
return {
...certificate,
hostname: certificate.hostname?.trim().toLowerCase(),
expires_at: expires_at || certificate.expires_at || null,
status,
has_certificate: Boolean(expires_at),
};
},
);
const known_hostnames = new Set(
certificates
.map((certificate) => certificate.hostname)
.filter(Boolean),
);
for (let i = 0; i < (routes_res.payload || []).length; i++) {
const route = routes_res.payload?.[i];
const hostname = route?.hostname?.trim().toLowerCase();
if (!hostname || known_hostnames.has(hostname)) {
continue;
}
const { CERT_FILE, KEY_FILE } = grabCertDirNames({ hostname });
certificates.push({
hostname,
provider: "letsencrypt",
challenge: "dns-01",
cert_path: CERT_FILE,
key_path: KEY_FILE,
status: "missing",
auto_renew: 1,
expires_at: null,
has_certificate: false,
});
known_hostnames.add(hostname);
}
certificates.sort((a, b) =>
(a.hostname || "").localeCompare(b.hostname || ""),
);
return {
caddy: grabCaddyStatus(),
certificates,
acme: {
env_path: acme_env.exists ? `/etc/wgui/acme.env` : null,
env_exists: acme_env.exists,
provider:
acme_env.values.ACME_DNS_PROVIDER ||
TlsData.DefaultDnsProvider ||
null,
email_configured: Boolean(acme_env.values.ACME_EMAIL),
credential_keys: acme_env.keys.filter(
(key) =>
key != "ACME_EMAIL" && key != "ACME_DNS_PROVIDER",
),
staging: TlsData.AcmeStaging,
},
https_port: TlsData.CaddyHttpsPort,
};
}
@@ -0,0 +1,309 @@
import { chmodSync, existsSync, mkdirSync } from "node:fs";
import { execFileSync } from "node:child_process";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import grabDirNames from "@/src/utils/grab-dir-names";
import type { TableType } from "@/src/types";
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status";
import grabCertDirNames from "../caddy/grab-cert-dir-names";
import syncCaddyIngress from "../caddy/sync-caddy-ingress";
import validateRouteHostname from "../caddy/validate-route-hostname";
import grabCertificateByHostname from "./grab-certificate-by-hostname";
import readCertificateExpiry from "./read-certificate-expiry";
import readRouteAcmeConfig from "./read-route-acme-config";
import recordCertificate from "./record-certificate";
import redactSecrets from "./redact-secrets";
type Params = {
route_id?: number | "";
route?: BUN_SQLITE_WGUI_CLIENT_ROUTES | null;
hostname?: string | null;
user_id?: number | "";
staging?: boolean;
force?: boolean;
};
const { WGUI_ACME_SCRIPT, WGUI_ACME_HOME } = grabDirNames();
const PROVIDER_PATTERN = /^dns_[a-z0-9_]+$/;
function formatOutput({ text, secrets }: { text?: string; secrets: string[] }) {
const redacted = redactSecrets({ text, secrets });
const trimmed = redacted.trim();
return trimmed.length > 4000 ? `${trimmed.slice(0, 4000)}…` : trimmed;
}
function fixPermissions({ cert_dir, cert_file, key_file }: {
cert_dir: string;
cert_file: string;
key_file: string;
}) {
try {
chmodSync(cert_dir, 0o750);
chmodSync(cert_file, 0o640);
chmodSync(key_file, 0o640);
for (const target of [cert_dir, cert_file, key_file]) {
execFileSync("chown", ["root:wgui-caddy", target], {
stdio: "pipe",
timeout: 5000,
});
}
} catch {
// The group may not exist in development; Caddy runs as root there.
}
}
export default async function issueCertificate({
route_id,
route: passed_route,
hostname: raw_hostname,
user_id,
staging,
force,
}: Params): Promise<APIResponseObject> {
let route = passed_route || undefined;
if (!route && route_id !== undefined && route_id !== "") {
const route_res = await BunSQLite.select<
BUN_SQLITE_WGUI_CLIENT_ROUTES,
TableType
>({
table: "client_routes",
targetId: route_id,
});
route = route_res.singleRes || undefined;
}
if (!route && raw_hostname) {
const route_res = await BunSQLite.select<
BUN_SQLITE_WGUI_CLIENT_ROUTES,
TableType
>({
table: "client_routes",
query: {
query: {
hostname: {
value: raw_hostname.trim().toLowerCase(),
},
},
},
});
route = route_res.singleRes || undefined;
}
const hostname = validateRouteHostname({
value: route?.hostname || raw_hostname,
});
if (!hostname.success) {
return { success: false, msg: hostname.msg };
}
if (!route) {
return {
success: false,
msg: `Route not found — open the route and save its TLS settings first`,
};
}
const acme = await readRouteAcmeConfig({ route });
if (!acme.success) {
return { success: false, msg: acme.msg };
}
const provider = acme.config.provider;
const email = acme.config.email;
const secrets = Object.values(acme.config.values);
if (!existsSync(WGUI_ACME_SCRIPT)) {
return {
success: false,
msg: `acme.sh is not installed at ${WGUI_ACME_SCRIPT}`,
};
}
if (!provider || !PROVIDER_PATTERN.test(provider)) {
return {
success: false,
msg: `Set the DNS provider on this route's form (e.g. dns_cf)`,
};
}
if (!email) {
return {
success: false,
msg: `Set the ACME email on this route's form`,
};
}
const { CERT_DIR, CERT_FILE, KEY_FILE } = grabCertDirNames({
hostname: hostname.value,
});
mkdirSync(CERT_DIR, { recursive: true, mode: 0o750 });
const setup_status = grabSystemSetupStatus();
const reload_command =
setup_status.init_system == "openrc"
? `rc-service wgui-caddy reload`
: `systemctl reload wgui-caddy`;
const child_env = {
...process.env,
...acme.config.values,
LE_WORKING_DIR: WGUI_ACME_HOME,
};
const use_staging =
staging !== undefined ? staging : acme.config.staging;
await recordCertificate({
certificate: {
user_id,
hostname: hostname.value,
provider: "letsencrypt",
challenge: "dns-01",
dns_provider: provider,
cert_path: CERT_FILE,
key_path: KEY_FILE,
status: "issuing",
last_error: "",
auto_renew: 1,
},
});
try {
const issue_args = [
"--issue",
"--server",
"letsencrypt",
"--dns",
provider,
"--accountemail",
email,
"-d",
hostname.value,
"--keylength",
"ec-256",
];
if (use_staging) {
issue_args.push("--staging");
}
if (force) {
issue_args.push("--force");
}
const issue_output = execFileSync(WGUI_ACME_SCRIPT, issue_args, {
env: child_env,
encoding: "utf-8",
stdio: "pipe",
timeout: 300000,
maxBuffer: 4 * 1024 * 1024,
});
const install_output = execFileSync(
WGUI_ACME_SCRIPT,
[
"--install-cert",
"-d",
hostname.value,
"--key-file",
KEY_FILE,
"--fullchain-file",
CERT_FILE,
"--reloadcmd",
reload_command,
],
{
env: child_env,
encoding: "utf-8",
stdio: "pipe",
timeout: 60000,
maxBuffer: 4 * 1024 * 1024,
},
);
fixPermissions({
cert_dir: CERT_DIR,
cert_file: CERT_FILE,
key_file: KEY_FILE,
});
const expires_at = readCertificateExpiry({ cert_path: CERT_FILE });
await recordCertificate({
certificate: {
user_id,
hostname: hostname.value,
provider: "letsencrypt",
challenge: "dns-01",
dns_provider: provider,
cert_path: CERT_FILE,
key_path: KEY_FILE,
expires_at: expires_at || undefined,
status: "active",
last_error: "",
auto_renew: 1,
},
});
const sync = await syncCaddyIngress({ reason: "certificate issued" });
return {
success: true,
msg:
formatOutput({
text: `${issue_output}\n${install_output}`,
secrets,
}) || `Certificate issued for ${hostname.value}`,
stringRes: hostname.value,
singleRes: await grabCertificateByHostname({
hostname: hostname.value,
}),
debug: {
warnings: sync.warnings,
},
};
} catch (error: any) {
const stdout = error?.stdout ? String(error.stdout) : "";
const stderr = error?.stderr ? String(error.stderr) : "";
const msg = formatOutput({
text: [stdout, stderr, error.message]
.filter(Boolean)
.join("\n"),
secrets,
});
await recordCertificate({
certificate: {
user_id,
hostname: hostname.value,
provider: "letsencrypt",
challenge: "dns-01",
dns_provider: provider,
cert_path: CERT_FILE,
key_path: KEY_FILE,
status: "error",
last_error: msg || `Certificate issuance failed`,
auto_renew: 1,
},
});
return {
success: false,
msg: msg || `Certificate issuance failed`,
stringRes: hostname.value,
singleRes: await grabCertificateByHostname({
hostname: hostname.value,
}),
};
}
}
@@ -0,0 +1,46 @@
import type {
Authorization,
Client,
Order,
} from "acme-client";
import type { Challenge } from "acme-client/types/rfc8555";
export type ManualDnsChallenge = {
route_id: number;
hostname: string;
txt_name: string;
txt_value: string;
client: Client;
order: Order;
authz: Authorization;
challenge: Challenge;
staging: boolean;
created_at: number;
};
const CHALLENGE_TTL_MS = 30 * 60 * 1000;
const challenges = new Map<number, ManualDnsChallenge>();
function clearExpired() {
const cutoff = Date.now() - CHALLENGE_TTL_MS;
for (const [route_id, challenge] of challenges) {
if (challenge.created_at < cutoff) {
challenges.delete(route_id);
}
}
}
export function storeManualDnsChallenge(challenge: ManualDnsChallenge) {
clearExpired();
challenges.set(challenge.route_id, challenge);
}
export function grabManualDnsChallenge(route_id: number) {
clearExpired();
return challenges.get(route_id) || null;
}
export function removeManualDnsChallenge(route_id: number) {
challenges.delete(route_id);
}
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import parseAcmeCredentials from "./parse-acme-credentials";
describe("parseAcmeCredentials", () => {
test("parses KEY=VALUE, export and comments", () => {
const res = parseAcmeCredentials({
text: [
"# provider credentials",
"CF_Token=abc123",
"export CF_Account_ID=456",
'CF_Zone="example.com"',
].join("\n"),
});
expect(res.success).toBe(true);
expect(res.success && res.values).toEqual({
CF_Token: "abc123",
CF_Account_ID: "456",
CF_Zone: "example.com",
});
});
test("rejects malformed lines", () => {
expect(
parseAcmeCredentials({ text: "CF_Token=abc\nnot a credential" })
.success,
).toBe(false);
});
});
@@ -0,0 +1,58 @@
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
type Params = {
text?: string | null;
};
/**
* Parse the route form's KEY=VALUE credential text into env values.
* Accepts `export KEY=VALUE`, quotes and `#` comments.
*/
export default function parseAcmeCredentials({ text }: Params) {
const values: Record<string, string> = {};
const lines = (text || "").split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i]?.trim();
if (!line || line.startsWith("#")) {
continue;
}
const match = line.match(
/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/,
);
if (!match?.[1] || !KEY_PATTERN.test(match[1])) {
return {
success: false as const,
msg: `Invalid credential line ${i + 1}`,
values: {},
};
}
let value = (match[2] || "").trim();
if (!value.startsWith('"') && !value.startsWith("'")) {
const inline_comment = value.indexOf(" #");
if (inline_comment > -1) {
value = value.slice(0, inline_comment).trim();
}
}
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[match[1]] = value;
}
return {
success: true as const,
values,
};
}
@@ -0,0 +1,76 @@
import { existsSync, readFileSync } from "node:fs";
import grabDirNames from "@/src/utils/grab-dir-names";
const { WGUI_ACME_ENV_FILE } = grabDirNames();
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
export type AcmeEnv = {
values: Record<string, string>;
keys: string[];
exists: boolean;
};
/**
* Parse /etc/wgui/acme.env without sourcing it through a shell. Provider
* credentials stay in this object and are only passed to acme.sh via env.
*/
export default function readAcmeEnv(): AcmeEnv {
if (!existsSync(WGUI_ACME_ENV_FILE)) {
return { values: {}, keys: [], exists: false };
}
const values: Record<string, string> = {};
try {
const lines = readFileSync(WGUI_ACME_ENV_FILE, "utf-8").split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i]?.trim();
if (!line || line.startsWith("#")) {
continue;
}
const match = line.match(
/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/,
);
if (!match?.[1]) {
continue;
}
const key = match[1];
let value = (match[2] || "").trim();
if (!KEY_PATTERN.test(key)) {
continue;
}
if (!value.startsWith('"') && !value.startsWith("'")) {
const inline_comment = value.indexOf(" #");
if (inline_comment > -1) {
value = value.slice(0, inline_comment).trim();
}
}
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[key] = value;
}
} catch {
return { values: {}, keys: [], exists: true };
}
return {
values,
keys: Object.keys(values).sort(),
exists: true,
};
}
@@ -0,0 +1,36 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
type Params = {
cert_path?: string | null;
};
export default function readCertificateExpiry({ cert_path }: Params) {
if (!cert_path || !existsSync(cert_path)) {
return null;
}
try {
const output = execFileSync(
"openssl",
["x509", "-in", cert_path, "-noout", "-enddate"],
{
encoding: "utf-8",
stdio: "pipe",
timeout: 10000,
},
).trim();
const match = output.match(/notAfter=(.+)$/m);
if (!match?.[1]) {
return null;
}
const parsed = Date.parse(match[1]);
return Number.isNaN(parsed) ? null : parsed;
} catch {
return null;
}
}
@@ -0,0 +1,61 @@
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
import { TlsData } from "@/src/data/tls-data";
import { decrypt } from "@/src/utils/crypt";
import readAcmeEnv from "./read-acme-env";
type Params = {
route?: BUN_SQLITE_WGUI_CLIENT_ROUTES | null;
};
export type RouteAcmeConfig = {
provider: string;
email: string;
staging: boolean;
values: Record<string, string>;
source: "route" | "env";
};
export default async function readRouteAcmeConfig({
route,
}: Params): Promise<
| { success: true; config: RouteAcmeConfig }
| { success: false; msg: string }
> {
const acme_env = readAcmeEnv();
let route_values: Record<string, string> = {};
if (route?.acme_credentials) {
try {
const decrypted = await decrypt(route.acme_credentials);
const parsed = JSON.parse(decrypted) as Record<string, string>;
route_values = parsed || {};
} catch {
return {
success: false,
msg: `Could not decrypt the saved ACME credentials for this route`,
};
}
}
const provider =
route?.acme_dns_provider?.trim() ||
acme_env.values.ACME_DNS_PROVIDER ||
TlsData.DefaultDnsProvider;
const email =
route?.acme_email?.trim() || acme_env.values.ACME_EMAIL || "";
const source = route?.acme_dns_provider ? "route" : "env";
const values =
source == "route" ? route_values : acme_env.values;
return {
success: true,
config: {
provider,
email,
staging: route?.acme_staging == 1 || TlsData.AcmeStaging,
values,
source,
},
};
}
@@ -0,0 +1,55 @@
import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TableType } from "@/src/types";
type Params = {
certificate: BUN_SQLITE_WGUI_TLS_CERTIFICATES;
};
/**
* Upsert by hostname. `update_on_duplicate` on a non-id unique column is not
* reliable across SQLite builds, so resolve the existing row explicitly.
*/
export default async function recordCertificate({ certificate }: Params) {
if (!certificate.hostname) {
return {
success: false as const,
msg: `Certificate hostname is required`,
};
}
const existing_res = await BunSQLite.select<
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
TableType
>({
table: "tls_certificates",
query: {
query: {
hostname: {
value: certificate.hostname,
},
},
},
});
const existing = existing_res.singleRes;
if (existing?.id) {
return await BunSQLite.update<
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
TableType
>({
table: "tls_certificates",
targetId: existing.id,
data: certificate,
});
}
return await BunSQLite.insert<
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
TableType
>({
table: "tls_certificates",
data: [certificate],
});
}
@@ -0,0 +1,18 @@
type Params = {
text?: string | null;
secrets: string[];
};
export default function redactSecrets({ text, secrets }: Params) {
let output = text || "";
for (let i = 0; i < secrets.length; i++) {
const secret = secrets[i];
if (secret && secret.length >= 4) {
output = output.split(secret).join(`[redacted]`);
}
}
return output;
}
@@ -0,0 +1,39 @@
import { encrypt } from "@/src/utils/crypt";
import parseAcmeCredentials from "./parse-acme-credentials";
type Params = {
/** Plaintext from the route form. Empty keeps the existing value. */
text?: string | null;
/** Existing encrypted value from the database. */
existing?: string | null;
};
export default async function sealRouteCredentials({
text,
existing,
}: Params) {
if (!text?.trim()) {
return {
success: true as const,
value: existing || "",
};
}
const parsed = parseAcmeCredentials({ text });
if (!parsed.success) {
return parsed;
}
if (!Object.keys(parsed.values).length) {
return {
success: true as const,
value: existing || "",
};
}
return {
success: true as const,
value: await encrypt(JSON.stringify(parsed.values)),
};
}
@@ -0,0 +1,140 @@
import * as acme from "acme-client";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TableType } from "@/src/types";
import validateRouteHostname from "@/src/functions/backend/caddy/validate-route-hostname";
import grabAcmeAccountKey from "./grab-acme-account-key";
import {
grabManualDnsChallenge,
removeManualDnsChallenge,
storeManualDnsChallenge,
} from "./manual-dns-challenges";
type Params = {
route_id?: number | "";
user_id?: number | "";
};
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default async function startManualCertificate({
route_id,
user_id,
}: Params): Promise<APIResponseObject> {
const final_route_id = Number(route_id);
if (!Number.isSafeInteger(final_route_id) || final_route_id < 1) {
return { success: false, msg: `Invalid route` };
}
const route_res = await BunSQLite.select<
BUN_SQLITE_WGUI_CLIENT_ROUTES,
TableType
>({
table: "client_routes",
targetId: final_route_id,
});
const route = route_res.singleRes;
if (!route) {
return { success: false, msg: `Route not found` };
}
const hostname = validateRouteHostname({ value: route.hostname });
if (!hostname.success) {
return { success: false, msg: hostname.msg };
}
const email = (route.acme_email || "").trim();
if (!email || !EMAIL_PATTERN.test(email)) {
return {
success: false,
msg: `Set a valid ACME email on the route form first`,
};
}
const staging = route.acme_staging == 1;
const directory_url = staging
? acme.directory.letsencrypt.staging
: acme.directory.letsencrypt.production;
try {
const previous = grabManualDnsChallenge(final_route_id);
if (previous) {
try {
await previous.client.deactivateAuthorization(previous.authz);
} catch {
// The previous authorization may already be invalid/expired.
}
removeManualDnsChallenge(final_route_id);
}
const account_key = await grabAcmeAccountKey();
const client = new acme.Client({
directoryUrl: directory_url,
accountKey: account_key,
});
await client.createAccount({
termsOfServiceAgreed: true,
contact: [`mailto:${email}`],
});
const order = await client.createOrder({
identifiers: [{ type: "dns", value: hostname.value }],
});
const authorizations = await client.getAuthorizations(order);
const authz = authorizations[0];
if (!authz) {
throw new Error(`The CA did not return an authorization`);
}
const challenge = authz.challenges.find(
(item) => item.type == "dns-01",
);
if (!challenge) {
throw new Error(`The CA did not offer a dns-01 challenge`);
}
const txt_value = await client.getChallengeKeyAuthorization(challenge);
const txt_name = `_acme-challenge.${hostname.value}`;
storeManualDnsChallenge({
route_id: final_route_id,
hostname: hostname.value,
txt_name,
txt_value,
client,
order,
authz,
challenge,
staging,
created_at: Date.now(),
});
return {
success: true,
msg: `Add the TXT record at your DNS registrar, then click Validate.`,
singleRes: {
txt_name,
txt_value,
hostname: hostname.value,
staging,
},
};
} catch (error: any) {
return {
success: false,
msg: error.message || `Could not start certificate issuance`,
};
}
}
@@ -0,0 +1,94 @@
import { execFileSync } from "node:child_process";
import { TlsData } from "@/src/data/tls-data";
import deriveWireguardInterfaceName from "@/src/utils/derive-wireguard-interface-name";
import deriveIptablesChainNames from "../setup/derive-iptables-chain-names";
type Params = {
host_id: number;
port?: number;
};
function iptables({ args }: { args: string[] }) {
try {
execFileSync("iptables", args, {
encoding: "utf-8",
stdio: "pipe",
timeout: 10000,
});
return true;
} catch {
return false;
}
}
/**
* Allow clients of a host to reach the Caddy ingress over the tunnel.
*
* The host's generated INPUT chain ends in DROP, so the port must be accepted
* explicitly. This applies the rule immediately for already-running tunnels;
* `buildHostIptablesScripts` also writes it into the next post_up.
*/
export default function applyCaddyIngressFirewallRule({
host_id,
port,
}: Params) {
const final_port = port || TlsData.CaddyHttpsPort;
if (
!Number.isSafeInteger(host_id) ||
host_id < 0 ||
!Number.isInteger(final_port) ||
final_port < 1 ||
final_port > 65535
) {
return {
success: false as const,
msg: `Invalid host or ingress port`,
};
}
const interface_name = deriveWireguardInterfaceName({ host_id });
const { input } = deriveIptablesChainNames({ host_id });
// The chain only exists while the tunnel is up. The next host setup will
// regenerate the script and include the ingress rule.
if (!iptables({ args: ["-L", input, "-n"] })) {
return {
success: true as const,
msg: ``,
};
}
const rule_args = [
"-i",
interface_name,
"-p",
"tcp",
"--dport",
String(final_port),
"-j",
"ACCEPT",
];
if (iptables({ args: ["-C", input, ...rule_args] })) {
return {
success: true as const,
msg: ``,
};
}
const added = iptables({
args: ["-I", input, "1", ...rule_args],
});
return added
? {
success: true as const,
msg: ``,
}
: {
success: false as const,
msg: `Could not allow port ${final_port}/tcp on ${interface_name}`,
};
}
@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test";
import type {
BUN_SQLITE_WGUI_CLIENT_ROUTES,
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
} from "@/db/types/db";
import buildCaddyfile from "./build-caddyfile";
const base = {
https_port: 8443,
http_port: 8081,
admin_socket: `/run/wgui-caddy/admin.sock`,
storage_dir: `/var/lib/wgui/caddy/data`,
};
const certificate = {
hostname: "media.example.com",
cert_path: "/var/lib/wgui/tls/media.example.com/fullchain.pem",
key_path: "/var/lib/wgui/tls/media.example.com/privkey.pem",
status: "active",
} as BUN_SQLITE_WGUI_TLS_CERTIFICATES;
function route(
values: Partial<BUN_SQLITE_WGUI_CLIENT_ROUTES>,
): BUN_SQLITE_WGUI_CLIENT_ROUTES {
return {
hostname: "media.example.com",
path_prefix: "/",
upstream_url: "http://10.5.0.20:8080",
enabled: 1,
...values,
};
}
describe("buildCaddyfile", () => {
test("uses a high port and never 80/443", () => {
const res = buildCaddyfile({ ...base, routes: [], certificates: [] });
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("https_port 443");
expect(res.config).not.toContain("http_port 80\n");
});
test("orders longer prefixes before the catch-all", () => {
const res = buildCaddyfile({
...base,
certificates: [certificate],
routes: [
route({ name: "root", path_prefix: "/" }),
route({
name: "app",
path_prefix: "/app",
strip_prefix: 1,
upstream_url: "http://10.5.0.30:9000",
}),
],
});
const app_index = res.config?.indexOf("@route_1 {") || 0;
const root_index = res.config?.indexOf("handle {") || 0;
expect(app_index).toBeGreaterThan(0);
expect(root_index).toBeGreaterThan(app_index);
expect(res.config).toContain("path /app /app/*");
expect(res.config).toContain("uri strip_prefix /app");
});
test("skips routes without an active certificate", () => {
const res = buildCaddyfile({
...base,
certificates: [],
routes: [route({ name: "media" })],
});
expect(res.config).not.toContain("media.example.com {");
expect(res.warnings[0]).toContain("certificate");
});
test("scopes a route to its host's WireGuard subnet", () => {
const res = buildCaddyfile({
...base,
certificates: [certificate],
hosts: [{ id: 0, wg_ip_address: "10.5.0.1" }],
routes: [route({ host_id: 0, path_prefix: "/app" })],
});
expect(res.config).toContain("remote_ip 10.5.0.0/24");
});
test("maps insecure upstream TLS to Caddy", () => {
const res = buildCaddyfile({
...base,
certificates: [certificate],
routes: [
route({
upstream_url: "https://10.5.0.20:8443",
upstream_tls_verify: 0,
}),
],
});
expect(res.config).toContain("tls_insecure_skip_verify");
});
});
@@ -0,0 +1,277 @@
import type {
BUN_SQLITE_WGUI_CLIENT_ROUTES,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
} from "@/db/types/db";
import validateRouteHostname from "./validate-route-hostname";
import validateRoutePath from "./validate-route-path";
import validateUpstreamUrl from "./validate-upstream-url";
export type CaddyBuildResult = {
success: boolean;
msg?: string;
config?: string;
warnings: string[];
};
type Params = {
routes?: BUN_SQLITE_WGUI_CLIENT_ROUTES[];
certificates?: BUN_SQLITE_WGUI_TLS_CERTIFICATES[];
hosts?: BUN_SQLITE_WGUI_HOSTS[];
https_port: number;
http_port: number;
admin_socket: string;
storage_dir: string;
};
function reverseProxyBlock({
route,
indent,
}: {
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
indent: string;
}) {
const upstream = route.upstream_url || "";
const lines: string[] = [];
if (upstream.startsWith("https:") && route.upstream_tls_verify == 0) {
lines.push(`${indent}reverse_proxy ${upstream} {`);
lines.push(`${indent} transport http {`);
lines.push(`${indent} tls_insecure_skip_verify`);
lines.push(`${indent} }`);
lines.push(`${indent}}`);
} else {
lines.push(`${indent}reverse_proxy ${upstream}`);
}
return lines;
}
function routeBlock({
route,
index,
subnet,
}: {
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
index: number;
subnet?: string;
}) {
const path_prefix = route.path_prefix || "/";
const matcher = `route_${index}`;
const lines: string[] = [];
if (path_prefix == "/" && !subnet) {
lines.push(` handle {`);
lines.push(...reverseProxyBlock({ route, indent: " " }));
lines.push(` }`);
return lines;
}
if (path_prefix == "/") {
lines.push(
` @${matcher} remote_ip ${subnet} 127.0.0.1/32 ::1/128`,
);
} 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`,
);
}
lines.push(` }`);
}
lines.push(` handle @${matcher} {`);
if (path_prefix != "/" && route.strip_prefix == 1) {
lines.push(` uri strip_prefix ${path_prefix}`);
}
lines.push(...reverseProxyBlock({ route, indent: " " }));
lines.push(` }`);
return lines;
}
/**
* Build a Caddyfile for the enabled routes. Routes without an active
* certificate are skipped and reported as warnings instead of being served
* with an untrusted fallback certificate.
*/
export default function buildCaddyfile({
routes,
certificates,
hosts,
https_port,
http_port,
admin_socket,
storage_dir,
}: Params): CaddyBuildResult {
const warnings: string[] = [];
const subnet_by_host_id = new Map<number, string>();
for (let i = 0; i < (hosts || []).length; i++) {
const host = hosts?.[i];
const parts = (host?.wg_ip_address || "").trim().split(".");
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`,
);
}
}
const certificates_by_hostname = new Map<
string,
BUN_SQLITE_WGUI_TLS_CERTIFICATES
>();
for (let i = 0; i < (certificates || []).length; i++) {
const certificate = certificates?.[i];
const hostname = certificate?.hostname?.trim().toLowerCase();
if (
hostname &&
certificate?.cert_path &&
certificate.key_path &&
(certificate.status == "active" ||
certificate.status == "expiring")
) {
certificates_by_hostname.set(hostname, {
...certificate,
hostname,
});
}
}
const routes_by_hostname = new Map<
string,
{
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
subnet?: string;
}[]
>();
for (let i = 0; i < (routes || []).length; i++) {
const route = routes?.[i];
if (!route?.hostname || !route.upstream_url || route.enabled == 0) {
continue;
}
const hostname = validateRouteHostname({ value: route.hostname });
const path_prefix = validateRoutePath({
value: route.path_prefix,
});
const upstream = validateUpstreamUrl({
value: route.upstream_url,
});
if (!hostname.success || !path_prefix.success || !upstream.success) {
warnings.push(
`Route ${route.name || route.hostname} has invalid settings and was skipped`,
);
continue;
}
if (!certificates_by_hostname.has(hostname.value)) {
warnings.push(
`Route ${route.name || hostname.value} is skipped until its certificate is issued`,
);
continue;
}
const safe_hostname = hostname.value || "";
const safe_route: BUN_SQLITE_WGUI_CLIENT_ROUTES = {
...route,
hostname: safe_hostname,
path_prefix: path_prefix.value || "/",
upstream_url: upstream.value || "",
};
const subnet = subnet_by_host_id.get(Number(route.host_id || 0));
if (hosts?.length && !subnet) {
warnings.push(
`Route ${route.name || safe_hostname} is skipped because its host has no WireGuard address`,
);
continue;
}
const existing = routes_by_hostname.get(safe_hostname) || [];
existing.push({ route: safe_route, subnet });
routes_by_hostname.set(safe_hostname, existing);
}
const lines: string[] = [
`{`,
` https_port ${https_port}`,
` http_port ${http_port}`,
` auto_https disable_redirects`,
` admin unix/${admin_socket}`,
` storage file_system ${storage_dir}`,
`}`,
``,
];
const hostnames = [...routes_by_hostname.keys()].sort();
for (let h = 0; h < hostnames.length; h++) {
const hostname = hostnames[h];
if (!hostname) continue;
const certificate = certificates_by_hostname.get(hostname);
const host_routes = routes_by_hostname.get(hostname) || [];
if (!certificate) {
continue;
}
host_routes.sort((a, b) => {
const a_path = a.route.path_prefix || "/";
const b_path = b.route.path_prefix || "/";
if (a_path == "/") return 1;
if (b_path == "/") return -1;
return b_path.length - a_path.length;
});
lines.push(`${hostname} {`);
lines.push(
` tls "${certificate.cert_path}" "${certificate.key_path}"`,
);
for (let r = 0; r < host_routes.length; r++) {
const entry = host_routes[r];
if (!entry) continue;
lines.push(
...routeBlock({
route: entry.route,
subnet: entry.subnet,
index: r + 1,
}),
);
}
lines.push(`}`);
lines.push(``);
}
return {
success: true,
config: lines.join("\n"),
warnings,
};
}
@@ -0,0 +1,94 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import grabDirNames from "@/src/utils/grab-dir-names";
import { TlsData } from "@/src/data/tls-data";
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status";
const {
WGUI_CADDY_BINARY,
WGUI_LIB_CADDY_CONFIG_FILE,
WGUI_LIB_CADDY_STATE_FILE,
WGUI_ACME_SCRIPT,
WGUI_ACME_ENV_FILE,
} = grabDirNames();
function readState() {
try {
if (!existsSync(WGUI_LIB_CADDY_STATE_FILE)) {
return null;
}
return JSON.parse(
readFileSync(WGUI_LIB_CADDY_STATE_FILE, "utf-8"),
) as {
last_reload_at?: number;
last_error?: string | null;
warnings?: string[];
};
} catch {
return null;
}
}
function version() {
try {
return execFileSync(WGUI_CADDY_BINARY, ["version"], {
encoding: "utf-8",
stdio: "pipe",
timeout: 5000,
}).trim();
} catch {
return null;
}
}
function serviceActive() {
const setup_status = grabSystemSetupStatus();
try {
if (setup_status.init_system == "systemd") {
return (
execFileSync(
"systemctl",
["is-active", "wgui-caddy"],
{
encoding: "utf-8",
stdio: "pipe",
timeout: 5000,
},
).trim() == "active"
);
}
if (setup_status.init_system == "openrc") {
execFileSync("rc-service", ["wgui-caddy", "status"], {
encoding: "utf-8",
stdio: "pipe",
timeout: 5000,
});
return true;
}
} catch {
return false;
}
return null;
}
export default function grabCaddyStatus() {
const state = readState();
return {
installed: existsSync(WGUI_CADDY_BINARY),
service_active: serviceActive(),
acme_installed: existsSync(WGUI_ACME_SCRIPT),
acme_env_exists: existsSync(WGUI_ACME_ENV_FILE),
version: version(),
https_port: TlsData.CaddyHttpsPort,
http_port: TlsData.CaddyHttpPort,
enabled: TlsData.IngressEnabled,
config_path: WGUI_LIB_CADDY_CONFIG_FILE,
state,
};
}
@@ -0,0 +1,17 @@
import path from "node:path";
import grabDirNames from "@/src/utils/grab-dir-names";
type Params = {
hostname: string;
};
export default function grabCertDirNames({ hostname }: Params) {
const { WGUI_LIB_TLS_DIR } = grabDirNames();
const CERT_DIR = path.join(WGUI_LIB_TLS_DIR, hostname);
return {
CERT_DIR,
CERT_FILE: path.join(CERT_DIR, "fullchain.pem"),
KEY_FILE: path.join(CERT_DIR, "privkey.pem"),
};
}
@@ -0,0 +1,86 @@
import { execFileSync } from "node:child_process";
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status";
const CADDY_SERVICE_NAME = "wgui-caddy";
function run({ command, args }: { command: string; args: string[] }) {
try {
const output = execFileSync(command, args, {
encoding: "utf-8",
stdio: "pipe",
timeout: 30000,
}).trim();
return {
success: true as const,
msg: output,
};
} catch (error: any) {
const stdout = error?.stdout ? String(error.stdout).trim() : "";
const stderr = error?.stderr ? String(error.stderr).trim() : "";
return {
success: false as const,
msg: [stdout, stderr, error.message]
.filter(Boolean)
.join("\n")
.trim(),
};
}
}
function caddyServiceIsActive({
init_system,
}: {
init_system: ReturnType<typeof grabSystemSetupStatus>["init_system"];
}) {
if (init_system == "systemd") {
const status = run({
command: "systemctl",
args: ["is-active", CADDY_SERVICE_NAME],
});
return status.success && status.msg == "active";
}
if (init_system == "openrc") {
return run({
command: "rc-service",
args: [CADDY_SERVICE_NAME, "status"],
}).success;
}
return false;
}
export default function reloadCaddyService() {
const setup_status = grabSystemSetupStatus();
const service_active = caddyServiceIsActive({
init_system: setup_status.init_system,
});
if (setup_status.is_dev && !service_active) {
return {
success: true as const,
msg: `Development mode — Caddy service is not running, reload skipped`,
};
}
switch (setup_status.init_system) {
case "systemd":
return run({
command: "systemctl",
args: ["reload", CADDY_SERVICE_NAME],
});
case "openrc":
return run({
command: "rc-service",
args: [CADDY_SERVICE_NAME, "reload"],
});
default:
return {
success: false as const,
msg: `No supported init system found for wgui-caddy`,
};
}
}
@@ -0,0 +1,98 @@
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
import validateRouteHostname from "./validate-route-hostname";
import validateRoutePath from "./validate-route-path";
import validateUpstreamUrl from "./validate-upstream-url";
const PROVIDER_PATTERN = /^dns_[a-z0-9_]+$/;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type Params = {
route?: BUN_SQLITE_WGUI_CLIENT_ROUTES | null;
user_id?: number | "";
host_id?: number | "";
};
export default function sanitizeClientRoute({
route,
user_id,
host_id,
}: Params) {
if (!route) {
return {
success: false as const,
msg: `No route provided`,
};
}
const hostname = validateRouteHostname({ value: route.hostname });
if (!hostname.success) {
return hostname;
}
const path_prefix = validateRoutePath({ value: route.path_prefix });
if (!path_prefix.success) {
return path_prefix;
}
const upstream = validateUpstreamUrl({ value: route.upstream_url });
if (!upstream.success) {
return upstream;
}
const final_host_id = Number(route.host_id ?? host_id ?? 0);
if (!Number.isSafeInteger(final_host_id) || final_host_id < 0) {
return {
success: false as const,
msg: `Invalid host`,
};
}
const acme_provider =
typeof route.acme_dns_provider == "string"
? route.acme_dns_provider.trim()
: "";
const acme_email =
typeof route.acme_email == "string" ? route.acme_email.trim() : "";
if (acme_provider && !PROVIDER_PATTERN.test(acme_provider)) {
return {
success: false as const,
msg: `Invalid DNS provider "${acme_provider}"`,
};
}
if (acme_email && !EMAIL_PATTERN.test(acme_email)) {
return {
success: false as const,
msg: `Invalid ACME email "${acme_email}"`,
};
}
return {
success: true as const,
route: {
...route,
user_id: route.user_id || user_id,
host_id: final_host_id,
name: (route.name || hostname.value).trim().slice(0, 120),
hostname: hostname.value,
path_prefix: path_prefix.value,
strip_prefix: route.strip_prefix == 1 ? 1 : 0,
upstream_url: upstream.value,
upstream_tls_verify: route.upstream_tls_verify == 0 ? 0 : 1,
enabled: route.enabled == 0 ? 0 : 1,
notes: (route.notes || "").trim(),
acme_email,
acme_dns_provider: acme_provider,
acme_staging: route.acme_staging == 1 ? 1 : 0,
acme_credentials:
typeof route.acme_credentials == "string"
? route.acme_credentials
: "",
} satisfies BUN_SQLITE_WGUI_CLIENT_ROUTES,
};
}
@@ -0,0 +1,255 @@
import type {
BUN_SQLITE_WGUI_CLIENT_ROUTES,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
} from "@/db/types/db";
import {
copyFileSync,
existsSync,
mkdirSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import path from "node:path";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TableType } from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
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";
export type CaddySyncResult = {
success: boolean;
msg?: string;
warnings: string[];
};
type Params = {
reason?: string;
};
const {
WGUI_CADDY_BINARY,
WGUI_LIB_CADDY_DIR,
WGUI_LIB_CADDY_CONFIG_FILE,
WGUI_LIB_CADDY_STATE_FILE,
WGUI_LIB_CADDY_DATA_DIR,
WGUI_LIB_CADDY_ADMIN_SOCKET,
} = grabDirNames();
let sync_chain: Promise<CaddySyncResult> = Promise.resolve({
success: true,
warnings: [],
});
function writeState({
warnings,
last_error,
}: {
warnings: string[];
last_error?: string | null;
}) {
try {
writeFileSync(
WGUI_LIB_CADDY_STATE_FILE,
JSON.stringify(
{
last_reload_at: Date.now(),
last_error: last_error || null,
warnings,
},
null,
4,
),
{ mode: 0o644 },
);
} catch {
// The config is still valid; state is only for the setup page.
}
}
async function doSync({ reason }: Params): Promise<CaddySyncResult> {
if (!TlsData.IngressEnabled) {
return {
success: true,
warnings: [],
msg: `Caddy ingress is disabled`,
};
}
const routes_res = await BunSQLite.select<
BUN_SQLITE_WGUI_CLIENT_ROUTES,
TableType
>({
table: "client_routes",
});
const certificates_res = await BunSQLite.select<
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
TableType
>({
table: "tls_certificates",
});
const hosts_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
table: "hosts",
});
const routes = routes_res.payload || [];
const hosts = hosts_res.payload || [];
const certificates = (certificates_res.payload || [])
.filter(
(certificate) =>
Boolean(
certificate.cert_path &&
certificate.key_path &&
existsSync(certificate.cert_path) &&
existsSync(certificate.key_path),
),
)
.map((certificate) => ({
...certificate,
// A valid file on disk is enough to serve; the setup page
// separately reports expiry and errors.
status:
certificate.status == "issuing"
? ("issuing" as const)
: ("active" as const),
}));
const built = buildCaddyfile({
routes,
certificates,
hosts,
https_port: TlsData.CaddyHttpsPort,
http_port: TlsData.CaddyHttpPort,
admin_socket: WGUI_LIB_CADDY_ADMIN_SOCKET,
storage_dir: WGUI_LIB_CADDY_DATA_DIR,
});
if (!built.success || !built.config) {
writeState({
warnings: built.warnings,
last_error: built.msg || `Could not build the Caddyfile`,
});
return {
success: false,
msg: built.msg || `Could not build the Caddyfile`,
warnings: built.warnings,
};
}
mkdirSync(WGUI_LIB_CADDY_DIR, { recursive: true });
mkdirSync(WGUI_LIB_CADDY_DATA_DIR, { recursive: true });
const tmp_path = path.join(WGUI_LIB_CADDY_DIR, `Caddyfile.tmp`);
const backup_path = path.join(WGUI_LIB_CADDY_DIR, `Caddyfile.last-good`);
writeFileSync(tmp_path, built.config, { mode: 0o644 });
if (existsSync(WGUI_CADDY_BINARY)) {
try {
execFileSync(
WGUI_CADDY_BINARY,
[
"validate",
"--config",
tmp_path,
"--adapter",
"caddyfile",
],
{
encoding: "utf-8",
stdio: "pipe",
timeout: 30000,
},
);
} catch (error: any) {
rmSync(tmp_path, { force: true });
const msg = `Caddy rejected the generated config: ${
error?.stderr
? String(error.stderr).trim()
: error.message
}`;
writeState({ warnings: built.warnings, last_error: msg });
return {
success: false,
msg,
warnings: built.warnings,
};
}
}
if (existsSync(WGUI_LIB_CADDY_CONFIG_FILE)) {
copyFileSync(WGUI_LIB_CADDY_CONFIG_FILE, backup_path);
}
renameSync(tmp_path, WGUI_LIB_CADDY_CONFIG_FILE);
const reload = reloadCaddyService();
const warnings = [...built.warnings];
if (!reload.success) {
warnings.push(`Caddy reload failed: ${reload.msg}`);
}
writeState({
warnings,
last_error: reload.success ? null : reload.msg,
});
const host_ids = [
...new Set(
routes
.filter((route) => route.enabled != 0)
.map((route) => Number(route.host_id || 0))
.filter((host_id) => host_id >= 0),
),
];
for (let i = 0; i < host_ids.length; i++) {
const host_id = host_ids[i];
if (typeof host_id != "number") continue;
const firewall = applyCaddyIngressFirewallRule({ host_id });
if (!firewall.success && firewall.msg) {
warnings.push(firewall.msg);
}
}
return {
success: reload.success,
msg: reload.success
? `Caddy ingress synced (${reason || "manual"})`
: reload.msg,
warnings,
};
}
/**
* Serialize config writes. Route/cert mutations can arrive concurrently while
* Caddy is reloading; the last committed config must always be a complete one.
*/
export default function syncCaddyIngress(
params?: Params,
): Promise<CaddySyncResult> {
const run = sync_chain.then(
() => doSync(params || {}),
() => doSync(params || {}),
);
sync_chain = run.then(
() => ({ success: true, warnings: [] }),
() => ({ success: true, warnings: [] }),
);
return run;
}
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import validateRouteHostname from "./validate-route-hostname";
describe("validateRouteHostname", () => {
test("accepts and lowercases a DNS name", () => {
const res = validateRouteHostname({ value: "Media.Example.COM" });
expect(res.success).toBe(true);
expect(res.success && res.value).toBe("media.example.com");
});
test("rejects schemes, ports, wildcards and injection", () => {
for (const value of [
"https://media.example.com",
"media.example.com:8443",
"*.example.com",
"media.example.com\nhandle",
"media example.com",
"",
]) {
expect(validateRouteHostname({ value }).success).toBe(false);
}
});
});
@@ -0,0 +1,34 @@
const HOSTNAME_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])$/;
type Params = {
value?: string | null;
};
/**
* Route hostnames are used as Caddy site addresses and certificate names.
* Keep them to plain lowercase DNS labels: no wildcards, ports, schemes,
* spaces or Caddyfile control characters.
*/
export default function validateRouteHostname({ value }: Params) {
const hostname = (value || "").trim().toLowerCase();
if (!hostname) {
return {
success: false as const,
msg: `Hostname is required`,
};
}
if (!HOSTNAME_PATTERN.test(hostname)) {
return {
success: false as const,
msg: `Invalid hostname "${hostname}"`,
};
}
return {
success: true as const,
value: hostname,
};
}
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import validateRoutePath from "./validate-route-path";
describe("validateRoutePath", () => {
test("normalizes prefixes", () => {
expect(validateRoutePath({ value: "" })).toEqual({
success: true,
value: "/",
});
expect(validateRoutePath({ value: "app/" })).toEqual({
success: true,
value: "/app",
});
expect(validateRoutePath({ value: "//app//media/" })).toEqual({
success: true,
value: "/app/media",
});
});
test("rejects traversal, whitespace and Caddyfile characters", () => {
for (const value of ["/../etc", "/app/{x}", "/app x", "/app\nhandle"]) {
expect(validateRoutePath({ value }).success).toBe(false);
}
});
});
@@ -0,0 +1,43 @@
type Params = {
value?: string | null;
};
/**
* Normalize a route path prefix to `/`, `/app` or `/app/nested`.
* Rejects traversal, whitespace and Caddyfile control characters.
*/
export default function validateRoutePath({ value }: Params) {
let path_prefix = (value || "/").trim();
if (!path_prefix.startsWith("/")) {
path_prefix = `/${path_prefix}`;
}
path_prefix = path_prefix.replace(/\/{2,}/g, "/").replace(/\/$/, "") || "/";
if (path_prefix.length > 512) {
return {
success: false as const,
msg: `Path prefix is too long`,
};
}
if (/[\s?#{}\\'"`]/.test(path_prefix)) {
return {
success: false as const,
msg: `Path prefix contains invalid characters`,
};
}
if (path_prefix.split("/").some((part) => part == ".." || part == ".")) {
return {
success: false as const,
msg: `Path prefix cannot contain "." or ".."`,
};
}
return {
success: true as const,
value: path_prefix,
};
}
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import validateUpstreamUrl from "./validate-upstream-url";
describe("validateUpstreamUrl", () => {
test("accepts bare http and https origins", () => {
expect(validateUpstreamUrl({ value: "http://10.5.0.20:8080" })).toEqual({
success: true,
value: "http://10.5.0.20:8080",
secure: false,
});
expect(validateUpstreamUrl({ value: "https://app.internal" })).toEqual({
success: true,
value: "https://app.internal",
secure: true,
});
});
test("rejects paths, credentials, queries and non-http schemes", () => {
for (const value of [
"http://10.5.0.20:8080/app",
"http://user:[email protected]:8080",
"http://10.5.0.20:8080?x=1",
"ftp://10.5.0.20:8080",
"http://10.5.0.20:8080\nhandle",
"",
]) {
expect(validateUpstreamUrl({ value }).success).toBe(false);
}
});
});
@@ -0,0 +1,83 @@
type Params = {
value?: string | null;
};
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
/**
* Upstreams must be a bare http(s) origin. Paths, credentials, queries and
* fragments are rejected so joining a route path can never escape the origin.
*/
export default function validateUpstreamUrl({ value }: Params) {
const raw = (value || "").trim();
if (!raw) {
return {
success: false as const,
msg: `Upstream URL is required`,
};
}
if (raw.length > 2048 || CONTROL_CHARS.test(raw)) {
return {
success: false as const,
msg: `Invalid upstream URL`,
};
}
let url: URL;
try {
url = new URL(raw);
} catch {
return {
success: false as const,
msg: `Invalid upstream URL`,
};
}
if (url.protocol != "http:" && url.protocol != "https:") {
return {
success: false as const,
msg: `Upstream URL must use http or https`,
};
}
if (url.username || url.password) {
return {
success: false as const,
msg: `Upstream URL cannot contain credentials`,
};
}
if (
url.pathname != "/" ||
url.search ||
url.hash ||
!url.hostname
) {
return {
success: false as const,
msg: `Upstream URL must be a bare origin, e.g. http://10.0.0.5:8080`,
};
}
const port = url.port
? Number(url.port)
: url.protocol == "https:"
? 443
: 80;
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return {
success: false as const,
msg: `Invalid upstream port`,
};
}
return {
success: true as const,
value: `${url.protocol}//${url.host}`,
secure: url.protocol == "https:",
};
}
@@ -52,11 +52,18 @@ export default async function grabClients(
sql += `LEFT JOIN hosts h ON c.host_id = h.id\n`;
sql += `WHERE 1=1\n`;
const target_host_id = params?.host_id || query?.host_id;
const target_host_id =
params?.host_id !== undefined
? params.host_id
: query?.host_id;
const target_user_id = params?.user_id || query?.user_id;
const target_client_id = params?.client_id || query?.client_id || query?.id;
if (target_host_id) {
if (
target_host_id !== undefined &&
target_host_id !== null &&
target_host_id !== ""
) {
sql += ` AND c.host_id=?\n`;
values.push(target_host_id);
}
@@ -455,6 +455,30 @@ describe("buildHostIptablesScripts", () => {
);
});
test("allows the TLS ingress port from the wireguard interface", () => {
const res = buildHostIptablesScripts({
...base,
clients: [],
ingress_ports: [8443],
});
expect(res.success).toBe(true);
expect(res.post_up).toContain(
"iptables -A WGUI0IN -i wgui0 -p tcp --dport 8443 -j ACCEPT",
);
});
test("rejects an invalid ingress port", () => {
const res = buildHostIptablesScripts({
...base,
clients: [],
ingress_ports: [70000],
});
expect(res.success).toBe(false);
expect(res.msg).toBe("Invalid ingress port");
});
test("rejects oversized host id", () => {
const res = buildHostIptablesScripts({
...base,
@@ -19,6 +19,8 @@ type Params = {
host_subnet?: string;
sibling_subnets?: string[];
clients?: HostIptablesClient[];
/** Host-level TCP ports clients may reach (e.g. the Caddy ingress). */
ingress_ports?: number[];
};
const SAFE_IFACE = /^[A-Za-z][A-Za-z0-9._-]*$/;
@@ -148,6 +150,7 @@ export default function buildHostIptablesScripts({
host_subnet,
sibling_subnets,
clients,
ingress_ports,
}: Params) {
if (!Number.isSafeInteger(host_id) || host_id < 0 || host_id > 999999999) {
return {
@@ -184,6 +187,21 @@ export default function buildHostIptablesScripts({
const accept_lines: string[] = [];
const host_clients = clients || [];
for (let i = 0; i < (ingress_ports || []).length; i++) {
const port = ingress_ports?.[i];
if (!Number.isInteger(port) || Number(port) < 1 || Number(port) > 65535) {
return {
success: false,
msg: `Invalid ingress port`,
};
}
accept_lines.push(
`iptables -A ${input} -i ${interface_name} -p tcp --dport ${port} -j ACCEPT`,
);
}
for (let i = 0; i < host_clients.length; i++) {
const client = host_clients[i];
@@ -84,6 +84,11 @@ const TOOL_CHECKS: {
command: "bun",
version_command: "bun --version",
},
{
name: "caddy",
command: "/opt/caddy/caddy",
version_command: "/opt/caddy/caddy version",
},
];
const tryShell = ({
@@ -21,7 +21,10 @@ export default async function readWireguardClientConfig({
}
const { HOST_CLIENTS_DIR } = grabHostDirnames({
host: host_id ? { id: Number(host_id) } : undefined,
host:
host_id !== undefined && host_id !== ""
? { id: Number(host_id) }
: undefined,
});
const config_path = path.join(
@@ -17,6 +17,7 @@ import grabClientDirnames from "./grab-client-dir-names";
import buildHostIptablesScripts from "./build-host-iptables-scripts";
import allowSubnetFirewallRules from "../firewall/allow-subnet-firewall-rules";
import { AppData } from "@/src/data/app-data";
import { TlsData } from "@/src/data/tls-data";
const { WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME } =
grabDirNames();
@@ -215,6 +216,9 @@ export default async function setupWireguardHost({
interface_name: INTERFACE_NAME,
host_subnet: HOST_SUBNET,
sibling_subnets,
ingress_ports: TlsData.IngressEnabled
? [TlsData.CaddyHttpsPort]
: [],
clients: clients.map((client) => ({
id: client.id,
wg_ip_address: client.wg_ip_address,