Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ blake3 = { path = "vendor/blake3" }
quinn-proto = { path = "vendor/quinn-proto" }

[workspace.package]
version = "0.0.39"
version = "0.0.40"
edition = "2024"
license = "GPL-3.0-only"
repository = "https://github.com/Screamshow/xrust"
Expand Down
113 changes: 108 additions & 5 deletions crates/core-runtime/src/outbound/wireguard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use std::collections::{HashMap, VecDeque};
use std::io;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::time::{Duration, Instant};

use serde_json::{Map, Value};
Expand Down Expand Up @@ -55,9 +55,55 @@ const DEFAULT_PORT: u16 = 51820;
const EPHEMERAL_BASE: u16 = 49152;

const SOCKET_BUF: usize = 65_536;
// Allow more than one receive burst in flight while the stack drains TCP
// into the caller. A 64 KiB window limited ARM downloads between ACKs.
// Keep the small buffers as a fallback when the shared WAN allowance is full.
const TCP_RECV_BUF: usize = 128 * 1024;
// Bytes must remain queued until ACKed. Small TX and RX rings otherwise cap
// throughput at roughly their capacity divided by the path RTT.
const TCP_WAN_RECV_BUF: usize = 2 * 1024 * 1024;
const TCP_WAN_SEND_BUF: usize = 2 * 1024 * 1024;
const TCP_WINDOW_EXTRA: usize = TCP_WAN_RECV_BUF + TCP_WAN_SEND_BUF - TCP_RECV_BUF - SOCKET_BUF;
// Shared across endpoints: a subscription with many WG endpoints must not
// multiply the extra window allowance. Existing small buffers remain available
// when the allowance is exhausted; this is not a total process memory cap.
// The supported 32-bit OpenWrt target is MIPS 24Kc, commonly RAM-limited.
// Keep two large connections there instead of consuming a 32 MiB allowance.
#[cfg(target_pointer_width = "32")]
const TCP_WINDOW_BUDGET: usize = 8 * 1024 * 1024;
#[cfg(not(target_pointer_width = "32"))]
const TCP_WINDOW_BUDGET: usize = 32 * 1024 * 1024;
static TCP_WINDOW_BYTES: AtomicUsize = AtomicUsize::new(0);

fn tcp_buffer_sizes(reserved: bool) -> (usize, usize) {
if reserved {
(TCP_WAN_RECV_BUF, TCP_WAN_SEND_BUF)
} else {
(TCP_RECV_BUF, SOCKET_BUF)
}
}

struct TcpWindowReservation<'a>(&'a AtomicUsize);

impl<'a> TcpWindowReservation<'a> {
fn acquire(bytes: &'a AtomicUsize) -> Option<Self> {
let mut used = bytes.load(Ordering::Relaxed);
loop {
let next = used.checked_add(TCP_WINDOW_EXTRA)?;
if next > TCP_WINDOW_BUDGET {
return None;
}
match bytes.compare_exchange_weak(used, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return Some(Self(bytes)),
Err(actual) => used = actual,
}
}
}
}

impl Drop for TcpWindowReservation<'_> {
fn drop(&mut self) {
self.0.fetch_sub(TCP_WINDOW_EXTRA, Ordering::Relaxed);
}
}
const SCRATCH_BUF: usize = 16_384;
const CALLER_BUF: usize = 65_536;
const CMD_QUEUE: usize = 256;
Expand Down Expand Up @@ -606,6 +652,7 @@ impl WireGuard {
}

struct TcpConn {
_window_reservation: Option<TcpWindowReservation<'static>>,
handle: SocketHandle,
#[allow(dead_code)]
dst: SocketAddr,
Expand Down Expand Up @@ -1094,9 +1141,11 @@ impl Stack {
return;
};
let port = self.alloc_port();
let window_reservation = TcpWindowReservation::acquire(&TCP_WINDOW_BYTES);
let (recv_size, send_size) = tcp_buffer_sizes(window_reservation.is_some());
let mut socket = TcpSock::new(
TcpBuffer::new(vec![0; TCP_RECV_BUF]),
TcpBuffer::new(vec![0; SOCKET_BUF]),
TcpBuffer::new(vec![0; recv_size]),
TcpBuffer::new(vec![0; send_size]),
);
socket.set_nagle_enabled(false);
socket.set_timeout(Some(SmolDuration::from_millis(
Expand All @@ -1115,6 +1164,7 @@ impl Stack {
self.tcp.insert(
id,
TcpConn {
_window_reservation: window_reservation,
handle,
dst,
to_caller,
Expand Down Expand Up @@ -2307,6 +2357,59 @@ fn ipv4_dst(pkt: &[u8]) -> Option<Ipv4Addr> {

#[cfg(test)]
mod tests {
#[test]
fn tcp_window_budget_survives_concurrent_acquire_and_drop() {
let bytes = std::sync::atomic::AtomicUsize::new(0);
std::thread::scope(|scope| {
for _ in 0..32 {
let bytes = &bytes;
scope.spawn(move || {
for _ in 0..500 {
let reservation = super::TcpWindowReservation::acquire(bytes);
assert!(
bytes.load(std::sync::atomic::Ordering::Relaxed)
<= super::TCP_WINDOW_BUDGET
);
std::thread::yield_now();
drop(reservation);
}
});
}
});
assert_eq!(bytes.load(std::sync::atomic::Ordering::Relaxed), 0);
}

#[test]
fn tcp_window_budget_is_bounded_and_reusable() {
let bytes = std::sync::atomic::AtomicUsize::new(0);
let mut reservations = Vec::new();
while let Some(reservation) = super::TcpWindowReservation::acquire(&bytes) {
reservations.push(reservation);
}
assert_eq!(
reservations.len(),
if cfg!(target_pointer_width = "32") {
2
} else {
8
}
);
assert_eq!(
super::tcp_buffer_sizes(true),
(2 * 1024 * 1024, 2 * 1024 * 1024)
);
assert_eq!(super::tcp_buffer_sizes(false), (128 * 1024, 64 * 1024));
let used = bytes.load(std::sync::atomic::Ordering::Relaxed);
assert!(used <= super::TCP_WINDOW_BUDGET);
assert!(used + super::TCP_WINDOW_EXTRA > super::TCP_WINDOW_BUDGET);
drop(reservations.pop());
let reused = super::TcpWindowReservation::acquire(&bytes).unwrap();
assert!(super::TcpWindowReservation::acquire(&bytes).is_none());
drop(reused);
drop(reservations);
assert_eq!(bytes.load(std::sync::atomic::Ordering::Relaxed), 0);
}

use super::*;

#[test]
Expand Down
13 changes: 9 additions & 4 deletions crates/core-runtime/tests/exit_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,17 @@ async fn f1_exit_gate_starts_and_answers_fakeip_dns() {
"fixture has experimental.clash_api; controller must bind"
);

// UDP DNS through the direct inbound → hijack-dns → FakeIP pool
// (domain_suffix google.com routes to fakeip-server; no upstream needed).
// UDP DNS through the direct inbound → hijack-dns → FakeIP pool.
// Use the explicit FakeIP rule before `evaluate`: google.com is routed
// after that action in the full fixture and would require external DNS.
let dns_addr = inbound_addr(&instance, "dns-in");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("client bind");
let query = codec::build_query(0xF1F1, "www.google.com", hickory_proto::rr::RecordType::A)
.expect("query");
let query = codec::build_query(
0xF1F1,
"fakeip.podkop.fyi",
hickory_proto::rr::RecordType::A,
)
.expect("query");
client.send_to(&query, dns_addr).await.expect("send query");
let mut buf = [0u8; 512];
let (n, _) = tokio::time::timeout(
Expand Down
4 changes: 4 additions & 0 deletions docs/releases/0.0.40.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## WireGuard и AmneziaWG

- Увеличены TCP-буферы приёма и отправки.
- Ограничено суммарное выделение дополнительной памяти под увеличенные буферы: до 32 МиБ на 64-битных платформах и до 8 МиБ на 32-битных.
51 changes: 51 additions & 0 deletions docs/wireguard-tcp-windows-2026-11-11.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Bounded WG/AWG TCP windows

## Behavior

Native WireGuard TCP connections previously used 128 KiB receive and 64 KiB send rings. Their capacity limits throughput over a high-RTT path even when CPU has headroom. The new large rings are 2 MiB in each direction, shared by ordinary WG and AmneziaWG.

Additional ring capacity is reserved atomically across every WG endpoint in the process:

| Target | Additional capacity budget | Large connections |
|---|---:|---:|
| 64-bit ARM/x86 | 32 MiB | 8 |
| 32-bit MIPS | 8 MiB | 2 |

Each large connection reserves 3.8125 MiB above the small 192 KiB pair. When the allowance is full, new connections retain the old small rings. Those existing fallback connections are not resized later. Closing a connection, failing its setup or destroying its stack returns the reservation. Base buffers, application queues, crypto buffers, kernel queues and allocator retention are outside this allowance: it is not a total RSS limit or an OOM guarantee for every router.

The smaller MIPS allowance avoids applying the MT6000's memory policy unchanged to the supported RAM-limited 24Kc target. No user configuration, protocol parser, SRS, crypto or AWG obfuscation parameter changes are required. The TCP window and timing/batching of the encrypted traffic can change; universal camouflage equivalence is not claimed.

## Causal throughput evidence

The local ARM fixture uses MT6000 with one client worker/CPU, an official amneziawg-go v3.1.20260828 WSL peer, disposable keys, MTU 1380 and nonzero J/S/H parameters. Owned-interface netem adds one 70/140 ms delay to a TCP round trip. There is no external traffic server or application UDP forwarder.

With the receive ring held at 2 MiB, changing only TX from 512 KiB to 2 MiB in equally instrumented diagnostic builds gave these medians of three 16 MiB uploads:

| Added RTT | TX 512 KiB | TX 2 MiB |
|---|---:|---:|
| 70 ms | 53.40 Mbit/s | 145.51 Mbit/s |
| 140 ms | 27.03 Mbit/s | 76.74 Mbit/s |

The unchanged build and instrumented 512 KiB control matched closely. In the smaller-TX control the ring was full in about 75% of observed stack iterations and upstream data waited for space. In the 2 MiB variant the proportion was about 27%. These are observations, not CPU-time percentages. Internal TCP captures found no repeated segment-start sequence values in that series. The smaller ring's capacity/RTT ceiling agrees with the measured bottleneck.

The diagnostic larger-TX peak process RSS increased by about 1.5 MiB. Echo median at 70 ms increased from 71.81 to 74.45 ms; at 140 ms it changed from 141.83 to 142.47 ms. These short synthetic-path samples do not establish WAN tail latency, total router memory/power, steady-state speed or universal parity with the Linux TCP stack. CPU/GiB includes concurrent echo and setup; it is not an isolated crypto profile.

## Release preparation verification

- 49 focused WireGuard tests include budget exhaustion/reuse, small-ring selection and concurrent acquire/drop. The large-connection count is target dependent (8/2).
- Local workspace tests: 1570 passed, 14 explicitly ignored; workspace Clippy and the rust-x profile Clippy pass. Four Python script tests pass.
- Privileged SO_MARK socket test, FakeIP exit gate and 14 inbound tests pass. Six rust-x profile tests pass.
- The existing exit-gate test's google.com query was found to traverse an earlier `evaluate` rule in the full fixture, contrary to its no-upstream comment. The test now uses the fixture's explicit FakeIP domain before `evaluate`. The corrected test passes in a network namespace with only loopback. No DNS runtime behavior was changed.
- ARM64, x86_64 and MIPS release builds pass their current size budgets with the existing target crypto flags. Existing MIPS 24Kc QEMU runtime checks pass HTTP/SOCKS to a local TLS proxy, DNS hijack, Clash counters and software TLS.
- The final MIPS binary additionally passes actual native AWG TCP-ring policy checks under QEMU: two large plus two fallback connections, release/reuse while fallback remains alive, and return of the allowance after 32 refused TCP connections. This is functional emulation evidence, not physical-MIPS speed/RAM evidence.
- The physical MT6000 fixture checks eight large plus eight fallback connections, release/reuse, 32 refused connections and repeated 16-way uploads/downloads for both WG and AWG, at saturation and at approximately 48 Mbit/s aggregate application rate. Payload bytes, final ACK and concurrent echo are verified; working Forkop is kept separate.

The exact final ARM binary passed 192 AWG and 192 WG measured transfers (384 total). Process FD counts were 11 before and after each mode. Sampled peak RSS was 25,744/24,192 KiB (AWG/WG), recovering to 7,972/7,928 KiB after closing connections. Saturation produced 68/430 client UDP socket drops, all recovered by TCP; rate-limited phases had zero measured client socket drops. These are finite recovery controls, not a memory soak or a zero-loss claim. Router package/config/binary/PID/service invariants matched and owned resources were removed. Original WSL NAT, absent .wslconfig and five container IDs were restored.

Operator raw evidence and scripts are retained outside the repository under Forkop's `tmp/awg-release-ready-20261111`, `tmp/awg-final-arm-20261111`, `tmp/awg-final-mips-20261111` and `tmp/awg-cause-20261111`. Test keys and runtime profiles are not release artifacts.

## Remaining limits

The previous direct reference comparison contained one native download at 70 ms falling to about 11.64 Mbit/s. Ten subsequent captured downloads of the unchanged candidate gave 120.60–128.26 Mbit/s with correct data and no observed sender TCP retransmissions. Its cause remains unlocalized; a release must not claim it fixed or claim universal reference parity.

Saturation can drop datagrams on the native client's UDP socket; the parallel TCP controls recover them. Drops are recorded separately from rate-limited controls rather than silently excluded. Larger windows also increased saturated LAN echo latency in the earlier series. Low-memory physical MIPS routers and long-running production memory behavior have not been benchmarked. Package installation/rollback, signed artifacts and publication remain part of the separate release workflow.