From f85428a6db65e06f65dacc93ee264474cb3b8fb8 Mon Sep 17 00:00:00 2001 From: sesh Date: Sun, 3 May 2026 22:56:28 +0200 Subject: [PATCH] initial commit --- Dockerfile | 15 ++ README.md | 74 +++++- docker-compose.yml | 63 +++++ index.html | 575 +++++++++++++++++++++++++++++++++++++++++++++ nginx.conf | 17 ++ package.json | 14 ++ server.js | 216 +++++++++++++++++ 7 files changed, 972 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 server.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ba5b385 --- /dev/null +++ b/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/README.md b/README.md index 76336e6..98ee209 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,73 @@ -# squid-configurator +# Squid + WPAD + Configurator Stack -Configurator for Squid Proxy via Node.js with a react frontend \ No newline at end of file +Selbstgehosteter Squid-Proxy mit Domain-Whitelist-Routing zu Gluetun, plus WPAD-Verteilung +und einer Web-UI zur Konfiguration. + +## Komponenten + +| Service | Zweck | Erreichbar | +|---|---|---| +| `squid` | HTTP-Proxy, leitet Whitelist-Domains zu Gluetun | Port 3128 (LAN) | +| `wpad` | nginx, served `wpad.dat` für Auto-Config | https://wpad.lan | +| `configurator` | Web-UI + API zum Pflegen der Config | https://squid.lan | + +## Setup + +```bash +# 1. Verzeichnisse anlegen +sudo mkdir -p /opt/squid/{config,cache,logs,wpad,configurator/data} + +# 2. Repo nach /opt/squid/ kopieren +sudo cp -r ./configurator/* /opt/squid/configurator/ +sudo cp ./wpad/nginx.conf /opt/squid/wpad/ +sudo cp ./docker-compose.yml /opt/squid/ + +# 3. Stack hochziehen (Configurator schreibt beim ersten Start die Default-Configs) +cd /opt/squid +docker compose up -d --build + +# 4. AdGuard DNS Rewrites: +# wpad.lan -> +# squid.lan -> + +# 5. DHCP Option 252 setzen: +# http://wpad.lan/wpad.dat +# (oder https mit Step CA falls alle Geräte der CA vertrauen) +``` + +## Sicherheitshinweise + +- **Docker socket mount:** Der Configurator hat Zugriff auf den Docker Socket, um + `squid -k reconfigure` per `docker exec` auszulösen. Das ist effektiv root auf dem Host. + → Daher hinter Forward-Auth (Pocket ID) stellen, niemals ohne Auth ans Internet hängen. + +- **Auth via Pocket ID:** Im `docker-compose.yml` ist die Middleware-Zeile auskommentiert. + Aktiviere sie sobald deine Pocket-ID-Forward-Auth-Middleware in Traefik definiert ist: + ```yaml + - "traefik.http.routers.squid-cfg-lan.middlewares=pocketid-auth@file" + ``` + +- **Input-Validierung:** Das Backend validiert CIDRs, Hostnames und Ports per Regex, + bevor irgendwas geschrieben wird. Trotzdem: nicht öffentlich erreichbar machen. + +## API + +| Endpoint | Method | Zweck | +|---|---|---| +| `/api/config` | GET | aktuellen State zurückgeben | +| `/api/config` | POST | State validieren, speichern, Configs schreiben, Squid reloaden | +| `/api/reload` | POST | Squid manuell reloaden (z.B. nach manueller Änderung) | +| `/api/health` | GET | Healthcheck | + +## Datenfluss + +``` +Browser ─POST /api/config─► configurator + │ + ├─► /data/state.json (state of truth) + ├─► /squid-config/squid.conf + ├─► /squid-config/vpn_domains.txt + ├─► /wpad/wpad.dat + │ + └─► docker.sock ──► squid -k reconfigure +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..648888e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +services: + squid: + image: ubuntu/squid:latest + container_name: squid + restart: unless-stopped + ports: + - "3128:3128" + volumes: + - /opt/squid/config/squid.conf:/etc/squid/squid.conf:ro + - /opt/squid/config/vpn_domains.txt:/etc/squid/vpn_domains.txt:ro + - /opt/squid/cache:/var/spool/squid + - /opt/squid/logs:/var/log/squid + networks: + - traefik-net + + wpad: + image: nginx:alpine + container_name: wpad + restart: unless-stopped + volumes: + - /opt/squid/wpad/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - /opt/squid/wpad/wpad.dat:/var/www/wpad/wpad.dat:ro + networks: + - traefik-net + labels: + - "traefik.enable=true" + - "traefik.http.services.wpad.loadbalancer.server.port=80" + - "traefik.http.routers.wpad-lan.rule=Host(`wpad.lan`)" + - "traefik.http.routers.wpad-lan.entrypoints=websecure" + - "traefik.http.routers.wpad-lan.tls=true" + - "traefik.http.routers.wpad-lan.tls.certresolver=stepca" + - "traefik.http.routers.wpad-lan.service=wpad" + + configurator: + build: /opt/squid/configurator + container_name: squid-configurator + restart: unless-stopped + environment: + - SQUID_CONTAINER=squid + - PORT=8080 + volumes: + - /opt/squid/config:/squid-config + - /opt/squid/wpad:/wpad + - /opt/squid/configurator/data:/data + - /var/run/docker.sock:/var/run/docker.sock + networks: + - traefik-net + depends_on: + - squid + labels: + - "traefik.enable=true" + - "traefik.http.services.squid-cfg.loadbalancer.server.port=8080" + - "traefik.http.routers.squid-cfg-lan.rule=Host(`squid.lan`)" + - "traefik.http.routers.squid-cfg-lan.entrypoints=websecure" + - "traefik.http.routers.squid-cfg-lan.tls=true" + - "traefik.http.routers.squid-cfg-lan.tls.certresolver=stepca" + - "traefik.http.routers.squid-cfg-lan.service=squid-cfg" + # Forward-Auth via Pocket ID (analog zu deinen anderen Stacks) + # - "traefik.http.routers.squid-cfg-lan.middlewares=pocketid-auth@file" + +networks: + traefik-net: + external: true diff --git a/index.html b/index.html new file mode 100644 index 0000000..1976037 --- /dev/null +++ b/index.html @@ -0,0 +1,575 @@ + + + + + +squid.conf · proxy + wpad configurator + + + + + + +
loading...
+ + + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..c39665c --- /dev/null +++ b/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; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7d6e505 --- /dev/null +++ b/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/server.js b/server.js new file mode 100644 index 0000000..341d5a1 --- /dev/null +++ b/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}`) + ); +})();