Replace WireGuard prototype with native host service and scoped access
This commit is contained in:
1 parent
37b5394df1
commit
11f16ef4a3
16 files changed
+660
-129
No files matched your search
+25
-7
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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});
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
+18
-89
@@ -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
|
|
||||||
self.cached_at = 0
|
|
||||||
|
|
||||||
def call(self,action,values=None,ingress='management'):
|
def call(self,action,values=None,ingress='management'):
|
||||||
if self.disabled:
|
if self.disabled:raise ValueError('Nativen WireGuard-Systemdienst zuerst auf dem Debian-Host einrichten.')
|
||||||
raise ValueError('WireGuard ist in dieser eigenständigen Installation nicht angebunden.')
|
token=Path(os.environ.get('DECK_PROXY_TOKEN_FILE','/run/athena-deck-network/proxy-token')).read_text().strip()
|
||||||
data = dict(values or {}, action=action)
|
result=request(dict(values or {},action=action,ingress=ingress,proxy_token=token),path='/run/athena-deck-network/control.sock')
|
||||||
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
|
self.cached=None
|
||||||
return value
|
return result
|
||||||
|
|
||||||
def status(self,ingress='management'):
|
def status(self,ingress='management'):
|
||||||
if self.disabled:
|
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.')
|
||||||
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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
+11
-15
@@ -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:
|
||||||
|
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()
|
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'])
|
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 patch('network.client.subprocess.run') as remote:
|
|
||||||
with self.assertRaises(ValueError):client.install('short')
|
with self.assertRaises(ValueError):client.install('short')
|
||||||
remote.assert_not_called()
|
|
||||||
Reference in new issue
Block a user