#!/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, CookieError from auth import CredentialStore, verify_password from network.client import NetworkClient from network.config import parse_config, ConfigError import json import signal import socket import subprocess import sys import threading import time import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path ROOT = Path(__file__).resolve().parent class DemoService: def __init__(self): self.lock = threading.RLock() self.process = None self.port = None def status(self): with self.lock: running = self.process is not None and self.process.poll() is None reachable = False if running: try: with urllib.request.urlopen(f'http://127.0.0.1:{self.port}/health', timeout=.5) as response: 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='Deck · isolierter Demo-Prozess') def start(self): with self.lock: if self.process is None or self.process.poll() is not None: with socket.socket() as sock: sock.bind(('127.0.0.1', 0)) sock.listen(8) self.port = sock.getsockname()[1] self.process = subprocess.Popen([sys.executable, str(ROOT/'demo.py'), str(sock.fileno())], pass_fds=(sock.fileno(),), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(30): if self.status()['reachable']: break time.sleep(.05) return self.status() def stop(self): with self.lock: if self.process is not None and self.process.poll() is None: self.process.terminate() try: self.process.wait(timeout=3) except subprocess.TimeoutExpired: self.process.kill() self.process.wait() return self.status() class HardwareProvider: def __init__(self): self.lock = threading.Lock() self.cached = None 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 try: result = subprocess.run(['ssh', '-i', str(Path.home()/'.ssh/athena_key'), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=4', '-o', 'StrictHostKeyChecking=yes', 'root@192.168.1.212', 'python3 -'], input=(ROOT/'collect_hardware.py').read_text(), capture_output=True, text=True, timeout=10, check=True) self.cached = json.loads(result.stdout) self.cached['available'] = True except (OSError, subprocess.SubprocessError, ValueError): self.cached = dict(available=False, sampled_at=None, cpu={}, ram={}, gpus=[], errors=['Athena per SSH nicht erreichbar oder Messung fehlgeschlagen.']) self.checked = time.monotonic() return self.cached class Server(ThreadingHTTPServer): daemon_threads = True def __init__(self, port, state_dir=None): super().__init__((os.environ.get('DECK_BIND_HOST','127.0.0.1'), port), Handler) self.demo = DemoService() self.hardware = HardwareProvider() self.started = time.time() self.network = NetworkClient() if os.environ.get('DECK_AUTH_RPC') == '1': from network.rpc import request self.credentials = CredentialStore(rpc=request) else: self.credentials = CredentialStore(Path(state_dir or os.environ.get('DECK_STATE_DIR', ROOT/'.state'))/'auth.json') if os.environ.get('DECK_REQUIRE_SETUP') == '1' and self.credentials.read() is None: self.server_close() raise RuntimeError('Serverzugang muss vor dem Start eingerichtet werden.') self.sessions = {} self.login_attempts = [] self.auth_lock = threading.Lock() class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def respond(self, data, status=200, mime='application/json'): body = json.dumps(data).encode() if mime == 'application/json' else data self.send_response(status) self.send_header('Content-Type', mime) self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-store') self.send_header('X-Content-Type-Options', 'nosniff') self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'") 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 api_token_authenticated(self): header = self.headers.get('Authorization', '') if not header.startswith('Bearer ') or len(header)>300: return False record = self.server.credentials.read() if not record or not record['api_token_hash']: return False actual = hashlib.sha256(header[7:].encode()).hexdigest() return secrets.compare_digest(actual,record['api_token_hash']) def token_route(self): return (self.command == 'GET' and self.path in ('/api/v1/status','/api/v1/hardware','/api/v1/demo')) or (self.command == 'POST' and self.path in ('/api/v1/demo/start','/api/v1/demo/stop')) def session_token(self): try: return SimpleCookie(self.headers.get('Cookie',''))['deck_session'].value except (KeyError, ValueError, CookieError): return None def authenticated(self, session_only=False): if self.headers.get('Authorization'): return not session_only and self.token_route() and self.api_token_authenticated() record = self.server.credentials.read() if not record: return False with self.server.auth_lock: session = self.server.sessions.get(self.session_token()) return bool(session and session['expires']>time.monotonic() and secrets.compare_digest(session['revision'],record['password']['hash'])) def allow_password_attempt(self): 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: self.respond({'error':'Zu viele Versuche. Bitte eine Minute warten.'},429) return False self.server.login_attempts.append(now) return True 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 not self.allow_password_attempt(): return try: data = self.read_json() with self.server.credentials.lock: record = self.server.credentials.read() if not record or not verify_password(data.get('password'),record['password']): return self.respond({'error':'Anmeldung fehlgeschlagen.'},401) token = secrets.token_urlsafe(32) now = time.monotonic() with self.server.auth_lock: self.server.sessions = {k:v for k,v in self.server.sessions.items() if v['expires']>now} if len(self.server.sessions)>=64: self.server.sessions.pop(next(iter(self.server.sessions))) self.server.sessions[token] = dict(expires=now+28800,revision=record['password']['hash']) 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 access_status(self): record = self.server.credentials.read() signed_in = self.authenticated(session_only=True) value = dict(initialized=record is not None, authenticated=signed_in) if signed_in: value.update(api_token_configured=bool(record['api_token_hash']), password_changed_at=record['password_changed_at'], token_changed_at=record['token_changed_at'], account='Administrator', location=os.environ.get('DECK_LOCATION', 'Athena' if os.environ.get('DECK_AUTH_RPC') else 'Mac')) return self.respond(value) def change_credentials(self, kind): if not self.allow_password_attempt(): return try: data = self.read_json() key = 'new_password' if kind == 'password' else 'new_token' if set(data) != {'current_password',key}: raise ValueError('Ungültige Zugangsdatenanfrage.') self.server.credentials.change(kind,data['current_password'],data[key]) if kind == 'password': with self.server.auth_lock: self.server.sessions.clear() return self.respond({'changed':True,'login_required':kind=='password'}) except ValueError as exc: return self.respond({'error':str(exc)},400) except OSError: return self.respond({'error':'Zugangsdaten konnten nicht gespeichert werden.'},503) def valid_host(self): if os.environ.get('DECK_PROXY_TOKEN'): return self.ingress() in ('lan','tunnel') allowed = {f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}'} allowed.update(filter(None,os.environ.get('DECK_ALLOWED_HOSTS','').split(','))) return self.headers.get('Host') in allowed def do_GET(self): if not self.valid_host(): return self.respond({'error': 'Host rejected'}, 403) if self.path == '/api/v1/auth/status': return self.access_status() 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'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.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.4.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena' if os.environ.get('DECK_AUTH_RPC') 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) def do_POST(self): origin = self.headers.get('Origin') bearer = self.token_route() and self.api_token_authenticated() if not self.valid_host() or (origin and origin != 'http://' + self.headers.get('Host', '')) or (not bearer and self.headers.get('X-Athena-Deck') != '1'): return self.respond({'error':'Same-origin control required'},403) if self.path == '/api/v1/auth/setup': try: data = self.read_json() if set(data) != {'password','api_token'}: raise ValueError('Kennwort und API-Token werden benötigt.') # Container installs always arrive provisioned. Never offer remote claim-on-first-use. if os.environ.get('DECK_AUTH_RPC') or os.environ.get('DECK_REQUIRE_SETUP') == '1': return self.respond({'error':'Serverzugang wird bei der Installation eingerichtet.'},403) self.server.credentials.setup(data['password'],data['api_token']) return self.respond({'initialized':True}) except ValueError as exc: return self.respond({'error':str(exc)},400) except OSError: return self.respond({'error':'Einrichtung konnte nicht gespeichert werden.'},503) if self.path == '/api/v1/login': return self.login() if not self.authenticated(): return self.respond({'error':'Anmeldung oder gültiger API-Token erforderlich.'},401) if self.path in ('/api/v1/auth/password','/api/v1/auth/token'): return self.change_credentials('password' if self.path.endswith('/password') else 'token') if self.path == '/api/v1/logout': with self.server.auth_lock: self.server.sessions.pop(self.session_token(),None) return self.respond({'logged_out':True}) 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'),data.get('api_token')) 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 result = action() self.respond(result, 503 if self.path.endswith('/start') and not result['reachable'] else 200) def main(): parser = argparse.ArgumentParser() parser.add_argument('--port', type=int, default=8108) args = parser.parse_args() server = Server(args.port) # Binding fails rather than taking over an occupied port. def shutdown(*_): threading.Thread(target=server.shutdown, daemon=True).start() signal.signal(signal.SIGTERM, shutdown) signal.signal(signal.SIGINT, shutdown) print(f'Athena Deck: http://127.0.0.1:{server.server_port}', flush=True) try: server.serve_forever() finally: server.demo.stop() server.server_close() if __name__ == '__main__': main()