Skip to content

Commit eca1f2c

Browse files
committed
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.
1 parent c119ad2 commit eca1f2c

3 files changed

Lines changed: 57 additions & 10 deletions

File tree

src/helpers/MQTTPresets.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
115115
{ "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 },
116116
{ "meshmapper", "wss://mqtt.meshmapper.net:443/mqtt", "mqtt.meshmapper.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
117117
{ "meshrank", "mqtts://meshrank.net:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHRANK, 0, false, 0, nullptr, nullptr },
118+
// waev token_lifetime is 3300 (55 min) on purpose: the broker's real JWT TTL is
119+
// 60 min, and claiming less keeps fresh tokens accepted even with ~5 min of fast
120+
// device-clock skew (and off any exp-iat<=3600 boundary strictness). Do NOT
121+
// "fix" this to 3600 — the renewal race is handled separately by
122+
// tokenRenewalBufferSecs() in MQTTBridge, which renews another 5 min earlier.
118123
{ "waev", "wss://mqtt.waev.app:443/mqtt", "mqtt.waev.app", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 3300, false, 55, nullptr, nullptr },
119124
{ "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 },
120125
{ "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 },

src/helpers/bridges/MQTTBridge.cpp

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,15 +1486,19 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
14861486
bool slot_uses_jwt = (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) ||
14871487
(!slot.preset && slot.audience[0] != '\0');
14881488
if (slot_uses_jwt) {
1489+
// Renew (and below, reconnect) this many seconds before the token's exp
1490+
// claim. Scaled to the slot's token lifetime — see tokenRenewalBufferSecs
1491+
// for why a flat 60 s lost the renewal race against brokers that enforce
1492+
// exp on live sessions (waev's 55-minute tokens).
1493+
const unsigned long renewal_buffer = tokenRenewalBufferSecs(slotTokenLifetime(index));
14891494
bool token_needs_renewal = false;
14901495
if (!time_synced) {
14911496
token_needs_renewal = (slot.token_expires_at == 0);
14921497
} else {
1493-
const unsigned long RENEWAL_BUFFER = 60;
14941498
token_needs_renewal = (slot.token_expires_at == 0) ||
14951499
!(slot.token_expires_at >= 1000000000) ||
14961500
(current_time >= slot.token_expires_at) ||
1497-
(current_time >= (slot.token_expires_at - RENEWAL_BUFFER));
1501+
(current_time >= (slot.token_expires_at - renewal_buffer));
14981502
}
14991503

15001504
// Throttle renewal attempts to once per minute
@@ -1509,12 +1513,16 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
15091513
if (createSlotAuthToken(index)) {
15101514
MQTT_DEBUG_PRINTLN("MQTT%d token renewed", index + 1);
15111515

1512-
const unsigned long DISCONNECT_THRESHOLD = 60;
1516+
// Bounce the connection while WE control the timing whenever the old
1517+
// token is inside the renewal buffer — waiting for the broker to
1518+
// enforce exp mid-session means a FIN plus a trip through the backoff
1519+
// ladder instead of one clean reconnect. Same buffer as the renewal
1520+
// trigger above, so a renewal implies a proactive reconnect.
15131521
bool old_token_expired_or_imminent = !time_synced ||
15141522
(old_token_expires_at == 0) ||
15151523
(current_time >= old_token_expires_at) ||
15161524
(time_synced && old_token_expires_at >= 1000000000 &&
1517-
current_time >= (old_token_expires_at - DISCONNECT_THRESHOLD));
1525+
current_time >= (old_token_expires_at - renewal_buffer));
15181526

15191527
if (old_token_expired_or_imminent || !slot.client->connected()) {
15201528
// Disconnect + reconnect with fresh credentials, reusing existing client
@@ -1632,17 +1640,52 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
16321640
}
16331641
}
16341642

1643+
// Effective JWT lifetime for a slot: the preset's token_lifetime (or the 24 h
1644+
// default for custom/audience slots), minus the per-slot expiry stagger that
1645+
// keeps multiple JWT slots from renewing/reconnecting simultaneously. This is
1646+
// the exact value createSlotAuthToken() puts in the token's exp claim, so the
1647+
// renewal scheduling in maintainSlotConnection() can be derived from it.
1648+
unsigned long MQTTBridge::slotTokenLifetime(int index) const {
1649+
const MQTTSlot& slot = _slots[index];
1650+
unsigned long base_lifetime = 86400; // default 24h
1651+
if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT && slot.preset->token_lifetime > 0) {
1652+
base_lifetime = slot.preset->token_lifetime;
1653+
}
1654+
// Stagger token expiry per slot to avoid simultaneous renewal/reconnect.
1655+
// Use 5% of lifetime per slot, capped at 300s, so short-lived tokens aren't over-reduced.
1656+
unsigned long stagger = index * min((unsigned long)300, base_lifetime / 20);
1657+
return base_lifetime - stagger;
1658+
}
1659+
1660+
// How early (seconds before the token's exp claim) to renew the token AND
1661+
// proactively bounce the connection with fresh credentials. exp and the
1662+
// renewal schedule are locked together (both derive from slotTokenLifetime),
1663+
// so this buffer is the ONLY margin between "device re-authenticates" and
1664+
// "broker enforces exp and FIN-closes the session mid-stream" — shortening a
1665+
// preset's token_lifetime moves both times together and cannot widen it.
1666+
// The old flat 60 s lost that race whenever the device clock ran slow, or a
1667+
// single renewal attempt failed (the 60 s renewal throttle then ate the whole
1668+
// margin) — observed on the waev preset, whose 55-minute tokens are the only
1669+
// ones short enough for brokers to enforce exp against a live session.
1670+
// lifetime/10 with a 60 s floor and 300 s cap: 24 h tokens renew 5 min early
1671+
// (unchanged in practice), waev renews ~5 min early with ~5 throttled retry
1672+
// windows, and degenerate short lifetimes still renew inside their validity.
1673+
unsigned long MQTTBridge::tokenRenewalBufferSecs(unsigned long lifetime_secs) {
1674+
unsigned long buffer = lifetime_secs / 10;
1675+
if (buffer < 60) buffer = 60;
1676+
if (buffer > 300) buffer = 300;
1677+
return buffer;
1678+
}
1679+
16351680
bool MQTTBridge::createSlotAuthToken(int index) {
16361681
if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false;
16371682
MQTTSlot& slot = _slots[index];
16381683
if (!_identity) return false;
16391684

16401685
// Determine JWT audience: preset takes priority, then custom slot audience field
16411686
const char* audience = nullptr;
1642-
unsigned long base_lifetime = 86400; // default 24h
16431687
if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) {
16441688
audience = slot.preset->jwt_audience;
1645-
if (slot.preset->token_lifetime > 0) base_lifetime = slot.preset->token_lifetime;
16461689
} else if (slot.audience[0] != '\0') {
16471690
audience = slot.audience;
16481691
}
@@ -1672,10 +1715,7 @@ bool MQTTBridge::createSlotAuthToken(int index) {
16721715
const char* email = (_obs->mqtt_email[0] != '\0') ? _obs->mqtt_email : nullptr;
16731716

16741717
unsigned long current_time = time(nullptr);
1675-
// Stagger token expiry per slot to avoid simultaneous renewal/reconnect
1676-
// Use 5% of lifetime per slot, capped at 300s, so short-lived tokens aren't over-reduced
1677-
unsigned long stagger = index * min((unsigned long)300, base_lifetime / 20);
1678-
unsigned long expires_in = base_lifetime - stagger;
1718+
unsigned long expires_in = slotTokenLifetime(index); // preset/default lifetime minus per-slot stagger
16791719
bool time_synced = (current_time >= 1000000000);
16801720

16811721
if (JWTHelper::createAuthToken(

src/helpers/bridges/MQTTBridge.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ class MQTTBridge : public BridgeBase {
340340
void maintainSlotConnections(); // Maintain all slot connections (token renewal, reconnect)
341341
void maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted);
342342
bool createSlotAuthToken(int index); // Create/renew JWT token for a slot
343+
unsigned long slotTokenLifetime(int index) const; // effective JWT lifetime (preset/default minus slot stagger), seconds
344+
static unsigned long tokenRenewalBufferSecs(unsigned long lifetime_secs); // how early to renew+reconnect before exp
343345
bool publishToSlot(int index, const char* topic, const char* payload, bool retained = false, uint8_t qos = 0);
344346
bool publishToAllSlots(const char* topic, const char* payload, bool retained = false, uint8_t qos = 0);
345347
void publishStatusToSlot(int index);

0 commit comments

Comments
 (0)