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

+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
## Was eingebaut ist
+21 -92
View File
@@ -1,101 +1,30 @@
"""Local management via fixed SSH target or container-private Unix RPC."""
from auth import initial_record
import base64
import hashlib
"""Authenticated Deck administration through a native host Unix socket only."""
import json
import os
from pathlib import Path
import secrets
import subprocess
import threading
import time
ROOT = Path(__file__).resolve().parent.parent
SSH = ['ssh', '-i', str(Path.home()/'.ssh/athena_key'), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes', 'root@192.168.1.212']
from network.rpc import request
class NetworkClient:
def __init__(self):
self.disabled = os.environ.get('DECK_NETWORK_MODE') == 'disabled'
self.local = os.environ.get('DECK_NETWORK_SOCKET') == '1'
self.job = None
self.lock = threading.Lock()
self.cached = None
self.cached_at = 0
def call(self, action, values=None, ingress='management'):
if self.disabled:
raise ValueError('WireGuard ist in dieser eigenständigen Installation nicht angebunden.')
data = dict(values or {}, action=action)
if self.local:
from network.rpc import request
data.update(ingress=ingress, proxy_token=os.environ.get('DECK_PROXY_TOKEN', ''))
return request(data)
result = subprocess.run(SSH + ['docker exec -i athena-deck-network python3 -m network.rpc'], input=json.dumps(data), text=True, capture_output=True, timeout=35)
try:
value = json.loads(result.stdout)
except ValueError:
raise ValueError('Deck-Netzwerkmodul auf Athena nicht erreichbar oder noch nicht installiert.') from None
if result.returncode or ('error' in value and 'installed' not in value):
raise ValueError(value.get('error', 'Netzwerkaktion fehlgeschlagen.'))
self.cached = None
return value
def status(self, ingress='management'):
if self.disabled:
return dict(installed=False, install_supported=False, state='disabled-module', ingress=ingress, error='Eigenständige Installation ohne WireGuard-Modul. Zugriff ist per SSH-Tunnel möglich. Vorhandene Gateways werden nicht verändert.')
self.native=os.environ.get('DECK_NETWORK_MODE')=='native'
self.disabled=not self.native
self.local=False
self.job=None;self.lock=threading.Lock();self.cached=None;self.cached_at=0
def call(self,action,values=None,ingress='management'):
if self.disabled:raise ValueError('Nativen WireGuard-Systemdienst zuerst auf dem Debian-Host einrichten.')
token=Path(os.environ.get('DECK_PROXY_TOKEN_FILE','/run/athena-deck-network/proxy-token')).read_text().strip()
result=request(dict(values or {},action=action,ingress=ingress,proxy_token=token),path='/run/athena-deck-network/control.sock')
self.cached=None
return result
def status(self,ingress='management'):
if self.disabled:return dict(installed=False,install_supported=False,state='disabled-module',ingress=ingress,backend='native-systemd',error='Nativer WireGuard-Systemdienst ist noch nicht angebunden. Einrichtung siehe Installationsanleitung.')
with self.lock:
if self.job and self.job['state'] == 'running':
return dict(installed=False, state='installing', job=self.job.copy(), ingress=ingress)
if self.cached is not None and time.monotonic()-self.cached_at < 3:
return dict(self.cached, ingress=ingress, job=self.job)
try:
result = self.call('status', ingress=ingress)
result['reachable'] = True
except (ValueError, OSError, subprocess.SubprocessError):
result = dict(installed=False, reachable=False, state='unavailable', error='Netzwerkmodul noch nicht installiert oder Athena nicht erreichbar.')
self.cached = result
self.cached_at = time.monotonic()
return dict(result, ingress=ingress, job=self.job)
def install(self, password, api_token=None):
if self.disabled:
raise ValueError('WireGuard-Installation ist für diese eigenständige Instanz deaktiviert.')
if self.local:
raise ValueError('Die Server-Instanz ist bereits installiert.')
if not isinstance(password, str) or not 16 <= len(password) <= 256:
raise ValueError('Bitte ein eigenes Deck-Passwort mit mindestens 16 Zeichen setzen.')
auth = initial_record(password,api_token)
with self.lock:
if self.job and self.job['state'] == 'running':
raise ValueError('Installation läuft bereits.')
self.job = dict(state='running', message='Eigener Deck-Container wird gebaut und gestartet. Das kann mehrere Minuten dauern.')
threading.Thread(target=self._install, args=(auth,), daemon=True).start()
return dict(job=self.job.copy())
def _install(self, auth):
try:
# Import the source allowlist without executing the installer entrypoint.
from network.install_remote import FILES
payload = dict(auth=auth, files={name:base64.b64encode((ROOT/name).read_bytes()).decode() for name in FILES})
script = (ROOT/'network/install_remote.py').read_text()
# Remote command contains only fixed trusted program text, never user input.
import shlex
command = 'python3 -c ' + shlex.quote(script)
result = subprocess.run(SSH+[command], input=json.dumps(payload), text=True, capture_output=True, timeout=900)
value = json.loads(result.stdout)
if result.returncode or ('error' in value and 'installed' not in value):
raise ValueError(value.get('error', 'Installation fehlgeschlagen.'))
# Confirm helper readiness; don't report a successful installation from docker run alone.
for _ in range(20):
try:
self.call('status')
break
except (ValueError, OSError, subprocess.SubprocessError):
time.sleep(1)
else:
raise ValueError('Container angelegt, aber Netzwerk-Helper nicht bereit. Installation prüfen.')
self.job = dict(state='complete', message='Netzwerkmodul installiert. Jetzt eine eigene WireGuard-Konfiguration importieren.')
except Exception as exc:
self.job = dict(state='failed', message=str(exc) if isinstance(exc, ValueError) and not isinstance(exc, json.JSONDecodeError) else 'Installation fehlgeschlagen; SSH und Docker prüfen.')
finally:
self.cached = None
if self.cached is not None and time.monotonic()-self.cached_at<3:return dict(self.cached,ingress=ingress)
try:result=self.call('status',ingress=ingress);result['reachable']=True
except (ValueError,OSError,json.JSONDecodeError):result=dict(installed=False,install_supported=False,reachable=False,state='unavailable',error='Nativer WireGuard-Systemdienst nicht erreichbar.')
self.cached=result;self.cached_at=time.monotonic()
return dict(result,ingress=ingress)
def install(self,password=None,api_token=None):
raise ValueError('WireGuard wird nativ mit deploy/setup_network_helper.py eingerichtet. Kein Netzwerk-Container und keine zweite Deck-Instanz.')
+8 -5
View File
@@ -7,7 +7,7 @@ class ConfigError(ValueError):
pass
def parse_config(text):
def parse_config(text, native=False):
if not isinstance(text, str) or len(text.encode('utf-8')) > 16384:
raise ConfigError('Die Konfiguration darf höchstens 16 KiB groß sein.')
sections = {}
@@ -25,6 +25,9 @@ def parse_config(text):
raise ConfigError('Ungültiges WireGuard-Dateiformat.')
name, value = (part.strip() for part in line.split('=', 1))
allowed = {'PrivateKey', 'Address', 'DNS', 'MTU', 'ListenPort'} if current is sections.get('[Interface]') else {'PublicKey', 'PresharedKey', 'AllowedIPs', 'Endpoint', 'PersistentKeepalive'}
if native and name == 'DNS' and name in current:
current[name] += ', ' + value
continue
if name not in allowed or name in current:
raise ConfigError('Unbekannte oder doppelte Direktive. Hooks, Table und SaveConfig sind nicht erlaubt.')
if not value or any(ord(c) < 32 for c in value):
@@ -44,9 +47,9 @@ def parse_config(text):
try:
addresses = [ipaddress.ip_interface(v.strip()) for v in interface['Address'].split(',')]
networks = [ipaddress.ip_network(v.strip(), strict=False) for v in peer['AllowedIPs'].split(',')]
if len(addresses) != 1 or addresses[0].version != 4 or any(n.version != 4 for n in networks):
if (not native and (len(addresses) != 1 or addresses[0].version != 4 or any(n.version != 4 for n in networks))) or (native and (not 1 <= len(addresses) <= 2 or len({a.version for a in addresses}) != len(addresses) or addresses[0].version != 4)):
raise ConfigError('Diese Version unterstützt eine IPv4-Tunneladresse und IPv4-AllowedIPs.')
if addresses[0].ip.is_loopback or addresses[0].ip.is_unspecified or addresses[0].ip.is_multicast:
if any(a.ip.is_loopback or a.ip.is_unspecified or a.ip.is_multicast for a in addresses):
raise ValueError()
if len(networks) > 32:
raise ValueError()
@@ -64,8 +67,8 @@ def parse_config(text):
if isinstance(exc, ConfigError):
raise
raise ConfigError('Ungültige Adresse, Endpoint, Port, MTU oder Keepalive.') from None
warnings = ['DNS wird nicht übernommen; die Container-DNS-Auflösung bleibt bestehen.'] if 'DNS' in interface else []
return dict(interface=interface, peer=peer, address=str(addresses[0].ip), allowed_ips=[str(n) for n in networks], mtu=mtu, keepalive=keepalive, listen=listen, warnings=warnings)
warnings = ['DNS wird nicht übernommen; die bestehende System-DNS-Auflösung bleibt erhalten.'] if 'DNS' in interface else []
return dict(addresses=[str(a) for a in addresses], interface=interface, peer=peer, address=str(addresses[0].ip), allowed_ips=[str(n) for n in networks], mtu=mtu, keepalive=keepalive, listen=listen, warnings=warnings)
def wireguard_text(config):
+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'
def request(data):
def request(data, path=SOCKET):
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(25)
sock.connect(SOCKET)
sock.connect(path)
sock.sendall(json.dumps(data).encode()+b'\n')
with sock.makefile('rb') as stream:
raw = stream.readline(65537)
+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()