Add isolated WireGuard module with guarded access modes and server setup
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
FROM python:3.13-slim-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wireguard-tools iproute2 && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 DECK_CONTAINER=1
|
||||
CMD ["python3", "-m", "network.agent"]
|
||||
@@ -0,0 +1,166 @@
|
||||
# WireGuard-Modul · Athena Deck 0.2
|
||||
|
||||
## Was eingebaut ist
|
||||
|
||||
Einstellungen → Netzwerk bietet Installation, Conf-Import, Aktivieren/Deaktivieren,
|
||||
Handshake-Status und die Modi **LAN**, **Tunnel**, **beides**. Diese Modi gelten
|
||||
für die Server-Instanz von Athena Deck und alle ihre `/api/v1/*`-Endpunkte.
|
||||
Produktive Router-, Modell-, Audio- und Bild-Endpunkte werden nicht übernommen.
|
||||
Die lokale Mac-Instanz bleibt ein separater SSH-Verwaltungskanal.
|
||||
|
||||
Die Implementierung benutzt einen eigenen Container `athena-deck-network` mit
|
||||
eigenem Netzwerk-Namespace. Im Container laufen ein eingeschränkter Root-Helper
|
||||
und die Webanwendung unter UID/GID 65534. Der Webprozess erhält keinen Docker-Socket
|
||||
und keinen Root-Zugriff. Der Helper erlaubt über seinen Unix-Socket nur fest
|
||||
implementierte Netzwerkaktionen. Er nimmt keine Shell-Befehle entgegen.
|
||||
|
||||
WireGuard-Pakete werden automatisch im Container-Image installiert. Docker und
|
||||
WireGuard-Kernelunterstützung müssen auf dem Host vorhanden sein; Athena hat beides.
|
||||
Kein `apt` auf dem Host, kein Kernel-Update, kein Reboot, kein Host-Netzwerkmodus,
|
||||
keine Änderungen am bestehenden Gateway oder dessen Konfiguration.
|
||||
Docker erstellt seine üblichen Regeln für den **neuen** Container und seine
|
||||
Port-Veröffentlichung; eine Aussage „keinerlei Netzwerkänderung“ wäre dafür falsch.
|
||||
|
||||
## Einrichtung in der Oberfläche
|
||||
|
||||
1. Auf dem Mac http://127.0.0.1:8108/#network öffnen.
|
||||
2. Eigenes Deck-Passwort (mindestens 16 Zeichen) vergeben und **Modul auf Athena
|
||||
installieren** wählen. Installation läuft asynchron und meldet ihren Status.
|
||||
3. Eigene IPv4-WireGuard-Conf importieren. **Einen eigenen Peer verwenden**, nicht
|
||||
die Konfiguration des produktiven Athena-Gateways wiederverwenden.
|
||||
4. WireGuard aktivieren. Erst ein Handshake innerhalb der letzten 180 Sekunden
|
||||
führt zum Status „Verbunden“. Ein geladenes Interface alleine genügt nicht.
|
||||
5. Gewünschten Zugriffsmodus testen, die Zieladresse öffnen, dort anmelden und
|
||||
innerhalb von 120 Sekunden bestätigen. Tunnel/beides muss über den Tunnel,
|
||||
LAN über den LAN-Zugang bestätigt werden. Der Mac-Verwaltungskanal kann diese
|
||||
Bestätigung nicht ersetzen.
|
||||
|
||||
Ohne Bestätigung wird der vorige Modus wiederhergestellt. Gespeichert wird nur der
|
||||
bestätigte Modus; ein Container-Neustart während der Testphase verwirft den Versuch.
|
||||
Bei Tunnelausfall im bestätigten Tunnel-Modus bleibt der LAN-Zugang geschlossen.
|
||||
Deaktivieren entfernt nur das Deck-WireGuard-Interface; der Zugriffsmodus bleibt.
|
||||
Die Zugriffspolitik steuert **eingehende Verbindungen**, nicht sämtlichen ausgehenden
|
||||
Verkehr des Servers. Die Host-/Container-Standardroute bleibt bestehen.
|
||||
|
||||
## Zugang, Installation und Wiederherstellung
|
||||
|
||||
Fester Zielhost für den aktuellen Prototyp: SSH `root@192.168.1.212`, vorhandener
|
||||
Schlüssel `~/.ssh/athena_key`, strikte Hostschlüsselprüfung. Keine Passwörter oder
|
||||
Schlüssel in Kommandozeilenargumenten, API-Antworten oder Logs.
|
||||
|
||||
Installation unter `/opt/athena-deck/source` und `/opt/athena-deck/state`.
|
||||
Der Installer prüft Port 8110 und bindet ihn an `127.0.0.1` und die ermittelte
|
||||
private LAN-Adresse; er veröffentlicht nicht auf `0.0.0.0`. Auf Athena war die
|
||||
LAN-Adresse beim Test `172.21.117.202`, während `192.168.1.212` zum vorhandenen
|
||||
VPN-Zugang gehört. Die tatsächliche ermittelte LAN-URL erscheint in der GUI.
|
||||
Eine DHCP-Adressänderung erfordert eine gezielte Neuerstellung der Portbindung.
|
||||
|
||||
Ein vorhandener Container oder vorhandene Zugangsdaten werden nicht überschrieben.
|
||||
Bei einem fehlgeschlagenen Erststart bleibt der geschützte Installationsstand zur
|
||||
gezielten Wiederherstellung liegen. Automatische Updates/Reparatur bestehender
|
||||
Installationen sind noch nicht implementiert.
|
||||
|
||||
Der neue Server verwendet aktuell HTTP. WireGuard schützt den Tunneltransport.
|
||||
Für Einrichtung und Secrets aus entfernten Netzen den lokalen Mac-Verwaltungskanal
|
||||
(SSH) verwenden. LAN-HTTP nur in einem vertrauenswürdigen Netz verwenden;
|
||||
HTTPS/Reverse-Proxy-Zertifikate sind noch nicht enthalten. Passwort wird mit
|
||||
PBKDF2-SHA256 (600.000 Iterationen, individuellem Salt) gespeichert. Sitzungen sind
|
||||
HttpOnly/SameSite=Strict, acht Stunden gültig und werden nach Neustart ungültig.
|
||||
Ein Anmeldelimit begrenzt Versuche auf zehn pro Minute. Kein Standardpasswort.
|
||||
|
||||
Im LAN-/Beides-Modus ist zusätzlich ein SSH-Tunnel zur Server-GUI möglich:
|
||||
|
||||
```sh
|
||||
ssh -i /Users/mike_i386/.ssh/athena_key -o BatchMode=yes \
|
||||
-N -L 8110:127.0.0.1:8110 root@192.168.1.212
|
||||
```
|
||||
|
||||
Dann http://127.0.0.1:8110 öffnen. Dieser Zugriff zählt als LAN, nicht als
|
||||
WireGuard-Bestätigung. Im Tunnel-only-Modus wird auch er gesperrt.
|
||||
Die Mac-Oberfläche auf Port 8108 kann weiterhin per SSH den Helper verwalten.
|
||||
Zur Wiederherstellung dort Modus LAN testen, SSH-Tunnel öffnen und dort bestätigen.
|
||||
|
||||
Nur den neuen Container stoppen/starten:
|
||||
|
||||
```sh
|
||||
ssh -i ~/.ssh/athena_key -o BatchMode=yes root@192.168.1.212 \
|
||||
docker stop athena-deck-network
|
||||
ssh -i ~/.ssh/athena_key -o BatchMode=yes root@192.168.1.212 \
|
||||
docker start athena-deck-network
|
||||
```
|
||||
|
||||
Beim regulären Stoppen wird auch der Demo-Kindprozess beendet. Der neue Container
|
||||
benötigt NET_ADMIN/NET_RAW in seinem eigenen Namespace sowie SETUID/SETGID/CHOWN
|
||||
zum Absenken der Webprozess-Rechte und Einrichten des privaten Sockets. Keine
|
||||
privileged-Option, kein SYS_ADMIN, keine Host-Geräte. NVIDIA ist ausschließlich
|
||||
mit Treiber-Capability `utility` für Hardware-Telemetrie eingebunden.
|
||||
|
||||
## Importgrenzen und Secrets
|
||||
|
||||
- Maximal 16 KiB, genau `[Interface]` und ein `[Peer]`.
|
||||
- Eine IPv4-Interface-Adresse, IPv4-AllowedIPs, Peer-Endpoint erforderlich.
|
||||
- PrivateKey/PublicKey und optional PresharedKey als gültige 32-Byte-Base64-Werte.
|
||||
- MTU, ListenPort und PersistentKeepalive werden geprüft; Keepalive standardmäßig 25.
|
||||
- DNS wird ausdrücklich nicht übernommen; GUI meldet dies.
|
||||
- PostUp/PostDown/PreUp/PreDown, SaveConfig, Table, doppelte und unbekannte
|
||||
Direktiven werden abgelehnt. Kein Ausführen von `wg-quick` oder importiertem Shelltext.
|
||||
- Konfiguration und Passwort-Hash unter `/data` mit 0600, Verzeichnis 0700.
|
||||
Secret für `wg setconf` liegt nur kurz im Container-tmpfs und wird danach entfernt.
|
||||
- Import/Entfernung nur im bestätigten LAN-Modus bei deaktiviertem Tunnel.
|
||||
- Keine Anzeige oder Export privater/öffentlicher Peer-Schlüssel durch die API.
|
||||
|
||||
IPv6, mehrere Peers, Konfiguration ohne Endpoint (passiver VPN-Server),
|
||||
DNS-Umschaltung und das Verwalten anderer produktiver Endpunkte sind noch nicht
|
||||
implementiert. Diese Einschränkungen werden beim Import ausdrücklich gemeldet.
|
||||
|
||||
## API
|
||||
|
||||
Alle POSTs: JSON, `Content-Type: application/json`, `X-Athena-Deck: 1`, passender
|
||||
Origin. Im Serverbetrieb zusätzlich gültige Sitzung. Host-Allowlist und
|
||||
serverseitige Prüfung des tatsächlichen Zugangs gelten vor dem API-Aufruf.
|
||||
|
||||
| Methode/Pfad unter `/api/v1` | JSON-Inhalt |
|
||||
|---|---|
|
||||
| POST `/login` | `password` |
|
||||
| GET `/network` | Status ohne Secrets |
|
||||
| POST `/network/install` | `password` für neue Server-Instanz, nur Mac-Verwaltung |
|
||||
| POST `/network/import` | `config` als Dateiinhalt |
|
||||
| POST `/network/connect` | `{}` |
|
||||
| POST `/network/disconnect` | `{}` |
|
||||
| POST `/network/delete` | `{}` |
|
||||
| POST `/network/mode` | `mode`: `lan`, `tunnel`, `both` |
|
||||
| POST `/network/confirm` | `trial_id` aus Status |
|
||||
| POST `/network/cancel` | `{}` |
|
||||
|
||||
`/network` liefert `installed`, `configured`, `enabled`, `connected`,
|
||||
`latest_handshake`, `state`, `mode`, `pending`, `ingress`, URLs und ggf. Fehler.
|
||||
Bei Nicht-Erreichbarkeit meldet die Mac-Ansicht ausdrücklich „noch nicht installiert
|
||||
oder nicht erreichbar“; sie behauptet nicht, den Unterschied sicher zu kennen.
|
||||
Anwendungsfehler geben HTTP 400, fehlende Sitzung 401, unerlaubter Host/Origin oder
|
||||
Zugangsweg 403. Installation läuft als Hintergrundauftrag mit `job.state`.
|
||||
|
||||
## Tests am 28.09.2026
|
||||
|
||||
Lokale Unit-/API-Tests: `python3 -m unittest discover -s . -v`.
|
||||
Parser, fehlender Handshake, falscher Bestätigungsweg, Timeout, Neustart-Policy,
|
||||
Login, gefälschter Ingress-Header und bestehende Demo-Prozesssteuerung sind abgedeckt.
|
||||
|
||||
Zusätzlich auf Athena zwei kurzlebige Testcontainer **ohne veröffentlichte Host-Ports**:
|
||||
frische Wegwerfschlüssel, echter WireGuard-Handshake, HTTP über beide Interfaces,
|
||||
LAN-/Tunnel-Sperren, richtige/falsche Bestätigung, geschlossenes LAN nach Disconnect,
|
||||
Neustart während unbestätigter Änderung. Testcontainer und Test-Secrets entfernt.
|
||||
Startzeiten sämtlicher vorher vorhandenen Container vor/nach dem Test unverändert.
|
||||
|
||||
Reproduzierbarer Integrationstest (nur bewusst auf einem Linux-Docker-Testhost):
|
||||
|
||||
```sh
|
||||
docker build -t athena-deck-network-test:20260928 -f network/Dockerfile .
|
||||
python3 network/test_integration.py
|
||||
```
|
||||
|
||||
Der echte Peer des Benutzers, seine Router-Freigaben und die persistente Installation
|
||||
über die GUI sind damit noch nicht live abgenommen. Der Test ersetzt keine eigene
|
||||
WireGuard-Konfiguration. Auf Athena wurde bisher nur der isolierte Test ausgeführt.
|
||||
|
||||
Technische Referenzen: [WireGuard Quick Start](https://www.wireguard.com/quickstart/),
|
||||
[Docker Runtime Capabilities](https://docs.docker.com/engine/containers/run/).
|
||||
@@ -0,0 +1 @@
|
||||
"""Isolated networking module for Athena Deck."""
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Container-only privileged helper. Never run on the host.
|
||||
|
||||
Owns one wg interface, one policy-routing table, and two ingress listeners.
|
||||
The web child runs without root; the helper accepts only fixed operations.
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import socketserver
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from network.config import parse_config, wireguard_text
|
||||
from network.policy import Policy
|
||||
|
||||
DATA = Path('/data')
|
||||
RUN = Path('/run/deck')
|
||||
INTERFACE = 'deckwg0'
|
||||
PORT = 8110
|
||||
LOCK = threading.RLock()
|
||||
PROXY_TOKEN = secrets.token_hex(32)
|
||||
|
||||
|
||||
def command(*args, input=None, check=True):
|
||||
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=10)
|
||||
if check and result.returncode:
|
||||
raise ValueError('WireGuard-Netzwerkaktion fehlgeschlagen; Einstellungen prüfen.')
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def save(path, value, mode=0o600):
|
||||
temporary = path.with_suffix('.tmp')
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
json.dump(value, stream)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
|
||||
class DeviceServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
def __init__(self, address, device, ingress):
|
||||
self.device, self.ingress = device, ingress
|
||||
super().__init__((address, PORT), Proxy, bind_and_activate=False)
|
||||
try:
|
||||
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
|
||||
|
||||
class Proxy(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.forward()
|
||||
|
||||
def do_POST(self):
|
||||
self.forward()
|
||||
|
||||
def forward(self):
|
||||
self.connection.settimeout(10)
|
||||
with LOCK:
|
||||
allowed = AGENT.policy.allowed(self.server.ingress)
|
||||
if not allowed:
|
||||
self.send_error(403, 'This network access is disabled')
|
||||
return
|
||||
try:
|
||||
allowed_hosts = {f'{self.server.server_address[0]}:{PORT}'}
|
||||
if self.server.ingress == 'lan':
|
||||
allowed_hosts.update({f'{AGENT.lan_ip}:{PORT}', f'127.0.0.1:{PORT}', f'localhost:{PORT}'})
|
||||
if self.headers.get('Host') not in allowed_hosts:
|
||||
self.send_error(403, 'Host rejected')
|
||||
return
|
||||
if self.headers.get('Transfer-Encoding'):
|
||||
raise ValueError()
|
||||
length = int(self.headers.get('Content-Length', '0'))
|
||||
if not 0 <= length <= 32768 or len(self.path)>2048:
|
||||
raise ValueError()
|
||||
body = self.rfile.read(length) if length else None
|
||||
headers = {name: self.headers[name] for name in ('Host', 'Origin', 'Content-Type', 'Cookie', 'X-Athena-Deck') if name in self.headers}
|
||||
headers.update({'X-Deck-Proxy': PROXY_TOKEN, 'X-Deck-Ingress': self.server.ingress})
|
||||
conn = http.client.HTTPConnection('127.0.0.1', 8108, timeout=30)
|
||||
try:
|
||||
conn.request(self.command, self.path, body, headers)
|
||||
response = conn.getresponse()
|
||||
data = response.read(2*1024*1024)
|
||||
self.send_response(response.status)
|
||||
for name, value in response.getheaders():
|
||||
if name.lower() not in ('connection', 'transfer-encoding', 'server', 'date', 'content-length'):
|
||||
self.send_header(name, value)
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
finally:
|
||||
conn.close()
|
||||
except (ValueError, OSError, http.client.HTTPException):
|
||||
self.send_error(502, 'Deck unavailable')
|
||||
|
||||
class Agent:
|
||||
def __init__(self):
|
||||
mode_file = DATA/'mode.json'
|
||||
mode = json.loads(mode_file.read_text()) if mode_file.exists() else 'lan'
|
||||
self.policy = Policy(mode=mode, persist=lambda value: save(mode_file, value))
|
||||
self.tunnel_server = None
|
||||
self.address = None
|
||||
self.error = None
|
||||
self.lan_ip = os.environ.get('DECK_LAN_IP', '')
|
||||
|
||||
def connected(self):
|
||||
try:
|
||||
raw = command('wg', 'show', INTERFACE, 'latest-handshakes', check=False)
|
||||
values = [int(line.split()[1]) for line in raw.splitlines()]
|
||||
latest = max(values, default=0)
|
||||
return latest > 0 and 0 <= time.time()-latest < 180, latest or None
|
||||
except (ValueError, OSError, subprocess.SubprocessError):
|
||||
return False, None
|
||||
|
||||
def status(self, ingress='management'):
|
||||
connected, latest = self.connected()
|
||||
config_file = DATA/'config.json'
|
||||
config = parse_config(json.loads(config_file.read_text())) if config_file.exists() else None
|
||||
return dict(installed=True, configured=config is not None, enabled=self.address is not None,
|
||||
connected=connected, latest_handshake=latest, state='connected' if connected else ('connecting' if self.address else 'disabled'),
|
||||
tunnel_url=f'http://{self.address}:{PORT}' if self.address else None,
|
||||
lan_url=f'http://{self.lan_ip}:{PORT}' if self.lan_ip else None,
|
||||
warnings=config['warnings'] if config else [], error=self.error,
|
||||
ingress=ingress, **self.policy.status())
|
||||
|
||||
def disconnect(self):
|
||||
if self.tunnel_server:
|
||||
self.tunnel_server.shutdown()
|
||||
self.tunnel_server.server_close()
|
||||
self.tunnel_server = None
|
||||
command('ip', 'link', 'delete', INTERFACE, check=False)
|
||||
command('ip', '-4', 'rule', 'del', 'priority', '20000', check=False)
|
||||
command('ip', '-4', 'route', 'flush', 'table', '51820', check=False)
|
||||
self.address = None
|
||||
|
||||
def connect(self):
|
||||
if self.address:
|
||||
return
|
||||
path = DATA/'config.json'
|
||||
if not path.exists():
|
||||
raise ValueError('Zuerst eine eigene WireGuard-Konfiguration importieren.')
|
||||
config = parse_config(json.loads(path.read_text()))
|
||||
# Avoid ambiguity with the LAN listener or loopback, even for unusual uploads.
|
||||
eth = json.loads(command('ip', '-j', '-4', 'addr', 'show', 'dev', 'eth0'))
|
||||
if any(a['local'] == config['address'] for i in eth for a in i.get('addr_info', [])):
|
||||
raise ValueError('Tunneladresse kollidiert mit der Containeradresse.')
|
||||
try:
|
||||
command('ip', 'link', 'add', INTERFACE, 'type', 'wireguard')
|
||||
secret = RUN/'wireguard.conf'
|
||||
fd = os.open(secret, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
stream.write(wireguard_text(config))
|
||||
command('wg', 'setconf', INTERFACE, str(secret))
|
||||
finally:
|
||||
secret.unlink(missing_ok=True)
|
||||
command('ip', '-4', 'addr', 'add', config['address']+'/32', 'dev', INTERFACE)
|
||||
command('ip', 'link', 'set', INTERFACE, 'mtu', str(config['mtu']), 'up')
|
||||
# Only packets sourced from the tunnel address use these routes.
|
||||
# Never change the host or container main/default route.
|
||||
for network in config['allowed_ips']:
|
||||
command('ip', '-4', 'route', 'replace', network, 'dev', INTERFACE, 'table', '51820')
|
||||
command('ip', '-4', 'rule', 'add', 'priority', '20000', 'from', config['address']+'/32', 'lookup', '51820')
|
||||
self.tunnel_server = DeviceServer(config['address'], INTERFACE, 'tunnel')
|
||||
threading.Thread(target=self.tunnel_server.serve_forever, daemon=True).start()
|
||||
self.address = config['address']
|
||||
self.error = None
|
||||
except Exception:
|
||||
self.disconnect()
|
||||
raise ValueError('Tunnel konnte nicht eingerichtet werden. Keine Host-Konfiguration wurde geändert.') from None
|
||||
|
||||
def dispatch(self, data):
|
||||
action = data.get('action')
|
||||
ingress = data.get('ingress', 'management')
|
||||
# Ingress may only be asserted by the web child using the private proxy token.
|
||||
if not secrets.compare_digest(str(data.get('proxy_token', '')), PROXY_TOKEN):
|
||||
ingress = 'management'
|
||||
if action == 'status':
|
||||
return self.status(ingress)
|
||||
if action == 'import':
|
||||
self.policy.expire()
|
||||
if self.address or self.policy.mode != 'lan' or self.policy.pending:
|
||||
raise ValueError('Import ist nur im bestätigten LAN-Modus bei deaktiviertem Tunnel möglich.')
|
||||
config = parse_config(data.get('config'))
|
||||
save(DATA/'config.json', data['config'])
|
||||
self.error = None
|
||||
elif action == 'connect':
|
||||
self.connect()
|
||||
save(DATA/'enabled.json', True)
|
||||
elif action == 'disconnect':
|
||||
self.disconnect()
|
||||
save(DATA/'enabled.json', False)
|
||||
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.address or self.policy.mode != 'lan' or self.policy.pending:
|
||||
raise ValueError('Löschen ist nur bei deaktiviertem Tunnel im bestätigten LAN-Modus möglich.')
|
||||
(DATA/'config.json').unlink(missing_ok=True)
|
||||
else:
|
||||
raise ValueError('Unbekannte Netzwerkaktion.')
|
||||
return self.status(ingress)
|
||||
|
||||
class RPC(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
self.connection.settimeout(30)
|
||||
try:
|
||||
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.')
|
||||
with LOCK:
|
||||
result = AGENT.dispatch(data)
|
||||
except ValueError as exc:
|
||||
# Parser errors are deliberately generic; never return submitted values.
|
||||
result = {'error': str(exc) if not isinstance(exc, json.JSONDecodeError) else 'Ungültige Anfrage.'}
|
||||
except Exception:
|
||||
result = {'error': 'Netzwerkaktion fehlgeschlagen.'}
|
||||
self.wfile.write(json.dumps(result).encode()+b'\n')
|
||||
|
||||
class RPCServer(socketserver.ThreadingUnixStreamServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
def main():
|
||||
global AGENT
|
||||
if not Path('/.dockerenv').exists() or os.environ.get('DECK_CONTAINER') != '1':
|
||||
raise SystemExit('This helper must run in its isolated Deck container.')
|
||||
os.umask(0o077)
|
||||
DATA.mkdir(exist_ok=True)
|
||||
DATA.chmod(0o700)
|
||||
RUN.mkdir(exist_ok=True)
|
||||
RUN.chmod(0o755)
|
||||
path = RUN/'control.sock'
|
||||
path.unlink(missing_ok=True)
|
||||
AGENT = Agent()
|
||||
rpc = RPCServer(str(path), RPC)
|
||||
os.chown(path, 0, 65534)
|
||||
path.chmod(0o660)
|
||||
threading.Thread(target=rpc.serve_forever, daemon=True).start()
|
||||
auth = DATA/'auth.json'
|
||||
if not auth.exists():
|
||||
raise SystemExit('Authentication must be provisioned before container startup.')
|
||||
env = os.environ.copy()
|
||||
env.update(DECK_PROXY_TOKEN=PROXY_TOKEN, DECK_AUTH_JSON=auth.read_text(), DECK_NETWORK_SOCKET='1', DECK_LOCAL_HARDWARE='1')
|
||||
child = subprocess.Popen([sys.executable, '/app/server.py'], env=env, user=65534, group=65534, extra_groups=[], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
address = json.loads(command('ip', '-j', '-4', 'addr', 'show', 'dev', 'eth0'))[0]['addr_info'][0]['local']
|
||||
lan = DeviceServer(address, 'eth0', 'lan')
|
||||
threading.Thread(target=lan.serve_forever, daemon=True).start()
|
||||
if (DATA/'enabled.json').exists() and json.loads((DATA/'enabled.json').read_text()):
|
||||
try:
|
||||
AGENT.connect()
|
||||
except ValueError:
|
||||
AGENT.error = 'Tunnel nach Neustart nicht verfügbar; gewählter Zugriffsmodus bleibt bestehen.'
|
||||
done = threading.Event()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
signal.signal(sig, lambda *_: done.set())
|
||||
try:
|
||||
while not done.wait(1):
|
||||
with LOCK:
|
||||
AGENT.policy.expire()
|
||||
if child.poll() is not None:
|
||||
break
|
||||
finally:
|
||||
lan.shutdown()
|
||||
rpc.shutdown()
|
||||
AGENT.disconnect()
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Local management via fixed SSH target or container-private Unix RPC."""
|
||||
import base64
|
||||
import hashlib
|
||||
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']
|
||||
|
||||
class NetworkClient:
|
||||
def __init__(self):
|
||||
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'):
|
||||
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'):
|
||||
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):
|
||||
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.')
|
||||
with self.lock:
|
||||
if self.job and self.job['state'] == 'running':
|
||||
raise ValueError('Installation läuft bereits.')
|
||||
salt = secrets.token_bytes(16)
|
||||
auth = dict(salt=salt.hex(), hash=hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600000).hex())
|
||||
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
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Strict, deliberately small WireGuard client configuration grammar."""
|
||||
import base64
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
class ConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def parse_config(text):
|
||||
if not isinstance(text, str) or len(text.encode('utf-8')) > 16384:
|
||||
raise ConfigError('Die Konfiguration darf höchstens 16 KiB groß sein.')
|
||||
sections = {}
|
||||
current = None
|
||||
for line in text.splitlines():
|
||||
line = line.split('#', 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith('['):
|
||||
if line not in ('[Interface]', '[Peer]') or line in sections:
|
||||
raise ConfigError('Genau ein Interface und ein Peer werden unterstützt.')
|
||||
current = sections.setdefault(line, {})
|
||||
continue
|
||||
if current is None or '=' not in line:
|
||||
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 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):
|
||||
raise ConfigError('Leerer oder ungültiger Konfigurationswert.')
|
||||
current[name] = value
|
||||
interface, peer = sections.get('[Interface]', {}), sections.get('[Peer]', {})
|
||||
if not {'PrivateKey', 'Address'} <= interface.keys() or not {'PublicKey', 'AllowedIPs', 'Endpoint'} <= peer.keys():
|
||||
raise ConfigError('PrivateKey, Address, PublicKey, AllowedIPs und Endpoint sind erforderlich.')
|
||||
for section, key in ((interface, 'PrivateKey'), (peer, 'PublicKey'), (peer, 'PresharedKey')):
|
||||
if key in section:
|
||||
try:
|
||||
decoded = base64.b64decode(section[key], validate=True)
|
||||
if len(decoded) != 32 or not any(decoded):
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
raise ConfigError('Ein WireGuard-Schlüssel hat ein ungültiges Format.') from None
|
||||
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):
|
||||
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:
|
||||
raise ValueError()
|
||||
if len(networks) > 32:
|
||||
raise ValueError()
|
||||
endpoint, port = peer['Endpoint'].rsplit(':', 1)
|
||||
if not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?', endpoint):
|
||||
raise ValueError()
|
||||
if not 1 <= int(port) <= 65535:
|
||||
raise ValueError()
|
||||
mtu = int(interface.get('MTU', '1420'))
|
||||
keepalive = int(peer.get('PersistentKeepalive', '25'))
|
||||
listen = int(interface.get('ListenPort', '0'))
|
||||
if not 576 <= mtu <= 9000 or not 0 <= keepalive <= 65535 or not 0 <= listen <= 65535:
|
||||
raise ValueError()
|
||||
except (ValueError, KeyError) as exc:
|
||||
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)
|
||||
|
||||
|
||||
def wireguard_text(config):
|
||||
"""Never executed as shell or wg-quick input; excludes all hooks and routes."""
|
||||
lines = ['[Interface]', 'PrivateKey = ' + config['interface']['PrivateKey'], 'ListenPort = ' + str(config['listen']), '[Peer]']
|
||||
for key in ('PublicKey', 'PresharedKey', 'Endpoint'):
|
||||
if key in config['peer']:
|
||||
lines.append(key + ' = ' + config['peer'][key])
|
||||
lines.extend(['AllowedIPs = ' + ', '.join(config['allowed_ips']), 'PersistentKeepalive = ' + str(config['keepalive'])])
|
||||
return '\n'.join(lines) + '\n'
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Receives a source-only JSON bundle on stdin over SSH. No secrets in argv/logs.
|
||||
Creates only /opt/athena-deck and the separately named Deck container.
|
||||
"""
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
NAME = 'athena-deck-network'
|
||||
BASE = Path('/opt/athena-deck')
|
||||
FILES = {'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
|
||||
|
||||
def run(*args, **kwargs):
|
||||
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
||||
|
||||
|
||||
def main():
|
||||
payload = json.load(sys.stdin)
|
||||
if set(payload['files']) != FILES:
|
||||
raise ValueError()
|
||||
if not Path('/sys/module/wireguard').exists():
|
||||
raise ValueError('WireGuard-Kernelunterstützung fehlt. Es werden keine Host-Pakete oder Kernel installiert.')
|
||||
if run('docker', 'version').returncode:
|
||||
raise ValueError('Docker ist nicht verfügbar. Host-Installation erfolgt nicht automatisch.')
|
||||
if not run('docker', 'inspect', NAME).returncode:
|
||||
raise ValueError('Deck-Container existiert bereits; keine Überschreibung.')
|
||||
routes = json.loads(run('ip', '-j', '-4', 'route', 'get', '1.1.1.1').stdout)
|
||||
lan = routes[0].get('prefsrc')
|
||||
address = ipaddress.ip_address(lan)
|
||||
if not address.is_private or address.is_loopback:
|
||||
raise ValueError('Keine private LAN-Adresse ermittelt; Installation abgebrochen.')
|
||||
# Preflight host port; Docker performs the definitive collision check.
|
||||
for addr in ('127.0.0.1', lan):
|
||||
with socket.socket() as sock:
|
||||
sock.bind((addr, 8110))
|
||||
BASE.mkdir(mode=0o700, exist_ok=True)
|
||||
if (BASE/'state/auth.json').exists():
|
||||
raise ValueError('Vorhandene Deck-Zugangsdaten werden nicht überschrieben.')
|
||||
source = BASE/'source'
|
||||
source.mkdir(mode=0o755, exist_ok=True)
|
||||
for name, encoded in payload['files'].items():
|
||||
target = source/name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(base64.b64decode(encoded, validate=True))
|
||||
target.chmod(0o644)
|
||||
result = run('docker', 'build', '-t', 'athena-deck-network:0.2', '-f', str(source/'network/Dockerfile'), str(source))
|
||||
if result.returncode:
|
||||
raise ValueError('Container-Build fehlgeschlagen. Docker benötigt Zugriff auf die Paketquellen.')
|
||||
state = BASE/'state'
|
||||
state.mkdir(mode=0o700, exist_ok=True)
|
||||
auth = payload['auth']
|
||||
if set(auth) != {'salt', 'hash'} or len(auth['salt']) != 32 or len(auth['hash']) != 64:
|
||||
raise ValueError('Ungültige Zugangsdaten.')
|
||||
int(auth['salt'], 16)
|
||||
int(auth['hash'], 16)
|
||||
fd = os.open(state/'auth.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
json.dump(auth, stream)
|
||||
result = run('docker', 'run', '-d', '--name', NAME, '--restart', 'unless-stopped',
|
||||
'--gpus', 'all', '-e', 'NVIDIA_DRIVER_CAPABILITIES=utility', '--read-only', '--cap-drop', 'ALL', '--cap-add', 'NET_ADMIN', '--cap-add', 'NET_RAW', '--cap-add', 'SETUID', '--cap-add', 'SETGID', '--cap-add', 'CHOWN',
|
||||
'--security-opt', 'no-new-privileges:true', '--pids-limit', '128', '--memory', '256m', '--cpus', '0.5',
|
||||
'--tmpfs', '/run:rw,nosuid,nodev,size=8m', '--tmpfs', '/tmp:rw,nosuid,nodev,size=8m',
|
||||
'-v', str(state)+':/data:rw', '-e', 'DECK_LAN_IP='+lan,
|
||||
'-p', '127.0.0.1:8110:8110', '-p', lan+':8110:8110', 'athena-deck-network:0.2')
|
||||
if result.returncode:
|
||||
# Auth remains protected for manual recovery; never delete an existing container.
|
||||
raise ValueError('Containerstart fehlgeschlagen. Gesicherter Installationsstand liegt unter /opt/athena-deck.')
|
||||
print(json.dumps({'installed':True, 'lan_url':f'http://{lan}:8110'}))
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
print(json.dumps({'error':str(exc) if isinstance(exc, ValueError) and str(exc) else 'Installation fehlgeschlagen; keine produktiven Dienste wurden verändert.'}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Access policy: persisted confirmed mode, monotonic trial deadline."""
|
||||
import secrets
|
||||
import time
|
||||
|
||||
class Policy:
|
||||
MODES = ('lan', 'tunnel', 'both')
|
||||
|
||||
def __init__(self, mode='lan', clock=time.monotonic, persist=lambda mode: None):
|
||||
self.mode = mode if mode in self.MODES else 'lan'
|
||||
self.clock = clock
|
||||
self.persist = persist
|
||||
self.pending = None
|
||||
|
||||
def expire(self):
|
||||
if self.pending and self.clock() >= self.pending['deadline']:
|
||||
self.mode = self.pending['previous']
|
||||
self.pending = None
|
||||
|
||||
def allowed(self, ingress):
|
||||
self.expire()
|
||||
return self.mode == 'both' or self.mode == ingress
|
||||
|
||||
def propose(self, mode, connected):
|
||||
self.expire()
|
||||
if mode not in self.MODES:
|
||||
raise ValueError('Ungültiger Zugriffsmodus.')
|
||||
if self.pending:
|
||||
raise ValueError('Zuerst den laufenden Zugriffsversuch bestätigen oder zurücknehmen.')
|
||||
if mode == self.mode:
|
||||
return
|
||||
if mode in ('tunnel', 'both') and not connected:
|
||||
raise ValueError('Zuerst WireGuard verbinden und einen aktuellen Handshake abwarten.')
|
||||
self.pending = dict(previous=self.mode, target=mode, deadline=self.clock()+120, id=secrets.token_urlsafe(24))
|
||||
self.mode = mode
|
||||
|
||||
def confirm(self, trial_id, ingress):
|
||||
self.expire()
|
||||
if not self.pending or not secrets.compare_digest(self.pending['id'], str(trial_id)):
|
||||
raise ValueError('Kein passender laufender Zugriffsversuch.')
|
||||
needed = 'tunnel' if self.mode in ('tunnel', 'both') else 'lan'
|
||||
if ingress != needed:
|
||||
raise ValueError('Bestätigung muss über den ausgewählten Netzwerkzugang erfolgen.')
|
||||
self.persist(self.mode)
|
||||
self.pending = None
|
||||
|
||||
def cancel(self):
|
||||
if self.pending:
|
||||
self.mode = self.pending['previous']
|
||||
self.pending = None
|
||||
|
||||
def status(self):
|
||||
self.expire()
|
||||
return dict(mode=self.mode, pending=None if not self.pending else dict(id=self.pending['id'], target=self.pending['target'], previous=self.pending['previous'], seconds_remaining=max(0, int(self.pending['deadline']-self.clock()))))
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Bounded JSON RPC over a private Unix socket; no shell commands exposed."""
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
|
||||
SOCKET = '/run/deck/control.sock'
|
||||
|
||||
def request(data):
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.settimeout(25)
|
||||
sock.connect(SOCKET)
|
||||
sock.sendall(json.dumps(data).encode()+b'\n')
|
||||
with sock.makefile('rb') as stream:
|
||||
raw = stream.readline(65537)
|
||||
if len(raw)>65536:
|
||||
raise ValueError('Ungültige Helper-Antwort.')
|
||||
result = json.loads(raw)
|
||||
if 'error' in result and 'installed' not in result:
|
||||
raise ValueError(result['error'])
|
||||
return result
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
data = json.loads(sys.stdin.buffer.readline(32769))
|
||||
print(json.dumps(request(data)))
|
||||
except Exception:
|
||||
print(json.dumps({'error':'Netzwerkaktion fehlgeschlagen. Konfiguration und Verbindung prüfen.'}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Run explicitly on a Linux Docker host; creates two disposable containers.
|
||||
No published ports, no production endpoints, fresh throwaway keys kept in memory.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
IMAGE='athena-deck-network-test:20260928'
|
||||
DECK='athena-deck-test-runtime'
|
||||
PEER='athena-deck-test-peer'
|
||||
|
||||
|
||||
def run(*args, data=None, check=True):
|
||||
result=subprocess.run(args,input=data,text=True,capture_output=True,timeout=45)
|
||||
if check and result.returncode:
|
||||
raise RuntimeError('Test command failed: '+args[0]+' (output redacted)')
|
||||
return result.stdout.strip()
|
||||
|
||||
def execute(container, script, value=None):
|
||||
payload=json.dumps(value) if value is not None else None
|
||||
result=run('docker','exec','-i',container,'python3','-c',script,data=payload)
|
||||
return json.loads(result) if result else None
|
||||
|
||||
def rpc(action, **values):
|
||||
result=execute(DECK,"import json,sys;from network.rpc import request;print(json.dumps(request(json.load(sys.stdin))))",dict(action=action,**values))
|
||||
return result
|
||||
|
||||
HTTP = '''import http.client,json,sys
|
||||
v=json.load(sys.stdin)
|
||||
c=http.client.HTTPConnection(v['host'],8110,timeout=5)
|
||||
h={'Content-Type':'application/json','X-Athena-Deck':'1'}
|
||||
if v.get('cookie'):h['Cookie']=v['cookie']
|
||||
if v.get('origin'):h['Origin']=v['origin']
|
||||
c.request(v.get('method','GET'),v.get('path','/'),json.dumps(v['body']) if 'body' in v else None,h)
|
||||
r=c.getresponse();b=r.read().decode()
|
||||
print(json.dumps({'status':r.status,'body':b,'cookie':r.getheader('Set-Cookie')}))
|
||||
'''
|
||||
|
||||
def http(host,path='/',method='GET',body=None,cookie=None):
|
||||
value=dict(host=host,path=path,method=method,cookie=cookie)
|
||||
if body is not None:value['body']=body
|
||||
return execute(PEER,HTTP,value)
|
||||
|
||||
|
||||
def main():
|
||||
for name in (DECK,PEER):
|
||||
if run('docker','ps','-aq','--filter','name=^/'+name+'$'):
|
||||
raise RuntimeError('Test container name already exists; refusing takeover')
|
||||
ids=run('docker','ps','-aq').splitlines()
|
||||
baseline=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
|
||||
state=tempfile.TemporaryDirectory(prefix='athena-deck-test-')
|
||||
password=secrets.token_urlsafe(32)
|
||||
salt=secrets.token_bytes(16)
|
||||
auth=dict(salt=salt.hex(),hash=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex())
|
||||
p=Path(state.name)/'auth.json';p.write_text(json.dumps(auth));p.chmod(0o600)
|
||||
try:
|
||||
run('docker','run','-d','--name',PEER,'--cap-drop','ALL','--cap-add','NET_ADMIN',IMAGE,'sleep','600')
|
||||
run('docker','run','-d','--name',DECK,'--read-only','--cap-drop','ALL','--cap-add','NET_ADMIN','--cap-add','NET_RAW','--cap-add','SETUID','--cap-add','SETGID','--cap-add','CHOWN','--security-opt','no-new-privileges:true','--tmpfs','/run:rw,nosuid,nodev,size=8m','--tmpfs','/tmp:rw,nosuid,nodev,size=8m','-v',state.name+':/data',IMAGE)
|
||||
for _ in range(20):
|
||||
try:
|
||||
assert rpc('status')['state']=='disabled'
|
||||
break
|
||||
except Exception:time.sleep(.5)
|
||||
else:raise RuntimeError('Helper did not become ready')
|
||||
print('PASS helper startup and unprivileged web child',flush=True)
|
||||
uid=execute(DECK,"import json;from pathlib import Path;print(json.dumps([p.read_text().split('Uid:')[1].splitlines()[0].split()[0] for p in Path('/proc').glob('[0-9]*/status') if 'Name:\\tpython' in p.read_text()]))")
|
||||
assert '65534' in uid
|
||||
deckip=run('docker','inspect','--format','{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}',DECK)
|
||||
peerip=run('docker','inspect','--format','{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}',PEER)
|
||||
assert http(deckip,'/api/v1/status')['status']==401
|
||||
login=http(deckip,'/api/v1/login','POST',{'password':password})
|
||||
assert login['status']==200
|
||||
cookie=login['cookie'].split(';')[0]
|
||||
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200
|
||||
print('PASS real LAN login and authenticated API',flush=True)
|
||||
# Keys stay in this Python process and stdin pipes; never printed.
|
||||
private_a=run('docker','exec',PEER,'wg','genkey')
|
||||
private_b=run('docker','exec',PEER,'wg','genkey')
|
||||
public_a=run('docker','exec','-i',PEER,'wg','pubkey',data=private_a+'\n')
|
||||
public_b=run('docker','exec','-i',PEER,'wg','pubkey',data=private_b+'\n')
|
||||
conf=f'[Interface]\nPrivateKey = {private_a}\nAddress = 10.240.77.1/32\nListenPort = 51822\n[Peer]\nPublicKey = {public_b}\nEndpoint = {peerip}:51823\nAllowedIPs = 10.240.77.2/32\nPersistentKeepalive = 1\n'
|
||||
status=rpc('import',config=conf)
|
||||
assert private_a not in json.dumps(status) and public_b not in json.dumps(status)
|
||||
peerconf=f'[Interface]\nPrivateKey = {private_b}\nListenPort = 51823\n[Peer]\nPublicKey = {public_a}\nEndpoint = {deckip}:51822\nAllowedIPs = 10.240.77.1/32\nPersistentKeepalive = 1\n'
|
||||
execute(PEER,'''import json,sys,subprocess,os
|
||||
v=json.load(sys.stdin)
|
||||
def cmd(*a):subprocess.run(a,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
cmd('ip','link','add','peerwg0','type','wireguard')
|
||||
fd=os.open('/tmp/peer.conf',os.O_WRONLY|os.O_CREAT,0o600)
|
||||
with os.fdopen(fd,'w') as f:f.write(v)
|
||||
cmd('wg','setconf','peerwg0','/tmp/peer.conf');os.unlink('/tmp/peer.conf')
|
||||
cmd('ip','addr','add','10.240.77.2/32','dev','peerwg0')
|
||||
cmd('ip','link','set','peerwg0','up')
|
||||
cmd('ip','route','add','10.240.77.1/32','dev','peerwg0')
|
||||
''',peerconf)
|
||||
rpc('connect')
|
||||
for _ in range(20):
|
||||
if rpc('status')['connected']:break
|
||||
time.sleep(.5)
|
||||
else:raise RuntimeError('No real WireGuard handshake')
|
||||
assert http('10.240.77.1','/api/v1/status',cookie=cookie)['status']==403
|
||||
print('PASS actual WireGuard handshake; LAN-only blocks tunnel ingress',flush=True)
|
||||
status=rpc('mode',mode='both');trial=status['pending']['id']
|
||||
assert http('10.240.77.1','/api/v1/status',cookie=cookie)['status']==200
|
||||
assert http(deckip,'/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==400
|
||||
assert http('10.240.77.1','/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==200
|
||||
print('PASS both paths; only actual tunnel ingress can confirm',flush=True)
|
||||
status=rpc('mode',mode='tunnel');trial=status['pending']['id']
|
||||
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==403
|
||||
assert http('10.240.77.1','/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==200
|
||||
rpc('disconnect')
|
||||
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==403
|
||||
assert rpc('status')['mode']=='tunnel'
|
||||
print('PASS tunnel-only closes LAN and stays closed after disconnect',flush=True)
|
||||
status=rpc('mode',mode='lan');trial=status['pending']['id']
|
||||
assert http(deckip,'/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==200
|
||||
rpc('connect')
|
||||
for _ in range(20):
|
||||
if rpc('status')['connected']:break
|
||||
time.sleep(.5)
|
||||
rpc('mode',mode='tunnel')
|
||||
run('docker','restart',DECK)
|
||||
for _ in range(20):
|
||||
try:
|
||||
status=rpc('status')
|
||||
break
|
||||
except Exception:time.sleep(.5)
|
||||
assert status['mode']=='lan' and not status['pending']
|
||||
print('PASS restart discards unconfirmed trial and restores confirmed mode',flush=True)
|
||||
after=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
|
||||
assert baseline==after
|
||||
print('PASS production container start times unchanged',flush=True)
|
||||
finally:
|
||||
for name in (DECK,PEER):run('docker','rm','-f',name,check=False)
|
||||
state.cleanup()
|
||||
|
||||
if __name__=='__main__':main()
|
||||
Reference in New Issue
Block a user