Add isolated WireGuard module with guarded access modes and server setup
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
__pycache__
|
||||
**/__pycache__
|
||||
*.conf
|
||||
*.key
|
||||
.state
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</stron
|
||||
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
|
||||
const 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.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft lokal auf deinem Mac.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
|
||||
if(page==='network'){await networkPage();return;}
|
||||
if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
|
||||
}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden.');if(!h.available)html+=`<section class="empty">Hardware nicht verfügbar<p>${esc(h.errors.join(' '))}</p></section>`;else{const c=h.cpu,r=h.ram;html+=`<div class="grid"><section class="card"><div class="label">CPU</div><h2>${esc(c.name)}</h2><div class="metrics">${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}</div>${bar(c.percent)}</section><section class="card"><div class="label">System-RAM</div><h2>Arbeitsspeicher</h2><div class="metrics">${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}</div>${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}</section>`;for(const g of h.gpus)html+=`<section class="card"><div class="card-top"><span class="label">GPU ${g.index}</span><span class="tag">${fmt(g.temperature_c,' °C')}</span></div><h2>${esc(g.name)}</h2><div class="metrics">${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}</div>${bar(g.percent)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;}
|
||||
}else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`<section class="empty"><div class="symbol">+</div><h2>Platz für den nächsten Schritt.</h2><p>${p[2]}</p><span class="pill">Platzhalter · noch keine Funktionen</span><p class="note">Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.</p></section>`;}
|
||||
if((location.hash.slice(1)||'home')===page){view.innerHTML=html;for(const action of ['start','stop'])document.getElementById(action)?.addEventListener('click',()=>control(action));}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#services">Weitere Dienste <span>05</span></a><a href="#hardware">Hardware <span>06</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Steuerung auf dem Mac<br>Hardware von Athena · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.1 · Prototyp</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#services">Weitere Dienste <span>05</span></a><a href="#hardware">Hardware <span>06</span></a><a href="#network">Einstellungen <span>07</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Steuerung auf dem Mac<br>Hardware von Athena · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.2 · Prototyp</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck · Anmeldung</title><link rel="stylesheet" href="/style.css"><script src="/login.js" defer></script></head><body><main class="login"><div class="kicker">ATHENA DECK / SERVER</div><h1>Willkommen zurück.</h1><p>Melde dich mit dem bei der Einrichtung vergebenen Deck-Passwort an.</p><form id="login"><label for="password">Deck-Passwort</label><input id="password" type="password" autocomplete="current-password" required maxlength="256"><button>Anmelden</button></form><p id="error" role="alert"></p><p class="note">Nutze den WireGuard-Tunnel oder einen SSH-Tunnel. Der direkte LAN-Zugang verwendet in dieser Version HTTP.</p></main></body></html>
|
||||
@@ -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}});
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Settings form stays mounted while status polling updates only status elements. */
|
||||
let networkBusy=false, networkState=null;
|
||||
async function networkRequest(action,body={}){
|
||||
const response=await fetch('/api/v1/network'+(action?'/'+action:''),{method:action?'POST':'GET',headers:action?{'Content-Type':'application/json','X-Athena-Deck':'1'}:{},body:action?JSON.stringify(body):undefined,signal:AbortSignal.timeout(40000)});
|
||||
const value=await response.json();if(!response.ok)throw Error(value.error||'Netzwerkaktion fehlgeschlagen.');return value;
|
||||
}
|
||||
async function networkAction(action,body={}){
|
||||
if(networkBusy)return;networkBusy=true;networkButtons();
|
||||
const output=document.querySelector('#network-message');output.textContent='Wird ausgeführt …';
|
||||
try{await networkRequest(action,body);output.textContent=action==='import'?'Konfiguration geprüft und geschützt auf Athena gespeichert.':action==='install'?'Installation läuft. Der Status aktualisiert sich automatisch.':'Einstellung übernommen.';}
|
||||
catch(e){output.textContent=e.message;}
|
||||
finally{networkBusy=false;await networkPage();}
|
||||
}
|
||||
function networkButtons(){
|
||||
if(!networkState)return;
|
||||
const s=networkState;
|
||||
document.querySelectorAll('#network-page button').forEach(b=>b.disabled=networkBusy||s.state==='installing');
|
||||
const set=(id,disabled)=>{const b=document.getElementById(id);if(b)b.disabled=b.disabled||disabled;};
|
||||
set('network-install',s.installed);
|
||||
set('network-import',!s.installed||s.enabled||s.mode!=='lan'||!!s.pending);
|
||||
set('network-connect',!s.configured||s.enabled);
|
||||
set('network-disconnect',!s.enabled);
|
||||
set('network-delete',!s.configured||s.enabled||s.mode!=='lan'||!!s.pending);
|
||||
set('network-apply',!s.installed||!!s.pending);
|
||||
const needed=s.pending?.target==='lan'?'lan':'tunnel';
|
||||
set('network-confirm',!s.pending||s.ingress!==needed);
|
||||
set('network-cancel',!s.pending);
|
||||
}
|
||||
async function networkPage(){
|
||||
const view=document.querySelector('#view');
|
||||
if(!document.querySelector('#network-page')){
|
||||
view.innerHTML=`<div id="network-page"><div class="kicker">EINSTELLUNGEN / NETZWERK</div><h1>Dein Weg zu Athena.</h1><p>Ein eigener WireGuard-Zugang für Athena Deck und seine internen API-Endpunkte.</p><section class="card network-card"><div class="card-top"><h2>WireGuard-Modul</h2><span id="network-state" class="pill">Wird geprüft …</span></div><div id="network-details" class="network-status"></div><p id="network-notice"></p><div id="network-setup"><h2>Auf Athena einrichten</h2><p>Installiert WireGuard und Deck in einem eigenen Container. Der vorhandene Gateway bleibt unverändert. Die erste Einrichtung startet im LAN-Modus auf Port 8110.</p><label for="network-password">Neues Deck-Passwort · mindestens 16 Zeichen</label><input id="network-password" type="password" autocomplete="new-password" minlength="16" maxlength="256"><button id="network-install">Modul auf Athena installieren</button><p class="note">Benötigt den vorhandenen SSH-Zugang und Docker auf Athena. LAN-Zugriff verwendet HTTP; für vertrauliche Eingaben WireGuard oder SSH-Tunnel verwenden.</p></div><div id="network-config" hidden><label for="network-file">Eigene WireGuard-Konfiguration (.conf)</label><input id="network-file" type="file" accept=".conf,text/plain"><p>Verwende einen eigenen Peer für Deck. Schlüssel werden nicht angezeigt. Ein Peer und IPv4 werden unterstützt; Shell-Hooks werden abgelehnt.</p><button id="network-import">Konfiguration importieren</button><button id="network-delete" class="secondary">Konfiguration löschen</button><div class="note"><button id="network-connect">WireGuard aktivieren</button><button id="network-disconnect" class="secondary">WireGuard deaktivieren</button><p>Aktivieren richtet den Tunnel ein. „Verbunden“ erscheint erst nach einem aktuellen Handshake.</p></div></div></section><section class="card network-card" id="network-access"><h2>Zugriff auf Deck und API</h2><label for="network-mode">Erreichbarkeit</label><select id="network-mode"><option value="lan">Nur lokales Netzwerk</option><option value="tunnel">Nur WireGuard-Tunnel</option><option value="both">Tunnel und lokales Netzwerk</option></select><button id="network-apply">Zugriffsmodus testen</button><p id="network-current"></p><div id="network-trial" hidden><p id="network-countdown" class="warning"></p><button id="network-confirm">Zugriff hier bestätigen</button><button id="network-cancel" class="secondary">Änderung zurücknehmen</button><p>Öffne die angezeigte Zieladresse und bestätige dort innerhalb von 120 Sekunden. Ohne Bestätigung wird der vorige Modus wiederhergestellt.</p></div><p class="note">Der lokale Mac-Zugang per SSH bleibt als Verwaltungskanal erhalten. Produktionsendpunkte werden nicht umgestellt. Im Modus „Nur Tunnel“ gibt es bei Tunnelausfall keinen LAN-Fallback.</p></section><p id="network-message" class="network-message" role="status" aria-live="polite"></p></div>`;
|
||||
document.querySelector('#network-install').onclick=()=>{const input=document.querySelector('#network-password'),password=input.value;input.value='';networkAction('install',{password});};
|
||||
document.querySelector('#network-import').onclick=async()=>{const input=document.querySelector('#network-file'),file=input.files[0];if(!file){document.querySelector('#network-message').textContent='Bitte zuerst eine .conf-Datei auswählen.';return}if(file.size>16384){document.querySelector('#network-message').textContent='Die Datei darf höchstens 16 KiB groß sein.';return}const config=await file.text();input.value='';await networkAction('import',{config});};
|
||||
for(const action of ['connect','disconnect','delete','cancel'])document.querySelector('#network-'+action).onclick=()=>networkAction(action);
|
||||
document.querySelector('#network-apply').onclick=()=>networkAction('mode',{mode:document.querySelector('#network-mode').value});
|
||||
document.querySelector('#network-confirm').onclick=()=>networkAction('confirm',{trial_id:networkState?.pending?.id});
|
||||
}
|
||||
try{
|
||||
const s=await networkRequest('');if(!document.querySelector('#network-page'))return;networkState=s;
|
||||
const labels={unavailable:'Nicht bereit',installing:'Installation läuft',disabled:'Deaktiviert',connecting:'Warte auf Handshake',connected:'Verbunden'};
|
||||
document.querySelector('#network-state').textContent=labels[s.state]||'Nicht verfügbar';
|
||||
const details=document.querySelector('#network-details');details.replaceChildren();
|
||||
for(const [label,value] of [['Konfiguration',s.configured?'Gespeichert':'Keine'],['Letzter Handshake',s.latest_handshake?new Date(s.latest_handshake*1000).toLocaleString('de-DE'):'Noch keiner'],['Dieser Zugang',s.ingress==='management'?'Mac / SSH-Verwaltung':s.ingress==='tunnel'?'WireGuard-Tunnel':'LAN']]){const d=document.createElement('div'),small=document.createElement('small'),strong=document.createElement('strong');small.textContent=label;strong.textContent=value;d.append(small,strong);details.append(d);}
|
||||
for(const [label,url] of [['LAN',s.lan_url],['Tunnel',s.tunnel_url]])if(url){const d=document.createElement('div'),a=document.createElement('a');a.href=url+'/#network';a.textContent=label+': '+url;a.target='_blank';a.rel='noopener';d.append(a);details.append(d);}
|
||||
document.querySelector('#network-setup').hidden=!!s.installed;
|
||||
document.querySelector('#network-config').hidden=false;
|
||||
document.querySelector('#network-file').disabled=!s.installed||s.enabled||networkBusy;
|
||||
document.querySelector('#network-access').hidden=false;
|
||||
document.querySelector('#network-mode').disabled=!s.installed||!!s.pending||networkBusy;
|
||||
document.querySelector('#network-notice').textContent=[s.error,s.job?.message,...(s.warnings||[])].filter(Boolean).join(' ');
|
||||
document.querySelector('#network-current').textContent='Aktuell: '+({lan:'Nur lokales Netzwerk',tunnel:'Nur WireGuard-Tunnel',both:'Tunnel und LAN'}[s.mode]||'Noch nicht eingerichtet');
|
||||
document.querySelector('#network-trial').hidden=!s.pending;
|
||||
if(s.pending)document.querySelector('#network-countdown').textContent=`Test aktiv · ${s.pending.seconds_remaining} Sekunden bis zur automatischen Rücknahme. Bestätigung nötig über ${s.pending.target==='lan'?'LAN':'WireGuard'}.`;
|
||||
networkButtons();
|
||||
}catch(e){if(document.querySelector('#network-message'))document.querySelector('#network-message').textContent=e.message;}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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/).
|
||||
@@ -0,0 +1 @@
|
||||
"""Isolated networking module for Athena Deck."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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()))))
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
+132
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user