Add first-run admin setup and independent password and API token rotation
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
.state/
|
||||
|
||||
@@ -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.
|
||||
@@ -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 <API-TOKEN>' http://127.0.0.1:8108/api/v1/status
|
||||
curl -X POST -H 'Authorization: Bearer <API-TOKEN>' 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/stop
|
||||
```
|
||||
|
||||
Hardware: `available` bezeichnet Erfolg der SSH-Collector-Abfrage, einzelne
|
||||
|
||||
@@ -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 <Token>.</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.'};
|
||||
}
|
||||
@@ -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)=>`<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 bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
|
||||
const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
|
||||
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
|
||||
if(page==='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.')+`<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>`;}
|
||||
|
||||
@@ -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
@@ -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 & 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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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=`<div id="network-page"><div class="kicker">EINSTELLUNGEN / NETZWERK</div><h1>Dein Weg zu Athena.</h1><p>Ein eigener WireGuard-Zugang für Athena Deck und seine internen API-Endpunkte.</p><section class="card network-card"><div class="card-top"><h2>WireGuard-Modul</h2><span id="network-state" class="pill">Wird geprüft …</span></div><div id="network-details" class="network-status"></div><p id="network-notice"></p><div id="network-setup"><h2>Auf Athena einrichten</h2><p>Installiert WireGuard und Deck in einem eigenen Container. Der vorhandene Gateway bleibt unverändert. Die erste Einrichtung startet im LAN-Modus auf Port 8110.</p><label for="network-password">Neues Deck-Passwort · mindestens 16 Zeichen</label><input id="network-password" type="password" autocomplete="new-password" minlength="16" maxlength="256"><button id="network-install">Modul auf Athena installieren</button><p class="note">Benötigt den vorhandenen SSH-Zugang und Docker auf Athena. LAN-Zugriff verwendet HTTP; für vertrauliche Eingaben WireGuard oder SSH-Tunnel verwenden.</p></div><div id="network-config" hidden><label for="network-file">Eigene WireGuard-Konfiguration (.conf)</label><input id="network-file" type="file" accept=".conf,text/plain"><p>Verwende einen eigenen Peer für Deck. Schlüssel werden nicht angezeigt. Ein Peer und IPv4 werden unterstützt; Shell-Hooks werden abgelehnt.</p><button id="network-import">Konfiguration importieren</button><button id="network-delete" class="secondary">Konfiguration löschen</button><div class="note"><button id="network-connect">WireGuard aktivieren</button><button id="network-disconnect" class="secondary">WireGuard deaktivieren</button><p>Aktivieren richtet den Tunnel ein. „Verbunden“ erscheint erst nach einem aktuellen Handshake.</p></div></div></section><section class="card network-card" id="network-access"><h2>Zugriff auf Deck und API</h2><label for="network-mode">Erreichbarkeit</label><select id="network-mode"><option value="lan">Nur lokales Netzwerk</option><option value="tunnel">Nur WireGuard-Tunnel</option><option value="both">Tunnel und lokales Netzwerk</option></select><button id="network-apply">Zugriffsmodus testen</button><p id="network-current"></p><div id="network-trial" hidden><p id="network-countdown" class="warning"></p><button id="network-confirm">Zugriff hier bestätigen</button><button id="network-cancel" class="secondary">Änderung zurücknehmen</button><p>Öffne die angezeigte Zieladresse und bestätige dort innerhalb von 120 Sekunden. Ohne Bestätigung wird der vorige Modus wiederhergestellt.</p></div><p class="note">Der lokale Mac-Zugang per SSH bleibt als Verwaltungskanal erhalten. Produktionsendpunkte werden nicht umgestellt. Im Modus „Nur Tunnel“ gibt es bei Tunnelausfall keinen LAN-Fallback.</p></section><p id="network-message" class="network-message" role="status" aria-live="polite"></p></div>`;
|
||||
document.querySelector('#network-install').onclick=()=>{const input=document.querySelector('#network-password'),password=input.value;input.value='';networkAction('install',{password});};
|
||||
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,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});
|
||||
|
||||
+5
-3
@@ -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` | `{}` |
|
||||
|
||||
+12
-2
@@ -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')
|
||||
|
||||
+3
-3
@@ -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())
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
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']):
|
||||
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()
|
||||
with self.server.auth_lock:
|
||||
self.server.sessions = {k:v for k,v in self.server.sessions.items() if v>now}
|
||||
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] = now+28800
|
||||
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', '')):
|
||||
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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
+139
@@ -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
@@ -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]
|
||||
|
||||
+12
-2
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user