diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..1bd453b --- /dev/null +++ b/INSTALL.md @@ -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:` | +| 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. diff --git a/README.md b/README.md index 532bc7e..efebb36 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..d771010 --- /dev/null +++ b/deploy/Dockerfile @@ -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"] diff --git a/deploy/install.py b/deploy/install.py new file mode 100644 index 0000000..e2cea93 --- /dev/null +++ b/deploy/install.py @@ -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) diff --git a/deploy/provision.py b/deploy/provision.py new file mode 100644 index 0000000..8f3d63b --- /dev/null +++ b/deploy/provision.py @@ -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.') diff --git a/deploy/smoke.py b/deploy/smoke.py new file mode 100644 index 0000000..ad3cf4d --- /dev/null +++ b/deploy/smoke.py @@ -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() diff --git a/index.html b/index.html index 59d6ee5..f868939 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.4 · GUI-Prototyp
+Athena Deck
ATHENA CONTROL SURFACEv0.4 · GUI-Prototyp
diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8ac5445 --- /dev/null +++ b/install.sh @@ -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" "$@" diff --git a/network-ui.js b/network-ui.js index fd12941..7ff0a07 100644 --- a/network-ui.js +++ b/network-ui.js @@ -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);} diff --git a/network/client.py b/network/client.py index 3794421..fec833c 100644 --- a/network/client.py +++ b/network/client.py @@ -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: diff --git a/server.py b/server.py index 0e1abc6..7b74290 100644 --- a/server.py +++ b/server.py @@ -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}) diff --git a/test_deploy.py b/test_deploy.py new file mode 100644 index 0000000..af05ae7 --- /dev/null +++ b/test_deploy.py @@ -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()