715 lines
17 KiB
JavaScript
Executable File

// 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,
});
});