nginx und caddy hinzugefügt

This commit is contained in:
Jesko Anschütz 2025-11-03 14:22:31 +01:00
parent 7a0639dc38
commit a02eb7d905
7 changed files with 625 additions and 0 deletions

View file

@ -0,0 +1,31 @@
services:
reverseproxy:
image: jwilder/nginx-proxy:latest
container_name: reverseproxy
restart: unless-stopped
environment:
DEFAULT_HOST: default.vhost
ENABLE_IPV6: "true"
ports:
- "80:80"
- "443:443"
volumes:
- ./zertifikate:/etc/nginx/certs:ro
- ./vhost.d:/etc/nginx/vhost.d
- ./html:/usr/share/nginx/html
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- reverseproxy_netz
letsencrypt-nginx-proxy-companion:
image: jrcs/letsencrypt-nginx-proxy-companion
volumes:
- '/var/run/docker.sock:/var/run/docker.sock:ro'
- './zertifikate:/etc/nginx/certs:rw'
volumes_from:
- reverseproxy
networks:
reverseproxy_netz:
external: true

View file

@ -0,0 +1,4 @@
add_header X-Real-IP $remote_addr always;
add_header X-Forwarded-For $proxy_add_x_forwarded_for always;
location = /ip { default_type text/plain; return 200 "$remote_addr\n"; }

View file

@ -0,0 +1,4 @@
add_header X-Real-IP $remote_addr always;
add_header X-Forwarded-For $proxy_add_x_forwarded_for always;
location = /ip { default_type text/plain; return 200 "$remote_addr\n"; }

View file

@ -0,0 +1,17 @@
networks:
reverseproxy_netz:
external: true
services:
meinwebserver2:
image: nginx
expose:
- 80
networks:
- reverseproxy_netz
environment:
VIRTUAL_HOST: webserver1.fobix.benbex.de
VIRTUAL_PORT: "80"
LETSENCRYPT_HOST: webserver1.fobix.benbex.de
volumes:
- ./html:/usr/share/nginx/html/

View file

@ -0,0 +1,276 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<title>Webserver-Testseite</title>
<style>
:root{--bg:#0b0c10;--card:#11131a;--fg:#e8edf2;--muted:#9aa7b4;--ok:#18a957;--warn:#e6a700;--err:#d64545;--accent:#7c4dff;--grid:rgba(124,77,255,.08)}
@media (prefers-color-scheme: light){
:root{--bg:#f6f8fb;--card:#ffffff;--fg:#0e1320;--muted:#556171;--accent:#5b4bff;--grid:rgba(91,75,255,.06)}
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;
background:radial-gradient(1200px 600px at 10% -10%,var(--grid),transparent 70%),
radial-gradient(900px 500px at 110% 10%,var(--grid),transparent 70%),var(--bg);
color:var(--fg);line-height:1.45;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility
}
.wrap{max-width:980px;margin:auto;padding:24px;display:grid;gap:24px}
header{display:grid;gap:12px}
.brand{display:flex;align-items:center;gap:12px}
.logo{inline-size:48px;block-size:48px;border-radius:12px;background:
conic-gradient(from 210deg,var(--accent),transparent 40%),
radial-gradient(70% 70% at 30% 30%,var(--accent),transparent 60%),
linear-gradient(135deg,rgba(255,255,255,.15),rgba(255,255,255,0));
box-shadow:0 8px 24px rgba(0,0,0,.25), inset 0 0 0 1px rgba(255,255,255,.06)
}
.title{font-size:clamp(22px,3.2vw,30px);font-weight:700}
.subtitle{color:var(--muted);font-size:14px}
.card{background:var(--card);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:18px;box-shadow:0 6px 20px rgba(0,0,0,.15)}
.grid{display:grid;gap:16px}
@media (min-width:780px){.two{grid-template-columns:1fr 1fr}}
.kv{display:grid;grid-template-columns:160px 1fr;gap:8px 12px;font-size:14px}
.kv dt{color:var(--muted)}.kv dd{margin:0;word-break:break-word}
.pill{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;
font-size:12px;border:1px solid rgba(255,255,255,.12)}
.ok{background:color-mix(in oklab,var(--ok) 18%,transparent)}
.warn{background:color-mix(in oklab,var(--warn) 18%,transparent)}
.err{background:color-mix(in oklab,var(--err) 18%,transparent)}
.list{display:grid;gap:8px}
.item{display:flex;gap:8px;align-items:center;justify-content:space-between;padding:8px 10px;border:1px dashed rgba(255,255,255,.12);border-radius:12px}
.badges{display:flex;flex-wrap:wrap;gap:8px}
.btn{appearance:none;border:1px solid rgba(255,255,255,.16);background:transparent;color:var(--fg);
padding:8px 12px;border-radius:10px;font-size:14px;cursor:pointer}
.btn:hover{border-color:rgba(255,255,255,.32)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}
.table{width:100%;border-collapse:separate;border-spacing:0 6px;font-size:13px}
.tr{display:grid;grid-template-columns:220px 1fr;gap:10px;align-items:start}
.key{color:var(--muted)}
.val{word-break:break-word}
footer{color:var(--muted);font-size:12px;text-align:center;padding:6px}
</style>
</head>
<body>
<main class="wrap">
<header>
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<div>
<div class="title">Webserver-Testseite</div>
<div class="subtitle">Keine externen Dienste. IP nur aus Response-Headern oder optional /ip.</div>
</div>
</div>
<div class="badges" id="badges"></div>
</header>
<section class="card">
<h2 style="margin:0 0 10px;font-size:18px">Anfrage-Kontext</h2>
<dl class="kv" id="kv"></dl>
</section>
<section class="grid two">
<div class="card">
<h3 style="margin:0 0 10px;font-size:16px">Anfragende Adresse (ohne STUN)</h3>
<div id="ipList" class="list"></div>
<div style="display:flex;gap:8px;margin-top:10px">
<button class="btn" style="display:none;" id="rerun">Neu laden</button>
<button class="btn" style="display:none;" id="copy">Kopieren</button>
</div>
<p style="margin:10px 0 0"><small class="mono" id="srcNote"></small></p>
</div>
<div class="card">
<h3 style="margin:0 0 10px;font-size:16px">Alle Response-Header</h3>
<div id="hdrTable" class="table"></div>
</div>
</section>
<footer>Nur same-origin. Kein WebRTC. Keine externen HTTP-Aufrufe.</footer>
</main>
<script>
(function(){
const $ = (id) => document.getElementById(id);
// --- utils ---
const isIPv4 = (s) => /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(s);
const isIPv6 = (s) => /^[0-9a-fA-F:]+$/.test(s) && s.includes(':');
const isIP = (s) => isIPv4(s) || isIPv6(s);
const splitCSV = (s) => s.split(',').map(x=>x.trim()).filter(Boolean);
const uniq = (arr) => Array.from(new Set(arr));
const addRow = (left, right, cls) => {
const div = document.createElement('div'); div.className='item';
const L = document.createElement('div'); L.textContent = left;
const R = document.createElement('div'); R.className = 'pill ' + (cls||''); R.textContent = right;
div.append(L,R); return div;
};
// --- context ---
function renderContext(){
const kv = $('kv'); kv.innerHTML='';
const {protocol, hostname, port, pathname, search, hash, href} = location;
const dpr = devicePixelRatio || 1;
const rows = [
['URL', href],
['Protokoll', protocol.replace(':','')],
['Host', hostname],
['Port', port || (protocol==='https:'?'443':'80')],
['Pfad', pathname || '/'],
['Query', search || ''],
['Hash', hash || ''],
['Zeit', new Date().toISOString()],
['Viewport', `${innerWidth}×${innerHeight} @${dpr}x`],
['UA', navigator.userAgent],
];
for(const [k,v] of rows){
const dt=document.createElement('dt'); dt.textContent=k;
const dd=document.createElement('dd'); dd.textContent=String(v??'—');
kv.append(dt,dd);
}
const badges = $('badges'); badges.innerHTML='';
const b = (t)=>{const s=document.createElement('span'); s.className='pill'; s.textContent=t; return s;};
badges.append(
b(protocol.toUpperCase().replace(':','')),
b(hostname),
b(port?(':'+port):(protocol==='https:'?':443':':80')),
b(/(curl|wget|httpie)/i.test(navigator.userAgent)?'CLI':'Browser')
);
}
// --- header parsing for IPs ---
function firstFromForwarded(val){
// Forwarded: for=1.2.3.4;proto=https;by=...
if(!val) return null;
const re=/for=(?:"|\[)?([0-9a-fA-F:.]+)(?:"|])?/g; let m;
while((m=re.exec(val))){ if(isIP(m[1])) return m[1]; }
return null;
}
function firstFromXFF(val){
if(!val) return null;
const ip = val.split(',')[0].trim().split(':')[0].trim();
return isIP(ip) ? ip : null;
}
function allIPs(val){
if(!val) return [];
const m = val.match(/([0-9a-fA-F:.]+)/g) || [];
return uniq(m.filter(isIP));
}
async function scanHeaders(){
const ipOut = $('ipList'); ipOut.innerHTML = '…';
const srcNote = $('srcNote'); srcNote.textContent = '';
const hdrTbl = $('hdrTable'); hdrTbl.innerHTML = '…';
let headersArray = [];
let found = [];
// HEAD derselben Seite
let headOk = false;
try{
const r = await fetch(location.href, { method:'HEAD', cache:'no-store' });
headOk = true;
// alle Header sammeln
r.headers.forEach((v,k)=>{ headersArray.push([k, v]); });
// Kandidaten prüfen
const candidates = [];
const h = (n)=>r.headers.get(n);
const fwd = h('forwarded');
const xff = h('x-forwarded-for');
const xri = h('x-real-ip');
const cf = h('cf-connecting-ip');
const tci = h('true-client-ip');
const xci = h('x-client-ip');
const fci = h('fastly-client-ip') || h('fly-client-ip');
if(firstFromForwarded(fwd)) candidates.push(['Forwarded', firstFromForwarded(fwd)]);
if(firstFromXFF(xff)) candidates.push(['X-Forwarded-For', firstFromXFF(xff)]);
if(isIP(xri)) candidates.push(['X-Real-IP', xri]);
if(isIP(cf)) candidates.push(['CF-Connecting-IP', cf]);
if(isIP(tci)) candidates.push(['True-Client-IP', tci]);
if(isIP(xci)) candidates.push(['X-Client-IP', xci]);
if(isIP(fci)) candidates.push(['Fastly/Fly', fci]);
found = candidates;
srcNote.textContent = candidates.length
? 'Quelle: Response-Header (same-origin)'
: '';
}catch(_){
srcNote.textContent = 'Quelle: HEAD fehlgeschlagen';
}
// Optionaler /ip Endpunkt, wenn keine Header-IP
if(found.length===0){
try{
const r = await fetch('/ip', { method:'GET', cache:'no-store' });
const text = await r.text();
let ip = null;
try{
const j = JSON.parse(text);
const v = j?.ip || j?.remote || j?.address;
if(typeof v === 'string' && isIP(v)) ip = v;
}catch{
ip = (text.match(/([0-9a-fA-F:.]+)/g)||[]).find(isIP) || null;
}
if(ip){
found = [['/ip', ip]];
srcNote.textContent = 'Quelle: /ip (same-origin)';
}
}catch(_){}
}
// Ausgabe IPs
ipOut.innerHTML = '';
if(found.length===0){
ipOut.append(addRow('keine IP gefunden', '—', 'warn'));
}else{
const map = new Map(); // ip -> Set(sources)
for(const [src, ip] of found){
if(!map.has(ip)) map.set(ip, new Set());
map.get(ip).add(src);
}
for(const [ip, srcs] of map.entries()){
ipOut.append(addRow(ip, Array.from(srcs).join(', '), 'ok'));
}
}
// Ausgabe aller Header
hdrTbl.innerHTML = '';
if(headOk && headersArray.length){
headersArray.sort((a,b)=>a[0].localeCompare(b[0]));
for(const [k,v] of headersArray){
const row = document.createElement('div'); row.className='tr';
const key = document.createElement('div'); key.className='key'; key.textContent=k;
const val = document.createElement('div'); val.className='val mono'; val.textContent=v;
row.append(key,val); hdrTbl.append(row);
}
}else{
const row = document.createElement('div'); row.className='tr';
const key = document.createElement('div'); key.className='key'; key.textContent='—';
const val = document.createElement('div'); val.className='val mono'; val.textContent='keine Header verfügbar';
row.append(key,val); hdrTbl.append(row);
}
}
function wire(){
$('rerun').addEventListener('click', scanHeaders);
$('copy').addEventListener('click', ()=>{
const items = Array.from(document.querySelectorAll('#ipList .item'));
const lines = items.map(i=>i.firstChild.textContent.trim());
if(lines.length) navigator.clipboard?.writeText(lines.join('\n')).catch(()=>{});
});
renderContext();
window.addEventListener('resize', renderContext);
scanHeaders();
}
wire();
})();
</script>
</body>
</html>

View file

@ -0,0 +1,17 @@
networks:
reverseproxy_netz:
external: true
services:
meinwebserver2:
image: nginx
expose:
- 80
networks:
- reverseproxy_netz
environment:
VIRTUAL_HOST: webserver2.fobix.benbex.de
VIRTUAL_PORT: "80"
LETSENCRYPT_HOST: webserver2.fobix.benbex.de
volumes:
- ./html:/usr/share/nginx/html/

View file

@ -0,0 +1,276 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<title>Webserver-Testseite</title>
<style>
:root{--bg:#0b0c10;--card:#11131a;--fg:#e8edf2;--muted:#9aa7b4;--ok:#18a957;--warn:#e6a700;--err:#d64545;--accent:#7c4dff;--grid:rgba(124,77,255,.08)}
@media (prefers-color-scheme: light){
:root{--bg:#f6f8fb;--card:#ffffff;--fg:#0e1320;--muted:#556171;--accent:#5b4bff;--grid:rgba(91,75,255,.06)}
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;
background:radial-gradient(1200px 600px at 10% -10%,var(--grid),transparent 70%),
radial-gradient(900px 500px at 110% 10%,var(--grid),transparent 70%),var(--bg);
color:var(--fg);line-height:1.45;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility
}
.wrap{max-width:980px;margin:auto;padding:24px;display:grid;gap:24px}
header{display:grid;gap:12px}
.brand{display:flex;align-items:center;gap:12px}
.logo{inline-size:48px;block-size:48px;border-radius:12px;background:
conic-gradient(from 210deg,var(--accent),transparent 40%),
radial-gradient(70% 70% at 30% 30%,var(--accent),transparent 60%),
linear-gradient(135deg,rgba(255,255,255,.15),rgba(255,255,255,0));
box-shadow:0 8px 24px rgba(0,0,0,.25), inset 0 0 0 1px rgba(255,255,255,.06)
}
.title{font-size:clamp(22px,3.2vw,30px);font-weight:700}
.subtitle{color:var(--muted);font-size:14px}
.card{background:var(--card);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:18px;box-shadow:0 6px 20px rgba(0,0,0,.15)}
.grid{display:grid;gap:16px}
@media (min-width:780px){.two{grid-template-columns:1fr 1fr}}
.kv{display:grid;grid-template-columns:160px 1fr;gap:8px 12px;font-size:14px}
.kv dt{color:var(--muted)}.kv dd{margin:0;word-break:break-word}
.pill{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;
font-size:12px;border:1px solid rgba(255,255,255,.12)}
.ok{background:color-mix(in oklab,var(--ok) 18%,transparent)}
.warn{background:color-mix(in oklab,var(--warn) 18%,transparent)}
.err{background:color-mix(in oklab,var(--err) 18%,transparent)}
.list{display:grid;gap:8px}
.item{display:flex;gap:8px;align-items:center;justify-content:space-between;padding:8px 10px;border:1px dashed rgba(255,255,255,.12);border-radius:12px}
.badges{display:flex;flex-wrap:wrap;gap:8px}
.btn{appearance:none;border:1px solid rgba(255,255,255,.16);background:transparent;color:var(--fg);
padding:8px 12px;border-radius:10px;font-size:14px;cursor:pointer}
.btn:hover{border-color:rgba(255,255,255,.32)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}
.table{width:100%;border-collapse:separate;border-spacing:0 6px;font-size:13px}
.tr{display:grid;grid-template-columns:220px 1fr;gap:10px;align-items:start}
.key{color:var(--muted)}
.val{word-break:break-word}
footer{color:var(--muted);font-size:12px;text-align:center;padding:6px}
</style>
</head>
<body>
<main class="wrap">
<header>
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<div>
<div class="title">Webserver-Testseite</div>
<div class="subtitle">Keine externen Dienste. IP nur aus Response-Headern oder optional /ip.</div>
</div>
</div>
<div class="badges" id="badges"></div>
</header>
<section class="card">
<h2 style="margin:0 0 10px;font-size:18px">Anfrage-Kontext</h2>
<dl class="kv" id="kv"></dl>
</section>
<section class="grid two">
<div class="card">
<h3 style="margin:0 0 10px;font-size:16px">Anfragende Adresse (ohne STUN)</h3>
<div id="ipList" class="list"></div>
<div style="display:flex;gap:8px;margin-top:10px">
<button class="btn" style="display:none;" id="rerun">Neu laden</button>
<button class="btn" style="display:none;" id="copy">Kopieren</button>
</div>
<p style="margin:10px 0 0"><small class="mono" id="srcNote"></small></p>
</div>
<div class="card">
<h3 style="margin:0 0 10px;font-size:16px">Alle Response-Header</h3>
<div id="hdrTable" class="table"></div>
</div>
</section>
<footer>Nur same-origin. Kein WebRTC. Keine externen HTTP-Aufrufe.</footer>
</main>
<script>
(function(){
const $ = (id) => document.getElementById(id);
// --- utils ---
const isIPv4 = (s) => /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(s);
const isIPv6 = (s) => /^[0-9a-fA-F:]+$/.test(s) && s.includes(':');
const isIP = (s) => isIPv4(s) || isIPv6(s);
const splitCSV = (s) => s.split(',').map(x=>x.trim()).filter(Boolean);
const uniq = (arr) => Array.from(new Set(arr));
const addRow = (left, right, cls) => {
const div = document.createElement('div'); div.className='item';
const L = document.createElement('div'); L.textContent = left;
const R = document.createElement('div'); R.className = 'pill ' + (cls||''); R.textContent = right;
div.append(L,R); return div;
};
// --- context ---
function renderContext(){
const kv = $('kv'); kv.innerHTML='';
const {protocol, hostname, port, pathname, search, hash, href} = location;
const dpr = devicePixelRatio || 1;
const rows = [
['URL', href],
['Protokoll', protocol.replace(':','')],
['Host', hostname],
['Port', port || (protocol==='https:'?'443':'80')],
['Pfad', pathname || '/'],
['Query', search || ''],
['Hash', hash || ''],
['Zeit', new Date().toISOString()],
['Viewport', `${innerWidth}×${innerHeight} @${dpr}x`],
['UA', navigator.userAgent],
];
for(const [k,v] of rows){
const dt=document.createElement('dt'); dt.textContent=k;
const dd=document.createElement('dd'); dd.textContent=String(v??'—');
kv.append(dt,dd);
}
const badges = $('badges'); badges.innerHTML='';
const b = (t)=>{const s=document.createElement('span'); s.className='pill'; s.textContent=t; return s;};
badges.append(
b(protocol.toUpperCase().replace(':','')),
b(hostname),
b(port?(':'+port):(protocol==='https:'?':443':':80')),
b(/(curl|wget|httpie)/i.test(navigator.userAgent)?'CLI':'Browser')
);
}
// --- header parsing for IPs ---
function firstFromForwarded(val){
// Forwarded: for=1.2.3.4;proto=https;by=...
if(!val) return null;
const re=/for=(?:"|\[)?([0-9a-fA-F:.]+)(?:"|])?/g; let m;
while((m=re.exec(val))){ if(isIP(m[1])) return m[1]; }
return null;
}
function firstFromXFF(val){
if(!val) return null;
const ip = val.split(',')[0].trim().split(':')[0].trim();
return isIP(ip) ? ip : null;
}
function allIPs(val){
if(!val) return [];
const m = val.match(/([0-9a-fA-F:.]+)/g) || [];
return uniq(m.filter(isIP));
}
async function scanHeaders(){
const ipOut = $('ipList'); ipOut.innerHTML = '…';
const srcNote = $('srcNote'); srcNote.textContent = '';
const hdrTbl = $('hdrTable'); hdrTbl.innerHTML = '…';
let headersArray = [];
let found = [];
// HEAD derselben Seite
let headOk = false;
try{
const r = await fetch(location.href, { method:'HEAD', cache:'no-store' });
headOk = true;
// alle Header sammeln
r.headers.forEach((v,k)=>{ headersArray.push([k, v]); });
// Kandidaten prüfen
const candidates = [];
const h = (n)=>r.headers.get(n);
const fwd = h('forwarded');
const xff = h('x-forwarded-for');
const xri = h('x-real-ip');
const cf = h('cf-connecting-ip');
const tci = h('true-client-ip');
const xci = h('x-client-ip');
const fci = h('fastly-client-ip') || h('fly-client-ip');
if(firstFromForwarded(fwd)) candidates.push(['Forwarded', firstFromForwarded(fwd)]);
if(firstFromXFF(xff)) candidates.push(['X-Forwarded-For', firstFromXFF(xff)]);
if(isIP(xri)) candidates.push(['X-Real-IP', xri]);
if(isIP(cf)) candidates.push(['CF-Connecting-IP', cf]);
if(isIP(tci)) candidates.push(['True-Client-IP', tci]);
if(isIP(xci)) candidates.push(['X-Client-IP', xci]);
if(isIP(fci)) candidates.push(['Fastly/Fly', fci]);
found = candidates;
srcNote.textContent = candidates.length
? 'Quelle: Response-Header (same-origin)'
: '';
}catch(_){
srcNote.textContent = 'Quelle: HEAD fehlgeschlagen';
}
// Optionaler /ip Endpunkt, wenn keine Header-IP
if(found.length===0){
try{
const r = await fetch('/ip', { method:'GET', cache:'no-store' });
const text = await r.text();
let ip = null;
try{
const j = JSON.parse(text);
const v = j?.ip || j?.remote || j?.address;
if(typeof v === 'string' && isIP(v)) ip = v;
}catch{
ip = (text.match(/([0-9a-fA-F:.]+)/g)||[]).find(isIP) || null;
}
if(ip){
found = [['/ip', ip]];
srcNote.textContent = 'Quelle: /ip (same-origin)';
}
}catch(_){}
}
// Ausgabe IPs
ipOut.innerHTML = '';
if(found.length===0){
ipOut.append(addRow('keine IP gefunden', '—', 'warn'));
}else{
const map = new Map(); // ip -> Set(sources)
for(const [src, ip] of found){
if(!map.has(ip)) map.set(ip, new Set());
map.get(ip).add(src);
}
for(const [ip, srcs] of map.entries()){
ipOut.append(addRow(ip, Array.from(srcs).join(', '), 'ok'));
}
}
// Ausgabe aller Header
hdrTbl.innerHTML = '';
if(headOk && headersArray.length){
headersArray.sort((a,b)=>a[0].localeCompare(b[0]));
for(const [k,v] of headersArray){
const row = document.createElement('div'); row.className='tr';
const key = document.createElement('div'); key.className='key'; key.textContent=k;
const val = document.createElement('div'); val.className='val mono'; val.textContent=v;
row.append(key,val); hdrTbl.append(row);
}
}else{
const row = document.createElement('div'); row.className='tr';
const key = document.createElement('div'); key.className='key'; key.textContent='—';
const val = document.createElement('div'); val.className='val mono'; val.textContent='keine Header verfügbar';
row.append(key,val); hdrTbl.append(row);
}
}
function wire(){
$('rerun').addEventListener('click', scanHeaders);
$('copy').addEventListener('click', ()=>{
const items = Array.from(document.querySelectorAll('#ipList .item'));
const lines = items.map(i=>i.firstChild.textContent.trim());
if(lines.length) navigator.clipboard?.writeText(lines.join('\n')).catch(()=>{});
});
renderContext();
window.addEventListener('resize', renderContext);
scanHeaders();
}
wire();
})();
</script>
</body>
</html>