diff --git a/server.js b/server.js deleted file mode 100644 index 341d5a1..0000000 --- a/server.js +++ /dev/null @@ -1,216 +0,0 @@ -import express from "express"; -import fs from "fs/promises"; -import path from "path"; -import Docker from "dockerode"; -import { fileURLToPath } from "url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const PORT = process.env.PORT || 8080; -const SQUID_CONTAINER = process.env.SQUID_CONTAINER || "squid"; -const STATE_FILE = "/data/state.json"; -const SQUID_CONFIG_DIR = "/squid-config"; -const WPAD_DIR = "/wpad"; - -const docker = new Docker({ socketPath: "/var/run/docker.sock" }); -const app = express(); - -app.use(express.json({ limit: "100kb" })); -app.use(express.static(path.join(__dirname, "public"))); - -const DEFAULTS = { - networks: ["192.168.0.0/24", "192.168.1.0/24", "192.168.2.0/24"], - domains: ["*.netflix.com", "*.spotify.com"], - upstreamHost: "gluetun", - upstreamPort: "8888", - proxyHost: "cerberus.lan", - proxyPort: "3128", -}; - -// ---------- State persistence ---------- - -async function loadState() { - try { - const raw = await fs.readFile(STATE_FILE, "utf-8"); - return { ...DEFAULTS, ...JSON.parse(raw) }; - } catch { - return { ...DEFAULTS }; - } -} - -async function writeAtomic(filePath, content) { - const tmp = filePath + ".tmp"; - await fs.writeFile(tmp, content, { mode: 0o644 }); - await fs.rename(tmp, filePath); -} - -async function saveState(state) { - await writeAtomic(STATE_FILE, JSON.stringify(state, null, 2)); -} - -// ---------- Validation ---------- - -const CIDR_RE = /^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/; -const HOST_RE = /^[a-zA-Z0-9.\-*_]+$/; -const PORT_RE = /^\d{1,5}$/; - -function validate(state) { - if (!state || typeof state !== "object") throw new Error("invalid payload"); - if (!Array.isArray(state.networks)) throw new Error("networks must be array"); - if (!Array.isArray(state.domains)) throw new Error("domains must be array"); - for (const n of state.networks) { - if (!CIDR_RE.test(n)) throw new Error(`invalid network: ${n}`); - } - for (const d of state.domains) { - if (!HOST_RE.test(d)) throw new Error(`invalid domain: ${d}`); - } - for (const f of ["upstreamHost", "proxyHost"]) { - if (typeof state[f] !== "string" || !HOST_RE.test(state[f])) { - throw new Error(`invalid ${f}`); - } - } - for (const f of ["upstreamPort", "proxyPort"]) { - if (!PORT_RE.test(String(state[f])) || +state[f] < 1 || +state[f] > 65535) { - throw new Error(`invalid ${f}`); - } - } -} - -// ---------- Generators ---------- - -function generateSquidConf(s) { - return `http_port 3128 - -# Erlaubte Subnetze -${s.networks.map((n) => `acl localnet src ${n}`).join("\n")} - -http_access allow localnet -http_access deny all - -# Domain-Whitelist -acl vpn_domains dstdomain "/etc/squid/vpn_domains.txt" - -# Upstream zu Gluetun -cache_peer ${s.upstreamHost} parent ${s.upstreamPort} 0 no-query default name=gluetun -never_direct allow vpn_domains -cache_peer_access gluetun allow vpn_domains -cache_peer_access gluetun deny all - -never_direct deny all -forwarded_for delete - -access_log /var/log/squid/access.log -cache_log /var/log/squid/cache.log -`; -} - -function generateVpnDomains(s) { - return ( - s.domains - .map((d) => (d.startsWith("*.") ? "." + d.slice(2) : d)) - .join("\n") + "\n" - ); -} - -function generateWpadDat(s) { - return `function FindProxyForURL(url, host) { - var vpn_domains = [ -${s.domains.map((d) => ` "${d}"`).join(",\n")} - ]; - - for (var i = 0; i < vpn_domains.length; i++) { - if (shExpMatch(host, vpn_domains[i])) { - return "PROXY ${s.proxyHost}:${s.proxyPort}"; - } - } - - return "DIRECT"; -} -`; -} - -async function applyConfig(state) { - await writeAtomic( - path.join(SQUID_CONFIG_DIR, "squid.conf"), - generateSquidConf(state) - ); - await writeAtomic( - path.join(SQUID_CONFIG_DIR, "vpn_domains.txt"), - generateVpnDomains(state) - ); - await writeAtomic(path.join(WPAD_DIR, "wpad.dat"), generateWpadDat(state)); -} - -// ---------- Squid reload ---------- - -async function reloadSquid() { - const container = docker.getContainer(SQUID_CONTAINER); - const exec = await container.exec({ - Cmd: ["squid", "-k", "reconfigure"], - AttachStdout: true, - AttachStderr: true, - }); - const stream = await exec.start({ hijack: true, stdin: false }); - return new Promise((resolve, reject) => { - let out = ""; - stream.on("data", (chunk) => (out += chunk.toString())); - stream.on("end", async () => { - const info = await exec.inspect(); - if (info.ExitCode === 0) resolve(out); - else reject(new Error(`squid -k reconfigure exit ${info.ExitCode}: ${out}`)); - }); - stream.on("error", reject); - }); -} - -// ---------- API ---------- - -app.get("/api/config", async (req, res) => { - try { - res.json(await loadState()); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -app.post("/api/config", async (req, res) => { - try { - validate(req.body); - await saveState(req.body); - await applyConfig(req.body); - let reloaded = false; - let reloadError = null; - try { - await reloadSquid(); - reloaded = true; - } catch (e) { - reloadError = e.message; - console.error("[reload]", e.message); - } - res.json({ ok: true, reloaded, reloadError }); - } catch (e) { - res.status(400).json({ error: e.message }); - } -}); - -app.post("/api/reload", async (req, res) => { - try { - const out = await reloadSquid(); - res.json({ ok: true, output: out }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -app.get("/api/health", (req, res) => res.json({ ok: true })); - -// Initial bootstrap: ensure files exist on first start -(async () => { - const state = await loadState(); - await applyConfig(state).catch((e) => - console.error("[bootstrap] could not write configs:", e.message) - ); - app.listen(PORT, () => - console.log(`squid-configurator listening on :${PORT}`) - ); -})();