Files
apxtri/models/Caddy.js
T
2026-10-12 13:51:47 +00:00

809 lines
36 KiB
JavaScript
Executable File

const fs = require("fs-extra");
const path = require("path");
const os = require("os");
const axios = require("axios");
const { execSync } = require('child_process');
const { getWebappSource, listWebappNames, resolveWebappDns } = require("../utils/webapps.js");
const DATA_PATH = process.env.DATAPATH;
const NODE_PATH = process.env.NODEPATH;
const TOWN = process.env.TOWN;
const NATION = process.env.NATION;
const CADDY_API_URL = process.env.CADDYAPIURL;
const DOMAIN = process.env.DOMAIN || "";
// Let's Encrypt production endpoint. This is also Caddy's own default, but it is
// declared explicitly here so the CA in use never depends on that default.
const ACME_DEFAULT_CA = "https://acme-v02.api.letsencrypt.org/directory";
// RFC 9110 token: the characters a header field-name may be built from. Caddy
// turns each key of a `headers` handler `response.set` map into a response
// header name, so a name outside this set would make the whole generated config
// invalid — and an invalid config is rejected at reload, i.e. a town-wide
// outage rather than one degraded webapp.
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const log = (message, ...args) => {
if (process.env.ACTIVELOG?.split(',').includes('Caddy')) {
console.log(`[Caddy] ${message}`, ...args);
}
};
/**
* Manages interactions with the Caddy server.
*/
class Caddy {
/**
* Reads the OPTIONAL per-webapp response headers declared in an appconf.json.
*
* Declared shape (a webapp's own appconf.json, or a mainapp subapp's):
* "security": {
* "headers": {
* "Content-Security-Policy": "default-src 'none'; …",
* "Referrer-Policy": "same-origin",
* "X-Content-Type-Options": "nosniff",
* "X-Frame-Options": "SAMEORIGIN",
* "Permissions-Policy": "geolocation=(), …"
* }
* }
*
* Any header name is accepted on purpose: the block is a plain name/value
* map, so a webapp that has been audited can carry its CSP *and* the
* companion headers (Referrer-Policy, X-Content-Type-Options,
* X-Frame-Options next to frame-ancestors, Permissions-Policy,
* Strict-Transport-Security, …) from a single declaration.
*
* Nothing declared — the case for every webapp that has not been audited —
* yields {}, and applySecurityHeaders() then returns the handler chain
* untouched. That is what makes the mechanism safe to roll out one webapp
* at a time: enabling it cannot change the response of any other webapp.
*
* A name that is not an RFC 9110 token, or a value containing CR or LF, is
* dropped with a warning rather than emitted: both would make the generated
* config invalid and take the town down at reload.
*
* @param {object} conf Parsed appconf.json (may be null/undefined)
* @param {string} label Human-readable "tribe/webapp" label for the log
* @returns {Object<string,string>} Sanitised header name -> value map (possibly empty)
*/
static parseSecurityHeaders(conf, label) {
const declared = conf && conf.security && conf.security.headers;
if (!declared || typeof declared !== "object" || Array.isArray(declared)) return {};
const out = {};
for (const [name, value] of Object.entries(declared)) {
if (!HEADER_NAME_RE.test(name)) {
console.warn(`[Caddy] ${label}: security.headers name "${name}" is not a valid header field-name, dropped`);
continue;
}
const headerValue = String(value == null ? "" : value).replace(/^[\s]+|[\s]+$/g, "");
if (!headerValue) {
console.warn(`[Caddy] ${label}: security.headers "${name}" has an empty value, dropped`);
continue;
}
if (/[\r\n]/.test(headerValue)) {
console.warn(`[Caddy] ${label}: security.headers "${name}" contains CR or LF, dropped`);
continue;
}
out[name] = headerValue;
}
if (Object.keys(out).length) {
log(`${label}: security headers declared -> ${Object.keys(out).join(', ')}`);
}
return out;
}
/**
* Prepends the Caddy `headers` handler for a webapp's declared security
* headers to a route handler chain, or returns the chain untouched when the
* webapp declares none.
*
* The handler must come FIRST: Caddy's `headers` handler wraps every
* handler that follows it in the same route, so putting it after the
* `file_server` would have it decorate nothing.
*
* @param {Object<string,string>} headers Name -> value map from parseSecurityHeaders()
* @param {Array<object>} handlers The route's existing handler chain
* @returns {Array<object>} The handler chain to use
*/
static applySecurityHeaders(headers, handlers) {
const names = Object.keys(headers || {});
if (!names.length) return handlers;
const set = {};
for (const name of names) set[name] = [headers[name]];
return [{ handler: "headers", response: { set } }, ...handlers];
}
/**
* Reads a mainapp subapp's OPTIONAL appconf.json.
*
* A subapp is not a webapp of its own: it lives inside the town mainapp and
* has no code directory, so it declares its per-app configuration (the
* security headers today) in its own appconf.json. The SOURCE copy is
* preferred so a versioned declaration wins over the served one, with the
* runtime dist as fallback for a subapp whose declaration was placed there
* directly. `Wwws.buildpwa` writes named files into the dist and never
* removes the directory, so a declaration living in the dist survives a
* rebuild.
*
* @param {string} subapp Subapp directory name (e.g. "mymail")
* @returns {object|null} Parsed appconf.json, or null when there is none
*/
static readSubappAppconf(subapp) {
const candidates = [
path.join(getWebappSource(TOWN, "mainapp"), subapp, "appconf.json"),
path.join(DATA_PATH, TOWN, "wwws", "mainapp", subapp, "appconf.json")
];
for (const confPath of candidates) {
if (!fs.existsSync(confPath)) continue;
try {
return JSON.parse(fs.readFileSync(confPath, "utf8"));
} catch (error) {
console.warn(`[Caddy] malformed appconf.json for subapp ${subapp} (${confPath}): ${error.message}`);
}
}
return null;
}
/**
* Retrieves the public and local network IP addresses.
*/
static async getip() {
const network = {};
try {
const response = await axios.get('https://api.ipify.org?format=json', { timeout: 3000 });
network.WANIP = response.data.ip;
} catch (error) {
log("Could not fetch public IP address:", error.message);
network.WANIP = "unknown";
}
const interfaces = os.networkInterfaces();
for (const name in interfaces) {
for (const iface of interfaces[name]) {
if (iface.family === "IPv4" && !iface.internal) {
network[name] = iface.address;
}
}
}
return network;
}
/**
* Reloads Caddy with a new configuration.
* Returns { status, ref, msg, data } so callers can detect a failed reload.
*/
static async reload(newConf) {
try {
log("Reloading Caddy configuration via HTTP API...");
await axios.post(`${CADDY_API_URL}/load`, newConf, {
headers: { 'Content-Type': 'application/json' }
});
log("✅ Caddy configuration updated successfully.");
return { status: 200, ref: "Caddy", msg: "caddy_reloaded" };
} catch (error) {
const errorMessage = error.response?.data?.error || error.message;
console.error("❌ Caddy reload error:", errorMessage);
if (error.response) {
console.error("Full error response:", JSON.stringify(error.response.data, null, 2));
}
return { status: 500, ref: "Caddy", msg: "caddy_reload_failed", data: { error: errorMessage } };
}
}
/**
* Re-discovers webapps, regenerates Caddy config, and hot-reloads Caddy via HTTP API.
*/
static async refreshConfig() {
try {
const webapps = Caddy.discoverWebapps();
const result = Caddy.getcaddyconf(webapps);
if (result.status !== 200) {
return { status: 500, ref: "Caddy", msg: "caddy_reload_failed", data: { error: result.msg || "config generation failed" } };
}
return await Caddy.reload(result.data.caddyconf);
} catch (error) {
console.error("[Caddy] Failed to refresh config:", error.message);
return { status: 500, ref: "Caddy", msg: "caddy_reload_failed", data: { error: error.message } };
}
}
/**
* Discovers webapps from wwws directories across all tribes.
* Looks for conf.json in {NODEPATH}/{tribe}/wwws/{webapp}/
*/
static discoverWebapps() {
const webapps = [];
const tribesPath = DATA_PATH;
if (!fs.existsSync(tribesPath)) {
log("Tribes path does not exist:", tribesPath);
return webapps;
}
const tribes = fs.readdirSync(tribesPath);
for (const tribe of tribes) {
if (tribe === 'logs' || tribe === 'backup' || tribe === 'tmp') continue;
const webappDirs = listWebappNames(tribe);
for (const webapp of webappDirs) {
const sourcePath = getWebappSource(tribe, webapp);
const confPath = path.join(sourcePath, 'appconf.json');
if (!fs.existsSync(confPath)) {
continue;
}
try {
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
if (conf.enabled === false) {
log(`Webapp ${webapp} in tribe ${tribe} is disabled`);
continue;
}
const runtimePath = path.join(DATA_PATH, tribe, 'wwws', webapp);
const dns = resolveWebappDns(conf, NATION);
webapps.push({
name: webapp,
tribe,
dns,
runtimePath,
sourcePath,
allowedOrigins: conf.allowedOrigins || [],
securityHeaders: Caddy.parseSecurityHeaders(conf, `${tribe}/${webapp}`)
});
log(`Discovered webapp: ${webapp} (tribe: ${tribe}, dns: ${dns})`);
} catch (error) {
console.error(`ERROR: Malformed conf.json for webapp ${webapp} in tribe ${tribe}:`, error.message);
}
}
}
return webapps;
}
/**
* Generates API documentation for webapps named 'apidoc'.
* Checks if appconf.json contains valid apidoc config,
* then runs apidoc to generate the documentation.
* Input paths that do not exist on disk are dropped, and the generation is
* skipped when none is left, so one bad input cannot abort apidoc.
* Documentation is always regenerated into runtimePath.
*/
static async generateApidoc(webapp) {
const confPath = path.join(webapp.sourcePath, 'appconf.json');
if (!fs.existsSync(confPath)) {
log(`apidoc: No appconf.json found at ${confPath}`);
return;
}
try {
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
// Validate required fields
if (!conf.apidoc || !conf.apidoc.input) {
log(`apidoc: appconf.json missing apidoc.input field`);
return;
}
const runtimePath = webapp.runtimePath;
const distPath = path.join(runtimePath, 'dist');
log(`apidoc: Generating documentation for tribe ${webapp.tribe}...`);
// Ensure output directory exists
fs.ensureDirSync(runtimePath);
// Run apidoc from source path to resolve relative paths
const sourcePath = webapp.sourcePath;
// apidoc fails hard on an input path that does not exist (ENOENT on
// scandir, no documentation produced at all), so the ones missing on
// disk are dropped here. Relative inputs are resolved against
// sourcePath, the cwd apidoc runs from below. An input pointing at an
// empty directory is legitimate and is kept.
const resolvedInput = (conf.apidoc.input || [])
.map(p => p.replace(/\{\{NODEPATH\}\}/g, NODE_PATH));
const input = resolvedInput.filter(p => fs.existsSync(path.resolve(sourcePath, p)));
const missingInput = resolvedInput.filter(p => !input.includes(p));
if (missingInput.length) {
console.warn(`apidoc: non-existing input path(s) dropped for ${webapp.tribe}: ${missingInput.join(', ')}`);
}
if (input.length === 0) {
console.warn(`apidoc: no existing input path for ${webapp.tribe}, skipping documentation generation`);
return;
}
// Create temporary conf.json for apidoc CLI
const tempConf = {
name: conf.name || webapp.name,
version: conf.version || "1.0.0",
description: conf.description || "",
url: "",
input: input,
output: runtimePath,
order: conf.apidoc.order || [],
template: conf.apidoc.template || {},
header: {
title: conf.apidoc.header?.title || "Introduction",
filename: conf.apidoc.header?.filename || "header.md"
},
footer: {
title: conf.apidoc.footer?.title || "Best practices",
filename: conf.apidoc.footer?.filename || "footer.md"
}
};
const tempConfPath = path.join(runtimePath, 'apidoc.conf.json');
fs.writeFileSync(tempConfPath, JSON.stringify(tempConf, null, 2));
// Run apidoc from source path to resolve relative paths
// Use --write-json to force regeneration of bundle with new data
const { execSync } = require('child_process');
execSync(`npx apidoc -c "${tempConfPath}" -o "${runtimePath}" --write-json`, {
cwd: sourcePath,
stdio: 'inherit'
});
// Clean up temp conf
fs.unlinkSync(tempConfPath);
log(`apidoc: Documentation generated for ${webapp.tribe}`);
} catch (error) {
console.error(`apidoc: Error generating for ${webapp.tribe}:`, error.message);
}
}
/**
* Generates API documentation for all discovered apidoc webapps.
*/
static async generateAllApidoc(webapps) {
const apidocApps = webapps.filter(w => w.name === 'apidoc');
if (apidocApps.length === 0) {
log(`apidoc: No apidoc webapps found`);
return;
}
log(`apidoc: Found ${apidocApps.length} apidoc webapp(s)`);
for (const webapp of apidocApps) {
await Caddy.generateApidoc(webapp);
}
}
/**
* Enables HTTPS/SSL for the domain.
* Writes a flag file, regenerates Caddy config with port 443 and auto_https,
* then reloads Caddy. Let's Encrypt certificates are provisioned automatically.
*/
static async enableHttps() {
const result = await Caddy._applyHttps(true);
return result;
}
/**
* Core HTTPS enable/disable: writes the flag file and regenerates + reloads Caddy.
* @param {boolean} enabled
*/
static async _applyHttps(enabled) {
const flagFile = path.join(NODE_PATH, 'apxtri', 'tmp', 'https-enabled.json');
if (enabled) {
if (!fs.existsSync(path.dirname(flagFile))) {
fs.mkdirpSync(path.dirname(flagFile));
}
fs.writeJsonSync(flagFile, { enabled: true, domain: DOMAIN, enabledAt: new Date().toISOString() });
log("HTTPS flag written, regenerating config...");
} else {
if (fs.existsSync(flagFile)) {
fs.removeSync(flagFile);
}
log("HTTPS flag removed, regenerating config...");
}
const webapps = Caddy.discoverWebapps();
const result = Caddy.getcaddyconf(webapps);
if (result.status !== 200) {
return { status: 500, ref: "Caddy", msg: enabled ? "https_config_failed" : "http_config_failed", data: result };
}
const reloadResult = await Caddy.reload(result.data.caddyconf);
if (reloadResult.status !== 200) {
return { status: 500, ref: "Caddy", msg: enabled ? "https_config_failed" : "http_config_failed", data: reloadResult };
}
return { status: 200, ref: "Caddy", msg: enabled ? "https_enabled" : "https_disabled", data: { domain: DOMAIN } };
}
/**
* Disables HTTPS/SSL, reverting to HTTP-only.
*/
static async disableHttps() {
const result = await Caddy._applyHttps(false);
return result;
}
/**
* Checks if HTTPS is currently enabled.
*/
static isHttpsEnabled() {
const flagFile = path.join(NODE_PATH, 'apxtri', 'tmp', 'https-enabled.json');
if (!fs.existsSync(flagFile)) return false;
try {
const data = fs.readJsonSync(flagFile);
return data.enabled === true;
} catch {
return false;
}
}
// ACME certificate authority for the automatic HTTPS policy.
// Caddy is started with --resume, so it restores the last configuration it was
// given through the admin API, and it can also inherit CADDY_ACME_CA from the
// service environment. The generated configuration therefore names the CA
// explicitly: an unstated issuer leaves it to guess which endpoint to contact,
// and a staging endpoint yields certificates no client trusts.
// CADDYACMECA overrides it, for example with
// https://acme-staging-v02.api.letsencrypt.org/directory while testing.
static getAcmeCa() {
const configured = (process.env.CADDYACMECA || "").trim();
if (configured) return configured;
if ((process.env.CADDY_ACME_CA || "").trim()) {
log("ignoring inherited CADDY_ACME_CA in favour of the explicit policy CA");
}
return ACME_DEFAULT_CA;
}
static isAcmeStagingCa(ca) {
return (ca || "").includes("staging") || (ca || "").includes("test");
}
static getcaddyconf(webapps) {
try {
const httpsEnabled = Caddy.isHttpsEnabled();
const routes = [];
const redirectRoutes = [];
const caddyconf = {
apps: {
http: {
servers: {}
}
}
};
// When HTTPS is enabled, redirect plaintext HTTP (port 80) to HTTPS.
// Redirects apply to the main DOMAIN and any host-based webapp DNS.
if (httpsEnabled) {
const redirectHosts = new Set();
if (DOMAIN) redirectHosts.add(DOMAIN);
webapps.forEach(w => { if (w.dns) redirectHosts.add(w.dns); });
if (redirectHosts.size > 0) {
redirectRoutes.push({
match: [{ host: Array.from(redirectHosts), not: [{ path: ["/.well-known/acme-challenge/*"] }] }],
handle: [{
handler: "static_response",
status_code: 308,
headers: { Location: ["https://{http.request.host}{http.request.uri}"] }
}],
terminal: true
});
log(`HTTPS redirect route added for: ${Array.from(redirectHosts).join(', ')}`);
}
}
// Add PROXY protocol v2 support when behind a reverse proxy (nginx/haproxy)
// Set TRUSTEDPROXYIPS in .env with comma-separated IPs of your proxy(ies)
// PROXY protocol is only applied to :80 (HTTP). Port 443 (HTTPS) does not use it
// because Caddy handles TLS handshake directly.
// (listener_wrappers applied to srv0 later, after server assignment)
// Build list of allowed origins
const allowedOriginsSet = new Set();
if (DOMAIN) allowedOriginsSet.add(DOMAIN);
webapps.forEach(w => {
if (w.dns) allowedOriginsSet.add(w.dns);
w.allowedOrigins.forEach(origin => allowedOriginsSet.add(origin));
});
const allowedOrigins = Array.from(allowedOriginsSet);
// === API ROUTES (global) ===
// API routes -> Node.js
routes.push({
match: [{ path: ["/api/*"] }],
handle: [
{
handler: "headers",
response: {
set: {
"Access-Control-Allow-Origin": allowedOrigins,
"Access-Control-Allow-Methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
"Access-Control-Allow-Headers": ["*"],
"Access-Control-Allow-Credentials": ["true"],
"Access-Control-Expose-Headers": ["*"],
"Vary": ["Origin"]
}
}
},
{ handler: "rewrite", strip_path_prefix: "/api" },
{ handler: "reverse_proxy", upstreams: [{ dial: `${process.env.BACKENDURL}:${process.env.APIPORT}` }] }
],
terminal: true
});
// Socket routes -> Socket.IO
routes.push({
match: [{ path: ["/socket/*"] }],
handle: [{ handler: "reverse_proxy", upstreams: [{ dial: `${process.env.BACKENDURL}:${process.env.SOCKETPORT}` }] }],
terminal: true
});
// Federation routes -> inter-town blockchain traffic
// Used by Consensus (propose/vote/finalize), UptimeTracker (heartbeat),
// Blockchain (receive/sync/info). Served on both :80 and :443.
routes.push({
match: [{ path: ["/apxtri/blockchain/*"] }],
handle: [{ handler: "reverse_proxy", upstreams: [{ dial: `${process.env.BACKENDURL}:${process.env.APIPORT}` }] }],
terminal: true
});
// === CDN ROUTES (path-based, cross-domain) ===
const allCdnPaths = {};
webapps.forEach(w => {
const cdnPath = path.join(DATA_PATH, w.tribe, 'wwws', 'cdn');
if (fs.existsSync(cdnPath) && !allCdnPaths[w.tribe]) {
allCdnPaths[w.tribe] = cdnPath;
}
});
const mainCdnPath = path.join(DATA_PATH, TOWN, 'wwws', 'cdn');
if (fs.existsSync(mainCdnPath) && !allCdnPaths[TOWN]) {
allCdnPaths[TOWN] = mainCdnPath;
}
Object.entries(allCdnPaths).forEach(([tribe, cdnPath]) => {
routes.push({
match: [{ path: [`/cdn/${tribe}/*`] }],
handle: [{ handler: "rewrite", strip_path_prefix: `/cdn/${tribe}` },
{ handler: "file_server", root: cdnPath }],
terminal: true
});
log(`CDN route: /cdn/${tribe}/* → ${cdnPath}`);
});
// === MAINAPP SUBAPP ROUTES (path-based, cross-domain) ===
// Serve mainapp subapps so they work from any domain (for auth, etc.)
const mainappRoot = path.join(DATA_PATH, TOWN, 'wwws', 'mainapp');
let mainappSubapps = [];
try {
mainappSubapps = fs.readdirSync(mainappRoot)
.filter(n => fs.existsSync(path.join(mainappRoot, n, 'manifest.json')));
} catch (e) { /* mainapp not provisioned yet */ }
mainappSubapps.forEach(subapp => {
const subappPath = path.join(mainappRoot, subapp);
if (!fs.existsSync(subappPath)) return;
// A host-based tribe webapp may ship a subapp with the same name
// (e.g. the edifiance "gestion" app has its own "com"). On such a
// host the webapp's own copy must win over the cross-domain
// mainapp one, otherwise /com/ (and any other colliding subapp)
// would serve mainapp instead of the tribe webapp.
const conflictingHosts = webapps
.filter(w => w.dns && fs.existsSync(path.join(w.runtimePath, subapp)))
.map(w => w.dns);
const notConflict = conflictingHosts.length
? { not: [{ host: conflictingHosts }] }
: {};
// Optional per-subapp security headers (appconf.json `security.headers`).
// Applied to the DOCUMENT route only, never to the town mainapp site
// block below: that block matches `host: [DOMAIN]` with no path, so it
// also serves `/`, and a header put there would leak onto every town
// page. The `/subapp` 301 carries no document either, so it is left alone.
const subappHeaders = Caddy.parseSecurityHeaders(
Caddy.readSubappAppconf(subapp), `${TOWN}/mainapp/${subapp}`);
// Redirect /subapp → /subapp/ (fixes relative path resolution)
routes.push({
match: [{ path: [`/${subapp}`], ...notConflict }],
handle: [{ handler: "static_response", status_code: 301,
headers: { Location: [`/${subapp}/`] } }],
terminal: true
});
// Serve /subapp/ and /subapp/*
routes.push({
match: [{ path: [`/${subapp}/`, `/${subapp}/*`], ...notConflict }],
handle: Caddy.applySecurityHeaders(subappHeaders, [
{ handler: "rewrite", strip_path_prefix: `/${subapp}` },
{ handler: "file_server", root: subappPath,
index_names: ["index.html", "index_fr.html", "index_en.html"] }
]),
terminal: true
});
log(`Subapp route: /${subapp}/* → ${subappPath}`);
});
// === WEBAPP ROUTES ===
// 1. Path-based: /{webapp}/* → runtime path (for apps without dedicated dns)
webapps.forEach(w => {
if (w.dns || w.name === 'mainapp' || w.name === 'apidoc') return;
// Default landing: prefer com/index.html when present, else index.html
if (fs.existsSync(path.join(w.runtimePath, 'com', 'index.html'))) {
routes.push({
match: [{ path: [`/${w.name}`, `/${w.name}/`] }],
handle: [{ handler: "static_response", status_code: 302, headers: { Location: [`/${w.name}/com/`] } }],
terminal: true
});
}
routes.push({
match: [{ path: [`/${w.name}/*`] }],
handle: Caddy.applySecurityHeaders(w.securityHeaders, [{
handler: "rewrite",
strip_path_prefix: `/${w.name}`
}, {
handler: "file_server",
root: w.runtimePath,
index_names: ["index.html", "index_fr.html", "index_en.html"]
}]),
terminal: true
});
});
const apidocWebapps = webapps.filter(w => w.name === 'apidoc');
const hasApidoc = w => fs.existsSync(path.join(w.runtimePath, 'index.html'));
// 1b. apidoc, host-based: on a host declared by a tribe the docs
// served on /apidoc are that tribe's. Several webapps of one tribe
// can share a dns and a tribe can declare several, so the
// (host, tribe) pairs are deduplicated before being emitted.
const apidocHosts = [];
const seenHostPairs = new Set();
webapps.forEach(w => {
if (!w.dns || w.tribe === TOWN) return;
const pair = `${w.dns}|${w.tribe}`;
if (seenHostPairs.has(pair)) return;
const apidoc = apidocWebapps.find(a => a.tribe === w.tribe);
if (!apidoc || !hasApidoc(apidoc)) return;
seenHostPairs.add(pair);
apidocHosts.push({ host: w.dns, apidoc });
});
apidocHosts.forEach(({ host, apidoc }) => {
routes.push({
match: [{ host: [host], path: ["/apidoc"] }],
handle: [{ handler: "static_response", status_code: 301,
headers: { Location: ["/apidoc/"] } }],
terminal: true
});
routes.push({
match: [{ host: [host], path: ["/apidoc/*"] }],
handle: [{ handler: "rewrite", strip_path_prefix: "/apidoc" },
{ handler: "file_server", root: apidoc.runtimePath,
index_names: ["index.html", "index_fr.html", "index_en.html"] }],
terminal: true
});
log(`apidoc host route: ${host}/apidoc/* → ${apidoc.runtimePath}`);
});
// 1c. apidoc, path-based: the town's docs on /apidoc (on every host
// no tribe claimed above), a tribe's on /{tribe}/apidoc to reach it
// from the town domain. These come after the host-based routes of
// 1b because Caddy keeps the first matching route and /apidoc/*
// matches on any host: emitted first, it would absorb the request
// before the host matcher of 1b is evaluated.
apidocWebapps.filter(hasApidoc).forEach(w => {
const base = w.tribe === TOWN ? '/apidoc' : `/${w.tribe}/apidoc`;
routes.push({
match: [{ path: [base] }],
handle: [{ handler: "static_response", status_code: 301,
headers: { Location: [`${base}/`] } }],
terminal: true
});
routes.push({
match: [{ path: [`${base}/*`] }],
handle: [{ handler: "rewrite", strip_path_prefix: base },
{ handler: "file_server", root: w.runtimePath,
index_names: ["index.html", "index_fr.html", "index_en.html"] }],
terminal: true
});
log(`apidoc route: ${base}/* → ${w.runtimePath}`);
});
// 2. Host-based: apps with explicit dns (future use)
webapps.forEach(w => {
if (!w.dns || w.name === 'apidoc') return;
if (fs.existsSync(path.join(w.runtimePath, 'com', 'index.html'))) {
routes.push({
match: [{ host: [w.dns], path: ["/"] }],
handle: [{ handler: "static_response", status_code: 302, headers: { Location: ["/com/"] } }],
terminal: true
});
}
routes.push({
match: [{ host: [w.dns] }],
handle: Caddy.applySecurityHeaders(w.securityHeaders, [{
handler: "file_server",
root: w.runtimePath,
index_names: ["index.html", "index_fr.html", "index_en.html"]
}]),
terminal: true
});
});
// 3. Default: DOMAIN → mainapp
if (DOMAIN) {
const domainRoot = path.join(DATA_PATH, TOWN, 'wwws', 'mainapp');
if (fs.existsSync(domainRoot)) {
// Default landing: prefer com/index.html when present, else index.html
if (fs.existsSync(path.join(domainRoot, 'com', 'index.html'))) {
routes.push({
match: [{ host: [DOMAIN], path: ["/"] }],
handle: [{ handler: "static_response", status_code: 302, headers: { Location: ["/com/"] } }],
terminal: true
});
}
routes.push({
match: [{ host: [DOMAIN] }],
handle: [{
handler: "file_server",
root: domainRoot,
index_names: ["index.html", "index_fr.html", "index_en.html"]
}],
terminal: true
});
} else {
log(`Domain ${DOMAIN} configured but mainapp not found at ${domainRoot}`);
}
}
// Assign routes to servers AFTER all app routes are built.
// Port 80: HTTPS redirect (if enabled) first, then app routes.
// Port 443: app routes only (no redirect loop).
const srv0Routes = [...redirectRoutes, ...routes];
caddyconf.apps.http.servers.srv0 = {
listen: [":80"],
routes: srv0Routes
};
if (httpsEnabled) {
const acmeCa = Caddy.getAcmeCa();
caddyconf.apps.http.servers.srv1 = {
listen: [":443"],
routes: routes
};
caddyconf.apps.tls = {
automation: {
policies: [{
issuers: [{ module: "acme", ca: acmeCa }]
}]
}
};
if (Caddy.isAcmeStagingCa(acmeCa)) {
log(`TLS automation policy added using the STAGING CA ${acmeCa}: certificates it issues are NOT publicly trusted`);
} else {
log(`TLS automation policy added (ACME CA: ${acmeCa})`);
}
}
// Apply PROXY protocol v2 to port 80 when behind a reverse proxy (nginx/haproxy)
const trustedProxyIps = process.env.TRUSTEDPROXYIPS;
if (trustedProxyIps && trustedProxyIps.trim()) {
const ips = trustedProxyIps.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length > 0) {
caddyconf.apps.http.servers.srv0.listener_wrappers = [{
wrapper: "proxy_protocol",
allow: ips.map(ip => ip.includes('/') ? ip : `${ip}/32`)
}];
}
}
fs.outputJsonSync("./tmp/caddyconf.json", caddyconf, { spaces: 2 });
return { status: 200, ref: "Caddy", msg: "caddy_config_generated", data: { caddyconf } };
} catch (error) {
console.error("❌ Error generating Caddy config:", error);
return { status: 500, ref: "Caddy", msg: "caddy_config_generation_failed" };
}
}
}
module.exports = Caddy;