Files
apxtri/models/Caddy.js
T

403 lines
15 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 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 MODE = process.env.MODE || "dev";
const log = (message, ...args) => {
if (process.env.ACTIVELOG.split(',').includes('Caddy')) {
console.log(`[Caddy] ${message}`, ...args);
}
};
/**
* Manages interactions with the Caddy server.
*/
class Caddy {
/**
* 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.
*/
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.");
} 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));
}
}
}
/**
* 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 tribeWwwsPath = path.join(NODE_PATH, tribe, 'wwws');
if (!fs.existsSync(tribeWwwsPath)) continue;
const webappDirs = fs.readdirSync(tribeWwwsPath);
for (const webapp of webappDirs) {
const confPath = path.join(tribeWwwsPath, webapp, '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 = conf.dns || `${webapp}.${tribe}.${TOWN}.${NATION}`; // Already correct: webapp.tribe.TOWN.NATION
webapps.push({
name: webapp,
tribe,
dns,
runtimePath,
sourcePath: path.join(NODE_PATH, tribe, 'wwws', webapp),
allowedOrigins: conf.allowedOrigins || []
});
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.
* In dev mode, skips regeneration if dist already exists.
*/
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');
// In dev mode, skip if already generated
if (MODE === 'dev' && fs.existsSync(distPath)) {
const distFiles = fs.readdirSync(distPath).filter(f => f.endsWith('.html'));
if (distFiles.length > 0) {
log(`apidoc: ${webapp.tribe} already generated, skipping...`);
return;
}
}
log(`apidoc: Generating documentation for tribe ${webapp.tribe}...`);
// Ensure output directory exists
fs.ensureDirSync(runtimePath);
// Create temporary conf.json for apidoc CLI
const sourcePath = webapp.sourcePath;
const tempConf = {
name: conf.name || webapp.name,
version: conf.version || "1.0.0",
description: conf.description || "",
url: "",
input: conf.apidoc.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);
}
}
/**
* Updates /etc/hosts with entries for all discovered webapps in dev mode.
* Only runs in dev mode (NATION === 'test').
* Uses sudo to write to /etc/hosts.
* Skips 'cdn' webapp as it's only used for CDN routing.
*/
static updateHostsFile(webapps) {
if (MODE !== "dev") {
return;
}
const hostsFile = '/etc/hosts';
const localhost = '127.0.0.1';
try {
let hostsContent = fs.readFileSync(hostsFile, 'utf8');
// Add new webapp entries (skip 'cdn' webapp - it's only for CDN routing)
const newEntries = [];
webapps.forEach(w => {
if (w.name === 'cdn') {
return; // Skip cdn webapp
}
const dns = w.dns;
if (!hostsContent.includes(dns)) {
newEntries.push(`${localhost} ${dns}`);
log(`Adding host entry: ${dns}`);
}
});
if (newEntries.length > 0) {
const entriesStr = newEntries.join('\n') + '\n';
// Use sudo tee to write to /etc/hosts
try {
execSync(`echo "${entriesStr}" | sudo tee -a "${hostsFile}" > /dev/null`, { stdio: 'pipe' });
log(`Updated /etc/hosts with ${newEntries.length} entry(ies)`);
} catch (sudoError) {
console.warn(`⚠️ Cannot update /etc/hosts with sudo.`);
console.warn(` Please add these entries manually:`);
newEntries.forEach(entry => console.warn(` ${entry}`));
}
}
} catch (error) {
console.error("❌ Error updating /etc/hosts:", error.message);
}
}
/**
* Generates the full Caddy JSON configuration from discovered webapps.
*/
static getcaddyconf(webapps) {
try {
const caddyconf = {
apps: {
http: {
servers: {
srv0: {
listen: [":80"],
routes: []
}
}
}
}
};
// Add PROXY protocol v2 support when behind a reverse proxy (nginx/haproxy)
// Set TRUSTEDPROXYIPS in .env with comma-separated IPs of your proxy(ies)
// Example: TRUSTEDPROXYIPS="192.168.1.3,10.31.251.1"
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.trusted_proxies = ips;
caddyconf.apps.http.servers.srv0.listener_wrappers = [{ wrapper: "proxy_protocol" }];
}
}
// Build list of allowed origins
const allowedOriginsSet = new Set();
webapps.forEach(w => {
allowedOriginsSet.add(w.dns);
w.allowedOrigins.forEach(origin => allowedOriginsSet.add(origin));
});
const allowedOrigins = Array.from(allowedOriginsSet);
// === API ROUTES (global) ===
// API routes -> Node.js
caddyconf.apps.http.servers.srv0.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
caddyconf.apps.http.servers.srv0.routes.push({
match: [{ path: ["/socket/*"] }],
handle: [{ handler: "reverse_proxy", upstreams: [{ dial: `${process.env.BACKENDURL}:${process.env.SOCKETPORT}` }] }],
terminal: true
});
// === PER-WEBAPP ROUTES ===
webapps.forEach(w => {
// CDN /cdn/{tribe}/* route for this webapp
// /cdn/dev/script.js -> /var/lib/apxtowns/{town}.{nation}/dev/wwws/cdn/script.js
const cdnPath = path.join(DATA_PATH, w.tribe, 'wwws', 'cdn');
if (fs.existsSync(cdnPath)) {
const cdnPathPattern = `/cdn/${w.tribe}/*`;
caddyconf.apps.http.servers.srv0.routes.push({
match: [{ host: [w.dns], path: [cdnPathPattern] }],
handle: [{
handler: "rewrite",
strip_path_prefix: `/cdn/${w.tribe}`
}, {
handler: "file_server",
root: cdnPath
}],
terminal: true
});
}
// Main route: serve from runtime path
caddyconf.apps.http.servers.srv0.routes.push({
match: [{ host: [w.dns] }],
handle: [{
handler: "file_server",
root: w.runtimePath,
index_names: ["index.html", "index_fr.html", "index_en.html"]
}],
terminal: true
});
});
// === TLS Automation for Production ===
if (MODE !== "dev") {
const allDns = webapps.map(w => w.dns);
caddyconf.apps.tls = {
automation: {
policies: [{
subjects: [...new Set(allDns)],
storage: { module: "file_system", root: "/var/lib/caddy/.local/share/caddy" }
}]
}
};
}
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;