From 5fda22ff163fc564bec642f72c67c2bf45f0002e Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Sun, 28 Jun 2026 00:04:49 +0400 Subject: [PATCH 1/2] feat(wifi): add multi-A-record DNS failover Resolve all A records via raw UDP DNS, stick to one IP, advance on each transport/TLS failure, fall back to hostname when exhausted. TCP connects to the IP while SNI and cert verification stay on the hostname. Ported onto main's refactored HTTP client. Also drop payload-encoder examples/ and test/ from the compiled src tree: PlatformIO and arduino-cli sweep every source under src/ and broke on their stale payload_encoder.h includes. --- CMakeLists.txt | 1 + src/airgradientWifiClient.cpp | 225 ++++++++- src/airgradientWifiClient.h | 28 ++ src/endpointSelector.cpp | 441 ++++++++++++++++++ src/endpointSelector.h | 113 +++++ src/payload-encoder/CMakeLists.txt | 25 - src/payload-encoder/README.md | 2 - src/payload-encoder/examples/demo.cpp | 211 --------- src/payload-encoder/test/CMakeLists.txt | 25 - src/payload-encoder/test/test_batching.cpp | 144 ------ .../test/test_dual_channel.cpp | 114 ----- src/payload-encoder/test/test_encoder.cpp | 209 --------- .../test/test_single_channel.cpp | 270 ----------- src/payload-encoder/test/test_sizes.cpp | 66 --- 14 files changed, 787 insertions(+), 1087 deletions(-) create mode 100644 src/endpointSelector.cpp create mode 100644 src/endpointSelector.h delete mode 100644 src/payload-encoder/examples/demo.cpp delete mode 100644 src/payload-encoder/test/CMakeLists.txt delete mode 100644 src/payload-encoder/test/test_batching.cpp delete mode 100644 src/payload-encoder/test/test_dual_channel.cpp delete mode 100644 src/payload-encoder/test/test_encoder.cpp delete mode 100644 src/payload-encoder/test/test_single_channel.cpp delete mode 100644 src/payload-encoder/test/test_sizes.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 345446a..536afdf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,7 @@ set(srcs "src/airgradientClient.cpp" "src/airgradientCellularClient.cpp" "src/airgradientWifiClient.cpp" + "src/endpointSelector.cpp" "src/atCommandHandler.cpp" "src/cellularModule.cpp" "src/cellularModuleA7672xx.cpp" diff --git a/src/airgradientWifiClient.cpp b/src/airgradientWifiClient.cpp index f53f427..6dbcdca 100644 --- a/src/airgradientWifiClient.cpp +++ b/src/airgradientWifiClient.cpp @@ -48,6 +48,8 @@ #ifdef ARDUINO #include +#include +#include "common.h" #else #include "esp_http_client.h" #endif @@ -146,20 +148,41 @@ bool AirgradientWifiClient::httpPostMeasures(const AirgradientPayload &payload) bool AirgradientWifiClient::_httpGet(const std::string &url, int &responseCode, std::string &responseBody) { #ifdef ARDUINO - // Init http client - HTTPClient client; - client.setConnectTimeout(timeoutMs); // Set timeout when establishing connection to server - client.setTimeout(timeoutMs); // Timeout when waiting for response from AG server - // By default, airgradient using https - if (client.begin(String(url.c_str()), AG_SERVER_ROOT_CA) == false) { - AG_LOGE(TAG, "Failed begin HTTPClient using TLS"); + _ensureSelectorReady(); + std::string path = _extractPath(url); + if (path.empty()) { + AG_LOGE(TAG, "Could not extract path from URL: %s", url.c_str()); return false; } + const char *host = httpDomain.c_str(); + + // Phase 1: try every resolved IP exactly once, in cursor order. The + // cursor stays put on success (sticky) and advances on failure. + bool ipsExhausted = false; + if (selectorReady_ && selector_.count() > 0) { + const uint8_t total = selector_.count(); + for (uint8_t i = 0; i < total; ++i) { + IPAddress ip = selector_.current(); + if (_httpGetSecure(ip, host, path, responseCode, responseBody)) { + return true; + } + selector_.advance(); + } + ipsExhausted = true; + AG_LOGW(TAG, "All %u IP(s) failed; falling back to hostname resolution", total); + } - responseCode = client.GET(); - responseBody = client.getString().c_str(); - client.end(); - return true; + // Phase 2: hostname fallback. Same WiFiClientSecure + HTTPClient API, + // just let WiFiClientSecure do its own DNS resolution. + if (_httpGetSecure(IPAddress(static_cast(0)), host, path, responseCode, responseBody)) { + if (ipsExhausted) { + AG_LOGI(TAG, "Hostname fallback succeeded after IP exhaustion; refreshing DNS"); + selector_.refresh(); + } + return true; + } + AG_LOGE(TAG, "Both IP and hostname paths failed"); + return false; #else esp_http_client_config_t config = {}; config.url = url.c_str(); @@ -196,19 +219,40 @@ bool AirgradientWifiClient::_httpGet(const std::string &url, int &responseCode, bool AirgradientWifiClient::_httpPost(const std::string &url, const std::string &payload, int &responseCode) { #ifdef ARDUINO - HTTPClient client; - client.setConnectTimeout(timeoutMs); // Set timeout when establishing connection to server - client.setTimeout(timeoutMs); // Timeout when waiting for response from AG server - // By default, airgradient using https - if (client.begin(String(url.c_str()), AG_SERVER_ROOT_CA) == false) { - AG_LOGE(TAG, "Failed begin HTTPClient using TLS"); + _ensureSelectorReady(); + std::string path = _extractPath(url); + if (path.empty()) { + AG_LOGE(TAG, "Could not extract path from URL: %s", url.c_str()); return false; } + const char *host = httpDomain.c_str(); + + // Phase 1: try every resolved IP exactly once, in cursor order. The + // cursor stays put on success (sticky) and advances on failure. + bool ipsExhausted = false; + if (selectorReady_ && selector_.count() > 0) { + const uint8_t total = selector_.count(); + for (uint8_t i = 0; i < total; ++i) { + IPAddress ip = selector_.current(); + if (_httpPostSecure(ip, host, path, payload, responseCode)) { + return true; + } + selector_.advance(); + } + ipsExhausted = true; + AG_LOGW(TAG, "All %u IP(s) failed; falling back to hostname resolution", total); + } - client.addHeader("content-type", "application/json"); - responseCode = client.POST(String(payload.c_str())); - client.end(); - return true; + // Phase 2: hostname fallback via the same WiFiClientSecure API. + if (_httpPostSecure(IPAddress(static_cast(0)), host, path, payload, responseCode)) { + if (ipsExhausted) { + AG_LOGI(TAG, "Hostname fallback succeeded after IP exhaustion; refreshing DNS"); + selector_.refresh(); + } + return true; + } + AG_LOGE(TAG, "Both IP and hostname paths failed"); + return false; #else esp_http_client_config_t config = {}; config.url = url.c_str(); @@ -317,4 +361,143 @@ void AirgradientWifiClient::_serialize(JsonDocument &doc, const PayloadBuffer &p } } +#ifdef ARDUINO + +bool AirgradientWifiClient::_httpGetSecure(const IPAddress &ip, const char *host, + const std::string &path, int &responseCode, + std::string &responseBody) { + const bool usingIp = (static_cast(ip) != 0); + // Note: must extend the String lifetime to function scope; cannot use + // `ip.toString().c_str()` directly (temporary gets destroyed). + String ipStr = usingIp ? ip.toString() : String(); + const char *target = usingIp ? ipStr.c_str() : host; + + WiFiClientSecure secClient; + secClient.setCACert(AG_SERVER_ROOT_CA); + // setTimeout() expects seconds. + secClient.setTimeout((timeoutMs + 500) / 1000); + + // When we have a selected IP: 6-arg overload connects TCP to `ip` but + // SNI + cert verification still use `host`. When we don't: let + // WiFiClientSecure resolve `host` itself (which internally calls the + // same 6-arg overload with the resolved IP). + uint32_t t0 = MILLIS(); + int connectRet; + if (usingIp) { + connectRet = secClient.connect(ip, 443, host, AG_SERVER_ROOT_CA, nullptr, nullptr); + } else { + connectRet = secClient.connect(host, 443, AG_SERVER_ROOT_CA, nullptr, nullptr); + } + uint32_t connectDt = MILLIS() - t0; + if (connectRet != 1) { + AG_LOGW(TAG, "TLS connect to %s failed in %ums (ret=%d)", target, connectDt, connectRet); + return false; + } + AG_LOGI(TAG, "TLS up to %s in %ums", target, connectDt); + + HTTPClient client; + client.setConnectTimeout(timeoutMs); + client.setTimeout(timeoutMs); + // begin(WiFiClient&, host, port, uri, https) reuses the already-connected + // socket and sets the Host header from `host`. + if (client.begin(secClient, host, 443, path.c_str(), true) == false) { + AG_LOGE(TAG, "Failed begin HTTPClient on pre-connected client"); + secClient.stop(); + return false; + } + + responseCode = client.GET(); + if (responseCode <= 0) { + AG_LOGW(TAG, "HTTP GET via %s failed: %d (%s)", target, responseCode, + HTTPClient::errorToString(responseCode).c_str()); + client.end(); + return false; + } + responseBody = client.getString().c_str(); + client.end(); + return true; +} + +bool AirgradientWifiClient::_httpPostSecure(const IPAddress &ip, const char *host, + const std::string &path, const std::string &payload, + int &responseCode) { + const bool usingIp = (static_cast(ip) != 0); + String ipStr = usingIp ? ip.toString() : String(); + const char *target = usingIp ? ipStr.c_str() : host; + + WiFiClientSecure secClient; + secClient.setCACert(AG_SERVER_ROOT_CA); + secClient.setTimeout((timeoutMs + 500) / 1000); + + uint32_t t0 = MILLIS(); + int connectRet; + if (usingIp) { + connectRet = secClient.connect(ip, 443, host, AG_SERVER_ROOT_CA, nullptr, nullptr); + } else { + connectRet = secClient.connect(host, 443, AG_SERVER_ROOT_CA, nullptr, nullptr); + } + uint32_t connectDt = MILLIS() - t0; + if (connectRet != 1) { + AG_LOGW(TAG, "TLS connect to %s failed in %ums (ret=%d)", target, connectDt, connectRet); + return false; + } + AG_LOGI(TAG, "TLS up to %s in %ums", target, connectDt); + + HTTPClient client; + client.setConnectTimeout(timeoutMs); + client.setTimeout(timeoutMs); + if (client.begin(secClient, host, 443, path.c_str(), true) == false) { + AG_LOGE(TAG, "Failed begin HTTPClient on pre-connected client"); + secClient.stop(); + return false; + } + client.addHeader("content-type", "application/json"); + + responseCode = client.POST(String(payload.c_str())); + if (responseCode <= 0) { + AG_LOGW(TAG, "HTTP POST via %s failed: %d (%s)", target, responseCode, + HTTPClient::errorToString(responseCode).c_str()); + client.end(); + return false; + } + client.end(); + return true; +} + +void AirgradientWifiClient::_ensureSelectorReady() { + // Re-initialize on first call or if the domain changed at runtime + // (e.g. setHttpDomain() was called by the application). + bool domainChanged = (selectorHost_ != httpDomain); + + if (!selectorReady_ || domainChanged) { + if (domainChanged && selectorReady_) { + AG_LOGI(TAG, "HTTP domain changed (%s -> %s); re-initializing selector", + selectorHost_.c_str(), httpDomain.c_str()); + } + selectorReady_ = selector_.begin(httpDomain.c_str()); + if (selectorReady_) { + selectorHost_ = httpDomain; + } else { + AG_LOGW(TAG, "Selector init failed for %s; will use domain-based path only", + httpDomain.c_str()); + } + return; + } + + // Periodic refresh (1h default). No-op if interval not yet elapsed. + selector_.maybeRefresh(MILLIS()); +} + +std::string AirgradientWifiClient::_extractPath(const std::string &url) const { + // Expected format: "https:///". We strip the prefix + // strictly so we don't accidentally hit a wrong path on malformed URLs. + std::string prefix = "https://" + httpDomain; + if (url.compare(0, prefix.size(), prefix) != 0) { + return std::string(); + } + return url.substr(prefix.size()); +} + +#endif // ARDUINO + #endif // ESP8266 diff --git a/src/airgradientWifiClient.h b/src/airgradientWifiClient.h index 1a43faf..f0aaff4 100644 --- a/src/airgradientWifiClient.h +++ b/src/airgradientWifiClient.h @@ -15,6 +15,9 @@ #include #include "airgradientClient.h" +#ifdef ARDUINO +#include "endpointSelector.h" +#endif #ifndef ARDUINO #define MAX_RESPONSE_BUFFER 2048 @@ -27,6 +30,11 @@ class AirgradientWifiClient : public AirgradientClient { #ifndef ARDUINO char responseBuffer[2048]; #endif +#ifdef ARDUINO + EndpointSelector selector_; + bool selectorReady_ = false; + std::string selectorHost_; +#endif public: AirgradientWifiClient() {}; ~AirgradientWifiClient() {}; @@ -41,6 +49,26 @@ class AirgradientWifiClient : public AirgradientClient { bool _httpPost(const std::string &url, const std::string &payload, int &responseCode); void _serialize(JsonDocument &doc, const PayloadBuffer &payload); +#ifdef ARDUINO + // Unified HTTPS GET/POST helpers used by both the IP-failover loop and + // the hostname fallback. If `ip` is 0.0.0.0, the WiFiClientSecure will + // resolve `host` via lwIP itself; otherwise TCP connects to `ip` while + // SNI + cert validation still use `host`. Return false on any + // transport/TLS failure or negative HTTP response code; true on any + // valid HTTP response (status code stored in responseCode). + bool _httpGetSecure(const IPAddress &ip, const char *host, const std::string &path, + int &responseCode, std::string &responseBody); + bool _httpPostSecure(const IPAddress &ip, const char *host, const std::string &path, + const std::string &payload, int &responseCode); + + // Ensure selector is initialized and tracks the current httpDomain. + // Performs lazy first-time init and periodic refresh. + void _ensureSelectorReady(); + + // Extract path portion from a full URL ("https://host/path" -> "/path"). + // Returns empty string if the URL does not match the expected format. + std::string _extractPath(const std::string &url) const; +#endif }; #endif // ESP8266 diff --git a/src/endpointSelector.cpp b/src/endpointSelector.cpp new file mode 100644 index 0000000..9249ed0 --- /dev/null +++ b/src/endpointSelector.cpp @@ -0,0 +1,441 @@ +/** + * AirGradient + * https://airgradient.com + * + * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License + */ + +#ifndef ESP8266 +#ifdef ARDUINO + +#include "endpointSelector.h" + +#include +#include +#include +#include + +#include "agLogger.h" +#include "common.h" + +static const char *const TAG = "AgEpSel"; + +namespace { + +constexpr uint16_t DNS_PORT = 53; +constexpr uint32_t DNS_QUERY_TIMEOUT_MS = 5000; +constexpr size_t DNS_MAX_PACKET_SIZE = 512; // standard UDP DNS +constexpr uint16_t DNS_TYPE_A = 1; +constexpr uint16_t DNS_CLASS_IN = 1; +constexpr uint16_t DNS_TYPE_CNAME = 5; + +inline uint16_t rd16_(const uint8_t *buf, size_t off) { + return (static_cast(buf[off]) << 8) | buf[off + 1]; +} + +} // namespace + +bool EndpointSelector::begin(const char *hostname) { + if (hostname == nullptr || hostname[0] == '\0') { + return false; + } + strncpy(hostname_, hostname, sizeof(hostname_) - 1); + hostname_[sizeof(hostname_) - 1] = '\0'; + + if (!resolveAndStore_()) { + return false; + } + shuffle_(); + currentIdx_ = 0; + lastRefreshMs_ = MILLIS(); + logState_("ready"); + return true; +} + +IPAddress EndpointSelector::current() const { + if (count_ == 0) { + return IPAddress(static_cast(0)); + } + return ips_[currentIdx_]; +} + +void EndpointSelector::advance() { + if (count_ == 0) { + return; + } + uint8_t prevIdx = currentIdx_; + currentIdx_ = (currentIdx_ + 1) % count_; + AG_LOGW(TAG, "Advance %s -> %s (idx %u -> %u)", + ips_[prevIdx].toString().c_str(), + ips_[currentIdx_].toString().c_str(), prevIdx, currentIdx_); +} + +bool EndpointSelector::maybeRefresh(uint32_t nowMs) { + if (count_ == 0) { + // Never initialized or last refresh failed; let begin()/refresh() run. + return false; + } + if ((nowMs - lastRefreshMs_) < REFRESH_INTERVAL_MS) { + return false; + } + return refresh(); +} + +bool EndpointSelector::refresh() { + AG_LOGI(TAG, "Refreshing DNS list for %s", hostname_); + IPAddress active = current(); + + // Save the old list so we can restore on resolution failure. + uint8_t oldCount = count_; + IPAddress oldIps[MAX_IPS]; + for (uint8_t i = 0; i < oldCount; i++) { + oldIps[i] = ips_[i]; + } + + if (!resolveAndStore_()) { + AG_LOGW(TAG, "Refresh failed; keeping previous list"); + count_ = oldCount; + for (uint8_t i = 0; i < oldCount; i++) { + ips_[i] = oldIps[i]; + } + lastRefreshMs_ = MILLIS(); + return false; + } + + shuffle_(); + + // Try to keep the previously-active IP if it is still present. + bool stillThere = false; + if ((uint32_t)active != 0) { + for (uint8_t i = 0; i < count_; i++) { + if (ips_[i] == active) { + if (i != 0) { + IPAddress tmp = ips_[0]; + ips_[0] = ips_[i]; + ips_[i] = tmp; + } + stillThere = true; + break; + } + } + } + currentIdx_ = 0; + if (!stillThere && (uint32_t)active != 0) { + AG_LOGW(TAG, "Previously active IP %s no longer in DNS", + active.toString().c_str()); + } + lastRefreshMs_ = MILLIS(); + logState_("after-refresh"); + return true; +} + +/* ---------- private helpers ---------- */ + +uint8_t EndpointSelector::resolveAll_(IPAddress *out, uint8_t maxOut) { + if (out == nullptr || maxOut == 0) { + return 0; + } + + IPAddress dnsServer = WiFi.dnsIP(0); + if (static_cast(dnsServer) == 0) { + AG_LOGW(TAG, "No DNS server configured; falling back to hostByName"); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + AG_LOGI(TAG, "Querying DNS %s for %s (A)", dnsServer.toString().c_str(), hostname_); + + // ---- Build query ---- + uint8_t query[DNS_MAX_PACKET_SIZE]; + uint16_t txnId = 0; + size_t queryLen = buildQuery_(hostname_, query, sizeof(query), txnId); + if (queryLen == 0) { + AG_LOGE(TAG, "DNS query build failed"); + return 0; + } + + // ---- Send + receive ---- + WiFiUDP udp; + if (!udp.begin(0)) { // 0 = any local port + AG_LOGW(TAG, "UDP begin failed; falling back to hostByName"); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + + bool sent = udp.beginPacket(dnsServer, DNS_PORT); + if (sent) { + udp.write(query, queryLen); + sent = udp.endPacket(); + } + if (!sent) { + AG_LOGW(TAG, "DNS UDP send failed"); + udp.stop(); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + + uint32_t deadline = MILLIS() + DNS_QUERY_TIMEOUT_MS; + int pktSize = 0; + while ((pktSize = udp.parsePacket()) == 0) { + if (static_cast(MILLIS() - deadline) >= 0) { + AG_LOGW(TAG, "DNS response timeout"); + udp.stop(); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + DELAY_MS(5); + } + + uint8_t response[DNS_MAX_PACKET_SIZE]; + int respLen = udp.read(response, sizeof(response)); + udp.stop(); + + if (respLen <= 0) { + AG_LOGW(TAG, "DNS UDP read returned no data"); + return 0; + } + AG_LOGI(TAG, "DNS response %d bytes", respLen); + + uint8_t count = + parseAnswers_(response, static_cast(respLen), txnId, out, maxOut); + if (count == 0) { + AG_LOGW(TAG, "Raw DNS parse found 0 A records; falling back to hostByName"); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + return count; +} + +/* ---------- Raw DNS helpers (RFC 1035) ---------- */ + +size_t EndpointSelector::buildQuery_(const char *hostname, uint8_t *out, size_t outMax, + uint16_t &txnIdOut) { + if (outMax < 12 + 5) return 0; // header + minimal question + + uint16_t txnId = static_cast(esp_random() & 0xFFFF); + txnIdOut = txnId; + + // Header (12 bytes) + out[0] = (txnId >> 8) & 0xFF; + out[1] = txnId & 0xFF; + out[2] = 0x01; // flags: standard query, RD=1 + out[3] = 0x00; + out[4] = 0x00; + out[5] = 0x01; // QDCOUNT = 1 + out[6] = 0x00; + out[7] = 0x00; // ANCOUNT = 0 + out[8] = 0x00; + out[9] = 0x00; // NSCOUNT = 0 + out[10] = 0x00; + out[11] = 0x00; // ARCOUNT = 0 + size_t pos = 12; + + size_t nameLen = encodeName_(hostname, out + pos, outMax - pos); + if (nameLen == 0) return 0; + pos += nameLen; + + if (pos + 4 > outMax) return 0; + out[pos++] = 0x00; + out[pos++] = 0x01; // QTYPE = A + out[pos++] = 0x00; + out[pos++] = 0x01; // QCLASS = IN + + return pos; +} + +size_t EndpointSelector::encodeName_(const char *hostname, uint8_t *out, size_t outMax) { + size_t pos = 0; + const char *s = hostname; + while (*s != '\0') { + const char *dot = strchr(s, '.'); + size_t labelLen = dot ? static_cast(dot - s) : strlen(s); + if (labelLen == 0 || labelLen > 63) return 0; + if (pos + 1 + labelLen + 1 > outMax) return 0; // length + label + trailing root + out[pos++] = static_cast(labelLen); + memcpy(out + pos, s, labelLen); + pos += labelLen; + s += labelLen; + if (*s == '.') s++; + } + if (pos + 1 > outMax) return 0; + out[pos++] = 0; // root label + return pos; +} + +size_t EndpointSelector::skipName_(const uint8_t *buf, size_t bufLen, size_t offset) { + // Names are either a sequence of