Corrected file structure

This commit is contained in:
2026-05-03 23:10:26 +02:00
parent f85428a6db
commit ff6ecd2ff4
7 changed files with 861 additions and 0 deletions
+575
View File
@@ -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>