Replace WireGuard prototype with native host service and scoped access

This commit is contained in:
Mikei386 committed 2026-10-01 23:00:43 +02:00
1 parent 37b5394df1
commit 11f16ef4a3
16 files changed
+667 -136

No files matched your search

+25 -7
View File
@@ -1,6 +1,6 @@
# Alter Athena Router und Athena Deck # 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 keine neue vollständige Live-Abnahme sämtlicher Funktionen. Installierte alte
Container bedeuten keine Anbindung an Deck. Kategorien und Docker-Listen allein Container bedeuten keine Anbindung an Deck. Kategorien und Docker-Listen allein
sind kein ausführbarer Worker. sind kein ausführbarer Worker.
@@ -16,8 +16,8 @@ sind kein ausführbarer Worker.
| Dashboard, History, Themes | vorhanden | kein neuer vollständiger Migrationsaudit | | Dashboard, History, Themes | vorhanden | kein neuer vollständiger Migrationsaudit |
| Modellkatalog, Komponenten, Downloads, automatische Speicheranpassung | vorhanden | Kompatibilitätsrezepte nicht universell | | 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 | | 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 | | YuE2 Musikgenerierung | vorhanden: audio.cpp, Musikmodus, Ladypoly | weitere Modelle/Funktionen separat anbinden |
| BS-RoFormer Stem-Separation | fehlt | Laufzeit und Job-/Dateiverarbeitung | | 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 | | OmniVoice Voice Studio | fehlt | eigene Worker-/UI-Anbindung |
| X-VC Voice Changer | fehlt | eigene Worker-/UI-Anbindung | | X-VC Voice Changer | fehlt | eigene Worker-/UI-Anbindung |
| Applio/RVC Konvertierung und Training | 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 | | Realtime Voice / WebRTC / OpenClaw Talk | fehlt in Deck | Brücke, Echtzeitaudio und Clientintegration |
| Responses API | fehlt in Deck | /v1/responses-Kompatibilität | | Responses API | fehlt in Deck | /v1/responses-Kompatibilität |
| PCM-Streaming für TTS | fehlt in Deck | Streaming-Audiopfad | | 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 | | 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 | | 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 | | 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 GPU-Spezialworker; Musik/Voice/3D je nach Bedarf; native Installation und vollständiger
Restore-Test. LTX-spezifische optionalen Funktionen separat gegen DeskWEB prüfen. Restore-Test. LTX-spezifische optionalen Funktionen separat gegen DeskWEB prüfen.
Die zuvor angefragte Löschung der unbenutzten alten Video-Laufzeit und ihrer ## Stilllegung und native Netzwerk-Anbindung am 01.10.2026
22 Backup-Kopien ist noch nicht ausgeführt: die konkrete Bestätigung steht aus.
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.
+2
View File
@@ -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. Token- und Sekundenfelder werden synchronisiert. Das Modell kann vor dem Limit enden.
Der Ergebnisplayer bleibt beim Status-Polling bestehen, damit die Wiedergabe nicht Der Ergebnisplayer bleibt beim Status-Polling bestehen, damit die Wiedergabe nicht
alle 1,5 Sekunden zurückgesetzt wird. 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.
+1 -1
View File
@@ -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 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: if network['config'] is not None:
from network.config import parse_config from network.config import parse_config
parse_config(network['config']) parse_config(network['config'],native=True)
services=doc.get('services') services=doc.get('services')
if not isinstance(services,list) or len(services)>50:raise ValueError('Ungültiger Containerbestand.') 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. # Validation is shared with the root helper, which independently revalidates before Docker calls.
+13 -4
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' 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): 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)"' 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. # Explicit isolated development bootstrap, reachable only through host loopback/SSH.
args += ['-e','DECK_REQUIRE_SETUP=0'] 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'): 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'] 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']: if config['gpu_telemetry']:
@@ -233,10 +235,12 @@ def parse_api_ports(value):
def main(): 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) 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') 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('--directory',type=Path,default=Path('/opt/athena-deck-standalone'))
parser.add_argument('--name',default='athena-deck-standalone') parser.add_argument('--name',default='athena-deck-standalone')
parser.add_argument('--port',type=int,default=8110) parser.add_argument('--port',type=int,default=8110)
@@ -257,7 +261,12 @@ def main():
else: else:
config=load_manifest(args.directory) config=load_manifest(args.directory)
item=owned(config['name'],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')) 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) config['docker_helper']=True;write_manifest(args.directory,config);update(args.directory,force=True)
elif args.status: elif args.status:
+84
View File
@@ -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()
+1 -5
View File
@@ -29,11 +29,7 @@ function networkButtons(){
async function networkPage(){ async function networkPage(){
const view=document.querySelector('#view'); const view=document.querySelector('#view');
if(!document.querySelector('#network-page')){ if(!document.querySelector('#network-page')){
view.innerHTML=`<div id="network-page"><div class="kicker">EINSTELLUNGEN / NETZWERK</div><h1>Dein Weg zu Athena.</h1><p>Ein eigener WireGuard-Zugang für Athena Deck und seine internen API-Endpunkte.</p><section class="card network-card"><div class="card-top"><h2>WireGuard-Modul</h2><span id="network-state" class="pill">Wird geprüft …</span></div><div id="network-details" class="network-status"></div><p id="network-notice"></p><div id="network-setup"><h2>Auf Athena einrichten</h2><p>Installiert WireGuard und Deck in einem eigenen Container. Der vorhandene Gateway bleibt unverändert. Die erste Einrichtung startet im LAN-Modus auf Port 8110.</p><label for="network-password">Neues Deck-Passwort · mindestens 16 Zeichen</label><input id="network-password" type="password" autocomplete="new-password" minlength="16" maxlength="256"><label for="network-api-token">Eigener API-Token für die Server-Instanz</label><input id="network-api-token" type="password" autocomplete="off" minlength="32" maxlength="256"><button id="network-generate-token" type="button" class="secondary">API-Token erzeugen</button><button id="network-show-token" type="button" class="secondary">API-Token anzeigen</button><button id="network-copy-token" type="button" class="secondary">API-Token kopieren</button><p>Vor der Installation kopieren und sicher speichern. Dieser Token gehört zur jeweiligen Server-Instanz.</p><button id="network-install">Modul auf Athena installieren</button><p class="note">Benötigt den vorhandenen SSH-Zugang und Docker auf Athena. LAN-Zugriff verwendet HTTP; für vertrauliche Eingaben WireGuard oder SSH-Tunnel verwenden.</p></div><div id="network-config" hidden><label for="network-file">Eigene WireGuard-Konfiguration (.conf)</label><input id="network-file" type="file" accept=".conf,text/plain"><p>Verwende einen eigenen Peer für Deck. Schlüssel werden nicht angezeigt. Ein Peer und IPv4 werden unterstützt; Shell-Hooks werden abgelehnt.</p><button id="network-import">Konfiguration importieren</button><button id="network-delete" class="secondary">Konfiguration löschen</button><div class="note"><button id="network-connect">WireGuard aktivieren</button><button id="network-disconnect" class="secondary">WireGuard deaktivieren</button><p>Aktivieren richtet den Tunnel ein. „Verbunden“ erscheint erst nach einem aktuellen Handshake.</p></div></div></section><section class="card network-card" id="network-access"><h2>Zugriff auf Deck und API</h2><label for="network-mode">Erreichbarkeit</label><select id="network-mode"><option value="lan">Nur lokales Netzwerk</option><option value="tunnel">Nur WireGuard-Tunnel</option><option value="both">Tunnel und lokales Netzwerk</option></select><button id="network-apply">Zugriffsmodus testen</button><p id="network-current"></p><div id="network-trial" hidden><p id="network-countdown" class="warning"></p><button id="network-confirm">Zugriff hier bestätigen</button><button id="network-cancel" class="secondary">Änderung zurücknehmen</button><p>Öffne die angezeigte Zieladresse und bestätige dort innerhalb von 120 Sekunden. Ohne Bestätigung wird der vorige Modus wiederhergestellt.</p></div><p class="note">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.</p></section><p id="network-message" class="network-message" role="status" aria-live="polite"></p></div>`; view.innerHTML=`<div id="network-page"><div class="kicker">EINSTELLUNGEN / NETZWERK</div><h1>Dein Weg zu Athena.</h1><p>Ein nativer WireGuard-Tunnel auf dem Debian-Server. Die Zugriffsmodi gelten für die freigegebenen Deck-Ports.</p><section class="card network-card"><div class="card-top"><h2>WireGuard-Modul</h2><span id="network-state" class="pill">Wird geprüft …</span></div><div id="network-details" class="network-status"></div><p id="network-notice"></p><div id="network-setup"><h2>Nativen Systemdienst einrichten</h2><p>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.</p></div><div id="network-config" hidden><label for="network-file">Eigene WireGuard-Konfiguration (.conf)</label><input id="network-file" type="file" accept=".conf,text/plain"><p>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.</p><button id="network-import">Konfiguration importieren</button><button id="network-delete" class="secondary">Konfiguration löschen</button><div class="note"><button id="network-connect">WireGuard aktivieren</button><button id="network-disconnect" class="secondary">WireGuard deaktivieren</button><p>Aktivieren richtet den Tunnel ein. „Verbunden“ erscheint erst nach einem aktuellen Handshake.</p></div></div></section><section class="card network-card" id="network-access"><h2>Zugriff auf Deck und API</h2><label for="network-mode">Erreichbarkeit</label><select id="network-mode"><option value="lan">Nur lokales Netzwerk</option><option value="tunnel">Nur WireGuard-Tunnel</option><option value="both">Tunnel und lokales Netzwerk</option></select><button id="network-apply">Zugriffsmodus testen</button><p id="network-current"></p><div id="network-trial" hidden><p id="network-countdown" class="warning"></p><button id="network-confirm">Zugriff hier bestätigen</button><button id="network-cancel" class="secondary">Änderung zurücknehmen</button><p>Öffne die angezeigte Zieladresse und bestätige dort innerhalb von 120 Sekunden. Ohne Bestätigung wird der vorige Modus wiederhergestellt.</p></div><p class="note">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.</p></section><p id="network-message" class="network-message" role="status" aria-live="polite"></p></div>`;
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.'}};
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});}; 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); 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}); document.querySelector('#network-apply').onclick=()=>networkAction('mode',{mode:document.querySelector('#network-mode').value});
+99
View File
@@ -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.
+6
View File
@@ -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 # WireGuard-Modul · Athena Deck 0.3
## Was eingebaut ist ## Was eingebaut ist
+21 -92
View File
@@ -1,101 +1,30 @@
"""Local management via fixed SSH target or container-private Unix RPC.""" """Authenticated Deck administration through a native host Unix socket only."""
from auth import initial_record
import base64
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
import secrets
import subprocess
import threading import threading
import time import time
from network.rpc import request
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']
class NetworkClient: class NetworkClient:
def __init__(self): def __init__(self):
self.disabled = os.environ.get('DECK_NETWORK_MODE') == 'disabled' self.native=os.environ.get('DECK_NETWORK_MODE')=='native'
self.local = os.environ.get('DECK_NETWORK_SOCKET') == '1' self.disabled=not self.native
self.job = None self.local=False
self.lock = threading.Lock() self.job=None;self.lock=threading.Lock();self.cached=None;self.cached_at=0
self.cached = None def call(self,action,values=None,ingress='management'):
self.cached_at = 0 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()
def call(self, action, values=None, ingress='management'): result=request(dict(values or {},action=action,ingress=ingress,proxy_token=token),path='/run/athena-deck-network/control.sock')
if self.disabled: self.cached=None
raise ValueError('WireGuard ist in dieser eigenständigen Installation nicht angebunden.') return result
data = dict(values or {}, action=action) def status(self,ingress='management'):
if self.local: 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.')
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.')
with self.lock: with self.lock:
if self.job and self.job['state'] == 'running': if self.cached is not None and time.monotonic()-self.cached_at<3:return dict(self.cached,ingress=ingress)
return dict(installed=False, state='installing', job=self.job.copy(), ingress=ingress) try:result=self.call('status',ingress=ingress);result['reachable']=True
if self.cached is not None and time.monotonic()-self.cached_at < 3: except (ValueError,OSError,json.JSONDecodeError):result=dict(installed=False,install_supported=False,reachable=False,state='unavailable',error='Nativer WireGuard-Systemdienst nicht erreichbar.')
return dict(self.cached, ingress=ingress, job=self.job) self.cached=result;self.cached_at=time.monotonic()
try: return dict(result,ingress=ingress)
result = self.call('status', ingress=ingress) def install(self,password=None,api_token=None):
result['reachable'] = True raise ValueError('WireGuard wird nativ mit deploy/setup_network_helper.py eingerichtet. Kein Netzwerk-Container und keine zweite Deck-Instanz.')
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
+8 -5
View File
@@ -7,7 +7,7 @@ class ConfigError(ValueError):
pass pass
def parse_config(text): def parse_config(text, native=False):
if not isinstance(text, str) or len(text.encode('utf-8')) > 16384: if not isinstance(text, str) or len(text.encode('utf-8')) > 16384:
raise ConfigError('Die Konfiguration darf höchstens 16 KiB groß sein.') raise ConfigError('Die Konfiguration darf höchstens 16 KiB groß sein.')
sections = {} sections = {}
@@ -25,6 +25,9 @@ def parse_config(text):
raise ConfigError('Ungültiges WireGuard-Dateiformat.') raise ConfigError('Ungültiges WireGuard-Dateiformat.')
name, value = (part.strip() for part in line.split('=', 1)) 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'} 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: if name not in allowed or name in current:
raise ConfigError('Unbekannte oder doppelte Direktive. Hooks, Table und SaveConfig sind nicht erlaubt.') 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): if not value or any(ord(c) < 32 for c in value):
@@ -44,9 +47,9 @@ def parse_config(text):
try: try:
addresses = [ipaddress.ip_interface(v.strip()) for v in interface['Address'].split(',')] 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(',')] 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.') 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() raise ValueError()
if len(networks) > 32: if len(networks) > 32:
raise ValueError() raise ValueError()
@@ -64,8 +67,8 @@ def parse_config(text):
if isinstance(exc, ConfigError): if isinstance(exc, ConfigError):
raise raise
raise ConfigError('Ungültige Adresse, Endpoint, Port, MTU oder Keepalive.') from None 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 [] warnings = ['DNS wird nicht übernommen; die bestehende System-DNS-Auflösung bleibt erhalten.'] 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) 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): def wireguard_text(config):
+235
View File
@@ -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()
+2 -2
View File
@@ -5,10 +5,10 @@ import sys
SOCKET = '/run/deck/control.sock' SOCKET = '/run/deck/control.sock'
def request(data): def request(data, path=SOCKET):
with socket.socket(socket.AF_UNIX) as sock: with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(25) sock.settimeout(25)
sock.connect(SOCKET) sock.connect(path)
sock.sendall(json.dumps(data).encode()+b'\n') sock.sendall(json.dumps(data).encode()+b'\n')
with sock.makefile('rb') as stream: with sock.makefile('rb') as stream:
raw = stream.readline(65537) raw = stream.readline(65537)
+59
View File
@@ -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()
+90
View File
@@ -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()
+6 -1
View File
@@ -173,6 +173,9 @@ class Handler(BaseHTTPRequestHandler):
def ingress(self): def ingress(self):
token = os.environ.get('DECK_PROXY_TOKEN', '') 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): if token and secrets.compare_digest(self.headers.get('X-Deck-Proxy', ''), token):
return self.headers.get('X-Deck-Ingress', 'management') return self.headers.get('X-Deck-Ingress', 'management')
return 'management' return 'management'
@@ -288,6 +291,8 @@ class Handler(BaseHTTPRequestHandler):
def valid_host(self): def valid_host(self):
if os.environ.get('DECK_PROXY_TOKEN'): if os.environ.get('DECK_PROXY_TOKEN'):
return self.ingress() in ('lan','tunnel') return self.ingress() in ('lan','tunnel')
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 = {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(','))) allowed.update(filter(None,os.environ.get('DECK_ALLOWED_HOSTS','').split(',')))
return self.headers.get('Host') in allowed return self.headers.get('Host') in allowed
@@ -715,7 +720,7 @@ class Handler(BaseHTTPRequestHandler):
if set(data) != fields: if set(data) != fields:
raise ValueError('Ungültige Netzwerkparameter.') raise ValueError('Ungültige Netzwerkparameter.')
if action == 'import': 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()) result = self.server.network.call(action, data, self.ingress())
self.server.network.cached = None self.server.network.cached = None
return self.respond(result) return self.respond(result)
+15 -19
View File
@@ -1,3 +1,4 @@
from pathlib import Path
import base64 import base64
import hashlib import hashlib
import json import json
@@ -116,24 +117,19 @@ class APITests(unittest.TestCase):
if __name__=='__main__':unittest.main() if __name__=='__main__':unittest.main()
class TransportTests(unittest.TestCase): class TransportTests(unittest.TestCase):
def test_status_with_nullable_error_is_success(self): def test_native_status_with_nullable_error_is_success(self):
import subprocess
from network.client import NetworkClient from network.client import NetworkClient
with patch.dict(os.environ, {'DECK_NETWORK_SOCKET':'0'}): with tempfile.TemporaryDirectory() as t:
client=NetworkClient() token=Path(t)/'proxy-token';token.write_text('synthetic-proxy-token')
result={'installed':True,'error':None,'state':'disabled'} 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:
with patch('network.client.subprocess.run',return_value=subprocess.CompletedProcess([],0,json.dumps(result),'')): client=NetworkClient()
self.assertTrue(client.call('status')['installed']) self.assertTrue(client.call('status')['installed'])
def test_installer_bundle_is_complete(self): self.assertEqual(transport.call_args.kwargs['path'],'/run/athena-deck-network/control.sock')
from network.install_remote import FILES def test_native_installer_bundle_is_complete(self):
from network.client import ROOT from deploy.install import FILES,ROOT
for name in FILES: for name in ('network/native.py','deploy/setup_network_helper.py','network/config.py','network/client.py'):
self.assertTrue((ROOT/name).is_file(),name) self.assertIn(name,FILES);self.assertTrue((ROOT/name).is_file())
self.assertNotIn('test_network.py',FILES) def test_no_legacy_container_install(self):
def test_install_password_required_before_thread_or_ssh(self):
from network.client import NetworkClient from network.client import NetworkClient
with patch.dict(os.environ, {'DECK_NETWORK_SOCKET':'0'}): client=NetworkClient()
client=NetworkClient() with self.assertRaises(ValueError):client.install('short')
with patch('network.client.subprocess.run') as remote:
with self.assertRaises(ValueError):client.install('short')
remote.assert_not_called()