@alteriom/painlessmesh 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -568,6 +568,8 @@ class InternetHealthChecker {
568
568
  * @return true if connection succeeded
569
569
  */
570
570
  bool checkNow() {
571
+ // A full probe answers the question the on-demand budget exists for.
572
+ onDemandSpent_ = false;
571
573
  status_.checkCount++;
572
574
  status_.lastCheckTime = millis();
573
575
 
@@ -592,6 +594,27 @@ class InternetHealthChecker {
592
594
  return connected;
593
595
  }
594
596
 
597
+ /**
598
+ * @brief Probe once outside the schedule, for a caller that needs the
599
+ * answer now (issue #450)
600
+ *
601
+ * A bridge's first periodic probe runs while its station is still
602
+ * associating and fails, and the next is a full interval away. A send in
603
+ * that window may spend one extra probe to learn that the uplink has come
604
+ * up since. One: the probe is a blocking connect with a timeout, so the
605
+ * budget is a single on-demand probe per periodic one. A node whose uplink
606
+ * really is down pays it once per interval, not on every call.
607
+ *
608
+ * @return true if Internet is reachable now
609
+ */
610
+ bool checkOnDemand() {
611
+ if (status_.available) return true;
612
+ if (onDemandSpent_) return false;
613
+ const bool connected = checkNow();
614
+ onDemandSpent_ = true;
615
+ return connected;
616
+ }
617
+
595
618
  /**
596
619
  * @brief Get check interval
597
620
  * @return Check interval in milliseconds
@@ -667,6 +690,13 @@ class InternetHealthChecker {
667
690
  status_.lastError = "Mock: No Internet in test environment";
668
691
  return false;
669
692
  #elif defined(ESP32) || defined(ESP8266)
693
+ // No station, no uplink: answer at once instead of spending the connect
694
+ // timeout on a link that does not exist. This is what keeps the
695
+ // on-demand probe cheap on a bridge whose router is down.
696
+ if (WiFi.status() != WL_CONNECTED) {
697
+ status_.lastError = "Station not connected";
698
+ return false;
699
+ }
670
700
  WiFiClient client;
671
701
  uint32_t started = millis();
672
702
  #ifdef ESP32
@@ -704,6 +734,7 @@ class InternetHealthChecker {
704
734
  // State
705
735
  InternetStatus status_;
706
736
  InternetChangedCallback_t connectivityChangedCallback_;
737
+ bool onDemandSpent_ = false;
707
738
 
708
739
  #ifdef PAINLESSMESH_BOOST
709
740
  bool mockConnected_ = false;
@@ -965,6 +996,301 @@ class GatewayDataPackage : public plugin::SinglePackage {
965
996
 
966
997
  };
967
998
 
999
+ /**
1000
+ * @brief The outcome of a gateway HTTP request, classified for the ack
1001
+ *
1002
+ * HTTPClient::GET()/POST() return an int with two distinct meanings: a
1003
+ * positive value is an HTTP status code from the destination, while a
1004
+ * negative value is one of the client's own transport errors (HTTPC_ERROR_*,
1005
+ * e.g. -1 for a refused connection or -11 for a read timeout). Zero is not
1006
+ * produced by either.
1007
+ *
1008
+ * GatewayAckPackage::httpStatus is a uint16_t, so a negative code cannot be
1009
+ * forwarded as-is. Transport errors are reported as httpStatus 0, which is
1010
+ * what Mesh::handleGatewayAck() already treats as a retryable network error;
1011
+ * the human-readable cause travels in GatewayAckPackage::error instead.
1012
+ */
1013
+ struct HttpRequestOutcome {
1014
+ /** True only for status codes that indicate genuine delivery. */
1015
+ bool success = false;
1016
+
1017
+ /** Value to place in GatewayAckPackage::httpStatus (0 for transport errors). */
1018
+ uint16_t ackStatus = 0;
1019
+
1020
+ /** True when the client failed before any HTTP status was received. */
1021
+ bool transportError = false;
1022
+
1023
+ /**
1024
+ * True when the status was success-class but the response body said the
1025
+ * service refused the request (issue #450).
1026
+ */
1027
+ bool refusedByBody = false;
1028
+
1029
+ /**
1030
+ * True for a 2xx the gateway cannot vouch for: anything but 200, 201, 202
1031
+ * and 204. CallMeBot answers HTTP 208 to a message that never arrives
1032
+ * (issue #452), so a status outside the verified set is reported as a
1033
+ * failure that carries the body, never as a delivery.
1034
+ */
1035
+ bool unverifiedStatus = false;
1036
+
1037
+ /**
1038
+ * One-line reason for a failure, taken from the response body when there
1039
+ * is one; empty on success. Sized to fit a GatewayAckPackage::error.
1040
+ */
1041
+ TSTRING reason;
1042
+ };
1043
+
1044
+ /** Longest response-body excerpt carried in an acknowledgment error. */
1045
+ static const size_t GATEWAY_RESPONSE_REASON_MAX = 120;
1046
+
1047
+ /**
1048
+ * How much of a response body the gateway reads for classification and for
1049
+ * the error it reports. Enough for a service's one-line verdict, small enough
1050
+ * that an HTML error page cannot eat an ESP8266's heap.
1051
+ */
1052
+ static const size_t GATEWAY_RESPONSE_HEAD_BYTES = 512;
1053
+
1054
+ /** How long the gateway waits for that head to arrive after the status. */
1055
+ static const uint32_t GATEWAY_RESPONSE_HEAD_TIMEOUT_MS = 250;
1056
+
1057
+ inline bool responseContains(const TSTRING& haystack, const char* needle) {
1058
+ #if defined(PAINLESSMESH_BOOST)
1059
+ return haystack.find(needle) != std::string::npos;
1060
+ #else
1061
+ return haystack.indexOf(needle) >= 0;
1062
+ #endif
1063
+ }
1064
+
1065
+ /**
1066
+ * @brief Reduce a response body to one line fit for a log or an error string
1067
+ *
1068
+ * Tags are dropped, whitespace collapsed and the result cut at maxLen with an
1069
+ * ellipsis, so an HTML error page reads "Oops! Too many requests... You have
1070
+ * called to the API to often." in a serial log instead of markup.
1071
+ */
1072
+ inline TSTRING summarizeResponseBody(
1073
+ const TSTRING& body, size_t maxLen = GATEWAY_RESPONSE_REASON_MAX) {
1074
+ TSTRING out;
1075
+ bool inTag = false;
1076
+ bool pendingSpace = false;
1077
+ for (size_t i = 0; i < body.length(); ++i) {
1078
+ const char c = body[i];
1079
+ if (c == '<') {
1080
+ inTag = true;
1081
+ continue;
1082
+ }
1083
+ if (c == '>') {
1084
+ inTag = false;
1085
+ pendingSpace = true;
1086
+ continue;
1087
+ }
1088
+ if (inTag) continue;
1089
+ if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
1090
+ pendingSpace = true;
1091
+ continue;
1092
+ }
1093
+ if (pendingSpace && out.length() > 0) out += ' ';
1094
+ pendingSpace = false;
1095
+ out += c;
1096
+ if (out.length() >= maxLen) {
1097
+ out += "...";
1098
+ break;
1099
+ }
1100
+ }
1101
+ return out;
1102
+ }
1103
+
1104
+ /**
1105
+ * @brief Does a success-class response body say the service refused the request?
1106
+ *
1107
+ * Some services answer a refusal with a 2xx. CallMeBot's WhatsApp API, which
1108
+ * the sendToInternet example integrates, returns its "Too many requests" page
1109
+ * under HTTP 201 and HTTP 203 (issue #450). The phrases are the ones seen
1110
+ * from services this library's examples target, and they are deliberately
1111
+ * specific: a JSON payload that merely contains the word "error" is not a
1112
+ * refusal.
1113
+ */
1114
+ inline bool responseBodyRefuses(const TSTRING& body) {
1115
+ static const char* const REFUSALS[] = {"Too many requests", "Oops!"};
1116
+ for (const char* phrase : REFUSALS) {
1117
+ if (responseContains(body, phrase)) return true;
1118
+ }
1119
+ return false;
1120
+ }
1121
+
1122
+ /**
1123
+ * @brief Classify an HTTPClient result for the gateway acknowledgment
1124
+ *
1125
+ * A transport error (rawCode <= 0) is neither success nor an HTTP status.
1126
+ * Only 200, 201, 202 and 204 count as delivery on the status alone, and even
1127
+ * those are overturned by a body that says the service refused the request:
1128
+ * CallMeBot answers "Too many requests" under 201 and 203 (issue #450). Any
1129
+ * other 2xx is unverified. 203 means a proxy transformed the reply; 208 is
1130
+ * what CallMeBot answers to a message that never arrives (issue #452); none
1131
+ * of them is a delivery this library can vouch for, so they are failures
1132
+ * that carry the body. Every failure's reason is a one-line excerpt of the
1133
+ * body when there is one, so the origin node learns why and not only a
1134
+ * number.
1135
+ *
1136
+ * @param rawCode The int returned by HTTPClient::GET() or ::POST()
1137
+ * @param body The start of the response body, empty if none was read
1138
+ * @return Classified outcome, safe to place in a GatewayAckPackage
1139
+ */
1140
+ inline HttpRequestOutcome classifyHttpResult(int rawCode,
1141
+ const TSTRING& body = TSTRING()) {
1142
+ HttpRequestOutcome outcome;
1143
+ if (rawCode <= 0) {
1144
+ outcome.transportError = true;
1145
+ return outcome;
1146
+ }
1147
+ outcome.ackStatus =
1148
+ static_cast<uint16_t>(rawCode > 0xFFFF ? 0xFFFF : rawCode);
1149
+ const bool verified =
1150
+ rawCode == 200 || rawCode == 201 || rawCode == 202 || rawCode == 204;
1151
+ if (verified && responseBodyRefuses(body)) {
1152
+ outcome.refusedByBody = true;
1153
+ outcome.reason = summarizeResponseBody(body);
1154
+ return outcome;
1155
+ }
1156
+ outcome.success = verified;
1157
+ if (!verified) {
1158
+ outcome.unverifiedStatus = rawCode >= 200 && rawCode < 300;
1159
+ outcome.reason = summarizeResponseBody(body);
1160
+ }
1161
+ return outcome;
1162
+ }
1163
+
1164
+ /**
1165
+ * The phrase a node puts in a failed GATEWAY_ACK when it received a gateway
1166
+ * request but serves none: a bridge that rebooted, crashed or was reflashed
1167
+ * as a regular node announces nothing, and its peers keep routing Internet
1168
+ * requests to it until its last bridge status ages out. The origin node
1169
+ * recognises the phrase, forgets that node as a gateway, and retries through
1170
+ * the next one. Changing it breaks that recognition across a mixed fleet.
1171
+ */
1172
+ static const char GATEWAY_NOT_A_GATEWAY_PHRASE[] = "is not an Internet gateway";
1173
+
1174
+ /**
1175
+ * How long a destination whose name failed to resolve is refused without
1176
+ * another lookup (issue #453). On ESP32 a DNS lookup has no timeout this
1177
+ * library can set, so a dead name stalls the cooperative scheduler for the
1178
+ * resolver's own patience; once a minute is survivable, once per attempt --
1179
+ * with the origin node retrying and the bridge's own sends adding theirs --
1180
+ * was not. On ESP8266 the core bounds HTTPClient's own lookup by the HTTP
1181
+ * timeout, so the gateway performs no separate lookup there and this cache
1182
+ * is never fed.
1183
+ */
1184
+ static const uint32_t GATEWAY_DNS_NEGATIVE_TTL_MS = 60000;
1185
+
1186
+ /**
1187
+ * @brief The host of an http(s) URL, without scheme, port, path or query
1188
+ *
1189
+ * "https://api.example.com:8443/x?y" -> "api.example.com". Empty when the
1190
+ * URL has no host.
1191
+ */
1192
+ inline TSTRING hostFromUrl(const TSTRING& url) {
1193
+ const char* s = url.c_str();
1194
+ const size_t n = url.length();
1195
+ size_t i = 0;
1196
+ for (size_t k = 0; k + 2 < n; ++k) {
1197
+ if (s[k] == ':' && s[k + 1] == '/' && s[k + 2] == '/') {
1198
+ i = k + 3;
1199
+ break;
1200
+ }
1201
+ }
1202
+ size_t j = i;
1203
+ while (j < n && s[j] != '/' && s[j] != '?' && s[j] != '#' && s[j] != ':') ++j;
1204
+ TSTRING host;
1205
+ for (size_t k = i; k < j; ++k) host += s[k];
1206
+ return host;
1207
+ }
1208
+
1209
+ /** True for a dotted-decimal IPv4 literal, which needs no DNS. */
1210
+ inline bool looksLikeIpLiteral(const TSTRING& host) {
1211
+ if (host.length() == 0) return false;
1212
+ for (size_t i = 0; i < host.length(); ++i) {
1213
+ const char c = host[i];
1214
+ if (!(c >= '0' && c <= '9') && c != '.') return false;
1215
+ }
1216
+ return true;
1217
+ }
1218
+
1219
+ /**
1220
+ * @brief A few hosts that recently failed to resolve, and when
1221
+ *
1222
+ * Small and fixed: a gateway talks to a handful of destinations. A host is
1223
+ * remembered with a TTL; while it is within it, isFailing() says so and the
1224
+ * caller answers the request without a lookup.
1225
+ */
1226
+ class NegativeDnsCache {
1227
+ public:
1228
+ // An enum, not a static const member: the test suite passes it to Catch2 by
1229
+ // reference, which needs a definition C++14 cannot give an in-class
1230
+ // constant without an out-of-line one.
1231
+ enum : size_t { SLOTS = 4 };
1232
+
1233
+ void remember(const TSTRING& host, uint32_t nowMs,
1234
+ uint32_t ttlMs = GATEWAY_DNS_NEGATIVE_TTL_MS) {
1235
+ Entry* slot = find(host);
1236
+ if (slot == nullptr) {
1237
+ slot = &entries_[0];
1238
+ for (size_t i = 0; i < SLOTS; ++i) {
1239
+ if (!entries_[i].used) {
1240
+ slot = &entries_[i];
1241
+ break;
1242
+ }
1243
+ if (static_cast<int32_t>(entries_[i].at - slot->at) < 0) slot = &entries_[i];
1244
+ }
1245
+ }
1246
+ slot->used = true;
1247
+ slot->host = host;
1248
+ slot->at = nowMs;
1249
+ slot->ttl = ttlMs;
1250
+ }
1251
+
1252
+ /** Is `host` inside its negative TTL? `ageMs` receives how long ago it failed. */
1253
+ bool isFailing(const TSTRING& host, uint32_t nowMs, uint32_t* ageMs = nullptr) {
1254
+ Entry* slot = find(host);
1255
+ if (slot == nullptr) return false;
1256
+ const uint32_t age = nowMs - slot->at;
1257
+ if (age >= slot->ttl) {
1258
+ slot->used = false;
1259
+ return false;
1260
+ }
1261
+ if (ageMs != nullptr) *ageMs = age;
1262
+ return true;
1263
+ }
1264
+
1265
+ void forget(const TSTRING& host) {
1266
+ Entry* slot = find(host);
1267
+ if (slot != nullptr) slot->used = false;
1268
+ }
1269
+
1270
+ size_t size() const {
1271
+ size_t n = 0;
1272
+ for (size_t i = 0; i < SLOTS; ++i) n += entries_[i].used ? 1 : 0;
1273
+ return n;
1274
+ }
1275
+
1276
+ private:
1277
+ struct Entry {
1278
+ TSTRING host;
1279
+ uint32_t at = 0;
1280
+ uint32_t ttl = 0;
1281
+ bool used = false;
1282
+ };
1283
+
1284
+ Entry* find(const TSTRING& host) {
1285
+ for (size_t i = 0; i < SLOTS; ++i) {
1286
+ if (entries_[i].used && entries_[i].host == host) return &entries_[i];
1287
+ }
1288
+ return nullptr;
1289
+ }
1290
+
1291
+ Entry entries_[SLOTS];
1292
+ };
1293
+
968
1294
  /**
969
1295
  * @brief Gateway Acknowledgment Package for delivery confirmations
970
1296
  *
@@ -1214,13 +1540,15 @@ class GatewayAckPackage : public plugin::SinglePackage {
1214
1540
  * GATEWAY_DNS_TIMEOUT_MS on ESP8266, skipped on ESP32) runs before them
1215
1541
  * whenever the GATEWAY_CONNECTIVITY_CACHE_MS window has expired.
1216
1542
  *
1217
- * @warning One residual is not in this budget: the HTTP calls resolve their
1218
- * hostnames inside the platform core before their socket timeout
1219
- * applies, and on ESP32 that in-request resolver wait is not
1220
- * separately boundable in the cores this library targets. On a
1221
- * network with blackholed DNS the request path can therefore still
1222
- * exceed this ceiling on ESP32. See SECURITY.md "Gateway blocking:
1223
- * the mesh partition risk".
1543
+ * @warning One residual is not in this budget: hostname resolution on ESP32
1544
+ * is not separately boundable in the cores this library targets.
1545
+ * Since issue #453 the handler performs that lookup itself on
1546
+ * ESP32 and remembers a failure for GATEWAY_DNS_NEGATIVE_TTL_MS, so
1547
+ * on a network with blackholed DNS the request path can still
1548
+ * exceed this ceiling on ESP32, but at most once per TTL per
1549
+ * destination host rather than once per attempt. On ESP8266 the
1550
+ * core bounds HTTPClient's own lookup by the HTTP timeout. See
1551
+ * SECURITY.md "Gateway blocking: the mesh partition risk".
1224
1552
  */
1225
1553
  constexpr unsigned long gatewayBlockingBudgetMs() {
1226
1554
  return 2UL * static_cast<unsigned long>(GATEWAY_HTTP_TIMEOUT_MS) +
@@ -30,6 +30,19 @@ typedef enum {
30
30
  DEBUG = 1 << 11
31
31
  } LogLevel;
32
32
 
33
+ // Log() is printf-like. In the desktop test build the compiler is told so,
34
+ // and -Wall -Werror in CI then rejects any argument that does not match its
35
+ // specifier. That class of bug reached main twice in routePackage(): size_t
36
+ // through %d/%u, and a DeserializationError object through %u (CodeQL
37
+ // cpp/wrong-type-format-argument). The check is kept to the desktop build on
38
+ // purpose: ESP-IDF 5 makes uint32_t `unsigned long`, so on ESP32 every %u of
39
+ // a node id would warn, harmlessly, in every user's build.
40
+ #if defined(PAINLESSMESH_BOOST) && defined(__GNUC__)
41
+ #define PAINLESSMESH_LOG_FORMAT __attribute__((format(printf, 3, 4)))
42
+ #else
43
+ #define PAINLESSMESH_LOG_FORMAT
44
+ #endif
45
+
33
46
  class LogClass {
34
47
  public:
35
48
  // Where messages go instead of Serial. The sketch owns framing: on a board
@@ -83,7 +96,7 @@ class LogClass {
83
96
  Serial.println();
84
97
  return;
85
98
  }
86
- void operator()(LogLevel type, const char *format...) {
99
+ void operator()(LogLevel type, const char *format...) PAINLESSMESH_LOG_FORMAT {
87
100
  if (type & types) { // Print only the message types set for output
88
101
  va_list args;
89
102
  va_start(args, format);