**Final verification pass** (all three task reports PASS; tasks already in `complete/`). One real defect found and fixed.
**Commands / results.** `bash tests/run_tests.sh` → `RESULT: all suites passed`, **5 104** checks (page **220**). `SANITIZE=1 bash tests/run_tests.sh` → passed. `bash .agents/validate.sh` → `validation OK`; bridge/console/rollback `SUCCESS`; `page bytes: 59262`; `README/AGENTS claims: OK`; `repo hygiene: OK`. Real-browser leg (headless Chromium, served assembled bytes): no horizontal scroll at 360/390/768/1440, ARM first-in-`main` and sticky, log height-capped, all 5 folds openable, all 3 changed page statements execute.
**Fix.** The folded notes' `<summary>` — the only route to five pinned safety sentences — rendered **17 px** on coarse pointers, under the page's own 44 px thumb floor, while every byte-level assertion stayed green. `src/web_page.h`: `button.k,.pill,input,summary{min-height:44px}` + `summary{padding:13px 0}`; `tests/test_page.js` +1 check (219→220); mutation-tested (reverting the CSS fails host *and* browser legs, then restored byte-for-byte).
**Criteria.** 1 ✓ browser-measured (physical phone still unobserved). 2 ✓ 220 checks + prose untouched. 3 ✓ page 59 286→**59 262 B** (−24 vs phase start), README = gate figure. 4 ✓ one `test_page` row at 220, intro 5 104 = measured. 5 ✓ gate green; phases 01–03 assertions unchanged apart from the re-pinned byte constant.
**Docs.** Re-pinned 59 262 (README, `config.h`, `test_conn.cpp`), totals 5 103→5 104, README Tests row 220; re-measured footprint rows (flash 1 257 901 / 1 178 141 / 1 157 593, TLS +6 176/+100 308); fixed stale "48 KB"/"~50 KB" (×4 src comments, ×2 README) and the 51 626-byte page-delivery row; added **bug 14** with root cause and renamed *Thirteen*→*Fourteen bugs* in README + AGENTS.md's two stale references; added the Phase 04 browser+device row to `.agents/HW_VERIFICATION.md`.
**Deviations.** Device leg stays open: live board serves 51 626 B (pre-phase image); no reflash attempted, per the standing no-unattended-reflash rule — no byte-equality claim made. `ruff check tools tests`: 29 findings, identical at HEAD and `--isolated`, `E9,F` clean, no repo lint config or gate step → left untouched. `.agents/validate.sh` is **untracked and has never been committed** (`git log --all --` empty) though README/AGENTS/PLAN cite it 13× — harness needs `git add` it; I did not stage anything.
664 lines
29 KiB
C++
664 lines
29 KiB
C++
/**
|
|
* 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 ~58 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
|