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 ws_client_transport_t, 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. ws_server_transport_t shares its slot/poll machinery with tcp_server_transport_t (#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 * `ws_client_transport_t` 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/mem_heap.hpp"
 54#include "libtracer/transport_ws.hpp"
 55#include "libtracer/ws.hpp"
 56
 57namespace {
 58
 59using namespace std::chrono_literals;
 60namespace ws = tr::net::ws;
 61
 62/** @brief Report expectation @p what and record a failure on @p ok. */
 63void check(bool& ok, bool cond, const char* what) {
 64    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
 65    ok = ok && cond;
 66}
 67
 68/** @brief A thread-safe borrowed-span sink: the recv thread pushes, `main` waits. */
 69class sink_t {
 70   public:
 71    /** @brief The receiver callback — copies the span, which dies when it returns. */
 72    void operator()(std::span<const std::byte> frame) {
 73        {
 74            const std::lock_guard lock(m_);
 75            frames_.emplace_back(frame.begin(), frame.end());
 76        }
 77        cv_.notify_all();
 78    }
 79
 80    /** @brief Wait until at least @p n frames have landed, or @p budget expires. */
 81    [[nodiscard]] bool wait_for(std::size_t n, std::chrono::milliseconds budget) {
 82        std::unique_lock lock(m_);
 83        return cv_.wait_for(lock, budget, [&] { return frames_.size() >= n; });
 84    }
 85
 86    /** @brief How many frames have landed so far. */
 87    [[nodiscard]] std::size_t count() const {
 88        const std::lock_guard lock(m_);
 89        return frames_.size();
 90    }
 91
 92    /** @brief Frame @p i, by value. */
 93    [[nodiscard]] std::vector<std::byte> at(std::size_t i) const {
 94        const std::lock_guard lock(m_);
 95        return frames_.at(i);
 96    }
 97
 98   private:
 99    mutable std::mutex m_;
100    std::condition_variable cv_;
101    std::vector<std::vector<std::byte>> frames_;
102};
103
104/** @brief A raw POSIX TCP client — the hand that types the HTTP request by hand. */
105class raw_client_t {
106   public:
107    /** @brief Connect to `127.0.0.1:@p port`; @ref ok reports whether it succeeded. */
108    explicit raw_client_t(std::uint16_t port) {
109        fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
110        sockaddr_in peer{};
111        peer.sin_family = AF_INET;
112        peer.sin_port = htons(port);
113        ::inet_pton(AF_INET, "127.0.0.1", &peer.sin_addr);
114        if (::connect(fd_, reinterpret_cast<sockaddr*>(&peer), sizeof(peer)) < 0) {
115            ::close(fd_);
116            fd_ = -1;
117        }
118    }
119    ~raw_client_t() {
120        if (fd_ >= 0) ::close(fd_);
121    }
122
123    raw_client_t(const raw_client_t&) = delete;
124    raw_client_t& operator=(const raw_client_t&) = delete;
125
126    /** @brief True iff the connect succeeded. */
127    [[nodiscard]] bool ok() const noexcept { return fd_ >= 0; }
128
129    /** @brief Push @p bytes, resuming partial writes. */
130    void write(std::span<const std::byte> bytes) {
131        std::size_t off = 0;
132        while (off < bytes.size()) {
133            const ssize_t n = ::send(fd_, bytes.data() + off, bytes.size() - off, 0);
134            if (n <= 0) return;
135            off += static_cast<std::size_t>(n);
136        }
137    }
138
139    /** @brief Push @p text as bytes. */
140    void write_text(std::string_view text) {
141        write(std::as_bytes(std::span(text.data(), text.size())));
142    }
143
144    /** @brief Read until `\r\n\r\n` is in hand, or @p budget expires — the header block. */
145    [[nodiscard]] std::string read_headers(std::chrono::milliseconds budget) {
146        std::string got;
147        const auto deadline = std::chrono::steady_clock::now() + budget;
148        while (got.find("\r\n\r\n") == std::string::npos) {
149            const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(
150                deadline - std::chrono::steady_clock::now());
151            if (left.count() <= 0) break;
152            pollfd p{fd_, POLLIN, 0};
153            if (::poll(&p, 1, static_cast<int>(left.count())) <= 0) break;
154            char buf[256];
155            const ssize_t n = ::recv(fd_, buf, sizeof(buf), 0);
156            if (n <= 0) break;
157            got.append(buf, static_cast<std::size_t>(n));
158        }
159        return got;
160    }
161
162   private:
163    int fd_ = -1;
164};
165
166/** @brief @p n bytes counting up from @p seed — a stand-in for an encoded frame. */
167std::vector<std::byte> frame_of(std::size_t n, unsigned seed) {
168    std::vector<std::byte> f(n);
169    for (std::size_t i = 0; i < n; ++i) f[i] = static_cast<std::byte>(seed + i);
170    return f;
171}
172
173}  // namespace
174
175int main() {
176    bool ok = true;
177
178    sink_t at_server;
179    tr::net::ws_server_transport_t server(std::uint16_t{0});
180    server.set_receiver(at_server);
181    check(ok, server.ok(), "the WS listener bound an ephemeral port");
182
183    // --- The Upgrade, typed by hand -----------------------------------------------------
184    std::printf("the opening handshake, on the wire:\n");
185    raw_client_t raw(server.local_port());
186    check(ok, raw.ok(), "a raw TCP client reached the listener's port");
187
188    // The RFC 6455 §1.3 example nonce, so the expected accept value is reproducible; a real
189    // client mints a fresh random one per connection, which is what makes the reply a proof
190    // that the server actually ran the computation.
191    const std::string client_key = "dGhlIHNhbXBsZSBub25jZQ==";
192    std::string upgrade =
193        "GET / HTTP/1.1\r\n"
194        "Host: 127.0.0.1\r\n"
195        "Upgrade: websocket\r\n"
196        "Connection: Upgrade\r\n"
197        "Sec-WebSocket-Key: ";
198    upgrade += client_key;
199    upgrade += "\r\nSec-WebSocket-Version: 13\r\n\r\n";
200    raw.write_text(upgrade);
201
202    const std::string response = raw.read_headers(2s);
203    check(ok, response.find("101 Switching Protocols") != std::string::npos,
204          "the server answered 101 Switching Protocols");
205    check(ok,
206          response.find("Sec-WebSocket-Accept: " +
207                        std::string(ws::accept_key(client_key).view())) != std::string::npos,
208          "…with Sec-WebSocket-Accept derived from OUR key, not a constant");
209
210    // --- One libtracer frame is one BINARY message --------------------------------------
211    std::printf("a frame, once the upgrade is done:\n");
212    const auto payload = frame_of(9, 0x10);
213    tr::mem::bytes_t masked(tr::mem::heap_source());
214    const std::size_t masked_len =
215        ws::try_encode_client_frame(masked, ws::opcode_t::BINARY, payload, 0x37FA213Du);
216    raw.write(std::span<const std::byte>(masked.data(), masked_len));
217    check(ok, at_server.wait_for(1, 2s), "the masked BINARY message reached the receiver");
218    check(ok, at_server.at(0) == payload,
219          "…unmasked and stripped: the sink sees the frame, never the WS header");
220
221    // --- The same handshake, behind ok() ------------------------------------------------
222    std::printf("the shipped dialer does exactly that:\n");
223    sink_t at_client;
224    tr::net::ws_client_transport_t client("127.0.0.1", server.local_port());
225    client.set_receiver(at_client);
226    check(ok, client.ok(), "ok() on a WS client is the HANDSHAKE's verdict, not the socket's");
227
228    const auto up = frame_of(6, 0x20);
229    client.send(up);
230    check(ok, at_server.wait_for(2, 2s), "the dialer's frame arrived too");
231    check(ok, at_server.at(1) == up, "…byte-identical");
232
233    // Server→client messages are unmasked (§5.1 masks only the client direction), and the
234    // sink on either side is handed the same thing: the frame.
235    const auto down = frame_of(4, 0x30);
236    server.send(down);
237    check(ok, at_client.wait_for(1, 2s), "and the server's BINARY message came back down");
238    check(ok, at_client.at(0) == down, "…byte-identical, unmasked direction");
239
240    // --- The budget the handshake makes necessary ---------------------------------------
241    check(ok, server.effective_max_handshake() > 0,
242          "the pre-auth handshake budget is a real, positive bound (#934)");
243    tr::net::ws_server_transport_t tight(std::uint16_t{0}, {.max_handshake = 256});
244    check(ok, tight.effective_max_handshake() == 256, "a smaller budget is honoured…");
245    tr::net::ws_server_transport_t loose(std::uint16_t{0}, {.max_handshake = 1u << 30});
246    check(ok, loose.effective_max_handshake() == server.effective_max_handshake(),
247          "…and a LARGER one is clamped back — tighten-only, because the peer is anonymous");
248
249    std::printf("ws: 1 upgrade, %zu frames up, %zu down, handshake budget %zu bytes\n",
250                at_server.count(), at_client.count(), server.effective_max_handshake());
251    return ok ? 0 : 1;
252}

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