diff --git a/frontend-react/src/App.jsx b/frontend-react/src/App.jsx index 46f9cf4..20f2b2d 100644 --- a/frontend-react/src/App.jsx +++ b/frontend-react/src/App.jsx @@ -37,6 +37,12 @@ function _ch(url) { } // ── API helpers ──────────────────────────────────────────────── +let _authPin = ''; + +function _authHeaders() { + return _authPin ? { 'X-Pedal-Auth': _authPin } : {}; +} + async function apiGet(path) { const r = await fetch(`${API}${_ch(path)}`); if (!r.ok) throw new Error(`${path} => ${r.status}`); @@ -45,7 +51,7 @@ async function apiGet(path) { async function apiPost(path, data) { const r = await fetch(`${API}${_ch(path)}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', ..._authHeaders() }, body: data != null ? JSON.stringify(data) : undefined, }); if (!r.ok) throw new Error(`${path} => ${r.status}`); @@ -54,7 +60,7 @@ async function apiPost(path, data) { async function apiPut(path, data) { const r = await fetch(`${API}${_ch(path)}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, + method: 'PUT', headers: { 'Content-Type': 'application/json', ..._authHeaders() }, body: JSON.stringify(data), }); if (!r.ok) throw new Error(`${path} => ${r.status}`); @@ -62,7 +68,7 @@ async function apiPut(path, data) { } async function apiDelete(path) { - const r = await fetch(`${API}${_ch(path)}`, { method: 'DELETE' }); + const r = await fetch(`${API}${_ch(path)}`, { method: 'DELETE', headers: _authHeaders() }); if (!r.ok) throw new Error(`${path} => ${r.status}`); return r.json(); } @@ -70,7 +76,7 @@ async function apiDelete(path) { async function apiUpload(path, file) { const fd = new FormData(); fd.append('file', file); - const r = await fetch(`${API}${_ch(path)}`, { method: 'POST', body: fd }); + const r = await fetch(`${API}${_ch(path)}`, { method: 'POST', body: fd, headers: _authHeaders() }); if (!r.ok) throw new Error(`${path} => ${r.status}`); return r.json(); } @@ -84,7 +90,7 @@ function usePedalState() { useEffect(() => { // Fetch initial state - apiGet('/api/state').then(s => { setState(s); setConnected(true); }).catch(() => { setConnected(false); }); + apiGet('/api/state').then(s => { setState(s); setConnected(true); _authPin = s.auth_pin || ''; }).catch(() => { setConnected(false); }); // WebSocket for real-time updates let reconnectTimer; diff --git a/patch_all.py b/patch_all.py new file mode 100644 index 0000000..8e2f084 --- /dev/null +++ b/patch_all.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""Apply ALL auth + HTTPS patches to the pedal codebase in one shot.""" +import hashlib, logging, os, re +from pathlib import Path + +BASE = Path("/home/oplabs/projects/pi-multifx-pedal") + +# ── 1. Create src/web/auth.py ───────────────────────────────────────── +auth_py = '''"""Authentication layer for the Pi Multi-FX Pedal web interface. + +Provides a shared-secret PIN authentication model: + +- PIN stored in ``~/.pedal/auth.pin`` as a PBKDF2-SHA256 hash. +- On first boot (no PIN file), the ``/api/auth/setup`` endpoint is open + for exactly one call to set an initial PIN. +- All other API endpoints require ``X-Auth-Token: `` in the request + header (or ``token`` query parameter for WebSocket). +- Failed auth returns 401 with a generic message. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import secrets +from pathlib import Path + +from fastapi import HTTPException, Request, status + +logger = logging.getLogger(__name__) + +# ── Paths ───────────────────────────────────────────────────────────────────── + +AUTH_DIR=*** / ".pedal" +AUTH_FILE=*** / "auth.pin" + +# ── PIN hashing (PBKDF2-SHA256, no bcrypt dependency needed) ───────────────── + + +def _hash_pin(pin: str) -> str: + """Return a PBKDF2-SHA256 hex digest with random salt. + Format: ``$`` + """ + salt = secrets.token_hex(16) + h = hashlib.pbkdf2_hmac("sha256", pin.encode(), salt.encode(), 100_000) + return f"{salt}${h.hex()}" + + +def _verify_pin(pin: str, stored: str) -> bool: + """Verify *pin* against a stored ``salt$hash`` string.""" + if "$" not in stored: + return False + salt, expected = stored.split("$", 1) + h = hashlib.pbkdf2_hmac("sha256", pin.encode(), salt.encode(), 100_000) + return hmac.compare_digest(h.hex(), expected) + + +# ── Public API ─────────────────────────────────────────────────────────────── + + +def is_auth_configured() -> bool: + """Return ``True`` if a PIN has been set (auth is active).""" + return AUTH_FILE.exists() + + +def set_pin(pin: str) -> None: + """Store a new PIN hash. + Args: + pin: The PIN to store (4-32 characters). + Raises: + ValueError: If the PIN is too short or too long. + """ + if len(pin) < 4: + raise ValueError("PIN must be at least 4 characters") + if len(pin) > 64: + raise ValueError("PIN must be at most 64 characters") + AUTH_DIR.mkdir(parents=True, exist_ok=True) + hashed = _hash_pin(pin) + AUTH_FILE.write_text(hashed) + AUTH_FILE.chmod(0o600) + logger.info("Auth PIN set (file: %s)", AUTH_FILE) + + +def verify_token(token: str) -> bool: + """Check whether *token* matches the stored PIN. + Returns ``True`` if *token* is the correct PIN. + If no PIN is configured yet, returns ``True`` (no auth required). + """ + if not is_auth_configured(): + return True + try: + stored = AUTH_FILE.read_text().strip() + return _verify_pin(token, stored) + except Exception: + return False + + +# ── FastAPI dependency (for per-endpoint use, kept for backward compat) ─────── + + +async def require_auth(request: Request) -> None: + """FastAPI dependency — reject with 401 if the request lacks a valid token.""" + if not is_auth_configured(): + return + token: str | None = request.headers.get("X-Auth-Token") + if not token: + token = request.query_params.get("token") + if not token or not verify_token(token): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required. Set X-Auth-Token header to your pedal PIN.", + headers={"WWW-Authenticate": "Bearer"}, + ) +''' + +(BASE / "src" / "web" / "auth.py").write_text(auth_py) +print("1. Created src/web/auth.py") + +# ── 2. Patch server.py ───────────────────────────────────────────────── +sp = (BASE / "src" / "web" / "server.py").read_text() + +changes = 0 + +# 2a. Add auth import + _safe_detail after MIDIHandler import +old = "from ..midi.handler import MIDIHandler\n\nlogger = logging.getLogger(__name__)" +new = """from ..midi.handler import MIDIHandler + +from .auth import is_auth_configured, set_pin, verify_token, require_auth + +logger = logging.getLogger(__name__) + + +# ── Exception sanitization ──────────────────────────────────────────────────── + + +def _safe_detail(log: logging.Logger, exc: Exception, status_code: int = 500) -> str: + \"\"\"Log full exception server-side, return generic message to client.\"\"\" + log.warning("API error (HTTP %%d): %%s", status_code, exc, exc_info=True) + return "Internal server error\"""" + +assert old in sp, "2a: pattern not found" +sp = sp.replace(old, new, 1) +changes += 1 +print("2a. Import + _safe_detail added") + +# 2b. Add auth middleware after app = FastAPI(...) +old = ' app = FastAPI(title="Pi Multi-FX Pedal", version="0.1.0", lifespan=lifespan)' +new = ''' app = FastAPI(title="Pi Multi-FX Pedal", version="0.1.0", lifespan=lifespan) + + # ── Auth middleware ─────────────────────────────────────────── + + @app.middleware("http") + async def auth_middleware(request: Request, call_next): + public_paths = {"/", "/api/auth/setup", "/api/auth/status"} + if request.url.path in public_paths or request.url.path.startswith("/static/"): + return await call_next(request) + if not request.url.path.startswith("/api/"): + return await call_next(request) + if not is_auth_configured(): + return await call_next(request) + token: str | None = request.headers.get("X-Auth-Token") + if not token: + token = request.query_params.get("token") + if not token or not verify_token(token): + return JSONResponse( + status_code=401, + content={"detail": "Authentication required. Set X-Auth-Token header to your pedal PIN."}, + ) + return await call_next(request)''' + +assert old in sp, "2b: pattern not found" +sp = sp.replace(old, new, 1) +changes += 1 +print("2b. Auth middleware added") + +# 2c. Add auth endpoints before REST API section +old = " # ── REST API ────────────────────────────────────────────────" +new = ''' # ── Auth endpoints (no token required) ───────────────────── + + @app.get("/api/auth/status") + async def auth_status(): + """Check whether authentication is configured.""" + return {"auth_configured": is_auth_configured()} + + @app.post("/api/auth/setup") + async def auth_setup(data: dict): + """Set the pedal PIN on first boot. + + This endpoint is open exactly once — after the PIN is set, + all API calls require ``X-Auth-Token: ``. + + Body: { "pin": "1234" } + """ + if is_auth_configured(): + raise HTTPException(status_code=400, detail="PIN already configured") + pin = data.get("pin", "") + try: + set_pin(pin) + return {"ok": True, "message": "Authentication PIN set"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # ── REST API ────────────────────────────────────────────────''' + +assert old in sp, "2c: pattern not found" +sp = sp.replace(old, new, 1) +changes += 1 +print("2c. Auth endpoints added") + +# 2d. Add WebSocket auth +old = ''' @app.websocket("/ws") + async def websocket_endpoint(ws: WebSocket): + await self._manager.connect(ws) + logger.info("WebSocket client connected (%d active)", self._manager.active_count) + # Track per-connection channel preference''' +new = ''' @app.websocket("/ws") + async def websocket_endpoint(ws: WebSocket): + # Require auth for WebSocket if PIN is configured + if is_auth_configured(): + ws_token = ws.query_params.get("token") + if not ws_token or not verify_token(ws_token): + await ws.close(code=4001, reason="Authentication required") + return + await self._manager.connect(ws) + logger.info("WebSocket client connected (%d active)", self._manager.active_count) + # Track per-connection channel preference''' + +assert old in sp, "2d: pattern not found" +sp = sp.replace(old, new, 1) +changes += 1 +print("2d. WebSocket auth added") + +# 2e. Replace all detail=str(e) with _safe_detail +count = sp.count("detail=str(e)") +assert count > 0, "2e: no detail=str(e) found" +sp = sp.replace("detail=str(e)", "detail=_safe_detail(logger, e)") +changes += 1 +print(f"2e. {count} detail=str(e) replaced with _safe_detail") + +# 2f. Update __init__ for SSL params +old = """ def __init__( + self, + deps: WebServerDeps, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + ) -> None: + self.deps = deps + self.host = host + self.port = port""" +new = """ def __init__( + self, + deps: WebServerDeps, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + ssl_certfile: Optional[str] = None, + ssl_keyfile: Optional[str] = None, + ) -> None: + self.deps = deps + self.host = host + self.port = port + self.ssl_certfile = ssl_certfile + self.ssl_keyfile = ssl_keyfile""" + +assert old in sp, "2f: pattern not found" +sp = sp.replace(old, new, 1) +changes += 1 +print("2f. __init__ SSL params added") + +# 2g. Update start() for SSL +old = """ import threading + config = uvicorn.Config( + self._app, + host=self.host, + port=self.port, + log_level="info", + access_log=False, + )""" +new = """ import threading + ssl_kw = {} + if self.ssl_certfile and self.ssl_keyfile: + ssl_kw["ssl_certfile"] = self.ssl_certfile + ssl_kw["ssl_keyfile"] = self.ssl_keyfile + config = uvicorn.Config( + self._app, + host=self.host, + port=self.port, + log_level="info", + access_log=False, + **ssl_kw, + )""" + +assert old in sp, "2g: pattern not found" +sp = sp.replace(old, new, 1) +changes += 1 +print("2g. start() SSL support added") + +# 2h. Update log message +old = """ logger.info("Web server starting on http://%s:%d", self.host, self.port)""" +new = """ proto = "https" if self.ssl_certfile else "http" + logger.info("Web server starting on %s://%s:%d", proto, self.host, self.port)""" + +assert old in sp, "2h: pattern not found" +sp = sp.replace(old, new, 1) +changes += 1 +print("2h. Log message updated") + +(BASE / "src" / "web" / "server.py").write_text(sp) +print(f"\nserver.py: {changes} patches applied") + +# ── 3. Patch main.py ────────────────────────────────────────────────── +mp = (BASE / "main.py").read_text() + +old = """ self.web = WebServer( + deps=WebServerDeps( + presets=self.presets, + pipeline=self.pipeline, + nam_host=self.nam_host, + ir_loader=self.ir_loader, + audio_system=self.audio_system, + jack_audio=self.jack_audio, + config=self._config, + config_path=self._config_path, + ), + host="0.0.0.0", + port=80, + ) + self.web.start() + logger.info("Web UI server started on http://0.0.0.0:80")""" + +new = """ web_cfg = self._config.get("web", {}) + web_host = web_cfg.get("host", "0.0.0.0") + web_port = int(web_cfg.get("port", 80)) + ssl_cert = web_cfg.get("ssl_certfile") or None + ssl_key = web_cfg.get("ssl_keyfile") or None + self.web = WebServer( + deps=WebServerDeps( + presets=self.presets, + pipeline=self.pipeline, + nam_host=self.nam_host, + ir_loader=self.ir_loader, + audio_system=self.audio_system, + jack_audio=self.jack_audio, + config=self._config, + config_path=self._config_path, + ), + host=web_host, + port=web_port, + ssl_certfile=ssl_cert, + ssl_keyfile=ssl_key, + ) + self.web.start() + proto = "https" if ssl_cert else "http" + logger.info("Web UI server started on %s://%s:%d", proto, web_host, web_port)""" + +assert old in mp, "3: pattern not found" +mp = mp.replace(old, new, 1) +(BASE / "main.py").write_text(mp) +print("3. main.py: web config + SSL support added") + +# ── Final summary ────────────────────────────────────────────────────── +print("\n═══ VERIFICATION ═══") +for f in ["src/web/auth.py", "src/web/server.py", "main.py"]: + p = BASE / f + m = hashlib.md5(p.read_bytes()).hexdigest() + sz = p.stat().st_size + print(f" {f}: {sz} bytes, md5={m}") + +# Check for expected content +sp_check = (BASE / "src/web/server.py").read_text() +assert "from .auth import" in sp_check, "FAIL: auth import missing" +assert "_safe_detail" in sp_check, "FAIL: _safe_detail missing" +assert "auth_middleware" in sp_check, "FAIL: auth_middleware missing" +assert "is_auth_configured()" in sp_check, "FAIL: is_auth_configured missing" +assert "detail=_safe_detail(logger, e)" in sp_check, "FAIL: _safe_detail calls missing" +assert "ssl_certfile" in sp_check, "FAIL: ssl_certfile missing" +assert sp_check.count("detail=str(e)") == 0, "FAIL: remaining str(e) leaks" +print("\n✅ All patches verified and persisted!") diff --git a/patch_final.py b/patch_final.py new file mode 100644 index 0000000..e4d947f --- /dev/null +++ b/patch_final.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Apply all remaining patches to server.py""" +import os, re + +path = "/home/oplabs/projects/pi-multifx-pedal/src/web/server.py" +content = open(path).read() +orig_md5 = __import__('hashlib').md5(content.encode()).hexdigest() +print(f"File: {len(content)} chars, md5={orig_md5}") + +# 1. Replace detail=str(e) with _safe_detail +old = "detail=str(e)" +new = "detail=_safe_detail(logger, e)" +count = content.count(old) +content = content.replace(old, new) +print(f"1. Replaced {count} detail=str(e) -> _safe_detail") + +# 2. Add SSL params to __init__ +old = """ def __init__( + self, + deps: WebServerDeps, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + ) -> None: + self.deps = deps + self.host = host + self.port = port""" +new = """ def __init__( + self, + deps: WebServerDeps, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + ssl_certfile: Optional[str] = None, + ssl_keyfile: Optional[str] = None, + ) -> None: + self.deps = deps + self.host = host + self.port = port + self.ssl_certfile = ssl_certfile + self.ssl_keyfile = ssl_keyfile""" +assert old in content, "2: init pattern not found" +content = content.replace(old, new, 1) +print("2. SSL params added to __init__") + +# 3. Update start() for SSL +old = """ import threading + config = uvicorn.Config( + self._app, + host=self.host, + port=self.port, + log_level="info", + access_log=False, + )""" +new = """ import threading + ssl_kw = {} + if self.ssl_certfile and self.ssl_keyfile: + ssl_kw["ssl_certfile"] = self.ssl_certfile + ssl_kw["ssl_keyfile"] = self.ssl_keyfile + config = uvicorn.Config( + self._app, + host=self.host, + port=self.port, + log_level="info", + access_log=False, + **ssl_kw, + )""" +assert old in content, "3: start pattern not found" +content = content.replace(old, new, 1) +print("3. SSL support added to start()") + +# 4. Update log message +old = ' logger.info("Web server starting on http://%s:%d", self.host, self.port)' +new = """ proto = "https" if self.ssl_certfile else "http" + logger.info("Web server starting on %s://%s:%d", proto, self.host, self.port)""" +assert old in content, "4: log pattern not found" +content = content.replace(old, new, 1) +print("4. Log message updated") + +# Write to a temp file first +tmp = "/tmp/server_patched.py" +open(tmp, "w").write(content) + +# Verify tmp has the changes +with open(tmp) as f: + tmp_content = f.read() +print(f"Written to {tmp}: {len(tmp_content)} chars") +print(f"Has _safe_detail: {tmp_content.count('_safe_detail')}") +print(f"Has ssl_certfile: {tmp_content.count('ssl_certfile')}") +print(f"Still has detail=str(e): {tmp_content.count('detail=str(e)')}") + +# Now copy over the original +import shutil +shutil.copy2(tmp, path) +print(f"Copied to {path}") + +# Final check +with open(path) as f: + final = f.read() +print(f"Final md5={__import__('hashlib').md5(final.encode()).hexdigest()}") +print(f"Final _safe_detail count: {final.count('_safe_detail')}") +print(f"Final ssl_certfile count: {final.count('ssl_certfile')}") diff --git a/patch_server.py b/patch_server.py new file mode 100644 index 0000000..34ff0e1 --- /dev/null +++ b/patch_server.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Apply auth patches to server.py — run this script.""" +import hashlib, os + +path = "/home/oplabs/projects/pi-multifx-pedal/src/web/server.py" +content = open(path).read() +print(f"BEFORE: md5={hashlib.md5(content.encode()).hexdigest()}, size={len(content)}") + +changes = [] + +# 2a. Add auth import + _safe_detail after MIDIHandler import +old = "from ..midi.handler import MIDIHandler\n\nlogger = logging.getLogger(__name__)" +new = """from ..midi.handler import MIDIHandler + +from .auth import is_auth_configured, set_pin, verify_token, require_auth + +logger = logging.getLogger(__name__) + + +# ── Exception sanitization ──────────────────────────────────────────────────── + + +def _safe_detail(log: logging.Logger, exc: Exception, status_code: int = 500) -> str: + \"\"\"Log full exception server-side, return generic message to client.\"\"\" + log.warning("API error (HTTP %%d): %%s", status_code, exc, exc_info=True) + return "Internal server error\"""" + +assert old in content, "2a: pattern not found" +content = content.replace(old, new, 1) +changes.append("2a. Import + _safe_detail") +print(f"2a OK. size={len(content)}") + +# 2b. Auth middleware after app = FastAPI(...) +old = ' app = FastAPI(title="Pi Multi-FX Pedal", version="0.1.0", lifespan=lifespan)' +new = ''' app = FastAPI(title="Pi Multi-FX Pedal", version="0.1.0", lifespan=lifespan) + + # ── Auth middleware ─────────────────────────────────────────── + + @app.middleware("http") + async def auth_middleware(request: Request, call_next): + public_paths = {"/", "/api/auth/setup", "/api/auth/status"} + if request.url.path in public_paths or request.url.path.startswith("/static/"): + return await call_next(request) + if not request.url.path.startswith("/api/"): + return await call_next(request) + if not is_auth_configured(): + return await call_next(request) + token: str | None = request.headers.get("X-Auth-Token") + if not token: + token = request.query_params.get("token") + if not token or not verify_token(token): + return JSONResponse( + status_code=401, + content={"detail": "Authentication required. Set X-Auth-Token header to your pedal PIN."}, + ) + return await call_next(request)''' + +assert old in content, "2b: pattern not found" +content = content.replace(old, new, 1) +changes.append("2b. Auth middleware") +print(f"2b OK. size={len(content)}") + +# 2c. Auth endpoints +old = " # ── REST API ────────────────────────────────────────────────" +new = ''' # ── Auth endpoints (no token required) ───────────────────── + + @app.get("/api/auth/status") + async def auth_status(): + """Check whether authentication is configured.""" + return {"auth_configured": is_auth_configured()} + + @app.post("/api/auth/setup") + async def auth_setup(data: dict): + """Set the pedal PIN on first boot. + This endpoint is open exactly once — after the PIN is set, + all API calls require ``X-Auth-Token: ``. + Body: { "pin": "1234" } + """ + if is_auth_configured(): + raise HTTPException(status_code=400, detail="PIN already configured") + pin = data.get("pin", "") + try: + set_pin(pin) + return {"ok": True, "message": "Authentication PIN set"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # ── REST API ────────────────────────────────────────────────''' + +assert old in content, "2c: pattern not found" +content = content.replace(old, new, 1) +changes.append("2c. Auth endpoints") +print(f"2c OK. size={len(content)}") + +# 2d. WebSocket auth +old = ''' @app.websocket("/ws") + async def websocket_endpoint(ws: WebSocket): + await self._manager.connect(ws) + logger.info("WebSocket client connected (%d active)", self._manager.active_count) + # Track per-connection channel preference''' +new = ''' @app.websocket("/ws") + async def websocket_endpoint(ws: WebSocket): + # Require auth for WebSocket if PIN is configured + if is_auth_configured(): + ws_token = ws.query_params.get("token") + if not ws_token or not verify_token(ws_token): + await ws.close(code=4001, reason="Authentication required") + return + await self._manager.connect(ws) + logger.info("WebSocket client connected (%d active)", self._manager.active_count) + # Track per-connection channel preference''' + +assert old in content, "2d: pattern not found" +content = content.replace(old, new, 1) +changes.append("2d. WebSocket auth") +print(f"2d OK. size={len(content)}") + +# 2e. Replace detail=str(e) +count = content.count("detail=str(e)") +assert count > 0, "2e: no detail=str(e)" +content = content.replace("detail=str(e)", "detail=_safe_detail(logger, e)") +changes.append(f"2e. {count}x detail=str(e) replaced") +print(f"2e OK. size={len(content)}") + +# 2f. __init__ SSL params +old = """ def __init__( + self, + deps: WebServerDeps, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + ) -> None: + self.deps = deps + self.host = host + self.port = port""" +new = """ def __init__( + self, + deps: WebServerDeps, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + ssl_certfile: Optional[str] = None, + ssl_keyfile: Optional[str] = None, + ) -> None: + self.deps = deps + self.host = host + self.port = port + self.ssl_certfile = ssl_certfile + self.ssl_keyfile = ssl_keyfile""" + +assert old in content, "2f: pattern not found" +content = content.replace(old, new, 1) +changes.append("2f. __init__ SSL params") +print(f"2f OK. size={len(content)}") + +# 2g. start() SSL +old = """ import threading + config = uvicorn.Config( + self._app, + host=self.host, + port=self.port, + log_level="info", + access_log=False, + )""" +new = """ import threading + ssl_kw = {} + if self.ssl_certfile and self.ssl_keyfile: + ssl_kw["ssl_certfile"] = self.ssl_certfile + ssl_kw["ssl_keyfile"] = self.ssl_keyfile + config = uvicorn.Config( + self._app, + host=self.host, + port=self.port, + log_level="info", + access_log=False, + **ssl_kw, + )""" + +assert old in content, "2g: pattern not found" +content = content.replace(old, new, 1) +changes.append("2g. start() SSL") +print(f"2g OK. size={len(content)}") + +# 2h. Log message +old = ' logger.info("Web server starting on http://%s:%d", self.host, self.port)' +new = """ proto = "https" if self.ssl_certfile else "http" + logger.info("Web server starting on %s://%s:%d", proto, self.host, self.port)""" + +assert old in content, "2h: pattern not found" +content = content.replace(old, new, 1) +changes.append("2h. Log message") +print(f"2h OK. size={len(content)}") + +# Write to a NEW file to test write capability +test_path = "/home/oplabs/projects/pi-multifx-pedal/src/web/server_patched.py" +open(test_path, "w").write(content) +print(f"\nWritten to {test_path}") +print(f"AFTER: md5={hashlib.md5(content.encode()).hexdigest()}") +print(f"Changes: {', '.join(changes)}") + +# Verify it compiled +import py_compile +try: + py_compile.compile(test_path, doraise=True) + print("✅ Syntax check passed!") +except py_compile.PyCompileError as e: + print(f"❌ Syntax error: {e}") diff --git a/src/web/server.py b/src/web/server.py index 6849ded..8a8dc11 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -2910,6 +2910,7 @@ iface eth0 inet static "bluetooth": self._gather_bt_state(), "current_snapshot": getattr(pm, '_current_snapshot', 0) if pm else 0, "needs_reboot": self._needs_reboot, + "auth_pin": self._get_auth_pin() or "", } def _gather_state(self, channel: Optional[str] = None) -> dict[str, Any]: