2026-05-01 15:51:12 +00:00
|
|
|
import fs from "fs/promises";
|
|
|
|
|
import path from "path";
|
2026-05-21 13:06:15 +00:00
|
|
|
import { execFile } from "child_process";
|
2026-05-01 17:09:45 +00:00
|
|
|
import { promisify } from "util";
|
2026-05-01 15:51:12 +00:00
|
|
|
import { getDb, getSetting, type DomainRow, type DomainGroupRow } from "./db";
|
|
|
|
|
|
2026-05-21 13:06:15 +00:00
|
|
|
const execFileAsync = promisify(execFile);
|
2026-05-01 17:09:45 +00:00
|
|
|
|
2026-05-01 15:51:12 +00:00
|
|
|
const CADDYFILE_PATH = process.env.NEXREDIRECT_CADDYFILE || "/etc/caddy/Caddyfile";
|
|
|
|
|
const CADDY_ADMIN = process.env.CADDY_ADMIN_URL || "http://localhost:2019";
|
|
|
|
|
const APP_PORT = process.env.PORT || "3000";
|
|
|
|
|
|
|
|
|
|
export function buildCaddyfile(): string {
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const baseDomain = getSetting("base_domain");
|
|
|
|
|
const adminEmail = getSetting("admin_email") || "admin@example.com";
|
|
|
|
|
|
|
|
|
|
const domains = db.prepare("SELECT * FROM domains WHERE status = 'active'").all() as DomainRow[];
|
|
|
|
|
const groups = db.prepare("SELECT * FROM domain_groups").all() as DomainGroupRow[];
|
|
|
|
|
const groupMap = new Map(groups.map((g) => [g.id, g]));
|
|
|
|
|
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
lines.push(`{`);
|
|
|
|
|
lines.push(` email ${adminEmail}`);
|
|
|
|
|
lines.push(`}`);
|
|
|
|
|
lines.push(``);
|
|
|
|
|
|
2026-05-01 19:36:24 +00:00
|
|
|
const adminHeaders = [
|
|
|
|
|
` header {`,
|
|
|
|
|
` Strict-Transport-Security "max-age=31536000; includeSubDomains"`,
|
|
|
|
|
` X-Content-Type-Options "nosniff"`,
|
|
|
|
|
` X-Frame-Options "DENY"`,
|
|
|
|
|
` Referrer-Policy "strict-origin-when-cross-origin"`,
|
|
|
|
|
` Permissions-Policy "geolocation=(), microphone=(), camera=()"`,
|
|
|
|
|
` -Server`,
|
|
|
|
|
` }`,
|
|
|
|
|
];
|
|
|
|
|
|
2026-05-01 15:51:12 +00:00
|
|
|
// Admin-UI
|
|
|
|
|
const adminHosts: string[] = [":80"];
|
|
|
|
|
if (baseDomain) adminHosts.push(baseDomain);
|
|
|
|
|
lines.push(`${adminHosts.join(", ")} {`);
|
2026-05-01 19:36:24 +00:00
|
|
|
lines.push(...adminHeaders);
|
2026-05-01 15:51:12 +00:00
|
|
|
lines.push(` reverse_proxy localhost:${APP_PORT}`);
|
|
|
|
|
lines.push(`}`);
|
|
|
|
|
lines.push(``);
|
|
|
|
|
|
|
|
|
|
// Per-Domain redirect blocks → reverse_proxy to app for hit logging
|
|
|
|
|
for (const d of domains) {
|
|
|
|
|
if (baseDomain && d.domain === baseDomain) continue;
|
|
|
|
|
const hosts = [d.domain];
|
|
|
|
|
if (d.include_www) hosts.push(`www.${d.domain}`);
|
|
|
|
|
lines.push(`${hosts.join(", ")} {`);
|
|
|
|
|
lines.push(` reverse_proxy localhost:${APP_PORT}`);
|
|
|
|
|
|
|
|
|
|
// Fallback redirect baked-in: if app is down, Caddy still redirects (no analytics)
|
|
|
|
|
const fallbackTarget = d.target_url || (d.group_id ? groupMap.get(d.group_id)?.target_url : null);
|
|
|
|
|
if (fallbackTarget) {
|
|
|
|
|
const code = d.redirect_code || 301;
|
|
|
|
|
const target = d.preserve_path ? `${fallbackTarget}{uri}` : fallbackTarget;
|
|
|
|
|
lines.push(` handle_errors {`);
|
|
|
|
|
lines.push(` redir ${target} ${code}`);
|
|
|
|
|
lines.push(` }`);
|
|
|
|
|
}
|
|
|
|
|
lines.push(`}`);
|
|
|
|
|
lines.push(``);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lines.join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function writeCaddyfile(): Promise<void> {
|
|
|
|
|
const content = buildCaddyfile();
|
|
|
|
|
await fs.mkdir(path.dirname(CADDYFILE_PATH), { recursive: true }).catch(() => {});
|
|
|
|
|
await fs.writeFile(CADDYFILE_PATH, content, "utf8");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function reloadCaddy(): Promise<{ ok: boolean; error?: string }> {
|
|
|
|
|
try {
|
|
|
|
|
await writeCaddyfile();
|
2026-05-01 17:09:45 +00:00
|
|
|
} catch (e) {
|
|
|
|
|
return { ok: false, error: `write Caddyfile failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try shell `caddy reload` first — it talks to admin API as caddy itself, no Origin-header issues.
|
|
|
|
|
try {
|
2026-05-21 13:06:15 +00:00
|
|
|
await execFileAsync("caddy", ["reload", "--config", CADDYFILE_PATH, "--address", "localhost:2019"], { timeout: 30_000 });
|
2026-05-01 15:51:12 +00:00
|
|
|
return { ok: true };
|
|
|
|
|
} catch (e) {
|
2026-05-01 17:09:45 +00:00
|
|
|
// Fall back to direct admin API POST (older Caddy / different admin URL).
|
|
|
|
|
try {
|
|
|
|
|
const adapt = await fetch(`${CADDY_ADMIN}/load`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "text/caddyfile", Origin: "" },
|
|
|
|
|
body: buildCaddyfile(),
|
|
|
|
|
});
|
|
|
|
|
if (!adapt.ok) {
|
|
|
|
|
const text = await adapt.text().catch(() => "");
|
|
|
|
|
return { ok: false, error: `caddy reload failed: ${e instanceof Error ? e.message : String(e)} | api fallback: ${adapt.status} ${text}` };
|
|
|
|
|
}
|
|
|
|
|
return { ok: true };
|
|
|
|
|
} catch (e2) {
|
|
|
|
|
return { ok: false, error: `caddy reload + api fallback both failed: ${e instanceof Error ? e.message : String(e)} ; ${e2 instanceof Error ? e2.message : String(e2)}` };
|
|
|
|
|
}
|
2026-05-01 15:51:12 +00:00
|
|
|
}
|
|
|
|
|
}
|