#!/usr/bin/env python3 """Statischer Server mit Passwortschutz fuer die RAM-Sortierhilfe. Liefert das gebaute Ergebnis aus dist/ aus, gibt aber ohne gueltiges Passwort nichts heraus - weder HTML noch Skripte. Nutzt ausschliesslich die Python-Standardbibliothek, damit keine zusaetzliche Abhaengigkeit entsteht. Das Passwort kommt aus der Umgebungsvariable APP_PASSWORD. Fehlt sie, startet der Server absichtlich nicht: Ein vergessener Eintrag soll auffallen, statt stillschweigend eine ungeschuetzte Seite ins Netz zu stellen. """ import base64 import hmac import os import sys from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path # Absolut aufgeloest, damit es nicht darauf ankommt, aus welchem Verzeichnis # die Hosting-Umgebung den Server startet. SERVE_DIRECTORY = str(Path(__file__).resolve().parent / 'dist') HEALTH_PATH = '/healthz' REALM = 'RAM-Sortierhilfe' def read_password(): """Liest das Passwort aus der Umgebung. Fehlt es, wird der Start abgebrochen.""" password = os.environ.get('APP_PASSWORD', '') if not password: sys.stderr.write( 'FEHLER: Die Umgebungsvariable APP_PASSWORD ist nicht gesetzt.\n' 'Der Server startet nicht, damit die Seite nicht ungeschuetzt\n' 'erreichbar wird. Setze das Passwort in den VCH-Einstellungen.\n' ) sys.exit(1) return password def build_handler(password): """Erzeugt einen Handler, der das Passwort per HTTP-Basic-Auth prueft.""" expected = base64.b64encode(f':{password}'.encode('utf-8')) class ProtectedHandler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=SERVE_DIRECTORY, **kwargs) def is_authorised(self): """Prueft nur das Passwort; der Benutzername darf beliebig sein.""" header = self.headers.get('Authorization', '') if not header.startswith('Basic '): return False try: decoded = base64.b64decode(header[6:].strip(), validate=True) except (ValueError, TypeError): return False _, separator, supplied = decoded.partition(b':') if not separator: return False # Zeitkonstanter Vergleich, damit die Antwortdauer nichts verraet. return hmac.compare_digest( base64.b64encode(b':' + supplied), expected ) def demand_password(self): self.send_response(401) self.send_header('WWW-Authenticate', f'Basic realm="{REALM}", charset="UTF-8"') self.send_header('Content-Type', 'text/plain; charset=utf-8') self.send_header('Content-Length', '0') self.end_headers() def answer_health(self): """Lebenszeichen fuer die Ueberwachung - ohne Passwort, ohne Inhalt.""" body = b'ok' self.send_response(200) self.send_header('Content-Type', 'text/plain; charset=utf-8') self.send_header('Content-Length', str(len(body))) self.end_headers() if self.command != 'HEAD': self.wfile.write(body) def guard(self): """True, wenn die Anfrage weiterverarbeitet werden darf.""" if self.path.split('?', 1)[0] == HEALTH_PATH: self.answer_health() return False if not self.is_authorised(): self.demand_password() return False return True def do_GET(self): if self.guard(): super().do_GET() def do_HEAD(self): if self.guard(): super().do_HEAD() return ProtectedHandler def main(): password = read_password() port = int(os.environ.get('PORT', '8000')) server = ThreadingHTTPServer(('0.0.0.0', port), build_handler(password)) sys.stderr.write(f'RAM-Sortierhilfe laeuft auf Port {port}, Zugang nur mit Passwort.\n') try: server.serve_forever() except KeyboardInterrupt: server.server_close() if __name__ == '__main__': main()