Add isolated WireGuard module with guarded access modes and server setup

This commit is contained in:
Mikei386
2026-09-28 14:50:02 +02:00
parent b7b2506272
commit e86e540ae4
21 changed files with 1278 additions and 8 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
__pycache__
**/__pycache__
*.conf
*.key
.state
+12 -3
View File
@@ -1,8 +1,15 @@
# Athena Deck · Prototyp 0.1 # Athena Deck · Prototyp 0.2
Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript. Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript.
Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10. Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10.
## Neu: WireGuard-Einstellungen
Installation einer getrennten Server-Instanz, sicherer Conf-Import und LAN-/Tunnel-Zugriff
sind jetzt unter Einstellungen verfügbar. Einrichtung, Sicherheitsgrenzen und Tests:
[WireGuard-Modul](network/README.md). Ohne Installation bleibt die Mac-Vorschau
rein lokal; erst der Installationsknopf erstellt den neuen Container auf Athena.
## Start und Stopp auf dem Mac ## Start und Stopp auf dem Mac
```sh ```sh
@@ -73,10 +80,12 @@ Sprachmodelle/Chat, Bildgenerierung, Audio und Weitere Dienste sind ausdrücklic
Platzhalter. Keine Downloads, Installation, Presets, Chats oder Modellwechsel. Platzhalter. Keine Downloads, Installation, Presets, Chats oder Modellwechsel.
Spätere Modellprofile und native llama.cpp-Worker sollten eigene Service-Adapter Spätere Modellprofile und native llama.cpp-Worker sollten eigene Service-Adapter
mit derselben Status-/Start-/Stopp-Trennung erhalten. Noch kein Worker-Registry, mit derselben Status-/Start-/Stopp-Trennung erhalten. Noch kein Worker-Registry,
Scheduler, Authentifizierung für Netzwerkbetrieb oder produktionsreifer Supervisor. Scheduler oder produktionsreifer Worker-Supervisor. Die optionale Server-Instanz
hat eine eigene Passwortanmeldung; ihre Netzwerkgrenzen stehen in der Modul-Dokumentation.
SIGKILL/Absturz-Cleanup ist nicht implementiert; regulär Ctrl+C/SIGTERM verwenden. SIGKILL/Absturz-Cleanup ist nicht implementiert; regulär Ctrl+C/SIGTERM verwenden.
SSH verwendet den vorgegebenen Root-Zugang; ein eingeschränktes Telemetrie-Konto SSH verwendet den vorgegebenen Root-Zugang; ein eingeschränktes Telemetrie-Konto
wäre ein späterer eigener Auftrag. Keinerlei Host-Konfiguration wurde geändert. wäre ein späterer eigener Auftrag. Der Hardware-Collector ändert keine Host-Konfiguration. Die optionale
Netzwerkmodul-Installation erzeugt einen eigenen Docker-Container samt Portbindungen.
## Verifikation am 28.09.2026 ## Verifikation am 28.09.2026
+2 -1
View File
@@ -8,7 +8,8 @@ const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</stron
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`; const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']}; const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft lokal auf deinem Mac.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`; if(page==='network'){await networkPage();return;}
if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden.');if(!h.available)html+=`<section class="empty">Hardware nicht verfügbar<p>${esc(h.errors.join(' '))}</p></section>`;else{const c=h.cpu,r=h.ram;html+=`<div class="grid"><section class="card"><div class="label">CPU</div><h2>${esc(c.name)}</h2><div class="metrics">${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}</div>${bar(c.percent)}</section><section class="card"><div class="label">System-RAM</div><h2>Arbeitsspeicher</h2><div class="metrics">${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}</div>${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}</section>`;for(const g of h.gpus)html+=`<section class="card"><div class="card-top"><span class="label">GPU ${g.index}</span><span class="tag">${fmt(g.temperature_c,' °C')}</span></div><h2>${esc(g.name)}</h2><div class="metrics">${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}</div>${bar(g.percent)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;} }else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden.');if(!h.available)html+=`<section class="empty">Hardware nicht verfügbar<p>${esc(h.errors.join(' '))}</p></section>`;else{const c=h.cpu,r=h.ram;html+=`<div class="grid"><section class="card"><div class="label">CPU</div><h2>${esc(c.name)}</h2><div class="metrics">${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}</div>${bar(c.percent)}</section><section class="card"><div class="label">System-RAM</div><h2>Arbeitsspeicher</h2><div class="metrics">${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}</div>${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}</section>`;for(const g of h.gpus)html+=`<section class="card"><div class="card-top"><span class="label">GPU ${g.index}</span><span class="tag">${fmt(g.temperature_c,' °C')}</span></div><h2>${esc(g.name)}</h2><div class="metrics">${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}</div>${bar(g.percent)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;}
}else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`<section class="empty"><div class="symbol">+</div><h2>Platz für den nächsten Schritt.</h2><p>${p[2]}</p><span class="pill">Platzhalter · noch keine Funktionen</span><p class="note">Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.</p></section>`;} }else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`<section class="empty"><div class="symbol">+</div><h2>Platz für den nächsten Schritt.</h2><p>${p[2]}</p><span class="pill">Platzhalter · noch keine Funktionen</span><p class="note">Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.</p></section>`;}
if((location.hash.slice(1)||'home')===page){view.innerHTML=html;for(const action of ['start','stop'])document.getElementById(action)?.addEventListener('click',()=>control(action));} if((location.hash.slice(1)||'home')===page){view.innerHTML=html;for(const action of ['start','stop'])document.getElementById(action)?.addEventListener('click',()=>control(action));}
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#services">Weitere Dienste <span>05</span></a><a href="#hardware">Hardware <span>06</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Steuerung auf dem Mac<br>Hardware von Athena · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.1 · Prototyp</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html> <!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#services">Weitere Dienste <span>05</span></a><a href="#hardware">Hardware <span>06</span></a><a href="#network">Einstellungen <span>07</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Steuerung auf dem Mac<br>Hardware von Athena · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.2 · Prototyp</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck · Anmeldung</title><link rel="stylesheet" href="/style.css"><script src="/login.js" defer></script></head><body><main class="login"><div class="kicker">ATHENA DECK / SERVER</div><h1>Willkommen zurück.</h1><p>Melde dich mit dem bei der Einrichtung vergebenen Deck-Passwort an.</p><form id="login"><label for="password">Deck-Passwort</label><input id="password" type="password" autocomplete="current-password" required maxlength="256"><button>Anmelden</button></form><p id="error" role="alert"></p><p class="note">Nutze den WireGuard-Tunnel oder einen SSH-Tunnel. Der direkte LAN-Zugang verwendet in dieser Version HTTP.</p></main></body></html>
+1
View File
@@ -0,0 +1 @@
document.querySelector('#login').addEventListener('submit',async event=>{event.preventDefault();const input=document.querySelector('#password'),button=document.querySelector('button');button.disabled=true;try{const r=await fetch('/api/v1/login',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({password:input.value})});input.value='';const value=await r.json();if(!r.ok)throw Error(value.error);location.reload();}catch(e){document.querySelector('#error').textContent=e.message}finally{button.disabled=false}});
+57
View File
@@ -0,0 +1,57 @@
/* Settings form stays mounted while status polling updates only status elements. */
let networkBusy=false, networkState=null;
async function networkRequest(action,body={}){
const response=await fetch('/api/v1/network'+(action?'/'+action:''),{method:action?'POST':'GET',headers:action?{'Content-Type':'application/json','X-Athena-Deck':'1'}:{},body:action?JSON.stringify(body):undefined,signal:AbortSignal.timeout(40000)});
const value=await response.json();if(!response.ok)throw Error(value.error||'Netzwerkaktion fehlgeschlagen.');return value;
}
async function networkAction(action,body={}){
if(networkBusy)return;networkBusy=true;networkButtons();
const output=document.querySelector('#network-message');output.textContent='Wird ausgeführt …';
try{await networkRequest(action,body);output.textContent=action==='import'?'Konfiguration geprüft und geschützt auf Athena gespeichert.':action==='install'?'Installation läuft. Der Status aktualisiert sich automatisch.':'Einstellung übernommen.';}
catch(e){output.textContent=e.message;}
finally{networkBusy=false;await networkPage();}
}
function networkButtons(){
if(!networkState)return;
const s=networkState;
document.querySelectorAll('#network-page button').forEach(b=>b.disabled=networkBusy||s.state==='installing');
const set=(id,disabled)=>{const b=document.getElementById(id);if(b)b.disabled=b.disabled||disabled;};
set('network-install',s.installed);
set('network-import',!s.installed||s.enabled||s.mode!=='lan'||!!s.pending);
set('network-connect',!s.configured||s.enabled);
set('network-disconnect',!s.enabled);
set('network-delete',!s.configured||s.enabled||s.mode!=='lan'||!!s.pending);
set('network-apply',!s.installed||!!s.pending);
const needed=s.pending?.target==='lan'?'lan':'tunnel';
set('network-confirm',!s.pending||s.ingress!==needed);
set('network-cancel',!s.pending);
}
async function networkPage(){
const view=document.querySelector('#view');
if(!document.querySelector('#network-page')){
view.innerHTML=`<div id="network-page"><div class="kicker">EINSTELLUNGEN / NETZWERK</div><h1>Dein Weg zu Athena.</h1><p>Ein eigener WireGuard-Zugang für Athena Deck und seine internen API-Endpunkte.</p><section class="card network-card"><div class="card-top"><h2>WireGuard-Modul</h2><span id="network-state" class="pill">Wird geprüft …</span></div><div id="network-details" class="network-status"></div><p id="network-notice"></p><div id="network-setup"><h2>Auf Athena einrichten</h2><p>Installiert WireGuard und Deck in einem eigenen Container. Der vorhandene Gateway bleibt unverändert. Die erste Einrichtung startet im LAN-Modus auf Port 8110.</p><label for="network-password">Neues Deck-Passwort · mindestens 16 Zeichen</label><input id="network-password" type="password" autocomplete="new-password" minlength="16" maxlength="256"><button id="network-install">Modul auf Athena installieren</button><p class="note">Benötigt den vorhandenen SSH-Zugang und Docker auf Athena. LAN-Zugriff verwendet HTTP; für vertrauliche Eingaben WireGuard oder SSH-Tunnel verwenden.</p></div><div id="network-config" hidden><label for="network-file">Eigene WireGuard-Konfiguration (.conf)</label><input id="network-file" type="file" accept=".conf,text/plain"><p>Verwende einen eigenen Peer für Deck. Schlüssel werden nicht angezeigt. Ein Peer und IPv4 werden unterstützt; Shell-Hooks werden abgelehnt.</p><button id="network-import">Konfiguration importieren</button><button id="network-delete" class="secondary">Konfiguration löschen</button><div class="note"><button id="network-connect">WireGuard aktivieren</button><button id="network-disconnect" class="secondary">WireGuard deaktivieren</button><p>Aktivieren richtet den Tunnel ein. „Verbunden“ erscheint erst nach einem aktuellen Handshake.</p></div></div></section><section class="card network-card" id="network-access"><h2>Zugriff auf Deck und API</h2><label for="network-mode">Erreichbarkeit</label><select id="network-mode"><option value="lan">Nur lokales Netzwerk</option><option value="tunnel">Nur WireGuard-Tunnel</option><option value="both">Tunnel und lokales Netzwerk</option></select><button id="network-apply">Zugriffsmodus testen</button><p id="network-current"></p><div id="network-trial" hidden><p id="network-countdown" class="warning"></p><button id="network-confirm">Zugriff hier bestätigen</button><button id="network-cancel" class="secondary">Änderung zurücknehmen</button><p>Öffne die angezeigte Zieladresse und bestätige dort innerhalb von 120 Sekunden. Ohne Bestätigung wird der vorige Modus wiederhergestellt.</p></div><p class="note">Der lokale Mac-Zugang per SSH bleibt als Verwaltungskanal erhalten. Produktionsendpunkte werden nicht umgestellt. Im Modus „Nur Tunnel“ gibt es bei Tunnelausfall keinen LAN-Fallback.</p></section><p id="network-message" class="network-message" role="status" aria-live="polite"></p></div>`;
document.querySelector('#network-install').onclick=()=>{const input=document.querySelector('#network-password'),password=input.value;input.value='';networkAction('install',{password});};
document.querySelector('#network-import').onclick=async()=>{const input=document.querySelector('#network-file'),file=input.files[0];if(!file){document.querySelector('#network-message').textContent='Bitte zuerst eine .conf-Datei auswählen.';return}if(file.size>16384){document.querySelector('#network-message').textContent='Die Datei darf höchstens 16 KiB groß sein.';return}const config=await file.text();input.value='';await networkAction('import',{config});};
for(const action of ['connect','disconnect','delete','cancel'])document.querySelector('#network-'+action).onclick=()=>networkAction(action);
document.querySelector('#network-apply').onclick=()=>networkAction('mode',{mode:document.querySelector('#network-mode').value});
document.querySelector('#network-confirm').onclick=()=>networkAction('confirm',{trial_id:networkState?.pending?.id});
}
try{
const s=await networkRequest('');if(!document.querySelector('#network-page'))return;networkState=s;
const labels={unavailable:'Nicht bereit',installing:'Installation läuft',disabled:'Deaktiviert',connecting:'Warte auf Handshake',connected:'Verbunden'};
document.querySelector('#network-state').textContent=labels[s.state]||'Nicht verfügbar';
const details=document.querySelector('#network-details');details.replaceChildren();
for(const [label,value] of [['Konfiguration',s.configured?'Gespeichert':'Keine'],['Letzter Handshake',s.latest_handshake?new Date(s.latest_handshake*1000).toLocaleString('de-DE'):'Noch keiner'],['Dieser Zugang',s.ingress==='management'?'Mac / SSH-Verwaltung':s.ingress==='tunnel'?'WireGuard-Tunnel':'LAN']]){const d=document.createElement('div'),small=document.createElement('small'),strong=document.createElement('strong');small.textContent=label;strong.textContent=value;d.append(small,strong);details.append(d);}
for(const [label,url] of [['LAN',s.lan_url],['Tunnel',s.tunnel_url]])if(url){const d=document.createElement('div'),a=document.createElement('a');a.href=url+'/#network';a.textContent=label+': '+url;a.target='_blank';a.rel='noopener';d.append(a);details.append(d);}
document.querySelector('#network-setup').hidden=!!s.installed;
document.querySelector('#network-config').hidden=false;
document.querySelector('#network-file').disabled=!s.installed||s.enabled||networkBusy;
document.querySelector('#network-access').hidden=false;
document.querySelector('#network-mode').disabled=!s.installed||!!s.pending||networkBusy;
document.querySelector('#network-notice').textContent=[s.error,s.job?.message,...(s.warnings||[])].filter(Boolean).join(' ');
document.querySelector('#network-current').textContent='Aktuell: '+({lan:'Nur lokales Netzwerk',tunnel:'Nur WireGuard-Tunnel',both:'Tunnel und LAN'}[s.mode]||'Noch nicht eingerichtet');
document.querySelector('#network-trial').hidden=!s.pending;
if(s.pending)document.querySelector('#network-countdown').textContent=`Test aktiv · ${s.pending.seconds_remaining} Sekunden bis zur automatischen Rücknahme. Bestätigung nötig über ${s.pending.target==='lan'?'LAN':'WireGuard'}.`;
networkButtons();
}catch(e){if(document.querySelector('#network-message'))document.querySelector('#network-message').textContent=e.message;}
}
+6
View File
@@ -0,0 +1,6 @@
FROM python:3.13-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends wireguard-tools iproute2 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
ENV PYTHONDONTWRITEBYTECODE=1 DECK_CONTAINER=1
CMD ["python3", "-m", "network.agent"]
+166
View File
@@ -0,0 +1,166 @@
# WireGuard-Modul · Athena Deck 0.2
## Was eingebaut ist
Einstellungen → Netzwerk bietet Installation, Conf-Import, Aktivieren/Deaktivieren,
Handshake-Status und die Modi **LAN**, **Tunnel**, **beides**. Diese Modi gelten
für die Server-Instanz von Athena Deck und alle ihre `/api/v1/*`-Endpunkte.
Produktive Router-, Modell-, Audio- und Bild-Endpunkte werden nicht übernommen.
Die lokale Mac-Instanz bleibt ein separater SSH-Verwaltungskanal.
Die Implementierung benutzt einen eigenen Container `athena-deck-network` mit
eigenem Netzwerk-Namespace. Im Container laufen ein eingeschränkter Root-Helper
und die Webanwendung unter UID/GID 65534. Der Webprozess erhält keinen Docker-Socket
und keinen Root-Zugriff. Der Helper erlaubt über seinen Unix-Socket nur fest
implementierte Netzwerkaktionen. Er nimmt keine Shell-Befehle entgegen.
WireGuard-Pakete werden automatisch im Container-Image installiert. Docker und
WireGuard-Kernelunterstützung müssen auf dem Host vorhanden sein; Athena hat beides.
Kein `apt` auf dem Host, kein Kernel-Update, kein Reboot, kein Host-Netzwerkmodus,
keine Änderungen am bestehenden Gateway oder dessen Konfiguration.
Docker erstellt seine üblichen Regeln für den **neuen** Container und seine
Port-Veröffentlichung; eine Aussage „keinerlei Netzwerkänderung“ wäre dafür falsch.
## Einrichtung in der Oberfläche
1. Auf dem Mac http://127.0.0.1:8108/#network öffnen.
2. Eigenes Deck-Passwort (mindestens 16 Zeichen) vergeben und **Modul auf Athena
installieren** wählen. Installation läuft asynchron und meldet ihren Status.
3. Eigene IPv4-WireGuard-Conf importieren. **Einen eigenen Peer verwenden**, nicht
die Konfiguration des produktiven Athena-Gateways wiederverwenden.
4. WireGuard aktivieren. Erst ein Handshake innerhalb der letzten 180 Sekunden
führt zum Status „Verbunden“. Ein geladenes Interface alleine genügt nicht.
5. Gewünschten Zugriffsmodus testen, die Zieladresse öffnen, dort anmelden und
innerhalb von 120 Sekunden bestätigen. Tunnel/beides muss über den Tunnel,
LAN über den LAN-Zugang bestätigt werden. Der Mac-Verwaltungskanal kann diese
Bestätigung nicht ersetzen.
Ohne Bestätigung wird der vorige Modus wiederhergestellt. Gespeichert wird nur der
bestätigte Modus; ein Container-Neustart während der Testphase verwirft den Versuch.
Bei Tunnelausfall im bestätigten Tunnel-Modus bleibt der LAN-Zugang geschlossen.
Deaktivieren entfernt nur das Deck-WireGuard-Interface; der Zugriffsmodus bleibt.
Die Zugriffspolitik steuert **eingehende Verbindungen**, nicht sämtlichen ausgehenden
Verkehr des Servers. Die Host-/Container-Standardroute bleibt bestehen.
## Zugang, Installation und Wiederherstellung
Fester Zielhost für den aktuellen Prototyp: SSH `root@192.168.1.212`, vorhandener
Schlüssel `~/.ssh/athena_key`, strikte Hostschlüsselprüfung. Keine Passwörter oder
Schlüssel in Kommandozeilenargumenten, API-Antworten oder Logs.
Installation unter `/opt/athena-deck/source` und `/opt/athena-deck/state`.
Der Installer prüft Port 8110 und bindet ihn an `127.0.0.1` und die ermittelte
private LAN-Adresse; er veröffentlicht nicht auf `0.0.0.0`. Auf Athena war die
LAN-Adresse beim Test `172.21.117.202`, während `192.168.1.212` zum vorhandenen
VPN-Zugang gehört. Die tatsächliche ermittelte LAN-URL erscheint in der GUI.
Eine DHCP-Adressänderung erfordert eine gezielte Neuerstellung der Portbindung.
Ein vorhandener Container oder vorhandene Zugangsdaten werden nicht überschrieben.
Bei einem fehlgeschlagenen Erststart bleibt der geschützte Installationsstand zur
gezielten Wiederherstellung liegen. Automatische Updates/Reparatur bestehender
Installationen sind noch nicht implementiert.
Der neue Server verwendet aktuell HTTP. WireGuard schützt den Tunneltransport.
Für Einrichtung und Secrets aus entfernten Netzen den lokalen Mac-Verwaltungskanal
(SSH) verwenden. LAN-HTTP nur in einem vertrauenswürdigen Netz verwenden;
HTTPS/Reverse-Proxy-Zertifikate sind noch nicht enthalten. Passwort wird mit
PBKDF2-SHA256 (600.000 Iterationen, individuellem Salt) gespeichert. Sitzungen sind
HttpOnly/SameSite=Strict, acht Stunden gültig und werden nach Neustart ungültig.
Ein Anmeldelimit begrenzt Versuche auf zehn pro Minute. Kein Standardpasswort.
Im LAN-/Beides-Modus ist zusätzlich ein SSH-Tunnel zur Server-GUI möglich:
```sh
ssh -i /Users/mike_i386/.ssh/athena_key -o BatchMode=yes \
-N -L 8110:127.0.0.1:8110 root@192.168.1.212
```
Dann http://127.0.0.1:8110 öffnen. Dieser Zugriff zählt als LAN, nicht als
WireGuard-Bestätigung. Im Tunnel-only-Modus wird auch er gesperrt.
Die Mac-Oberfläche auf Port 8108 kann weiterhin per SSH den Helper verwalten.
Zur Wiederherstellung dort Modus LAN testen, SSH-Tunnel öffnen und dort bestätigen.
Nur den neuen Container stoppen/starten:
```sh
ssh -i ~/.ssh/athena_key -o BatchMode=yes root@192.168.1.212 \
docker stop athena-deck-network
ssh -i ~/.ssh/athena_key -o BatchMode=yes root@192.168.1.212 \
docker start athena-deck-network
```
Beim regulären Stoppen wird auch der Demo-Kindprozess beendet. Der neue Container
benötigt NET_ADMIN/NET_RAW in seinem eigenen Namespace sowie SETUID/SETGID/CHOWN
zum Absenken der Webprozess-Rechte und Einrichten des privaten Sockets. Keine
privileged-Option, kein SYS_ADMIN, keine Host-Geräte. NVIDIA ist ausschließlich
mit Treiber-Capability `utility` für Hardware-Telemetrie eingebunden.
## Importgrenzen und Secrets
- Maximal 16 KiB, genau `[Interface]` und ein `[Peer]`.
- Eine IPv4-Interface-Adresse, IPv4-AllowedIPs, Peer-Endpoint erforderlich.
- PrivateKey/PublicKey und optional PresharedKey als gültige 32-Byte-Base64-Werte.
- MTU, ListenPort und PersistentKeepalive werden geprüft; Keepalive standardmäßig 25.
- DNS wird ausdrücklich nicht übernommen; GUI meldet dies.
- PostUp/PostDown/PreUp/PreDown, SaveConfig, Table, doppelte und unbekannte
Direktiven werden abgelehnt. Kein Ausführen von `wg-quick` oder importiertem Shelltext.
- Konfiguration und Passwort-Hash unter `/data` mit 0600, Verzeichnis 0700.
Secret für `wg setconf` liegt nur kurz im Container-tmpfs und wird danach entfernt.
- Import/Entfernung nur im bestätigten LAN-Modus bei deaktiviertem Tunnel.
- Keine Anzeige oder Export privater/öffentlicher Peer-Schlüssel durch die API.
IPv6, mehrere Peers, Konfiguration ohne Endpoint (passiver VPN-Server),
DNS-Umschaltung und das Verwalten anderer produktiver Endpunkte sind noch nicht
implementiert. Diese Einschränkungen werden beim Import ausdrücklich gemeldet.
## API
Alle POSTs: JSON, `Content-Type: application/json`, `X-Athena-Deck: 1`, passender
Origin. Im Serverbetrieb zusätzlich gültige Sitzung. Host-Allowlist und
serverseitige Prüfung des tatsächlichen Zugangs gelten vor dem API-Aufruf.
| Methode/Pfad unter `/api/v1` | JSON-Inhalt |
|---|---|
| POST `/login` | `password` |
| GET `/network` | Status ohne Secrets |
| POST `/network/install` | `password` für neue Server-Instanz, nur Mac-Verwaltung |
| POST `/network/import` | `config` als Dateiinhalt |
| POST `/network/connect` | `{}` |
| POST `/network/disconnect` | `{}` |
| POST `/network/delete` | `{}` |
| POST `/network/mode` | `mode`: `lan`, `tunnel`, `both` |
| POST `/network/confirm` | `trial_id` aus Status |
| POST `/network/cancel` | `{}` |
`/network` liefert `installed`, `configured`, `enabled`, `connected`,
`latest_handshake`, `state`, `mode`, `pending`, `ingress`, URLs und ggf. Fehler.
Bei Nicht-Erreichbarkeit meldet die Mac-Ansicht ausdrücklich „noch nicht installiert
oder nicht erreichbar“; sie behauptet nicht, den Unterschied sicher zu kennen.
Anwendungsfehler geben HTTP 400, fehlende Sitzung 401, unerlaubter Host/Origin oder
Zugangsweg 403. Installation läuft als Hintergrundauftrag mit `job.state`.
## Tests am 28.09.2026
Lokale Unit-/API-Tests: `python3 -m unittest discover -s . -v`.
Parser, fehlender Handshake, falscher Bestätigungsweg, Timeout, Neustart-Policy,
Login, gefälschter Ingress-Header und bestehende Demo-Prozesssteuerung sind abgedeckt.
Zusätzlich auf Athena zwei kurzlebige Testcontainer **ohne veröffentlichte Host-Ports**:
frische Wegwerfschlüssel, echter WireGuard-Handshake, HTTP über beide Interfaces,
LAN-/Tunnel-Sperren, richtige/falsche Bestätigung, geschlossenes LAN nach Disconnect,
Neustart während unbestätigter Änderung. Testcontainer und Test-Secrets entfernt.
Startzeiten sämtlicher vorher vorhandenen Container vor/nach dem Test unverändert.
Reproduzierbarer Integrationstest (nur bewusst auf einem Linux-Docker-Testhost):
```sh
docker build -t athena-deck-network-test:20260928 -f network/Dockerfile .
python3 network/test_integration.py
```
Der echte Peer des Benutzers, seine Router-Freigaben und die persistente Installation
über die GUI sind damit noch nicht live abgenommen. Der Test ersetzt keine eigene
WireGuard-Konfiguration. Auf Athena wurde bisher nur der isolierte Test ausgeführt.
Technische Referenzen: [WireGuard Quick Start](https://www.wireguard.com/quickstart/),
[Docker Runtime Capabilities](https://docs.docker.com/engine/containers/run/).
+1
View File
@@ -0,0 +1 @@
"""Isolated networking module for Athena Deck."""
+294
View File
@@ -0,0 +1,294 @@
"""Container-only privileged helper. Never run on the host.
Owns one wg interface, one policy-routing table, and two ingress listeners.
The web child runs without root; the helper accepts only fixed operations.
"""
import http.client
import json
import os
import secrets
import signal
import socket
import socketserver
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from network.config import parse_config, wireguard_text
from network.policy import Policy
DATA = Path('/data')
RUN = Path('/run/deck')
INTERFACE = 'deckwg0'
PORT = 8110
LOCK = threading.RLock()
PROXY_TOKEN = secrets.token_hex(32)
def command(*args, input=None, check=True):
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=10)
if check and result.returncode:
raise ValueError('WireGuard-Netzwerkaktion fehlgeschlagen; Einstellungen prüfen.')
return result.stdout.strip()
def save(path, value, mode=0o600):
temporary = path.with_suffix('.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
with os.fdopen(fd, 'w') as stream:
json.dump(value, stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
class DeviceServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address, device, ingress):
self.device, self.ingress = device, ingress
super().__init__((address, PORT), Proxy, bind_and_activate=False)
try:
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, device.encode()+b'\0')
self.server_bind()
self.server_activate()
except Exception:
self.server_close()
raise
class Proxy(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
self.forward()
def do_POST(self):
self.forward()
def forward(self):
self.connection.settimeout(10)
with LOCK:
allowed = AGENT.policy.allowed(self.server.ingress)
if not allowed:
self.send_error(403, 'This network access is disabled')
return
try:
allowed_hosts = {f'{self.server.server_address[0]}:{PORT}'}
if self.server.ingress == 'lan':
allowed_hosts.update({f'{AGENT.lan_ip}:{PORT}', f'127.0.0.1:{PORT}', f'localhost:{PORT}'})
if self.headers.get('Host') not in allowed_hosts:
self.send_error(403, 'Host rejected')
return
if self.headers.get('Transfer-Encoding'):
raise ValueError()
length = int(self.headers.get('Content-Length', '0'))
if not 0 <= length <= 32768 or len(self.path)>2048:
raise ValueError()
body = self.rfile.read(length) if length else None
headers = {name: self.headers[name] for name in ('Host', 'Origin', 'Content-Type', 'Cookie', 'X-Athena-Deck') if name in self.headers}
headers.update({'X-Deck-Proxy': PROXY_TOKEN, 'X-Deck-Ingress': self.server.ingress})
conn = http.client.HTTPConnection('127.0.0.1', 8108, timeout=30)
try:
conn.request(self.command, self.path, body, headers)
response = conn.getresponse()
data = response.read(2*1024*1024)
self.send_response(response.status)
for name, value in response.getheaders():
if name.lower() not in ('connection', 'transfer-encoding', 'server', 'date', 'content-length'):
self.send_header(name, value)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
finally:
conn.close()
except (ValueError, OSError, http.client.HTTPException):
self.send_error(502, 'Deck unavailable')
class Agent:
def __init__(self):
mode_file = DATA/'mode.json'
mode = json.loads(mode_file.read_text()) if mode_file.exists() else 'lan'
self.policy = Policy(mode=mode, persist=lambda value: save(mode_file, value))
self.tunnel_server = None
self.address = None
self.error = None
self.lan_ip = os.environ.get('DECK_LAN_IP', '')
def connected(self):
try:
raw = command('wg', 'show', INTERFACE, 'latest-handshakes', check=False)
values = [int(line.split()[1]) for line in raw.splitlines()]
latest = max(values, default=0)
return latest > 0 and 0 <= time.time()-latest < 180, latest or None
except (ValueError, OSError, subprocess.SubprocessError):
return False, None
def status(self, ingress='management'):
connected, latest = self.connected()
config_file = DATA/'config.json'
config = parse_config(json.loads(config_file.read_text())) if config_file.exists() else None
return dict(installed=True, configured=config is not None, enabled=self.address is not None,
connected=connected, latest_handshake=latest, state='connected' if connected else ('connecting' if self.address else 'disabled'),
tunnel_url=f'http://{self.address}:{PORT}' if self.address else None,
lan_url=f'http://{self.lan_ip}:{PORT}' if self.lan_ip else None,
warnings=config['warnings'] if config else [], error=self.error,
ingress=ingress, **self.policy.status())
def disconnect(self):
if self.tunnel_server:
self.tunnel_server.shutdown()
self.tunnel_server.server_close()
self.tunnel_server = None
command('ip', 'link', 'delete', INTERFACE, check=False)
command('ip', '-4', 'rule', 'del', 'priority', '20000', check=False)
command('ip', '-4', 'route', 'flush', 'table', '51820', check=False)
self.address = None
def connect(self):
if self.address:
return
path = DATA/'config.json'
if not path.exists():
raise ValueError('Zuerst eine eigene WireGuard-Konfiguration importieren.')
config = parse_config(json.loads(path.read_text()))
# Avoid ambiguity with the LAN listener or loopback, even for unusual uploads.
eth = json.loads(command('ip', '-j', '-4', 'addr', 'show', 'dev', 'eth0'))
if any(a['local'] == config['address'] for i in eth for a in i.get('addr_info', [])):
raise ValueError('Tunneladresse kollidiert mit der Containeradresse.')
try:
command('ip', 'link', 'add', INTERFACE, 'type', 'wireguard')
secret = RUN/'wireguard.conf'
fd = os.open(secret, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, 'w') as stream:
stream.write(wireguard_text(config))
command('wg', 'setconf', INTERFACE, str(secret))
finally:
secret.unlink(missing_ok=True)
command('ip', '-4', 'addr', 'add', config['address']+'/32', 'dev', INTERFACE)
command('ip', 'link', 'set', INTERFACE, 'mtu', str(config['mtu']), 'up')
# Only packets sourced from the tunnel address use these routes.
# Never change the host or container main/default route.
for network in config['allowed_ips']:
command('ip', '-4', 'route', 'replace', network, 'dev', INTERFACE, 'table', '51820')
command('ip', '-4', 'rule', 'add', 'priority', '20000', 'from', config['address']+'/32', 'lookup', '51820')
self.tunnel_server = DeviceServer(config['address'], INTERFACE, 'tunnel')
threading.Thread(target=self.tunnel_server.serve_forever, daemon=True).start()
self.address = config['address']
self.error = None
except Exception:
self.disconnect()
raise ValueError('Tunnel konnte nicht eingerichtet werden. Keine Host-Konfiguration wurde geändert.') from None
def dispatch(self, data):
action = data.get('action')
ingress = data.get('ingress', 'management')
# Ingress may only be asserted by the web child using the private proxy token.
if not secrets.compare_digest(str(data.get('proxy_token', '')), PROXY_TOKEN):
ingress = 'management'
if action == 'status':
return self.status(ingress)
if action == 'import':
self.policy.expire()
if self.address or self.policy.mode != 'lan' or self.policy.pending:
raise ValueError('Import ist nur im bestätigten LAN-Modus bei deaktiviertem Tunnel möglich.')
config = parse_config(data.get('config'))
save(DATA/'config.json', data['config'])
self.error = None
elif action == 'connect':
self.connect()
save(DATA/'enabled.json', True)
elif action == 'disconnect':
self.disconnect()
save(DATA/'enabled.json', False)
elif action == 'mode':
self.policy.propose(data.get('mode'), self.connected()[0])
elif action == 'confirm':
self.policy.confirm(data.get('trial_id'), ingress)
elif action == 'cancel':
self.policy.cancel()
elif action == 'delete':
if self.address or self.policy.mode != 'lan' or self.policy.pending:
raise ValueError('Löschen ist nur bei deaktiviertem Tunnel im bestätigten LAN-Modus möglich.')
(DATA/'config.json').unlink(missing_ok=True)
else:
raise ValueError('Unbekannte Netzwerkaktion.')
return self.status(ingress)
class RPC(socketserver.StreamRequestHandler):
def handle(self):
self.connection.settimeout(30)
try:
raw = self.rfile.readline(32769)
if len(raw)>32768:
raise ValueError('Anfrage zu groß.')
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError('Ungültige Anfrage.')
with LOCK:
result = AGENT.dispatch(data)
except ValueError as exc:
# Parser errors are deliberately generic; never return submitted values.
result = {'error': str(exc) if not isinstance(exc, json.JSONDecodeError) else 'Ungültige Anfrage.'}
except Exception:
result = {'error': 'Netzwerkaktion fehlgeschlagen.'}
self.wfile.write(json.dumps(result).encode()+b'\n')
class RPCServer(socketserver.ThreadingUnixStreamServer):
daemon_threads = True
def main():
global AGENT
if not Path('/.dockerenv').exists() or os.environ.get('DECK_CONTAINER') != '1':
raise SystemExit('This helper must run in its isolated Deck container.')
os.umask(0o077)
DATA.mkdir(exist_ok=True)
DATA.chmod(0o700)
RUN.mkdir(exist_ok=True)
RUN.chmod(0o755)
path = RUN/'control.sock'
path.unlink(missing_ok=True)
AGENT = Agent()
rpc = RPCServer(str(path), RPC)
os.chown(path, 0, 65534)
path.chmod(0o660)
threading.Thread(target=rpc.serve_forever, daemon=True).start()
auth = DATA/'auth.json'
if not auth.exists():
raise SystemExit('Authentication must be provisioned before container startup.')
env = os.environ.copy()
env.update(DECK_PROXY_TOKEN=PROXY_TOKEN, DECK_AUTH_JSON=auth.read_text(), DECK_NETWORK_SOCKET='1', DECK_LOCAL_HARDWARE='1')
child = subprocess.Popen([sys.executable, '/app/server.py'], env=env, user=65534, group=65534, extra_groups=[], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
address = json.loads(command('ip', '-j', '-4', 'addr', 'show', 'dev', 'eth0'))[0]['addr_info'][0]['local']
lan = DeviceServer(address, 'eth0', 'lan')
threading.Thread(target=lan.serve_forever, daemon=True).start()
if (DATA/'enabled.json').exists() and json.loads((DATA/'enabled.json').read_text()):
try:
AGENT.connect()
except ValueError:
AGENT.error = 'Tunnel nach Neustart nicht verfügbar; gewählter Zugriffsmodus bleibt bestehen.'
done = threading.Event()
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, lambda *_: done.set())
try:
while not done.wait(1):
with LOCK:
AGENT.policy.expire()
if child.poll() is not None:
break
finally:
lan.shutdown()
rpc.shutdown()
AGENT.disconnect()
child.terminate()
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
if __name__ == '__main__':
main()
+94
View File
@@ -0,0 +1,94 @@
"""Local management via fixed SSH target or container-private Unix RPC."""
import base64
import hashlib
import json
import os
from pathlib import Path
import secrets
import subprocess
import threading
import time
ROOT = Path(__file__).resolve().parent.parent
SSH = ['ssh', '-i', str(Path.home()/'.ssh/athena_key'), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes', 'root@192.168.1.212']
class NetworkClient:
def __init__(self):
self.local = os.environ.get('DECK_NETWORK_SOCKET') == '1'
self.job = None
self.lock = threading.Lock()
self.cached = None
self.cached_at = 0
def call(self, action, values=None, ingress='management'):
data = dict(values or {}, action=action)
if self.local:
from network.rpc import request
data.update(ingress=ingress, proxy_token=os.environ.get('DECK_PROXY_TOKEN', ''))
return request(data)
result = subprocess.run(SSH + ['docker exec -i athena-deck-network python3 -m network.rpc'], input=json.dumps(data), text=True, capture_output=True, timeout=35)
try:
value = json.loads(result.stdout)
except ValueError:
raise ValueError('Deck-Netzwerkmodul auf Athena nicht erreichbar oder noch nicht installiert.') from None
if result.returncode or ('error' in value and 'installed' not in value):
raise ValueError(value.get('error', 'Netzwerkaktion fehlgeschlagen.'))
self.cached = None
return value
def status(self, ingress='management'):
with self.lock:
if self.job and self.job['state'] == 'running':
return dict(installed=False, state='installing', job=self.job.copy(), ingress=ingress)
if self.cached is not None and time.monotonic()-self.cached_at < 3:
return dict(self.cached, ingress=ingress, job=self.job)
try:
result = self.call('status', ingress=ingress)
result['reachable'] = True
except (ValueError, OSError, subprocess.SubprocessError):
result = dict(installed=False, reachable=False, state='unavailable', error='Netzwerkmodul noch nicht installiert oder Athena nicht erreichbar.')
self.cached = result
self.cached_at = time.monotonic()
return dict(result, ingress=ingress, job=self.job)
def install(self, password):
if self.local:
raise ValueError('Die Server-Instanz ist bereits installiert.')
if not isinstance(password, str) or not 16 <= len(password) <= 256:
raise ValueError('Bitte ein eigenes Deck-Passwort mit mindestens 16 Zeichen setzen.')
with self.lock:
if self.job and self.job['state'] == 'running':
raise ValueError('Installation läuft bereits.')
salt = secrets.token_bytes(16)
auth = dict(salt=salt.hex(), hash=hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600000).hex())
self.job = dict(state='running', message='Eigener Deck-Container wird gebaut und gestartet. Das kann mehrere Minuten dauern.')
threading.Thread(target=self._install, args=(auth,), daemon=True).start()
return dict(job=self.job.copy())
def _install(self, auth):
try:
# Import the source allowlist without executing the installer entrypoint.
from network.install_remote import FILES
payload = dict(auth=auth, files={name:base64.b64encode((ROOT/name).read_bytes()).decode() for name in FILES})
script = (ROOT/'network/install_remote.py').read_text()
# Remote command contains only fixed trusted program text, never user input.
import shlex
command = 'python3 -c ' + shlex.quote(script)
result = subprocess.run(SSH+[command], input=json.dumps(payload), text=True, capture_output=True, timeout=900)
value = json.loads(result.stdout)
if result.returncode or ('error' in value and 'installed' not in value):
raise ValueError(value.get('error', 'Installation fehlgeschlagen.'))
# Confirm helper readiness; don't report a successful installation from docker run alone.
for _ in range(20):
try:
self.call('status')
break
except (ValueError, OSError, subprocess.SubprocessError):
time.sleep(1)
else:
raise ValueError('Container angelegt, aber Netzwerk-Helper nicht bereit. Installation prüfen.')
self.job = dict(state='complete', message='Netzwerkmodul installiert. Jetzt eine eigene WireGuard-Konfiguration importieren.')
except Exception as exc:
self.job = dict(state='failed', message=str(exc) if isinstance(exc, ValueError) and not isinstance(exc, json.JSONDecodeError) else 'Installation fehlgeschlagen; SSH und Docker prüfen.')
finally:
self.cached = None
+78
View File
@@ -0,0 +1,78 @@
"""Strict, deliberately small WireGuard client configuration grammar."""
import base64
import ipaddress
import re
class ConfigError(ValueError):
pass
def parse_config(text):
if not isinstance(text, str) or len(text.encode('utf-8')) > 16384:
raise ConfigError('Die Konfiguration darf höchstens 16 KiB groß sein.')
sections = {}
current = None
for line in text.splitlines():
line = line.split('#', 1)[0].strip()
if not line:
continue
if line.startswith('['):
if line not in ('[Interface]', '[Peer]') or line in sections:
raise ConfigError('Genau ein Interface und ein Peer werden unterstützt.')
current = sections.setdefault(line, {})
continue
if current is None or '=' not in line:
raise ConfigError('Ungültiges WireGuard-Dateiformat.')
name, value = (part.strip() for part in line.split('=', 1))
allowed = {'PrivateKey', 'Address', 'DNS', 'MTU', 'ListenPort'} if current is sections.get('[Interface]') else {'PublicKey', 'PresharedKey', 'AllowedIPs', 'Endpoint', 'PersistentKeepalive'}
if name not in allowed or name in current:
raise ConfigError('Unbekannte oder doppelte Direktive. Hooks, Table und SaveConfig sind nicht erlaubt.')
if not value or any(ord(c) < 32 for c in value):
raise ConfigError('Leerer oder ungültiger Konfigurationswert.')
current[name] = value
interface, peer = sections.get('[Interface]', {}), sections.get('[Peer]', {})
if not {'PrivateKey', 'Address'} <= interface.keys() or not {'PublicKey', 'AllowedIPs', 'Endpoint'} <= peer.keys():
raise ConfigError('PrivateKey, Address, PublicKey, AllowedIPs und Endpoint sind erforderlich.')
for section, key in ((interface, 'PrivateKey'), (peer, 'PublicKey'), (peer, 'PresharedKey')):
if key in section:
try:
decoded = base64.b64decode(section[key], validate=True)
if len(decoded) != 32 or not any(decoded):
raise ValueError()
except ValueError:
raise ConfigError('Ein WireGuard-Schlüssel hat ein ungültiges Format.') from None
try:
addresses = [ipaddress.ip_interface(v.strip()) for v in interface['Address'].split(',')]
networks = [ipaddress.ip_network(v.strip(), strict=False) for v in peer['AllowedIPs'].split(',')]
if len(addresses) != 1 or addresses[0].version != 4 or any(n.version != 4 for n in networks):
raise ConfigError('Diese Version unterstützt eine IPv4-Tunneladresse und IPv4-AllowedIPs.')
if addresses[0].ip.is_loopback or addresses[0].ip.is_unspecified or addresses[0].ip.is_multicast:
raise ValueError()
if len(networks) > 32:
raise ValueError()
endpoint, port = peer['Endpoint'].rsplit(':', 1)
if not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?', endpoint):
raise ValueError()
if not 1 <= int(port) <= 65535:
raise ValueError()
mtu = int(interface.get('MTU', '1420'))
keepalive = int(peer.get('PersistentKeepalive', '25'))
listen = int(interface.get('ListenPort', '0'))
if not 576 <= mtu <= 9000 or not 0 <= keepalive <= 65535 or not 0 <= listen <= 65535:
raise ValueError()
except (ValueError, KeyError) as exc:
if isinstance(exc, ConfigError):
raise
raise ConfigError('Ungültige Adresse, Endpoint, Port, MTU oder Keepalive.') from None
warnings = ['DNS wird nicht übernommen; die Container-DNS-Auflösung bleibt bestehen.'] if 'DNS' in interface else []
return dict(interface=interface, peer=peer, address=str(addresses[0].ip), allowed_ips=[str(n) for n in networks], mtu=mtu, keepalive=keepalive, listen=listen, warnings=warnings)
def wireguard_text(config):
"""Never executed as shell or wg-quick input; excludes all hooks and routes."""
lines = ['[Interface]', 'PrivateKey = ' + config['interface']['PrivateKey'], 'ListenPort = ' + str(config['listen']), '[Peer]']
for key in ('PublicKey', 'PresharedKey', 'Endpoint'):
if key in config['peer']:
lines.append(key + ' = ' + config['peer'][key])
lines.extend(['AllowedIPs = ' + ', '.join(config['allowed_ips']), 'PersistentKeepalive = ' + str(config['keepalive'])])
return '\n'.join(lines) + '\n'
+79
View File
@@ -0,0 +1,79 @@
"""Receives a source-only JSON bundle on stdin over SSH. No secrets in argv/logs.
Creates only /opt/athena-deck and the separately named Deck container.
"""
import base64
import ipaddress
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
NAME = 'athena-deck-network'
BASE = Path('/opt/athena-deck')
FILES = {'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
def run(*args, **kwargs):
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
def main():
payload = json.load(sys.stdin)
if set(payload['files']) != FILES:
raise ValueError()
if not Path('/sys/module/wireguard').exists():
raise ValueError('WireGuard-Kernelunterstützung fehlt. Es werden keine Host-Pakete oder Kernel installiert.')
if run('docker', 'version').returncode:
raise ValueError('Docker ist nicht verfügbar. Host-Installation erfolgt nicht automatisch.')
if not run('docker', 'inspect', NAME).returncode:
raise ValueError('Deck-Container existiert bereits; keine Überschreibung.')
routes = json.loads(run('ip', '-j', '-4', 'route', 'get', '1.1.1.1').stdout)
lan = routes[0].get('prefsrc')
address = ipaddress.ip_address(lan)
if not address.is_private or address.is_loopback:
raise ValueError('Keine private LAN-Adresse ermittelt; Installation abgebrochen.')
# Preflight host port; Docker performs the definitive collision check.
for addr in ('127.0.0.1', lan):
with socket.socket() as sock:
sock.bind((addr, 8110))
BASE.mkdir(mode=0o700, exist_ok=True)
if (BASE/'state/auth.json').exists():
raise ValueError('Vorhandene Deck-Zugangsdaten werden nicht überschrieben.')
source = BASE/'source'
source.mkdir(mode=0o755, exist_ok=True)
for name, encoded in payload['files'].items():
target = source/name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(encoded, validate=True))
target.chmod(0o644)
result = run('docker', 'build', '-t', 'athena-deck-network:0.2', '-f', str(source/'network/Dockerfile'), str(source))
if result.returncode:
raise ValueError('Container-Build fehlgeschlagen. Docker benötigt Zugriff auf die Paketquellen.')
state = BASE/'state'
state.mkdir(mode=0o700, exist_ok=True)
auth = payload['auth']
if set(auth) != {'salt', 'hash'} or len(auth['salt']) != 32 or len(auth['hash']) != 64:
raise ValueError('Ungültige Zugangsdaten.')
int(auth['salt'], 16)
int(auth['hash'], 16)
fd = os.open(state/'auth.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as stream:
json.dump(auth, stream)
result = run('docker', 'run', '-d', '--name', NAME, '--restart', 'unless-stopped',
'--gpus', 'all', '-e', 'NVIDIA_DRIVER_CAPABILITIES=utility', '--read-only', '--cap-drop', 'ALL', '--cap-add', 'NET_ADMIN', '--cap-add', 'NET_RAW', '--cap-add', 'SETUID', '--cap-add', 'SETGID', '--cap-add', 'CHOWN',
'--security-opt', 'no-new-privileges:true', '--pids-limit', '128', '--memory', '256m', '--cpus', '0.5',
'--tmpfs', '/run:rw,nosuid,nodev,size=8m', '--tmpfs', '/tmp:rw,nosuid,nodev,size=8m',
'-v', str(state)+':/data:rw', '-e', 'DECK_LAN_IP='+lan,
'-p', '127.0.0.1:8110:8110', '-p', lan+':8110:8110', 'athena-deck-network:0.2')
if result.returncode:
# Auth remains protected for manual recovery; never delete an existing container.
raise ValueError('Containerstart fehlgeschlagen. Gesicherter Installationsstand liegt unter /opt/athena-deck.')
print(json.dumps({'installed':True, 'lan_url':f'http://{lan}:8110'}))
if __name__ == '__main__':
try:
main()
except Exception as exc:
print(json.dumps({'error':str(exc) if isinstance(exc, ValueError) and str(exc) else 'Installation fehlgeschlagen; keine produktiven Dienste wurden verändert.'}))
sys.exit(1)
+53
View File
@@ -0,0 +1,53 @@
"""Access policy: persisted confirmed mode, monotonic trial deadline."""
import secrets
import time
class Policy:
MODES = ('lan', 'tunnel', 'both')
def __init__(self, mode='lan', clock=time.monotonic, persist=lambda mode: None):
self.mode = mode if mode in self.MODES else 'lan'
self.clock = clock
self.persist = persist
self.pending = None
def expire(self):
if self.pending and self.clock() >= self.pending['deadline']:
self.mode = self.pending['previous']
self.pending = None
def allowed(self, ingress):
self.expire()
return self.mode == 'both' or self.mode == ingress
def propose(self, mode, connected):
self.expire()
if mode not in self.MODES:
raise ValueError('Ungültiger Zugriffsmodus.')
if self.pending:
raise ValueError('Zuerst den laufenden Zugriffsversuch bestätigen oder zurücknehmen.')
if mode == self.mode:
return
if mode in ('tunnel', 'both') and not connected:
raise ValueError('Zuerst WireGuard verbinden und einen aktuellen Handshake abwarten.')
self.pending = dict(previous=self.mode, target=mode, deadline=self.clock()+120, id=secrets.token_urlsafe(24))
self.mode = mode
def confirm(self, trial_id, ingress):
self.expire()
if not self.pending or not secrets.compare_digest(self.pending['id'], str(trial_id)):
raise ValueError('Kein passender laufender Zugriffsversuch.')
needed = 'tunnel' if self.mode in ('tunnel', 'both') else 'lan'
if ingress != needed:
raise ValueError('Bestätigung muss über den ausgewählten Netzwerkzugang erfolgen.')
self.persist(self.mode)
self.pending = None
def cancel(self):
if self.pending:
self.mode = self.pending['previous']
self.pending = None
def status(self):
self.expire()
return dict(mode=self.mode, pending=None if not self.pending else dict(id=self.pending['id'], target=self.pending['target'], previous=self.pending['previous'], seconds_remaining=max(0, int(self.pending['deadline']-self.clock()))))
+28
View File
@@ -0,0 +1,28 @@
"""Bounded JSON RPC over a private Unix socket; no shell commands exposed."""
import json
import socket
import sys
SOCKET = '/run/deck/control.sock'
def request(data):
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(25)
sock.connect(SOCKET)
sock.sendall(json.dumps(data).encode()+b'\n')
with sock.makefile('rb') as stream:
raw = stream.readline(65537)
if len(raw)>65536:
raise ValueError('Ungültige Helper-Antwort.')
result = json.loads(raw)
if 'error' in result and 'installed' not in result:
raise ValueError(result['error'])
return result
if __name__ == '__main__':
try:
data = json.loads(sys.stdin.buffer.readline(32769))
print(json.dumps(request(data)))
except Exception:
print(json.dumps({'error':'Netzwerkaktion fehlgeschlagen. Konfiguration und Verbindung prüfen.'}))
sys.exit(1)
+142
View File
@@ -0,0 +1,142 @@
"""Run explicitly on a Linux Docker host; creates two disposable containers.
No published ports, no production endpoints, fresh throwaway keys kept in memory.
"""
import hashlib
import json
import os
from pathlib import Path
import secrets
import subprocess
import tempfile
import time
IMAGE='athena-deck-network-test:20260928'
DECK='athena-deck-test-runtime'
PEER='athena-deck-test-peer'
def run(*args, data=None, check=True):
result=subprocess.run(args,input=data,text=True,capture_output=True,timeout=45)
if check and result.returncode:
raise RuntimeError('Test command failed: '+args[0]+' (output redacted)')
return result.stdout.strip()
def execute(container, script, value=None):
payload=json.dumps(value) if value is not None else None
result=run('docker','exec','-i',container,'python3','-c',script,data=payload)
return json.loads(result) if result else None
def rpc(action, **values):
result=execute(DECK,"import json,sys;from network.rpc import request;print(json.dumps(request(json.load(sys.stdin))))",dict(action=action,**values))
return result
HTTP = '''import http.client,json,sys
v=json.load(sys.stdin)
c=http.client.HTTPConnection(v['host'],8110,timeout=5)
h={'Content-Type':'application/json','X-Athena-Deck':'1'}
if v.get('cookie'):h['Cookie']=v['cookie']
if v.get('origin'):h['Origin']=v['origin']
c.request(v.get('method','GET'),v.get('path','/'),json.dumps(v['body']) if 'body' in v else None,h)
r=c.getresponse();b=r.read().decode()
print(json.dumps({'status':r.status,'body':b,'cookie':r.getheader('Set-Cookie')}))
'''
def http(host,path='/',method='GET',body=None,cookie=None):
value=dict(host=host,path=path,method=method,cookie=cookie)
if body is not None:value['body']=body
return execute(PEER,HTTP,value)
def main():
for name in (DECK,PEER):
if run('docker','ps','-aq','--filter','name=^/'+name+'$'):
raise RuntimeError('Test container name already exists; refusing takeover')
ids=run('docker','ps','-aq').splitlines()
baseline=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
state=tempfile.TemporaryDirectory(prefix='athena-deck-test-')
password=secrets.token_urlsafe(32)
salt=secrets.token_bytes(16)
auth=dict(salt=salt.hex(),hash=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex())
p=Path(state.name)/'auth.json';p.write_text(json.dumps(auth));p.chmod(0o600)
try:
run('docker','run','-d','--name',PEER,'--cap-drop','ALL','--cap-add','NET_ADMIN',IMAGE,'sleep','600')
run('docker','run','-d','--name',DECK,'--read-only','--cap-drop','ALL','--cap-add','NET_ADMIN','--cap-add','NET_RAW','--cap-add','SETUID','--cap-add','SETGID','--cap-add','CHOWN','--security-opt','no-new-privileges:true','--tmpfs','/run:rw,nosuid,nodev,size=8m','--tmpfs','/tmp:rw,nosuid,nodev,size=8m','-v',state.name+':/data',IMAGE)
for _ in range(20):
try:
assert rpc('status')['state']=='disabled'
break
except Exception:time.sleep(.5)
else:raise RuntimeError('Helper did not become ready')
print('PASS helper startup and unprivileged web child',flush=True)
uid=execute(DECK,"import json;from pathlib import Path;print(json.dumps([p.read_text().split('Uid:')[1].splitlines()[0].split()[0] for p in Path('/proc').glob('[0-9]*/status') if 'Name:\\tpython' in p.read_text()]))")
assert '65534' in uid
deckip=run('docker','inspect','--format','{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}',DECK)
peerip=run('docker','inspect','--format','{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}',PEER)
assert http(deckip,'/api/v1/status')['status']==401
login=http(deckip,'/api/v1/login','POST',{'password':password})
assert login['status']==200
cookie=login['cookie'].split(';')[0]
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200
print('PASS real LAN login and authenticated API',flush=True)
# Keys stay in this Python process and stdin pipes; never printed.
private_a=run('docker','exec',PEER,'wg','genkey')
private_b=run('docker','exec',PEER,'wg','genkey')
public_a=run('docker','exec','-i',PEER,'wg','pubkey',data=private_a+'\n')
public_b=run('docker','exec','-i',PEER,'wg','pubkey',data=private_b+'\n')
conf=f'[Interface]\nPrivateKey = {private_a}\nAddress = 10.240.77.1/32\nListenPort = 51822\n[Peer]\nPublicKey = {public_b}\nEndpoint = {peerip}:51823\nAllowedIPs = 10.240.77.2/32\nPersistentKeepalive = 1\n'
status=rpc('import',config=conf)
assert private_a not in json.dumps(status) and public_b not in json.dumps(status)
peerconf=f'[Interface]\nPrivateKey = {private_b}\nListenPort = 51823\n[Peer]\nPublicKey = {public_a}\nEndpoint = {deckip}:51822\nAllowedIPs = 10.240.77.1/32\nPersistentKeepalive = 1\n'
execute(PEER,'''import json,sys,subprocess,os
v=json.load(sys.stdin)
def cmd(*a):subprocess.run(a,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
cmd('ip','link','add','peerwg0','type','wireguard')
fd=os.open('/tmp/peer.conf',os.O_WRONLY|os.O_CREAT,0o600)
with os.fdopen(fd,'w') as f:f.write(v)
cmd('wg','setconf','peerwg0','/tmp/peer.conf');os.unlink('/tmp/peer.conf')
cmd('ip','addr','add','10.240.77.2/32','dev','peerwg0')
cmd('ip','link','set','peerwg0','up')
cmd('ip','route','add','10.240.77.1/32','dev','peerwg0')
''',peerconf)
rpc('connect')
for _ in range(20):
if rpc('status')['connected']:break
time.sleep(.5)
else:raise RuntimeError('No real WireGuard handshake')
assert http('10.240.77.1','/api/v1/status',cookie=cookie)['status']==403
print('PASS actual WireGuard handshake; LAN-only blocks tunnel ingress',flush=True)
status=rpc('mode',mode='both');trial=status['pending']['id']
assert http('10.240.77.1','/api/v1/status',cookie=cookie)['status']==200
assert http(deckip,'/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==400
assert http('10.240.77.1','/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==200
print('PASS both paths; only actual tunnel ingress can confirm',flush=True)
status=rpc('mode',mode='tunnel');trial=status['pending']['id']
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==403
assert http('10.240.77.1','/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==200
rpc('disconnect')
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==403
assert rpc('status')['mode']=='tunnel'
print('PASS tunnel-only closes LAN and stays closed after disconnect',flush=True)
status=rpc('mode',mode='lan');trial=status['pending']['id']
assert http(deckip,'/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==200
rpc('connect')
for _ in range(20):
if rpc('status')['connected']:break
time.sleep(.5)
rpc('mode',mode='tunnel')
run('docker','restart',DECK)
for _ in range(20):
try:
status=rpc('status')
break
except Exception:time.sleep(.5)
assert status['mode']=='lan' and not status['pending']
print('PASS restart discards unconfirmed trial and restores confirmed mode',flush=True)
after=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
assert baseline==after
print('PASS production container start times unchanged',flush=True)
finally:
for name in (DECK,PEER):run('docker','rm','-f',name,check=False)
state.cleanup()
if __name__=='__main__':main()
+122 -3
View File
@@ -1,6 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Athena Deck prototype: loopback API, owned demo process, read-only telemetry.""" """Athena Deck prototype: loopback API, owned demo process, read-only telemetry."""
import argparse import argparse
import os
import hashlib
import secrets
from http.cookies import SimpleCookie
from network.client import NetworkClient
from network.config import parse_config, ConfigError
import json import json
import signal import signal
import socket import socket
@@ -30,7 +36,7 @@ class DemoService:
reachable = json.load(response).get('service') == 'athena-deck-demo' reachable = json.load(response).get('service') == 'athena-deck-demo'
except (OSError, ValueError): except (OSError, ValueError):
pass pass
return dict(state='running' if running else 'stopped', reachable=reachable, pid=self.process.pid if running else None, port=self.port if running else None, location='Mac · lokaler Demo-Prozess') return dict(state='running' if running else 'stopped', reachable=reachable, pid=self.process.pid if running else None, port=self.port if running else None, location='Deck · isolierter Demo-Prozess')
def start(self): def start(self):
with self.lock: with self.lock:
@@ -64,6 +70,13 @@ class HardwareProvider:
self.checked = 0 self.checked = 0
def snapshot(self): def snapshot(self):
if os.environ.get('DECK_LOCAL_HARDWARE') == '1':
from collect_hardware import collect
with self.lock:
if self.cached is None or time.monotonic()-self.checked >= 5:
self.cached = dict(collect(), available=True)
self.checked = time.monotonic()
return self.cached
with self.lock: with self.lock:
if time.monotonic() - self.checked < 5 and self.cached is not None: if time.monotonic() - self.checked < 5 and self.cached is not None:
return self.cached return self.cached
@@ -83,6 +96,11 @@ class Server(ThreadingHTTPServer):
self.demo = DemoService() self.demo = DemoService()
self.hardware = HardwareProvider() self.hardware = HardwareProvider()
self.started = time.time() self.started = time.time()
self.network = NetworkClient()
self.auth = json.loads(os.environ['DECK_AUTH_JSON']) if os.environ.get('DECK_AUTH_JSON') else None
self.sessions = {}
self.login_attempts = []
self.auth_lock = threading.Lock()
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): def log_message(self, *args):
@@ -99,20 +117,97 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def ingress(self):
token = os.environ.get('DECK_PROXY_TOKEN', '')
if token and secrets.compare_digest(self.headers.get('X-Deck-Proxy', ''), token):
return self.headers.get('X-Deck-Ingress', 'management')
return 'management'
def authenticated(self):
if self.server.auth is None:
return True
try:
cookie = SimpleCookie(self.headers.get('Cookie', ''))
token = cookie['deck_session'].value
except (KeyError, ValueError):
return False
with self.server.auth_lock:
return self.server.sessions.get(token, 0) > time.monotonic()
def read_json(self):
self.connection.settimeout(10)
if self.headers.get('Transfer-Encoding'):
raise ValueError('Chunked Uploads werden nicht unterstützt.')
length = int(self.headers.get('Content-Length', '0'))
if not 0 < length <= 32768 or self.headers.get('Content-Type') != 'application/json':
raise ValueError('JSON-Anfrage erwartet, maximal 32 KiB.')
raw = self.rfile.read(length)
try:
value = json.loads(raw)
except (ValueError, UnicodeDecodeError):
raise ValueError('Ungültige JSON-Anfrage.') from None
if not isinstance(value, dict):
raise ValueError('JSON-Objekt erwartet.')
return value
def login(self):
if self.server.auth is None:
return self.respond({'authenticated': True})
try:
data = self.read_json()
password = data.get('password', '')
if not isinstance(password, str) or len(password)>256:
raise ValueError()
with self.server.auth_lock:
now = time.monotonic()
self.server.login_attempts = [t for t in self.server.login_attempts if now-t<60]
if len(self.server.login_attempts)>=10:
return self.respond({'error':'Zu viele Anmeldeversuche. Bitte eine Minute warten.'}, 429)
self.server.login_attempts.append(now)
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(self.server.auth['salt']), 600000).hex()
if not secrets.compare_digest(actual, self.server.auth['hash']):
return self.respond({'error':'Anmeldung fehlgeschlagen.'}, 401)
token = secrets.token_urlsafe(32)
with self.server.auth_lock:
self.server.sessions = {k:v for k,v in self.server.sessions.items() if v>now}
if len(self.server.sessions)>=64:
self.server.sessions.pop(next(iter(self.server.sessions)))
self.server.sessions[token] = now+28800
body = b'{"authenticated":true}'
self.send_response(200)
self.send_header('Content-Type','application/json')
self.send_header('Cache-Control','no-store')
self.send_header('Content-Length',str(len(body)))
self.send_header('Set-Cookie',f'deck_session={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800')
self.end_headers()
self.wfile.write(body)
except (ValueError, OSError):
self.respond({'error':'Ungültige Anmeldung.'},400)
def valid_host(self): def valid_host(self):
if os.environ.get('DECK_PROXY_TOKEN'):
return self.ingress() in ('lan','tunnel')
return self.headers.get('Host') in (f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}') return self.headers.get('Host') in (f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}')
def do_GET(self): def do_GET(self):
if not self.valid_host(): if not self.valid_host():
return self.respond({'error': 'Host rejected'}, 403) return self.respond({'error': 'Host rejected'}, 403)
routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css')} if self.path == '/login.js':
return self.respond((ROOT/'login.js').read_bytes(), mime='text/javascript')
if not self.authenticated() and self.path not in ('/', '/style.css'):
return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript')}
if self.path in routes: if self.path in routes:
name, mime = routes[self.path] name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime) return self.respond((ROOT/name).read_bytes(), mime=mime)
if self.path == '/api/v1/status': if self.path == '/api/v1/status':
return self.respond(dict(name='Athena Deck', version='0.1.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', demo=self.server.demo.status())) return self.respond(dict(name='Athena Deck', version='0.2.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location='Athena' if self.server.auth else 'Mac', demo=self.server.demo.status()))
if self.path == '/api/v1/hardware': if self.path == '/api/v1/hardware':
return self.respond(self.server.hardware.snapshot()) return self.respond(self.server.hardware.snapshot())
if self.path == '/api/v1/network':
return self.respond(self.server.network.status(self.ingress()))
if self.path == '/api/v1/demo': if self.path == '/api/v1/demo':
return self.respond(self.server.demo.status()) return self.respond(self.server.demo.status())
return self.respond({'error': 'Not found'}, 404) return self.respond({'error': 'Not found'}, 404)
@@ -121,6 +216,30 @@ class Handler(BaseHTTPRequestHandler):
origin = self.headers.get('Origin') origin = self.headers.get('Origin')
if not self.valid_host() or self.headers.get('X-Athena-Deck') != '1' or (origin and origin != 'http://' + self.headers.get('Host', '')): if not self.valid_host() or self.headers.get('X-Athena-Deck') != '1' or (origin and origin != 'http://' + self.headers.get('Host', '')):
return self.respond({'error': 'Same-origin control required'}, 403) return self.respond({'error': 'Same-origin control required'}, 403)
if self.path == '/api/v1/login':
return self.login()
if not self.authenticated():
return self.respond({'error':'Anmeldung erforderlich.'},401)
if self.path.startswith('/api/v1/network/'):
action = self.path.rsplit('/',1)[1]
if action not in ('install','import','connect','disconnect','mode','confirm','cancel','delete'):
return self.respond({'error':'Not found'},404)
try:
data = self.read_json()
if action == 'install':
result = self.server.network.install(data.get('password'))
else:
fields = {'import': {'config'}, 'mode': {'mode'}, 'confirm': {'trial_id'}}.get(action,set())
if set(data) != fields:
raise ValueError('Ungültige Netzwerkparameter.')
if action == 'import':
parse_config(data.get('config'))
result = self.server.network.call(action, data, self.ingress())
self.server.network.cached = None
return self.respond(result)
except (ValueError, OSError, subprocess.SubprocessError) as exc:
message = str(exc) if isinstance(exc, ValueError) else 'Netzwerkmodul nicht erreichbar.'
return self.respond({'error':message},400)
if self.path not in ('/api/v1/demo/start', '/api/v1/demo/stop'): if self.path not in ('/api/v1/demo/start', '/api/v1/demo/stop'):
return self.respond({'error': 'Not found'}, 404) return self.respond({'error': 'Not found'}, 404)
action = self.server.demo.start if self.path.endswith('/start') else self.server.demo.stop action = self.server.demo.start if self.path.endswith('/start') else self.server.demo.stop
+1
View File
@@ -1 +1,2 @@
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#101416;color:#e9edea}*{box-sizing:border-box}body{margin:0;display:flex;min-height:100vh}aside{width:270px;background:#141a1c;border-right:1px solid #2c3435;padding:36px 23px;display:flex;flex-direction:column;position:fixed;height:100vh}.brand{font-size:17px;letter-spacing:2px;text-decoration:none;color:inherit;display:flex;align-items:center;gap:8px}.brand span{background:#c5edaf;color:#172412;font-size:28px;border-radius:10px;padding:0 9px;margin-right:6px}.brand b{color:#a5adab;font-weight:400}.eyebrow{font-size:10px;letter-spacing:1.7px;color:#798783;margin-top:46px}nav{display:grid;gap:8px;margin-top:12px}nav a{color:#aeb9b5;text-decoration:none;padding:14px 12px;border-radius:7px;font-size:13px;display:flex;justify-content:space-between}nav a:hover{background:#20292a}nav a.active{background:#28362b;color:#d3efc5}nav span{opacity:.4;font-size:10px}footer{margin-top:auto;font-size:11px;color:#b6c2bc}footer p{line-height:1.8;color:#77857f}i{display:inline-block;width:7px;height:7px;background:#bdeba4;border-radius:50%;margin-right:7px}main{margin-left:270px;padding:30px 5vw 60px;width:calc(100% - 270px);max-width:1500px}header{display:flex;justify-content:space-between;align-items:center;font-size:10px;letter-spacing:2px;color:#7f9189;padding-bottom:40px}.pill{border:1px solid #35423b;padding:7px 11px;border-radius:20px;font-size:11px;letter-spacing:0;color:#c6d6ca}h1{font-size:40px;font-weight:500;letter-spacing:-1.5px;margin:12px 0}h2{font-weight:500;font-size:20px}p{color:#97a79e;line-height:1.65;font-size:14px}.kicker{color:#c5edaf;font-size:11px;letter-spacing:2px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;margin-top:30px}.card{background:#181f21;border:1px solid #303a37;border-radius:13px;padding:26px}.wide{grid-column:1/-1}.label{font-size:11px;color:#95a69b;text-transform:uppercase;letter-spacing:1px}.value{font-size:30px;margin:16px 0;color:#e4f1de}.card-top{display:flex;justify-content:space-between;align-items:center;gap:10px}.tag{color:#bdeba4;font-size:12px}.muted{color:#8b9c91}.metrics{display:flex;gap:34px;flex-wrap:wrap;margin:24px 0}.metrics strong{display:block;font-size:24px;font-weight:500;margin:8px 0}.metrics small{color:#8e9c93}button{font:inherit;padding:11px 20px;border-radius:7px;border:1px solid #465348;background:#c5edaf;color:#1a2a18;cursor:pointer;margin-right:8px}button.secondary{background:transparent;color:#dce7da}button:disabled{opacity:.35;cursor:default}button:focus-visible,a:focus-visible{outline:2px solid #d9fac7;outline-offset:4px}.note{border-top:1px solid #303a37;margin-top:25px;padding-top:20px;font-size:12px}.empty{padding:46px 30px;margin-top:30px;border:1px dashed #3b4840;border-radius:12px}.empty .symbol{font-size:35px;color:#bdd4b2}#error{color:#ffc1a7}progress{width:100%;height:6px;accent-color:#bce4a6}progress::-webkit-progress-bar{background:#303a37;border-radius:10px}progress::-webkit-progress-value{background:#bce4a6;border-radius:10px}@media(max-width:850px){aside{position:static;width:100%;height:auto;padding:20px}body{display:block}nav{grid-template-columns:repeat(2,1fr)}footer{display:none}main{margin:0;width:100%;padding:28px 20px}.eyebrow{margin-top:22px}.grid{grid-template-columns:1fr}h1{font-size:32px}header{padding-bottom:20px}} :root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#101416;color:#e9edea}*{box-sizing:border-box}body{margin:0;display:flex;min-height:100vh}aside{width:270px;background:#141a1c;border-right:1px solid #2c3435;padding:36px 23px;display:flex;flex-direction:column;position:fixed;height:100vh}.brand{font-size:17px;letter-spacing:2px;text-decoration:none;color:inherit;display:flex;align-items:center;gap:8px}.brand span{background:#c5edaf;color:#172412;font-size:28px;border-radius:10px;padding:0 9px;margin-right:6px}.brand b{color:#a5adab;font-weight:400}.eyebrow{font-size:10px;letter-spacing:1.7px;color:#798783;margin-top:46px}nav{display:grid;gap:8px;margin-top:12px}nav a{color:#aeb9b5;text-decoration:none;padding:14px 12px;border-radius:7px;font-size:13px;display:flex;justify-content:space-between}nav a:hover{background:#20292a}nav a.active{background:#28362b;color:#d3efc5}nav span{opacity:.4;font-size:10px}footer{margin-top:auto;font-size:11px;color:#b6c2bc}footer p{line-height:1.8;color:#77857f}i{display:inline-block;width:7px;height:7px;background:#bdeba4;border-radius:50%;margin-right:7px}main{margin-left:270px;padding:30px 5vw 60px;width:calc(100% - 270px);max-width:1500px}header{display:flex;justify-content:space-between;align-items:center;font-size:10px;letter-spacing:2px;color:#7f9189;padding-bottom:40px}.pill{border:1px solid #35423b;padding:7px 11px;border-radius:20px;font-size:11px;letter-spacing:0;color:#c6d6ca}h1{font-size:40px;font-weight:500;letter-spacing:-1.5px;margin:12px 0}h2{font-weight:500;font-size:20px}p{color:#97a79e;line-height:1.65;font-size:14px}.kicker{color:#c5edaf;font-size:11px;letter-spacing:2px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;margin-top:30px}.card{background:#181f21;border:1px solid #303a37;border-radius:13px;padding:26px}.wide{grid-column:1/-1}.label{font-size:11px;color:#95a69b;text-transform:uppercase;letter-spacing:1px}.value{font-size:30px;margin:16px 0;color:#e4f1de}.card-top{display:flex;justify-content:space-between;align-items:center;gap:10px}.tag{color:#bdeba4;font-size:12px}.muted{color:#8b9c91}.metrics{display:flex;gap:34px;flex-wrap:wrap;margin:24px 0}.metrics strong{display:block;font-size:24px;font-weight:500;margin:8px 0}.metrics small{color:#8e9c93}button{font:inherit;padding:11px 20px;border-radius:7px;border:1px solid #465348;background:#c5edaf;color:#1a2a18;cursor:pointer;margin-right:8px}button.secondary{background:transparent;color:#dce7da}button:disabled{opacity:.35;cursor:default}button:focus-visible,a:focus-visible{outline:2px solid #d9fac7;outline-offset:4px}.note{border-top:1px solid #303a37;margin-top:25px;padding-top:20px;font-size:12px}.empty{padding:46px 30px;margin-top:30px;border:1px dashed #3b4840;border-radius:12px}.empty .symbol{font-size:35px;color:#bdd4b2}#error{color:#ffc1a7}progress{width:100%;height:6px;accent-color:#bce4a6}progress::-webkit-progress-bar{background:#303a37;border-radius:10px}progress::-webkit-progress-value{background:#bce4a6;border-radius:10px}@media(max-width:850px){aside{position:static;width:100%;height:auto;padding:20px}body{display:block}nav{grid-template-columns:repeat(2,1fr)}footer{display:none}main{margin:0;width:100%;padding:28px 20px}.eyebrow{margin-top:22px}.grid{grid-template-columns:1fr}h1{font-size:32px}header{padding-bottom:20px}}
label{display:block;margin:18px 0 8px;font-size:13px;color:#c4d1c6}input,select{display:block;width:100%;max-width:560px;padding:12px;margin-bottom:16px;background:#101718;color:#e4eee5;border:1px solid #465348;border-radius:7px;font:inherit}input[type=file]{font-size:13px}button{margin-bottom:8px}.network-message{white-space:pre-wrap;color:#d1eabf}.warning{color:#f2c891}.login{margin:10vh auto;width:100%;max-width:640px}.network-status{display:flex;gap:30px;flex-wrap:wrap}.network-status strong{display:block;margin:8px 0}.link{color:#c5edaf}.network-card a{color:#c5edaf}.network-card button{font-size:13px}.network-card .note{overflow-wrap:anywhere}
+132
View File
@@ -0,0 +1,132 @@
import base64
import hashlib
import json
import os
import secrets
import threading
import unittest
import urllib.request
import urllib.error
from unittest.mock import patch
from network.config import parse_config, ConfigError, wireguard_text
from network.policy import Policy
from server import Server
def configuration(extra=''):
# Synthetic random fixture, never real credentials.
key=lambda:base64.b64encode(secrets.token_bytes(32)).decode()
return f'[Interface]\nPrivateKey = {key()}\nAddress = 10.240.77.1/24\n{extra}\n[Peer]\nPublicKey = {key()}\nEndpoint = vpn.example.test:51820\nAllowedIPs = 0.0.0.0/0\n'
class ParserTests(unittest.TestCase):
def test_valid_sanitized_config(self):
config=parse_config(configuration('DNS = 192.168.1.1'))
self.assertEqual(config['address'],'10.240.77.1')
self.assertEqual(config['allowed_ips'],['0.0.0.0/0'])
self.assertEqual(config['keepalive'],25)
self.assertTrue(config['warnings'])
rendered=wireguard_text(config)
for name in ('DNS','Address','Table','PostUp'):self.assertNotIn(name,rendered)
def test_reject_hooks_without_echoing_secrets(self):
for hook in ('PostUp','PostDown','PreUp','PreDown','Table','SaveConfig'):
with self.assertRaises(ConfigError) as e:parse_config(configuration(hook+' = secret-canary'))
self.assertNotIn('secret-canary',str(e.exception))
def test_duplicate_invalid_and_oversize(self):
samples=[configuration('PrivateKey = invalid'),configuration().replace('10.240.77.1/24','::1/128'),configuration()+'[Peer]\n',configuration().replace('vpn.example.test:51820','$(whoami):22'),'x'*16385]
for text in samples:
with self.assertRaises(ConfigError):parse_config(text)
class PolicyTests(unittest.TestCase):
def setUp(self):
self.now=0
self.saved=[]
self.policy=Policy(clock=lambda:self.now,persist=self.saved.append)
def test_disconnect_never_opens_lan(self):
p=self.policy
p.propose('tunnel',True)
trial=p.status()['pending']['id']
self.assertFalse(p.allowed('lan'))
with self.assertRaises(ValueError):p.confirm(trial,'management')
with self.assertRaises(ValueError):p.confirm(trial,'lan')
p.confirm(trial,'tunnel')
self.now=999
self.assertFalse(p.allowed('lan'))
self.assertEqual(self.saved,['tunnel'])
def test_trial_timeout_and_reboot(self):
p=self.policy
p.propose('tunnel',True)
self.now=121
self.assertTrue(p.allowed('lan'))
self.assertEqual(self.saved,[])
self.assertIsNone(p.status()['pending'])
self.assertEqual(Policy(mode='lan').mode,'lan')
def test_no_tunnel_without_handshake(self):
for mode in ('tunnel','both'):
with self.assertRaises(ValueError):self.policy.propose(mode,False)
def test_lan_confirmation_and_cancel(self):
p=self.policy
p.propose('both',True)
p.confirm(p.pending['id'],'tunnel')
p.propose('lan',False)
with self.assertRaises(ValueError):p.confirm(p.pending['id'],'tunnel')
p.cancel()
self.assertEqual(p.mode,'both')
class APITests(unittest.TestCase):
def setUp(self):
self.server=Server(0)
self.thread=threading.Thread(target=self.server.serve_forever)
self.thread.start()
self.base=f'http://127.0.0.1:{self.server.server_port}'
def tearDown(self):
self.server.shutdown();self.server.demo.stop();self.server.server_close();self.thread.join()
def post(self,path,body,headers=None):
req=urllib.request.Request(self.base+'/api/v1/'+path,data=json.dumps(body).encode(),headers={'Content-Type':'application/json','X-Athena-Deck':'1',**(headers or {})})
return urllib.request.urlopen(req)
def test_import_rejects_before_transport(self):
with patch.object(self.server.network,'call') as transport:
with self.assertRaises(urllib.error.HTTPError) as error:self.post('network/import',{'config':configuration('PostUp = no')})
self.assertEqual(error.exception.code,400);error.exception.close()
transport.assert_not_called()
def test_cannot_forge_confirm_ingress(self):
with patch.object(self.server.network,'call',return_value={}) as transport:
with self.post('network/confirm',{'trial_id':'test'}, {'X-Deck-Ingress':'tunnel'}) as result:self.assertEqual(result.status,200)
self.assertEqual(transport.call_args.args[2],'management')
with self.assertRaises(urllib.error.HTTPError) as error:self.post('network/confirm',{'trial_id':'test','ingress':'tunnel'})
self.assertEqual(error.exception.code,400);error.exception.close()
def test_authentication(self):
salt=secrets.token_bytes(16)
password='test-only-'+secrets.token_hex(20)
self.server.auth={'salt':salt.hex(),'hash':hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex()}
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(self.base+'/api/v1/status')
self.assertEqual(error.exception.code,401);error.exception.close()
with self.post('login',{'password':password}) as response:cookie=response.headers['Set-Cookie'].split(';')[0]
req=urllib.request.Request(self.base+'/api/v1/status',headers={'Cookie':cookie})
with urllib.request.urlopen(req) as response:self.assertEqual(response.status,200)
with self.assertRaises(urllib.error.HTTPError) as error:self.post('login',{'password':'wrong'})
self.assertEqual(error.exception.code,401);error.exception.close()
if __name__=='__main__':unittest.main()
class TransportTests(unittest.TestCase):
def test_status_with_nullable_error_is_success(self):
import subprocess
from network.client import NetworkClient
with patch.dict(os.environ, {'DECK_NETWORK_SOCKET':'0'}):
client=NetworkClient()
result={'installed':True,'error':None,'state':'disabled'}
with patch('network.client.subprocess.run',return_value=subprocess.CompletedProcess([],0,json.dumps(result),'')):
self.assertTrue(client.call('status')['installed'])
def test_installer_bundle_is_complete(self):
from network.install_remote import FILES
from network.client import ROOT
for name in FILES:
self.assertTrue((ROOT/name).is_file(),name)
self.assertNotIn('test_network.py',FILES)
def test_install_password_required_before_thread_or_ssh(self):
from network.client import NetworkClient
with patch.dict(os.environ, {'DECK_NETWORK_SOCKET':'0'}):
client=NetworkClient()
with patch('network.client.subprocess.run') as remote:
with self.assertRaises(ValueError):client.install('short')
remote.assert_not_called()
+2
View File
@@ -33,8 +33,10 @@ class Tests(unittest.TestCase):
for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}): for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}):
with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers) with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)
self.assertEqual(e.exception.code,403) self.assertEqual(e.exception.code,403)
e.exception.close()
with self.assertRaises(urllib.error.HTTPError) as e:self.request('models/start','POST',{'X-Athena-Deck':'1'}) with self.assertRaises(urllib.error.HTTPError) as e:self.request('models/start','POST',{'X-Athena-Deck':'1'})
self.assertEqual(e.exception.code,404) self.assertEqual(e.exception.code,404)
e.exception.close()
def test_unavailable_hardware(self): def test_unavailable_hardware(self):
with patch('server.subprocess.run',side_effect=OSError()): with patch('server.subprocess.run',side_effect=OSError()):
result=HardwareProvider().snapshot() result=HardwareProvider().snapshot()