// 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"; // 👉 Environment / Secrets const API = process.env.HESTIA_API_HASH || "vvQjauyYQ2YDY2Au-_DC4QO5MUPFiHNn"; const SERVER = process.env.HESTIA_SERVER_URL || "https://host.metatronhost.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, }); // =============================== // 🔧 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) { return hestia("v-delete-web-domain-ssl", { arg1: HESTIA_USER, arg2: domain, }); } // Add Let's Encrypt SSL async function addSsl(domain) { return hestia("v-add-letsencrypt-domain", { arg1: HESTIA_USER, arg2: domain, }); } // Remove + Add (renew) async function refreshSsl(domain) { const removed = await removeSsl(domain); const added = await addSsl(domain); return { removed, 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 { // if it fails, we just keep raw string, but there's no safe way to summarize return { success: true, subject: null, aliases: null, notBefore: null, notAfter: null, issuer: null, signature: null, pubKey: null, sslForce: null, rawType: "string", }; } } // parsed usually looks like: // { "metatronhost.com": { CRT, KEY, CA, SUBJECT, ALIASES, NOT_BEFORE, NOT_AFTER, ... } } 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()); // Health app.get("/", (req, res) => { res.json({ running: true, user: HESTIA_USER }); }); // 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) { // Not in cache → fetch from Hestia and cache it 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) { res.status(500).json({ success: false, error: err.response?.data || err.message, }); } }); // Remove SSL 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 { const result = await removeSsl(domain); // Invalidate cache for that domain, since SSL changed sslCache.del(`ssl-${domain}`); res.json({ success: true, domain, result }); } catch (err) { res.status(500).json({ success: false, domain, error: err.response?.data || err.message, }); } }); // Add SSL 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 { const result = await addSsl(domain); // Invalidate cache so next /dns/domains pulls fresh SSL info sslCache.del(`ssl-${domain}`); res.json({ success: true, domain, result }); } catch (err) { res.status(500).json({ success: false, domain, error: err.response?.data || err.message, }); } }); // Refresh SSL 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 { const result = await refreshSsl(domain); // Invalidate cache so next /dns/domains pulls fresh SSL info sslCache.del(`ssl-${domain}`); res.json({ success: true, domain, result }); } catch (err) { res.status(500).json({ success: false, domain, error: err.response?.data || err.message, }); } }); // Refresh SSL for ALL domains app.post("/ssl/refresh-all", async (req, res) => { try { const raw = await getRawDomainList(); const domains = parseDomainsFromTable(raw); const results = []; for (const domain of domains) { try { const result = await refreshSsl(domain); sslCache.del(`ssl-${domain}`); results.push({ domain, success: true, result }); } catch (e) { results.push({ domain, success: false, error: e.response?.data || e.message, }); } } res.json({ success: true, user: HESTIA_USER, results, }); } catch (err) { res.status(500).json({ success: false, error: err.response?.data || err.message, }); } }); // Start server const PORT = process.env.PORT || 3015; app.listen(PORT, () => { console.log(`🔥 SSL Manager API running at port ${PORT}`); });