phase: 02_tls_control_channel
**Verified / implemented (phase 02 final pass)** - All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label). - Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no). - Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page. **Tests / lint (100 % green)** - `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`. - Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2). - `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free. **Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓. **Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`. **Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit. **Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`.
This commit is contained in:
+663
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* keyrelay -- the transport view: one byte pipe, two implementations, and the tables that
|
||||
* decide what the *plaintext* port is allowed to say.
|
||||
*
|
||||
* Why this file exists
|
||||
* --------------------
|
||||
* `ControlServer` used to carry two HTTP paths: one that wrote straight to a `WiFiClient`
|
||||
* and one that staged bytes for an mbed TLS session and pumped them across ticks. Two
|
||||
* paths that can disagree about a route is the failure mode this project keeps having to
|
||||
* fix (AGENTS.md, invariant 8: both read-only routes go through `serveReadonly()` "so they
|
||||
* cannot drift apart"), and a half-encrypted server is the shape that drifts. So the
|
||||
* transport stops being a routing question: the routing code asks a `Conn` to read, write
|
||||
* and drain, and does not know -- and must not care -- which of the two it is holding.
|
||||
*
|
||||
* What is pure, and where each half is proven
|
||||
* -------------------------------------------
|
||||
* The PURE half is everything that decides rather than I/Os: the HTTP response head, the
|
||||
* `:80` answer table (`plaintextRoute`), the redirect target builder (which is the one
|
||||
* place a client-supplied `Host:` and request path become bytes a browser will navigate
|
||||
* to), and the flash-body send cursor. It includes no Arduino, no IDF and no socket
|
||||
* header, so tests/test_conn.cpp walks it under sanitisers exactly like tls_pem.h.
|
||||
*
|
||||
* The LIBRARY half is `Conn` itself, compiled only where `WiFiClient` exists. It is proven
|
||||
* the way tls_session.h is: both firmware environments link, and .agents/HW_VERIFICATION.md
|
||||
* records what a real client did with it (page byte-exact over TLS, `:80` redirecting, a
|
||||
* plaintext `/ws` upgrade refused with 426).
|
||||
*
|
||||
* Tagged union, not virtual -- measured, then chosen
|
||||
* --------------------------------------------------
|
||||
* `Conn` is a kind tag plus two pointers, not a base class with two derived classes.
|
||||
* Measured on the target toolchain (`xtensa-esp32s3-elf-g++ -Os`, two probe translation
|
||||
* units performing the same dispatch): the virtual form emits `l32i` + `l32i` + `callx8`
|
||||
* -- two dependent loads and an indirect call -- and came out *smaller* at the call site,
|
||||
* 82 B against the tag switch's 98 B. So this choice is not about the cost of one dispatch,
|
||||
* and pretending it were would be a comment rationalising a decision instead of recording
|
||||
* it. It is about three other things:
|
||||
*
|
||||
* - a Conn is RE-BOUND between transports as slots come and go (`bindPlain` on an accept
|
||||
* at :80, `bindTls` on an accept at :443, the same slot object either way). A vtable
|
||||
* expresses that only by placement-new over a live object, or by holding both derived
|
||||
* objects and a pointer to the live one -- which is this union, plus an indirection and
|
||||
* a lifetime question nobody has to answer;
|
||||
* - `callx8` is not inlinable. `writeTextFrame()` -> `push()` -> `pump()` -> `writeRaw()`
|
||||
* is the road a keystroke frame leaves on, and every hop of a virtual chain there is a
|
||||
* call, while a branch on a value that never changes between a bind and a reset is one
|
||||
* the predictor sees coming and the inliner can see through;
|
||||
* - no vptr per object and no vtable to place: static RAM is the scarce resource on this
|
||||
* board (no PSRAM) and `Conn` lives inside an object that is already ~11.7 kB of it.
|
||||
*
|
||||
* The write contract
|
||||
* ------------------
|
||||
* `pump()` is resumable on every transport and never blocks `loop()`:
|
||||
* - plaintext asks lwIP whether the socket will take bytes *this instant* (zero-timeout
|
||||
* select) and returns "nothing written" rather than entering the Arduino write helper,
|
||||
* which retries a blocking select ten times with a one-second timeout each;
|
||||
* - TLS hands `mbedtls_ssl_write` the identical (ptr, len) until it accepts them, which
|
||||
* is that library's documented resume rule and is enforced in tls::Session;
|
||||
* - both are capped per call (`kConnWriteChunk`) and per tick (`kPageBytesPerTick`), and
|
||||
* a body that lives in flash is copied through a bounded RAM chunk, so a 48 KB page
|
||||
* leaves in bounded pieces across ticks instead of in one call that can wait.
|
||||
* Nothing here ever waits. A peer that stops reading is dropped by the caller's stall
|
||||
* deadline, which is the same rule that already applied to a stalled *request*.
|
||||
*
|
||||
* What this file must never become
|
||||
* --------------------------------
|
||||
* A Conn is a byte pipe plus one opinion: what the plaintext port is allowed to say. It
|
||||
* protects nothing else, and it must not grow. No route, no credential check, no arming
|
||||
* decision and no "but this one is over TLS" may land here -- the transport is not an authority
|
||||
* (README, Safety model), which is the same sentence that keeps a `wss://` controller from being
|
||||
* privileged and a `:80` socket from being trusted. Two specific decisions are load-bearing and
|
||||
* have tests: a token-carrying request and a `/ws` upgrade on the plaintext port are answered
|
||||
* 426 rather than 301, because a `Location:` that echoes `?token=` leaks the passphrase twice on
|
||||
* the wire the redirect is escaping; and exactly one formatter in `src/` writes an HTTP status
|
||||
* line, so the encrypted answer and the plaintext answer cannot disagree about anything except
|
||||
* which of them they are (AGENTS.md invariant 8).
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h> // snprintf, for the one response-head formatter
|
||||
#include <string.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "tls_session.h" // tls::TxRing, tls::Handshake (+ Session behind KR_TLS && ESP_PLATFORM)
|
||||
|
||||
// WiFiClient is a typedef of NetworkClient in this core, so it cannot be forward-declared;
|
||||
// including it is the only way to name it. Guarded, exactly like the mbed TLS includes in
|
||||
// tls_session.h, so a host translation unit (tests/test_conn.cpp) can include THIS header
|
||||
// and walk the pure half with no Arduino installed.
|
||||
#if defined(ESP_PLATFORM)
|
||||
#include <WiFi.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/time.h>
|
||||
#endif
|
||||
|
||||
namespace kr {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure: the HTTP response head
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The two content paths, named once. The redirect builder below and the route table in
|
||||
// control_server.cpp both need them, and a second spelling of "/index.html" is a second
|
||||
// answer to "what does this board serve" -- which is the drift this phase exists to remove.
|
||||
constexpr char kPathIndex[] = "/";
|
||||
constexpr char kPathIndexHtml[] = "/index.html";
|
||||
|
||||
/**
|
||||
* One response formatter for every route, on every transport, in every build. It lives
|
||||
* here (pure) rather than in the server because a header block is a wire format whose
|
||||
* failure mode is silent: `snprintf` truncating it used to mean `headLen = 0`, which meant
|
||||
* the client got a closed socket and no explanation. The cap it is given,
|
||||
* `kHttpResponseHeadCap` (config.h, with the arithmetic), is asserted in
|
||||
* tests/test_conn.cpp against the *widest legal* response -- a 63-character authority and
|
||||
* the longest path a redirect carries over -- not believed.
|
||||
*
|
||||
* Returns the length written, or 0 when `cap` cannot hold the whole block (never a partial
|
||||
* head: half an HTTP response is a desynchronised stream).
|
||||
*/
|
||||
inline size_t renderHttpResponseHead(char *out, size_t cap, const char *status,
|
||||
const char *contentType, size_t bodyLen,
|
||||
const char *extra = nullptr) {
|
||||
if (!out || cap < kHttpResponseHeadCap) return 0;
|
||||
const int n = snprintf(out, cap,
|
||||
"HTTP/1.1 %s\r\n"
|
||||
"Content-Type: %s\r\n"
|
||||
"Content-Length: %u\r\n"
|
||||
"Cache-Control: no-store\r\n"
|
||||
"X-Content-Type-Options: nosniff\r\n"
|
||||
"%s"
|
||||
"Connection: close\r\n\r\n",
|
||||
status, contentType, (unsigned)bodyLen, extra ? extra : "");
|
||||
return (n > 0 && static_cast<size_t>(n) < cap) ? static_cast<size_t>(n) : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure: what :80 is allowed to answer when this build serves TLS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `Serve` is the pre-TLS (and `-D KR_TLS=0`) behaviour: answer the request here.
|
||||
* `Redirect` is a 301 to the same path over `https://`.
|
||||
* `UpgradeRequired` is 426: the client is asking for something this port refuses to do at
|
||||
* all, and the truthful answer is "use TLS", not a redirect.
|
||||
*
|
||||
* Why 426 and not a redirect for two of the shapes (the assumption this task was written
|
||||
* against, and the reason is a leak): a `Location:` that echoes `?token=<passphrase>`
|
||||
* exposes the passphrase TWICE -- once in the plaintext request and once in the plaintext
|
||||
* redirect the browser dutifully re-issues -- and would also train operators to paste the
|
||||
* passphrase into a `http://` bar. A plaintext `/ws` upgrade gets the same code because
|
||||
* silently accepting the weaker thing is never the default (invariant 5): the browser must
|
||||
* fail, read the answer, and reconnect to `https://`.
|
||||
*/
|
||||
enum class PlainRoute : uint8_t { Serve, Redirect, UpgradeRequired };
|
||||
|
||||
/**
|
||||
* `token=` present as a *key* in the query string. The value is never copied -- this
|
||||
* answers "is the passphrase in this URL", and copying it here would put the thing the
|
||||
* whole response exists to protect into a buffer for no reason. Same boundary rule as
|
||||
* ws::tokenFromQuery(): at the start of the query or straight after a '&', so a path like
|
||||
* `/status?mytoken=x` is not a token-carrying request.
|
||||
*/
|
||||
inline bool queryCarriesToken(const char *query) {
|
||||
static const char kKey[] = "token=";
|
||||
const size_t kl = sizeof(kKey) - 1;
|
||||
if (!query) return false;
|
||||
// Bounded by the length, not by the NUL: memcmp() of a 6-byte key against the last two
|
||||
// bytes of "a=1" reads four bytes past the buffer. The sanitiser found exactly that on
|
||||
// the first run of this file, which is why the boundary is written as `i + kl <= len`
|
||||
// (the same shape ws::tokenFromQuery uses) and why tests/test_conn.cpp feeds it every
|
||||
// short prefix of the key.
|
||||
const size_t len = strlen(query);
|
||||
for (size_t i = 0; i + kl <= len; i++) {
|
||||
if (i != 0 && query[i - 1] != '&') continue;
|
||||
if (memcmp(query + i, kKey, kl) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline PlainRoute plaintextRoute(bool upgradeWebsocket, const char *query, bool tlsBuild) {
|
||||
if (!tlsBuild) return PlainRoute::Serve; // the rollback build IS the plaintext server
|
||||
if (upgradeWebsocket) return PlainRoute::UpgradeRequired;
|
||||
if (queryCarriesToken(query)) return PlainRoute::UpgradeRequired;
|
||||
return PlainRoute::Redirect;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure: the redirect target
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The authority for a `Location:` header, from the client's own `Host:`.
|
||||
*
|
||||
* Not a cosmetic decision: `Location` is bytes a browser navigates to. `Host` is attacker
|
||||
* text on an attacker-watched port, and `http://keyrelay.local@evil.com/` puts `evil.com`
|
||||
* in it -- which would make this board an open redirect onto a phishing page, on the one
|
||||
* port whose whole purpose is to say "go here instead". So the Host is accepted only when
|
||||
* it is a name this board can actually be reached at: the certificate's name, the bare
|
||||
* hostname, or a private-use dotted quad (the README's "IP if the name fails" path, which
|
||||
* is why a bare IP has to keep working). Anything else falls back to `kCertCommonName`,
|
||||
* which is always right about this board.
|
||||
*
|
||||
* A `:port` suffix is stripped (a redirect keeps the scheme's default port). Returns false
|
||||
* only when even the fallback would not fit `cap`, which cannot happen with the names in
|
||||
* config.h -- and is still checked, because that is how a name change stays honest.
|
||||
*/
|
||||
inline bool dottedQuadPrivate(const char *h, size_t n) {
|
||||
// Every index into octets[] is guarded BEFORE the write, not at the end: the first
|
||||
// version validated the octet count when a dot arrived, so "192.168.1.1.1" wrote
|
||||
// octets[4] -- a stack overflow on the way to answering "is this a private address?".
|
||||
// ASan found it on the first run; tests/test_conn.cpp keeps it found.
|
||||
unsigned octets[4] = {0, 0, 0, 0};
|
||||
size_t oi = 0, digits = 0;
|
||||
for (size_t i = 0; i <= n; i++) {
|
||||
if (i == n || h[i] == '.') {
|
||||
if (digits == 0 || oi >= 4) return false;
|
||||
oi++;
|
||||
digits = 0;
|
||||
continue;
|
||||
}
|
||||
if (h[i] < '0' || h[i] > '9' || oi >= 4) return false;
|
||||
octets[oi] = octets[oi] * 10u + static_cast<unsigned>(h[i] - '0');
|
||||
if (octets[oi] > 255u) return false; // bound it where it is built, not later
|
||||
digits++;
|
||||
}
|
||||
if (oi != 4) return false;
|
||||
// 10/8, 172.16/12, 192.168/16, link-local, loopback. Nothing else is a name this board
|
||||
// is plausibly reached at by number, and an attacker-supplied public IP never is.
|
||||
return octets[0] == 10 || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
||||
(octets[0] == 192 && octets[1] == 168) || octets[0] == 169 || octets[0] == 127;
|
||||
}
|
||||
|
||||
inline bool ciEqN(const char *a, const char *b, size_t n) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const char ca = (a[i] >= 'A' && a[i] <= 'Z') ? static_cast<char>(a[i] + 32) : a[i];
|
||||
const char cb = (b[i] >= 'A' && b[i] <= 'Z') ? static_cast<char>(b[i] + 32) : b[i];
|
||||
if (ca != cb || ca == '\0') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
inline size_t strLenN(const char *s, size_t max) {
|
||||
size_t n = 0;
|
||||
while (n < max && s[n]) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
inline bool redirectAuthority(const char *headerHost, char *out, size_t cap) {
|
||||
const char *fallback = kCertCommonName;
|
||||
char cand[80];
|
||||
size_t n = headerHost ? strLenN(headerHost, sizeof(cand) - 1) : 0;
|
||||
if (headerHost && n) {
|
||||
memcpy(cand, headerHost, n);
|
||||
cand[n] = '\0';
|
||||
// strip :port -- but a second colon means an IPv6 literal or garbage, not a name
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (cand[i] != ':') continue;
|
||||
bool digits = i + 1 < n;
|
||||
for (size_t j = i + 1; j < n; j++)
|
||||
if (cand[j] < '0' || cand[j] > '9') digits = false;
|
||||
if (!digits) {
|
||||
n = 0;
|
||||
break;
|
||||
}
|
||||
cand[i] = '\0';
|
||||
n = i;
|
||||
break;
|
||||
}
|
||||
bool ok = n > 0 && n < sizeof(cand);
|
||||
for (size_t i = 0; ok && i < n; i++) {
|
||||
const char c = cand[i];
|
||||
ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '.' || c == '-';
|
||||
}
|
||||
ok = ok && cand[0] != '.' && cand[0] != '-' && cand[n - 1] != '.' && cand[n - 1] != '-';
|
||||
const size_t flen = strlen(kCertCommonName);
|
||||
const size_t hlen = strlen(kHostname);
|
||||
const bool isCertName = n == flen && ciEqN(cand, kCertCommonName, n);
|
||||
const bool isHostname = n == hlen && ciEqN(cand, kHostname, n);
|
||||
ok = ok && (isCertName || isHostname || dottedQuadPrivate(cand, n));
|
||||
// Accepted: emit the canonical spelling, not the caller's. A DNS name is
|
||||
// case-insensitive, so `KEYRELAY.LOCAL` matched -- and echoing a client's mixed case
|
||||
// into a header this board is authoring buys nothing worth the ambiguity.
|
||||
if (isCertName) fallback = kCertCommonName;
|
||||
else if (isHostname) fallback = kHostname;
|
||||
else if (ok) fallback = cand;
|
||||
else n = 0;
|
||||
}
|
||||
const size_t len = strlen(fallback);
|
||||
if (!out || cap < len + 1) return false;
|
||||
memcpy(out, fallback, len + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `https://<authority><path>` -- the whole `Location` value.
|
||||
*
|
||||
* Only the two paths this board actually has are carried over (`/` and `/index.html`);
|
||||
* anything else redirects to `/`. That is both the honest answer -- the board has two
|
||||
* content paths and everything else is a 404 on the encrypted port too -- and the safe
|
||||
* one: the request target is client text, and a bare CR or LF in it would inject headers
|
||||
* into the response this board is building. The character check below stays in as defence
|
||||
* in depth for whoever reuses this function with a longer path. The query is NEVER carried
|
||||
* over: the only query this board answers is `/status?token=`, and that request gets 426
|
||||
* precisely so its query does not travel twice.
|
||||
*
|
||||
* Returns the length written (the caller puts it in a body or a log), 0 if it would not fit.
|
||||
*/
|
||||
inline size_t renderRedirectTarget(const char *headerHost, const char *path, char *out,
|
||||
size_t cap) {
|
||||
if (!out || cap == 0) return 0;
|
||||
char authority[80];
|
||||
if (!redirectAuthority(headerHost, authority, sizeof(authority))) return 0;
|
||||
const char *safe = "/";
|
||||
if (path && path[0] == '/' && path[1]) {
|
||||
bool ok = true;
|
||||
for (size_t i = 1; path[i]; i++) {
|
||||
const char c = path[i];
|
||||
ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '.' || c == '-' || c == '_' || c == '~' || c == '/' || c == '%' ||
|
||||
c == '+' || c == '=';
|
||||
if (!ok) break;
|
||||
}
|
||||
// ... and one of the two paths this board serves. Both halves are load-bearing: the
|
||||
// character check stops injection, this one bounds the head and refuses to echo an
|
||||
// arbitrary target back as a promise.
|
||||
if (ok && (strcmp(path, kPathIndexHtml) == 0 || strcmp(path, kPathIndex) == 0))
|
||||
safe = path;
|
||||
}
|
||||
const int n = snprintf(out, cap, "https://%s%s", authority, safe);
|
||||
return (n > 0 && static_cast<size_t>(n) < cap) ? static_cast<size_t>(n) : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure: a body that lives in flash, drained in bounded pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The controller page is a PROGMEM raw string of several tens of kilobytes and no socket
|
||||
* call here may be handed all of it at once. This is the cursor that decides what leaves
|
||||
* next: at most `kPageChunkBytes` per transport write (the copy that bounds one call), and
|
||||
* at most `kPageBytesPerTick` per tick (the budget that bounds one loop() iteration).
|
||||
*
|
||||
* The bytes are COPIED into a bounded RAM buffer rather than passed through, even though on
|
||||
* ESP32 flash is memory-mapped and readable in place: the same code serves a transport that
|
||||
* reads the pointer directly (plaintext lwIP) and one that encrypts it (mbed TLS), and a
|
||||
* copy of one MSS is cheaper than a rule about what a transport may dereference. It also
|
||||
* keeps the resume arguments -- the bytes a partial write re-hands -- stable and small.
|
||||
*
|
||||
* `sent`/`total` are the per-connection send cursor the routing code keeps, and they stay
|
||||
* readable after the send completes so a caller can report "the page left intact".
|
||||
*/
|
||||
class FlashSend {
|
||||
public:
|
||||
void start(const char *src, size_t len) {
|
||||
_src = (src && len) ? src : nullptr;
|
||||
_total = _src ? len : 0;
|
||||
_sent = 0;
|
||||
}
|
||||
void stop() {
|
||||
_src = nullptr;
|
||||
_total = 0;
|
||||
_sent = 0;
|
||||
}
|
||||
|
||||
bool armed() const { return _src != nullptr; } // more bytes to copy
|
||||
bool done() const { return _sent >= _total; } // everything has been handed over
|
||||
size_t sent() const { return _sent; }
|
||||
size_t total() const { return _total; }
|
||||
size_t remaining() const { return _total - _sent; }
|
||||
|
||||
// How many bytes to copy for the next write: 0 when finished, when no budget is left, or
|
||||
// when the chunk would be empty. Never more than one chunk, never more than the budget.
|
||||
size_t next(size_t chunkCap, size_t budgetLeft) const {
|
||||
if (!armed() || chunkCap == 0 || budgetLeft == 0) return 0;
|
||||
size_t n = remaining();
|
||||
if (n > chunkCap) n = chunkCap;
|
||||
if (n > budgetLeft) n = budgetLeft;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Copy `n` bytes at the cursor. On ESP32 `_src` is in flash and a plain memcpy reads it
|
||||
// (there is no Harvard split to mind, unlike AVR's pgm_read_*); on the host it is memory.
|
||||
void copyTo(char *dst, size_t n) const {
|
||||
if (!armed() || !dst) return;
|
||||
if (n > remaining()) n = remaining();
|
||||
memcpy(dst, _src + _sent, n);
|
||||
}
|
||||
|
||||
// Called with what the transport actually accepted. Completing the send clears the source
|
||||
// (so armed() goes false and the caller can close the link) but keeps sent()/total().
|
||||
void advance(size_t n) {
|
||||
if (!armed()) return;
|
||||
const size_t left = remaining();
|
||||
_sent += (n < left) ? n : left;
|
||||
if (_sent >= _total) _src = nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
const char *_src = nullptr;
|
||||
size_t _total = 0;
|
||||
size_t _sent = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The transport itself (ESP only -- this is the half that touches sockets)
|
||||
// ---------------------------------------------------------------------------
|
||||
#if defined(ESP_PLATFORM)
|
||||
|
||||
/**
|
||||
* One control link, either transport, seen as a byte pipe with a resumable outbound queue.
|
||||
*
|
||||
* Not a base class (see the header comment). Not copyable: it is a *view* of a socket that
|
||||
* somebody else owns -- `ControlServer` owns the `WiFiClient` and the `tls::Session`
|
||||
* objects, and a copy of the view would be a second owner of the same close().
|
||||
*
|
||||
* Outbound bytes are queued, never written from the caller's stack frame: `push()` takes a
|
||||
* whole frame or nothing (a half-queued WebSocket frame corrupts the stream; a dropped
|
||||
* status frame is one second of staleness), and `pump()` drains what the transport will
|
||||
* take this tick. Inbound is a straight `read()` -- the caller's buffer IS the framing
|
||||
* state, which is why requests need no queue.
|
||||
*/
|
||||
class Conn {
|
||||
public:
|
||||
Conn() = default;
|
||||
Conn(const Conn &) = delete;
|
||||
Conn &operator=(const Conn &) = delete;
|
||||
|
||||
void bindPlain(WiFiClient *client, uint8_t *ringBuf, size_t ringCap) {
|
||||
resetOut();
|
||||
_kind = Kind::Plain;
|
||||
_plain = client;
|
||||
_ring.reseat(ringBuf, ringCap);
|
||||
}
|
||||
#if KR_TLS
|
||||
void bindTls(tls::Session *session, uint8_t *ringBuf, size_t ringCap) {
|
||||
resetOut();
|
||||
_kind = Kind::Tls;
|
||||
_tls = session;
|
||||
_ring.reseat(ringBuf, ringCap);
|
||||
}
|
||||
#endif
|
||||
// Forget the transport without closing it: the slot is being handed to a different socket
|
||||
// object, and whatever was queued for the old one is not coming.
|
||||
void unbind() {
|
||||
resetOut();
|
||||
_kind = Kind::None;
|
||||
_plain = nullptr;
|
||||
#if KR_TLS
|
||||
_tls = nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isTls() const { return _kind == Kind::Tls; }
|
||||
bool bound() const { return _kind != Kind::None; }
|
||||
|
||||
/** A socket is held, handshake pending or not. */
|
||||
bool attached() const {
|
||||
if (_kind == Kind::Plain) return _plain && static_cast<bool>(*_plain);
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls) return _tls && _tls->alive();
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
/** ... and the peer is still there. This is the question a controller link asks. */
|
||||
bool alive() const {
|
||||
if (_kind == Kind::Plain) return _plain && static_cast<bool>(*_plain) && _plain->connected();
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls) return _tls && _tls->alive();
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Application bytes may flow. Plaintext is ready the moment it is attached; TLS not
|
||||
* until the handshake completes. */
|
||||
bool ready() const {
|
||||
if (_kind == Kind::Plain) return attached();
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls) return _tls && _tls->ready();
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
/** One non-blocking step of the handshake. Plaintext has none, and says so. */
|
||||
tls::Handshake poll() {
|
||||
if (_kind == Kind::Plain) return tls::Handshake::Ready;
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls && _tls) return _tls->poll();
|
||||
#endif
|
||||
return tls::Handshake::Failed;
|
||||
}
|
||||
|
||||
/** Application bytes in. 0 means "nothing right now" (or "gone"), never "spin". */
|
||||
size_t read(uint8_t *dst, size_t cap) {
|
||||
if (!dst || cap == 0) return 0;
|
||||
if (_kind == Kind::Plain) {
|
||||
if (!_plain || !static_cast<bool>(*_plain) || !_plain->available()) return 0;
|
||||
const int n = _plain->read(dst, cap);
|
||||
return n > 0 ? static_cast<size_t>(n) : 0;
|
||||
}
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls && _tls) return _tls->read(dst, cap);
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Close the transport. Never waits; the queue goes with it. */
|
||||
void stop() {
|
||||
if (_kind == Kind::Plain && _plain && static_cast<bool>(*_plain)) _plain->stop();
|
||||
#if KR_TLS
|
||||
else if (_kind == Kind::Tls && _tls) _tls->close();
|
||||
#endif
|
||||
resetOut();
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-chance flush before a close, plaintext only: without it the FIN that follows a
|
||||
* WebSocket close frame often arrives as an RST and the peer never sees the goodbye.
|
||||
* Over TLS there is nothing to flush -- the close_notify rides the same queue, and
|
||||
* tls::Session::close() sends what it can and moves on.
|
||||
*/
|
||||
void flushBeforeClose() {
|
||||
if (_kind == Kind::Plain && _plain && static_cast<bool>(*_plain)) _plain->flush();
|
||||
}
|
||||
|
||||
// ---- outbound -------------------------------------------------------------
|
||||
|
||||
/** Whole frame or nothing: false leaves the queue untouched. */
|
||||
bool push(const uint8_t *src, size_t len) {
|
||||
if (!src || len == 0) return len == 0;
|
||||
return _ring.push(src, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two segments queued atomically -- an HTTP head followed by a body that lives in RAM
|
||||
* (a status JSON, a one-line error). Either both fit or neither is queued, because a
|
||||
* head without its body is a response that lies about Content-Length.
|
||||
*/
|
||||
bool pushPair(const uint8_t *a, size_t alen, const uint8_t *b, size_t blen) {
|
||||
if (alen + blen > _ring.capacity() - _ring.count()) return false;
|
||||
if (alen && !_ring.push(a, alen)) return false;
|
||||
if (blen && !_ring.push(b, blen)) return false; // cannot happen: sized above
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A head in RAM followed by a body in flash: the page, and nothing else is this big.
|
||||
* The head is queued, the body is armed here and copied through the chunk buffer as the
|
||||
* pump gets to it.
|
||||
*/
|
||||
bool pushFlashBody(const uint8_t *head, size_t headLen, const char *body, size_t bodyLen) {
|
||||
if (!body || bodyLen == 0) return push(head, headLen) || headLen == 0;
|
||||
if (headLen && headLen > _ring.capacity() - _ring.count()) return false;
|
||||
if (headLen && !_ring.push(head, headLen)) return false;
|
||||
_flash.start(body, bodyLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool busyOut() const {
|
||||
return _ring.count() != 0 || _pendLen != 0 || _flash.armed();
|
||||
}
|
||||
size_t queuedOut() const { return _ring.count() + _pendLen + _flash.remaining(); }
|
||||
const FlashSend &flashSend() const { return _flash; }
|
||||
|
||||
/**
|
||||
* Hand the transport as much as it will take, up to `budgetBytes` this call, one write of
|
||||
* at most `kConnWriteChunk` at a time. True means the queue is empty; false means "not
|
||||
* yet" -- including "the peer is not reading", which the caller owns as a stall deadline.
|
||||
* Never waits, never spins, and a partial write resumes from the identical bytes.
|
||||
*/
|
||||
bool pump(size_t budgetBytes) {
|
||||
while (budgetBytes > 0) {
|
||||
if (_pendLen == 0) {
|
||||
size_t n = 0;
|
||||
const size_t cap = budgetBytes < kConnWriteChunk ? budgetBytes : kConnWriteChunk;
|
||||
const uint8_t *p = _ring.head(cap, &n);
|
||||
if (p && n) {
|
||||
_pendPtr = p;
|
||||
_pendIsChunk = false;
|
||||
_pendLen = n;
|
||||
} else if (_flash.armed()) {
|
||||
// The ring first, always: the head precedes the body it describes.
|
||||
const size_t c = _flash.next(kPageChunkBytes, budgetBytes);
|
||||
if (c == 0) return false; // budget spent; the rest goes next tick
|
||||
_flash.copyTo(_chunk, c);
|
||||
_pendPtr = reinterpret_cast<const uint8_t *>(_chunk);
|
||||
_pendIsChunk = true;
|
||||
_pendLen = c;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const size_t acc = writeRaw(_pendPtr, _pendLen);
|
||||
if (acc == 0) return false;
|
||||
if (_pendIsChunk) _flash.advance(acc);
|
||||
else _ring.advance(acc);
|
||||
_pendPtr += acc;
|
||||
_pendLen -= acc;
|
||||
budgetBytes -= (acc < budgetBytes) ? acc : budgetBytes;
|
||||
}
|
||||
return !busyOut();
|
||||
}
|
||||
|
||||
/** Drop everything queued. Used when the link is going away, never mid-response. */
|
||||
void resetOut() {
|
||||
_ring.reset();
|
||||
_pendPtr = nullptr;
|
||||
_pendIsChunk = false;
|
||||
_pendLen = 0;
|
||||
_flash.stop();
|
||||
}
|
||||
|
||||
private:
|
||||
/** What the transport accepted this call: whole, partial or nothing. Never waits. */
|
||||
size_t writeRaw(const uint8_t *p, size_t len) {
|
||||
if (!p || len == 0) return 0;
|
||||
if (_kind == Kind::Plain) {
|
||||
if (!_plain || !static_cast<bool>(*_plain)) return 0;
|
||||
const int fd = _plain->fd();
|
||||
if (fd < 0) return 0;
|
||||
// Never enter NetworkClient::write() unless the socket will take bytes THIS instant.
|
||||
// That helper retries a blocking select() up to WIFI_CLIENT_MAX_WRITE_RETRY (10) times
|
||||
// with WIFI_CLIENT_SELECT_TIMEOUT_US (1 s) each, which is how one laptop that stopped
|
||||
// reading -- locked tab, asleep phone, closed lid -- could hold loop(), and therefore
|
||||
// the keystroke path, for ten seconds inside a single page write. A zero-timeout
|
||||
// select turns "not now" into a 0 return and a retry on the next tick, which is what
|
||||
// pump() already handles for TLS.
|
||||
fd_set wfds;
|
||||
FD_ZERO(&wfds);
|
||||
FD_SET(fd, &wfds);
|
||||
struct timeval tv;
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = 0;
|
||||
if (select(fd + 1, nullptr, &wfds, nullptr, &tv) <= 0) return 0;
|
||||
if (!FD_ISSET(fd, &wfds)) return 0;
|
||||
return _plain->write(p, len); // may be partial; the caller keeps the cursor
|
||||
}
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls && _tls) return _tls->write(p, len);
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
enum class Kind : uint8_t { None, Plain, Tls };
|
||||
|
||||
Kind _kind = Kind::None;
|
||||
WiFiClient *_plain = nullptr; // owned by ControlServer, outlives every use here
|
||||
#if KR_TLS
|
||||
tls::Session *_tls = nullptr; // likewise
|
||||
#endif
|
||||
tls::TxRing _ring{nullptr, 1};
|
||||
const uint8_t *_pendPtr = nullptr;
|
||||
bool _pendIsChunk = false;
|
||||
size_t _pendLen = 0;
|
||||
FlashSend _flash;
|
||||
char _chunk[kPageChunkBytes]; // the bounded RAM copy of the flash body
|
||||
};
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
|
||||
} // namespace kr
|
||||
Reference in New Issue
Block a user