gigafibre-infra/traefik-hub/server.js

255 lines
15 KiB
JavaScript

const http = require("http");
const fs = require("fs");
const Docker = require("dockerode");
const crypto = require("crypto");
const PORT = 3080;
const TRAEFIK_API = process.env.TRAEFIK_API || "http://127.0.0.1:8080/api";
const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString("hex");
const ADMIN_USER = process.env.ADMIN_USER || "admin";
const ADMIN_PASS = process.env.ADMIN_PASS || "targo2026";
const OPENSRS_USER = process.env.OPENSRS_USER || "targo";
const OPENSRS_KEY = process.env.OPENSRS_KEY || "";
const OPENSRS_DOMAIN = process.env.OPENSRS_DOMAIN || "gigafibre.ca";
const SERVER_IP = process.env.SERVER_IP || "96.125.196.67";
const ROUTES_FILE = process.env.ROUTES_FILE || "/dynamic/routes.yml";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
const sessions = new Map();
// --- Auth ---
function createSession(user) { const t = crypto.randomBytes(48).toString("hex"); sessions.set(t, { user, created: Date.now() }); return t; }
function getSession(req) {
const c = (req.headers.cookie || "").split(";").find(c => c.trim().startsWith("hub_session="));
if (!c) return null; const t = c.split("=")[1]?.trim(); const s = sessions.get(t);
if (!s || Date.now() - s.created > 86400000) { sessions.delete(t); return null; } return s;
}
// --- Traefik API ---
function fetchTraefik(path) {
return new Promise(r => {
http.get(TRAEFIK_API + path, res => { let d = ""; res.on("data", c => d += c); res.on("end", () => { try { r(JSON.parse(d)); } catch { r(null); } }); }).on("error", () => r(null));
});
}
// --- Docker helpers ---
async function listContainers() {
return (await docker.listContainers({ all: true })).map(c => ({
id: c.Id.slice(0, 12), name: c.Names[0]?.replace("/", ""), image: c.Image,
state: c.State, status: c.Status,
ports: c.Ports.map(p => p.PublicPort ? p.PublicPort + ":" + p.PrivatePort : "" + p.PrivatePort).filter(Boolean),
labels: c.Labels, networks: Object.keys(c.NetworkSettings?.Networks || {})
}));
}
async function containerAction(id, action) {
const c = docker.getContainer(id);
if (action === "start") await c.start(); else if (action === "stop") await c.stop();
else if (action === "restart") await c.restart(); else if (action === "remove") { try { await c.stop(); } catch {} await c.remove({ force: true }); }
else if (action === "logs") return (await c.logs({ stdout: true, stderr: true, tail: 150, timestamps: true })).toString("utf8");
return { ok: true };
}
async function getSystemInfo() {
const [info, ver] = await Promise.all([docker.info(), docker.version()]);
return { containers: info.Containers, running: info.ContainersRunning, stopped: info.ContainersStopped,
images: info.Images, memTotal: info.MemTotal, cpus: info.NCPU, os: info.OperatingSystem,
dockerVersion: ver.Version, swarm: info.Swarm?.LocalNodeState || "inactive" };
}
async function getNetworks() {
return (await docker.listNetworks()).filter(n => !["none","host"].includes(n.Name))
.map(n => ({ id: n.Id.slice(0, 12), name: n.Name, driver: n.Driver, scope: n.Scope }));
}
async function getVolumes() {
const { Volumes } = await docker.listVolumes();
return (Volumes || []).map(v => ({ name: v.Name, driver: v.Driver, mountpoint: v.Mountpoint }));
}
// --- Deploy new service ---
async function deployService(opts) {
const { image, subdomain, port, envVars, volumes, networks: nets, name } = opts;
const containerName = name || subdomain + "-svc";
const domain = subdomain + "." + OPENSRS_DOMAIN;
// 1. Pull image
await new Promise((resolve, reject) => {
docker.pull(image, (err, stream) => {
if (err) return reject(err);
docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve(), () => {});
});
});
// 2. Prepare labels
const labels = {
"traefik.enable": "true",
["traefik.http.routers." + subdomain + ".rule"]: "Host(`" + domain + "`)",
["traefik.http.routers." + subdomain + ".entrypoints"]: "websecure",
["traefik.http.routers." + subdomain + ".tls.certresolver"]: "letsencrypt",
["traefik.http.services." + subdomain + ".loadbalancer.server.port"]: String(port),
"hub.managed": "true",
"hub.subdomain": subdomain,
"hub.domain": domain
};
// 3. Parse env vars
const Env = (envVars || []).filter(e => e.key && e.value).map(e => e.key + "=" + e.value);
// 4. Parse volumes
const Binds = (volumes || []).filter(v => v.host && v.container).map(v => v.host + ":" + v.container);
// 5. Create container
const container = await docker.createContainer({
Image: image, name: containerName, Labels: labels, Env,
HostConfig: { Binds, RestartPolicy: { Name: "unless-stopped" }, NetworkMode: "proxy" },
ExposedPorts: { [port + "/tcp"]: {} }
});
// 6. Connect to additional networks
for (const net of (nets || []).filter(n => n !== "proxy")) {
try { const network = docker.getNetwork(net); await network.connect({ Container: container.id }); } catch {}
}
// 7. Start
await container.start();
// 8. Create DNS record
try {
const dnsRecs = await getDnsRecords();
if (!dnsRecs.find(r => r.subdomain === subdomain)) {
dnsRecs.push({ subdomain, ip: SERVER_IP });
await setDnsRecords(dnsRecs);
}
} catch (e) { console.error("DNS error:", e.message); }
return { ok: true, container: containerName, url: "https://" + domain };
}
// --- Routes (file provider) ---
function parseRoutesYaml(yaml) {
const routes = {}; if (!yaml) return routes;
const blocks = yaml.split(/\n(?= \S)/).filter(b => b.includes("-route:"));
for (const block of blocks) {
const nm = block.match(/([\w-]+)-route:/)?.[1];
const rule = block.match(/Host\(`([\w.-]+)`\)/)?.[1];
if (nm && rule) routes[nm] = { subdomain: rule.replace("." + OPENSRS_DOMAIN, ""), target: "", port: 0 };
}
const svcBlocks = yaml.split(/\n(?= \S)/).filter(b => b.includes("-svc:"));
for (const block of svcBlocks) {
const nm = block.match(/([\w-]+)-svc:/)?.[1];
const url = block.match(/url:\s*"http:\/\/([^:]+):(\d+)"/);
if (nm && url && routes[nm]) { routes[nm].target = url[1]; routes[nm].port = parseInt(url[2]); }
}
return routes;
}
function buildRoutesYaml(routes) {
const entries = Object.entries(routes);
if (!entries.length) return "http:\n routers: {}\n services: {}";
let routers = "", services = "";
for (const [name, r] of entries) {
const domain = r.subdomain.includes(".") ? r.subdomain : r.subdomain + "." + OPENSRS_DOMAIN;
routers += " " + name + "-route:\n rule: \"Host(`" + domain + "`)\"\n entryPoints:\n - websecure\n service: " + name + "-svc\n tls:\n certResolver: letsencrypt\n";
services += " " + name + "-svc:\n loadBalancer:\n servers:\n - url: \"http://" + r.target + ":" + r.port + "\"\n";
}
return "http:\n routers:\n" + routers + " services:\n" + services;
}
function getRoutes() { try { return parseRoutesYaml(fs.readFileSync(ROUTES_FILE, "utf8")); } catch { return {}; } }
function saveRoute(sub, target, port) {
const routes = getRoutes(); routes[sub.replace(/[^a-z0-9]/gi, "-")] = { subdomain: sub, target, port: parseInt(port) };
fs.writeFileSync(ROUTES_FILE, buildRoutesYaml(routes)); return { ok: true };
}
function deleteRoute(name) { const routes = getRoutes(); delete routes[name]; fs.writeFileSync(ROUTES_FILE, buildRoutesYaml(routes)); return { ok: true }; }
// --- OpenSRS DNS ---
function opensrsSign(xml) { const md5 = s => crypto.createHash("md5").update(s).digest("hex"); return md5(md5(xml + OPENSRS_KEY) + OPENSRS_KEY); }
function opensrsReq(xml) {
return new Promise((resolve, reject) => {
const sig = opensrsSign(xml);
const req = require("https").request({ hostname: "rr-n1-tor.opensrs.net", port: 55443, path: "/", method: "POST",
headers: { "Content-Type": "text/xml", "X-Username": OPENSRS_USER, "X-Signature": sig }
}, res => { let d = ""; res.on("data", c => d += c); res.on("end", () => resolve(d)); });
req.on("error", reject); req.write(xml); req.end();
});
}
async function getDnsRecords() {
const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE OPS_envelope SYSTEM "ops.dtd"><OPS_envelope><header><version>0.9</version></header><body><data_block><dt_assoc><item key="protocol">XCP</item><item key="action">GET_DNS_ZONE</item><item key="object">DOMAIN</item><item key="attributes"><dt_assoc><item key="domain">' + OPENSRS_DOMAIN + '</item></dt_assoc></item></dt_assoc></data_block></body></OPS_envelope>';
const resp = await opensrsReq(xml);
const records = [], seen = new Set();
const items = resp.match(/<item key="\d+">([\s\S]*?)<\/item>/g) || [];
for (const item of items) {
const sub = item.match(/<item key="subdomain">(.*?)<\/item>/)?.[1] || "";
const ip = item.match(/<item key="ip_address">(.*?)<\/item>/)?.[1] || "";
const key = sub + "|" + ip;
if (ip && !seen.has(key)) { seen.add(key); records.push({ subdomain: sub, ip, domain: sub ? sub + "." + OPENSRS_DOMAIN : OPENSRS_DOMAIN }); }
}
return records;
}
async function setDnsRecords(records) {
// Deduplicate
const seen = new Set(); const unique = [];
for (const r of records) { const k = r.subdomain + "|" + r.ip; if (!seen.has(k)) { seen.add(k); unique.push(r); } }
let items = "";
unique.forEach((r, i) => { items += '<item key="' + i + '"><dt_assoc><item key="subdomain">' + r.subdomain + '</item><item key="ip_address">' + r.ip + '</item></dt_assoc></item>'; });
const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE OPS_envelope SYSTEM "ops.dtd"><OPS_envelope><header><version>0.9</version></header><body><data_block><dt_assoc><item key="protocol">XCP</item><item key="action">SET_DNS_ZONE</item><item key="object">DOMAIN</item><item key="attributes"><dt_assoc><item key="domain">' + OPENSRS_DOMAIN + '</item><item key="records"><dt_assoc><item key="A"><dt_array>' + items + '</dt_array></item></dt_assoc></item></dt_assoc></item></dt_assoc></data_block></body></OPS_envelope>';
return (await opensrsReq(xml)).includes('"is_success">1');
}
// --- Body parser ---
function parseBody(req) { return new Promise(r => { let b = ""; req.on("data", c => b += c); req.on("end", () => { try { r(JSON.parse(b)); } catch { r({}); } }); }); }
// --- Server ---
const server = http.createServer(async (req, res) => {
const path = new URL(req.url, "http://localhost").pathname;
if (path === "/" || path === "/index.html") {
if (!getSession(req)) { res.writeHead(200, { "Content-Type": "text/html" }); res.end(fs.readFileSync("/app/login.html", "utf8")); return; }
res.writeHead(200, { "Content-Type": "text/html" }); res.end(fs.readFileSync("/app/index.html", "utf8")); return;
}
if (path === "/api/login" && req.method === "POST") {
const { user, pass } = await parseBody(req);
if (user === ADMIN_USER && pass === ADMIN_PASS) { const t = createSession(user); res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": "hub_session=" + t + "; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400" }); res.end(JSON.stringify({ ok: true })); }
else { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid" })); } return;
}
if (path === "/api/logout") { res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": "hub_session=; HttpOnly; Path=/; Max-Age=0" }); res.end(JSON.stringify({ ok: true })); return; }
if (path.startsWith("/api/") && !getSession(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized" })); return; }
try {
let data;
switch (true) {
case path === "/api/overview":
const [routers, services, entrypoints, system, containers] = await Promise.all([fetchTraefik("/http/routers"), fetchTraefik("/http/services"), fetchTraefik("/entrypoints"), getSystemInfo(), listContainers()]);
data = { routers, services, entrypoints, system, containers }; break;
case path === "/api/routers": data = await fetchTraefik("/http/routers"); break;
case path === "/api/services": data = await fetchTraefik("/http/services"); break;
case path === "/api/entrypoints": data = await fetchTraefik("/entrypoints"); break;
case path === "/api/middlewares": data = await fetchTraefik("/http/middlewares"); break;
case path === "/api/containers": data = await listContainers(); break;
case path === "/api/system": data = await getSystemInfo(); break;
case path === "/api/networks": data = await getNetworks(); break;
case path === "/api/volumes": data = await getVolumes(); break;
case path === "/api/certificates":
data = ((await fetchTraefik("/http/routers")) || []).filter(r => r.tls).map(r => ({
domain: r.rule?.match(/Host\(`([^`]+)`\)/)?.[1] || "?", status: r.tls?.certResolver ? "managed" : "manual", resolver: r.tls?.certResolver, router: r.name
})); break;
case path === "/api/routes" && req.method === "GET": data = { routes: getRoutes(), domain: OPENSRS_DOMAIN, serverIp: SERVER_IP }; break;
case path === "/api/routes" && req.method === "POST": const rd = await parseBody(req); data = saveRoute(rd.subdomain, rd.target, rd.port); break;
case path.startsWith("/api/routes/") && req.method === "DELETE": data = deleteRoute(path.split("/")[3]); break;
case path === "/api/dns" && req.method === "GET": data = { records: await getDnsRecords(), domain: OPENSRS_DOMAIN, serverIp: SERVER_IP }; break;
case path === "/api/dns" && req.method === "POST":
const dd = await parseBody(req); const cur = await getDnsRecords();
if (!cur.find(r => r.subdomain === dd.subdomain && r.ip === (dd.ip || SERVER_IP))) { cur.push({ subdomain: dd.subdomain, ip: dd.ip || SERVER_IP }); }
data = { ok: await setDnsRecords(cur) }; break;
case path === "/api/dns" && req.method === "DELETE":
const del = await parseBody(req); const recs = await getDnsRecords();
data = { ok: await setDnsRecords(recs.filter(r => !(r.subdomain === del.subdomain && r.ip === del.ip))) }; break;
case path === "/api/deploy" && req.method === "POST": data = await deployService(await parseBody(req)); break;
case path.startsWith("/api/container/") && path.endsWith("/logs"):
data = await containerAction(path.split("/")[3], "logs");
res.writeHead(200, { "Content-Type": "text/plain" }); res.end(typeof data === "string" ? data : JSON.stringify(data)); return;
case path.startsWith("/api/container/") && req.method === "POST":
data = await containerAction(path.split("/")[3], path.split("/")[4]); break;
default: res.writeHead(404); res.end("Not found"); return;
}
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(data));
} catch (e) { console.error(e); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
});
server.listen(PORT, "0.0.0.0", () => console.log("Traefik Hub on :" + PORT));