From eca1f2c0dc6770529b550b70bfc4eade540b965d Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 10 Jul 2026 19:41:41 -0700 Subject: [PATCH] fix(mqtt): scale JWT renewal buffer with token lifetime The token's exp claim and the renewal schedule derive from the same value, so the flat 60 s RENEWAL_BUFFER was the entire margin between proactive re-auth and the broker enforcing exp on the live session - one failed renewal attempt (60 s throttle) or a minute of clock skew lost the race, seen as clean-FIN disconnects (tls=0x8008) on the waev preset, whose 55-minute tokens are the only ones short enough to hit enforcement. Buffer is now lifetime/10 clamped to [60 s, 300 s], and the disconnect-now threshold uses the same value so every renewal is a proactive reconnect on the device's schedule; waev re-auths 10 min before its real 60-minute TTL with ~5 retry windows. Document why waev's preset claims 3300 s against the broker's real 3600 s TTL: the 5-minute claim-side gap protects token acceptance against fast device clocks, which the renewal buffer cannot do. --- src/helpers/MQTTPresets.h | 5 +++ src/helpers/bridges/MQTTBridge.cpp | 60 +++++++++++++++++++++++++----- src/helpers/bridges/MQTTBridge.h | 2 + 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/helpers/MQTTPresets.h b/src/helpers/MQTTPresets.h index e32923a1..db53d43d 100644 --- a/src/helpers/MQTTPresets.h +++ b/src/helpers/MQTTPresets.h @@ -115,6 +115,11 @@ static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = { { "nz-analyzer", "wss://meshcore-mqtt-1.baird.io:443", "meshcore-mqtt-1.baird.io", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "meshmapper", "wss://mqtt.meshmapper.net:443/mqtt", "mqtt.meshmapper.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "meshrank", "mqtts://meshrank.net:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHRANK, 0, false, 0, nullptr, nullptr }, + // waev token_lifetime is 3300 (55 min) on purpose: the broker's real JWT TTL is + // 60 min, and claiming less keeps fresh tokens accepted even with ~5 min of fast + // device-clock skew (and off any exp-iat<=3600 boundary strictness). Do NOT + // "fix" this to 3600 — the renewal race is handled separately by + // tokenRenewalBufferSecs() in MQTTBridge, which renews another 5 min earlier. { "waev", "wss://mqtt.waev.app:443/mqtt", "mqtt.waev.app", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 3300, false, 55, nullptr, nullptr }, { "meshomatic", "wss://us-east.meshomatic.net:443/mqtt", "us-east.meshomatic.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "cascadiamesh", "wss://mqtt-v1.cascadiamesh.org:443/mqtt", "mqtt-v1.cascadiamesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index df85722c..aa737d98 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1486,15 +1486,19 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns bool slot_uses_jwt = (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) || (!slot.preset && slot.audience[0] != '\0'); if (slot_uses_jwt) { + // Renew (and below, reconnect) this many seconds before the token's exp + // claim. Scaled to the slot's token lifetime — see tokenRenewalBufferSecs + // for why a flat 60 s lost the renewal race against brokers that enforce + // exp on live sessions (waev's 55-minute tokens). + const unsigned long renewal_buffer = tokenRenewalBufferSecs(slotTokenLifetime(index)); bool token_needs_renewal = false; if (!time_synced) { token_needs_renewal = (slot.token_expires_at == 0); } else { - const unsigned long RENEWAL_BUFFER = 60; token_needs_renewal = (slot.token_expires_at == 0) || !(slot.token_expires_at >= 1000000000) || (current_time >= slot.token_expires_at) || - (current_time >= (slot.token_expires_at - RENEWAL_BUFFER)); + (current_time >= (slot.token_expires_at - renewal_buffer)); } // Throttle renewal attempts to once per minute @@ -1509,12 +1513,16 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns if (createSlotAuthToken(index)) { MQTT_DEBUG_PRINTLN("MQTT%d token renewed", index + 1); - const unsigned long DISCONNECT_THRESHOLD = 60; + // Bounce the connection while WE control the timing whenever the old + // token is inside the renewal buffer — waiting for the broker to + // enforce exp mid-session means a FIN plus a trip through the backoff + // ladder instead of one clean reconnect. Same buffer as the renewal + // trigger above, so a renewal implies a proactive reconnect. bool old_token_expired_or_imminent = !time_synced || (old_token_expires_at == 0) || (current_time >= old_token_expires_at) || (time_synced && old_token_expires_at >= 1000000000 && - current_time >= (old_token_expires_at - DISCONNECT_THRESHOLD)); + current_time >= (old_token_expires_at - renewal_buffer)); if (old_token_expired_or_imminent || !slot.client->connected()) { // Disconnect + reconnect with fresh credentials, reusing existing client @@ -1632,6 +1640,43 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns } } +// Effective JWT lifetime for a slot: the preset's token_lifetime (or the 24 h +// default for custom/audience slots), minus the per-slot expiry stagger that +// keeps multiple JWT slots from renewing/reconnecting simultaneously. This is +// the exact value createSlotAuthToken() puts in the token's exp claim, so the +// renewal scheduling in maintainSlotConnection() can be derived from it. +unsigned long MQTTBridge::slotTokenLifetime(int index) const { + const MQTTSlot& slot = _slots[index]; + unsigned long base_lifetime = 86400; // default 24h + if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT && slot.preset->token_lifetime > 0) { + base_lifetime = slot.preset->token_lifetime; + } + // Stagger token expiry per slot to avoid simultaneous renewal/reconnect. + // Use 5% of lifetime per slot, capped at 300s, so short-lived tokens aren't over-reduced. + unsigned long stagger = index * min((unsigned long)300, base_lifetime / 20); + return base_lifetime - stagger; +} + +// How early (seconds before the token's exp claim) to renew the token AND +// proactively bounce the connection with fresh credentials. exp and the +// renewal schedule are locked together (both derive from slotTokenLifetime), +// so this buffer is the ONLY margin between "device re-authenticates" and +// "broker enforces exp and FIN-closes the session mid-stream" — shortening a +// preset's token_lifetime moves both times together and cannot widen it. +// The old flat 60 s lost that race whenever the device clock ran slow, or a +// single renewal attempt failed (the 60 s renewal throttle then ate the whole +// margin) — observed on the waev preset, whose 55-minute tokens are the only +// ones short enough for brokers to enforce exp against a live session. +// lifetime/10 with a 60 s floor and 300 s cap: 24 h tokens renew 5 min early +// (unchanged in practice), waev renews ~5 min early with ~5 throttled retry +// windows, and degenerate short lifetimes still renew inside their validity. +unsigned long MQTTBridge::tokenRenewalBufferSecs(unsigned long lifetime_secs) { + unsigned long buffer = lifetime_secs / 10; + if (buffer < 60) buffer = 60; + if (buffer > 300) buffer = 300; + return buffer; +} + bool MQTTBridge::createSlotAuthToken(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; MQTTSlot& slot = _slots[index]; @@ -1639,10 +1684,8 @@ bool MQTTBridge::createSlotAuthToken(int index) { // Determine JWT audience: preset takes priority, then custom slot audience field const char* audience = nullptr; - unsigned long base_lifetime = 86400; // default 24h if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) { audience = slot.preset->jwt_audience; - if (slot.preset->token_lifetime > 0) base_lifetime = slot.preset->token_lifetime; } else if (slot.audience[0] != '\0') { audience = slot.audience; } @@ -1672,10 +1715,7 @@ bool MQTTBridge::createSlotAuthToken(int index) { const char* email = (_obs->mqtt_email[0] != '\0') ? _obs->mqtt_email : nullptr; unsigned long current_time = time(nullptr); - // Stagger token expiry per slot to avoid simultaneous renewal/reconnect - // Use 5% of lifetime per slot, capped at 300s, so short-lived tokens aren't over-reduced - unsigned long stagger = index * min((unsigned long)300, base_lifetime / 20); - unsigned long expires_in = base_lifetime - stagger; + unsigned long expires_in = slotTokenLifetime(index); // preset/default lifetime minus per-slot stagger bool time_synced = (current_time >= 1000000000); if (JWTHelper::createAuthToken( diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 15db1261..377ea51c 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -340,6 +340,8 @@ private: void maintainSlotConnections(); // Maintain all slot connections (token renewal, reconnect) void maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted); bool createSlotAuthToken(int index); // Create/renew JWT token for a slot + unsigned long slotTokenLifetime(int index) const; // effective JWT lifetime (preset/default minus slot stagger), seconds + static unsigned long tokenRenewalBufferSecs(unsigned long lifetime_secs); // how early to renew+reconnect before exp bool publishToSlot(int index, const char* topic, const char* payload, bool retained = false, uint8_t qos = 0); bool publishToAllSlots(const char* topic, const char* payload, bool retained = false, uint8_t qos = 0); void publishStatusToSlot(int index);