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 = '
0.9
XCPGET_DNS_ZONEDOMAIN' + OPENSRS_DOMAIN + '
'; const resp = await opensrsReq(xml); const records = [], seen = new Set(); const items = resp.match(/([\s\S]*?)<\/item>/g) || []; for (const item of items) { const sub = item.match(/(.*?)<\/item>/)?.[1] || ""; const ip = item.match(/(.*?)<\/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 += '' + r.subdomain + '' + r.ip + ''; }); const xml = '
0.9
XCPSET_DNS_ZONEDOMAIN' + OPENSRS_DOMAIN + '' + items + '
'; 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));