245 lines
6.5 KiB
JavaScript
245 lines
6.5 KiB
JavaScript
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) {
|
|
// Wildcards: '*.foo.bar' -> covers 'foo.bar' und alle Subdomains in squid
|
|
let wildcardBases = s.domains
|
|
.filter((d) => d.startsWith("*."))
|
|
.map((d) => d.slice(2));
|
|
|
|
// Dedupe: wenn '*.bar' und '*.foo.bar' beide drin sind, '*.foo.bar' droppen
|
|
wildcardBases = wildcardBases.filter((base) => {
|
|
return !wildcardBases.some(
|
|
(other) => other !== base && base.endsWith("." + other)
|
|
);
|
|
});
|
|
|
|
const isCoveredByWildcard = (d) =>
|
|
wildcardBases.some((base) => d === base || d.endsWith("." + base));
|
|
|
|
const seen = new Set();
|
|
const out = [];
|
|
|
|
for (const base of wildcardBases) {
|
|
const dotted = "." + base;
|
|
if (!seen.has(dotted)) {
|
|
out.push(dotted);
|
|
seen.add(dotted);
|
|
}
|
|
}
|
|
|
|
for (const d of s.domains) {
|
|
if (d.startsWith("*.")) continue;
|
|
if (isCoveredByWildcard(d) || seen.has(d)) continue;
|
|
out.push(d);
|
|
seen.add(d);
|
|
}
|
|
|
|
return out.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}`)
|
|
);
|
|
})(); |