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.
Verwende einen eigenen Peer für Deck. Schlüssel werden nicht angezeigt. Ein Peer und IPv4 werden unterstützt; Shell-Hooks werden abgelehnt.
Aktivieren richtet den Tunnel ein. „Verbunden“ erscheint erst nach einem aktuellen Handshake.
Zugriff auf Deck und API
Öffne die angezeigte Zieladresse und bestätige dort innerhalb von 120 Sekunden. Ohne Bestätigung wird der vorige Modus wiederhergestellt.
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.
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.
Ein Peer mit IPv4 und optional IPv6 wird unterstützt. Schlüssel werden nicht angezeigt. Bei einer Migration darf derselbe Peer nicht gleichzeitig im alten Gateway aktiv sein. DNS bleibt unverändert; Shell-Hooks werden abgelehnt.
Aktivieren richtet den Tunnel ein. „Verbunden“ erscheint erst nach einem aktuellen Handshake.
Zugriff auf Deck und API
Öffne die angezeigte Zieladresse und bestätige dort innerhalb von 120 Sekunden. Ohne Bestätigung wird der vorige Modus wiederhergestellt.
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')