Add isolated Debian installer with credential setup and guarded updates

This commit is contained in:
Mikei386
2026-09-28 15:35:17 +02:00
parent e1a54e3485
commit 2ad844ed20
12 changed files with 618 additions and 8 deletions
+185
View File
@@ -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.
+6
View File
@@ -3,6 +3,12 @@
Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript.
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
Katalog, Bibliothek, Profilentwürfe und eine Laufzeit-/Update-Ansicht sind als
+11
View File
@@ -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"]
+241
View File
@@ -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)
+35
View File
@@ -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.')
+62
View File
@@ -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
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/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 &amp; Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; 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 &amp; Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; 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
View File
@@ -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
View File
@@ -16,7 +16,7 @@ function networkButtons(){
const s=networkState;
document.querySelectorAll('#network-page button').forEach(b=>b.disabled=networkBusy||s.state==='installing');
const set=(id,disabled)=>{const b=document.getElementById(id);if(b)b.disabled=b.disabled||disabled;};
set('network-install',s.installed);
set('network-install',s.installed||s.install_supported===false);
set('network-import',!s.installed||s.enabled||s.mode!=='lan'||!!s.pending);
set('network-connect',!s.configured||s.enabled);
set('network-disconnect',!s.enabled);
@@ -41,7 +41,7 @@ async function networkPage(){
}
try{
const s=await networkRequest('');if(!document.querySelector('#network-page'))return;networkState=s;
const labels={unavailable:'Nicht bereit',installing:'Installation läuft',disabled:'Deaktiviert',connecting:'Warte auf Handshake',connected:'Verbunden'};
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';
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);}
+7
View File
@@ -15,6 +15,7 @@ SSH = ['ssh', '-i', str(Path.home()/'.ssh/athena_key'), '-o', 'BatchMode=yes', '
class NetworkClient:
def __init__(self):
self.disabled = os.environ.get('DECK_NETWORK_MODE') == 'disabled'
self.local = os.environ.get('DECK_NETWORK_SOCKET') == '1'
self.job = None
self.lock = threading.Lock()
@@ -22,6 +23,8 @@ class NetworkClient:
self.cached_at = 0
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)
if self.local:
from network.rpc import request
@@ -38,6 +41,8 @@ class NetworkClient:
return value
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:
if self.job and self.job['state'] == 'running':
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)
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:
raise ValueError('Die Server-Instanz ist bereits installiert.')
if not isinstance(password, str) or not 16 <= len(password) <= 256:
+10 -5
View File
@@ -93,7 +93,7 @@ class HardwareProvider:
class Server(ThreadingHTTPServer):
daemon_threads = True
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.hardware = HardwareProvider()
self.started = time.time()
@@ -103,6 +103,9 @@ class Server(ThreadingHTTPServer):
self.credentials = CredentialStore(rpc=request)
else:
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.login_attempts = []
self.auth_lock = threading.Lock()
@@ -215,7 +218,7 @@ class Handler(BaseHTTPRequestHandler):
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')
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)
def change_credentials(self, kind):
@@ -239,7 +242,9 @@ class Handler(BaseHTTPRequestHandler):
def valid_host(self):
if os.environ.get('DECK_PROXY_TOKEN'):
return self.ingress() in ('lan','tunnel')
return self.headers.get('Host') in (f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}')
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):
if not self.valid_host():
@@ -257,7 +262,7 @@ class Handler(BaseHTTPRequestHandler):
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.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':
return self.respond(self.server.hardware.snapshot())
if self.path == '/api/v1/network':
@@ -277,7 +282,7 @@ class Handler(BaseHTTPRequestHandler):
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'):
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)
self.server.credentials.setup(data['password'],data['api_token'])
return self.respond({'initialized':True})
+50
View File
@@ -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()