627 lines
16 KiB
TypeScript
627 lines
16 KiB
TypeScript
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import crypto from "node:crypto";
|
|
import prisma, { nowIso } from "./db";
|
|
import { config } from "./config";
|
|
import { getCloudflareSettings, setSetting } from "./settings";
|
|
|
|
const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs");
|
|
mkdirSync(CERTS_DIR, { recursive: true });
|
|
|
|
const DEFAULT_AUTHENTIK_HEADERS = [
|
|
"X-Authentik-Username",
|
|
"X-Authentik-Groups",
|
|
"X-Authentik-Entitlements",
|
|
"X-Authentik-Email",
|
|
"X-Authentik-Name",
|
|
"X-Authentik-Uid",
|
|
"X-Authentik-Jwt",
|
|
"X-Authentik-Meta-Jwks",
|
|
"X-Authentik-Meta-Outpost",
|
|
"X-Authentik-Meta-Provider",
|
|
"X-Authentik-Meta-App",
|
|
"X-Authentik-Meta-Version"
|
|
];
|
|
|
|
const DEFAULT_AUTHENTIK_TRUSTED_PROXIES = ["private_ranges"];
|
|
|
|
type ProxyHostRow = {
|
|
id: number;
|
|
name: string;
|
|
domains: string;
|
|
upstreams: string;
|
|
certificate_id: number | null;
|
|
access_list_id: number | null;
|
|
ssl_forced: number;
|
|
hsts_enabled: number;
|
|
hsts_subdomains: number;
|
|
allow_websocket: number;
|
|
preserve_host_header: number;
|
|
skip_https_hostname_validation: number;
|
|
meta: string | null;
|
|
enabled: number;
|
|
};
|
|
|
|
type ProxyHostMeta = {
|
|
custom_reverse_proxy_json?: string;
|
|
custom_pre_handlers_json?: string;
|
|
authentik?: ProxyHostAuthentikMeta;
|
|
};
|
|
|
|
type ProxyHostAuthentikMeta = {
|
|
enabled?: boolean;
|
|
outpost_domain?: string;
|
|
outpost_upstream?: string;
|
|
auth_endpoint?: string;
|
|
copy_headers?: string[];
|
|
trusted_proxies?: string[];
|
|
set_outpost_host_header?: boolean;
|
|
};
|
|
|
|
type AuthentikRouteConfig = {
|
|
enabled: boolean;
|
|
outpostDomain: string;
|
|
outpostUpstream: string;
|
|
authEndpoint: string;
|
|
copyHeaders: string[];
|
|
trustedProxies: string[];
|
|
setOutpostHostHeader: boolean;
|
|
};
|
|
|
|
type RedirectHostRow = {
|
|
id: number;
|
|
name: string;
|
|
domains: string;
|
|
destination: string;
|
|
status_code: number;
|
|
preserve_query: number;
|
|
enabled: number;
|
|
};
|
|
|
|
type DeadHostRow = {
|
|
id: number;
|
|
name: string;
|
|
domains: string;
|
|
status_code: number;
|
|
response_body: string | null;
|
|
enabled: number;
|
|
};
|
|
|
|
type AccessListEntryRow = {
|
|
access_list_id: number;
|
|
username: string;
|
|
password_hash: string;
|
|
};
|
|
|
|
type CertificateRow = {
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
domain_names: string;
|
|
certificate_pem: string | null;
|
|
private_key_pem: string | null;
|
|
auto_renew: number;
|
|
provider_options: string | null;
|
|
};
|
|
|
|
type CaddyHttpRoute = Record<string, unknown>;
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function parseJson<T>(value: string | null, fallback: T): T {
|
|
if (!value) {
|
|
return fallback;
|
|
}
|
|
try {
|
|
return JSON.parse(value) as T;
|
|
} catch (error) {
|
|
console.warn("Failed to parse JSON value", value, error);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function parseOptionalJson(value: string | null | undefined) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch (error) {
|
|
console.warn("Failed to parse custom JSON", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function mergeDeep(target: Record<string, unknown>, source: Record<string, unknown>) {
|
|
for (const [key, value] of Object.entries(source)) {
|
|
const existing = target[key];
|
|
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
mergeDeep(existing, value);
|
|
} else {
|
|
target[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseCustomHandlers(value: string | null | undefined): Record<string, unknown>[] {
|
|
const parsed = parseOptionalJson(value);
|
|
if (!parsed) {
|
|
return [];
|
|
}
|
|
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
const handlers: Record<string, unknown>[] = [];
|
|
for (const item of list) {
|
|
if (isPlainObject(item)) {
|
|
handlers.push(item);
|
|
} else {
|
|
console.warn("Ignoring custom handler entry that is not an object", item);
|
|
}
|
|
}
|
|
return handlers;
|
|
}
|
|
|
|
function writeCertificateFiles(cert: CertificateRow) {
|
|
if (cert.type !== "imported" || !cert.certificate_pem || !cert.private_key_pem) {
|
|
return null;
|
|
}
|
|
const certPath = join(CERTS_DIR, `certificate-${cert.id}.pem`);
|
|
const keyPath = join(CERTS_DIR, `certificate-${cert.id}.key.pem`);
|
|
writeFileSync(certPath, cert.certificate_pem, { encoding: "utf-8" });
|
|
writeFileSync(keyPath, cert.private_key_pem, { encoding: "utf-8" });
|
|
return { certificate_file: certPath, key_file: keyPath };
|
|
}
|
|
|
|
function buildProxyRoutes(
|
|
rows: ProxyHostRow[],
|
|
certificates: Map<number, CertificateRow>,
|
|
accessAccounts: Map<number, AccessListEntryRow[]>
|
|
): CaddyHttpRoute[] {
|
|
const routes: CaddyHttpRoute[] = [];
|
|
|
|
for (const row of rows) {
|
|
if (!row.enabled) {
|
|
continue;
|
|
}
|
|
|
|
const domains = parseJson<string[]>(row.domains, []);
|
|
if (domains.length === 0) {
|
|
continue;
|
|
}
|
|
const upstreams = parseJson<string[]>(row.upstreams, []);
|
|
if (upstreams.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const handlers: Record<string, unknown>[] = [];
|
|
const meta = parseJson<ProxyHostMeta>(row.meta, {});
|
|
const authentik = parseAuthentikConfig(meta.authentik);
|
|
const hostRoutes: CaddyHttpRoute[] = [];
|
|
|
|
if (row.hsts_enabled) {
|
|
const value = row.hsts_subdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
|
|
handlers.push({
|
|
handler: "headers",
|
|
response: {
|
|
set: {
|
|
"Strict-Transport-Security": [value]
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (row.access_list_id) {
|
|
const accounts = accessAccounts.get(row.access_list_id) ?? [];
|
|
if (accounts.length > 0) {
|
|
handlers.push({
|
|
handler: "authentication",
|
|
providers: {
|
|
http_basic: {
|
|
accounts: accounts.map((entry) => ({
|
|
username: entry.username,
|
|
password: entry.password_hash
|
|
}))
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const reverseProxyHandler: Record<string, unknown> = {
|
|
handler: "reverse_proxy",
|
|
upstreams: upstreams.map((dial) => ({ dial }))
|
|
};
|
|
|
|
if (authentik) {
|
|
const outpostHandler: Record<string, unknown> = {
|
|
handler: "reverse_proxy",
|
|
upstreams: [
|
|
{
|
|
dial: authentik.outpostUpstream
|
|
}
|
|
]
|
|
};
|
|
|
|
if (authentik.setOutpostHostHeader) {
|
|
outpostHandler.headers = {
|
|
request: {
|
|
set: {
|
|
Host: ["{http.reverse_proxy.upstream.host}"]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
hostRoutes.push({
|
|
match: [
|
|
{
|
|
host: domains,
|
|
path: [`/${authentik.outpostDomain}/*`]
|
|
}
|
|
],
|
|
handle: [outpostHandler],
|
|
terminal: true
|
|
});
|
|
}
|
|
|
|
if (row.preserve_host_header) {
|
|
reverseProxyHandler.headers = {
|
|
request: {
|
|
set: {
|
|
Host: ["{http.request.host}"]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
if (row.skip_https_hostname_validation) {
|
|
reverseProxyHandler.transport = {
|
|
http: {
|
|
tls: {
|
|
insecure_skip_verify: true
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
const customReverseProxy = parseOptionalJson(meta.custom_reverse_proxy_json);
|
|
if (customReverseProxy) {
|
|
if (isPlainObject(customReverseProxy)) {
|
|
mergeDeep(reverseProxyHandler, customReverseProxy as Record<string, unknown>);
|
|
} else {
|
|
console.warn("Ignoring custom reverse proxy JSON because it is not an object", customReverseProxy);
|
|
}
|
|
}
|
|
|
|
const customHandlers = parseCustomHandlers(meta.custom_pre_handlers_json);
|
|
if (customHandlers.length > 0) {
|
|
handlers.push(...customHandlers);
|
|
}
|
|
|
|
if (authentik) {
|
|
handlers.push({
|
|
handler: "forward_auth",
|
|
upstreams: [
|
|
{
|
|
dial: authentik.outpostUpstream
|
|
}
|
|
],
|
|
uri: authentik.authEndpoint,
|
|
copy_headers: authentik.copyHeaders,
|
|
trusted_proxies: authentik.trustedProxies
|
|
});
|
|
}
|
|
|
|
handlers.push(reverseProxyHandler);
|
|
|
|
const route: CaddyHttpRoute = {
|
|
match: [
|
|
{
|
|
host: domains
|
|
}
|
|
],
|
|
handle: handlers,
|
|
terminal: true
|
|
};
|
|
|
|
if (row.certificate_id) {
|
|
const cert = certificates.get(row.certificate_id);
|
|
if (cert) {
|
|
const files = writeCertificateFiles(cert);
|
|
if (files) {
|
|
(route as Record<string, unknown>).tls = {
|
|
certificates: [
|
|
{
|
|
certificate_file: files.certificate_file,
|
|
key_file: files.key_file
|
|
}
|
|
]
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
hostRoutes.push(route);
|
|
routes.push(...hostRoutes);
|
|
}
|
|
|
|
return routes;
|
|
}
|
|
|
|
function buildRedirectRoutes(rows: RedirectHostRow[]): CaddyHttpRoute[] {
|
|
return rows
|
|
.filter((row) => Boolean(row.enabled))
|
|
.map((row) => {
|
|
const domains = parseJson<string[]>(row.domains, []);
|
|
const preserveQuery = Boolean(row.preserve_query);
|
|
const location = preserveQuery ? `${row.destination}{uri}` : row.destination;
|
|
return {
|
|
match: [{ host: domains }],
|
|
handle: [
|
|
{
|
|
handler: "static_response",
|
|
status_code: row.status_code,
|
|
headers: {
|
|
Location: [location],
|
|
"Strict-Transport-Security": ["max-age=63072000"]
|
|
}
|
|
}
|
|
],
|
|
terminal: true
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildDeadRoutes(rows: DeadHostRow[]): CaddyHttpRoute[] {
|
|
return rows
|
|
.filter((row) => Boolean(row.enabled))
|
|
.map((row) => ({
|
|
match: [{ host: parseJson<string[]>(row.domains, []) }],
|
|
handle: [
|
|
{
|
|
handler: "static_response",
|
|
status_code: row.status_code,
|
|
body: row.response_body ?? "Service unavailable",
|
|
headers: {
|
|
"Strict-Transport-Security": ["max-age=63072000"]
|
|
}
|
|
}
|
|
],
|
|
terminal: true
|
|
}));
|
|
}
|
|
|
|
function buildTlsAutomation(certificates: Map<number, CertificateRow>) {
|
|
// TODO: This function needs to be migrated to async to use getCloudflareSettings()
|
|
// For now, Cloudflare DNS challenges are disabled until migration is complete
|
|
return undefined;
|
|
}
|
|
|
|
async function buildCaddyDocument() {
|
|
const [proxyHosts, redirectHosts, deadHosts, certRows, accessListEntries] = await Promise.all([
|
|
prisma.proxyHost.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
domains: true,
|
|
upstreams: true,
|
|
certificateId: true,
|
|
accessListId: true,
|
|
sslForced: true,
|
|
hstsEnabled: true,
|
|
hstsSubdomains: true,
|
|
allowWebsocket: true,
|
|
preserveHostHeader: true,
|
|
skipHttpsHostnameValidation: true,
|
|
meta: true,
|
|
enabled: true
|
|
}
|
|
}),
|
|
prisma.redirectHost.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
domains: true,
|
|
destination: true,
|
|
statusCode: true,
|
|
preserveQuery: true,
|
|
enabled: true
|
|
}
|
|
}),
|
|
prisma.deadHost.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
domains: true,
|
|
statusCode: true,
|
|
responseBody: true,
|
|
enabled: true
|
|
}
|
|
}),
|
|
prisma.certificate.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
type: true,
|
|
domainNames: true,
|
|
certificatePem: true,
|
|
privateKeyPem: true,
|
|
autoRenew: true,
|
|
providerOptions: true
|
|
}
|
|
}),
|
|
prisma.accessListEntry.findMany({
|
|
select: {
|
|
accessListId: true,
|
|
username: true,
|
|
passwordHash: true
|
|
}
|
|
})
|
|
]);
|
|
|
|
// Map Prisma results to expected types
|
|
const proxyHostRows: ProxyHostRow[] = proxyHosts.map((h: typeof proxyHosts[0]) => ({
|
|
id: h.id,
|
|
name: h.name,
|
|
domains: h.domains,
|
|
upstreams: h.upstreams,
|
|
certificate_id: h.certificateId,
|
|
access_list_id: h.accessListId,
|
|
ssl_forced: h.sslForced ? 1 : 0,
|
|
hsts_enabled: h.hstsEnabled ? 1 : 0,
|
|
hsts_subdomains: h.hstsSubdomains ? 1 : 0,
|
|
allow_websocket: h.allowWebsocket ? 1 : 0,
|
|
preserve_host_header: h.preserveHostHeader ? 1 : 0,
|
|
skip_https_hostname_validation: h.skipHttpsHostnameValidation ? 1 : 0,
|
|
meta: h.meta,
|
|
enabled: h.enabled ? 1 : 0
|
|
}));
|
|
|
|
const redirectHostRows: RedirectHostRow[] = redirectHosts.map((h: typeof redirectHosts[0]) => ({
|
|
id: h.id,
|
|
name: h.name,
|
|
domains: h.domains,
|
|
destination: h.destination,
|
|
status_code: h.statusCode,
|
|
preserve_query: h.preserveQuery ? 1 : 0,
|
|
enabled: h.enabled ? 1 : 0
|
|
}));
|
|
|
|
const deadHostRows: DeadHostRow[] = deadHosts.map((h: typeof deadHosts[0]) => ({
|
|
id: h.id,
|
|
name: h.name,
|
|
domains: h.domains,
|
|
status_code: h.statusCode,
|
|
response_body: h.responseBody,
|
|
enabled: h.enabled ? 1 : 0
|
|
}));
|
|
|
|
const certRowsMapped: CertificateRow[] = certRows.map((c: typeof certRows[0]) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
type: c.type as "managed" | "imported",
|
|
domain_names: c.domainNames,
|
|
certificate_pem: c.certificatePem,
|
|
private_key_pem: c.privateKeyPem,
|
|
auto_renew: c.autoRenew ? 1 : 0,
|
|
provider_options: c.providerOptions
|
|
}));
|
|
|
|
const accessListEntryRows: AccessListEntryRow[] = accessListEntries.map((e: typeof accessListEntries[0]) => ({
|
|
access_list_id: e.accessListId,
|
|
username: e.username,
|
|
password_hash: e.passwordHash
|
|
}));
|
|
|
|
const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));
|
|
const accessMap = accessListEntryRows.reduce<Map<number, AccessListEntryRow[]>>((map, entry) => {
|
|
if (!map.has(entry.access_list_id)) {
|
|
map.set(entry.access_list_id, []);
|
|
}
|
|
map.get(entry.access_list_id)!.push(entry);
|
|
return map;
|
|
}, new Map());
|
|
|
|
const httpRoutes: CaddyHttpRoute[] = [
|
|
...buildProxyRoutes(proxyHostRows, certificateMap, accessMap),
|
|
...buildRedirectRoutes(redirectHostRows),
|
|
...buildDeadRoutes(deadHostRows)
|
|
];
|
|
|
|
const tlsSection = buildTlsAutomation(certificateMap);
|
|
|
|
const httpApp =
|
|
httpRoutes.length > 0
|
|
? {
|
|
http: {
|
|
servers: {
|
|
cpm: {
|
|
listen: [":80", ":443"],
|
|
routes: httpRoutes
|
|
}
|
|
}
|
|
}
|
|
}
|
|
: {};
|
|
|
|
return {
|
|
apps: {
|
|
...httpApp,
|
|
...(tlsSection ? { tls: tlsSection } : {})
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function applyCaddyConfig() {
|
|
const document = await buildCaddyDocument();
|
|
const payload = JSON.stringify(document);
|
|
const hash = crypto.createHash("sha256").update(payload).digest("hex");
|
|
setSetting("caddy_config_hash", { hash, updated_at: nowIso() });
|
|
|
|
try {
|
|
const response = await fetch(`${config.caddyApiUrl}/load`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: payload
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Caddy config load failed: ${response.status} ${text}`);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to apply Caddy config", error);
|
|
|
|
// Check if it's a fetch error with ECONNREFUSED or ENOTFOUND
|
|
const err = error as { cause?: NodeJS.ErrnoException };
|
|
const causeCode = err?.cause?.code;
|
|
|
|
if (causeCode === "ENOTFOUND" || causeCode === "ECONNREFUSED") {
|
|
throw new Error(`Unable to reach Caddy API at ${config.caddyApiUrl}. Ensure Caddy is running and accessible.`);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function parseAuthentikConfig(meta: ProxyHostAuthentikMeta | undefined | null): AuthentikRouteConfig | null {
|
|
if (!meta || !meta.enabled) {
|
|
return null;
|
|
}
|
|
|
|
const outpostDomain = typeof meta.outpost_domain === "string" ? meta.outpost_domain.trim() : "";
|
|
const outpostUpstream = typeof meta.outpost_upstream === "string" ? meta.outpost_upstream.trim() : "";
|
|
if (!outpostDomain || !outpostUpstream) {
|
|
return null;
|
|
}
|
|
|
|
const authEndpointRaw = typeof meta.auth_endpoint === "string" ? meta.auth_endpoint.trim() : "";
|
|
const authEndpoint = authEndpointRaw || `/${outpostDomain}/auth/caddy`;
|
|
|
|
const copyHeaders =
|
|
Array.isArray(meta.copy_headers) && meta.copy_headers.length > 0
|
|
? meta.copy_headers.map((header) => header?.trim()).filter((header): header is string => Boolean(header))
|
|
: DEFAULT_AUTHENTIK_HEADERS;
|
|
|
|
const trustedProxies =
|
|
Array.isArray(meta.trusted_proxies) && meta.trusted_proxies.length > 0
|
|
? meta.trusted_proxies.map((item) => item?.trim()).filter((item): item is string => Boolean(item))
|
|
: DEFAULT_AUTHENTIK_TRUSTED_PROXIES;
|
|
|
|
const setOutpostHostHeader =
|
|
meta.set_outpost_host_header !== undefined ? Boolean(meta.set_outpost_host_header) : true;
|
|
|
|
return {
|
|
enabled: true,
|
|
outpostDomain,
|
|
outpostUpstream,
|
|
authEndpoint,
|
|
copyHeaders,
|
|
trustedProxies,
|
|
setOutpostHostHeader
|
|
};
|
|
}
|