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 behindtransport_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_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.
transport_ws_servershares its slot/poll machinery withtransport_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_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 * `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.