No frame crosses until the Upgrade completes (transport plane, ws)

ws is the browser-reachable kind: a page cannot open a raw TCP socket, so libtracer frames reach it inside RFC 6455 messages. The price is a phase no other stream kind has.

Before the first byte of a frame, the client sends GET / HTTP/1.1 with Upgrade: websocket and a fresh 16-byte nonce in Sec-WebSocket-Key; the server answers 101 Switching Protocols with Sec-WebSocket-Accept set to base64(sha1(key ++ RFC-6455-GUID)). The example drives that from a raw POSIX socket and checks the answer against ws::accept_key — so the 101 is shown to be computed from the client’s own nonce, not a constant a stub could echo.

What to notice

  • ok() on a WS transport is the handshake’s verdict, not the socket’s. The TCP connect succeeding is not the link coming up. The example proves the point twice: once by hand, once behind transport_ws_client, which does exactly the exchange spelled out above.

  • One libtracer frame is one BINARY message. The sink is handed the payload; the WS header never reaches it. Client→server frames are masked (§5.1), server→client are not — and both directions land in the same sink shape, because masking is the kind’s business.

  • The handshake is also an attack surface, and it has its own budget. The peer on that path has authenticated nothing and is making this node accumulate a header block, so max_handshake is a PRE-AUTH request-size bound (#934). The example shows both arms: a smaller budget is honoured, a larger one is clamped back.

  • Tighten-only is the general shape of a config-writable bound here. A key an unauthenticated peer’s deployment can reach may narrow what it costs the node and may never widen it — the same rule max_frame follows on every kind.

  • The multi-peer listener is the same object. transport_ws_server shares its slot/poll machinery with transport_tcp_server (#871); what WS adds is the packaging. The slot side is its own page.

  • This target needs the WS transport. It is built only when LIBTRACER_TRANSPORT_WS is on (the default). Nothing in it is conditional at run time.

Source

  1/*
  2 * SPDX-License-Identifier: Apache-2.0
  3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
  4 */
  5
  6/**
  7 * @file
  8 * @brief ONE CONCEPT — a `ws` link carries no frame until an HTTP/1.1 Upgrade has
  9 *        completed, and the `101` is COMPUTED from the client's own nonce, so the
 10 *        handshake is a real exchange rather than a greeting — which is why `ok()` on a WS
 11 *        transport is the handshake's verdict and not the socket's.
 12 *
 13 * `ws` is the browser-reachable kind: a page cannot open a raw TCP socket, so libtracer
 14 * frames reach it inside RFC 6455 messages. The price is a phase no other stream kind has.
 15 * Before the first byte of a frame:
 16 *
 17 *  - the client sends `GET / HTTP/1.1` with `Upgrade: websocket` and a fresh 16-byte nonce
 18 *    base64'd into `Sec-WebSocket-Key`;
 19 *  - the server answers `101 Switching Protocols` with `Sec-WebSocket-Accept` set to
 20 *    `base64(sha1(key ++ RFC-6455-GUID))` — `ws::accept_key`, which this example calls
 21 *    itself to check the server's answer against;
 22 *  - only then does either side write a frame, each libtracer frame being exactly one
 23 *    BINARY message (client→server masked per §5.1, server→client unmasked).
 24 *
 25 * That phase is also an attack surface unique to this kind: the peer is unauthenticated and
 26 * is making this node accumulate a header block. Hence `max_handshake`, a PRE-AUTH budget
 27 * that is TIGHTEN-ONLY (#934) — a config-writable key may narrow what an anonymous peer can
 28 * cost the node and may never widen it.
 29 *
 30 * The handshake is driven from a raw POSIX socket so it is visible on the wire; the shipped
 31 * `transport_ws_client` then does the same thing behind `ok()`.
 32 *
 33 * Needs the WS transport (`LIBTRACER_TRANSPORT_WS`, on by default). Runs under ctest as
 34 * `example_net_ws_upgrade`; returns non-zero on any failed check.
 35 */
 36
 37#include <arpa/inet.h>
 38#include <netinet/in.h>
 39#include <poll.h>
 40#include <sys/socket.h>
 41#include <unistd.h>
 42
 43#include <chrono>
 44#include <condition_variable>
 45#include <cstddef>
 46#include <cstdint>
 47#include <cstdio>
 48#include <mutex>
 49#include <span>
 50#include <string>
 51#include <vector>
 52
 53#include "libtracer/transport_ws.hpp"
 54#include "libtracer/ws.hpp"
 55
 56namespace {
 57
 58using namespace std::chrono_literals;
 59namespace ws = tr::net::ws;
 60
 61/** @brief Report expectation @p what and record a failure on @p ok. */
 62void check(bool& ok, bool cond, const char* what) {
 63    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
 64    ok = ok && cond;
 65}
 66
 67/** @brief A thread-safe borrowed-span sink: the recv thread pushes, `main` waits. */
 68class sink_t {
 69   public:
 70    /** @brief The receiver callback — copies the span, which dies when it returns. */
 71    void operator()(std::span<const std::byte> frame) {
 72        {
 73            const std::lock_guard lock(m_);
 74            frames_.emplace_back(frame.begin(), frame.end());
 75        }
 76        cv_.notify_all();
 77    }
 78
 79    /** @brief Wait until at least @p n frames have landed, or @p budget expires. */
 80    [[nodiscard]] bool wait_for(std::size_t n, std::chrono::milliseconds budget) {
 81        std::unique_lock lock(m_);
 82        return cv_.wait_for(lock, budget, [&] { return frames_.size() >= n; });
 83    }
 84
 85    /** @brief How many frames have landed so far. */
 86    [[nodiscard]] std::size_t count() const {
 87        const std::lock_guard lock(m_);
 88        return frames_.size();
 89    }
 90
 91    /** @brief Frame @p i, by value. */
 92    [[nodiscard]] std::vector<std::byte> at(std::size_t i) const {
 93        const std::lock_guard lock(m_);
 94        return frames_.at(i);
 95    }
 96
 97   private:
 98    mutable std::mutex m_;
 99    std::condition_variable cv_;
100    std::vector<std::vector<std::byte>> frames_;
101};
102
103/** @brief A raw POSIX TCP client — the hand that types the HTTP request by hand. */
104class raw_client_t {
105   public:
106    /** @brief Connect to `127.0.0.1:@p port`; @ref ok reports whether it succeeded. */
107    explicit raw_client_t(std::uint16_t port) {
108        fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
109        sockaddr_in peer{};
110        peer.sin_family = AF_INET;
111        peer.sin_port = htons(port);
112        ::inet_pton(AF_INET, "127.0.0.1", &peer.sin_addr);
113        if (::connect(fd_, reinterpret_cast<sockaddr*>(&peer), sizeof(peer)) < 0) {
114            ::close(fd_);
115            fd_ = -1;
116        }
117    }
118    ~raw_client_t() {
119        if (fd_ >= 0) ::close(fd_);
120    }
121
122    raw_client_t(const raw_client_t&) = delete;
123    raw_client_t& operator=(const raw_client_t&) = delete;
124
125    /** @brief True iff the connect succeeded. */
126    [[nodiscard]] bool ok() const noexcept { return fd_ >= 0; }
127
128    /** @brief Push @p bytes, resuming partial writes. */
129    void write(std::span<const std::byte> bytes) {
130        std::size_t off = 0;
131        while (off < bytes.size()) {
132            const ssize_t n = ::send(fd_, bytes.data() + off, bytes.size() - off, 0);
133            if (n <= 0) return;
134            off += static_cast<std::size_t>(n);
135        }
136    }
137
138    /** @brief Push @p text as bytes. */
139    void write_text(std::string_view text) {
140        write(std::as_bytes(std::span(text.data(), text.size())));
141    }
142
143    /** @brief Read until `\r\n\r\n` is in hand, or @p budget expires — the header block. */
144    [[nodiscard]] std::string read_headers(std::chrono::milliseconds budget) {
145        std::string got;
146        const auto deadline = std::chrono::steady_clock::now() + budget;
147        while (got.find("\r\n\r\n") == std::string::npos) {
148            const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(
149                deadline - std::chrono::steady_clock::now());
150            if (left.count() <= 0) break;
151            pollfd p{fd_, POLLIN, 0};
152            if (::poll(&p, 1, static_cast<int>(left.count())) <= 0) break;
153            char buf[256];
154            const ssize_t n = ::recv(fd_, buf, sizeof(buf), 0);
155            if (n <= 0) break;
156            got.append(buf, static_cast<std::size_t>(n));
157        }
158        return got;
159    }
160
161   private:
162    int fd_ = -1;
163};
164
165/** @brief @p n bytes counting up from @p seed — a stand-in for an encoded frame. */
166std::vector<std::byte> frame_of(std::size_t n, unsigned seed) {
167    std::vector<std::byte> f(n);
168    for (std::size_t i = 0; i < n; ++i) f[i] = static_cast<std::byte>(seed + i);
169    return f;
170}
171
172}  // namespace
173
174int main() {
175    bool ok = true;
176
177    sink_t at_server;
178    tr::net::transport_ws_server server(std::uint16_t{0});
179    server.set_receiver(at_server);
180    check(ok, server.ok(), "the WS listener bound an ephemeral port");
181
182    // --- The Upgrade, typed by hand -----------------------------------------------------
183    std::printf("the opening handshake, on the wire:\n");
184    raw_client_t raw(server.local_port());
185    check(ok, raw.ok(), "a raw TCP client reached the listener's port");
186
187    // The RFC 6455 §1.3 example nonce, so the expected accept value is reproducible; a real
188    // client mints a fresh random one per connection, which is what makes the reply a proof
189    // that the server actually ran the computation.
190    const std::string client_key = "dGhlIHNhbXBsZSBub25jZQ==";
191    std::string upgrade =
192        "GET / HTTP/1.1\r\n"
193        "Host: 127.0.0.1\r\n"
194        "Upgrade: websocket\r\n"
195        "Connection: Upgrade\r\n"
196        "Sec-WebSocket-Key: ";
197    upgrade += client_key;
198    upgrade += "\r\nSec-WebSocket-Version: 13\r\n\r\n";
199    raw.write_text(upgrade);
200
201    const std::string response = raw.read_headers(2s);
202    check(ok, response.find("101 Switching Protocols") != std::string::npos,
203          "the server answered 101 Switching Protocols");
204    check(ok,
205          response.find("Sec-WebSocket-Accept: " + ws::accept_key(client_key)) != std::string::npos,
206          "…with Sec-WebSocket-Accept derived from OUR key, not a constant");
207
208    // --- One libtracer frame is one BINARY message --------------------------------------
209    std::printf("a frame, once the upgrade is done:\n");
210    const auto payload = frame_of(9, 0x10);
211    raw.write(ws::encode_client_frame(ws::opcode_t::BINARY, payload, 0x37FA213Du));
212    check(ok, at_server.wait_for(1, 2s), "the masked BINARY message reached the receiver");
213    check(ok, at_server.at(0) == payload,
214          "…unmasked and stripped: the sink sees the frame, never the WS header");
215
216    // --- The same handshake, behind ok() ------------------------------------------------
217    std::printf("the shipped dialer does exactly that:\n");
218    sink_t at_client;
219    tr::net::transport_ws_client client("127.0.0.1", server.local_port());
220    client.set_receiver(at_client);
221    check(ok, client.ok(), "ok() on a WS client is the HANDSHAKE's verdict, not the socket's");
222
223    const auto up = frame_of(6, 0x20);
224    client.send(up);
225    check(ok, at_server.wait_for(2, 2s), "the dialer's frame arrived too");
226    check(ok, at_server.at(1) == up, "…byte-identical");
227
228    // Server→client messages are unmasked (§5.1 masks only the client direction), and the
229    // sink on either side is handed the same thing: the frame.
230    const auto down = frame_of(4, 0x30);
231    server.send(down);
232    check(ok, at_client.wait_for(1, 2s), "and the server's BINARY message came back down");
233    check(ok, at_client.at(0) == down, "…byte-identical, unmasked direction");
234
235    // --- The budget the handshake makes necessary ---------------------------------------
236    check(ok, server.effective_max_handshake() > 0,
237          "the pre-auth handshake budget is a real, positive bound (#934)");
238    tr::net::transport_ws_server tight(std::uint16_t{0}, &tr::mem::heap_backend(), 0, 0, false, 0,
239                                       0, /*max_handshake=*/256);
240    check(ok, tight.effective_max_handshake() == 256, "a smaller budget is honoured…");
241    tr::net::transport_ws_server loose(std::uint16_t{0}, &tr::mem::heap_backend(), 0, 0, false, 0,
242                                       0, /*max_handshake=*/1u << 30);
243    check(ok, loose.effective_max_handshake() == server.effective_max_handshake(),
244          "…and a LARGER one is clamped back — tighten-only, because the peer is anonymous");
245
246    std::printf("ws: 1 upgrade, %zu frames up, %zu down, handshake budget %zu bytes\n",
247                at_server.count(), at_client.count(), server.effective_max_handshake());
248    return ok ? 0 : 1;
249}

See also: transport module · WebSocket session & auth reference · connection config · the raw stream underneath.