diff --git a/config/domain_whitelist.txt b/config/domain_whitelist.txt new file mode 100755 index 0000000..6b12365 --- /dev/null +++ b/config/domain_whitelist.txt @@ -0,0 +1,2 @@ +.empornium.sx +.rutracker.org diff --git a/config/squid.conf b/config/squid.conf new file mode 100755 index 0000000..340a774 --- /dev/null +++ b/config/squid.conf @@ -0,0 +1,22 @@ +http_port 3128 + +# Zugriffssteuerung +acl localnet src 192.168.0.0/16 +http_access allow localnet +http_access deny all + +# Upstream-Proxy (Gluetun) für bestimmte Domains +acl vpn_domains dstdomain .example.com .someservice.net +cache_peer 127.0.0.1 parent 8888 0 no-query default name=gluetun + +# Traffic für vpn_domains zu Gluetun weiterleiten +never_direct allow vpn_domains +cache_peer_access gluetun allow vpn_domains +cache_peer_access gluetun deny all + +# Alles andere direkt +never_direct deny all + +# Optionale Optimierungen +dns_v4_first on +forwarded_for delete diff --git a/configurator/Dockerfile b/configurator/Dockerfile new file mode 100644 index 0000000..ba5b385 --- /dev/null +++ b/configurator/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev + +COPY server.js ./ +COPY public ./public + +# /data hält state.json, wird via Volume gemountet +RUN mkdir -p /data + +EXPOSE 8080 +CMD ["node", "server.js"] diff --git a/configurator/package.json b/configurator/package.json new file mode 100644 index 0000000..7d6e505 --- /dev/null +++ b/configurator/package.json @@ -0,0 +1,14 @@ +{ + "name": "squid-configurator", + "version": "1.0.0", + "description": "Web UI for managing Squid + WPAD config", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "dockerode": "^4.0.7", + "express": "^4.21.2" + } +} diff --git a/configurator/public/index.html b/configurator/public/index.html new file mode 100644 index 0000000..1976037 --- /dev/null +++ b/configurator/public/index.html @@ -0,0 +1,575 @@ + + + + + +squid.conf · proxy + wpad configurator + + + + + + +
loading...
+ + + + diff --git a/configurator/server.js b/configurator/server.js new file mode 100644 index 0000000..341d5a1 --- /dev/null +++ b/configurator/server.js @@ -0,0 +1,216 @@ +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}`) + ); +})(); diff --git a/wpad/nginx.conf b/wpad/nginx.conf new file mode 100644 index 0000000..c39665c --- /dev/null +++ b/wpad/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name wpad wpad.lan wpad.kernel.rip; + + location /wpad.dat { + root /var/www/wpad; + types { } + default_type application/x-ns-proxy-autoconfig; + } + + # WPAD spec wants both /wpad.dat and /proxy.pac available + location /proxy.pac { + alias /var/www/wpad/wpad.dat; + types { } + default_type application/x-ns-proxy-autoconfig; + } +}