From 52bf9342df667e417555b044211afec875798c76 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 10 Jun 2026 20:20:19 +0200 Subject: [PATCH] feat: SHARED_SECRET_LEGACY migration escape hatch for plugin secret rollout Accepts one legacy secret alongside the real one so existing clients keep registering while game machines migrate to websocket_secret.txt. Remove SHARED_SECRET_LEGACY from .env after the rollout. Co-Authored-By: Claude Fable 5 --- docker-compose.yml | 3 +++ main.py | 21 ++++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index aaf4d9f3..8c7212b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,9 @@ services: DB_MAX_SQL_VARIABLES: "${DB_MAX_SQL_VARIABLES}" DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}" SHARED_SECRET: "${SHARED_SECRET}" + # Optional second secret accepted during plugin migration — remove + # from .env after rollout (see main.py SHARED_SECRET_LEGACY). + SHARED_SECRET_LEGACY: "${SHARED_SECRET_LEGACY:-}" SECRET_KEY: "${SECRET_KEY}" INVENTORY_SERVICE_URL: "http://inventory-service:8000" DISCORD_ACLOG_WEBHOOK: "${DISCORD_ACLOG_WEBHOOK:-}" diff --git a/main.py b/main.py index f54f98c3..5b5c7091 100644 --- a/main.py +++ b/main.py @@ -1003,6 +1003,17 @@ if not _SHARED_SECRET_OK: "SHARED_SECRET env var is unset or still the placeholder — " "refusing ALL plugin WebSocket connections until it is set in .env" ) +# Migration escape hatch: while game machines are being migrated to +# websocket_secret.txt, a second (legacy) secret can be accepted alongside +# the real one. REMOVE SHARED_SECRET_LEGACY from .env once the plugin +# rollout is complete — with the old placeholder in it, this re-opens the +# known-secret hole it exists to close. +SHARED_SECRET_LEGACY = os.getenv("SHARED_SECRET_LEGACY", "") +if SHARED_SECRET_LEGACY: + logger.warning( + "SHARED_SECRET_LEGACY is set — legacy plugin secret accepted during " + "migration; remove it from .env after the plugin rollout" + ) # Secret key for signing session cookies. Fail closed: running with a # publicly-known default would let anyone forge admin sessions. SECRET_KEY = os.getenv("SECRET_KEY", "") @@ -2979,9 +2990,13 @@ async def ws_receive_snapshots( # compare; refuse everything when the secret is not configured). key = secret or x_plugin_secret or "" # compare bytes: compare_digest(str, str) raises TypeError on non-ASCII - if not _SHARED_SECRET_OK or not hmac.compare_digest( - key.encode("utf-8", "replace"), SHARED_SECRET.encode("utf-8") - ): + key_b = key.encode("utf-8", "replace") + auth_ok = _SHARED_SECRET_OK and hmac.compare_digest( + key_b, SHARED_SECRET.encode("utf-8") + ) + if not auth_ok and SHARED_SECRET_LEGACY: + auth_ok = hmac.compare_digest(key_b, SHARED_SECRET_LEGACY.encode("utf-8")) + if not auth_ok: # Reject without completing the WebSocket handshake logger.warning( f"Plugin WebSocket authentication failed from {websocket.client}"