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