espilon-source/tools/C3PO/streams/web_server.py
Eun0us 8b6c1cd53d ε - ChaCha20-Poly1305 AEAD + HKDF crypto upgrade + C3PO rewrite + docs
Crypto:
- Replace broken ChaCha20 (static nonce) with ChaCha20-Poly1305 AEAD
- HKDF-SHA256 key derivation from per-device factory NVS master keys
- Random 12-byte nonce per message (ESP32 hardware RNG)
- crypto_init/encrypt/decrypt API with mbedtls legacy (ESP-IDF v5.3.2)
- Custom partition table with factory NVS (fctry at 0x10000)

Firmware:
- crypto.c full rewrite, messages.c device_id prefix + AEAD encrypt
- crypto_init() at boot with esp_restart() on failure
- Fix command_t initializations across all modules (sub/help fields)
- Clean CMakeLists dependencies for ESP-IDF v5.3.2

C3PO (C2):
- Rename tools/c2 + tools/c3po -> tools/C3PO
- Per-device CryptoContext with HKDF key derivation
- KeyStore (keys.json) for master key management
- Transport parses device_id:base64(...) wire format

Tools:
- New tools/provisioning/provision.py for factory NVS key generation
- Updated flasher with mbedtls config for v5.3.2

Docs:
- Update all READMEs for new crypto, C3PO paths, provisioning
- Update roadmap, architecture diagrams, security sections
- Update CONTRIBUTING.md project structure
2026-02-10 21:28:45 +01:00

159 lines
5.2 KiB
Python

"""Flask web server for camera stream display."""
import os
import logging
import threading
from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify
from werkzeug.serving import make_server
from .config import (
WEB_HOST, WEB_PORT, FLASK_SECRET_KEY,
DEFAULT_USERNAME, DEFAULT_PASSWORD, IMAGE_DIR
)
# Disable Flask/Werkzeug request logging
logging.getLogger('werkzeug').setLevel(logging.ERROR)
class WebServer:
"""Flask-based web server for viewing camera streams."""
def __init__(self,
host: str = WEB_HOST,
port: int = WEB_PORT,
image_dir: str = IMAGE_DIR,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD):
self.host = host
self.port = port
self.image_dir = image_dir
self.username = username
self.password = password
self._app = self._create_app()
self._server = None
self._thread = None
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def _create_app(self) -> Flask:
"""Create and configure the Flask application."""
# Get the c2 root directory for templates
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
template_dir = os.path.join(c2_root, "templates")
static_dir = os.path.join(c2_root, "static")
app = Flask(__name__,
template_folder=template_dir,
static_folder=static_dir)
app.secret_key = FLASK_SECRET_KEY
# Store reference to self for route handlers
web_server = self
@app.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if username == web_server.username and password == web_server.password:
session["logged_in"] = True
return redirect(url_for("index"))
else:
error = "Invalid credentials."
return render_template("login.html", error=error)
@app.route("/logout")
def logout():
session.pop("logged_in", None)
return redirect(url_for("login"))
@app.route("/")
def index():
if not session.get("logged_in"):
return redirect(url_for("login"))
# List available camera images
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
image_files = sorted([
f for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
])
except FileNotFoundError:
image_files = []
if not image_files:
image_files = []
return render_template("index.html", image_files=image_files)
@app.route("/streams/<filename>")
def stream_image(filename):
full_image_dir = os.path.join(c2_root, web_server.image_dir)
return send_from_directory(full_image_dir, filename)
@app.route("/api/cameras")
def api_cameras():
"""API endpoint to get list of active cameras."""
if not session.get("logged_in"):
return jsonify({"error": "Unauthorized"}), 401
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
cameras = [
f.replace(".jpg", "")
for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
]
except FileNotFoundError:
cameras = []
return jsonify({"cameras": cameras})
@app.route("/api/stats")
def api_stats():
"""API endpoint for server statistics."""
if not session.get("logged_in"):
return jsonify({"error": "Unauthorized"}), 401
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
camera_count = len([
f for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
])
except FileNotFoundError:
camera_count = 0
return jsonify({
"active_cameras": camera_count,
"server_running": True
})
return app
def start(self) -> bool:
"""Start the web server in a background thread."""
if self.is_running:
return False
self._server = make_server(self.host, self.port, self._app, threaded=True)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
return True
def stop(self):
"""Stop the web server."""
if self._server:
self._server.shutdown()
self._server = None
self._thread = None
def get_url(self) -> str:
"""Get the server URL."""
return f"http://{self.host}:{self.port}"