<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Илья Федотов</title>
    <description>The latest articles on DEV Community by Илья Федотов (@__bf699f275acc).</description>
    <link>https://dev.to/__bf699f275acc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2847528%2Fe1fbd949-6a21-419d-8760-119cd6eb698b.jpg</url>
      <title>DEV Community: Илья Федотов</title>
      <link>https://dev.to/__bf699f275acc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/__bf699f275acc"/>
    <language>en</language>
    <item>
      <title># Receive buffers and flow control in Rust multiplexer</title>
      <dc:creator>Илья Федотов</dc:creator>
      <pubDate>Sat, 26 Sep 2026 13:24:35 +0000</pubDate>
      <link>https://dev.to/__bf699f275acc/-receive-buffers-and-flow-control-in-rust-multiplexer-2cl0</link>
      <guid>https://dev.to/__bf699f275acc/-receive-buffers-and-flow-control-in-rust-multiplexer-2cl0</guid>
      <description>&lt;p&gt;Take a 1 MiB receive window. A peer sends 256 KiB, leaving 768 KiB of credit. The receiver takes that buffer out of its queue and starts writing it to a socket. The socket stalls.&lt;/p&gt;

&lt;p&gt;The queue is empty, but the buffer still occupies 256 KiB. Returning credit at that point would let replacement data arrive while the write was still pending.&lt;/p&gt;

&lt;p&gt;BibaVPN keeps those bytes accounted for until &lt;code&gt;write_all()&lt;/code&gt; completes. This is part of the TCP multiplexer in my experimental Rust tunnel, which carries several logical streams through a shared WebSocket connection.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbf062cx7a22n472lmde.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbf062cx7a22n472lmde.png" alt="One WebSocket reader dispatches records to separate queues and socket-writing pumps. A stalled destination retains its own buffer while the reader continues dispatching other streams." width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Conceptual receive path. The independent pumps separate socket writes from shared record dispatch; all streams still share the outer TCP transport.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A mux record starts with a nine-byte header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Offset       0                 4       5                 9
             +-----------------+-------+-----------------+---------+
             | stream_id       | flags | payload_length  | payload |
             | u32, big-endian | u8    | u32, big-endian |         |
             +-----------------+-------+-----------------+---------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decoder checks the declared payload length and rejects trailing bytes. Once decoded, the stream ID tells the receiver where to dispatch the record. The flags identify operations such as OPEN, DATA, CLOSE, RST, and WIN.&lt;/p&gt;

&lt;p&gt;Writing directly to a destination socket from the shared reader would make every stream depend on that destination's speed. Instead, the implementation keeps per-stream receive state and separate pumps for the two directions. Its optional, negotiated byte-credit extension controls how much data can remain outstanding.&lt;/p&gt;

&lt;p&gt;Here is the 256 KiB example through one complete receive cycle:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8n42h5efrj21k3p9kybg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8n42h5efrj21k3p9kybg.png" alt="A 256 KiB record consumes receive credit. Credit remains consumed while the socket write is pending and is returned through a window update after the write completes" width="800" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Worked example in negotiated credit mode. No other data arrives during this sequence; the empty queue triggers publication of the accumulated credit after the write.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The relevant operations in &lt;a href="https://github.com/Eljaja/BibaVPN/blob/main/bibavpn/src/tcp_mux_flow.rs" rel="noopener noreferrer"&gt;tcp_mux_flow.rs&lt;/a&gt; are ordered like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="nf"&gt;.write_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;drop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="nf"&gt;.consumed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The window-update logic follows those operations. It batches consumed bytes and publishes credit when the accumulated amount reaches one eighth of the receive limit, or when the queue is empty. While the socket write is pending, the buffer remains charged to the stream.&lt;/p&gt;

&lt;p&gt;There is a second limit at session level. Each admitted stream reserves its local receive allowance against a 64 MiB logical receive budget:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Local receive window&lt;/th&gt;
&lt;th&gt;Reservations that fit in 64 MiB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 MiB&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 MiB&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 MiB&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 MiB&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are admission-budget calculations. Process memory also includes retained allocations, output queues, TLS state, tasks, and other overhead.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bytes::Bytes&lt;/code&gt; makes the allocation detail worth checking. A short slice can retain the allocation from which it was taken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Backing allocation: 8 KiB
+-------------------------------------------------------------+
| header / padding | payload slice | unused capacity           |
+-------------------------------------------------------------+
                     ^^^^^^^^^^^^^
                     visible bytes

Keeping the slice can keep the entire allocation alive.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Counting only &lt;code&gt;slice.len()&lt;/code&gt; can therefore understate memory retained by queued data. The receive implementation includes compaction and backing-allocation accounting alongside its logical byte limits.&lt;/p&gt;

&lt;p&gt;Closing has a related lifetime problem. A client may finish sending its request while it still expects a response. In negotiated mode, CLOSE is directional: the other pump can continue. Legacy mode keeps its older behavior of closing both directions.&lt;/p&gt;

&lt;p&gt;None of the per-stream accounting changes TCP's ordered delivery. Packet loss on one outer connection can still delay data for every logical stream carried by it. Separate receive queues address slow destination sockets inside the application; outer transport loss remains shared.&lt;/p&gt;

&lt;p&gt;For code review, the useful places to start are &lt;code&gt;Flow::enqueue&lt;/code&gt;, &lt;code&gt;Flow::receive&lt;/code&gt;, &lt;code&gt;Flow::consumed&lt;/code&gt;, and the downlink write loop. Together they show when a buffer enters the accounting, who owns it while a write is blocked, and when the peer gets permission to send more.&lt;/p&gt;

&lt;p&gt;Source code, protocol documentation, and tests: &lt;a href="https://github.com/Eljaja/BibaVPN" rel="noopener noreferrer"&gt;BibaVPN on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>networking</category>
      <category>rust</category>
      <category>systems</category>
    </item>
    <item>
      <title>What 255 bytes of padding can and can’t do for an encrypted tunnel</title>
      <dc:creator>Илья Федотов</dc:creator>
      <pubDate>Sat, 26 Sep 2026 12:28:28 +0000</pubDate>
      <link>https://dev.to/__bf699f275acc/what-255-bytes-of-padding-can-and-cant-do-for-an-encrypted-tunnel-1m93</link>
      <guid>https://dev.to/__bf699f275acc/what-255-bytes-of-padding-can-and-cant-do-for-an-encrypted-tunnel-1m93</guid>
      <description>&lt;p&gt;BibaVPN, &lt;code&gt;adaptive&lt;/code&gt; padding selects a target of roughly 900–1400 bytes for the first seven inner frames. Set &lt;code&gt;max_pad&lt;/code&gt; to 64, send a 100-byte payload, and the resulting frame is 169 bytes. The target was 1,200; the padding budget ran out at 64.&lt;/p&gt;

&lt;p&gt;Here is where those numbers come from.&lt;/p&gt;

&lt;p&gt;BibaVPN is my experimental SOCKS5/HTTP CONNECT tunnel, written in Rust, with a TLS + WebSocket transport to a server on your own VPS. Its inner frame format reserves five bytes for the header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Byte offset    0       1       2       3       4       5 ...
             +-------+-----------------------+-------+------------------+
             | ver   | payload length (u24)  | pad N | padding | payload|
             +-------+-----------------------+-------+------------------+
                 1 B           3 B              1 B     N B      L B

Total inner frame size = 5 + N + L
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The padding length is a &lt;code&gt;u8&lt;/code&gt;, so this layer can add at most 255 bytes. The configured &lt;code&gt;max_pad&lt;/code&gt; can reduce that further.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wlzvzxyv7zuhsvmr1dz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wlzvzxyv7zuhsvmr1dz.png" alt="A 100-byte payload with a five-byte header and 64 bytes of padding produces a 169-byte inner frame, below its selected 1,200-byte target" width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Calculated example, before encryption and outer transport overhead. The upper bar shows the actual frame; the lower bar compares it with the selected target.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The three modes share that byte budget:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;How it chooses padding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;random&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Uniform random amount from zero through &lt;code&gt;max_pad&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http-buckets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Select a size bucket, vary its target by about ±5%, then clamp the required padding.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;adaptive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Select larger targets for the first seven frames, then targets around 128–512 bytes; clamp the required padding.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The clamp in &lt;a href="https://github.com/Eljaja/BibaVPN/blob/main/bibavpn/src/frame.rs" rel="noopener noreferrer"&gt;frame.rs&lt;/a&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;need&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_total&lt;/span&gt;
    &lt;span class="nf"&gt;.saturating_sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_pad&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;base&lt;/code&gt; includes the header. If the payload already exceeds the selected target, &lt;code&gt;saturating_sub&lt;/code&gt; returns zero. There is no fragmentation or shrinking in this calculation.&lt;/p&gt;

&lt;p&gt;For a fixed target of 1,200 bytes and &lt;code&gt;max_pad = 64&lt;/code&gt;, the arithmetic looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Payload&lt;/th&gt;
&lt;th&gt;With header&lt;/th&gt;
&lt;th&gt;Padding added&lt;/th&gt;
&lt;th&gt;Resulting inner frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 B&lt;/td&gt;
&lt;td&gt;105 B&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;td&gt;169 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,150 B&lt;/td&gt;
&lt;td&gt;1,155 B&lt;/td&gt;
&lt;td&gt;45 B&lt;/td&gt;
&lt;td&gt;1,200 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,500 B&lt;/td&gt;
&lt;td&gt;1,505 B&lt;/td&gt;
&lt;td&gt;0 B&lt;/td&gt;
&lt;td&gt;1,505 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The names of the modes describe the size-selection heuristic. Whether the resulting traffic resembles HTTP requires a capture and a comparison. The inner frame still passes through encryption, WebSocket framing, TLS records, and TCP segmentation before it appears on the wire.&lt;/p&gt;

&lt;p&gt;For the PSK data path, the boundaries look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inner payload
     |
     v
padded inner frame       &amp;lt;-- sizes calculated above
     |
     v
PSK encryption wrapper
     |
     v
WebSocket / TLS / TCP    &amp;lt;-- network capture observes this traffic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A capture without TLS keys does not expose the WebSocket payload. It does expose traffic direction, timing, volume, and encrypted record lengths. Those are the properties the shaping settings aim to influence.&lt;/p&gt;

&lt;p&gt;The timing controls have their own limits. In the current &lt;a href="https://github.com/Eljaja/BibaVPN/blob/main/bibavpn/src/tcp_mux_flow.rs" rel="noopener noreferrer"&gt;multiplexer writer&lt;/a&gt;, the artificial-delay branch excludes DATA and window updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="py"&gt;.flags&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;MUX_FLAG_DATA&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="py"&gt;.flags&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;MUX_FLAG_WIN&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Apply the configured timing controls here.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delaying every DATA fragment would accumulate quickly. Five thousand serialized waits of 20 ms would add 100 seconds of waiting. That is an illustrative calculation; it is not a measured transfer time. Delaying window updates would also hold back a sender that is waiting for permission to transmit more bytes.&lt;/p&gt;

&lt;p&gt;Dummy frames are another setting. They create traffic without application data, which consumes bandwidth even during otherwise quiet periods. Their cadence matters as much as their size: periodic dummy traffic can introduce a pattern of its own.&lt;/p&gt;

&lt;p&gt;The code gives us the frame-size limits and the points where delays are applied. Establishing whether a setting helps against a particular classifier still requires network measurements. These examples explain the implementation; they do not establish a DPI-bypass result.&lt;/p&gt;

&lt;p&gt;Source code and protocol documentation: &lt;a href="https://github.com/Eljaja/BibaVPN" rel="noopener noreferrer"&gt;BibaVPN on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>security</category>
      <category>rust</category>
    </item>
    <item>
      <title>I built a self-hosted DPI-resistant tunnel over TLS + WebSocket in Rust</title>
      <dc:creator>Илья Федотов</dc:creator>
      <pubDate>Sat, 20 Jun 2026 14:32:32 +0000</pubDate>
      <link>https://dev.to/__bf699f275acc/i-built-a-self-hosted-dpi-resistant-tunnel-over-tls-websocket-in-rust-3abd</link>
      <guid>https://dev.to/__bf699f275acc/i-built-a-self-hosted-dpi-resistant-tunnel-over-tls-websocket-in-rust-3abd</guid>
      <description>&lt;p&gt;I built &lt;strong&gt;BibaVPN&lt;/strong&gt; for one reason: I did not want to hand traffic to a third-party VPN provider.&lt;/p&gt;

&lt;p&gt;It is a self-hosted &lt;strong&gt;SOCKS5 / HTTP CONNECT&lt;/strong&gt; tunnel over &lt;strong&gt;TLS + WebSocket&lt;/strong&gt;, with a &lt;strong&gt;Rust&lt;/strong&gt; core and &lt;strong&gt;Android/Desktop&lt;/strong&gt; clients.&lt;/p&gt;

&lt;p&gt;Some users in &lt;strong&gt;Russia, Iran, and China&lt;/strong&gt; use it to get around network blocks through their own VPS.&lt;/p&gt;

&lt;p&gt;The project is small on purpose. It is not trying to be a full private network stack. It is meant for private, self-hosted access in restrictive environments.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/Eljaja/BibaVPN" rel="noopener noreferrer"&gt;https://github.com/Eljaja/BibaVPN&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>programming</category>
      <category>dpi</category>
      <category>security</category>
    </item>
  </channel>
</rss>
