diff --git a/FEATURE_COMPARISON.md b/FEATURE_COMPARISON.md index 249c5c5..21a8e00 100644 --- a/FEATURE_COMPARISON.md +++ b/FEATURE_COMPARISON.md @@ -1,6 +1,6 @@ # Alter Athena Router und Athena Deck -Stand 30.09.2026. Vergleich der aktuellen Quellen und dokumentierten Prüfungen; +Stand 01.10.2026. Vergleich der aktuellen Quellen und dokumentierten Prüfungen; keine neue vollständige Live-Abnahme sämtlicher Funktionen. Installierte alte Container bedeuten keine Anbindung an Deck. Kategorien und Docker-Listen allein sind kein ausführbarer Worker. @@ -16,8 +16,8 @@ sind kein ausführbarer Worker. | Dashboard, History, Themes | vorhanden | kein neuer vollständiger Migrationsaudit | | Modellkatalog, Komponenten, Downloads, automatische Speicheranpassung | vorhanden | Kompatibilitätsrezepte nicht universell | | ACE-Step Musikstudio | fehlt als Deck-Worker | Laufzeit, Profile/API, GPU-Modus und Oberfläche | -| YuE2 Musikgenerierung | fehlt als Deck-Worker | Laufzeit, Profile/API, GPU-Modus und Oberfläche | -| BS-RoFormer Stem-Separation | fehlt | Laufzeit und Job-/Dateiverarbeitung | +| YuE2 Musikgenerierung | vorhanden: audio.cpp, Musikmodus, Ladypoly | weitere Modelle/Funktionen separat anbinden | +| BS-RoFormer, MelBand und Demucs Trennung | vorhanden: native Laufzeit, Profile, Web-GUI | weitere Familien separat prüfen | | OmniVoice Voice Studio | fehlt | eigene Worker-/UI-Anbindung | | X-VC Voice Changer | fehlt | eigene Worker-/UI-Anbindung | | Applio/RVC Konvertierung und Training | fehlt | eigene Worker-/UI-Anbindung | @@ -26,9 +26,9 @@ sind kein ausführbarer Worker. | Realtime Voice / WebRTC / OpenClaw Talk | fehlt in Deck | Brücke, Echtzeitaudio und Clientintegration | | Responses API | fehlt in Deck | /v1/responses-Kompatibilität | | PCM-Streaming für TTS | fehlt in Deck | Streaming-Audiopfad | -| Spezialmodi für Musik/Voice/3D | fehlt | mehr als die vorhandenen LLM-/Video-Modi | +| Spezialmodi | LLM, Video, Musik, Audio-Trennung vorhanden | Voice/3D fehlen | | Markierte zusätzliche Docker-Anwendungen | vorhanden | allein noch keine GPU-Worker-Orchestrierung | -| WireGuard-Einstellungen | implementiert, im Athena-Test deaktiviert | Ersatz des vorhandenen Gateway nicht migriert/abgenommen | +| WireGuard-Einstellungen | nativer Host-/systemd-Dienst installiert, GUI/API/Web-Tools angebunden | echte Peer-Konfiguration und alter Gateway noch nicht migriert; Dual-Stack mit synthetischem Peer geprüft | | Backup/Restore | implementiert | kompletter Neuaufbau auf leerem Debian nicht durchgetestet; Anwendungsdaten zusätzlicher Studios brauchen Rezepte | | Nativer Debian-/systemd-Installer | unvollständig | frischer Host, Voraussetzungen und vollständige Abnahme | @@ -36,5 +36,23 @@ Empfohlene Reihenfolge: Embeddings und API-Lücken; gemeinsame Steuerung weitere GPU-Spezialworker; Musik/Voice/3D je nach Bedarf; native Installation und vollständiger Restore-Test. LTX-spezifische optionalen Funktionen separat gegen DeskWEB prüfen. -Die zuvor angefragte Löschung der unbenutzten alten Video-Laufzeit und ihrer -22 Backup-Kopien ist noch nicht ausgeführt: die konkrete Bestätigung steht aus. +## Stilllegung und native Netzwerk-Anbindung am 01.10.2026 + +Alle 33 alten `mike-ai-*`-Container außer `mike-ai-wireguard-gateway` sind +stillgelegt und auf `restart=no` gesetzt. Images, Definitionen und Daten bleiben +für den Vergleich erhalten. Vorherige Zustände und Neustartregeln sind geschützt +unter `/opt/athena-deck-dev/runtime/legacy-services-before-native-wireguard.json` +gesichert. Alte laufende Dienste sind damit keine aktive Rückfallversorgung mehr. + +`athena-deck-network.service` läuft nativ auf dem Debian-Host. Es verwaltet die +Schnittstelle `wgdeck0`; aktuell ist noch keine echte Peer-Konfiguration importiert +und der neue Tunnel ist deaktiviert. Im lokalen Modus werden die Deck-GUI 8108, +API-Ports 8120–8124 und die explizit registrierten Web-Tools 8118/8127/8128 über +Athenas KIT-LAN-Adresse bereitgestellt. SSH-Verwaltung bleibt unabhängig. + +Nachweis: 86 lokale Netzwerk-/API-/Profiltests, 32 isolierte Backup-/Modelltests +(mit Überschneidungen) und realer Dual-Stack-WireGuard-Handshake in zwei Wegwerf- +Netzwerk-Namensräumen. Kein produktiver Peer wurde zum Test verwendet. +GUI über SSH und KIT-LAN: HTTP 200; API ohne Token: HTTP 401; drei Web-Tools: HTTP 200. +Die vollständige Migration des alten Gateways steht noch aus; dieser bleibt aktiv. +Ein kompletter Restore auf leerem Debian ist weiterhin nicht abgenommen. diff --git a/README.md b/README.md index 3ed10b4..0b6175e 100644 --- a/README.md +++ b/README.md @@ -257,3 +257,5 @@ VAE-Konfiguration entsprechen 25 semantische Tokens einer Sekunde; die erweitert Token- und Sekundenfelder werden synchronisiert. Das Modell kann vor dem Limit enden. Der Ergebnisplayer bleibt beim Status-Polling bestehen, damit die Wiedergabe nicht alle 1,5 Sekunden zurückgesetzt wird. + +Native WireGuard-Anbindung: [Systemdienst, Zugriffsmodi und Migration](network/NATIVE.md). Der Tunnel läuft außerhalb von Docker; die isolierte Deck-App kann vorerst im Container bleiben. diff --git a/backup.py b/backup.py index 5d9b935..e49f07b 100644 --- a/backup.py +++ b/backup.py @@ -97,7 +97,7 @@ def validate(doc): if not isinstance(network,dict) or set(network)!={'config','enabled','mode'} or type(network['enabled']) is not bool or network['mode'] not in ('lan','both','tunnel'):raise ValueError('Ungültiger Netzwerkstand.') if network['config'] is not None: from network.config import parse_config - parse_config(network['config']) + parse_config(network['config'],native=True) services=doc.get('services') if not isinstance(services,list) or len(services)>50:raise ValueError('Ungültiger Containerbestand.') # Validation is shared with the root helper, which independently revalidates before Docker calls. diff --git a/deploy/install.py b/deploy/install.py index afa33ac..117765f 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['separator_sources.json','deploy/separator-web/Dockerfile','deploy/separator-web/app.py','deploy/separator-web/index.html','separator.py','separator_runtime.py','separator_worker.py','separator-ui.js','deploy/ladypoly/Dockerfile','deploy/ladypoly/bridge.py','music.py','music-ui.js','audio_cpp_runtime.py','audio-cpp-ui.js','ltx_original_runtime.py','ltx_original_entry.py','video_original.py','ltx-original-ui.js','deploy/ltx-original-requirements.lock','backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.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','deploy/image-requirements.lock'] +FILES = ['network/native.py','deploy/setup_network_helper.py','separator_sources.json','deploy/separator-web/Dockerfile','deploy/separator-web/app.py','deploy/separator-web/index.html','separator.py','separator_runtime.py','separator_worker.py','separator-ui.js','deploy/ladypoly/Dockerfile','deploy/ladypoly/bridge.py','music.py','music-ui.js','audio_cpp_runtime.py','audio-cpp-ui.js','ltx_original_runtime.py','ltx_original_entry.py','video_original.py','ltx-original-ui.js','deploy/ltx-original-requirements.lock','backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.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','deploy/image-requirements.lock'] def run(*args, check=True, interactive=False): @@ -107,6 +107,8 @@ def launch(config): args[args.index('--health-cmd')+1] = 'python3 -c "import urllib.request; urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3)"' # Explicit isolated development bootstrap, reachable only through host loopback/SSH. args += ['-e','DECK_REQUIRE_SETUP=0'] + if config.get('network_helper'): + args+=['--mount','type=bind,src=/run/athena-deck-network,dst=/run/athena-deck-network,readonly','-e','DECK_NETWORK_MODE=native','-e','DECK_PROXY_TOKEN_FILE=/run/athena-deck-network/proxy-token'] if config.get('docker_helper'): args += ['--mount','type=bind,src=/run/athena-deck-docker,dst=/run/athena-deck-docker,readonly','-e','DECK_DOCKER_HELPER_SOCKET=/run/athena-deck-docker/control.sock'] if config['gpu_telemetry']: @@ -233,10 +235,12 @@ def parse_api_ports(value): def main(): - parser=argparse.ArgumentParser(description='Athena Deck auf Debian 12/13 getrennt installieren. Kein WireGuard, keine Host-Paket- oder Treiberänderungen.') + parser=argparse.ArgumentParser(description='Athena Deck auf Debian 12/13 getrennt installieren. WireGuard optional als nativer Hostdienst; keine Treiberänderungen.') actions=parser.add_mutually_exclusive_group(required=True) - for action in ('check','install','setup','start','stop','status','update','rollback','docker-helper'): + for action in ('check','install','setup','start','stop','status','update','rollback','docker-helper','network-helper'): actions.add_argument('--'+action,action='store_true') + parser.add_argument('--lan-address',help='Host-LAN-Adresse für den nativen WireGuard-Dienst.') + parser.add_argument('--network-ports',help='Explizit freizugebende Ports; Standard GUI und API-Portbereich.') 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) @@ -257,7 +261,12 @@ def main(): else: config=load_manifest(args.directory) item=owned(config['name'],args.directory) - if args.docker_helper: + if args.network_helper: + if not args.lan_address:raise RuntimeError('--lan-address erforderlich.') + ports=args.network_ports or ','.join(map(str,[config['port'],*config.get('api_ports',[])])) + run(sys.executable,str(ROOT/'deploy/setup_network_helper.py'),'--client-uid','65534','--client-gid','65534','--lan-address',args.lan_address,'--gui-port',str(config['port']),'--ports',ports) + config['network_helper']=True;write_manifest(args.directory,config);update(args.directory,force=True,reuse_runtime=True) + elif args.docker_helper: run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534','--deck-state',str(args.directory/'state')) config['docker_helper']=True;write_manifest(args.directory,config);update(args.directory,force=True) elif args.status: diff --git a/deploy/setup_network_helper.py b/deploy/setup_network_helper.py new file mode 100644 index 0000000..cfb9d47 --- /dev/null +++ b/deploy/setup_network_helper.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Install the native Deck host network service, initially disconnected. +Does not stop existing gateways, import secrets or change default routes. +""" +import argparse +import ipaddress +import json +import os +from pathlib import Path +import shutil +import socket +import time +import subprocess + +MARKER='# Athena Deck native network service' +def main(): + p=argparse.ArgumentParser();p.add_argument('--client-uid',type=int,required=True);p.add_argument('--client-gid',type=int,required=True);p.add_argument('--lan-address',required=True);p.add_argument('--gui-port',type=int,default=8108);p.add_argument('--install-tools',action='store_true');p.add_argument('--ports',default='8108,8120,8121,8122,8123,8124');a=p.parse_args() + if os.geteuid()!=0 or not Path('/run/systemd/system').is_dir():raise SystemExit('Root auf Debian mit systemd erforderlich.') + if a.client_uid<1 or a.client_gid<1:raise SystemExit('Unprivilegierte Deck-UID/GID erforderlich.') + address=ipaddress.ip_address(a.lan_address) + if address.version!=4 or address.is_loopback or address.is_unspecified:raise SystemExit('Konkrete Host-LAN-Adresse erforderlich.') + ports=sorted(set(int(v) for v in a.ports.split(','))) + if a.gui_port not in ports or any(not 1024<=v<=65535 for v in ports) or len(ports)>16:raise SystemExit('Maximal 16 feste, nicht privilegierte Ports einschließlich GUI-Port.') + if a.install_tools and (not shutil.which('wg') or not shutil.which('ip')): + subprocess.run(['apt-get','update'],check=True) + subprocess.run(['apt-get','install','-y','--no-install-recommends','wireguard-tools','iproute2'],check=True) + if not shutil.which('wg') or not shutil.which('ip'):raise SystemExit('Zuerst Debian-Pakete wireguard-tools und iproute2 installieren. Kein Kernel- oder Treiberupdate erforderlich.') + base=Path('/opt/athena-deck-network');state=Path('/var/lib/athena-deck-network');unit=Path('/etc/systemd/system/athena-deck-network.service') + if any(p.is_symlink() for p in (base,state,unit)):raise SystemExit('Symlink-Ziel nicht erlaubt.') + if unit.exists() and not unit.read_text().startswith(MARKER):raise SystemExit('Dienstname belegt.') + if base.exists() and not (base/'managed-by-deck').exists():raise SystemExit('Installationsverzeichnis belegt.') + base.mkdir(mode=0o755,exist_ok=True);(base/'managed-by-deck').touch();(base/'network').mkdir(exist_ok=True) + source=Path(__file__).resolve().parent.parent/'network' + for name in ('__init__.py','native.py','config.py','policy.py'):shutil.copyfile(source/name,base/'network'/name) + state.mkdir(mode=0o700,exist_ok=True);state.chmod(0o700) + policy=state/'policy.json';interfaces=json.loads(subprocess.check_output(['ip','-j','address','show'],text=True)) + interface=next((r['ifname'] for r in interfaces if any(v.get('local')==str(address) for v in r.get('addr_info',[]))),None) + if not interface:raise SystemExit('LAN-Adresse ist nicht auf diesem Host vorhanden.') + value={'lan_address':str(address),'lan_interface':interface,'gui_port':a.gui_port,'ports':ports} + if policy.exists() and json.loads(policy.read_text())!=value:raise SystemExit('Bestehende Dienst-Portdefinition nicht automatisch ändern.') + policy.write_text(json.dumps(value));policy.chmod(0o600) + unit.write_text(MARKER+f''' +[Unit] +Description=Athena Deck native WireGuard and restricted access service +After=network-online.target +Wants=network-online.target +[Service] +Type=simple +WorkingDirectory={base} +ExecStart=/usr/bin/python3 -m network.native --client-uid {a.client_uid} --client-gid {a.client_gid} +Restart=on-failure +RuntimeDirectory=athena-deck-network +RuntimeDirectoryMode=0755 +RuntimeDirectoryPreserve=yes +StateDirectory=athena-deck-network +StateDirectoryMode=0700 +UMask=0077 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_CHOWN +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +ReadWritePaths=/var/lib/athena-deck-network /run/athena-deck-network +[Install] +WantedBy=multi-user.target +''');unit.chmod(0o644) + subprocess.run(['systemctl','daemon-reload'],check=True) + subprocess.run(['systemctl','enable','athena-deck-network.service'],check=True,capture_output=True) + subprocess.run(['systemctl','restart','athena-deck-network.service'],check=True) + for _ in range(30): + try: + with socket.socket(socket.AF_UNIX) as sock: + sock.settimeout(2);sock.connect('/run/athena-deck-network/control.sock');sock.sendall(b'{"action":"status"}\n') + with sock.makefile('rb') as f:status=json.loads(f.readline(65536)) + if status.get('installed'):break + except (OSError,ValueError):pass + time.sleep(.2) + else:raise SystemExit('Nativer Dienst nicht bereit; systemctl status athena-deck-network.service prüfen.') + print('Nativer Netzwerkdienst installiert. Bestehender Gateway und dessen Konfiguration unverändert.') +if __name__=='__main__':main() diff --git a/network-ui.js b/network-ui.js index 1f1be67..f7b3450 100644 --- a/network-ui.js +++ b/network-ui.js @@ -29,11 +29,7 @@ function networkButtons(){ async function networkPage(){ const view=document.querySelector('#view'); if(!document.querySelector('#network-page')){ - view.innerHTML=`
EINSTELLUNGEN / NETZWERK

Dein Weg zu Athena.

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

WireGuard-Modul

Wird geprüft …

Auf Athena einrichten

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

Vor der Installation kopieren und sicher speichern. Dieser Token gehört zur jeweiligen Server-Instanz.

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

Zugriff auf Deck und API

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

`; - document.querySelector('#network-install').onclick=()=>{const input=document.querySelector('#network-password'),password=input.value,token=document.querySelector('#network-api-token'),api_token=token.value;input.value='';token.value='';networkAction('install',{password,api_token});}; - document.querySelector('#network-generate-token').onclick=()=>{document.querySelector('#network-api-token').value=accessToken();}; - document.querySelector('#network-show-token').onclick=()=>{const input=document.querySelector('#network-api-token');input.type=input.type==='password'?'text':'password';document.querySelector('#network-show-token').textContent=input.type==='password'?'API-Token anzeigen':'API-Token verbergen';}; - document.querySelector('#network-copy-token').onclick=async()=>{try{const token=document.querySelector('#network-api-token').value;if(!token)throw Error();await navigator.clipboard.writeText(token);document.querySelector('#network-message').textContent='API-Token kopiert. Bitte sicher speichern.';}catch{document.querySelector('#network-message').textContent='Kopieren nicht möglich. Bitte eigenen Token verwenden.'}}; + view.innerHTML=`
EINSTELLUNGEN / NETZWERK

Dein Weg zu Athena.

Ein nativer WireGuard-Tunnel auf dem Debian-Server. Die Zugriffsmodi gelten für die freigegebenen Deck-Ports.

WireGuard-Modul

Wird geprüft …

Nativen Systemdienst einrichten

WireGuard läuft direkt auf Debian als systemd-Dienst, außerhalb von Docker. Die Einrichtung erfolgt mit deploy/setup_network_helper.py; dabei werden weder ein bestehender Gateway noch dessen Konfiguration automatisch übernommen.

Zugriff auf Deck und API

Der SSH-Zugang bleibt als Verwaltungskanal erhalten. Die freigegebenen GUI-, API- und Web-Tool-Ports verwenden denselben Zugriffsmodus. Im Modus „Nur Tunnel“ gibt es bei Tunnelausfall keinen LAN-Fallback.

`; document.querySelector('#network-import').onclick=async()=>{const input=document.querySelector('#network-file'),file=input.files[0];if(!file){document.querySelector('#network-message').textContent='Bitte zuerst eine .conf-Datei auswählen.';return}if(file.size>16384){document.querySelector('#network-message').textContent='Die Datei darf höchstens 16 KiB groß sein.';return}const config=await file.text();input.value='';await networkAction('import',{config});}; for(const action of ['connect','disconnect','delete','cancel'])document.querySelector('#network-'+action).onclick=()=>networkAction(action); document.querySelector('#network-apply').onclick=()=>networkAction('mode',{mode:document.querySelector('#network-mode').value}); diff --git a/network/NATIVE.md b/network/NATIVE.md new file mode 100644 index 0000000..af53423 --- /dev/null +++ b/network/NATIVE.md @@ -0,0 +1,99 @@ +# Nativer WireGuard-Dienst + +WireGuard wird auf dem Debian-Host als `wgdeck0` verwaltet. Der systemd-Dienst +`athena-deck-network.service` läuft außerhalb von Docker. Die Deck-Webanwendung +kommuniziert als unprivilegierter Benutzer über einen Unix-Socket mit dem +Helfer; sie bekommt weder NET_ADMIN noch allgemeinen Root-Zugriff. + +## Einrichtung + +Auf einem frischen Debian zuerst `wireguard-tools` und `iproute2` installieren; +`setup_network_helper.py --install-tools` kann genau diese Pakete nachziehen. +Es installiert keinen Kernel, keinen NVIDIA-Treiber und verlangt keinen Reboot. + +Für die derzeitige isolierte Deck-Docker-Installation: + +```sh +sudo python3 deploy/install.py --network-helper \ + --directory /opt/athena-deck-dev/runtime --lan-address 172.21.117.202 +``` + +Standardmäßig werden nur GUI-Port und der konfigurierte API-Portbereich +freigegeben. Zusätzliche Web-Tools müssen explizit mit `--network-ports` +registriert werden, beispielsweise `8108,8120,8121,8122,8123,8124,8118,8127,8128`. +Der Installer bindet den Dienst-Socket und Proxy-Prüfwert schreibgeschützt ein. +Die einmalige App-Aktualisierung startet nur Deck neu; der Tunnel bleibt bis zum +Conf-Import und Aktivieren deaktiviert. Keine zweite Deck-Instanz entsteht. + +Für eine spätere native Deck-Installation kann derselbe Helfer mit der +unprivilegierten Deck-UID/GID bereitgestellt werden: + +```sh +sudo python3 deploy/setup_network_helper.py --client-uid UID --client-gid GID \ + --lan-address HOST_LAN_IP --gui-port 8108 --ports 8108,8120 +``` + +Deck benötigt `DECK_NETWORK_MODE=native` und +`DECK_PROXY_TOKEN_FILE=/run/athena-deck-network/proxy-token`. +Die konkrete Portdefinition liegt rootgeschützt in +`/var/lib/athena-deck-network/policy.json`. Sie wird beim erneuten Setup nicht +still verändert. Bestehende WireGuard-Interfaces und Gateways bleiben erhalten. + +## Konfiguration und Zugriff + +Einstellungen → Netzwerkzugang: Conf importieren, aktivieren, Handshake prüfen, +Zugriff wählen. Unterstützt werden ein Peer, eine IPv4-Adresse und optional eine +IPv6-Adresse. Wiederholte DNS-Zeilen sind importierbar, verändern aber nicht den +Host-DNS. Shell-Hooks, Table und SaveConfig werden abgelehnt. + +LAN/Tunnel/beides gilt ausschließlich für die registrierten Deck-Ports. +Listener binden an konkrete Adressen und Interfaces; keine Wildcard-Freigabe, +keine Änderungen an Firewall-Default-Policies oder Docker-Regeln. Die vorhandenen +Loopback-Ports bleiben für SSH-Verwaltung erreichbar. SSH-Port 22 wird nicht +vom Zugriffsmodus gesperrt. Separate öffentliche Freigaben außerhalb des Helfers +werden nicht automatisch kontrolliert. + +GUI-Anfragen erhalten einen geheimen Proxy-Prüfwert und den tatsächlichen +Zugangsweg. Bestätigung aus der GUI benötigt Anmeldung und den richtigen +Zugangsweg. Ein Zugriffswechsel läuft 120 Sekunden auf Probe; ohne Bestätigung +wird der vorherige Modus wieder wirksam. API/WebSocket-Verbindungen werden ohne +Parameterübersetzung weitergeleitet. Zusatzoberflächen behalten ihre eigenen +Anmelde- und Hostprüfungen; deren URL-Freigaben sind separat zu prüfen. + +Der Dienst übernimmt absichtlich **nicht** die globale Standardroute. +AllowedIPs-Routen liegen ausschließlich in Tabelle 51826 und werden nur für +Pakete mit der eigenen Tunnel-Quelladresse ausgewählt. Full-tunnel-AllowedIPs +machen somit nicht den gesamten Debian-Host zum VPN-Client. Eine spätere +systemweite Egress-Policy wäre ein eigener Auftrag. Prioritäten 12126/12127 und +Tabelle 51826 werden vor Aktivierung auf Kollisionen geprüft. + +## Sicherung und Migration + +Die originale Conf liegt rootgeschützt im Dienst-Zustandsverzeichnis. Status +enthält keine Schlüssel. Decks verschlüsseltes Backup exportiert/importiert +Conf, Aktivierungszustand und bestätigten Modus. Der Helfer und seine feste +Portdefinition müssen auf einem neuen Host vor dem Netzwerk-Restore eingerichtet +sein. Wiederherstellung fremder Ports oder des alten Docker-Gateway-Routings ist +kein Bestandteil dieses Modul-Backups. + +Denselben Peer niemals gleichzeitig im alten Gateway und auf dem Host aktivieren. +Vor einer Übernahme unabhängigen SSH-Zugang prüfen, alte Abhängigkeiten stoppen, +Rückfall vorbereiten, dann alten Gateway stoppen und neuen Tunnel aktivieren. +Zuerst beide Zugänge prüfen; erst anschließend "nur WireGuard" bestätigen. + +## Betrieb + +```sh +sudo systemctl status athena-deck-network.service +sudo systemctl stop athena-deck-network.service +sudo systemctl start athena-deck-network.service +``` + +Stop entfernt nur die vom Dienst angelegte Schnittstelle und deren eigene Regeln. +Ein explizit aktivierter Tunnel wird beim nächsten Dienststart wieder aktiviert; +eine Deaktivierung über Deck bleibt dagegen gespeichert. + +Tests: `python3 -m unittest network.test_native test_network` und auf einem +Linux-Testhost als root `python3 network/test_native_integration.py`. Letzterer +nutzt zwei Wegwerf-Netzwerk-Namensräume und synthetische Schlüssel, keine Container, +produktiven Peer-Konfigurationen oder Host-Routen. diff --git a/network/README.md b/network/README.md index 98f00ea..3ef96aa 100644 --- a/network/README.md +++ b/network/README.md @@ -1,3 +1,9 @@ +# WireGuard in Athena Deck + +Aktueller Weg: **nativer Debian-/systemd-Dienst**, siehe [Einrichtung, Zugriff und Migration](NATIVE.md). Die aktuelle Client-Anbindung verwendet keinen Netzwerk-Container. + +## Historischer Container-Prototyp (nicht mehr zur Installation verwenden) + # WireGuard-Modul · Athena Deck 0.3 ## Was eingebaut ist diff --git a/network/client.py b/network/client.py index fec833c..0040da1 100644 --- a/network/client.py +++ b/network/client.py @@ -1,101 +1,30 @@ -"""Local management via fixed SSH target or container-private Unix RPC.""" -from auth import initial_record -import base64 -import hashlib +"""Authenticated Deck administration through a native host Unix socket only.""" import json import os from pathlib import Path -import secrets -import subprocess import threading import time - -ROOT = Path(__file__).resolve().parent.parent -SSH = ['ssh', '-i', str(Path.home()/'.ssh/athena_key'), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes', 'root@192.168.1.212'] +from network.rpc import request 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() - self.cached = None - 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 - data.update(ingress=ingress, proxy_token=os.environ.get('DECK_PROXY_TOKEN', '')) - return request(data) - result = subprocess.run(SSH + ['docker exec -i athena-deck-network python3 -m network.rpc'], input=json.dumps(data), text=True, capture_output=True, timeout=35) - try: - value = json.loads(result.stdout) - except ValueError: - raise ValueError('Deck-Netzwerkmodul auf Athena nicht erreichbar oder noch nicht installiert.') from None - if result.returncode or ('error' in value and 'installed' not in value): - raise ValueError(value.get('error', 'Netzwerkaktion fehlgeschlagen.')) - self.cached = None - 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.') + self.native=os.environ.get('DECK_NETWORK_MODE')=='native' + self.disabled=not self.native + self.local=False + self.job=None;self.lock=threading.Lock();self.cached=None;self.cached_at=0 + def call(self,action,values=None,ingress='management'): + if self.disabled:raise ValueError('Nativen WireGuard-Systemdienst zuerst auf dem Debian-Host einrichten.') + token=Path(os.environ.get('DECK_PROXY_TOKEN_FILE','/run/athena-deck-network/proxy-token')).read_text().strip() + result=request(dict(values or {},action=action,ingress=ingress,proxy_token=token),path='/run/athena-deck-network/control.sock') + self.cached=None + return result + def status(self,ingress='management'): + if self.disabled:return dict(installed=False,install_supported=False,state='disabled-module',ingress=ingress,backend='native-systemd',error='Nativer WireGuard-Systemdienst ist noch nicht angebunden. Einrichtung siehe Installationsanleitung.') with self.lock: - if self.job and self.job['state'] == 'running': - return dict(installed=False, state='installing', job=self.job.copy(), ingress=ingress) - if self.cached is not None and time.monotonic()-self.cached_at < 3: - return dict(self.cached, ingress=ingress, job=self.job) - try: - result = self.call('status', ingress=ingress) - result['reachable'] = True - except (ValueError, OSError, subprocess.SubprocessError): - result = dict(installed=False, reachable=False, state='unavailable', error='Netzwerkmodul noch nicht installiert oder Athena nicht erreichbar.') - self.cached = result - self.cached_at = time.monotonic() - 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: - raise ValueError('Bitte ein eigenes Deck-Passwort mit mindestens 16 Zeichen setzen.') - auth = initial_record(password,api_token) - with self.lock: - if self.job and self.job['state'] == 'running': - raise ValueError('Installation läuft bereits.') - self.job = dict(state='running', message='Eigener Deck-Container wird gebaut und gestartet. Das kann mehrere Minuten dauern.') - threading.Thread(target=self._install, args=(auth,), daemon=True).start() - return dict(job=self.job.copy()) - - def _install(self, auth): - try: - # Import the source allowlist without executing the installer entrypoint. - from network.install_remote import FILES - payload = dict(auth=auth, files={name:base64.b64encode((ROOT/name).read_bytes()).decode() for name in FILES}) - script = (ROOT/'network/install_remote.py').read_text() - # Remote command contains only fixed trusted program text, never user input. - import shlex - command = 'python3 -c ' + shlex.quote(script) - result = subprocess.run(SSH+[command], input=json.dumps(payload), text=True, capture_output=True, timeout=900) - value = json.loads(result.stdout) - if result.returncode or ('error' in value and 'installed' not in value): - raise ValueError(value.get('error', 'Installation fehlgeschlagen.')) - # Confirm helper readiness; don't report a successful installation from docker run alone. - for _ in range(20): - try: - self.call('status') - break - except (ValueError, OSError, subprocess.SubprocessError): - time.sleep(1) - else: - raise ValueError('Container angelegt, aber Netzwerk-Helper nicht bereit. Installation prüfen.') - self.job = dict(state='complete', message='Netzwerkmodul installiert. Jetzt eine eigene WireGuard-Konfiguration importieren.') - except Exception as exc: - self.job = dict(state='failed', message=str(exc) if isinstance(exc, ValueError) and not isinstance(exc, json.JSONDecodeError) else 'Installation fehlgeschlagen; SSH und Docker prüfen.') - finally: - self.cached = None + if self.cached is not None and time.monotonic()-self.cached_at<3:return dict(self.cached,ingress=ingress) + try:result=self.call('status',ingress=ingress);result['reachable']=True + except (ValueError,OSError,json.JSONDecodeError):result=dict(installed=False,install_supported=False,reachable=False,state='unavailable',error='Nativer WireGuard-Systemdienst nicht erreichbar.') + self.cached=result;self.cached_at=time.monotonic() + return dict(result,ingress=ingress) + def install(self,password=None,api_token=None): + raise ValueError('WireGuard wird nativ mit deploy/setup_network_helper.py eingerichtet. Kein Netzwerk-Container und keine zweite Deck-Instanz.') diff --git a/network/config.py b/network/config.py index 18f1015..e337d24 100644 --- a/network/config.py +++ b/network/config.py @@ -7,7 +7,7 @@ class ConfigError(ValueError): pass -def parse_config(text): +def parse_config(text, native=False): if not isinstance(text, str) or len(text.encode('utf-8')) > 16384: raise ConfigError('Die Konfiguration darf höchstens 16 KiB groß sein.') sections = {} @@ -25,6 +25,9 @@ def parse_config(text): raise ConfigError('Ungültiges WireGuard-Dateiformat.') name, value = (part.strip() for part in line.split('=', 1)) allowed = {'PrivateKey', 'Address', 'DNS', 'MTU', 'ListenPort'} if current is sections.get('[Interface]') else {'PublicKey', 'PresharedKey', 'AllowedIPs', 'Endpoint', 'PersistentKeepalive'} + if native and name == 'DNS' and name in current: + current[name] += ', ' + value + continue if name not in allowed or name in current: raise ConfigError('Unbekannte oder doppelte Direktive. Hooks, Table und SaveConfig sind nicht erlaubt.') if not value or any(ord(c) < 32 for c in value): @@ -44,9 +47,9 @@ def parse_config(text): try: addresses = [ipaddress.ip_interface(v.strip()) for v in interface['Address'].split(',')] networks = [ipaddress.ip_network(v.strip(), strict=False) for v in peer['AllowedIPs'].split(',')] - if len(addresses) != 1 or addresses[0].version != 4 or any(n.version != 4 for n in networks): + if (not native and (len(addresses) != 1 or addresses[0].version != 4 or any(n.version != 4 for n in networks))) or (native and (not 1 <= len(addresses) <= 2 or len({a.version for a in addresses}) != len(addresses) or addresses[0].version != 4)): raise ConfigError('Diese Version unterstützt eine IPv4-Tunneladresse und IPv4-AllowedIPs.') - if addresses[0].ip.is_loopback or addresses[0].ip.is_unspecified or addresses[0].ip.is_multicast: + if any(a.ip.is_loopback or a.ip.is_unspecified or a.ip.is_multicast for a in addresses): raise ValueError() if len(networks) > 32: raise ValueError() @@ -64,8 +67,8 @@ def parse_config(text): if isinstance(exc, ConfigError): raise raise ConfigError('Ungültige Adresse, Endpoint, Port, MTU oder Keepalive.') from None - warnings = ['DNS wird nicht übernommen; die Container-DNS-Auflösung bleibt bestehen.'] if 'DNS' in interface else [] - return dict(interface=interface, peer=peer, address=str(addresses[0].ip), allowed_ips=[str(n) for n in networks], mtu=mtu, keepalive=keepalive, listen=listen, warnings=warnings) + warnings = ['DNS wird nicht übernommen; die bestehende System-DNS-Auflösung bleibt erhalten.'] if 'DNS' in interface else [] + return dict(addresses=[str(a) for a in addresses], interface=interface, peer=peer, address=str(addresses[0].ip), allowed_ips=[str(n) for n in networks], mtu=mtu, keepalive=keepalive, listen=listen, warnings=warnings) def wireguard_text(config): diff --git a/network/native.py b/network/native.py new file mode 100644 index 0000000..b5f4722 --- /dev/null +++ b/network/native.py @@ -0,0 +1,235 @@ +"""Native host WireGuard, fixed Unix RPC and address-bound Deck access gateways. +No wg-quick hooks, default route replacement, DNS changes or Docker operations. +""" +import argparse +import http.client +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import ipaddress +import json +import os +from pathlib import Path +import secrets +import selectors +import signal +import socket +import socketserver +import struct +import subprocess +import threading +import time +from network.config import parse_config, wireguard_text +from network.policy import Policy + +INTERFACE='wgdeck0' +TABLE='51826' +PRIORITIES={4:'12126',6:'12127'} +ENV={'PATH':'/usr/sbin:/usr/bin:/sbin:/bin','LANG':'C.UTF-8'} + +def run(args,check=True): + r=subprocess.run(args,capture_output=True,text=True,timeout=15,env=ENV) + if check and r.returncode:raise ValueError('WireGuard-Systemaktion fehlgeschlagen ('+' '.join(args)+'); Verbindung und Hostzustand prüfen.') + return r.stdout.strip() + +def save(path,value): + temp=path.with_suffix('.tmp');fd=os.open(temp,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600) + with os.fdopen(fd,'w') as f:json.dump(value,f);f.flush();os.fsync(f.fileno()) + temp.replace(path) + +class Manager: + def __init__(self,state,policy): + self.root=Path(state);self.root.mkdir(mode=0o700,exist_ok=True);self.settings=policy + self.lock=threading.RLock();self.listeners=[];self.enabled=False;self.error=None + self.policy=Policy(mode=self.read('mode.json','lan'),persist=lambda v:save(self.root/'mode.json',v)) + token=self.root/'proxy-token' + if not token.exists():token.write_text(secrets.token_hex(32));token.chmod(0o600) + self.token=token.read_text().strip() + self.reconcile() + def read(self,name,default=None): + p=self.root/name + return json.loads(p.read_text()) if p.exists() else default + def config(self): + text=self.read('config.json') + return parse_config(text,native=True) if text else None + def connected(self): + if not self.enabled:return False,0 + raw=run(['wg','show',INTERFACE,'latest-handshakes'],False) + latest=max((int(l.split()[1]) for l in raw.splitlines() if len(l.split())==2),default=0) + return latest>0 and 0<=time.time()-latest<180,latest + def status(self,ingress='management'): + connected,latest=self.connected();c=self.config() + return dict(installed=True,install_supported=False,backend='native-systemd',interface=INTERFACE,enabled=self.enabled,configured=bool(c),state='connected' if connected else 'connecting' if self.enabled else 'disabled',latest_handshake=latest,ingress=ingress,lan_url='http://'+self.settings['lan_address']+':'+str(self.settings['gui_port']),tunnel_url='http://'+c['address']+':'+str(self.settings['gui_port']) if c else None,warnings=c['warnings'] if c else [],error=self.error,**self.policy.status()) + def connect(self): + if self.enabled:return + c=self.config() + if not c:raise ValueError('Zuerst eine WireGuard-Konfiguration importieren.') + existing=json.loads(run(['ip','-j','address','show'])) + if any(ipaddress.ip_interface(a).ip==ipaddress.ip_address(v['local']) for a in c['addresses'] for d in existing for v in d.get('addr_info',[])): + raise ValueError('Tunnel-IP ist bereits auf diesem Host aktiv. Alten Tunnel zuerst kontrolliert deaktivieren.') + if run(['ip','link','show',INTERFACE],False):raise ValueError('Schnittstelle wgdeck0 ist bereits belegt.') + for version in (4,6): + if run(['ip','-'+str(version),'route','show','table',TABLE],False) or any(line.split(':',1)[0].strip()==PRIORITIES[version] for line in run(['ip','-'+str(version),'rule','show']).splitlines()):raise ValueError('Reservierte Routingtabelle oder Regelpriorität ist bereits belegt.') + try: + run(['ip','link','add',INTERFACE,'type','wireguard']);self.enabled=True + temp=self.root/'setconf.conf';fd=os.open(temp,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600) + try: + with os.fdopen(fd,'w') as f:f.write(wireguard_text(c)) + run(['wg','setconf',INTERFACE,str(temp)]) + finally:temp.unlink(missing_ok=True) + run(['ip','link','set',INTERFACE,'mtu',str(c['mtu']),'up']) + for address in c['addresses']: + a=ipaddress.ip_interface(address);version=str(a.version) + run(['ip','-'+version,'address','add',str(a.ip)+('/32' if a.version==4 else '/128'),'dev',INTERFACE]) + for network in c['allowed_ips']: + if ipaddress.ip_network(network).version==a.version:run(['ip','-'+version,'route','add',network,'dev',INTERFACE,'table',TABLE]) + run(['ip','-'+version,'rule','add','priority',PRIORITIES[a.version],'from',str(a.ip)+('/32' if a.version==4 else '/128'),'lookup',TABLE]) + self.reconcile();save(self.root/'enabled.json',True);self.error=None + except Exception: + self.disconnect();raise + def disconnect(self,persist=True): + if self.enabled: + self.enabled=False;self.reconcile() + for version in (4,6): + run(['ip','-'+str(version),'rule','del','priority',PRIORITIES[version],'lookup',TABLE],False) + run(['ip','-'+str(version),'route','flush','table',TABLE],False) + run(['ip','link','del',INTERFACE],False) + if persist:save(self.root/'enabled.json',False) + def reconcile(self): + # LAN never uses 0.0.0.0; API and applications keep their loopback upstream. + desired={(self.settings['lan_address'],p,'lan') for p in self.settings['ports']} + if self.enabled: + c=self.config();desired|={(str(ipaddress.ip_interface(a).ip),p,'tunnel') for a in c['addresses'] for p in self.settings['ports']} + present={(s.bind_address,s.port,s.ingress) for s in self.listeners} + added=[] + try: + for address,port,ingress in sorted(desired-present): + cls=GUI if port==self.settings['gui_port'] else Relay + server=Listener(address,port,ingress,cls,self) + threading.Thread(target=server.serve_forever,daemon=True).start();added.append(server) + except Exception: + for s in added:s.shutdown();s.server_close() + raise ValueError('Netzwerkport ist belegt; bestehende Listener bleiben unverändert.') from None + self.listeners+=added + for s in self.listeners[:]: + if (s.bind_address,s.port,s.ingress) not in desired: + s.shutdown();s.server_close();self.listeners.remove(s) + def dispatch(self,data): + action=data.get('action');ingress='management' + if secrets.compare_digest(str(data.get('proxy_token','')),self.token) and data.get('ingress') in ('lan','tunnel'):ingress=data['ingress'] + with self.lock: + if action=='status':return self.status(ingress) + if action=='import': + if self.enabled or self.policy.mode!='lan' or self.policy.pending:raise ValueError('Import nur bei deaktiviertem Tunnel im bestätigten lokalen Modus.') + text=data.get('config');parse_config(text,native=True);save(self.root/'config.json',text) + elif action=='connect':self.connect() + elif action=='disconnect':self.disconnect() + elif action=='mode':self.policy.propose(data.get('mode'),self.connected()[0]) + elif action=='confirm':self.policy.confirm(data.get('trial_id'),ingress) + elif action=='cancel':self.policy.cancel() + elif action=='delete': + if self.enabled or self.policy.mode!='lan' or self.policy.pending:raise ValueError('Tunnel zuerst deaktivieren und lokalen Modus bestätigen.') + (self.root/'config.json').unlink(missing_ok=True) + elif action=='backup-export':return dict(config=self.read('config.json'),enabled=self.enabled,mode=self.policy.mode) + elif action=='backup-restore': + b=data.get('backup',{}) + if set(b)!={'config','enabled','mode'} or type(b['enabled']) is not bool or b['mode'] not in Policy.MODES:raise ValueError('Ungültiger Netzwerkstand.') + if self.enabled or self.policy.mode!='lan':raise ValueError('Restore benötigt deaktivierten Tunnel im lokalen Modus.') + if b['config']:parse_config(b['config'],native=True);save(self.root/'config.json',b['config']) + if b['enabled']:self.connect() + if b['mode']!='lan':self.policy.propose(b['mode'],self.connected()[0]) + else:raise ValueError('Unbekannte Netzwerkaktion.') + return self.status(ingress) + +class Listener(socketserver.ThreadingTCPServer): + allow_reuse_address=True;daemon_threads=True + def __init__(self,address,port,ingress,handler,manager): + self.address_family=socket.AF_INET6 if ':' in address else socket.AF_INET + self.bind_address=address;self.port=port;self.ingress=ingress;self.manager=manager + super().__init__((address,port),handler,bind_and_activate=False) + try: + device=INTERFACE if ingress=='tunnel' else manager.settings.get('lan_interface') + if device:self.socket.setsockopt(socket.SOL_SOCKET,socket.SO_BINDTODEVICE,device.encode()+b'\0') + self.server_bind();self.server_activate() + except Exception:self.server_close();raise + def allowed(self): + with self.manager.lock:return self.manager.policy.allowed(self.ingress) + +class Relay(socketserver.BaseRequestHandler): + def handle(self): + if not self.server.allowed():return + try: + with socket.create_connection(('127.0.0.1',self.server.port),timeout=5) as upstream,selectors.DefaultSelector() as sel: + self.request.setblocking(False);upstream.setblocking(False) + sel.register(self.request,selectors.EVENT_READ,upstream);sel.register(upstream,selectors.EVENT_READ,self.request) + while self.server.allowed(): + for key,_ in sel.select(1): + data=key.fileobj.recv(65536) + if not data:return + # Bounded blocking send: preserve streaming and WebSocket bytes unchanged. + dst=key.data;dst.settimeout(10);dst.sendall(data);dst.setblocking(False) + except (OSError,ValueError):pass + +class GUI(BaseHTTPRequestHandler): + protocol_version='HTTP/1.1' + def log_message(self,*args):pass + def do_GET(self):self.forward() + def do_POST(self):self.forward() + def do_HEAD(self):self.forward() + def forward(self): + if not self.server.allowed():self.send_error(403,'Network access disabled');return + expected=('['+self.server.bind_address+']' if ':' in self.server.bind_address else self.server.bind_address)+':'+str(self.server.port) + if self.headers.get('Host')!=expected:self.send_error(403,'Host rejected');return + try: + if self.headers.get('Transfer-Encoding'):raise ValueError() + length=int(self.headers.get('Content-Length','0')) + if not 0<=length<=64*1024*1024:raise ValueError() + self.connection.settimeout(30) + body=self.rfile.read(length) if length else None + if body is not None and len(body)!=length:raise ValueError() + headers={k:v for k,v in self.headers.items() if k.lower() not in ('connection','x-deck-proxy','x-deck-ingress','transfer-encoding')} + headers.update({'X-Deck-Proxy':self.server.manager.token,'X-Deck-Ingress':self.server.ingress,'Connection':'close'}) + conn=http.client.HTTPConnection('127.0.0.1',self.server.port,timeout=120) + try: + conn.request(self.command,self.path,body,headers);response=conn.getresponse();self.send_response(response.status) + for k,v in response.getheaders(): + if k.lower() not in ('connection','transfer-encoding','server','date'):self.send_header(k,v) + self.send_header('Connection','close');self.end_headers();self.close_connection=True + if self.command!='HEAD': + while chunk:=response.read(65536):self.wfile.write(chunk) + finally:conn.close() + except (OSError,ValueError,http.client.HTTPException):self.close_connection=True + +class RPC(socketserver.StreamRequestHandler): + def handle(self): + self.connection.settimeout(30) + try: + _,uid,_=struct.unpack('3i',self.connection.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12)) + if uid not in (0,self.server.client_uid):raise ValueError('Client nicht freigegeben.') + raw=self.rfile.readline(32769) + if len(raw)>32768:raise ValueError('Anfrage zu groß.') + data=json.loads(raw) + if not isinstance(data,dict):raise ValueError('Ungültige Anfrage.') + result=self.server.manager.dispatch(data) + except Exception as e:result={'error':str(e) if isinstance(e,ValueError) else 'Nativer Netzwerkdienst nicht verfügbar.'} + self.wfile.write(json.dumps(result).encode()+b'\n') + +class Server(socketserver.ThreadingUnixStreamServer):daemon_threads=True + +def main(): + p=argparse.ArgumentParser();p.add_argument('--client-uid',type=int,required=True);p.add_argument('--client-gid',type=int,required=True);p.add_argument('--state',default='/var/lib/athena-deck-network');args=p.parse_args() + root=Path(args.state);policy=json.loads((root/'policy.json').read_text()) + manager=Manager(root,policy) + runtime=Path('/run/athena-deck-network');runtime.mkdir(exist_ok=True) + token=runtime/'proxy-token';token.write_text(manager.token);os.chown(token,0,args.client_gid);token.chmod(0o640) + sock=runtime/'control.sock';sock.unlink(missing_ok=True) + with Server(str(sock),RPC) as server: + os.chown(sock,0,args.client_gid);sock.chmod(0o660);server.client_uid=args.client_uid;server.manager=manager + # enabled is persisted only on explicit successful activation; native service owns recovery. + if manager.read('enabled.json',False): + try:manager.connect() + except Exception:manager.error='Gespeicherter Tunnel konnte nicht aktiviert werden; Hostzustand prüfen.' + signal.signal(signal.SIGTERM,lambda *_:threading.Thread(target=server.shutdown,daemon=True).start()) + try:server.serve_forever() + finally: + manager.disconnect(persist=False) + for listener in manager.listeners:listener.shutdown();listener.server_close() +if __name__=='__main__':main() diff --git a/network/rpc.py b/network/rpc.py index 22f97c8..94a1929 100644 --- a/network/rpc.py +++ b/network/rpc.py @@ -5,10 +5,10 @@ import sys SOCKET = '/run/deck/control.sock' -def request(data): +def request(data, path=SOCKET): with socket.socket(socket.AF_UNIX) as sock: sock.settimeout(25) - sock.connect(SOCKET) + sock.connect(path) sock.sendall(json.dumps(data).encode()+b'\n') with sock.makefile('rb') as stream: raw = stream.readline(65537) diff --git a/network/test_native.py b/network/test_native.py new file mode 100644 index 0000000..9d13682 --- /dev/null +++ b/network/test_native.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from network.config import parse_config,ConfigError +from network.native import Manager + +CONF='''[Interface] +PrivateKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= +Address = 192.0.2.10/24, fd00::10/64 +DNS = 192.0.2.1 +DNS = example.test +[Peer] +PublicKey = AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI= +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = vpn.example.test:51820 +PersistentKeepalive = 25 +''' +class NativeTests(unittest.TestCase): + def manager(self,root): + with patch.object(Manager,'reconcile'):return Manager(root,dict(lan_address='192.0.2.11',gui_port=8108,ports=[8108,8120])) + def test_dual_stack_repeated_dns(self): + c=parse_config(CONF,native=True);self.assertEqual(len(c['addresses']),2);self.assertEqual(c['allowed_ips'],['0.0.0.0/0','::/0']);self.assertTrue(c['warnings']) + def test_hooks_rejected(self): + with self.assertRaises(ConfigError):parse_config(CONF.replace('DNS = example.test','PostUp = echo unsafe'),native=True) + def test_no_key_in_status_and_backup_complete(self): + with tempfile.TemporaryDirectory() as t: + m=self.manager(t);m.dispatch(dict(action='import',config=CONF));s=m.status() + self.assertNotIn('PrivateKey',json.dumps(s));self.assertNotIn('AQEBAQ',json.dumps(s));self.assertEqual(m.dispatch(dict(action='backup-export'))['config'],CONF) + self.assertEqual((Path(t)/'config.json').stat().st_mode&0o777,0o600) + def test_confirm_requires_native_ingress_token(self): + with tempfile.TemporaryDirectory() as t: + m=self.manager(t) + with patch.object(m,'connected',return_value=(True,1)): + s=m.dispatch(dict(action='mode',mode='both'));trial=s['pending']['id'] + with self.assertRaises(ValueError):m.dispatch(dict(action='confirm',trial_id=trial,ingress='tunnel',proxy_token='fake')) + m.dispatch(dict(action='confirm',trial_id=trial,ingress='tunnel',proxy_token=m.token));self.assertEqual(m.policy.mode,'both') + def test_duplicate_host_ip_aborts_without_mutation(self): + with tempfile.TemporaryDirectory() as t: + m=self.manager(t);m.dispatch(dict(action='import',config=CONF)) + with patch('network.native.run',return_value=json.dumps([{'addr_info':[{'local':'192.0.2.10'}]}])) as command: + with self.assertRaises(ValueError):m.connect() + self.assertEqual(command.call_count,1);self.assertFalse(m.enabled) + def test_source_routes_only_and_owned_cleanup(self): + with tempfile.TemporaryDirectory() as t: + m=self.manager(t);m.dispatch(dict(action='import',config=CONF));calls=[] + def command(args,check=True):calls.append(args);return '[]' if '-j' in args else '' + with patch('network.native.run',side_effect=command),patch.object(m,'reconcile'): + m.connect();self.assertTrue(m.enabled) + self.assertFalse(any('main' in a or 'default' in a for a in calls)) + up=next(i for i,a in enumerate(calls) if a[:3]==['ip','link','set']) + route=next(i for i,a in enumerate(calls) if a[:4]==['ip','-4','route','add']) + self.assertLess(up,route) + self.assertTrue(any(a[:4]==['ip','-4','rule','add'] and 'from' in a for a in calls)) + self.assertTrue(any(a[:4]==['ip','-6','rule','add'] for a in calls)) + m.disconnect();self.assertFalse(m.enabled) + self.assertFalse(any('iptables' in a or 'nft' in a for a in calls)) +if __name__=='__main__':unittest.main() diff --git a/network/test_native_integration.py b/network/test_native_integration.py new file mode 100644 index 0000000..5655950 --- /dev/null +++ b/network/test_native_integration.py @@ -0,0 +1,90 @@ +"""Explicit Linux/root test: native processes in two disposable network namespaces. +No containers, host routes, production ports, keys or existing WireGuard peers used. +""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +ROOT=Path(__file__).resolve().parent.parent +sys.path.insert(0,str(ROOT)) +A='deck-wg-test-a';B='deck-wg-test-b' +def run(*args,data=None): + r=subprocess.run(args,input=data,text=True,capture_output=True,timeout=15) + if r.returncode:raise RuntimeError('Isolated integration command failed: '+args[0]) + return r.stdout.strip() +def ns(name,*args,data=None):return run('ip','netns','exec',name,*args,data=data) +SCRIPT=r''' +import json,os,signal,socketserver,threading +from pathlib import Path +from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer +from network.native import Manager,RPC,Server +policy={'lan_address':'10.241.88.1','lan_interface':'testa','gui_port':18108,'ports':[18108,18120]} +m=Manager(os.environ['TEST_STATE'],policy) +class App(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def do_GET(self): + body=b'{"synthetic":true}';self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) +class Stream(socketserver.BaseRequestHandler): + def handle(self):self.request.sendall(b'synthetic-stream');self.request.close() +threading.Thread(target=ThreadingHTTPServer(('127.0.0.1',18108),App).serve_forever,daemon=True).start() +threading.Thread(target=socketserver.ThreadingTCPServer(('127.0.0.1',18120),Stream).serve_forever,daemon=True).start() +with Server(os.environ['TEST_SOCKET'],RPC) as server: + server.manager=m;server.client_uid=0 + signal.signal(signal.SIGTERM,lambda *_:threading.Thread(target=server.shutdown,daemon=True).start()) + try:server.serve_forever() + finally: + m.disconnect() + for s in m.listeners:s.shutdown();s.server_close() +''' +def main(): + if os.geteuid()!=0:raise SystemExit('Explicit root test on Linux required.') + process=None + before=run('ip','-4','route','show') + with tempfile.TemporaryDirectory(prefix='deck-native-test-') as t: + try: + for n in (A,B):run('ip','netns','add',n) + run('ip','link','add','testa','type','veth','peer','name','testb') + run('ip','link','set','testa','netns',A);run('ip','link','set','testb','netns',B) + for n,dev,ip in ((A,'testa','10.241.88.1/24'),(B,'testb','10.241.88.2/24')): + ns(n,'ip','link','set','lo','up');ns(n,'ip','address','add',ip,'dev',dev);ns(n,'ip','link','set',dev,'up') + env=dict(os.environ,PYTHONPATH=str(ROOT),TEST_STATE=t,TEST_SOCKET=t+'/rpc.sock') + process=subprocess.Popen(['ip','netns','exec',A,'python3','-c',SCRIPT],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + for _ in range(30): + if Path(t+'/rpc.sock').exists():break + time.sleep(.1) + from network.rpc import request + def rpc(action,**values):return request(dict(action=action,**values),path=t+'/rpc.sock') + ka=ns(A,'wg','genkey');kb=ns(B,'wg','genkey');pa=ns(A,'wg','pubkey',data=ka+'\n');pb=ns(B,'wg','pubkey',data=kb+'\n') + rpc('import',config=f'[Interface]\nPrivateKey = {ka}\nAddress = 10.242.88.1/24, fd42:88::1/64\n[Peer]\nPublicKey = {pb}\nAllowedIPs = 10.242.88.2/32, fd42:88::2/128\nEndpoint = 10.241.88.2:51835\nPersistentKeepalive = 1\n') + peer=Path(t)/'peer.conf';peer.write_text(f'[Interface]\nPrivateKey = {kb}\nListenPort = 51835\n[Peer]\nPublicKey = {pa}\nAllowedIPs = 10.242.88.1/32, fd42:88::1/128\nEndpoint = 10.241.88.1:51836\nPersistentKeepalive = 1\n');peer.chmod(0o600) + ns(B,'ip','link','add','peerwg0','type','wireguard');ns(B,'wg','setconf','peerwg0',str(peer));peer.unlink() + ns(B,'ip','address','add','10.242.88.2/32','dev','peerwg0');ns(B,'ip','link','set','peerwg0','up');ns(B,'ip','route','add','10.242.88.1/32','dev','peerwg0') + ns(B,'ip','-6','address','add','fd42:88::2/128','dev','peerwg0');ns(B,'ip','-6','route','add','fd42:88::1/128','dev','peerwg0') + rpc('connect');ns(A,'wg','set','wgdeck0','listen-port','51836') + for _ in range(40): + if rpc('status')['state']=='connected':break + time.sleep(.25) + else:raise RuntimeError('Synthetic WireGuard handshake missing') + def http(host): + if ':' in host:host='['+host+']' + return int(ns(B,'python3','-c',"import urllib.request,urllib.error;\ntry: print(urllib.request.urlopen('http://"+host+":18108/',timeout=5).status)\nexcept urllib.error.HTTPError as e: print(e.code)")) + assert http('10.241.88.1')==200;assert http('10.242.88.1')==403 + s=rpc('mode',mode='both');assert http('10.242.88.1')==200;assert http('fd42:88::1')==200 + try:rpc('confirm',trial_id=s['pending']['id'],ingress='tunnel',proxy_token='fake') + except ValueError:pass + else:raise AssertionError('Forged ingress accepted') + token=(Path(t)/'proxy-token').read_text().strip();rpc('confirm',trial_id=s['pending']['id'],ingress='tunnel',proxy_token=token) + assert ns(B,'python3','-c',"import socket;s=socket.create_connection(('10.242.88.1',18120),5);print(s.recv(1024).decode())")=='synthetic-stream' + s=rpc('mode',mode='tunnel');assert http('10.241.88.1')==403;assert http('10.242.88.1')==200 + rpc('cancel');assert http('10.241.88.1')==200 + rpc('disconnect');assert rpc('status')['enabled'] is False + assert run('ip','-4','route','show')==before + print('PASS native dual-stack handshake/access, LAN/tunnel/both, forged confirmation rejection, streamed API bytes, cancellation, cleanup; host routes unchanged') + finally: + if process:process.terminate();process.wait(timeout=15) + for n in (A,B):subprocess.run(['ip','netns','del',n],capture_output=True) +if __name__=='__main__':main() diff --git a/server.py b/server.py index 8e5e47d..429e807 100644 --- a/server.py +++ b/server.py @@ -173,6 +173,9 @@ class Handler(BaseHTTPRequestHandler): def ingress(self): token = os.environ.get('DECK_PROXY_TOKEN', '') + if os.environ.get('DECK_PROXY_TOKEN_FILE'): + try:token=Path(os.environ['DECK_PROXY_TOKEN_FILE']).read_text().strip() + except OSError:pass if token and secrets.compare_digest(self.headers.get('X-Deck-Proxy', ''), token): return self.headers.get('X-Deck-Ingress', 'management') return 'management' @@ -288,6 +291,8 @@ class Handler(BaseHTTPRequestHandler): def valid_host(self): if os.environ.get('DECK_PROXY_TOKEN'): return self.ingress() in ('lan','tunnel') + if os.environ.get('DECK_PROXY_TOKEN_FILE') and self.ingress() in ('lan','tunnel'): + return True 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 @@ -715,7 +720,7 @@ class Handler(BaseHTTPRequestHandler): if set(data) != fields: raise ValueError('Ungültige Netzwerkparameter.') if action == 'import': - parse_config(data.get('config')) + parse_config(data.get('config'),native=self.server.network.native) result = self.server.network.call(action, data, self.ingress()) self.server.network.cached = None return self.respond(result) diff --git a/test_network.py b/test_network.py index e852d9c..719647c 100644 --- a/test_network.py +++ b/test_network.py @@ -1,3 +1,4 @@ +from pathlib import Path import base64 import hashlib import json @@ -116,24 +117,19 @@ class APITests(unittest.TestCase): if __name__=='__main__':unittest.main() class TransportTests(unittest.TestCase): - def test_status_with_nullable_error_is_success(self): - import subprocess + def test_native_status_with_nullable_error_is_success(self): from network.client import NetworkClient - with patch.dict(os.environ, {'DECK_NETWORK_SOCKET':'0'}): - client=NetworkClient() - result={'installed':True,'error':None,'state':'disabled'} - with patch('network.client.subprocess.run',return_value=subprocess.CompletedProcess([],0,json.dumps(result),'')): - self.assertTrue(client.call('status')['installed']) - def test_installer_bundle_is_complete(self): - from network.install_remote import FILES - from network.client import ROOT - for name in FILES: - self.assertTrue((ROOT/name).is_file(),name) - self.assertNotIn('test_network.py',FILES) - def test_install_password_required_before_thread_or_ssh(self): + with tempfile.TemporaryDirectory() as t: + token=Path(t)/'proxy-token';token.write_text('synthetic-proxy-token') + with patch.dict(os.environ, {'DECK_NETWORK_MODE':'native','DECK_PROXY_TOKEN_FILE':str(token)}), patch('network.client.request',return_value={'installed':True,'error':None,'state':'disabled'}) as transport: + client=NetworkClient() + self.assertTrue(client.call('status')['installed']) + self.assertEqual(transport.call_args.kwargs['path'],'/run/athena-deck-network/control.sock') + def test_native_installer_bundle_is_complete(self): + from deploy.install import FILES,ROOT + for name in ('network/native.py','deploy/setup_network_helper.py','network/config.py','network/client.py'): + self.assertIn(name,FILES);self.assertTrue((ROOT/name).is_file()) + def test_no_legacy_container_install(self): from network.client import NetworkClient - with patch.dict(os.environ, {'DECK_NETWORK_SOCKET':'0'}): - client=NetworkClient() - with patch('network.client.subprocess.run') as remote: - with self.assertRaises(ValueError):client.install('short') - remote.assert_not_called() + client=NetworkClient() + with self.assertRaises(ValueError):client.install('short')