Add TLS feature with Caddy
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user