initial commit
This commit is contained in:
+15
@@ -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"]
|
||||
@@ -1,3 +1,73 @@
|
||||
# squid-configurator
|
||||
# Squid + WPAD + Configurator Stack
|
||||
|
||||
Configurator for Squid Proxy via Node.js with a react frontend
|
||||
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 -> <Cerberus-IP>
|
||||
# squid.lan -> <Cerberus-IP>
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
+575
@@ -0,0 +1,575 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>squid.conf · proxy + wpad configurator</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background: #0a0908;
|
||||
color: #e8e4dc;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; }
|
||||
.display { font-family: 'IBM Plex Sans Condensed', sans-serif; letter-spacing: -0.01em; }
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid #2a2724;
|
||||
padding: 20px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.logo {
|
||||
width: 36px; height: 36px;
|
||||
border: 1px solid #ff8a00;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #ff8a00;
|
||||
}
|
||||
.title { font-size: 22px; font-weight: 600; line-height: 1; }
|
||||
.subtitle { font-size: 11px; color: #8a857d; margin-top: 4px; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
|
||||
min-height: calc(100vh - 78px);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.left { border-right: none !important; border-bottom: 1px solid #2a2724; }
|
||||
}
|
||||
.left {
|
||||
padding: 32px;
|
||||
border-right: 1px solid #2a2724;
|
||||
display: flex; flex-direction: column; gap: 32px;
|
||||
}
|
||||
.right {
|
||||
padding: 32px;
|
||||
display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||
}
|
||||
.section-icon { color: #ff8a00; flex-shrink: 0; }
|
||||
.section-title {
|
||||
font-size: 12px; font-weight: 500; color: #e8e4dc;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.section-sub {
|
||||
font-size: 10px; color: #8a857d; margin-top: 2px;
|
||||
}
|
||||
|
||||
.chip-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: #161513;
|
||||
border: 1px solid #2a2724;
|
||||
font-size: 11px;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.chip:hover { border-color: #ff8a00; }
|
||||
.chip-x {
|
||||
background: transparent; border: none; color: #ff8a00;
|
||||
cursor: pointer; padding: 0; display: flex; align-items: center;
|
||||
opacity: 0; transition: opacity 120ms ease;
|
||||
}
|
||||
.chip:hover .chip-x { opacity: 1; }
|
||||
.chip-empty { font-size: 11px; color: #5a5751; padding: 8px 0; }
|
||||
|
||||
.input-row { display: flex; gap: 6px; }
|
||||
.field {
|
||||
background: #0a0908;
|
||||
border: 1px solid #2a2724;
|
||||
color: #e8e4dc;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
transition: border-color 120ms ease;
|
||||
width: 100%;
|
||||
}
|
||||
.field:focus { outline: none; border-color: #ff8a00; }
|
||||
.field-label {
|
||||
font-size: 9px; color: #5a5751; text-transform: uppercase;
|
||||
letter-spacing: 0.1em; margin-bottom: 4px;
|
||||
}
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 100px; gap: 8px; }
|
||||
|
||||
.btn {
|
||||
background: transparent;
|
||||
color: #8a857d;
|
||||
border: 1px solid #2a2724;
|
||||
padding: 8px 14px;
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #1f1d1a; border-color: #ff8a00; color: #e8e4dc; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { color: #ff8a00; border-color: #ff8a00; }
|
||||
.btn-primary:hover:not(:disabled) { background: #ff8a00; color: #0a0908; }
|
||||
.btn-success { color: #4ade80; border-color: #4ade80; }
|
||||
.btn-danger { color: #f87171; border-color: #f87171; }
|
||||
|
||||
.actions { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
.footnote {
|
||||
font-size: 10px; color: #5a5751;
|
||||
border-top: 1px solid #2a2724;
|
||||
padding-top: 16px;
|
||||
margin-top: auto;
|
||||
line-height: 1.7;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.out-card {
|
||||
background: #0f0e0c;
|
||||
border: 1px solid #2a2724;
|
||||
}
|
||||
.out-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; border-bottom: 1px solid #2a2724; gap: 8px;
|
||||
}
|
||||
.out-name {
|
||||
font-size: 12px; font-weight: 500; color: #e8e4dc;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.out-path {
|
||||
font-size: 10px; color: #5a5751; margin-top: 1px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.out-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.out-actions .btn { padding: 6px 10px; font-size: 10px; }
|
||||
.out-code {
|
||||
margin: 0; padding: 16px;
|
||||
font-size: 11px; line-height: 1.7;
|
||||
color: #c8c4bc;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
overflow: auto; max-height: 320px;
|
||||
background: #0a0908;
|
||||
white-space: pre;
|
||||
}
|
||||
.out-code::-webkit-scrollbar { height: 6px; width: 6px; }
|
||||
.out-code::-webkit-scrollbar-thumb { background: #2a2724; border-radius: 3px; }
|
||||
.out-code::-webkit-scrollbar-thumb:hover { background: #ff8a00; }
|
||||
|
||||
.right-head {
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
margin-bottom: 4px; gap: 16px;
|
||||
}
|
||||
.gen-label {
|
||||
font-size: 10px; color: #ff8a00;
|
||||
letter-spacing: 0.1em; margin-bottom: 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.gen-title { font-size: 18px; font-weight: 600; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
background: #161513; border: 1px solid #2a2724;
|
||||
padding: 12px 16px; font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
transform: translateY(100px); opacity: 0;
|
||||
transition: all 200ms ease;
|
||||
z-index: 100;
|
||||
}
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
.toast.ok { border-color: #4ade80; color: #4ade80; }
|
||||
.toast.warn { border-color: #facc15; color: #facc15; }
|
||||
.toast.err { border-color: #f87171; color: #f87171; }
|
||||
|
||||
.dirty-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #ff8a00;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; color: #8a857d; font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"><div class="loading">loading...</div></div>
|
||||
|
||||
<script type="module">
|
||||
import { h, render } from 'https://esm.sh/preact@10.24.3';
|
||||
import { useState, useEffect, useCallback } from 'https://esm.sh/preact@10.24.3/hooks';
|
||||
import htm from 'https://esm.sh/htm@3.1.1';
|
||||
const html = htm.bind(h);
|
||||
|
||||
// --- Inline SVG icons (no external icon lib for size) ---
|
||||
const Icon = (paths, size = 14) => () => html`
|
||||
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
${paths.map((d) => html`<path d=${d} />`)}
|
||||
</svg>
|
||||
`;
|
||||
const NetworkIcon = Icon(["M9 2L9 7","M15 2L15 7","M9 17L9 22","M15 17L15 22","M2 9L7 9","M2 15L7 15","M17 9L22 9","M17 15L22 15","M7 7L17 7L17 17L7 17Z"]);
|
||||
const GlobeIcon = Icon(["M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z","M2 12h20","M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"]);
|
||||
const ServerIcon = Icon(["M2 4h20v6H2z","M2 14h20v6H2z","M6 7h.01","M6 17h.01"]);
|
||||
const TerminalIcon = Icon(["M4 17l6-6-6-6","M12 19h8"]);
|
||||
const FileIcon = Icon(["M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z","M14 2v6h6","M16 13H8","M16 17H8","M10 9H8"]);
|
||||
const CodeIcon = Icon(["M16 18l6-6-6-6","M8 6l-6 6 6 6"]);
|
||||
const PlusIcon = Icon(["M12 5v14","M5 12h14"]);
|
||||
const XIcon = Icon(["M18 6L6 18","M6 6l12 12"], 11);
|
||||
const CopyIcon = Icon(["M16 3H4a2 2 0 0 0-2 2v12","M8 7h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"], 11);
|
||||
const DownloadIcon = Icon(["M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4","M7 10l5 5 5-5","M12 15V3"], 11);
|
||||
const CheckIcon = Icon(["M20 6L9 17l-5-5"], 11);
|
||||
const ResetIcon = Icon(["M3 12a9 9 0 1 0 9-9","M3 4v5h5"], 12);
|
||||
const SaveIcon = Icon(["M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z","M17 21v-8H7v8","M7 3v5h8"]);
|
||||
const RefreshIcon = Icon(["M23 4v6h-6","M1 20v-6h6","M3.51 9a9 9 0 0 1 14.85-3.36L23 10","M1 14l4.64 4.36A9 9 0 0 0 20.49 15"], 12);
|
||||
|
||||
// --- API ---
|
||||
async function apiGet(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
|
||||
return r.json();
|
||||
}
|
||||
async function apiPost(url, body) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || r.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Generators (mirror server, for live preview) ---
|
||||
function genSquid(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 genDomains(s) {
|
||||
return s.domains.map(d => d.startsWith('*.') ? '.' + d.slice(2) : d).join('\n') + '\n';
|
||||
}
|
||||
function genWpad(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";
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Components ---
|
||||
function App() {
|
||||
const [state, setState] = useState(null);
|
||||
const [savedState, setSavedState] = useState(null);
|
||||
const [newNet, setNewNet] = useState('');
|
||||
const [newDom, setNewDom] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
const [copied, setCopied] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet('/api/config')
|
||||
.then((cfg) => { setState(cfg); setSavedState(JSON.stringify(cfg)); })
|
||||
.catch((e) => showToast('err', `load failed: ${e.message}`));
|
||||
}, []);
|
||||
|
||||
const showToast = (kind, msg, ms = 2500) => {
|
||||
setToast({ kind, msg });
|
||||
setTimeout(() => setToast(null), ms);
|
||||
};
|
||||
|
||||
const update = (patch) => setState((s) => ({ ...s, ...patch }));
|
||||
const dirty = state && JSON.stringify(state) !== savedState;
|
||||
|
||||
const save = async () => {
|
||||
if (!state) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await apiPost('/api/config', state);
|
||||
setSavedState(JSON.stringify(state));
|
||||
if (r.reloaded) showToast('ok', 'saved & squid reloaded');
|
||||
else showToast('warn', `saved, reload failed: ${r.reloadError ?? 'unknown'}`, 4000);
|
||||
} catch (e) {
|
||||
showToast('err', e.message, 4000);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reload = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost('/api/reload');
|
||||
showToast('ok', 'squid reloaded');
|
||||
} catch (e) {
|
||||
showToast('err', e.message, 4000);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
if (!confirm('Auf Defaults zurücksetzen? Wird erst beim Speichern persistiert.')) return;
|
||||
try {
|
||||
// Reset to defaults but don't save yet
|
||||
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',
|
||||
};
|
||||
setState(defaults);
|
||||
} catch (e) {
|
||||
showToast('err', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const addNet = () => {
|
||||
const v = newNet.trim();
|
||||
if (v && !state.networks.includes(v)) {
|
||||
update({ networks: [...state.networks, v] });
|
||||
setNewNet('');
|
||||
}
|
||||
};
|
||||
const addDom = () => {
|
||||
const v = newDom.trim();
|
||||
if (v && !state.domains.includes(v)) {
|
||||
update({ domains: [...state.domains, v] });
|
||||
setNewDom('');
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (key, text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta); ta.select(); document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
const downloadFile = (name, content) => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (!state) return html`<div class="loading">loading...</div>`;
|
||||
|
||||
const outputs = [
|
||||
{ key: 'squid', name: 'squid.conf', path: '/opt/squid/config/squid.conf', content: genSquid(state), Icon: TerminalIcon },
|
||||
{ key: 'domains', name: 'vpn_domains.txt', path: '/opt/squid/config/vpn_domains.txt', content: genDomains(state), Icon: FileIcon },
|
||||
{ key: 'wpad', name: 'wpad.dat', path: '/opt/squid/wpad/wpad.dat', content: genWpad(state), Icon: CodeIcon },
|
||||
];
|
||||
|
||||
return html`
|
||||
<header>
|
||||
<div style="display:flex; align-items:center; gap:16px;">
|
||||
<div class="logo">${h(NetworkIcon, { size: 18 })}</div>
|
||||
<div>
|
||||
<div class="display title">squid<span style="color:#ff8a00;">.</span>conf</div>
|
||||
<div class="subtitle mono">proxy + wpad configurator</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" onClick=${reset} disabled=${busy}>
|
||||
${h(ResetIcon, {})} reset
|
||||
</button>
|
||||
<button class="btn" onClick=${reload} disabled=${busy}>
|
||||
${h(RefreshIcon, {})} reload squid
|
||||
</button>
|
||||
<button class=${`btn ${dirty ? 'btn-primary' : ''}`}
|
||||
onClick=${save} disabled=${!dirty || busy}>
|
||||
${h(SaveIcon, { size: 12 })} ${busy ? 'saving...' : 'save & apply'}
|
||||
${dirty && !busy ? html`<span class="dirty-dot"></span>` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="left">
|
||||
<${Section} icon=${NetworkIcon} title="allowed networks" subtitle="subnets that may use this proxy">
|
||||
<div class="chip-list">
|
||||
${state.networks.map((n) => html`
|
||||
<div class="chip mono" key=${n}>
|
||||
<span>${n}</span>
|
||||
<button class="chip-x" onClick=${() => update({ networks: state.networks.filter(x => x !== n) })}>
|
||||
${h(XIcon, {})}
|
||||
</button>
|
||||
</div>
|
||||
`)}
|
||||
${state.networks.length === 0 && html`<div class="chip-empty mono">no networks — proxy will deny all</div>`}
|
||||
</div>
|
||||
<${InputRow} value=${newNet} onChange=${setNewNet} onSubmit=${addNet} placeholder="192.168.3.0/24" />
|
||||
<//>
|
||||
|
||||
<${Section} icon=${GlobeIcon} title="proxied domains" subtitle="whitelist routed through gluetun">
|
||||
<div class="chip-list">
|
||||
${state.domains.map((d) => html`
|
||||
<div class="chip mono" key=${d}>
|
||||
<span>${d}</span>
|
||||
<button class="chip-x" onClick=${() => update({ domains: state.domains.filter(x => x !== d) })}>
|
||||
${h(XIcon, {})}
|
||||
</button>
|
||||
</div>
|
||||
`)}
|
||||
${state.domains.length === 0 && html`<div class="chip-empty mono">no domains — nothing will be proxied</div>`}
|
||||
</div>
|
||||
<${InputRow} value=${newDom} onChange=${setNewDom} onSubmit=${addDom} placeholder="*.example.com" />
|
||||
<div class="mono" style="font-size:10px; color:#5a5751; margin-top:8px;">
|
||||
wildcards work in PAC. for squid, leading dot is auto-added.
|
||||
</div>
|
||||
<//>
|
||||
|
||||
<${Section} icon=${ServerIcon} title="upstream proxy (gluetun)" subtitle="where matched traffic is forwarded">
|
||||
<div class="field-grid">
|
||||
<${Field} label="host" value=${state.upstreamHost} onChange=${(v) => update({ upstreamHost: v })} placeholder="gluetun" />
|
||||
<${Field} label="port" value=${state.upstreamPort} onChange=${(v) => update({ upstreamPort: v })} placeholder="8888" />
|
||||
</div>
|
||||
<//>
|
||||
|
||||
<${Section} icon=${TerminalIcon} title="squid address (for PAC)" subtitle="how clients reach this squid instance">
|
||||
<div class="field-grid">
|
||||
<${Field} label="host" value=${state.proxyHost} onChange=${(v) => update({ proxyHost: v })} placeholder="cerberus.lan" />
|
||||
<${Field} label="port" value=${state.proxyPort} onChange=${(v) => update({ proxyPort: v })} placeholder="3128" />
|
||||
</div>
|
||||
<//>
|
||||
|
||||
<div class="footnote">
|
||||
changes apply on save · backend writes files atomically · squid reloads via docker exec
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<div class="right-head">
|
||||
<div>
|
||||
<div class="gen-label">GENERATED OUTPUT</div>
|
||||
<div class="gen-title display">${dirty ? 'Live-Vorschau (ungespeichert)' : 'Aktuelle Konfiguration'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${outputs.map((o) => html`
|
||||
<div class="out-card" key=${o.key}>
|
||||
<div class="out-head">
|
||||
<div style="display:flex; align-items:center; gap:10px; min-width:0;">
|
||||
${h(o.Icon, { size: 13 })}
|
||||
<div style="min-width:0;">
|
||||
<div class="out-name">${o.name}</div>
|
||||
<div class="out-path">${o.path}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="out-actions">
|
||||
<button class=${`btn ${copied === o.key ? 'btn-success' : ''}`}
|
||||
onClick=${() => copyText(o.key, o.content)}>
|
||||
${copied === o.key ? h(CheckIcon, {}) : h(CopyIcon, {})}
|
||||
${copied === o.key ? 'copied' : 'copy'}
|
||||
</button>
|
||||
<button class="btn" onClick=${() => downloadFile(o.name, o.content)}>
|
||||
${h(DownloadIcon, {})} save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="out-code">${o.content}</pre>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${toast && html`
|
||||
<div class=${`toast show ${toast.kind}`}>
|
||||
${toast.kind === 'ok' ? h(CheckIcon, {}) : '⚠'} ${toast.msg}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
function Section({ icon, title, subtitle, children }) {
|
||||
return html`
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<div class="section-icon">${h(icon, {})}</div>
|
||||
<div>
|
||||
<div class="section-title mono">${title}</div>
|
||||
<div class="section-sub mono">${subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
${children}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function InputRow({ value, onChange, onSubmit, placeholder }) {
|
||||
return html`
|
||||
<div class="input-row">
|
||||
<input class="field" type="text" value=${value}
|
||||
placeholder=${placeholder}
|
||||
onInput=${(e) => onChange(e.target.value)}
|
||||
onKeyDown=${(e) => e.key === 'Enter' && onSubmit()} />
|
||||
<button class="btn btn-primary" onClick=${onSubmit}>
|
||||
${h(PlusIcon, { size: 12 })} add
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange, placeholder }) {
|
||||
return html`
|
||||
<div>
|
||||
<div class="field-label mono">${label}</div>
|
||||
<input class="field" type="text" value=${value}
|
||||
placeholder=${placeholder}
|
||||
onInput=${(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render(html`<${App}/>`, document.getElementById('root'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
);
|
||||
})();
|
||||
Reference in New Issue
Block a user