403 lines
15 KiB
JavaScript
Executable File
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;
|