diff --git a/.gitignore b/.gitignore index 7a60b85..a039664 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ __pycache__/ *.pyc + +.state/ diff --git a/ACCESS.md b/ACCESS.md new file mode 100644 index 0000000..0b125fa --- /dev/null +++ b/ACCESS.md @@ -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 `. 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 ' 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. diff --git a/README.md b/README.md index 11a8ccb..bae05f6 100644 --- a/README.md +++ b/README.md @@ -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. 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 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 -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 | |---|---|---| @@ -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/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. Keine CORS-Freigabe. 403 bei ungültigem Zugriff, 404 bei unbekannten Pfaden, 503 bei fehlgeschlagener Demo-Bereitschaft. Keine frei übergebbaren Befehle, Prozess-IDs, Service-Namen oder Remote-Ziele. ```sh -curl 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 'X-Athena-Deck: 1' http://127.0.0.1:8108/api/v1/demo/stop +curl -H 'Authorization: Bearer ' http://127.0.0.1:8108/api/v1/status +curl -X POST -H 'Authorization: Bearer ' http://127.0.0.1:8108/api/v1/demo/start +curl -X POST -H 'Authorization: Bearer ' http://127.0.0.1:8108/api/v1/demo/stop ``` Hardware: `available` bezeichnet Erfolg der SSH-Collector-Abfrage, einzelne diff --git a/access-ui.js b/access-ui.js new file mode 100644 index 0000000..117d723 --- /dev/null +++ b/access-ui.js @@ -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=`
EINSTELLUNGEN / ZUGANG

Dein Zugang. Deine Schlüssel.

Ein Administratorzugang für die Oberfläche und ein separater Token für API-Clients.

Oberflächenkennwort ändern

Alle bestehenden Browser-Sitzungen werden abgemeldet. Der API-Token bleibt gültig.

API-Token ändern

Vor dem Speichern kopieren. Der bisherige Token wird sofort ungültig. Bestehende Browser-Sitzungen bleiben angemeldet. Verwende in API-Clients: Authorization: Bearer <Token>.

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.

`; + 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.'}; +} diff --git a/app.js b/app.js index 7cd9a79..0a3248c 100644 --- a/app.js +++ b/app.js @@ -3,11 +3,12 @@ let busy=false, refreshing=false; const esc=v=>String(v??'Nicht verfügbar').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const fmt=(v,unit='')=>v==null?'Nicht verfügbar':`${Number(v).toLocaleString('de-DE',{maximumFractionDigits:1})}${unit}`; const heading=(tag,title,desc)=>`
${tag}

${title}

${desc}

`; -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)=>`
${title}${value}
`; const bar=v=>v==null?'':``; const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']}; async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; +if(page==='access'){await accessPage();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.')+`
Anwendung
Bereit

Athena Deck läuft auf ${esc(s.location)}.

Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1
Betriebsmodus
Isoliert

Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.

Keine Modell- oder Router-Steuerung angebunden

Demo-Dienst

LOKAL / CPU

Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.

${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}
Lädt keine Modelle · endet beim Beenden von Athena Deck
`; }else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden.');if(!h.available)html+=`
Hardware nicht verfügbar

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

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

${esc(c.name)}

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

Arbeitsspeicher

${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}
${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}
`;for(const g of h.gpus)html+=`
GPU ${g.index}${fmt(g.temperature_c,' °C')}

${esc(g.name)}

${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}
${bar(g.percent)}
`;if(h.gpus.length<2)html+='
Nicht alle zwei GPUs verfügbar.
';html+=`

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

`;} diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..2393408 --- /dev/null +++ b/auth.py @@ -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 diff --git a/index.html b/index.html index dcaaf5f..f455b3b 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.2 · Prototyp
+Athena Deck
ATHENA CONTROL SURFACEv0.3 · Prototyp
diff --git a/login.html b/login.html index b8bb85f..164a4ab 100644 --- a/login.html +++ b/login.html @@ -1 +1 @@ -Athena Deck · Anmeldung
ATHENA DECK / SERVER

Willkommen zurück.

Melde dich mit dem bei der Einrichtung vergebenen Deck-Passwort an.

Nutze den WireGuard-Tunnel oder einen SSH-Tunnel. Der direkte LAN-Zugang verwendet in dieser Version HTTP.

+Athena Deck · Zugang
ATHENA DECK / ZUGANG

Zugang wird geprüft …

Für den Serverzugriff WireGuard oder SSH verwenden. Direkter LAN-Zugriff verwendet derzeit HTTP.

diff --git a/login.js b/login.js index 545a712..e7e4aa7 100644 --- a/login.js +++ b/login.js @@ -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(); diff --git a/network-ui.js b/network-ui.js index 1db3484..fd12941 100644 --- a/network-ui.js +++ b/network-ui.js @@ -2,7 +2,7 @@ let networkBusy=false, networkState=null; async function networkRequest(action,body={}){ const response=await fetch('/api/v1/network'+(action?'/'+action:''),{method:action?'POST':'GET',headers:action?{'Content-Type':'application/json','X-Athena-Deck':'1'}:{},body:action?JSON.stringify(body):undefined,signal:AbortSignal.timeout(40000)}); - const value=await response.json();if(!response.ok)throw Error(value.error||'Netzwerkaktion fehlgeschlagen.');return value; + 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={}){ if(networkBusy)return;networkBusy=true;networkButtons(); @@ -29,8 +29,11 @@ function networkButtons(){ async function networkPage(){ const view=document.querySelector('#view'); if(!document.querySelector('#network-page')){ - view.innerHTML=`
EINSTELLUNGEN / NETZWERK

Dein Weg zu Athena.

Ein eigener WireGuard-Zugang für Athena Deck und seine internen API-Endpunkte.

WireGuard-Modul

Wird geprüft …

Auf Athena einrichten

Installiert WireGuard und Deck in einem eigenen Container. Der vorhandene Gateway bleibt unverändert. Die erste Einrichtung startet im LAN-Modus auf Port 8110.

Benötigt den vorhandenen SSH-Zugang und Docker auf Athena. LAN-Zugriff verwendet HTTP; für vertrauliche Eingaben WireGuard oder SSH-Tunnel verwenden.

Zugriff auf Deck und API

Der lokale Mac-Zugang per SSH bleibt als Verwaltungskanal erhalten. Produktionsendpunkte werden nicht umgestellt. Im Modus „Nur Tunnel“ gibt es bei Tunnelausfall keinen LAN-Fallback.

`; - document.querySelector('#network-install').onclick=()=>{const input=document.querySelector('#network-password'),password=input.value;input.value='';networkAction('install',{password});}; + view.innerHTML=`
EINSTELLUNGEN / NETZWERK

Dein Weg zu Athena.

Ein eigener WireGuard-Zugang für Athena Deck und seine internen API-Endpunkte.

WireGuard-Modul

Wird geprüft …

Auf Athena einrichten

Installiert WireGuard und Deck in einem eigenen Container. Der vorhandene Gateway bleibt unverändert. Die erste Einrichtung startet im LAN-Modus auf Port 8110.

Vor der Installation kopieren und sicher speichern. Dieser Token ist vom lokalen Mac-Zugang getrennt.

Benötigt den vorhandenen SSH-Zugang und Docker auf Athena. LAN-Zugriff verwendet HTTP; für vertrauliche Eingaben WireGuard oder SSH-Tunnel verwenden.

Zugriff auf Deck und API

Der lokale Mac-Zugang per SSH bleibt als Verwaltungskanal erhalten. Produktionsendpunkte werden nicht umgestellt. Im Modus „Nur Tunnel“ gibt es bei Tunnelausfall keinen LAN-Fallback.

`; + document.querySelector('#network-install').onclick=()=>{const input=document.querySelector('#network-password'),password=input.value,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});}; 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}); diff --git a/network/README.md b/network/README.md index c3a4f58..aaaeb34 100644 --- a/network/README.md +++ b/network/README.md @@ -1,4 +1,4 @@ -# WireGuard-Modul · Athena Deck 0.2 +# WireGuard-Modul · Athena Deck 0.3 ## Was eingebaut ist @@ -24,7 +24,8 @@ Port-Veröffentlichung; eine Aussage „keinerlei Netzwerkänderung“ wäre daf ## Einrichtung in der Oberfläche 1. Auf dem Mac http://127.0.0.1:8108/#network öffnen. -2. Eigenes Deck-Passwort (mindestens 16 Zeichen) vergeben und **Modul auf Athena +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. 3. Eigene IPv4-WireGuard-Conf importieren. **Einen eigenen Peer verwenden**, nicht 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 HttpOnly/SameSite=Strict, acht Stunden gültig und werden nach Neustart ungültig. 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: @@ -123,7 +125,7 @@ serverseitige Prüfung des tatsächlichen Zugangs gelten vor dem API-Aufruf. |---|---| | POST `/login` | `password` | | 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/connect` | `{}` | | POST `/network/disconnect` | `{}` | diff --git a/network/agent.py b/network/agent.py index ed29ed8..d18af94 100644 --- a/network/agent.py +++ b/network/agent.py @@ -17,6 +17,7 @@ import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from auth import normalize, validate_record from network.config import parse_config, wireguard_text from network.policy import Policy @@ -87,7 +88,7 @@ class Proxy(BaseHTTPRequestHandler): if not 0 <= length <= 32768 or len(self.path)>2048: raise ValueError() body = self.rfile.read(length) if length else None - headers = {name: self.headers[name] for name in ('Host', 'Origin', 'Content-Type', 'Cookie', 'X-Athena-Deck') if name in self.headers} + headers = {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}) conn = http.client.HTTPConnection('127.0.0.1', 8108, timeout=30) try: @@ -188,6 +189,15 @@ class Agent: # Ingress may only be asserted by the web child using the private proxy token. if not secrets.compare_digest(str(data.get('proxy_token', '')), PROXY_TOKEN): ingress = 'management' + if action == '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': return self.status(ingress) if action == 'import': @@ -260,7 +270,7 @@ def main(): if not auth.exists(): raise SystemExit('Authentication must be provisioned before container startup.') env = os.environ.copy() - env.update(DECK_PROXY_TOKEN=PROXY_TOKEN, DECK_AUTH_JSON=auth.read_text(), DECK_NETWORK_SOCKET='1', DECK_LOCAL_HARDWARE='1') + 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) address = json.loads(command('ip', '-j', '-4', 'addr', 'show', 'dev', 'eth0'))[0]['addr_info'][0]['local'] lan = DeviceServer(address, 'eth0', 'lan') diff --git a/network/client.py b/network/client.py index 62cc5ab..3794421 100644 --- a/network/client.py +++ b/network/client.py @@ -1,4 +1,5 @@ """Local management via fixed SSH target or container-private Unix RPC.""" +from auth import initial_record import base64 import hashlib import json @@ -51,16 +52,15 @@ class NetworkClient: self.cached_at = time.monotonic() return dict(result, ingress=ingress, job=self.job) - def install(self, password): + def install(self, password, api_token=None): if self.local: raise ValueError('Die Server-Instanz ist bereits installiert.') if not isinstance(password, str) or not 16 <= len(password) <= 256: raise ValueError('Bitte ein eigenes Deck-Passwort mit mindestens 16 Zeichen setzen.') + auth = initial_record(password,api_token) with self.lock: if self.job and self.job['state'] == 'running': raise ValueError('Installation läuft bereits.') - salt = secrets.token_bytes(16) - auth = dict(salt=salt.hex(), hash=hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600000).hex()) self.job = dict(state='running', message='Eigener Deck-Container wird gebaut und gestartet. Das kann mehrere Minuten dauern.') threading.Thread(target=self._install, args=(auth,), daemon=True).start() return dict(job=self.job.copy()) diff --git a/network/install_remote.py b/network/install_remote.py index e60142e..9dae2f2 100644 --- a/network/install_remote.py +++ b/network/install_remote.py @@ -12,7 +12,7 @@ import sys NAME = 'athena-deck-network' BASE = Path('/opt/athena-deck') -FILES = {'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} +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): 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.write_bytes(base64.b64decode(encoded, validate=True)) target.chmod(0o644) - result = run('docker', 'build', '-t', 'athena-deck-network:0.2', '-f', str(source/'network/Dockerfile'), str(source)) + result = run('docker', 'build', '-t', 'athena-deck-network:0.3', '-f', str(source/'network/Dockerfile'), str(source)) if result.returncode: raise ValueError('Container-Build fehlgeschlagen. Docker benötigt Zugriff auf die Paketquellen.') state = BASE/'state' state.mkdir(mode=0o700, exist_ok=True) - auth = payload['auth'] - if set(auth) != {'salt', 'hash'} or len(auth['salt']) != 32 or len(auth['hash']) != 64: - raise ValueError('Ungültige Zugangsdaten.') - int(auth['salt'], 16) - int(auth['hash'], 16) + sys.path.insert(0,str(source)) + from auth import validate_record + auth = validate_record(payload['auth']) fd = os.open(state/'auth.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, 'w') as stream: json.dump(auth, stream) @@ -65,7 +63,7 @@ def main(): '--security-opt', 'no-new-privileges:true', '--pids-limit', '128', '--memory', '256m', '--cpus', '0.5', '--tmpfs', '/run:rw,nosuid,nodev,size=8m', '--tmpfs', '/tmp:rw,nosuid,nodev,size=8m', '-v', str(state)+':/data:rw', '-e', 'DECK_LAN_IP='+lan, - '-p', '127.0.0.1:8110:8110', '-p', lan+':8110:8110', 'athena-deck-network:0.2') + '-p', '127.0.0.1:8110:8110', '-p', lan+':8110:8110', 'athena-deck-network:0.3') if result.returncode: # Auth remains protected for manual recovery; never delete an existing container. raise ValueError('Containerstart fehlgeschlagen. Gesicherter Installationsstand liegt unter /opt/athena-deck.') diff --git a/network/test_integration.py b/network/test_integration.py index 00cee93..927bc24 100644 --- a/network/test_integration.py +++ b/network/test_integration.py @@ -35,14 +35,15 @@ v=json.load(sys.stdin) c=http.client.HTTPConnection(v['host'],8110,timeout=5) h={'Content-Type':'application/json','X-Athena-Deck':'1'} if v.get('cookie'):h['Cookie']=v['cookie'] +if v.get('token'):h['Authorization']='Bearer '+v['token'] if v.get('origin'):h['Origin']=v['origin'] c.request(v.get('method','GET'),v.get('path','/'),json.dumps(v['body']) if 'body' in v else None,h) r=c.getresponse();b=r.read().decode() print(json.dumps({'status':r.status,'body':b,'cookie':r.getheader('Set-Cookie')})) ''' -def http(host,path='/',method='GET',body=None,cookie=None): - value=dict(host=host,path=path,method=method,cookie=cookie) +def http(host,path='/',method='GET',body=None,cookie=None,token=None): + value=dict(host=host,path=path,method=method,cookie=cookie,token=token) if body is not None:value['body']=body return execute(PEER,HTTP,value) @@ -56,7 +57,8 @@ def main(): state=tempfile.TemporaryDirectory(prefix='athena-deck-test-') password=secrets.token_urlsafe(32) salt=secrets.token_bytes(16) - auth=dict(salt=salt.hex(),hash=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex()) + 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) try: 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] assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200 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. private_a=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) assert status['mode']=='lan' and not status['pending'] 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 '' assert baseline==after print('PASS production container start times unchanged',flush=True) diff --git a/server.py b/server.py index 17e43fb..9033c7e 100644 --- a/server.py +++ b/server.py @@ -4,7 +4,8 @@ import argparse import os import hashlib 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.config import parse_config, ConfigError import json @@ -91,13 +92,17 @@ class HardwareProvider: class Server(ThreadingHTTPServer): daemon_threads = True - def __init__(self, port): + def __init__(self, port, state_dir=None): super().__init__(('127.0.0.1', port), Handler) self.demo = DemoService() self.hardware = HardwareProvider() self.started = time.time() self.network = NetworkClient() - self.auth = json.loads(os.environ['DECK_AUTH_JSON']) if os.environ.get('DECK_AUTH_JSON') else None + 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.login_attempts = [] self.auth_lock = threading.Lock() @@ -123,16 +128,44 @@ class Handler(BaseHTTPRequestHandler): return self.headers.get('X-Deck-Ingress', 'management') return 'management' - def authenticated(self): - if self.server.auth is None: - return True + def api_token_authenticated(self): + header = self.headers.get('Authorization', '') + 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: - cookie = SimpleCookie(self.headers.get('Cookie', '')) - token = cookie['deck_session'].value - except (KeyError, ValueError): + return SimpleCookie(self.headers.get('Cookie',''))['deck_session'].value + except (KeyError, ValueError, CookieError): + 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 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): self.connection.settimeout(10) @@ -151,28 +184,21 @@ class Handler(BaseHTTPRequestHandler): return value def login(self): - if self.server.auth is None: - return self.respond({'authenticated': True}) + if not self.allow_password_attempt(): + return try: data = self.read_json() - password = data.get('password', '') - if not isinstance(password, str) or len(password)>256: - raise ValueError() - with self.server.auth_lock: + with self.server.credentials.lock: + record = self.server.credentials.read() + if not record or not verify_password(data.get('password'),record['password']): + return self.respond({'error':'Anmeldung fehlgeschlagen.'},401) + token = secrets.token_urlsafe(32) now = time.monotonic() - self.server.login_attempts = [t for t in self.server.login_attempts if now-t<60] - if len(self.server.login_attempts)>=10: - return self.respond({'error':'Zu viele Anmeldeversuche. Bitte eine Minute warten.'}, 429) - self.server.login_attempts.append(now) - actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(self.server.auth['salt']), 600000).hex() - if not secrets.compare_digest(actual, self.server.auth['hash']): - return self.respond({'error':'Anmeldung fehlgeschlagen.'}, 401) - token = secrets.token_urlsafe(32) - with self.server.auth_lock: - self.server.sessions = {k:v for k,v in self.server.sessions.items() if v>now} - if len(self.server.sessions)>=64: - self.server.sessions.pop(next(iter(self.server.sessions))) - self.server.sessions[token] = now+28800 + with self.server.auth_lock: + self.server.sessions = {k:v for k,v in self.server.sessions.items() if v['expires']>now} + if len(self.server.sessions)>=64: + self.server.sessions.pop(next(iter(self.server.sessions))) + self.server.sessions[token] = dict(expires=now+28800,revision=record['password']['hash']) body = b'{"authenticated":true}' self.send_response(200) self.send_header('Content-Type','application/json') @@ -184,6 +210,32 @@ class Handler(BaseHTTPRequestHandler): except (ValueError, OSError): 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): if os.environ.get('DECK_PROXY_TOKEN'): return self.ingress() in ('lan','tunnel') @@ -192,18 +244,20 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): if not self.valid_host(): return self.respond({'error': 'Host rejected'}, 403) + if self.path == '/api/v1/auth/status': + return self.access_status() if self.path == '/login.js': return self.respond((ROOT/'login.js').read_bytes(), mime='text/javascript') if not self.authenticated() and self.path not in ('/', '/style.css'): return self.respond({'error':'Anmeldung erforderlich.'},401) if not self.authenticated() and self.path == '/': return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') - routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript')} + 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: name, mime = routes[self.path] return self.respond((ROOT/name).read_bytes(), mime=mime) if self.path == '/api/v1/status': - return self.respond(dict(name='Athena Deck', version='0.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': return self.respond(self.server.hardware.snapshot()) if self.path == '/api/v1/network': @@ -214,12 +268,33 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): origin = self.headers.get('Origin') - if not self.valid_host() or self.headers.get('X-Athena-Deck') != '1' or (origin and origin != 'http://' + self.headers.get('Host', '')): - return self.respond({'error': 'Same-origin control required'}, 403) + bearer = self.token_route() and self.api_token_authenticated() + 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': return self.login() 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/'): action = self.path.rsplit('/',1)[1] if action not in ('install','import','connect','disconnect','mode','confirm','cancel','delete'): @@ -227,7 +302,7 @@ class Handler(BaseHTTPRequestHandler): try: data = self.read_json() if action == 'install': - result = self.server.network.install(data.get('password')) + result = self.server.network.install(data.get('password'),data.get('api_token')) else: fields = {'import': {'config'}, 'mode': {'mode'}, 'confirm': {'trial_id'}}.get(action,set()) if set(data) != fields: diff --git a/style.css b/style.css index 7ac2ff6..8296517 100644 --- a/style.css +++ b/style.css @@ -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}} 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} diff --git a/test_auth.py b/test_auth.py new file mode 100644 index 0000000..4f3b557 --- /dev/null +++ b/test_auth.py @@ -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() diff --git a/test_network.py b/test_network.py index 0cd769e..8fdd7be 100644 --- a/test_network.py +++ b/test_network.py @@ -5,6 +5,9 @@ import os import secrets import threading import unittest +import tempfile +import secrets +import time import urllib.request import urllib.error from unittest.mock import patch @@ -74,14 +77,20 @@ class PolicyTests(unittest.TestCase): class APITests(unittest.TestCase): 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.start() self.base=f'http://127.0.0.1:{self.server.server_port}' def tearDown(self): - self.server.shutdown();self.server.demo.stop();self.server.server_close();self.thread.join() + self.server.shutdown();self.server.demo.stop();self.server.server_close();self.thread.join();self.state.cleanup() 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) def test_import_rejects_before_transport(self): 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'}) self.assertEqual(error.exception.code,400);error.exception.close() def test_authentication(self): - salt=secrets.token_bytes(16) - password='test-only-'+secrets.token_hex(20) - self.server.auth={'salt':salt.hex(),'hash':hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex()} + password=self.password with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(self.base+'/api/v1/status') self.assertEqual(error.exception.code,401);error.exception.close() with self.post('login',{'password':password}) as response:cookie=response.headers['Set-Cookie'].split(';')[0] diff --git a/test_server.py b/test_server.py index 5789dfb..321d8d3 100644 --- a/test_server.py +++ b/test_server.py @@ -1,6 +1,9 @@ import json import threading import unittest +import tempfile +import secrets +import time import urllib.request import urllib.error from unittest.mock import patch @@ -8,7 +11,13 @@ from server import Server, HardwareProvider class Tests(unittest.TestCase): 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.start() 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.server_close() self.thread.join() + self.state.cleanup() 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) def test_lifecycle(self): self.assertEqual(self.request('status')['demo']['state'],'stopped')