initial commit

This commit is contained in:
2026-05-03 22:56:28 +02:00
parent 1e18619a4b
commit f85428a6db
7 changed files with 972 additions and 2 deletions
+216
View File
@@ -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}`)
);
})();