Add first-run admin setup and independent password and API token rotation

This commit is contained in:
Mikei386
2026-09-28 15:02:41 +02:00
parent e86e540ae4
commit 9f3a13e45f
20 changed files with 651 additions and 76 deletions
+2
View File
@@ -1,2 +1,4 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.state/
+128
View File
@@ -0,0 +1,128 @@
# Zugang und API · Athena Deck 0.3
## Ersteinrichtung
Beim ersten Öffnen der lokalen Oberfläche legt der Betreiber zwei getrennte
Zugangsdaten fest:
1. **Oberflächenkennwort**: 16–256 Zeichen, ein Administratorzugang.
2. **API-Token**: 32–256 Zeichen aus Buchstaben, Ziffern, Bindestrich/Unterstrich.
„Sicheren Token erzeugen“ erzeugt mit dem Browser-Zufallsgenerator 32 zufällige
Bytes, dargestellt als Hexwert mit `ad_`-Präfix. Alternativ eigenen Token einsetzen.
Kennwort und Token dürfen nicht identisch sein. Der Token kann während der Eingabe
angezeigt/kopiert werden. **Vor dem Speichern im Passwortmanager sichern.** Ein
vorhandener Token ist anschließend nicht abrufbar; bei Verlust einen neuen setzen.
Auf HTTP-Adressen kann die Browser-Zwischenablage gesperrt sein. Dann „Token anzeigen“
verwenden und manuell kopieren. Im ersten Schritt wird noch keine Sitzung erzeugt:
nach dem Einrichten mit dem gewählten Kennwort anmelden.
Lokaler Zustand: `.state/auth.json`, Verzeichnis 0700, Datei 0600, in Git und
Docker-Builds ausgeschlossen. Es gibt keine voreingestellten oder vom Agenten
festgelegten Zugangsdaten. Auch die Mac-Verwaltungsoberfläche verlangt jetzt die
Ersteinrichtung und Anmeldung. Status, Hardware und Steuerung sind davor gesperrt.
Der Ersteinrichtungs-Endpunkt ist einmalig und nur in der Loopback-Instanz aktiv.
Die optionale Athena-Serverinstallation erhält in ihrem Installationsformular
**eigene** Zugangsdaten (Kennwort und API-Token). Diese gelten für die Server-Instanz,
nicht für die Mac-Instanz. Die Server-Anwendung wird bereits provisioniert gestartet;
es gibt keinen ungeschützten Fernzugriff, mit dem ein beliebiger Besucher den
Server beanspruchen könnte. Ein vorhandener 0.2-Kennwortstand bleibt lesbar;
API-Zugriff ist dort zunächst deaktiviert, bis ein Token eingerichtet wird.
Bestehende Server-Container werden durch diese Codeänderung nicht automatisch aktualisiert.
## Einstellungen → Zugang & API
- **Kennwort ändern:** aktuelles Kennwort, neues Kennwort und Wiederholung eingeben.
Nach erfolgreicher Speicherung sind alle Browser-Sitzungen ungültig. Erneut mit
dem neuen Kennwort anmelden. Der API-Token bleibt gültig.
- **API-Token ändern:** aktuelles Oberflächenkennwort bestätigen und neuen Token
einsetzen oder erzeugen. Nach Speicherung wird der alte Token sofort abgewiesen.
Browser-Sitzungen bleiben gültig. API-Clients müssen den neuen Token erhalten.
- **Abmelden:** beendet die aktuelle Sitzung serverseitig.
Die Einstellungsseite benennt die gerade verwaltete Instanz (Mac oder Athena).
Es gibt zunächst **einen Administrator**, keine Benutzernamen, weiteren Benutzer,
Rollen, persönlichen Tokens oder Passwort-zurücksetzen-per-E-Mail.
## Browser und API sind getrennt
Die Oberfläche verwendet acht Stunden gültige HttpOnly-/SameSite=Strict-Cookies.
Mutationen im Browser benötigen zusätzlich `X-Athena-Deck: 1` und einen passenden
Origin. Kennwortprüfung und sensible Änderungen sind auf zehn Versuche pro Minute
und Instanz begrenzt. Zugangsdaten werden niemals in Querystrings übertragen.
API-Clients verwenden `Authorization: Bearer <API-TOKEN>`. Aktuell erlaubt sind:
| Methode | Endpunkt |
|---|---|
| GET | `/api/v1/status` |
| GET | `/api/v1/hardware` |
| GET | `/api/v1/demo` |
| POST | `/api/v1/demo/start` |
| POST | `/api/v1/demo/stop` |
API-Clients brauchen weder Cookie noch Browser-CSRF-Header. Ein vorhandener
Authorization-Header wird ausdrücklich geprüft; ein ungültiger Token wird nicht
über ein zusätzliches gültiges Browser-Cookie umgangen. Der API-Token erlaubt
**keine** Zugangsdaten-, WireGuard-, Installations- oder sonstigen Admin-Aktionen.
Beispiel (Platzhalter ersetzen):
```sh
curl -H 'Authorization: Bearer <API-TOKEN>' http://127.0.0.1:8108/api/v1/status
```
Die späteren Modell-/Inferenz-Endpunkte existieren noch nicht. Ihre Autorisierung
muss beim Ergänzen explizit an die Token-Prüfung angeschlossen werden. Es wird kein
produktiver Router-Token verändert. Der WireGuard-Zugriffsmodus gilt weiterhin vor
der Authentifizierung: ein gültiger Token umgeht keine gesperrte LAN-/Tunnelroute.
## Speicherung und Serverbetrieb
Kennwörter: PBKDF2-HMAC-SHA256 mit 600.000 Iterationen und individuellem Salt.
API-Tokens: ausschließlich SHA-256-Prüfwert (bei generierten Tokens 256 Bit Zufall).
Keine Klartext-Speicherung von Kennwort oder Token. Statusantworten enthalten nur
Einrichtungsstatus und Änderungszeiten, keine Hashes, Kennwörter oder Tokenwerte.
Lokale Schreibvorgänge sind atomar; bei fehlgeschlagenem Schreiben bleibt der alte
Stand gültig. Die lokale Anwendung serialisiert Einrichtung und Änderungen. Im
Servercontainer persistiert der Root-Helper Änderungen unter `/data/auth.json` über
eine fest definierte RPC-Schnittstelle; der Webprozess bleibt unprivilegiert.
Versionsprüfung verhindert das Überschreiben inzwischen geänderter Zugangsdaten.
Der Webprozess liest den aktuellen Stand bei der Autorisierung. Es gibt keinen
veralteten Credential-Snapshot in Umgebungsvariablen. Neustarts erhalten geänderte
Zugangsdaten und verwerfen Sitzungen.
Die Netzwerkschicht leitet Authorization gezielt weiter, vertraut jedoch weiterhin
keinen vom Client gesetzten Ingress-Headern. Kein CORS-Zugriff, kein Docker-Socket im
Webprozess. Die bestehende Transportgrenze bleibt: lokale Nutzung über Loopback,
Server über WireGuard/SSH; der direkte LAN-Zugang bietet derzeit HTTP, kein HTTPS.
## API für die Oberfläche
| Methode / Endpunkt | Inhalt / Verhalten |
|---|---|
| GET `/api/v1/auth/status` | Öffentlich nur initialized/authenticated; angemeldet zusätzlich Zeiten und Tokenstatus |
| POST `/api/v1/auth/setup` | password, api_token; einmalige lokale Einrichtung |
| POST `/api/v1/login` | password; setzt Sitzungscookie |
| POST `/api/v1/logout` | aktuelle Sitzung beenden |
| POST `/api/v1/auth/password` | current_password, new_password; Sitzung erforderlich |
| POST `/api/v1/auth/token` | current_password, new_token; Sitzung erforderlich |
| POST `/api/v1/network/install` | password, api_token für die neue Server-Instanz; lokale Admin-Sitzung erforderlich |
Keine generische Benutzerverwaltung oder API für das Auslesen der Geheimnisse.
## Verifikation
26 lokale Tests: Ersteinrichtung, parallele Einrichtung, fehlende Anmeldung,
Kennwort-/Tokenwechsel, alte und neue Credentials, alle Sitzungen, Token-Rechte,
Logout, Legacy-Stand, Dateirechte, fehlgeschlagenes Schreiben, keine Klartexte in
der Persistenz, vorhandene Demo-/WireGuard-Regressionsprüfungen.
Zusätzlich echte Tests in zwei kurzlebigen Docker-Containern auf Athena:
Token durch Proxy, gesperrte Admin-Endpunkte, beide Rotationen, alte Credentials
abgewiesen, neue Credentials nach Container-Neustart gültig. WireGuard-Handshake,
Ingress-Sperren und Rückfallverhalten weiterhin erfolgreich. Keine Host-Ports
für Tests veröffentlicht; Testcontainer und Test-Secrets entfernt. Startzeiten
aller zuvor vorhandenen Container unverändert.
+14 -7
View File
@@ -1,9 +1,15 @@
# Athena Deck · Prototyp 0.2 # Athena Deck · Prototyp 0.3
Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript. Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript.
Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10. Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10.
## Neu: WireGuard-Einstellungen ## Neu: Zugang und API-Token
Beim ersten Öffnen werden Oberflächenkennwort und separater API-Token eingerichtet.
Beide sind unter **Zugang & API** änderbar. Die lokale Vorschau ist jetzt ebenfalls
anmeldepflichtig. Details und API-Rechte: [Zugangsverwaltung](ACCESS.md).
## WireGuard-Einstellungen
Installation einer getrennten Server-Instanz, sicherer Conf-Import und LAN-/Tunnel-Zugriff Installation einer getrennten Server-Instanz, sicherer Conf-Import und LAN-/Tunnel-Zugriff
sind jetzt unter Einstellungen verfügbar. Einrichtung, Sicherheitsgrenzen und Tests: sind jetzt unter Einstellungen verfügbar. Einrichtung, Sicherheitsgrenzen und Tests:
@@ -34,7 +40,7 @@ auf dem Host werden keine Dateien angelegt und keine Dienste gesteuert.
## Interne API v1 ## Interne API v1
Alle Antworten JSON, keine persistenten Zustandsdaten. GUI verwendet nur diese API. Alle Antworten JSON. Zugangsdaten werden geschützt persistiert; Demo-Zustand ist flüchtig. GUI verwendet nur diese API.
| Methode | Pfad | Ergebnis | | Methode | Pfad | Ergebnis |
|---|---|---| |---|---|---|
@@ -44,16 +50,17 @@ Alle Antworten JSON, keine persistenten Zustandsdaten. GUI verwendet nur diese A
| POST | `/api/v1/demo/start` | Idempotent starten und Health prüfen | | POST | `/api/v1/demo/start` | Idempotent starten und Health prüfen |
| POST | `/api/v1/demo/stop` | Idempotent eigenen Kindprozess stoppen | | POST | `/api/v1/demo/stop` | Idempotent eigenen Kindprozess stoppen |
POST benötigt `X-Athena-Deck: 1`; Browser-Origin muss zum Host passen. Browser-POST benötigt Sitzung und `X-Athena-Deck: 1`; Browser-Origin muss zum Host passen.
API-Clients verwenden für freigegebene Dienst-Endpunkte den separaten Bearer-Token.
Host-Allowlist: localhost oder 127.0.0.1 mit tatsächlichem UI-Port. Host-Allowlist: localhost oder 127.0.0.1 mit tatsächlichem UI-Port.
Keine CORS-Freigabe. 403 bei ungültigem Zugriff, 404 bei unbekannten Pfaden, Keine CORS-Freigabe. 403 bei ungültigem Zugriff, 404 bei unbekannten Pfaden,
503 bei fehlgeschlagener Demo-Bereitschaft. Keine frei übergebbaren Befehle, 503 bei fehlgeschlagener Demo-Bereitschaft. Keine frei übergebbaren Befehle,
Prozess-IDs, Service-Namen oder Remote-Ziele. Prozess-IDs, Service-Namen oder Remote-Ziele.
```sh ```sh
curl http://127.0.0.1:8108/api/v1/status curl -H 'Authorization: Bearer <API-TOKEN>' http://127.0.0.1:8108/api/v1/status
curl -X POST -H 'X-Athena-Deck: 1' http://127.0.0.1:8108/api/v1/demo/start curl -X POST -H 'Authorization: Bearer <API-TOKEN>' http://127.0.0.1:8108/api/v1/demo/start
curl -X POST -H 'X-Athena-Deck: 1' http://127.0.0.1:8108/api/v1/demo/stop curl -X POST -H 'Authorization: Bearer <API-TOKEN>' http://127.0.0.1:8108/api/v1/demo/stop
``` ```
Hardware: `available` bezeichnet Erfolg der SSH-Collector-Abfrage, einzelne Hardware: `available` bezeichnet Erfolg der SSH-Collector-Abfrage, einzelne
+15
View File
@@ -0,0 +1,15 @@
let accessBusy=false;
function accessToken(){const bytes=crypto.getRandomValues(new Uint8Array(32));return 'ad_'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
async function accessPage(){
const view=document.querySelector('#view');
if(document.querySelector('#access-page'))return;
view.innerHTML=`<div id="access-page"><div class="kicker">EINSTELLUNGEN / ZUGANG</div><h1>Dein Zugang. Deine Schlüssel.</h1><p>Ein Administratorzugang für die Oberfläche und ein separater Token für API-Clients.</p><p id="access-info"></p><section class="card"><h2>Oberflächenkennwort ändern</h2><form id="password-form"><label for="current-password">Aktuelles Kennwort</label><input id="current-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-password">Neues Kennwort · mindestens 16 Zeichen</label><input id="new-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><label for="repeat-password">Neues Kennwort wiederholen</label><input id="repeat-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><button>Kennwort ändern</button></form><p class="note">Alle bestehenden Browser-Sitzungen werden abgemeldet. Der API-Token bleibt gültig.</p></section><section class="card"><h2>API-Token ändern</h2><p id="token-info"></p><form id="token-form"><label for="token-password">Aktuelles Oberflächenkennwort zur Bestätigung</label><input id="token-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-token">Neuer API-Token</label><input id="new-token" type="password" autocomplete="off" spellcheck="false" required minlength="32" maxlength="256"><button id="token-generate" type="button" class="secondary">Sicheren Token erzeugen</button><button id="token-show" type="button" class="secondary">Token anzeigen</button><button id="token-copy" type="button" class="secondary">Token kopieren</button><button>Neuen Token speichern</button></form><p class="note">Vor dem Speichern kopieren. Der bisherige Token wird sofort ungültig. Bestehende Browser-Sitzungen bleiben angemeldet. Verwende in API-Clients: Authorization: Bearer &lt;Token&gt;.</p></section><p id="access-message" role="status" aria-live="polite"></p><button id="logout" class="secondary">Abmelden</button><p class="note">Der Token berechtigt zum Dienststatus, Hardwareabruf und Demo-Start/Stopp. Er erlaubt keine Kennwort-, Token- oder Netzwerkeinstellungen. Weitere Benutzer und Rollen sind noch nicht enthalten.</p></div>`;
try{const r=await fetch('/api/v1/auth/status'),s=await r.json();if(!s.authenticated){location.reload();return}document.querySelector('#access-info').textContent=`Administrator · Diese Instanz: ${s.location}`;document.querySelector('#token-info').textContent=s.api_token_configured?('API-Token eingerichtet'+(s.token_changed_at?' · geändert am '+new Date(s.token_changed_at*1000).toLocaleString('de-DE'):'')+'. Der Wert wird nicht angezeigt.'):'Noch kein API-Token eingerichtet. API-Clients haben keinen Zugang.';}catch{document.querySelector('#access-message').textContent='Zugangsstatus nicht erreichbar.'}
async function save(kind,data,form){if(accessBusy)return;accessBusy=true;view.querySelectorAll('button').forEach(b=>b.disabled=true);try{const r=await fetch('/api/v1/auth/'+kind,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}),s=await r.json();if(r.status===401){location.reload();return}if(!r.ok)throw Error(s.error);form.reset();if(s.login_required){location.reload();return}document.querySelector('#access-message').textContent='Neuer API-Token gespeichert. Der alte Token ist jetzt ungültig.';document.querySelector('#token-info').textContent='API-Token gerade geändert. Der Wert wird nicht angezeigt.';}catch(e){document.querySelector('#access-message').textContent=e.message}finally{accessBusy=false;view.querySelectorAll('button').forEach(b=>b.disabled=false)}}
document.querySelector('#password-form').onsubmit=e=>{e.preventDefault();const value=document.querySelector('#new-password').value;if(value!==document.querySelector('#repeat-password').value){document.querySelector('#access-message').textContent='Die neuen Kennwörter stimmen nicht überein.';return}save('password',{current_password:document.querySelector('#current-password').value,new_password:value},e.target)};
document.querySelector('#token-form').onsubmit=e=>{e.preventDefault();save('token',{current_password:document.querySelector('#token-password').value,new_token:document.querySelector('#new-token').value},e.target)};
document.querySelector('#token-generate').onclick=()=>{document.querySelector('#new-token').value=accessToken();document.querySelector('#access-message').textContent='Neuen Token vor dem Speichern kopieren.'};
document.querySelector('#token-show').onclick=()=>{const input=document.querySelector('#new-token');input.type=input.type==='password'?'text':'password';document.querySelector('#token-show').textContent=input.type==='password'?'Token anzeigen':'Token verbergen';};
document.querySelector('#token-copy').onclick=async()=>{try{const token=document.querySelector('#new-token').value;if(!token)throw Error();await navigator.clipboard.writeText(token);document.querySelector('#access-message').textContent='Token kopiert. Bitte sicher speichern.';}catch{document.querySelector('#access-message').textContent='Kopieren nicht möglich. Bitte einen eigenen Token aus dem Passwortmanager verwenden.'}};
document.querySelector('#logout').onclick=async()=>{const r=await fetch('/api/v1/logout',{method:'POST',headers:{'X-Athena-Deck':'1'}});if(r.ok||r.status===401)location.reload();else document.querySelector('#access-message').textContent='Abmelden fehlgeschlagen. Bitte erneut versuchen.'};
}
+2 -1
View File
@@ -3,11 +3,12 @@ let busy=false, refreshing=false;
const esc=v=>String(v??'Nicht verfügbar').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc=v=>String(v??'Nicht verfügbar').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=(v,unit='')=>v==null?'Nicht verfügbar':`${Number(v).toLocaleString('de-DE',{maximumFractionDigits:1})}${unit}`; const fmt=(v,unit='')=>v==null?'Nicht verfügbar':`${Number(v).toLocaleString('de-DE',{maximumFractionDigits:1})}${unit}`;
const heading=(tag,title,desc)=>`<div class="kicker">${tag}</div><h1>${title}</h1><p>${desc}</p>`; const heading=(tag,title,desc)=>`<div class="kicker">${tag}</div><h1>${title}</h1><p>${desc}</p>`;
async function api(path,method='GET'){const r=await fetch('/api/v1/'+path,{method,headers:method==='POST'?{'X-Athena-Deck':'1'}:{},signal:AbortSignal.timeout(15000)});if(!r.ok)throw Error(`API-Anfrage fehlgeschlagen (${r.status})`);return r.json()} async function api(path,method='GET'){const r=await fetch('/api/v1/'+path,{method,headers:method==='POST'?{'X-Athena-Deck':'1'}:{},signal:AbortSignal.timeout(15000)});if(r.status===401){location.reload();throw Error('Bitte erneut anmelden.')}if(!r.ok)throw Error(`API-Anfrage fehlgeschlagen (${r.status})`);return r.json()}
const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</strong></div>`; const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</strong></div>`;
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`; const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']}; const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
if(page==='access'){await accessPage();return;}
if(page==='network'){await networkPage();return;} 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>`; if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden.');if(!h.available)html+=`<section class="empty">Hardware nicht verfügbar<p>${esc(h.errors.join(' '))}</p></section>`;else{const c=h.cpu,r=h.ram;html+=`<div class="grid"><section class="card"><div class="label">CPU</div><h2>${esc(c.name)}</h2><div class="metrics">${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}</div>${bar(c.percent)}</section><section class="card"><div class="label">System-RAM</div><h2>Arbeitsspeicher</h2><div class="metrics">${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}</div>${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}</section>`;for(const g of h.gpus)html+=`<section class="card"><div class="card-top"><span class="label">GPU ${g.index}</span><span class="tag">${fmt(g.temperature_c,' °C')}</span></div><h2>${esc(g.name)}</h2><div class="metrics">${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}</div>${bar(g.percent)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;} }else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden.');if(!h.available)html+=`<section class="empty">Hardware nicht verfügbar<p>${esc(h.errors.join(' '))}</p></section>`;else{const c=h.cpu,r=h.ram;html+=`<div class="grid"><section class="card"><div class="label">CPU</div><h2>${esc(c.name)}</h2><div class="metrics">${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}</div>${bar(c.percent)}</section><section class="card"><div class="label">System-RAM</div><h2>Arbeitsspeicher</h2><div class="metrics">${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}</div>${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}</section>`;for(const g of h.gpus)html+=`<section class="card"><div class="card-top"><span class="label">GPU ${g.index}</span><span class="tag">${fmt(g.temperature_c,' °C')}</span></div><h2>${esc(g.name)}</h2><div class="metrics">${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}</div>${bar(g.percent)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;}
+140
View File
@@ -0,0 +1,140 @@
"""Single administrator credentials, independent API token and atomic persistence."""
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import secrets
import threading
import time
ITERATIONS = 600000
def password_hash(password):
if not isinstance(password, str) or not 16 <= len(password) <= 256:
raise ValueError('Das Kennwort muss 16 bis 256 Zeichen lang sein.')
salt = secrets.token_bytes(16)
return dict(salt=salt.hex(), hash=hashlib.pbkdf2_hmac('sha256', password.encode(), salt, ITERATIONS).hex())
def verify_password(password, record):
if not isinstance(password, str) or len(password) > 256 or not record:
return False
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(record['salt']), ITERATIONS).hex()
return hmac.compare_digest(actual, record['hash'])
def token_hash(token):
if not isinstance(token, str) or not re.fullmatch(r'[A-Za-z0-9_-]{32,256}', token):
raise ValueError('Der API-Token muss 32 bis 256 Zeichen enthalten: Buchstaben, Ziffern, - oder _.')
return hashlib.sha256(token.encode()).hexdigest()
def normalize(record):
# Existing 0.2 installs keep their password; API access stays disabled until a token is set.
if record and set(record) == {'salt', 'hash'}:
return dict(version=1, password=record, revision=record['hash'], api_token_hash=None,
password_changed_at=None, token_changed_at=None)
return record
def validate_record(record):
if not isinstance(record, dict) or set(record) != {'version','password','revision','api_token_hash','password_changed_at','token_changed_at'}:
raise ValueError('Ungültiger Zugangsdatenstand.')
p = record['password']
if record['version'] != 1 or not isinstance(p, dict) or set(p) != {'salt','hash'}:
raise ValueError('Ungültiger Zugangsdatenstand.')
if not re.fullmatch('[a-f0-9]{32}', str(p['salt'])) or not re.fullmatch('[a-f0-9]{64}', str(p['hash'])):
raise ValueError('Ungültiger Zugangsdatenstand.')
if not re.fullmatch('[a-f0-9]{32,64}', str(record['revision'])):
raise ValueError('Ungültiger Zugangsdatenstand.')
if record['api_token_hash'] is not None and not re.fullmatch('[a-f0-9]{64}', str(record['api_token_hash'])):
raise ValueError('Ungültiger Zugangsdatenstand.')
for field in ('password_changed_at','token_changed_at'):
if record[field] is not None and (not isinstance(record[field], (float,int)) or not 0 < record[field] < 1e12):
raise ValueError('Ungültiger Zugangsdatenstand.')
return record
def initial_record(password, token):
if password == token:
raise ValueError('Oberflächenkennwort und API-Token müssen verschieden sein.')
now = time.time()
return dict(version=1, password=password_hash(password), revision=secrets.token_hex(16),
api_token_hash=token_hash(token), password_changed_at=now, token_changed_at=now)
def atomic_write(path, record):
path = Path(path)
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = path.with_name(path.name+'.'+secrets.token_hex(8)+'.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd,'w') as stream:
json.dump(record,stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary,path)
finally:
temporary.unlink(missing_ok=True)
class CredentialStore:
def __init__(self, path=None, rpc=None):
self.path = Path(path) if path else None
self.rpc = rpc
self.lock = threading.RLock()
def read(self):
with self.lock:
if self.rpc:
record = self.rpc({'action':'credentials-read'})['credentials']
else:
try:
record = json.loads(self.path.read_text())
except FileNotFoundError:
return None
return validate_record(normalize(record)) if record else None
def write(self, record, expected):
validate_record(record)
if self.rpc:
self.rpc({'action':'credentials-write','expected_revision':expected,'credentials':record})
else:
current = self.read()
if (current['revision'] if current else None) != expected:
raise ValueError('Zugangsdaten wurden inzwischen geändert. Bitte erneut anmelden.')
atomic_write(self.path, record)
def setup(self, password, token):
with self.lock:
if self.read() is not None:
raise ValueError('Die Ersteinrichtung ist bereits abgeschlossen.')
record = initial_record(password,token)
self.write(record,None)
return record
def change(self, kind, current_password, value):
with self.lock:
record = self.read()
if not record or not verify_password(current_password,record['password']):
raise ValueError('Das aktuelle Kennwort ist nicht korrekt.')
expected = record['revision']
if kind == 'password':
if verify_password(value,record['password']):
raise ValueError('Bitte ein anderes neues Kennwort wählen.')
if record['api_token_hash'] and isinstance(value,str) and hmac.compare_digest(hashlib.sha256(value.encode()).hexdigest(),record['api_token_hash']):
raise ValueError('Oberflächenkennwort und API-Token müssen verschieden sein.')
record.update(password=password_hash(value), password_changed_at=time.time(), revision=secrets.token_hex(16))
elif kind == 'token':
digest = token_hash(value)
if verify_password(value,record['password']):
raise ValueError('Oberflächenkennwort und API-Token müssen verschieden sein.')
if record['api_token_hash'] and hmac.compare_digest(digest,record['api_token_hash']):
raise ValueError('Bitte einen anderen neuen API-Token wählen.')
record.update(api_token_hash=digest,token_changed_at=time.time(),revision=secrets.token_hex(16))
else:
raise ValueError('Unbekannte Zugangsdatenaktion.')
self.write(record,expected)
return record
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/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> <!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="/access-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">Netzwerk <span>07</span></a><a href="#access">Zugang &amp; API <span>08</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.3 · Prototyp</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck · 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> <!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck · Zugang</title><link rel="stylesheet" href="/style.css"><script src="/login.js" defer></script></head><body><main class="login"><div class="kicker">ATHENA DECK / ZUGANG</div><h1 id="title">Zugang wird geprüft …</h1><p id="intro"></p><form id="login" hidden><label for="password">Oberflächenkennwort</label><input id="password" type="password" autocomplete="current-password" required maxlength="256"><div id="setup-fields" hidden><label for="repeat">Kennwort wiederholen</label><input id="repeat" type="password" autocomplete="new-password" maxlength="256"><label for="api-token">Separater API-Token</label><input id="api-token" type="password" autocomplete="off" spellcheck="false" minlength="32" maxlength="256"><button id="generate" type="button" class="secondary">Sicheren Token erzeugen</button><button id="show-token" type="button" class="secondary">Token anzeigen</button><button id="copy" type="button" class="secondary">Token kopieren</button><p>Speichere den Token jetzt in deinem Passwortmanager. Er wird später nicht wieder angezeigt. Mindestens 32 Zeichen, getrennt vom Kennwort.</p><p>Das Oberflächenkennwort benötigt mindestens 16 Zeichen. Es wird ein Administratorzugang eingerichtet.</p></div><button id="submit">Anmelden</button></form><p id="error" role="status" aria-live="polite"></p><p class="note">Für den Serverzugriff WireGuard oder SSH verwenden. Direkter LAN-Zugriff verwendet derzeit HTTP.</p></main></body></html>
+9 -1
View File
@@ -1 +1,9 @@
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}}); let firstSetup=false;
const form=document.querySelector('#login'),message=document.querySelector('#error');
function freshToken(){const bytes=crypto.getRandomValues(new Uint8Array(32));return 'ad_'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
async function load(){try{const r=await fetch('/api/v1/auth/status'),s=await r.json();firstSetup=!s.initialized;document.querySelector('#title').textContent=firstSetup?'Dein Zugang zu Athena.':'Willkommen zurück.';document.querySelector('#intro').textContent=firstSetup?'Lege das Kennwort für diese Oberfläche und einen separaten Token für API-Clients fest.':'Melde dich mit deinem Oberflächenkennwort an.';document.querySelector('#setup-fields').hidden=!firstSetup;document.querySelector('#password').autocomplete=firstSetup?'new-password':'current-password';document.querySelector('#submit').textContent=firstSetup?'Zugang einrichten':'Anmelden';form.hidden=false;}catch(e){message.textContent='Zugangsstatus nicht erreichbar.'}}
document.querySelector('#generate').onclick=()=>{document.querySelector('#api-token').value=freshToken();message.textContent='Token erzeugt. Vor dem Einrichten kopieren und sicher speichern.';};
document.querySelector('#show-token').onclick=()=>{const input=document.querySelector('#api-token');input.type=input.type==='password'?'text':'password';document.querySelector('#show-token').textContent=input.type==='password'?'Token anzeigen':'Token verbergen';};
document.querySelector('#copy').onclick=async()=>{try{const token=document.querySelector('#api-token').value;if(!token)throw Error();await navigator.clipboard.writeText(token);message.textContent='Token kopiert. Bitte sicher speichern.';}catch{message.textContent='Token konnte nicht kopiert werden. Bitte einen eigenen Token aus deinem Passwortmanager einfügen.'}};
form.addEventListener('submit',async event=>{event.preventDefault();const input=document.querySelector('#password'),button=document.querySelector('#submit');button.disabled=true;try{let data={password:input.value},path='login';if(firstSetup){if(input.value!==document.querySelector('#repeat').value)throw Error('Die Kennwörter stimmen nicht überein.');data.api_token=document.querySelector('#api-token').value;path='auth/setup';}const r=await fetch('/api/v1/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const value=await r.json();if(!r.ok)throw Error(value.error);form.reset();if(firstSetup){await load();message.textContent='Zugang eingerichtet. Bitte jetzt anmelden.';}else location.reload();}catch(e){message.textContent=e.message}finally{button.disabled=false}});
load();
+6 -3
View File
@@ -2,7 +2,7 @@
let networkBusy=false, networkState=null; let networkBusy=false, networkState=null;
async function networkRequest(action,body={}){ 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 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; const value=await response.json();if(response.status===401){location.reload();throw Error('Bitte erneut anmelden.')}if(!response.ok)throw Error(value.error||'Netzwerkaktion fehlgeschlagen.');return value;
} }
async function networkAction(action,body={}){ async function networkAction(action,body={}){
if(networkBusy)return;networkBusy=true;networkButtons(); if(networkBusy)return;networkBusy=true;networkButtons();
@@ -29,8 +29,11 @@ function networkButtons(){
async function networkPage(){ async function networkPage(){
const view=document.querySelector('#view'); const view=document.querySelector('#view');
if(!document.querySelector('#network-page')){ 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>`; 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"><label for="network-api-token">Eigener API-Token für die Server-Instanz</label><input id="network-api-token" type="password" autocomplete="off" minlength="32" maxlength="256"><button id="network-generate-token" type="button" class="secondary">API-Token erzeugen</button><button id="network-show-token" type="button" class="secondary">API-Token anzeigen</button><button id="network-copy-token" type="button" class="secondary">API-Token kopieren</button><p>Vor der Installation kopieren und sicher speichern. Dieser Token ist vom lokalen Mac-Zugang getrennt.</p><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-install').onclick=()=>{const input=document.querySelector('#network-password'),password=input.value,token=document.querySelector('#network-api-token'),api_token=token.value;input.value='';token.value='';networkAction('install',{password,api_token});};
document.querySelector('#network-generate-token').onclick=()=>{document.querySelector('#network-api-token').value=accessToken();};
document.querySelector('#network-show-token').onclick=()=>{const input=document.querySelector('#network-api-token');input.type=input.type==='password'?'text':'password';document.querySelector('#network-show-token').textContent=input.type==='password'?'API-Token anzeigen':'API-Token verbergen';};
document.querySelector('#network-copy-token').onclick=async()=>{try{const token=document.querySelector('#network-api-token').value;if(!token)throw Error();await navigator.clipboard.writeText(token);document.querySelector('#network-message').textContent='API-Token kopiert. Bitte sicher speichern.';}catch{document.querySelector('#network-message').textContent='Kopieren nicht möglich. Bitte eigenen Token verwenden.'}};
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});}; 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); 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-apply').onclick=()=>networkAction('mode',{mode:document.querySelector('#network-mode').value});
+5 -3
View File
@@ -1,4 +1,4 @@
# WireGuard-Modul · Athena Deck 0.2 # WireGuard-Modul · Athena Deck 0.3
## Was eingebaut ist ## Was eingebaut ist
@@ -24,7 +24,8 @@ Port-Veröffentlichung; eine Aussage „keinerlei Netzwerkänderung“ wäre daf
## Einrichtung in der Oberfläche ## Einrichtung in der Oberfläche
1. Auf dem Mac http://127.0.0.1:8108/#network öffnen. 1. Auf dem Mac http://127.0.0.1:8108/#network öffnen.
2. Eigenes Deck-Passwort (mindestens 16 Zeichen) vergeben und **Modul auf Athena 2. Eigenes Deck-Passwort (mindestens 16 Zeichen) und separaten API-Token
(mindestens 32 Zeichen) vergeben, Token sichern und **Modul auf Athena
installieren** wählen. Installation läuft asynchron und meldet ihren Status. installieren** wählen. Installation läuft asynchron und meldet ihren Status.
3. Eigene IPv4-WireGuard-Conf importieren. **Einen eigenen Peer verwenden**, nicht 3. Eigene IPv4-WireGuard-Conf importieren. **Einen eigenen Peer verwenden**, nicht
die Konfiguration des produktiven Athena-Gateways wiederverwenden. die Konfiguration des produktiven Athena-Gateways wiederverwenden.
@@ -67,6 +68,7 @@ HTTPS/Reverse-Proxy-Zertifikate sind noch nicht enthalten. Passwort wird mit
PBKDF2-SHA256 (600.000 Iterationen, individuellem Salt) gespeichert. Sitzungen sind PBKDF2-SHA256 (600.000 Iterationen, individuellem Salt) gespeichert. Sitzungen sind
HttpOnly/SameSite=Strict, acht Stunden gültig und werden nach Neustart ungültig. HttpOnly/SameSite=Strict, acht Stunden gültig und werden nach Neustart ungültig.
Ein Anmeldelimit begrenzt Versuche auf zehn pro Minute. Kein Standardpasswort. Ein Anmeldelimit begrenzt Versuche auf zehn pro Minute. Kein Standardpasswort.
Kennwort-/Tokenwechsel und Rechte sind in [Zugang und API](../ACCESS.md) beschrieben.
Im LAN-/Beides-Modus ist zusätzlich ein SSH-Tunnel zur Server-GUI möglich: Im LAN-/Beides-Modus ist zusätzlich ein SSH-Tunnel zur Server-GUI möglich:
@@ -123,7 +125,7 @@ serverseitige Prüfung des tatsächlichen Zugangs gelten vor dem API-Aufruf.
|---|---| |---|---|
| POST `/login` | `password` | | POST `/login` | `password` |
| GET `/network` | Status ohne Secrets | | GET `/network` | Status ohne Secrets |
| POST `/network/install` | `password` für neue Server-Instanz, nur Mac-Verwaltung | | POST `/network/install` | `password`, `api_token` für neue Server-Instanz, nur angemeldete Mac-Verwaltung |
| POST `/network/import` | `config` als Dateiinhalt | | POST `/network/import` | `config` als Dateiinhalt |
| POST `/network/connect` | `{}` | | POST `/network/connect` | `{}` |
| POST `/network/disconnect` | `{}` | | POST `/network/disconnect` | `{}` |
+12 -2
View File
@@ -17,6 +17,7 @@ import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from auth import normalize, validate_record
from network.config import parse_config, wireguard_text from network.config import parse_config, wireguard_text
from network.policy import Policy from network.policy import Policy
@@ -87,7 +88,7 @@ class Proxy(BaseHTTPRequestHandler):
if not 0 <= length <= 32768 or len(self.path)>2048: if not 0 <= length <= 32768 or len(self.path)>2048:
raise ValueError() raise ValueError()
body = self.rfile.read(length) if length else None 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 = {name: self.headers[name] for name in ('Host', 'Origin', 'Content-Type', 'Cookie', 'Authorization', 'X-Athena-Deck') if name in self.headers}
headers.update({'X-Deck-Proxy': PROXY_TOKEN, 'X-Deck-Ingress': self.server.ingress}) headers.update({'X-Deck-Proxy': PROXY_TOKEN, 'X-Deck-Ingress': self.server.ingress})
conn = http.client.HTTPConnection('127.0.0.1', 8108, timeout=30) conn = http.client.HTTPConnection('127.0.0.1', 8108, timeout=30)
try: try:
@@ -188,6 +189,15 @@ class Agent:
# Ingress may only be asserted by the web child using the private proxy token. # 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): if not secrets.compare_digest(str(data.get('proxy_token', '')), PROXY_TOKEN):
ingress = 'management' ingress = 'management'
if action == 'credentials-read':
return {'credentials': validate_record(normalize(json.loads((DATA/'auth.json').read_text())))}
if action == 'credentials-write':
current = validate_record(normalize(json.loads((DATA/'auth.json').read_text())))
if current['revision'] != data.get('expected_revision'):
raise ValueError('Zugangsdaten wurden inzwischen geändert. Bitte erneut anmelden.')
record = validate_record(data.get('credentials'))
save(DATA/'auth.json', record)
return {'saved':True}
if action == 'status': if action == 'status':
return self.status(ingress) return self.status(ingress)
if action == 'import': if action == 'import':
@@ -260,7 +270,7 @@ def main():
if not auth.exists(): if not auth.exists():
raise SystemExit('Authentication must be provisioned before container startup.') raise SystemExit('Authentication must be provisioned before container startup.')
env = os.environ.copy() 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') env.update(DECK_PROXY_TOKEN=PROXY_TOKEN, DECK_AUTH_RPC='1', 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) 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'] address = json.loads(command('ip', '-j', '-4', 'addr', 'show', 'dev', 'eth0'))[0]['addr_info'][0]['local']
lan = DeviceServer(address, 'eth0', 'lan') lan = DeviceServer(address, 'eth0', 'lan')
+3 -3
View File
@@ -1,4 +1,5 @@
"""Local management via fixed SSH target or container-private Unix RPC.""" """Local management via fixed SSH target or container-private Unix RPC."""
from auth import initial_record
import base64 import base64
import hashlib import hashlib
import json import json
@@ -51,16 +52,15 @@ class NetworkClient:
self.cached_at = time.monotonic() self.cached_at = time.monotonic()
return dict(result, ingress=ingress, job=self.job) return dict(result, ingress=ingress, job=self.job)
def install(self, password): def install(self, password, api_token=None):
if self.local: if self.local:
raise ValueError('Die Server-Instanz ist bereits installiert.') raise ValueError('Die Server-Instanz ist bereits installiert.')
if not isinstance(password, str) or not 16 <= len(password) <= 256: if not isinstance(password, str) or not 16 <= len(password) <= 256:
raise ValueError('Bitte ein eigenes Deck-Passwort mit mindestens 16 Zeichen setzen.') raise ValueError('Bitte ein eigenes Deck-Passwort mit mindestens 16 Zeichen setzen.')
auth = initial_record(password,api_token)
with self.lock: with self.lock:
if self.job and self.job['state'] == 'running': if self.job and self.job['state'] == 'running':
raise ValueError('Installation läuft bereits.') 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.') 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() threading.Thread(target=self._install, args=(auth,), daemon=True).start()
return dict(job=self.job.copy()) return dict(job=self.job.copy())
+6 -8
View File
@@ -12,7 +12,7 @@ import sys
NAME = 'athena-deck-network' NAME = 'athena-deck-network'
BASE = Path('/opt/athena-deck') 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'} FILES = {'auth.py', 'access-ui.js', '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): def run(*args, **kwargs):
return subprocess.run(args, capture_output=True, timeout=600, **kwargs) return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
@@ -47,16 +47,14 @@ def main():
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(encoded, validate=True)) target.write_bytes(base64.b64decode(encoded, validate=True))
target.chmod(0o644) target.chmod(0o644)
result = run('docker', 'build', '-t', 'athena-deck-network:0.2', '-f', str(source/'network/Dockerfile'), str(source)) result = run('docker', 'build', '-t', 'athena-deck-network:0.3', '-f', str(source/'network/Dockerfile'), str(source))
if result.returncode: if result.returncode:
raise ValueError('Container-Build fehlgeschlagen. Docker benötigt Zugriff auf die Paketquellen.') raise ValueError('Container-Build fehlgeschlagen. Docker benötigt Zugriff auf die Paketquellen.')
state = BASE/'state' state = BASE/'state'
state.mkdir(mode=0o700, exist_ok=True) state.mkdir(mode=0o700, exist_ok=True)
auth = payload['auth'] sys.path.insert(0,str(source))
if set(auth) != {'salt', 'hash'} or len(auth['salt']) != 32 or len(auth['hash']) != 64: from auth import validate_record
raise ValueError('Ungültige Zugangsdaten.') auth = validate_record(payload['auth'])
int(auth['salt'], 16)
int(auth['hash'], 16)
fd = os.open(state/'auth.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) fd = os.open(state/'auth.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as stream: with os.fdopen(fd, 'w') as stream:
json.dump(auth, stream) json.dump(auth, stream)
@@ -65,7 +63,7 @@ def main():
'--security-opt', 'no-new-privileges:true', '--pids-limit', '128', '--memory', '256m', '--cpus', '0.5', '--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', '--tmpfs', '/run:rw,nosuid,nodev,size=8m', '--tmpfs', '/tmp:rw,nosuid,nodev,size=8m',
'-v', str(state)+':/data:rw', '-e', 'DECK_LAN_IP='+lan, '-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') '-p', '127.0.0.1:8110:8110', '-p', lan+':8110:8110', 'athena-deck-network:0.3')
if result.returncode: if result.returncode:
# Auth remains protected for manual recovery; never delete an existing container. # Auth remains protected for manual recovery; never delete an existing container.
raise ValueError('Containerstart fehlgeschlagen. Gesicherter Installationsstand liegt unter /opt/athena-deck.') raise ValueError('Containerstart fehlgeschlagen. Gesicherter Installationsstand liegt unter /opt/athena-deck.')
+31 -3
View File
@@ -35,14 +35,15 @@ v=json.load(sys.stdin)
c=http.client.HTTPConnection(v['host'],8110,timeout=5) c=http.client.HTTPConnection(v['host'],8110,timeout=5)
h={'Content-Type':'application/json','X-Athena-Deck':'1'} h={'Content-Type':'application/json','X-Athena-Deck':'1'}
if v.get('cookie'):h['Cookie']=v['cookie'] if v.get('cookie'):h['Cookie']=v['cookie']
if v.get('token'):h['Authorization']='Bearer '+v['token']
if v.get('origin'):h['Origin']=v['origin'] 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) 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() r=c.getresponse();b=r.read().decode()
print(json.dumps({'status':r.status,'body':b,'cookie':r.getheader('Set-Cookie')})) print(json.dumps({'status':r.status,'body':b,'cookie':r.getheader('Set-Cookie')}))
''' '''
def http(host,path='/',method='GET',body=None,cookie=None): def http(host,path='/',method='GET',body=None,cookie=None,token=None):
value=dict(host=host,path=path,method=method,cookie=cookie) value=dict(host=host,path=path,method=method,cookie=cookie,token=token)
if body is not None:value['body']=body if body is not None:value['body']=body
return execute(PEER,HTTP,value) return execute(PEER,HTTP,value)
@@ -56,7 +57,8 @@ def main():
state=tempfile.TemporaryDirectory(prefix='athena-deck-test-') state=tempfile.TemporaryDirectory(prefix='athena-deck-test-')
password=secrets.token_urlsafe(32) password=secrets.token_urlsafe(32)
salt=secrets.token_bytes(16) salt=secrets.token_bytes(16)
auth=dict(salt=salt.hex(),hash=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex()) api_token=secrets.token_urlsafe(32)
auth=dict(version=1,password=dict(salt=salt.hex(),hash=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex()),revision=secrets.token_hex(16),api_token_hash=hashlib.sha256(api_token.encode()).hexdigest(),password_changed_at=time.time(),token_changed_at=time.time())
p=Path(state.name)/'auth.json';p.write_text(json.dumps(auth));p.chmod(0o600) p=Path(state.name)/'auth.json';p.write_text(json.dumps(auth));p.chmod(0o600)
try: try:
run('docker','run','-d','--name',PEER,'--cap-drop','ALL','--cap-add','NET_ADMIN',IMAGE,'sleep','600') run('docker','run','-d','--name',PEER,'--cap-drop','ALL','--cap-add','NET_ADMIN',IMAGE,'sleep','600')
@@ -78,6 +80,22 @@ def main():
cookie=login['cookie'].split(';')[0] cookie=login['cookie'].split(';')[0]
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200 assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200
print('PASS real LAN login and authenticated API',flush=True) print('PASS real LAN login and authenticated API',flush=True)
assert http(deckip,'/api/v1/status',token=api_token)['status']==200
assert http(deckip,'/api/v1/network',token=api_token)['status']==401
next_token=secrets.token_urlsafe(32)
assert http(deckip,'/api/v1/auth/token','POST',{'current_password':password,'new_token':next_token},cookie)['status']==200
assert http(deckip,'/api/v1/status',token=api_token)['status']==401
assert http(deckip,'/api/v1/status',token=next_token)['status']==200
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200
next_password=secrets.token_urlsafe(32)
assert http(deckip,'/api/v1/auth/password','POST',{'current_password':password,'new_password':next_password},cookie)['status']==200
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==401
assert http(deckip,'/api/v1/login','POST',{'password':password})['status']==401
password=next_password
login=http(deckip,'/api/v1/login','POST',{'password':password})
assert login['status']==200
cookie=login['cookie'].split(';')[0]
print('PASS proxy forwards API token; rotations persist through privileged helper; old credentials rejected',flush=True)
# Keys stay in this Python process and stdin pipes; never printed. # Keys stay in this Python process and stdin pipes; never printed.
private_a=run('docker','exec',PEER,'wg','genkey') private_a=run('docker','exec',PEER,'wg','genkey')
private_b=run('docker','exec',PEER,'wg','genkey') private_b=run('docker','exec',PEER,'wg','genkey')
@@ -132,6 +150,16 @@ cmd('ip','route','add','10.240.77.1/32','dev','peerwg0')
except Exception:time.sleep(.5) except Exception:time.sleep(.5)
assert status['mode']=='lan' and not status['pending'] assert status['mode']=='lan' and not status['pending']
print('PASS restart discards unconfirmed trial and restores confirmed mode',flush=True) print('PASS restart discards unconfirmed trial and restores confirmed mode',flush=True)
for _ in range(20):
try:
restored=http(deckip,'/api/v1/login','POST',{'password':password})
if restored['status']==200:break
except Exception:pass
time.sleep(.5)
else:raise RuntimeError('Changed password did not survive container restart')
assert http(deckip,'/api/v1/status',token=next_token)['status']==200
assert http(deckip,'/api/v1/status',token=api_token)['status']==401
print('PASS changed password and token survive container restart',flush=True)
after=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else '' after=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
assert baseline==after assert baseline==after
print('PASS production container start times unchanged',flush=True) print('PASS production container start times unchanged',flush=True)
+110 -35
View File
@@ -4,7 +4,8 @@ import argparse
import os import os
import hashlib import hashlib
import secrets import secrets
from http.cookies import SimpleCookie from http.cookies import SimpleCookie, CookieError
from auth import CredentialStore, verify_password
from network.client import NetworkClient from network.client import NetworkClient
from network.config import parse_config, ConfigError from network.config import parse_config, ConfigError
import json import json
@@ -91,13 +92,17 @@ class HardwareProvider:
class Server(ThreadingHTTPServer): class Server(ThreadingHTTPServer):
daemon_threads = True daemon_threads = True
def __init__(self, port): def __init__(self, port, state_dir=None):
super().__init__(('127.0.0.1', port), Handler) super().__init__(('127.0.0.1', port), Handler)
self.demo = DemoService() self.demo = DemoService()
self.hardware = HardwareProvider() self.hardware = HardwareProvider()
self.started = time.time() self.started = time.time()
self.network = NetworkClient() self.network = NetworkClient()
self.auth = json.loads(os.environ['DECK_AUTH_JSON']) if os.environ.get('DECK_AUTH_JSON') else None if os.environ.get('DECK_AUTH_RPC') == '1':
from network.rpc import request
self.credentials = CredentialStore(rpc=request)
else:
self.credentials = CredentialStore(Path(state_dir or os.environ.get('DECK_STATE_DIR', ROOT/'.state'))/'auth.json')
self.sessions = {} self.sessions = {}
self.login_attempts = [] self.login_attempts = []
self.auth_lock = threading.Lock() self.auth_lock = threading.Lock()
@@ -123,16 +128,44 @@ class Handler(BaseHTTPRequestHandler):
return self.headers.get('X-Deck-Ingress', 'management') return self.headers.get('X-Deck-Ingress', 'management')
return 'management' return 'management'
def authenticated(self): def api_token_authenticated(self):
if self.server.auth is None: header = self.headers.get('Authorization', '')
return True if not header.startswith('Bearer ') or len(header)>300:
return False
record = self.server.credentials.read()
if not record or not record['api_token_hash']:
return False
actual = hashlib.sha256(header[7:].encode()).hexdigest()
return secrets.compare_digest(actual,record['api_token_hash'])
def token_route(self):
return (self.command == 'GET' and self.path in ('/api/v1/status','/api/v1/hardware','/api/v1/demo')) or (self.command == 'POST' and self.path in ('/api/v1/demo/start','/api/v1/demo/stop'))
def session_token(self):
try: try:
cookie = SimpleCookie(self.headers.get('Cookie', '')) return SimpleCookie(self.headers.get('Cookie',''))['deck_session'].value
token = cookie['deck_session'].value except (KeyError, ValueError, CookieError):
except (KeyError, ValueError): return None
def authenticated(self, session_only=False):
if self.headers.get('Authorization'):
return not session_only and self.token_route() and self.api_token_authenticated()
record = self.server.credentials.read()
if not record:
return False return False
with self.server.auth_lock: with self.server.auth_lock:
return self.server.sessions.get(token, 0) > time.monotonic() session = self.server.sessions.get(self.session_token())
return bool(session and session['expires']>time.monotonic() and secrets.compare_digest(session['revision'],record['password']['hash']))
def allow_password_attempt(self):
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:
self.respond({'error':'Zu viele Versuche. Bitte eine Minute warten.'},429)
return False
self.server.login_attempts.append(now)
return True
def read_json(self): def read_json(self):
self.connection.settimeout(10) self.connection.settimeout(10)
@@ -151,28 +184,21 @@ class Handler(BaseHTTPRequestHandler):
return value return value
def login(self): def login(self):
if self.server.auth is None: if not self.allow_password_attempt():
return self.respond({'authenticated': True}) return
try: try:
data = self.read_json() data = self.read_json()
password = data.get('password', '') with self.server.credentials.lock:
if not isinstance(password, str) or len(password)>256: record = self.server.credentials.read()
raise ValueError() if not record or not verify_password(data.get('password'),record['password']):
with self.server.auth_lock: return self.respond({'error':'Anmeldung fehlgeschlagen.'},401)
token = secrets.token_urlsafe(32)
now = time.monotonic() now = time.monotonic()
self.server.login_attempts = [t for t in self.server.login_attempts if now-t<60] with self.server.auth_lock:
if len(self.server.login_attempts)>=10: self.server.sessions = {k:v for k,v in self.server.sessions.items() if v['expires']>now}
return self.respond({'error':'Zu viele Anmeldeversuche. Bitte eine Minute warten.'}, 429) if len(self.server.sessions)>=64:
self.server.login_attempts.append(now) self.server.sessions.pop(next(iter(self.server.sessions)))
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(self.server.auth['salt']), 600000).hex() self.server.sessions[token] = dict(expires=now+28800,revision=record['password']['hash'])
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}' body = b'{"authenticated":true}'
self.send_response(200) self.send_response(200)
self.send_header('Content-Type','application/json') self.send_header('Content-Type','application/json')
@@ -184,6 +210,32 @@ class Handler(BaseHTTPRequestHandler):
except (ValueError, OSError): except (ValueError, OSError):
self.respond({'error':'Ungültige Anmeldung.'},400) self.respond({'error':'Ungültige Anmeldung.'},400)
def access_status(self):
record = self.server.credentials.read()
signed_in = self.authenticated(session_only=True)
value = dict(initialized=record is not None, authenticated=signed_in)
if signed_in:
value.update(api_token_configured=bool(record['api_token_hash']), password_changed_at=record['password_changed_at'], token_changed_at=record['token_changed_at'], account='Administrator', location='Athena' if os.environ.get('DECK_AUTH_RPC') else 'Mac')
return self.respond(value)
def change_credentials(self, kind):
if not self.allow_password_attempt():
return
try:
data = self.read_json()
key = 'new_password' if kind == 'password' else 'new_token'
if set(data) != {'current_password',key}:
raise ValueError('Ungültige Zugangsdatenanfrage.')
self.server.credentials.change(kind,data['current_password'],data[key])
if kind == 'password':
with self.server.auth_lock:
self.server.sessions.clear()
return self.respond({'changed':True,'login_required':kind=='password'})
except ValueError as exc:
return self.respond({'error':str(exc)},400)
except OSError:
return self.respond({'error':'Zugangsdaten konnten nicht gespeichert werden.'},503)
def valid_host(self): def valid_host(self):
if os.environ.get('DECK_PROXY_TOKEN'): if os.environ.get('DECK_PROXY_TOKEN'):
return self.ingress() in ('lan','tunnel') return self.ingress() in ('lan','tunnel')
@@ -192,18 +244,20 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
if not self.valid_host(): if not self.valid_host():
return self.respond({'error': 'Host rejected'}, 403) return self.respond({'error': 'Host rejected'}, 403)
if self.path == '/api/v1/auth/status':
return self.access_status()
if self.path == '/login.js': if self.path == '/login.js':
return self.respond((ROOT/'login.js').read_bytes(), mime='text/javascript') return self.respond((ROOT/'login.js').read_bytes(), mime='text/javascript')
if not self.authenticated() and self.path not in ('/', '/style.css'): if not self.authenticated() and self.path not in ('/', '/style.css'):
return self.respond({'error':'Anmeldung erforderlich.'},401) return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/': if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') 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')} 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'), '/access-ui.js': ('access-ui.js', 'text/javascript')}
if self.path in routes: if self.path in routes:
name, mime = routes[self.path] name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime) return self.respond((ROOT/name).read_bytes(), mime=mime)
if self.path == '/api/v1/status': if self.path == '/api/v1/status':
return self.respond(dict(name='Athena Deck', version='0.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())) return self.respond(dict(name='Athena Deck', version='0.3.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location='Athena' if os.environ.get('DECK_AUTH_RPC') else 'Mac', demo=self.server.demo.status()))
if self.path == '/api/v1/hardware': if self.path == '/api/v1/hardware':
return self.respond(self.server.hardware.snapshot()) return self.respond(self.server.hardware.snapshot())
if self.path == '/api/v1/network': if self.path == '/api/v1/network':
@@ -214,12 +268,33 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
origin = self.headers.get('Origin') origin = self.headers.get('Origin')
if not self.valid_host() or self.headers.get('X-Athena-Deck') != '1' or (origin and origin != 'http://' + self.headers.get('Host', '')): bearer = self.token_route() and self.api_token_authenticated()
return self.respond({'error': 'Same-origin control required'}, 403) if not self.valid_host() or (origin and origin != 'http://' + self.headers.get('Host', '')) or (not bearer and self.headers.get('X-Athena-Deck') != '1'):
return self.respond({'error':'Same-origin control required'},403)
if self.path == '/api/v1/auth/setup':
try:
data = self.read_json()
if set(data) != {'password','api_token'}:
raise ValueError('Kennwort und API-Token werden benötigt.')
# Container installs always arrive provisioned. Never offer remote claim-on-first-use.
if os.environ.get('DECK_AUTH_RPC'):
return self.respond({'error':'Serverzugang wird bei der Installation eingerichtet.'},403)
self.server.credentials.setup(data['password'],data['api_token'])
return self.respond({'initialized':True})
except ValueError as exc:
return self.respond({'error':str(exc)},400)
except OSError:
return self.respond({'error':'Einrichtung konnte nicht gespeichert werden.'},503)
if self.path == '/api/v1/login': if self.path == '/api/v1/login':
return self.login() return self.login()
if not self.authenticated(): if not self.authenticated():
return self.respond({'error':'Anmeldung erforderlich.'},401) return self.respond({'error':'Anmeldung oder gültiger API-Token erforderlich.'},401)
if self.path in ('/api/v1/auth/password','/api/v1/auth/token'):
return self.change_credentials('password' if self.path.endswith('/password') else 'token')
if self.path == '/api/v1/logout':
with self.server.auth_lock:
self.server.sessions.pop(self.session_token(),None)
return self.respond({'logged_out':True})
if self.path.startswith('/api/v1/network/'): if self.path.startswith('/api/v1/network/'):
action = self.path.rsplit('/',1)[1] action = self.path.rsplit('/',1)[1]
if action not in ('install','import','connect','disconnect','mode','confirm','cancel','delete'): if action not in ('install','import','connect','disconnect','mode','confirm','cancel','delete'):
@@ -227,7 +302,7 @@ class Handler(BaseHTTPRequestHandler):
try: try:
data = self.read_json() data = self.read_json()
if action == 'install': if action == 'install':
result = self.server.network.install(data.get('password')) result = self.server.network.install(data.get('password'),data.get('api_token'))
else: else:
fields = {'import': {'config'}, 'mode': {'mode'}, 'confirm': {'trial_id'}}.get(action,set()) fields = {'import': {'config'}, 'mode': {'mode'}, 'confirm': {'trial_id'}}.get(action,set())
if set(data) != fields: if set(data) != fields:
+2
View File
@@ -1,2 +1,4 @@
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#101416;color:#e9edea}*{box-sizing:border-box}body{margin:0;display:flex;min-height:100vh}aside{width:270px;background:#141a1c;border-right:1px solid #2c3435;padding:36px 23px;display:flex;flex-direction:column;position:fixed;height:100vh}.brand{font-size:17px;letter-spacing:2px;text-decoration:none;color:inherit;display:flex;align-items:center;gap:8px}.brand span{background:#c5edaf;color:#172412;font-size:28px;border-radius:10px;padding:0 9px;margin-right:6px}.brand b{color:#a5adab;font-weight:400}.eyebrow{font-size:10px;letter-spacing:1.7px;color:#798783;margin-top:46px}nav{display:grid;gap:8px;margin-top:12px}nav a{color:#aeb9b5;text-decoration:none;padding:14px 12px;border-radius:7px;font-size:13px;display:flex;justify-content:space-between}nav a:hover{background:#20292a}nav a.active{background:#28362b;color:#d3efc5}nav span{opacity:.4;font-size:10px}footer{margin-top:auto;font-size:11px;color:#b6c2bc}footer p{line-height:1.8;color:#77857f}i{display:inline-block;width:7px;height:7px;background:#bdeba4;border-radius:50%;margin-right:7px}main{margin-left:270px;padding:30px 5vw 60px;width:calc(100% - 270px);max-width:1500px}header{display:flex;justify-content:space-between;align-items:center;font-size:10px;letter-spacing:2px;color:#7f9189;padding-bottom:40px}.pill{border:1px solid #35423b;padding:7px 11px;border-radius:20px;font-size:11px;letter-spacing:0;color:#c6d6ca}h1{font-size:40px;font-weight:500;letter-spacing:-1.5px;margin:12px 0}h2{font-weight:500;font-size:20px}p{color:#97a79e;line-height:1.65;font-size:14px}.kicker{color:#c5edaf;font-size:11px;letter-spacing:2px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;margin-top:30px}.card{background:#181f21;border:1px solid #303a37;border-radius:13px;padding:26px}.wide{grid-column:1/-1}.label{font-size:11px;color:#95a69b;text-transform:uppercase;letter-spacing:1px}.value{font-size:30px;margin:16px 0;color:#e4f1de}.card-top{display:flex;justify-content:space-between;align-items:center;gap:10px}.tag{color:#bdeba4;font-size:12px}.muted{color:#8b9c91}.metrics{display:flex;gap:34px;flex-wrap:wrap;margin:24px 0}.metrics strong{display:block;font-size:24px;font-weight:500;margin:8px 0}.metrics small{color:#8e9c93}button{font:inherit;padding:11px 20px;border-radius:7px;border:1px solid #465348;background:#c5edaf;color:#1a2a18;cursor:pointer;margin-right:8px}button.secondary{background:transparent;color:#dce7da}button:disabled{opacity:.35;cursor:default}button:focus-visible,a:focus-visible{outline:2px solid #d9fac7;outline-offset:4px}.note{border-top:1px solid #303a37;margin-top:25px;padding-top:20px;font-size:12px}.empty{padding:46px 30px;margin-top:30px;border:1px dashed #3b4840;border-radius:12px}.empty .symbol{font-size:35px;color:#bdd4b2}#error{color:#ffc1a7}progress{width:100%;height:6px;accent-color:#bce4a6}progress::-webkit-progress-bar{background:#303a37;border-radius:10px}progress::-webkit-progress-value{background:#bce4a6;border-radius:10px}@media(max-width:850px){aside{position:static;width:100%;height:auto;padding:20px}body{display:block}nav{grid-template-columns:repeat(2,1fr)}footer{display:none}main{margin:0;width:100%;padding:28px 20px}.eyebrow{margin-top:22px}.grid{grid-template-columns:1fr}h1{font-size:32px}header{padding-bottom:20px}} :root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#101416;color:#e9edea}*{box-sizing:border-box}body{margin:0;display:flex;min-height:100vh}aside{width:270px;background:#141a1c;border-right:1px solid #2c3435;padding:36px 23px;display:flex;flex-direction:column;position:fixed;height:100vh}.brand{font-size:17px;letter-spacing:2px;text-decoration:none;color:inherit;display:flex;align-items:center;gap:8px}.brand span{background:#c5edaf;color:#172412;font-size:28px;border-radius:10px;padding:0 9px;margin-right:6px}.brand b{color:#a5adab;font-weight:400}.eyebrow{font-size:10px;letter-spacing:1.7px;color:#798783;margin-top:46px}nav{display:grid;gap:8px;margin-top:12px}nav a{color:#aeb9b5;text-decoration:none;padding:14px 12px;border-radius:7px;font-size:13px;display:flex;justify-content:space-between}nav a:hover{background:#20292a}nav a.active{background:#28362b;color:#d3efc5}nav span{opacity:.4;font-size:10px}footer{margin-top:auto;font-size:11px;color:#b6c2bc}footer p{line-height:1.8;color:#77857f}i{display:inline-block;width:7px;height:7px;background:#bdeba4;border-radius:50%;margin-right:7px}main{margin-left:270px;padding:30px 5vw 60px;width:calc(100% - 270px);max-width:1500px}header{display:flex;justify-content:space-between;align-items:center;font-size:10px;letter-spacing:2px;color:#7f9189;padding-bottom:40px}.pill{border:1px solid #35423b;padding:7px 11px;border-radius:20px;font-size:11px;letter-spacing:0;color:#c6d6ca}h1{font-size:40px;font-weight:500;letter-spacing:-1.5px;margin:12px 0}h2{font-weight:500;font-size:20px}p{color:#97a79e;line-height:1.65;font-size:14px}.kicker{color:#c5edaf;font-size:11px;letter-spacing:2px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;margin-top:30px}.card{background:#181f21;border:1px solid #303a37;border-radius:13px;padding:26px}.wide{grid-column:1/-1}.label{font-size:11px;color:#95a69b;text-transform:uppercase;letter-spacing:1px}.value{font-size:30px;margin:16px 0;color:#e4f1de}.card-top{display:flex;justify-content:space-between;align-items:center;gap:10px}.tag{color:#bdeba4;font-size:12px}.muted{color:#8b9c91}.metrics{display:flex;gap:34px;flex-wrap:wrap;margin:24px 0}.metrics strong{display:block;font-size:24px;font-weight:500;margin:8px 0}.metrics small{color:#8e9c93}button{font:inherit;padding:11px 20px;border-radius:7px;border:1px solid #465348;background:#c5edaf;color:#1a2a18;cursor:pointer;margin-right:8px}button.secondary{background:transparent;color:#dce7da}button:disabled{opacity:.35;cursor:default}button:focus-visible,a:focus-visible{outline:2px solid #d9fac7;outline-offset:4px}.note{border-top:1px solid #303a37;margin-top:25px;padding-top:20px;font-size:12px}.empty{padding:46px 30px;margin-top:30px;border:1px dashed #3b4840;border-radius:12px}.empty .symbol{font-size:35px;color:#bdd4b2}#error{color:#ffc1a7}progress{width:100%;height:6px;accent-color:#bce4a6}progress::-webkit-progress-bar{background:#303a37;border-radius:10px}progress::-webkit-progress-value{background:#bce4a6;border-radius:10px}@media(max-width:850px){aside{position:static;width:100%;height:auto;padding:20px}body{display:block}nav{grid-template-columns:repeat(2,1fr)}footer{display:none}main{margin:0;width:100%;padding:28px 20px}.eyebrow{margin-top:22px}.grid{grid-template-columns:1fr}h1{font-size:32px}header{padding-bottom:20px}}
label{display:block;margin:18px 0 8px;font-size:13px;color:#c4d1c6}input,select{display:block;width:100%;max-width:560px;padding:12px;margin-bottom:16px;background:#101718;color:#e4eee5;border:1px solid #465348;border-radius:7px;font:inherit}input[type=file]{font-size:13px}button{margin-bottom:8px}.network-message{white-space:pre-wrap;color:#d1eabf}.warning{color:#f2c891}.login{margin:10vh auto;width:100%;max-width:640px}.network-status{display:flex;gap:30px;flex-wrap:wrap}.network-status strong{display:block;margin:8px 0}.link{color:#c5edaf}.network-card a{color:#c5edaf}.network-card button{font-size:13px}.network-card .note{overflow-wrap:anywhere} 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}
#access-page .card{margin-top:20px}#access-message{color:#d1eabf;white-space:pre-wrap}
+139
View File
@@ -0,0 +1,139 @@
import hashlib
import json
import os
import secrets
import tempfile
import threading
import time
import unittest
import urllib.request
import urllib.error
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest.mock import patch
from auth import CredentialStore, initial_record, verify_password
from server import Server
class StoreTests(unittest.TestCase):
def test_persistence_and_no_plaintext(self):
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'auth.json'
store=CredentialStore(path)
password,token=secrets.token_urlsafe(32),secrets.token_urlsafe(32)
store.setup(password,token)
raw=path.read_text()
self.assertNotIn(password,raw);self.assertNotIn(token,raw)
self.assertEqual(path.stat().st_mode&0o777,0o600)
restored=CredentialStore(path)
self.assertTrue(verify_password(password,restored.read()['password']))
with self.assertRaises(ValueError):restored.setup(password,token)
new_password=secrets.token_urlsafe(32)
restored.change('password',password,new_password)
record=CredentialStore(path).read()
self.assertTrue(verify_password(new_password,record['password']))
self.assertFalse(verify_password(password,record['password']))
self.assertEqual(record['api_token_hash'],hashlib.sha256(token.encode()).hexdigest())
def test_write_failure_keeps_previous_credentials(self):
with tempfile.TemporaryDirectory() as directory:
store=CredentialStore(Path(directory)/'auth.json')
password=secrets.token_urlsafe(32)
before=store.setup(password,secrets.token_urlsafe(32))
with patch('auth.os.replace',side_effect=OSError('disk unavailable')):
with self.assertRaises(OSError):store.change('token',password,secrets.token_urlsafe(32))
self.assertEqual(store.read(),before)
def test_legacy_password_preserved_api_disabled(self):
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'auth.json'
password=secrets.token_urlsafe(32)
old=initial_record(password,secrets.token_urlsafe(32))['password']
path.write_text(json.dumps(old))
store=CredentialStore(path)
self.assertTrue(verify_password(password,store.read()['password']))
self.assertIsNone(store.read()['api_token_hash'])
store.change('token',password,secrets.token_urlsafe(32))
self.assertTrue(verify_password(password,store.read()['password']))
def test_bad_credentials_do_not_mutate(self):
with tempfile.TemporaryDirectory() as directory:
store=CredentialStore(Path(directory)/'auth.json')
p,t=secrets.token_urlsafe(32),secrets.token_urlsafe(32)
before=store.setup(p,t)
for kind,current,new in [('token','wrong',secrets.token_urlsafe(32)),('password',p,'short'),('token',p,p),('password',p,t),('token',p,t)]:
with self.assertRaises(ValueError):store.change(kind,current,new)
self.assertEqual(store.read(),before)
def test_simultaneous_setup_has_one_winner(self):
with tempfile.TemporaryDirectory() as directory:
store=CredentialStore(Path(directory)/'auth.json')
def setup(_):
try:store.setup(secrets.token_urlsafe(32),secrets.token_urlsafe(32));return True
except ValueError:return False
with ThreadPoolExecutor(max_workers=2) as pool:self.assertEqual(sum(pool.map(setup,range(2))),1)
class AccessAPITests(unittest.TestCase):
def setUp(self):
self.directory=tempfile.TemporaryDirectory()
self.server=Server(0,state_dir=self.directory.name)
self.thread=threading.Thread(target=self.server.serve_forever);self.thread.start()
self.base=f'http://127.0.0.1:{self.server.server_port}'
self.password=secrets.token_urlsafe(32);self.token=secrets.token_urlsafe(32)
def tearDown(self):
self.server.shutdown();self.server.demo.stop();self.server.server_close();self.thread.join();self.directory.cleanup()
def request(self,path,body=None,cookie=None,token=None,headers=None):
h={**(headers or {})}
if body is not None:h.update({'Content-Type':'application/json','X-Athena-Deck':'1'})
if cookie:h['Cookie']=cookie
if token:h['Authorization']='Bearer '+token
req=urllib.request.Request(self.base+path,data=json.dumps(body).encode() if body is not None else None,headers=h)
try:r=urllib.request.urlopen(req)
except urllib.error.HTTPError as e:r=e
with r:
raw=r.read()
return r.status,json.loads(raw),r.headers
def setup_login(self):
code,_,_=self.request('/api/v1/auth/setup',dict(password=self.password,api_token=self.token))
self.assertEqual(code,200)
return self.login(self.password)
def login(self,password):
code,_,headers=self.request('/api/v1/login',{'password':password})
self.assertEqual(code,200)
return headers['Set-Cookie'].split(';')[0]
def test_first_run_and_setup_guard(self):
self.assertEqual(self.request('/api/v1/status')[0],401)
self.assertFalse(self.request('/api/v1/auth/status')[1]['initialized'])
self.assertEqual(self.request('/api/v1/auth/setup',{'password':self.password,'api_token':self.token},headers={'Origin':'http://evil.test'})[0],403)
cookie=self.setup_login()
self.assertEqual(self.request('/api/v1/status',cookie=cookie)[0],200)
self.assertEqual(self.request('/api/v1/auth/setup',{'password':self.password,'api_token':self.token})[0],400)
def test_password_rotation_revokes_all_sessions_not_api(self):
first=self.setup_login();second=self.login(self.password)
new=secrets.token_urlsafe(32)
self.assertEqual(self.request('/api/v1/auth/password',dict(current_password=self.password,new_password=new),cookie=first)[0],200)
for cookie in (first,second):self.assertEqual(self.request('/api/v1/status',cookie=cookie)[0],401)
self.assertEqual(self.request('/api/v1/login',{'password':self.password})[0],401)
self.login(new)
self.assertEqual(self.request('/api/v1/status',token=self.token)[0],200)
def test_token_rotation_scopes_and_session_survival(self):
cookie=self.setup_login()
self.assertEqual(self.request('/api/v1/status',token=self.token)[0],200)
for path,body in [('/api/v1/network',None),('/api/v1/auth/password',dict(current_password=self.password,new_password=secrets.token_urlsafe(32))),('/api/v1/auth/token',dict(current_password=self.password,new_token=secrets.token_urlsafe(32))),('/api/v1/network/disconnect',{})]:
self.assertEqual(self.request(path,body,token=self.token)[0],401)
new=secrets.token_urlsafe(32)
self.assertEqual(self.request('/api/v1/auth/token',dict(current_password=self.password,new_token=new),cookie=cookie)[0],200)
self.assertEqual(self.request('/api/v1/status',token=self.token)[0],401)
self.assertEqual(self.request('/api/v1/status',token=new)[0],200)
self.assertEqual(self.request('/api/v1/status',cookie=cookie)[0],200)
status=self.request('/api/v1/auth/status',cookie=cookie)[1]
raw=json.dumps(status)
for secret in (new,self.token,self.password,'api_token_hash','salt'):self.assertNotIn(secret,raw)
def test_api_client_without_browser_headers(self):
self.setup_login()
req=urllib.request.Request(self.base+'/api/v1/demo/start',method='POST',headers={'Authorization':'Bearer '+self.token})
with urllib.request.urlopen(req) as r:self.assertTrue(json.load(r)['reachable'])
def test_wrong_password_and_logout(self):
cookie=self.setup_login()
self.assertEqual(self.request('/api/v1/auth/token',dict(current_password='wrong',new_token=secrets.token_urlsafe(32)),cookie=cookie)[0],400)
self.assertEqual(self.request('/api/v1/status',token=self.token)[0],200)
self.assertEqual(self.request('/api/v1/logout',{},cookie=cookie)[0],200)
self.assertEqual(self.request('/api/v1/status',cookie=cookie)[0],401)
if __name__=='__main__':unittest.main()
+13 -6
View File
@@ -5,6 +5,9 @@ import os
import secrets import secrets
import threading import threading
import unittest import unittest
import tempfile
import secrets
import time
import urllib.request import urllib.request
import urllib.error import urllib.error
from unittest.mock import patch from unittest.mock import patch
@@ -74,14 +77,20 @@ class PolicyTests(unittest.TestCase):
class APITests(unittest.TestCase): class APITests(unittest.TestCase):
def setUp(self): def setUp(self):
self.server=Server(0) self.state=tempfile.TemporaryDirectory()
self.server=Server(0,state_dir=self.state.name)
self.password=secrets.token_urlsafe(32)
self.api_token=secrets.token_urlsafe(32)
record=self.server.credentials.setup(self.password,self.api_token)
self.server.sessions['test-session']=dict(expires=time.monotonic()+300,revision=record['password']['hash'])
self.cookie='deck_session=test-session'
self.thread=threading.Thread(target=self.server.serve_forever) self.thread=threading.Thread(target=self.server.serve_forever)
self.thread.start() self.thread.start()
self.base=f'http://127.0.0.1:{self.server.server_port}' self.base=f'http://127.0.0.1:{self.server.server_port}'
def tearDown(self): def tearDown(self):
self.server.shutdown();self.server.demo.stop();self.server.server_close();self.thread.join() self.server.shutdown();self.server.demo.stop();self.server.server_close();self.thread.join();self.state.cleanup()
def post(self,path,body,headers=None): 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 {})}) req=urllib.request.Request(self.base+'/api/v1/'+path,data=json.dumps(body).encode(),headers={'Content-Type':'application/json','X-Athena-Deck':'1','Cookie':self.cookie,**(headers or {})})
return urllib.request.urlopen(req) return urllib.request.urlopen(req)
def test_import_rejects_before_transport(self): def test_import_rejects_before_transport(self):
with patch.object(self.server.network,'call') as transport: with patch.object(self.server.network,'call') as transport:
@@ -95,9 +104,7 @@ class APITests(unittest.TestCase):
with self.assertRaises(urllib.error.HTTPError) as error:self.post('network/confirm',{'trial_id':'test','ingress':'tunnel'}) 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() self.assertEqual(error.exception.code,400);error.exception.close()
def test_authentication(self): def test_authentication(self):
salt=secrets.token_bytes(16) password=self.password
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') 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() self.assertEqual(error.exception.code,401);error.exception.close()
with self.post('login',{'password':password}) as response:cookie=response.headers['Set-Cookie'].split(';')[0] with self.post('login',{'password':password}) as response:cookie=response.headers['Set-Cookie'].split(';')[0]
+12 -2
View File
@@ -1,6 +1,9 @@
import json import json
import threading import threading
import unittest import unittest
import tempfile
import secrets
import time
import urllib.request import urllib.request
import urllib.error import urllib.error
from unittest.mock import patch from unittest.mock import patch
@@ -8,7 +11,13 @@ from server import Server, HardwareProvider
class Tests(unittest.TestCase): class Tests(unittest.TestCase):
def setUp(self): def setUp(self):
self.server=Server(0) self.state=tempfile.TemporaryDirectory()
self.server=Server(0,state_dir=self.state.name)
self.password=secrets.token_urlsafe(32)
self.api_token=secrets.token_urlsafe(32)
record=self.server.credentials.setup(self.password,self.api_token)
self.server.sessions['test-session']=dict(expires=time.monotonic()+300,revision=record['password']['hash'])
self.cookie='deck_session=test-session'
self.thread=threading.Thread(target=self.server.serve_forever) self.thread=threading.Thread(target=self.server.serve_forever)
self.thread.start() self.thread.start()
self.url=f'http://127.0.0.1:{self.server.server_port}' self.url=f'http://127.0.0.1:{self.server.server_port}'
@@ -17,8 +26,9 @@ class Tests(unittest.TestCase):
self.server.demo.stop() self.server.demo.stop()
self.server.server_close() self.server.server_close()
self.thread.join() self.thread.join()
self.state.cleanup()
def request(self,path,method='GET',headers=None): def request(self,path,method='GET',headers=None):
req=urllib.request.Request(self.url+'/api/v1/'+path,method=method,headers=headers or {}) req=urllib.request.Request(self.url+'/api/v1/'+path,method=method,headers={"Cookie":self.cookie,**(headers or {})})
with urllib.request.urlopen(req) as r:return json.load(r) with urllib.request.urlopen(req) as r:return json.load(r)
def test_lifecycle(self): def test_lifecycle(self):
self.assertEqual(self.request('status')['demo']['state'],'stopped') self.assertEqual(self.request('status')['demo']['state'],'stopped')