diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..7c36b48
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+.git
+__pycache__
+**/__pycache__
+*.conf
+*.key
+.state
diff --git a/README.md b/README.md
index 1079234..11a8ccb 100644
--- a/README.md
+++ b/README.md
@@ -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.
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
```sh
@@ -73,10 +80,12 @@ Sprachmodelle/Chat, Bildgenerierung, Audio und Weitere Dienste sind ausdrücklic
Platzhalter. Keine Downloads, Installation, Presets, Chats oder Modellwechsel.
Spätere Modellprofile und native llama.cpp-Worker sollten eigene Service-Adapter
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.
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
diff --git a/app.js b/app.js
index b8fa9a1..7cd9a79 100644
--- a/app.js
+++ b/app.js
@@ -8,7 +8,8 @@ const metric=(title,value)=>`
${title}${value}v==null?'':``;
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;
-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.')+`
Anwendung
Bereit
Athena Deck läuft lokal auf deinem Mac.
Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1
Betriebsmodus
Isoliert
Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.
Keine Modell- oder Router-Steuerung angebunden
Demo-Dienst
LOKAL / CPU
Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.
Lädt keine Modelle · endet beim Beenden von Athena Deck
`;
+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.')+`
Anwendung
Bereit
Athena Deck läuft auf ${esc(s.location)}.
Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1
Betriebsmodus
Isoliert
Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.
Keine Modell- oder Router-Steuerung angebunden
Demo-Dienst
LOKAL / CPU
Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.
Lädt keine Modelle · endet beim Beenden von Athena Deck
`;
}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+=`Hardware nicht verfügbar
Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.
`;}
if((location.hash.slice(1)||'home')===page){view.innerHTML=html;for(const action of ['start','stop'])document.getElementById(action)?.addEventListener('click',()=>control(action));}
diff --git a/index.html b/index.html
index 071fca9..dcaaf5f 100644
--- a/index.html
+++ b/index.html
@@ -1 +1 @@
-Athena DeckATHENA CONTROL SURFACEv0.1 · Prototyp
+Athena DeckATHENA CONTROL SURFACEv0.2 · Prototyp
diff --git a/login.html b/login.html
new file mode 100644
index 0000000..b8bb85f
--- /dev/null
+++ b/login.html
@@ -0,0 +1 @@
+Athena Deck · Anmeldung
ATHENA DECK / SERVER
Willkommen zurück.
Melde dich mit dem bei der Einrichtung vergebenen Deck-Passwort an.
Nutze den WireGuard-Tunnel oder einen SSH-Tunnel. Der direkte LAN-Zugang verwendet in dieser Version HTTP.
diff --git a/login.js b/login.js
new file mode 100644
index 0000000..545a712
--- /dev/null
+++ b/login.js
@@ -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}});
diff --git a/network-ui.js b/network-ui.js
new file mode 100644
index 0000000..1db3484
--- /dev/null
+++ b/network-ui.js
@@ -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=`
EINSTELLUNGEN / NETZWERK
Dein Weg zu Athena.
Ein eigener WireGuard-Zugang für Athena Deck und seine internen API-Endpunkte.
WireGuard-Modul
Wird geprüft …
Auf Athena einrichten
Installiert WireGuard und Deck in einem eigenen Container. Der vorhandene Gateway bleibt unverändert. Die erste Einrichtung startet im LAN-Modus auf Port 8110.
Benötigt den vorhandenen SSH-Zugang und Docker auf Athena. LAN-Zugriff verwendet HTTP; für vertrauliche Eingaben WireGuard oder SSH-Tunnel verwenden.
Verwende einen eigenen Peer für Deck. Schlüssel werden nicht angezeigt. Ein Peer und IPv4 werden unterstützt; Shell-Hooks werden abgelehnt.
Aktivieren richtet den Tunnel ein. „Verbunden“ erscheint erst nach einem aktuellen Handshake.
Zugriff auf Deck und API
Öffne die angezeigte Zieladresse und bestätige dort innerhalb von 120 Sekunden. Ohne Bestätigung wird der vorige Modus wiederhergestellt.
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.
`;
+ 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;}
+}
diff --git a/network/Dockerfile b/network/Dockerfile
new file mode 100644
index 0000000..fd5295e
--- /dev/null
+++ b/network/Dockerfile
@@ -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"]
diff --git a/network/README.md b/network/README.md
new file mode 100644
index 0000000..c3a4f58
--- /dev/null
+++ b/network/README.md
@@ -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/).
diff --git a/network/__init__.py b/network/__init__.py
new file mode 100644
index 0000000..360fccb
--- /dev/null
+++ b/network/__init__.py
@@ -0,0 +1 @@
+"""Isolated networking module for Athena Deck."""
diff --git a/network/agent.py b/network/agent.py
new file mode 100644
index 0000000..ed29ed8
--- /dev/null
+++ b/network/agent.py
@@ -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()
diff --git a/network/client.py b/network/client.py
new file mode 100644
index 0000000..62cc5ab
--- /dev/null
+++ b/network/client.py
@@ -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
diff --git a/network/config.py b/network/config.py
new file mode 100644
index 0000000..18f1015
--- /dev/null
+++ b/network/config.py
@@ -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'
diff --git a/network/install_remote.py b/network/install_remote.py
new file mode 100644
index 0000000..e60142e
--- /dev/null
+++ b/network/install_remote.py
@@ -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)
diff --git a/network/policy.py b/network/policy.py
new file mode 100644
index 0000000..31419f4
--- /dev/null
+++ b/network/policy.py
@@ -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()))))
diff --git a/network/rpc.py b/network/rpc.py
new file mode 100644
index 0000000..22f97c8
--- /dev/null
+++ b/network/rpc.py
@@ -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)
diff --git a/network/test_integration.py b/network/test_integration.py
new file mode 100644
index 0000000..00cee93
--- /dev/null
+++ b/network/test_integration.py
@@ -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()
diff --git a/server.py b/server.py
index 7b66ebf..17e43fb 100644
--- a/server.py
+++ b/server.py
@@ -1,6 +1,12 @@
#!/usr/bin/env python3
"""Athena Deck prototype: loopback API, owned demo process, read-only telemetry."""
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 signal
import socket
@@ -30,7 +36,7 @@ class DemoService:
reachable = json.load(response).get('service') == 'athena-deck-demo'
except (OSError, ValueError):
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):
with self.lock:
@@ -64,6 +70,13 @@ class HardwareProvider:
self.checked = 0
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:
if time.monotonic() - self.checked < 5 and self.cached is not None:
return self.cached
@@ -83,6 +96,11 @@ class Server(ThreadingHTTPServer):
self.demo = DemoService()
self.hardware = HardwareProvider()
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):
def log_message(self, *args):
@@ -99,20 +117,97 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
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):
+ 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}')
def do_GET(self):
if not self.valid_host():
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:
name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime)
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':
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':
return self.respond(self.server.demo.status())
return self.respond({'error': 'Not found'}, 404)
@@ -121,6 +216,30 @@ class Handler(BaseHTTPRequestHandler):
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', '')):
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'):
return self.respond({'error': 'Not found'}, 404)
action = self.server.demo.start if self.path.endswith('/start') else self.server.demo.stop
diff --git a/style.css b/style.css
index ebab5ef..7ac2ff6 100644
--- a/style.css
+++ b/style.css
@@ -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}}
+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}
diff --git a/test_network.py b/test_network.py
new file mode 100644
index 0000000..0cd769e
--- /dev/null
+++ b/test_network.py
@@ -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()
diff --git a/test_server.py b/test_server.py
index 73eab4b..5789dfb 100644
--- a/test_server.py
+++ b/test_server.py
@@ -33,8 +33,10 @@ class Tests(unittest.TestCase):
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)
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'})
self.assertEqual(e.exception.code,404)
+ e.exception.close()
def test_unavailable_hardware(self):
with patch('server.subprocess.run',side_effect=OSError()):
result=HardwareProvider().snapshot()