809 lines
36 KiB
JavaScript
Executable File
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;
|