Add isolated WireGuard module with guarded access modes and server setup
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Athena Deck prototype: loopback API, owned demo process, read-only telemetry."""
|
||||
import argparse
|
||||
import os
|
||||
import hashlib
|
||||
import secrets
|
||||
from http.cookies import SimpleCookie
|
||||
from network.client import NetworkClient
|
||||
from network.config import parse_config, ConfigError
|
||||
import json
|
||||
import signal
|
||||
import socket
|
||||
@@ -30,7 +36,7 @@ class DemoService:
|
||||
reachable = json.load(response).get('service') == 'athena-deck-demo'
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return dict(state='running' if running else 'stopped', reachable=reachable, pid=self.process.pid if running else None, port=self.port if running else None, location='Mac · lokaler Demo-Prozess')
|
||||
return dict(state='running' if running else 'stopped', reachable=reachable, pid=self.process.pid if running else None, port=self.port if running else None, location='Deck · isolierter Demo-Prozess')
|
||||
|
||||
def start(self):
|
||||
with self.lock:
|
||||
@@ -64,6 +70,13 @@ class HardwareProvider:
|
||||
self.checked = 0
|
||||
|
||||
def snapshot(self):
|
||||
if os.environ.get('DECK_LOCAL_HARDWARE') == '1':
|
||||
from collect_hardware import collect
|
||||
with self.lock:
|
||||
if self.cached is None or time.monotonic()-self.checked >= 5:
|
||||
self.cached = dict(collect(), available=True)
|
||||
self.checked = time.monotonic()
|
||||
return self.cached
|
||||
with self.lock:
|
||||
if time.monotonic() - self.checked < 5 and self.cached is not None:
|
||||
return self.cached
|
||||
@@ -83,6 +96,11 @@ class Server(ThreadingHTTPServer):
|
||||
self.demo = DemoService()
|
||||
self.hardware = HardwareProvider()
|
||||
self.started = time.time()
|
||||
self.network = NetworkClient()
|
||||
self.auth = json.loads(os.environ['DECK_AUTH_JSON']) if os.environ.get('DECK_AUTH_JSON') else None
|
||||
self.sessions = {}
|
||||
self.login_attempts = []
|
||||
self.auth_lock = threading.Lock()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
@@ -99,20 +117,97 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def ingress(self):
|
||||
token = os.environ.get('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 'management'
|
||||
|
||||
def authenticated(self):
|
||||
if self.server.auth is None:
|
||||
return True
|
||||
try:
|
||||
cookie = SimpleCookie(self.headers.get('Cookie', ''))
|
||||
token = cookie['deck_session'].value
|
||||
except (KeyError, ValueError):
|
||||
return False
|
||||
with self.server.auth_lock:
|
||||
return self.server.sessions.get(token, 0) > time.monotonic()
|
||||
|
||||
def read_json(self):
|
||||
self.connection.settimeout(10)
|
||||
if self.headers.get('Transfer-Encoding'):
|
||||
raise ValueError('Chunked Uploads werden nicht unterstützt.')
|
||||
length = int(self.headers.get('Content-Length', '0'))
|
||||
if not 0 < length <= 32768 or self.headers.get('Content-Type') != 'application/json':
|
||||
raise ValueError('JSON-Anfrage erwartet, maximal 32 KiB.')
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise ValueError('Ungültige JSON-Anfrage.') from None
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError('JSON-Objekt erwartet.')
|
||||
return value
|
||||
|
||||
def login(self):
|
||||
if self.server.auth is None:
|
||||
return self.respond({'authenticated': True})
|
||||
try:
|
||||
data = self.read_json()
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str) or len(password)>256:
|
||||
raise ValueError()
|
||||
with self.server.auth_lock:
|
||||
now = time.monotonic()
|
||||
self.server.login_attempts = [t for t in self.server.login_attempts if now-t<60]
|
||||
if len(self.server.login_attempts)>=10:
|
||||
return self.respond({'error':'Zu viele Anmeldeversuche. Bitte eine Minute warten.'}, 429)
|
||||
self.server.login_attempts.append(now)
|
||||
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(self.server.auth['salt']), 600000).hex()
|
||||
if not secrets.compare_digest(actual, self.server.auth['hash']):
|
||||
return self.respond({'error':'Anmeldung fehlgeschlagen.'}, 401)
|
||||
token = secrets.token_urlsafe(32)
|
||||
with self.server.auth_lock:
|
||||
self.server.sessions = {k:v for k,v in self.server.sessions.items() if v>now}
|
||||
if len(self.server.sessions)>=64:
|
||||
self.server.sessions.pop(next(iter(self.server.sessions)))
|
||||
self.server.sessions[token] = now+28800
|
||||
body = b'{"authenticated":true}'
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type','application/json')
|
||||
self.send_header('Cache-Control','no-store')
|
||||
self.send_header('Content-Length',str(len(body)))
|
||||
self.send_header('Set-Cookie',f'deck_session={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except (ValueError, OSError):
|
||||
self.respond({'error':'Ungültige Anmeldung.'},400)
|
||||
|
||||
def valid_host(self):
|
||||
if os.environ.get('DECK_PROXY_TOKEN'):
|
||||
return self.ingress() in ('lan','tunnel')
|
||||
return self.headers.get('Host') in (f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}')
|
||||
|
||||
def do_GET(self):
|
||||
if not self.valid_host():
|
||||
return self.respond({'error': 'Host rejected'}, 403)
|
||||
routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css')}
|
||||
if self.path == '/login.js':
|
||||
return self.respond((ROOT/'login.js').read_bytes(), mime='text/javascript')
|
||||
if not self.authenticated() and self.path not in ('/', '/style.css'):
|
||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||
if not self.authenticated() and self.path == '/':
|
||||
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
|
||||
routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript')}
|
||||
if self.path in routes:
|
||||
name, mime = routes[self.path]
|
||||
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
||||
if self.path == '/api/v1/status':
|
||||
return self.respond(dict(name='Athena Deck', version='0.1.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', demo=self.server.demo.status()))
|
||||
return self.respond(dict(name='Athena Deck', version='0.2.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location='Athena' if self.server.auth else 'Mac', demo=self.server.demo.status()))
|
||||
if self.path == '/api/v1/hardware':
|
||||
return self.respond(self.server.hardware.snapshot())
|
||||
if self.path == '/api/v1/network':
|
||||
return self.respond(self.server.network.status(self.ingress()))
|
||||
if self.path == '/api/v1/demo':
|
||||
return self.respond(self.server.demo.status())
|
||||
return self.respond({'error': 'Not found'}, 404)
|
||||
@@ -121,6 +216,30 @@ class Handler(BaseHTTPRequestHandler):
|
||||
origin = self.headers.get('Origin')
|
||||
if not self.valid_host() or self.headers.get('X-Athena-Deck') != '1' or (origin and origin != 'http://' + self.headers.get('Host', '')):
|
||||
return self.respond({'error': 'Same-origin control required'}, 403)
|
||||
if self.path == '/api/v1/login':
|
||||
return self.login()
|
||||
if not self.authenticated():
|
||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||
if self.path.startswith('/api/v1/network/'):
|
||||
action = self.path.rsplit('/',1)[1]
|
||||
if action not in ('install','import','connect','disconnect','mode','confirm','cancel','delete'):
|
||||
return self.respond({'error':'Not found'},404)
|
||||
try:
|
||||
data = self.read_json()
|
||||
if action == 'install':
|
||||
result = self.server.network.install(data.get('password'))
|
||||
else:
|
||||
fields = {'import': {'config'}, 'mode': {'mode'}, 'confirm': {'trial_id'}}.get(action,set())
|
||||
if set(data) != fields:
|
||||
raise ValueError('Ungültige Netzwerkparameter.')
|
||||
if action == 'import':
|
||||
parse_config(data.get('config'))
|
||||
result = self.server.network.call(action, data, self.ingress())
|
||||
self.server.network.cached = None
|
||||
return self.respond(result)
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||
message = str(exc) if isinstance(exc, ValueError) else 'Netzwerkmodul nicht erreichbar.'
|
||||
return self.respond({'error':message},400)
|
||||
if self.path not in ('/api/v1/demo/start', '/api/v1/demo/stop'):
|
||||
return self.respond({'error': 'Not found'}, 404)
|
||||
action = self.server.demo.start if self.path.endswith('/start') else self.server.demo.stop
|
||||
|
||||
Reference in New Issue
Block a user