mirror of
https://github.com/agessaman/MeshCore.git
synced 2026-07-11 20:48:49 +00:00
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:
@@ -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 },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user