// server.mjs (or server.js with "type": "module") import express from "express"; import axios from "axios"; import https from "https"; import NodeCache from "node-cache"; import { promises as fs } from "fs"; import path from "path"; import dns from "node:dns"; dns.setDefaultResultOrder("ipv4first"); // ๐Ÿ‘‰ Environment / Secrets const API = "vvQjauyYQ2YDY2Au-_DC4QO5MUPFiHNn"|| process.env.HESTIA_API_HASH || "GCzPEYiK2NYDgq0Pz2d-CRctVyStsxiE"; const SERVER = process.env.HESTIA_SERVER_URL ||"https://127.0.0.1:8083/api/" || "https://host.metatronnest.com:8083/api/"; // ๐Ÿ‘‰ FIXED Hestia username const HESTIA_USER = "user"; // ๐Ÿง  SSL info cache (per domain, 1 day TTL) const sslCache = new NodeCache({ stdTTL: 60 * 60 * 24, // 24 hours checkperiod: 60 * 60, // check every hour }); // Ignore self-signed SSL const httpsAgent = new https.Agent({ rejectUnauthorized: false, }); // =============================== // ๐Ÿงพ L O G G I N G // =============================== const LOG_DIR = process.env.LOG_DIR || "/var/log/ssl-manager"; const LOG_FILE = process.env.LOG_FILE || path.join(LOG_DIR, "server.log"); async function ensureLogPath() { await fs.mkdir(LOG_DIR, { recursive: true }); } function safeStringify(obj) { try { return JSON.stringify(obj); } catch { return String(obj); } } async function logLine(level, message, meta = null) { const ts = new Date().toISOString(); const line = `[${ts}] [${level}] ${message}` + (meta ? ` | ${safeStringify(meta)}` : "") + "\n"; // console log also if (level === "ERROR") console.error(line.trim()); else console.log(line.trim()); try { await ensureLogPath(); await fs.appendFile(LOG_FILE, line, "utf8"); } catch (e) { // if log file fails, don't crash server console.error( `[${ts}] [ERROR] Failed to write log file: ${e.message || e}` ); } } // =============================== // ๐Ÿ”Ž N G I N X P R O X Y P O R T // =============================== const WEB_CONF_ROOT = process.env.WEB_CONF_ROOT || "/home/user/conf/web"; const SERVER_IP = process.env.SERVER_IP || "147.93.40.215"; // Endpoint to notify when reverse proxy exists const NEST_PROXY_ENDPOINT = process.env.NEST_PROXY_ENDPOINT ||"http://147.93.40.215:9999/api/nginx/app"|| "https://manage.api.metatronnest.com/proxy/nginx-app"; // Optional auth header if needed const NEST_PROXY_TOKEN = process.env.NEST_PROXY_TOKEN || ""; // Separate agent (if you ever have self-signed on metatronnest, set to false) // by default, keep secure for external endpoint const nestHttpsAgent = new https.Agent({ rejectUnauthorized: true, }); function extractProxyPort(nginxConfText) { // strip comments const text = nginxConfText.replace(/#.*$/gm, ""); // match: proxy_pass http://127.0.0.1:3022; // or: proxy_pass http://localhost:3022; const m = text.match( /proxy_pass\s+https?:\/\/(?:127\.0\.0\.1|localhost):(\d+)\s*;/i ); if (!m) return null; const port = Number(m[1]); if (!Number.isInteger(port) || port <= 0 || port > 65535) return null; return port; } async function getDomainProxyInfo(domain) { if (!domain || typeof domain !== "string") { throw new Error("domain must be a non-empty string"); } const confPath = path.join(WEB_CONF_ROOT, domain, "nginx.conf"); let nginxConf; try { nginxConf = await fs.readFile(confPath, "utf8"); } catch (e) { console.warn(`โš ๏ธ [Proxy Info] nginx.conf not found for domain ${domain}`,e); return { success: false, domain, serverIp: SERVER_IP, port: null, error: "nginx.conf not found", }; } const port = extractProxyPort(nginxConf); if (!port) { return { success: false, domain, serverIp: SERVER_IP, port: null, error: "proxy_pass not found in nginx.conf", }; } console.log( { success: true, domain, serverIp: SERVER_IP, port }) return { success: true, domain, serverIp: SERVER_IP, port }; } /** * If reverse proxy exists for domain, notify metatronnest endpoint */ async function notifyNestIfReverseProxy(domain, proxyInfo) { try { await logLine("INFO", "Reverse proxy check", { domain, proxyInfo }); if (!proxyInfo.success || !proxyInfo.port) { await logLine("INFO", "No reverse proxy found, skipping notify", { domain, }); return { notified: false, reason: "no_proxy", proxyInfo }; } const payload = { server_ip: proxyInfo.serverIp, domain: proxyInfo.domain, port: String(proxyInfo.port), }; const headers = { "Content-Type": "application/json", }; if (NEST_PROXY_TOKEN) headers["Authorization"] = `Bearer ${NEST_PROXY_TOKEN}`; await logLine("INFO", "Notifying metatronnest /proxy/nginx-app", { endpoint: NEST_PROXY_ENDPOINT, payload, }); const resp = await axios.post(NEST_PROXY_ENDPOINT, payload, { headers, httpsAgent: nestHttpsAgent, timeout: 20_000, }); await logLine("INFO", "metatronnest notify success", { status: resp.status, data: resp.data, }); return { notified: true, payload, response: resp.data }; } catch (err) { await logLine("ERROR", "metatronnest notify failed", { domain, error: err?.response?.data || err?.message || String(err), status: err?.response?.status || null, }); return { notified: false, reason: "notify_failed", error: err?.response?.data || err?.message || String(err), }; } } // =============================== // ๐Ÿ”ง H E S T I A H E L P E R S // =============================== async function hestia(cmd, args = {}) { const form = new URLSearchParams({ hash: API, cmd, ...args, }); const res = await axios.post(SERVER, form, { httpsAgent }); return res.data; } // Fetch raw DNS domain table async function getRawDomainList() { const form = new URLSearchParams({ hash: API, cmd: "v-list-dns-domains", arg1: HESTIA_USER, }); const res = await axios.post(SERVER, form, { httpsAgent }); return res.data; } // Parse domains from raw table (only domain names) function parseDomainsFromTable(text) { const lines = text.split("\n"); const domains = []; for (let line of lines) { line = line.trim(); if (!line.includes(".")) continue; if (line.startsWith("DOMAIN")) continue; if (line.startsWith("------")) continue; const parts = line.split(/\s+/); const domain = parts[0]; if (domain && domain.includes(".")) { domains.push(domain); } } return domains; } // Parse the full DNS table into structured rows function parseRawTable(text) { const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); const dataRows = []; // Skip first 2 header lines for (let i = 2; i < lines.length; i++) { const line = lines[i]; const parts = line.split(/\s+/); // Expect at least 8 columns if (parts.length < 8) continue; const [domain, ip, tpl, ttl, dnssec, rec, spnd, date] = parts; dataRows.push({ domain, ip, tpl, ttl: Number(ttl), dnssec, rec: Number(rec), spnd, date, }); } return dataRows; } // Remove SSL certificate async function removeSsl(domain) { await logLine("INFO", "Removing SSL", { domain }); return hestia("v-delete-web-domain-ssl", { arg1: HESTIA_USER, arg2: domain, }); } // Add Let's Encrypt SSL async function addSsl(domain) { await logLine("INFO", "Adding Let's Encrypt SSL", { domain }); return hestia("v-add-letsencrypt-domain", { arg1: HESTIA_USER, arg2: domain, }); } // Remove + Add (renew) async function refreshSsl(domain) { await logLine("INFO", "Refreshing SSL (remove + add)", { domain }); // const removed = await removeSsl(domain); const added = await addSsl(domain); // return { removed, added }; return { added }; } // โœ… Get REAL SSL info for a web domain, but return ONLY summary (no big CRT/KEY/CA) async function getWebSslInfoSummary(domain) { try { // v-list-web-domain-ssl USER DOMAIN [FORMAT] const data = await hestia("v-list-web-domain-ssl", { arg1: HESTIA_USER, arg2: domain, arg3: "json", }); let parsed = data; // If Hestia returns a string, try parse it if (typeof data === "string") { try { parsed = JSON.parse(data); } catch { return { success: true, subject: null, aliases: null, notBefore: null, notAfter: null, issuer: null, signature: null, pubKey: null, sslForce: null, rawType: "string", }; } } const keys = Object.keys(parsed || {}); if (!keys.length) { return { success: false, error: "No SSL data found in response", }; } const cert = parsed[keys[0]] || {}; return { success: true, subject: cert.SUBJECT || null, aliases: cert.ALIASES || null, notBefore: cert.NOT_BEFORE || null, notAfter: cert.NOT_AFTER || null, issuer: cert.ISSUER || null, signature: cert.SIGNATURE || null, pubKey: cert.PUB_KEY || null, sslForce: cert.SSL_FORCE || null, }; } catch (err) { return { success: false, error: err.response?.data || err.message || "Unknown error", }; } } // =============================== // ๐Ÿš€ E X P R E S S A P I // =============================== const app = express(); app.use(express.json()); // request logging app.use(async (req, res, next) => { const start = Date.now(); res.on("finish", async () => { await logLine("INFO", "HTTP", { method: req.method, path: req.originalUrl, status: res.statusCode, ms: Date.now() - start, }); }); next(); }); // Health app.get("/", (req, res) => { res.json({ running: true, user: HESTIA_USER }); }); // โœ… Get proxy info for one domain // Example: /proxy/info?domain=gmb.metatronhost.com app.get("/proxy/info", async (req, res) => { const domain = req.query.domain; if (!domain) { return res .status(400) .json({ success: false, error: "domain query param is required" }); } try { const info = await getDomainProxyInfo(String(domain)); return res.json(info); } catch (err) { return res.status(500).json({ success: false, domain, error: err.message || "Unknown error", }); } }); // โœ… Get proxy info for all domains (based on Hestia DNS list) app.get("/proxy/all", async (req, res) => { try { const raw = await getRawDomainList(); const domains = parseDomainsFromTable(raw); const results = []; for (const domain of domains) { results.push(await getDomainProxyInfo(domain)); } res.json({ success: true, serverIp: SERVER_IP, results }); } catch (err) { res.status(500).json({ success: false, error: err.response?.data || err.message, }); } }); // Get all DNS domains + SSL summary (cached) app.get("/dns/domains", async (req, res) => { try { const raw = await getRawDomainList(); const domains = parseDomainsFromTable(raw); const rawParsed = parseRawTable(raw); const sslInfoByDomain = {}; for (const domain of domains) { const cacheKey = `ssl-${domain}`; let summary = sslCache.get(cacheKey); if (!summary) { summary = await getWebSslInfoSummary(domain); sslCache.set(cacheKey, summary); } sslInfoByDomain[domain] = summary; } const rawParsedWithSsl = rawParsed.map((row) => ({ ...row, ssl: sslInfoByDomain[row.domain] || null, })); res.json({ success: true, domains, rawParsed: rawParsedWithSsl, }); } catch (err) { console.error("Error in /dns/domains:", err); res.status(500).json({ success: false, error: err.response?.data || err.message, }); } }); // Remove SSL (only notify if reverse proxy exists) app.post("/ssl/remove", async (req, res) => { const { domain } = req.body; if (!domain) { return res .status(400) .json({ success: false, error: "domain is required" }); } try { await logLine("INFO", "SSL REMOVE requested", { domain }); // const notify = await notifyNestIfReverseProxy(domain); const result = await removeSsl(domain); sslCache.del(`ssl-${domain}`); res.json({ success: true, domain, result }); } catch (err) { await logLine("ERROR", "SSL REMOVE failed", { domain, error: err.response?.data || err.message, }); res.status(500).json({ success: false, domain, error: err.response?.data || err.message, }); } }); // Add SSL (only notify if reverse proxy exists) app.post("/ssl/add", async (req, res) => { const { domain } = req.body; if (!domain) { return res .status(400) .json({ success: false, error: "domain is required" }); } try { await logLine("INFO", "SSL ADD requested", { domain }); const proxyInfo = await getDomainProxyInfo(domain); const result = await addSsl(domain); sslCache.del(`ssl-${domain}`); const notify = await notifyNestIfReverseProxy(domain, proxyInfo); res.json({ success: true, domain, notify, result }); } catch (err) { await logLine("ERROR", "SSL ADD failed", { domain, error: err.response?.data || err.message, }); res.status(500).json({ success: false, domain, error: err.response?.data || err.message, }); } }); // Refresh SSL (only notify if reverse proxy exists) app.post("/ssl/refresh", async (req, res) => { const { domain } = req.body; if (!domain) { return res .status(400) .json({ success: false, error: "domain is required" }); } try { await logLine("INFO", "SSL REFRESH requested", { domain }); const proxyInfo = await getDomainProxyInfo(domain); const result = await refreshSsl(domain); sslCache.del(`ssl-${domain}`); const notify = await notifyNestIfReverseProxy(domain, proxyInfo); res.json({ success: true, domain, notify, result }); } catch (err) { await logLine("ERROR", "SSL REFRESH failed", { domain, error: err.response?.data || err.message, }); res.status(500).json({ success: false, domain, error: err.response?.data || err.message, }); } }); // Refresh SSL for ALL domains (only notify if reverse proxy exists) app.post("/ssl/refresh-all", async (req, res) => { try { await logLine("INFO", "SSL REFRESH-ALL requested", { user: HESTIA_USER }); const raw = await getRawDomainList(); const domains = parseDomainsFromTable(raw); const results = []; for (const domain of domains) { try { await logLine("INFO", "Processing domain for refresh-all", { domain }); const proxyInfo = await getDomainProxyInfo(domain); const result = await refreshSsl(domain); sslCache.del(`ssl-${domain}`); const notify = await notifyNestIfReverseProxy(domain, proxyInfo); // results.push({ domain, success: true,proxyInfo }); results.push({ domain, success: true, notify, result }); } catch (e) { await logLine("ERROR", "refresh-all domain failed", { domain, error: e.response?.data || e.message, }); results.push({ domain, success: false, error: e.response?.data || e.message, }); } } res.json({ success: true, user: HESTIA_USER, results, }); } catch (err) { await logLine("ERROR", "SSL REFRESH-ALL failed", { error: err.response?.data || err.message, }); res.status(500).json({ success: false, error: err.response?.data || err.message, }); } }); app.get("/reverse-proxy-info", async (req, res) => { try { await logLine("INFO", "SSL REFRESH-ALL requested", { user: HESTIA_USER }); const raw = await getRawDomainList(); const domains = parseDomainsFromTable(raw); const results = []; for (const domain of domains) { try { await logLine("INFO", "Processing domain for refresh-all", { domain }); const proxyInfo = await getDomainProxyInfo(domain); // const result = await refreshSsl(domain); // sslCache.del(`ssl-${domain}`); // const notify = await notifyNestIfReverseProxy(domain, proxyInfo); results.push({ domain, success: true,proxyInfo }); // results.push({ domain, success: true, notify, result }); } catch (e) { await logLine("ERROR", "reverse-proxy domain failed", { domain, error: e.response?.data || e.message, }); results.push({ domain, success: false, error: e.response?.data || e.message, }); } } res.json({ success: true, user: HESTIA_USER, results, }); } catch (err) { await logLine("ERROR", "SSL REFRESH-ALL failed", { error: err.response?.data || err.message, }); res.status(500).json({ success: false, error: err.response?.data || err.message, }); } }); // Start server const PORT = process.env.PORT || 3015; app.listen(PORT, async () => { await logLine("INFO", "๐Ÿ”ฅ SSL Manager API started", { port: PORT, user: HESTIA_USER, serverIp: SERVER_IP, webConfRoot: WEB_CONF_ROOT, logFile: LOG_FILE, nestEndpoint: NEST_PROXY_ENDPOINT, }); });