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 behindws_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_handshakeis 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_framefollows on every kind.The multi-peer listener is the same object.
ws_server_transport_tshares its slot/poll machinery withtcp_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_WSis 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.