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
+
+
+
+
+
+
+
+
+
+
+
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;
+ }
+}