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 "/squid-config/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}`) ); })();