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.
This commit is contained in:
agessaman
2026-07-10 19:41:41 -07:00
parent c119ad2ab9
commit eca1f2c0dc
3 changed files with 57 additions and 10 deletions
+5
View File
@@ -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 },
+50 -10
View File
@@ -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(
+2
View File
@@ -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);