Add isolated Debian installer with credential setup and guarded updates
This commit is contained in:
1 parent
e1a54e3485
commit
2ad844ed20
12 files changed
+618
-8
No files matched your search
+185
@@ -0,0 +1,185 @@
|
|||||||
|
# Athena Deck auf Debian installieren
|
||||||
|
|
||||||
|
Eigenständige Installation ohne WireGuard-Modul und ohne Änderungen am bestehenden
|
||||||
|
Athena-Router. Der Installer installiert den aktuellen Deck-Anwendungsstand;
|
||||||
|
Modellkatalog, Downloads und llama.cpp-Builds bleiben weiterhin GUI-Vorschau.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Debian 12 oder 13, Python 3 und root/sudo.
|
||||||
|
- Docker Engine >= 28, bereits eingerichtet und erreichbar.
|
||||||
|
- Mindestens 2 GiB freier Speicher für die Anwendung, zusätzlich Platz für
|
||||||
|
Docker-Images. Modelle sind nicht Teil dieser Installation.
|
||||||
|
- Freier Loopback-Port, standardmäßig 8110.
|
||||||
|
- Terminal für die Zugangseinrichtung; bei SSH `ssh -t` verwenden.
|
||||||
|
|
||||||
|
Auf einem frischen Server zuerst Python und Docker einrichten. Der Deck-Installer
|
||||||
|
ändert bewusst weder Host-Pakete noch Docker-Daemon, Firewall-Regeln, GPU-Treiber
|
||||||
|
oder Kernel. Insbesondere installiert/aktualisiert er Docker nicht automatisch
|
||||||
|
auf einem bereits genutzten Server. Offizielle Anleitung:
|
||||||
|
[Docker Engine für Debian](https://docs.docker.com/engine/install/debian/).
|
||||||
|
|
||||||
|
Optional für GPU-Messwerte: vorhandener NVIDIA-Treiber und vorhandenes NVIDIA
|
||||||
|
Container Toolkit. `--gpu-telemetry` bindet ausschließlich Treiber-Capability
|
||||||
|
`utility` ein; es installiert keinen Treiber und startet kein Modell. Ohne diese
|
||||||
|
Option bleiben GPU-Werte in der Server-GUI ausdrücklich nicht verfügbar.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Den Athena-Deck-Checkout auf den **Debian-Server** kopieren/klonen und dort im
|
||||||
|
Wurzelverzeichnis ausführen. Das Skript ist kein macOS-Installer.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./install.sh --check
|
||||||
|
sudo ./install.sh --install
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit GPU-Telemetrie auf einem bereits dafür vorbereiteten Server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./install.sh --install --gpu-telemetry
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls Port 8110 belegt ist, beispielsweise:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./install.sh --check --port 8112
|
||||||
|
sudo ./install.sh --install --port 8112
|
||||||
|
```
|
||||||
|
|
||||||
|
`--check` prüft rein lesend die Voraussetzungen und testet die Port-Verfügbarkeit.
|
||||||
|
Es legt weder Verzeichnisse noch Container oder Images an. Beim wirklichen Start
|
||||||
|
führt Docker die endgültige Port-Kollisionsprüfung aus.
|
||||||
|
|
||||||
|
Die Installation:
|
||||||
|
|
||||||
|
1. Prüft Betriebssystem, Docker, eigenen Containernamen und Zielverzeichnis.
|
||||||
|
2. Baut ein eigenes Python-Anwendungsimage aus dem Checkout (kein llama.cpp).
|
||||||
|
3. Erstellt eine getrennte Datenablage unter `/opt/athena-deck-standalone`.
|
||||||
|
4. Fragt das Oberflächenkennwort zweimal verdeckt ab und fragt einen separaten
|
||||||
|
API-Token ab. Leere Token-Eingabe erzeugt einen zufälligen Token.
|
||||||
|
5. Startet `athena-deck-standalone` und prüft die Bereitschaft.
|
||||||
|
|
||||||
|
Kein Standardkennwort. Geheimnisse werden nicht als CLI-Argumente übergeben oder
|
||||||
|
in Konsolenausgaben geschrieben. Ein automatisch erzeugter API-Token liegt einmalig
|
||||||
|
unter `/opt/athena-deck-standalone/bootstrap/api-token.txt` (0600, über das root-private
|
||||||
|
Elternverzeichnis geschützt). Über einen vertraulichen Verwaltungsweg in den
|
||||||
|
Passwortmanager übernehmen und anschließend diese Bootstrap-Datei entfernen.
|
||||||
|
Die Anwendung selbst speichert nur Prüfwerte. Bei einem späteren Tokenwechsel
|
||||||
|
wird die Bootstrap-Datei nicht erneuert; sie ist kein Token-Export.
|
||||||
|
|
||||||
|
Vorhandene Zielverzeichnisse, Zugangsdaten oder fremde Container werden nicht
|
||||||
|
überschrieben. Eigene Container sind zusätzlich mit einem installationsspezifischen
|
||||||
|
Docker-Label gekennzeichnet. Kein `docker compose down` oder globales Stoppen.
|
||||||
|
|
||||||
|
## Zugriff vom Mac
|
||||||
|
|
||||||
|
Standardmäßig ist die Anwendung ausschließlich auf dem Server-Loopback erreichbar.
|
||||||
|
Der Container selbst hört intern auf 8108, Docker veröffentlicht nur
|
||||||
|
`127.0.0.1:8110`. Kein LAN-Port und keine neue WireGuard-Verbindung.
|
||||||
|
|
||||||
|
Für Athena im Terminal auf dem Mac:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -i /Users/mike_i386/.ssh/athena_key -o BatchMode=yes \
|
||||||
|
-N -L 8110:127.0.0.1:8110 root@192.168.1.212
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach [Athena Deck](http://127.0.0.1:8110) öffnen und mit dem bei der Installation
|
||||||
|
gewählten Kennwort anmelden. Terminal offen halten; Ctrl+C beendet nur den Tunnel.
|
||||||
|
Bei anderem Installationsport beide Portangaben entsprechend anpassen. Der lokale
|
||||||
|
Browserport sollte dem gewählten Serverport entsprechen (Host-Allowlist).
|
||||||
|
Die bestehende Mac-Vorschau auf Port 8108 bleibt eine separate Instanz.
|
||||||
|
|
||||||
|
## Betrieb und Updates
|
||||||
|
|
||||||
|
Befehle im Checkout auf dem Debian-Server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./install.sh --status
|
||||||
|
sudo ./install.sh --stop
|
||||||
|
sudo ./install.sh --start
|
||||||
|
|
||||||
|
# Erst den gewünschten neuen Quellstand im Checkout bereitstellen, dann:
|
||||||
|
sudo ./install.sh --update
|
||||||
|
|
||||||
|
# Auf das zuvor gespeicherte Deck-Image zurückwechseln:
|
||||||
|
sudo ./install.sh --rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
Updates betreffen ausschließlich den eigenen Deck-Container. Das neue Image wird
|
||||||
|
vorher gebaut, der bisherige Container bleibt während des Builds in Betrieb.
|
||||||
|
Vor dem Austausch wird der Zustandsordner nach `backups/` kopiert. Der bisherige
|
||||||
|
Container wird erst dann gestoppt und für einen Rückfall umbenannt. Scheitert der
|
||||||
|
Start/Bereitschaftstest, wird der vorherige Container wiederhergestellt. Nach einem
|
||||||
|
erfolgreichen Update bleibt dessen Image für `--rollback` erhalten. Beim Wechsel
|
||||||
|
hat **Deck selbst** eine kurze Unterbrechung; andere Dienste werden nicht angefasst.
|
||||||
|
Neue Browser-Anmeldung ist nach dem Neustart erforderlich.
|
||||||
|
|
||||||
|
Rollback wechselt die Anwendungsversion, nicht automatisch die Daten. Bei zukünftigen
|
||||||
|
inkompatiblen Datenformatänderungen muss die passende gesicherte Version von `state/`
|
||||||
|
gezielt wiederhergestellt werden. Alte Images/Backups werden nicht automatisch gelöscht.
|
||||||
|
|
||||||
|
`unless-stopped` startet Deck bei einem späteren regulären Docker-/Hoststart wieder,
|
||||||
|
sofern es nicht bewusst gestoppt wurde. Der Installer löst keinen solchen Neustart aus.
|
||||||
|
|
||||||
|
Für eine zweite Installation sind eigener Name, eigener Port und eigener Datenpfad
|
||||||
|
nötig. Namen müssen mit `athena-deck-` beginnen. Bei späteren Verwaltungsbefehlen
|
||||||
|
immer denselben `--directory`-Pfad angeben; die übrigen Betriebswerte werden aus dem
|
||||||
|
Installationsmanifest gelesen.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./install.sh --install --name athena-deck-test \
|
||||||
|
--directory /opt/athena-deck-test --port 8112
|
||||||
|
sudo ./install.sh --status --directory /opt/athena-deck-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Abgebrochene Erstinstallation
|
||||||
|
|
||||||
|
- Vor dem Anlegen der Datenablage: Fehler beheben und `--install` erneut ausführen.
|
||||||
|
- Manifest vorhanden, Zugangseinrichtung noch nicht abgeschlossen:
|
||||||
|
`sudo ./install.sh --setup`, anschließend `sudo ./install.sh --start`.
|
||||||
|
- Zugangsdaten vorhanden, Containerstart fehlgeschlagen: Ursache (beispielsweise
|
||||||
|
belegter Port oder nicht verfügbares NVIDIA-Toolkit) beheben und `--start` verwenden.
|
||||||
|
- Vorhandene Daten nicht löschen oder mit einer erneuten Installation überschreiben.
|
||||||
|
|
||||||
|
## Trennung und Daten
|
||||||
|
|
||||||
|
| Bestandteil | Ort / Umfang |
|
||||||
|
|---|---|
|
||||||
|
| Container | `athena-deck-standalone` |
|
||||||
|
| Anwendungsimage | `athena-deck-standalone:<Quellhash>` |
|
||||||
|
| Zugangsdaten | `/opt/athena-deck-standalone/state/auth.json` |
|
||||||
|
| Installationsmanifest | `/opt/athena-deck-standalone/installation.json` |
|
||||||
|
| Bootstrap-Token | `/opt/athena-deck-standalone/bootstrap/api-token.txt`, nur bei Erzeugung |
|
||||||
|
| Zustandssicherungen | `/opt/athena-deck-standalone/backups/` |
|
||||||
|
| Für spätere Modelle reserviert | `/opt/athena-deck-standalone/models/`, derzeit leer |
|
||||||
|
|
||||||
|
Die Webanwendung läuft als UID/GID 65534, mit schreibgeschütztem Root-Dateisystem,
|
||||||
|
allen Linux-Capabilities entzogen, ohne Docker-Socket, ohne privilegierten Modus
|
||||||
|
und ohne Host-Netzwerk. Ressourcenlimit: eine CPU, 512 MiB RAM, 128 Prozesse.
|
||||||
|
GPU-Zugriff ist standardmäßig aus. Nur `state/` und ein temporäres tmpfs sind für
|
||||||
|
die Anwendung beschreibbar. Docker erstellt seine normalen Regeln für den neuen
|
||||||
|
Container; vorhandene Gateways und ihre Konfiguration werden nicht bearbeitet.
|
||||||
|
|
||||||
|
Die WireGuard-Einstellungen zeigen in dieser Variante „nicht angebunden“. Es gibt
|
||||||
|
keinen SSH-Schlüssel im Container und keine heimliche Fernsteuerung des vorhandenen
|
||||||
|
Athena-Gateways. Der bisherige WireGuard-Installer bleibt ein separater Weg und wird
|
||||||
|
hier nicht aufgerufen. Eine spätere Kombination benötigt eine gezielte Erweiterung.
|
||||||
|
|
||||||
|
Browser-Profilentwürfe aus dem GUI-Prototyp liegen weiterhin im jeweiligen
|
||||||
|
localStorage, nicht im Server-Zustandsordner. Sie werden nicht mitinstalliert.
|
||||||
|
|
||||||
|
## Prüfstand
|
||||||
|
|
||||||
|
32 lokale Tests inklusive Fremdcontainer-Schutz, fehlgeschlagenem Update und
|
||||||
|
Rückfall, Loopback-Portbindung, gesperrter SSH-Steuerung und fehlenden Zugangsdaten.
|
||||||
|
Shell-/Python-Syntax geprüft. Auf Athena: lesende `--check`-Vorprüfung erfolgreich.
|
||||||
|
Zusätzlich Image-Build und Test der echten Zugangseinrichtung mit synthetischen
|
||||||
|
Testeingaben in einem kurzlebigen Container ohne Netzwerk: API-Anmeldung und
|
||||||
|
Zugangsdaten nach Container-Neustart erfolgreich. Testcontainer und Test-Secrets
|
||||||
|
entfernt; Startzeiten zuvor vorhandener Container unverändert.
|
||||||
|
|
||||||
|
Es wurde **keine dauerhafte Installation auf Athena ausgeführt**. Die interaktive
|
||||||
|
Erstinstallation mit deinen echten Zugangsdaten und die optionale NVIDIA-Telemetrie
|
||||||
|
sind noch nicht als persistente Installation abgenommen.
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript.
|
Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript.
|
||||||
Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10.
|
Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10.
|
||||||
|
|
||||||
|
## Installation auf Debian
|
||||||
|
|
||||||
|
Ein eigenständiger Installer ohne WireGuard ist vorhanden: [Debian-Installation](INSTALL.md).
|
||||||
|
Auf dem Zielserver: `sudo ./install.sh --check`, anschließend `sudo ./install.sh --install`.
|
||||||
|
Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht verändert.
|
||||||
|
|
||||||
## Neu: Modellverwaltung und llama.cpp-Einstellungen
|
## Neu: Modellverwaltung und llama.cpp-Einstellungen
|
||||||
|
|
||||||
Katalog, Bibliothek, Profilentwürfe und eine Laufzeit-/Update-Ansicht sind als
|
Katalog, Bibliothek, Profilentwürfe und eine Laufzeit-/Update-Ansicht sind als
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.13-slim-bookworm
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server.py auth.py collect_hardware.py demo.py /app/
|
||||||
|
COPY index.html app.js studio.js style.css login.html login.js access-ui.js network-ui.js /app/
|
||||||
|
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||||
|
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
||||||
|
DECK_REQUIRE_SETUP=1 DECK_LOCAL_HARDWARE=1 DECK_LOCATION=Server \
|
||||||
|
DECK_NETWORK_MODE=disabled
|
||||||
|
USER 65534:65534
|
||||||
|
CMD ["python3", "server.py", "--port", "8108"]
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Standalone Debian installer. Explicit allowlist; never manages production services."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||||
|
FILES = ['server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile']
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args, check=True, interactive=False):
|
||||||
|
result = subprocess.run(args, text=True, capture_output=not interactive)
|
||||||
|
if check and result.returncode:
|
||||||
|
raise RuntimeError('Befehl fehlgeschlagen: '+args[0]+'. Vorhandene Dienste bleiben unangetastet.')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def inspect(name):
|
||||||
|
result = run('docker','inspect',name,check=False)
|
||||||
|
return json.loads(result.stdout)[0] if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def owned(name, base):
|
||||||
|
value = inspect(name)
|
||||||
|
if value and value['Config'].get('Labels',{}).get(LABEL) != str(base):
|
||||||
|
raise RuntimeError('Containername ist bereits anderweitig belegt. Keine Änderung ausgeführt.')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def preflight(base, port):
|
||||||
|
if sys.platform != 'linux' or os.geteuid() != 0:
|
||||||
|
raise RuntimeError('Auf dem Debian-Zielserver mit sudo ausführen.')
|
||||||
|
release = dict(line.split('=',1) for line in Path('/etc/os-release').read_text().splitlines() if '=' in line)
|
||||||
|
if release.get('ID','').strip('"') != 'debian' or release.get('VERSION_ID','').strip('"') not in ('12','13'):
|
||||||
|
raise RuntimeError('Unterstützt werden Debian 12 und 13.')
|
||||||
|
if not shutil.which('docker'):
|
||||||
|
raise RuntimeError('Docker fehlt. Zuerst nach der offiziellen Debian-Anleitung installieren: https://docs.docker.com/engine/install/debian/ . Der Installer verändert keine Host-Pakete.')
|
||||||
|
version = run('docker','version','--format','{{.Server.Version}}').stdout.strip()
|
||||||
|
if int(version.split('.')[0]) < 28:
|
||||||
|
raise RuntimeError('Docker Engine >= 28 erforderlich. Kein automatisches Upgrade bestehender Docker-Installationen.')
|
||||||
|
if not 1024 <= port <= 65535:
|
||||||
|
raise RuntimeError('Port muss zwischen 1024 und 65535 liegen.')
|
||||||
|
if not base.is_absolute() or base in (Path('/'),Path('/opt'),Path('/srv'),Path('/var/lib')) or base != base.resolve():
|
||||||
|
raise RuntimeError('Eigenes absolutes Installationsverzeichnis ohne Symlinks erforderlich.')
|
||||||
|
for name in FILES + ['deploy/provision.py']:
|
||||||
|
if not (ROOT/name).is_file():
|
||||||
|
raise RuntimeError('Unvollständiger Checkout: '+name)
|
||||||
|
|
||||||
|
|
||||||
|
def free_port(port):
|
||||||
|
with socket.socket() as sock:
|
||||||
|
try:
|
||||||
|
sock.bind(('127.0.0.1',port))
|
||||||
|
except OSError:
|
||||||
|
raise RuntimeError('Der gewünschte Loopback-Port ist bereits belegt.') from None
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for name in FILES:
|
||||||
|
digest.update(name.encode());digest.update((ROOT/name).read_bytes())
|
||||||
|
tag = 'athena-deck-standalone:'+digest.hexdigest()[:16]
|
||||||
|
print('Eigenes Deck-Image bauen (keine Modelle oder llama.cpp-Builds).',flush=True)
|
||||||
|
result = run('docker','build','--label',LABEL+'=image','-t',tag,'-f',str(ROOT/'deploy/Dockerfile'),str(ROOT),interactive=True)
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
def write_manifest(base, config):
|
||||||
|
temp=base/'installation.tmp'
|
||||||
|
fd=os.open(temp,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
||||||
|
with os.fdopen(fd,'w') as stream:
|
||||||
|
json.dump(config,stream,indent=2)
|
||||||
|
os.replace(temp,base/'installation.json')
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(base):
|
||||||
|
config=json.loads((base/'installation.json').read_text())
|
||||||
|
if config.get('owner') != LABEL or config.get('base') != str(base):
|
||||||
|
raise RuntimeError('Kein gültiger eigener Installationsstand.')
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def launch(config):
|
||||||
|
base=Path(config['base'])
|
||||||
|
args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base),
|
||||||
|
'--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true',
|
||||||
|
'--pids-limit','128','--memory','512m','--cpus','1', '--tmpfs','/tmp:rw,nosuid,nodev,size=16m',
|
||||||
|
'-v',str(base/'state')+':/var/lib/deck:rw','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']),
|
||||||
|
'-p','127.0.0.1:'+str(config['port'])+':8108',
|
||||||
|
'--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"',
|
||||||
|
'--health-interval','30s','--health-timeout','5s','--health-retries','3']
|
||||||
|
if config['gpu_telemetry']:
|
||||||
|
args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=utility']
|
||||||
|
args.append(config['image'])
|
||||||
|
run(*args)
|
||||||
|
|
||||||
|
|
||||||
|
def ready(config):
|
||||||
|
# Verify the new container itself as well as the published port.
|
||||||
|
for _ in range(30):
|
||||||
|
item=owned(config['name'],Path(config['base']))
|
||||||
|
if item and item['State']['Running']:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen('http://127.0.0.1:'+str(config['port'])+'/api/v1/auth/status',timeout=2) as r:
|
||||||
|
if json.load(r).get('initialized') is True:
|
||||||
|
return
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError('Neue Deck-Instanz wurde nicht bereit.')
|
||||||
|
|
||||||
|
|
||||||
|
def provision(config):
|
||||||
|
base=Path(config['base'])
|
||||||
|
if (base/'state/auth.json').exists():
|
||||||
|
raise RuntimeError('Zugangsdaten existieren bereits; keine Überschreibung.')
|
||||||
|
os.chown(base/'bootstrap',65534,65534)
|
||||||
|
run('docker','run','--rm','-it','--network','none','--user','65534:65534','--cap-drop','ALL',
|
||||||
|
'--security-opt','no-new-privileges:true','--read-only','--tmpfs','/tmp:rw,nosuid,nodev,size=8m',
|
||||||
|
'-v',str(base/'state')+':/var/lib/deck:rw','-v',str(base/'bootstrap')+':/bootstrap:rw',
|
||||||
|
'-v',str(ROOT/'deploy/provision.py')+':/provision.py:ro','-e','PYTHONPATH=/app',config['image'],
|
||||||
|
'python3','/provision.py',interactive=True)
|
||||||
|
os.chown(base/'state/auth.json',65534,65534)
|
||||||
|
|
||||||
|
|
||||||
|
def install(args):
|
||||||
|
base=args.directory
|
||||||
|
preflight(base,args.port)
|
||||||
|
if owned(args.name,base):
|
||||||
|
raise RuntimeError('Deck-Container existiert bereits. Für Updates --update verwenden.')
|
||||||
|
if base.exists():
|
||||||
|
raise RuntimeError('Installationsverzeichnis existiert bereits. Es wird nicht überschrieben.')
|
||||||
|
free_port(args.port)
|
||||||
|
parent=base.parent
|
||||||
|
while not parent.exists():parent=parent.parent
|
||||||
|
if shutil.disk_usage(parent).free < 2*1024**3:
|
||||||
|
raise RuntimeError('Mindestens 2 GiB freier Speicher für Installation erforderlich.')
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
raise RuntimeError('Interaktives Terminal für Zugangseinrichtung benötigt (über SSH: ssh -t).')
|
||||||
|
image=build()
|
||||||
|
base.mkdir(parents=True,mode=0o700)
|
||||||
|
for sub in ('state','models','backups','bootstrap'):
|
||||||
|
(base/sub).mkdir(mode=0o700)
|
||||||
|
os.chown(base/'state',65534,65534)
|
||||||
|
config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry)
|
||||||
|
write_manifest(base,config)
|
||||||
|
provision(config)
|
||||||
|
try:
|
||||||
|
launch(config);ready(config)
|
||||||
|
except Exception:
|
||||||
|
item=owned(config['name'],base)
|
||||||
|
if item:run('docker','rm','-f',config['name'])
|
||||||
|
raise RuntimeError('Deck-Start fehlgeschlagen. Nur der neue Deck-Container wurde entfernt; Zugangsdaten und Installationsstand bleiben für --start erhalten.') from None
|
||||||
|
print('Deck ist bereit: http://127.0.0.1:'+str(args.port))
|
||||||
|
if (base/'bootstrap/api-token.txt').exists():
|
||||||
|
print('Generierter API-Token: geschützte Datei '+str(base/'bootstrap/api-token.txt')+'. In Passwortmanager übernehmen und Datei danach entfernen.')
|
||||||
|
|
||||||
|
|
||||||
|
def update(base, rollback=False):
|
||||||
|
config=load_manifest(base)
|
||||||
|
previous=owned(config['name'],base)
|
||||||
|
if not previous:
|
||||||
|
raise RuntimeError('Kein installierter Deck-Container vorhanden.')
|
||||||
|
image=config.get('previous_image') if rollback else build()
|
||||||
|
if not image:raise RuntimeError('Kein vorheriger Build gespeichert.')
|
||||||
|
if image==config['image']:
|
||||||
|
print('Dieser Quellstand ist bereits installiert.');return
|
||||||
|
backup_name=config['name']+'-previous'
|
||||||
|
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
||||||
|
stamp=str(time.time_ns())
|
||||||
|
shutil.copytree(base/'state',base/'backups'/stamp)
|
||||||
|
was_running=previous['State']['Running']
|
||||||
|
if was_running:run('docker','stop','--time','15',config['name'])
|
||||||
|
run('docker','rename',config['name'],backup_name)
|
||||||
|
new=dict(config,image=image,previous_image=config['image'])
|
||||||
|
try:
|
||||||
|
launch(new);ready(new);write_manifest(base,new)
|
||||||
|
except Exception:
|
||||||
|
item=owned(config['name'],base)
|
||||||
|
if item:run('docker','rm','-f',config['name'])
|
||||||
|
run('docker','rename',backup_name,config['name'])
|
||||||
|
if was_running:run('docker','start',config['name'])
|
||||||
|
raise RuntimeError('Update fehlgeschlagen. Vorheriger Deck-Container wiederhergestellt.') from None
|
||||||
|
run('docker','rm',backup_name)
|
||||||
|
print('Nur Athena Deck wurde aktualisiert. Vorheriges Image bleibt für --rollback erhalten.')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser=argparse.ArgumentParser(description='Athena Deck auf Debian 12/13 getrennt installieren. Kein WireGuard, keine Host-Paket- oder Treiberänderungen.')
|
||||||
|
actions=parser.add_mutually_exclusive_group(required=True)
|
||||||
|
for action in ('check','install','setup','start','stop','status','update','rollback'):
|
||||||
|
actions.add_argument('--'+action,action='store_true')
|
||||||
|
parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone'))
|
||||||
|
parser.add_argument('--name',default='athena-deck-standalone')
|
||||||
|
parser.add_argument('--port',type=int,default=8110)
|
||||||
|
parser.add_argument('--gpu-telemetry',action='store_true',help='Vorhandenes NVIDIA Container Toolkit nur für Messwerte nutzen; installiert keine Treiber.')
|
||||||
|
args=parser.parse_args()
|
||||||
|
import re
|
||||||
|
if not re.fullmatch(r'athena-deck-[a-z0-9-]{1,40}',args.name):
|
||||||
|
parser.error('Name muss mit athena-deck- beginnen und nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.')
|
||||||
|
preflight(args.directory,args.port)
|
||||||
|
if args.check:
|
||||||
|
item=owned(args.name,args.directory)
|
||||||
|
if not item:free_port(args.port)
|
||||||
|
print('Vorprüfung erfolgreich. Keine Dateien, Images oder Dienste geändert.')
|
||||||
|
elif args.install:
|
||||||
|
install(args)
|
||||||
|
else:
|
||||||
|
config=load_manifest(args.directory)
|
||||||
|
item=owned(config['name'],args.directory)
|
||||||
|
if args.status:
|
||||||
|
print('Deck: '+('running' if item and item['State']['Running'] else 'stopped / absent'))
|
||||||
|
print('URL: http://127.0.0.1:'+str(config['port']))
|
||||||
|
elif args.setup:
|
||||||
|
provision(config)
|
||||||
|
print('Zugang eingerichtet. Jetzt --start ausführen.')
|
||||||
|
elif args.stop:
|
||||||
|
if item:run('docker','stop','--time','15',config['name'])
|
||||||
|
elif args.start:
|
||||||
|
if not (args.directory/'state/auth.json').exists():
|
||||||
|
raise RuntimeError('Zugangseinrichtung unvollständig. Siehe INSTALL.md zur Wiederaufnahme.')
|
||||||
|
os.chown(args.directory/'state/auth.json',65534,65534)
|
||||||
|
if item:run('docker','start',config['name'])
|
||||||
|
else:launch(config)
|
||||||
|
ready(config);print('Deck bereit.')
|
||||||
|
elif args.update or args.rollback:update(args.directory,args.rollback)
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
try:main()
|
||||||
|
except (RuntimeError,OSError,ValueError,KeyError) as exc:
|
||||||
|
print('Installation/Betrieb abgebrochen: '+str(exc),file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Executed interactively inside a one-shot container. Never prints credentials."""
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from auth import CredentialStore
|
||||||
|
|
||||||
|
store = CredentialStore('/var/lib/deck/auth.json')
|
||||||
|
if store.read() is not None:
|
||||||
|
raise SystemExit('Zugangsdaten existieren bereits; keine Überschreibung.')
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
raise SystemExit('Interaktives Terminal benötigt. Keine Kennwörter als Argumente übergeben.')
|
||||||
|
password = getpass.getpass('Neues Oberflächenkennwort (mindestens 16 Zeichen): ')
|
||||||
|
repeat = getpass.getpass('Kennwort wiederholen: ')
|
||||||
|
if password != repeat:
|
||||||
|
raise SystemExit('Kennwörter stimmen nicht überein.')
|
||||||
|
token = getpass.getpass('API-Token (mindestens 32 Zeichen; leer = sicher erzeugen): ')
|
||||||
|
generated = not token
|
||||||
|
if generated:
|
||||||
|
token = 'ad_' + secrets.token_hex(32)
|
||||||
|
if generated and Path('/bootstrap/api-token.txt').exists():
|
||||||
|
raise SystemExit('Bootstrap-Datei existiert bereits; keine Überschreibung.')
|
||||||
|
try:
|
||||||
|
record = store.setup(password, token)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(str(exc)) from None
|
||||||
|
if generated:
|
||||||
|
path = Path('/bootstrap/api-token.txt')
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
with os.fdopen(fd,'w') as stream:
|
||||||
|
stream.write(token+'\n')
|
||||||
|
print('API-Token wurde geschützt in der Bootstrap-Datei abgelegt (nicht ausgegeben).')
|
||||||
|
print('Zugang eingerichtet. Kennwort und API-Token sind in der Anwendung nur als Prüfwerte gespeichert.')
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Explicit Linux/Docker smoke test: disposable container, no published ports."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
NAME='athena-deck-installer-smoke'
|
||||||
|
IMAGE='athena-deck-installer-test:local'
|
||||||
|
ROOT=Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
def run(*args,data=None,check=True):
|
||||||
|
result=subprocess.run(args,input=data,text=True,capture_output=True,timeout=120)
|
||||||
|
if check and result.returncode:raise RuntimeError('Smoke command failed (output withheld): '+args[0])
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if run('docker','ps','-aq','--filter','name=^/'+NAME+'$'):
|
||||||
|
raise RuntimeError('Smoke container already exists; refusing takeover')
|
||||||
|
ids=run('docker','ps','-aq').splitlines()
|
||||||
|
before=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
|
||||||
|
with tempfile.TemporaryDirectory(prefix='deck-install-smoke-') as directory:
|
||||||
|
base=Path(directory)
|
||||||
|
for name in ('state','bootstrap'):
|
||||||
|
(base/name).mkdir(mode=0o700);os.chown(base/name,65534,65534)
|
||||||
|
password=secrets.token_urlsafe(32)
|
||||||
|
script="""import json,sys,runpy
|
||||||
|
from unittest.mock import patch
|
||||||
|
p=json.load(sys.stdin)['password'];values=iter([p,p,''])
|
||||||
|
with patch('getpass.getpass',side_effect=lambda _:next(values)),patch('sys.stdin.isatty',return_value=True):runpy.run_path('/provision.py',run_name='__main__')
|
||||||
|
"""
|
||||||
|
mounts=['-v',str(base/'state')+':/var/lib/deck','-v',str(base/'bootstrap')+':/bootstrap','-v',str(ROOT/'deploy/provision.py')+':/provision.py:ro']
|
||||||
|
run('docker','run','--rm','-i','--network','none','--user','65534:65534','--cap-drop','ALL','--read-only','-e','PYTHONPATH=/app',*mounts,IMAGE,'python3','-c',script,data=json.dumps({'password':password}))
|
||||||
|
token=(base/'bootstrap/api-token.txt').read_text().strip()
|
||||||
|
assert (base/'bootstrap/api-token.txt').stat().st_mode&0o777==0o600
|
||||||
|
assert token not in (base/'state/auth.json').read_text()
|
||||||
|
print('PASS interactive provisioner with synthetic inputs; token protected, hashes persisted',flush=True)
|
||||||
|
try:
|
||||||
|
run('docker','run','-d','--name',NAME,'--network','none','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:rw,nosuid,nodev,size=8m','-v',str(base/'state')+':/var/lib/deck',IMAGE)
|
||||||
|
probe="""import json,sys,urllib.request
|
||||||
|
v=json.load(sys.stdin)
|
||||||
|
req=urllib.request.Request('http://127.0.0.1:8108/api/v1/status',headers={'Authorization':'Bearer '+v['token']})
|
||||||
|
with urllib.request.urlopen(req,timeout=3) as r:assert json.load(r)['location']=='Server'
|
||||||
|
"""
|
||||||
|
for _ in range(20):
|
||||||
|
try:run('docker','exec','-i',NAME,'python3','-c',probe,data=json.dumps({'token':token}));break
|
||||||
|
except RuntimeError:time.sleep(.5)
|
||||||
|
else:raise RuntimeError('Server not ready')
|
||||||
|
run('docker','stop',NAME);run('docker','start',NAME)
|
||||||
|
for _ in range(20):
|
||||||
|
try:run('docker','exec','-i',NAME,'python3','-c',probe,data=json.dumps({'token':token}));break
|
||||||
|
except RuntimeError:time.sleep(.5)
|
||||||
|
else:raise RuntimeError('Server not ready after restart')
|
||||||
|
print('PASS unprivileged standalone server, API authentication and persistence after restart',flush=True)
|
||||||
|
after=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
|
||||||
|
assert before==after
|
||||||
|
print('PASS previously existing container start times unchanged',flush=True)
|
||||||
|
finally:run('docker','rm','-f',NAME,check=False)
|
||||||
|
|
||||||
|
if __name__=='__main__':main()
|
||||||
+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="/access-ui.js" defer></script><script src="/studio.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="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtime">llama.cpp & Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</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.4 · GUI-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="/studio.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="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtime">llama.cpp & Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.4 · GUI-Prototyp</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo 'Python 3 fehlt. Auf einem frischen Debian zuerst: sudo apt-get install python3' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec python3 "$SCRIPT_DIR/deploy/install.py" "$@"
|
||||||
+2
-2
@@ -16,7 +16,7 @@ function networkButtons(){
|
|||||||
const s=networkState;
|
const s=networkState;
|
||||||
document.querySelectorAll('#network-page button').forEach(b=>b.disabled=networkBusy||s.state==='installing');
|
document.querySelectorAll('#network-page button').forEach(b=>b.disabled=networkBusy||s.state==='installing');
|
||||||
const set=(id,disabled)=>{const b=document.getElementById(id);if(b)b.disabled=b.disabled||disabled;};
|
const set=(id,disabled)=>{const b=document.getElementById(id);if(b)b.disabled=b.disabled||disabled;};
|
||||||
set('network-install',s.installed);
|
set('network-install',s.installed||s.install_supported===false);
|
||||||
set('network-import',!s.installed||s.enabled||s.mode!=='lan'||!!s.pending);
|
set('network-import',!s.installed||s.enabled||s.mode!=='lan'||!!s.pending);
|
||||||
set('network-connect',!s.configured||s.enabled);
|
set('network-connect',!s.configured||s.enabled);
|
||||||
set('network-disconnect',!s.enabled);
|
set('network-disconnect',!s.enabled);
|
||||||
@@ -41,7 +41,7 @@ async function networkPage(){
|
|||||||
}
|
}
|
||||||
try{
|
try{
|
||||||
const s=await networkRequest('');if(!document.querySelector('#network-page'))return;networkState=s;
|
const s=await networkRequest('');if(!document.querySelector('#network-page'))return;networkState=s;
|
||||||
const labels={unavailable:'Nicht bereit',installing:'Installation läuft',disabled:'Deaktiviert',connecting:'Warte auf Handshake',connected:'Verbunden'};
|
const labels={'disabled-module':'Nicht angebunden',unavailable:'Nicht bereit',installing:'Installation läuft',disabled:'Deaktiviert',connecting:'Warte auf Handshake',connected:'Verbunden'};
|
||||||
document.querySelector('#network-state').textContent=labels[s.state]||'Nicht verfügbar';
|
document.querySelector('#network-state').textContent=labels[s.state]||'Nicht verfügbar';
|
||||||
const details=document.querySelector('#network-details');details.replaceChildren();
|
const details=document.querySelector('#network-details');details.replaceChildren();
|
||||||
for(const [label,value] of [['Konfiguration',s.configured?'Gespeichert':'Keine'],['Letzter Handshake',s.latest_handshake?new Date(s.latest_handshake*1000).toLocaleString('de-DE'):'Noch keiner'],['Dieser Zugang',s.ingress==='management'?'Mac / SSH-Verwaltung':s.ingress==='tunnel'?'WireGuard-Tunnel':'LAN']]){const d=document.createElement('div'),small=document.createElement('small'),strong=document.createElement('strong');small.textContent=label;strong.textContent=value;d.append(small,strong);details.append(d);}
|
for(const [label,value] of [['Konfiguration',s.configured?'Gespeichert':'Keine'],['Letzter Handshake',s.latest_handshake?new Date(s.latest_handshake*1000).toLocaleString('de-DE'):'Noch keiner'],['Dieser Zugang',s.ingress==='management'?'Mac / SSH-Verwaltung':s.ingress==='tunnel'?'WireGuard-Tunnel':'LAN']]){const d=document.createElement('div'),small=document.createElement('small'),strong=document.createElement('strong');small.textContent=label;strong.textContent=value;d.append(small,strong);details.append(d);}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ SSH = ['ssh', '-i', str(Path.home()/'.ssh/athena_key'), '-o', 'BatchMode=yes', '
|
|||||||
|
|
||||||
class NetworkClient:
|
class NetworkClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.disabled = os.environ.get('DECK_NETWORK_MODE') == 'disabled'
|
||||||
self.local = os.environ.get('DECK_NETWORK_SOCKET') == '1'
|
self.local = os.environ.get('DECK_NETWORK_SOCKET') == '1'
|
||||||
self.job = None
|
self.job = None
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
@@ -22,6 +23,8 @@ class NetworkClient:
|
|||||||
self.cached_at = 0
|
self.cached_at = 0
|
||||||
|
|
||||||
def call(self, action, values=None, ingress='management'):
|
def call(self, action, values=None, ingress='management'):
|
||||||
|
if self.disabled:
|
||||||
|
raise ValueError('WireGuard ist in dieser eigenständigen Installation nicht angebunden.')
|
||||||
data = dict(values or {}, action=action)
|
data = dict(values or {}, action=action)
|
||||||
if self.local:
|
if self.local:
|
||||||
from network.rpc import request
|
from network.rpc import request
|
||||||
@@ -38,6 +41,8 @@ class NetworkClient:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def status(self, ingress='management'):
|
def status(self, ingress='management'):
|
||||||
|
if self.disabled:
|
||||||
|
return dict(installed=False, install_supported=False, state='disabled-module', ingress=ingress, error='Eigenständige Installation ohne WireGuard-Modul. Zugriff ist per SSH-Tunnel möglich. Vorhandene Gateways werden nicht verändert.')
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.job and self.job['state'] == 'running':
|
if self.job and self.job['state'] == 'running':
|
||||||
return dict(installed=False, state='installing', job=self.job.copy(), ingress=ingress)
|
return dict(installed=False, state='installing', job=self.job.copy(), ingress=ingress)
|
||||||
@@ -53,6 +58,8 @@ class NetworkClient:
|
|||||||
return dict(result, ingress=ingress, job=self.job)
|
return dict(result, ingress=ingress, job=self.job)
|
||||||
|
|
||||||
def install(self, password, api_token=None):
|
def install(self, password, api_token=None):
|
||||||
|
if self.disabled:
|
||||||
|
raise ValueError('WireGuard-Installation ist für diese eigenständige Instanz deaktiviert.')
|
||||||
if self.local:
|
if self.local:
|
||||||
raise ValueError('Die Server-Instanz ist bereits installiert.')
|
raise ValueError('Die Server-Instanz ist bereits installiert.')
|
||||||
if not isinstance(password, str) or not 16 <= len(password) <= 256:
|
if not isinstance(password, str) or not 16 <= len(password) <= 256:
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class HardwareProvider:
|
|||||||
class Server(ThreadingHTTPServer):
|
class Server(ThreadingHTTPServer):
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
def __init__(self, port, state_dir=None):
|
def __init__(self, port, state_dir=None):
|
||||||
super().__init__(('127.0.0.1', port), Handler)
|
super().__init__((os.environ.get('DECK_BIND_HOST','127.0.0.1'), port), Handler)
|
||||||
self.demo = DemoService()
|
self.demo = DemoService()
|
||||||
self.hardware = HardwareProvider()
|
self.hardware = HardwareProvider()
|
||||||
self.started = time.time()
|
self.started = time.time()
|
||||||
@@ -103,6 +103,9 @@ class Server(ThreadingHTTPServer):
|
|||||||
self.credentials = CredentialStore(rpc=request)
|
self.credentials = CredentialStore(rpc=request)
|
||||||
else:
|
else:
|
||||||
self.credentials = CredentialStore(Path(state_dir or os.environ.get('DECK_STATE_DIR', ROOT/'.state'))/'auth.json')
|
self.credentials = CredentialStore(Path(state_dir or os.environ.get('DECK_STATE_DIR', ROOT/'.state'))/'auth.json')
|
||||||
|
if os.environ.get('DECK_REQUIRE_SETUP') == '1' and self.credentials.read() is None:
|
||||||
|
self.server_close()
|
||||||
|
raise RuntimeError('Serverzugang muss vor dem Start eingerichtet werden.')
|
||||||
self.sessions = {}
|
self.sessions = {}
|
||||||
self.login_attempts = []
|
self.login_attempts = []
|
||||||
self.auth_lock = threading.Lock()
|
self.auth_lock = threading.Lock()
|
||||||
@@ -215,7 +218,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
signed_in = self.authenticated(session_only=True)
|
signed_in = self.authenticated(session_only=True)
|
||||||
value = dict(initialized=record is not None, authenticated=signed_in)
|
value = dict(initialized=record is not None, authenticated=signed_in)
|
||||||
if 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')
|
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=os.environ.get('DECK_LOCATION', 'Athena' if os.environ.get('DECK_AUTH_RPC') else 'Mac'))
|
||||||
return self.respond(value)
|
return self.respond(value)
|
||||||
|
|
||||||
def change_credentials(self, kind):
|
def change_credentials(self, kind):
|
||||||
@@ -239,7 +242,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def valid_host(self):
|
def valid_host(self):
|
||||||
if os.environ.get('DECK_PROXY_TOKEN'):
|
if os.environ.get('DECK_PROXY_TOKEN'):
|
||||||
return self.ingress() in ('lan','tunnel')
|
return self.ingress() in ('lan','tunnel')
|
||||||
return self.headers.get('Host') in (f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}')
|
allowed = {f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}'}
|
||||||
|
allowed.update(filter(None,os.environ.get('DECK_ALLOWED_HOSTS','').split(',')))
|
||||||
|
return self.headers.get('Host') in allowed
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if not self.valid_host():
|
if not self.valid_host():
|
||||||
@@ -257,7 +262,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
name, mime = routes[self.path]
|
name, mime = routes[self.path]
|
||||||
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
||||||
if self.path == '/api/v1/status':
|
if self.path == '/api/v1/status':
|
||||||
return self.respond(dict(name='Athena Deck', version='0.4.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()))
|
return self.respond(dict(name='Athena Deck', version='0.4.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena' if os.environ.get('DECK_AUTH_RPC') else 'Mac'), demo=self.server.demo.status()))
|
||||||
if self.path == '/api/v1/hardware':
|
if self.path == '/api/v1/hardware':
|
||||||
return self.respond(self.server.hardware.snapshot())
|
return self.respond(self.server.hardware.snapshot())
|
||||||
if self.path == '/api/v1/network':
|
if self.path == '/api/v1/network':
|
||||||
@@ -277,7 +282,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if set(data) != {'password','api_token'}:
|
if set(data) != {'password','api_token'}:
|
||||||
raise ValueError('Kennwort und API-Token werden benötigt.')
|
raise ValueError('Kennwort und API-Token werden benötigt.')
|
||||||
# Container installs always arrive provisioned. Never offer remote claim-on-first-use.
|
# Container installs always arrive provisioned. Never offer remote claim-on-first-use.
|
||||||
if os.environ.get('DECK_AUTH_RPC'):
|
if os.environ.get('DECK_AUTH_RPC') or os.environ.get('DECK_REQUIRE_SETUP') == '1':
|
||||||
return self.respond({'error':'Serverzugang wird bei der Installation eingerichtet.'},403)
|
return self.respond({'error':'Serverzugang wird bei der Installation eingerichtet.'},403)
|
||||||
self.server.credentials.setup(data['password'],data['api_token'])
|
self.server.credentials.setup(data['password'],data['api_token'])
|
||||||
return self.respond({'initialized':True})
|
return self.respond({'initialized':True})
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from auth import CredentialStore
|
||||||
|
from server import Server
|
||||||
|
from deploy import install as installer
|
||||||
|
from network.client import NetworkClient
|
||||||
|
|
||||||
|
class InstallTests(unittest.TestCase):
|
||||||
|
def test_refuses_foreign_container(self):
|
||||||
|
with patch.object(installer,'inspect',return_value={'Config':{'Labels':{}}}):
|
||||||
|
with self.assertRaises(RuntimeError):installer.owned('athena-deck-test',Path('/opt/test'))
|
||||||
|
def test_launch_is_loopback_and_unprivileged(self):
|
||||||
|
config=dict(base='/opt/athena-deck-test',name='athena-deck-test',port=8119,image='test:only',gpu_telemetry=False)
|
||||||
|
with patch.object(installer,'run') as run:
|
||||||
|
installer.launch(config)
|
||||||
|
args=run.call_args.args
|
||||||
|
self.assertIn('127.0.0.1:8119:8108',args)
|
||||||
|
self.assertIn('--read-only',args)
|
||||||
|
self.assertEqual(args[args.index('--cap-drop')+1],'ALL')
|
||||||
|
for forbidden in ('--privileged','--gpus','--cap-add','host','/var/run/docker.sock'):
|
||||||
|
self.assertNotIn(forbidden,args)
|
||||||
|
def test_failed_update_restores_only_owned_container(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
base=Path(directory);(base/'state').mkdir();(base/'backups').mkdir()
|
||||||
|
config=dict(base=str(base),owner=installer.LABEL,name='athena-deck-test',port=8110,image='old:build',previous_image=None,gpu_telemetry=False)
|
||||||
|
installer.write_manifest(base,config)
|
||||||
|
with patch.object(installer,'owned',return_value={'State':{'Running':True}}),patch.object(installer,'inspect',return_value=None),patch.object(installer,'build',return_value='new:build'),patch.object(installer,'launch',side_effect=RuntimeError('failed')),patch.object(installer,'run') as run:
|
||||||
|
with self.assertRaises(RuntimeError):installer.update(base)
|
||||||
|
self.assertEqual(installer.load_manifest(base)['image'],'old:build')
|
||||||
|
calls=[c.args for c in run.call_args_list]
|
||||||
|
self.assertIn(('docker','rename','athena-deck-test-previous','athena-deck-test'),calls)
|
||||||
|
self.assertIn(('docker','start','athena-deck-test'),calls)
|
||||||
|
self.assertTrue(all('mike-ai' not in ' '.join(c) for c in calls))
|
||||||
|
def test_standalone_never_attempts_ssh(self):
|
||||||
|
with patch.dict(os.environ,{'DECK_NETWORK_MODE':'disabled'}):client=NetworkClient()
|
||||||
|
with patch('network.client.subprocess.run') as remote:
|
||||||
|
self.assertFalse(client.status()['install_supported'])
|
||||||
|
with self.assertRaises(ValueError):client.call('connect')
|
||||||
|
with self.assertRaises(ValueError):client.install('x'*16,'y'*32)
|
||||||
|
remote.assert_not_called()
|
||||||
|
def test_requires_preprovisioned_credentials(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory,patch.dict(os.environ,{'DECK_REQUIRE_SETUP':'1'}):
|
||||||
|
with self.assertRaises(RuntimeError):Server(0,state_dir=directory)
|
||||||
|
def test_source_allowlist_exists(self):
|
||||||
|
for name in installer.FILES:self.assertTrue((installer.ROOT/name).is_file())
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
Reference in new issue
Block a user