From e86e540ae44f932d1b2ce3e623555865df68babe Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:50:02 +0200 Subject: [PATCH] Add isolated WireGuard module with guarded access modes and server setup --- .dockerignore | 6 + README.md | 15 +- app.js | 3 +- index.html | 2 +- login.html | 1 + login.js | 1 + network-ui.js | 57 +++++++ network/Dockerfile | 6 + network/README.md | 166 ++++++++++++++++++++ network/__init__.py | 1 + network/agent.py | 294 ++++++++++++++++++++++++++++++++++++ network/client.py | 94 ++++++++++++ network/config.py | 78 ++++++++++ network/install_remote.py | 79 ++++++++++ network/policy.py | 53 +++++++ network/rpc.py | 28 ++++ network/test_integration.py | 142 +++++++++++++++++ server.py | 125 ++++++++++++++- style.css | 1 + test_network.py | 132 ++++++++++++++++ test_server.py | 2 + 21 files changed, 1278 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 login.html create mode 100644 login.js create mode 100644 network-ui.js create mode 100644 network/Dockerfile create mode 100644 network/README.md create mode 100644 network/__init__.py create mode 100644 network/agent.py create mode 100644 network/client.py create mode 100644 network/config.py create mode 100644 network/install_remote.py create mode 100644 network/policy.py create mode 100644 network/rpc.py create mode 100644 network/test_integration.py create mode 100644 test_network.py 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.

${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}
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.

${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}
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

${esc(h.errors.join(' '))}

`;else{const c=h.cpu,r=h.ram;html+=`
CPU

${esc(c.name)}

${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}
${bar(c.percent)}
System-RAM

Arbeitsspeicher

${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'))}
${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}
`;for(const g of h.gpus)html+=`
GPU ${g.index}${fmt(g.temperature_c,' °C')}

${esc(g.name)}

${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'))}
${bar(g.percent)}
`;if(h.gpus.length<2)html+='
Nicht alle zwei GPUs verfügbar.
';html+=`

${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}
${esc(h.errors.join(' '))}

`;} }else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`
+

Platz für den nächsten Schritt.

${p[2]}

Platzhalter · noch keine Funktionen

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 Deck
ATHENA CONTROL SURFACEv0.1 · Prototyp
+Athena Deck
ATHENA 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.

Zugriff auf Deck und API

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()