alksocks 0.2.0

SOCKS5 (RFC 1928) proxy protocol over alkcall channels: targeted-forked fast-socks5 typestate core plus producer/consumer sessions. Producer/consumer protocol crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
//! The consumer UDP ASSOCIATE integration tests — `AssociateSession`
//! end to end through the harness's consumer-side topology (the
//! producer's real command-dispatch path over channels; the framed
//! datagram stage, ADR-003, driven by the wrapper-aware client, POC
//! #2's shape).
//!
//! Covered: associate → sentinel reply (received, never used for
//! addressing) → send/recv round-trips (multiple in-order datagrams,
//! one association); the split-datagram persistence test (reader
//! state persists across `recv_datagram` calls — the POC's
//! 2-byte-prefix chunk finding); empty-payload datagram round-trip
//! (F-2, never EOF); shutdown → the producer's stage ends and a
//! fresh associate succeeds immediately; Drop teardown (drop
//! mid-stream → the producer's stage sees EOF, the connection stays
//! healthy); the ASSOCIATE refusal surfaced as a typed error with no
//! phantom channel; CONNECT + ASSOCIATE on the same registration
//! (the regression guard extended).

mod harness;

use tokio::io::{AsyncReadExt, AsyncWriteExt};

use alksocks::consumer::{AssociateSession, Socks5ChannelSession};
use alksocks::egress::EgressError;
use alksocks::error::SocksSessionError;
use alksocks::rfc1928::consts;
use alksocks::rfc1928::util::target_addr::{TargetAddr, ToTargetAddr};
use alksocks::SocksError as SocksErrorKind;

use harness::{echo_dial, echo_egress, refusing_egress, wire_consumer_side_with};

/// The no-leaked-channels probe (the connect/bind suites' shape,
/// re-pinned for the ASSOCIATE session): both managers hold only
/// channel 0 — the producer's entry reaps by its open-op wrapper;
/// the probe polls past the reap race.
async fn no_leaked_channels(topo: &harness::ConsumerSideTopology) {
    let deadline = std::time::Duration::from_secs(5);
    let consumer_ids = topo.consumer.manager().channel_ids();
    assert!(
        consumer_ids.iter().all(|&id| id == 0),
        "consumer leaked channel entries: {consumer_ids:?}"
    );
    tokio::time::timeout(deadline, async {
        loop {
            let producer_ids = topo.producer_manager.channel_ids();
            if producer_ids.iter().all(|&id| id == 0) {
                return;
            }
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        }
    })
    .await
    .expect("the producer's handler-exit teardown reaps the entry");
}

/// The happy path: `associate` → the reply's sentinel `BND.ADDR` is
/// received (RFC fidelity) and never used for addressing → send/recv
/// round-trips through the echo egress — multiple datagrams, in
/// order, on one association; the establisher witnessed the opener
/// identity (CF-006).
#[tokio::test]
async fn associate_round_trips_multiple_datagrams_one_association() {
    let topo = wire_consumer_side_with(
        echo_dial(),
        Some(echo_egress()),
        None,
        Some(harness::consumer_identity()),
    )
    .await;

    let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect("the association associates");

    // The reply's BND.ADDR is the producer's sentinel (127.0.0.1:0 —
    // ADR-003): received for RFC fidelity, never used for addressing.
    let relay = assoc
        .relay_addr()
        .expect("the reply carried BND.ADDR")
        .into_string_and_port();
    assert_eq!(relay.0, "127.0.0.1", "the in-band sentinel BND.ADDR");
    assert_eq!(relay.1, 0);

    let target = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
    for i in 0..5u8 {
        let payload = vec![i; 10];
        assoc
            .send_datagram(&target, &payload)
            .await
            .expect("send datagram");
    }
    for i in 0..5u8 {
        let (src, payload) =
            tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
                .await
                .expect("recv within timeout")
                .expect("a datagram (not eof)")
                .expect("a datagram");
        assert_eq!(src, target, "the echo reports the destination");
        assert_eq!(payload, bytes::Bytes::from(vec![i; 10]), "in-order echo");
    }

    // The association is alive: another datagram round-trips.
    assoc.send_datagram(&target, b"alive").await.expect("send");
    let (_, payload) =
        tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(&payload[..], b"alive");

    assert!(
        topo.consumer.manager().has_channel(assoc.channel_id()),
        "the adopted channel entry exists while associated"
    );

    let reaped = assoc.shutdown().await.expect("half-close");
    assert!(reaped, "the adopted channel entry existed");
    no_leaked_channels(&topo).await;
}

/// The split-datagram persistence test (POC #2's finding — the
/// reader state must persist across `recv_datagram` calls): a large
/// datagram's frame (under the u16 joint bound but well over the 16
/// KB read buffer) spans multiple channel reads AND multiple
/// `recv_datagram` calls — the first call returns the earlier small
/// datagram while the large one is mid-flight, the next call
/// reassembles it across its internal reads — and a trailing small
/// datagram batched in the same read batch drains through the
/// pending queue.
#[tokio::test]
async fn recv_datagram_state_persists_across_calls() {
    let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;

    let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect("the association associates");
    let target = TargetAddr::Ip("10.0.0.2:53".parse().unwrap());

    // The order tells the story: "first" completes early; the 60 KiB
    // datagram (60007 inner bytes — legal, but over the 16 KB read
    // buffer) is mid-flight when the first call returns; "third"
    // batches after it.
    assoc
        .send_datagram(&target, b"first")
        .await
        .expect("send the first datagram");
    let big = vec![7u8; 60 * 1024];
    assoc
        .send_datagram(&target, &big)
        .await
        .expect("send the big datagram");
    assoc
        .send_datagram(&target, b"third")
        .await
        .expect("send the third datagram");

    let (src, first) =
        tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(src, target);
    assert_eq!(&first[..], b"first", "the first datagram, in order");

    // The large datagram reassembles across the call boundary (its
    // mid-frame state persisted into this call) — the POC finding's
    // shape.
    let (src, second) =
        tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(src, target);
    assert_eq!(&second[..], &big[..], "the split datagram reassembled");

    // The trailing datagram rode the same read batches: the pending
    // queue carries it to this call.
    let (_, third) =
        tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(&third[..], b"third", "the batched datagram drained");

    let reaped = assoc.shutdown().await.expect("half-close");
    assert!(reaped);
    no_leaked_channels(&topo).await;
}

/// An empty-payload datagram is a legal datagram — never EOF (the
/// F-2 invariant, end to end through the echo egress), and the
/// association survives it (pre → empty → post, in order).
#[tokio::test]
async fn empty_payload_datagram_round_trips_and_survives() {
    let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;

    let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect("the association associates");
    let target = TargetAddr::Ip("10.0.0.3:53".parse().unwrap());

    assoc
        .send_datagram(&target, b"pre")
        .await
        .expect("send pre");
    assoc
        .send_datagram(&target, b"")
        .await
        .expect("an empty payload is a legal datagram");
    assoc
        .send_datagram(&target, b"post")
        .await
        .expect("send post");

    let (_, pre) = tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
        .await
        .expect("recv timed out")
        .expect("a datagram (not eof)")
        .expect("a datagram");
    assert_eq!(&pre[..], b"pre");

    let (src, empty) =
        tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(src, target);
    assert!(
        empty.is_empty(),
        "the empty-payload datagram round-trips (never EOF)"
    );

    let (_, post) = tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
        .await
        .expect("recv timed out")
        .expect("a datagram (not eof)")
        .expect("a datagram");
    assert_eq!(&post[..], b"post");

    let reaped = assoc.shutdown().await.expect("half-close");
    assert!(reaped);
    no_leaked_channels(&topo).await;
}

/// Shutdown half-closes the stream: the mux's implicit-EOF sentinel
/// reaches the producer's stage as EOF and the association ends
/// (association lifetime = channel lifetime) — and a fresh associate
/// succeeds immediately after (the POC's test 4, from the consumer
/// side; the same registration is unpoisoned).
#[tokio::test]
async fn shutdown_ends_the_association_and_a_fresh_associate_succeeds() {
    let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;

    let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect("the association associates");
    let channel_id = assoc.channel_id();
    let target = TargetAddr::Ip("10.0.0.4:53".parse().unwrap());
    assoc
        .send_datagram(&target, b"before")
        .await
        .expect("send before shutdown");
    let (_, echoed) =
        tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(&echoed[..], b"before");

    // Half-close: the producer's stage exits on the EOF sentinel.
    assoc.shutdown().await.expect("shutdown");
    assert!(
        !topo.consumer.manager().has_channel(channel_id),
        "the adopted channel entry was reaped"
    );

    // The connection is healthy: a fresh associate round-trips.
    let mut fresh = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect("fresh associate after the shutdown");
    fresh
        .send_datagram(&target, b"fresh")
        .await
        .expect("send after fresh associate");
    let (_, echoed) =
        tokio::time::timeout(std::time::Duration::from_secs(10), fresh.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(&echoed[..], b"fresh");
    let reaped = fresh.shutdown().await.expect("shutdown");
    assert!(reaped);
    no_leaked_channels(&topo).await;
}

/// Drop teardown: dropping a live association mid-stream reaps the
/// adopted channel entry (no leak), the producer's stage sees the
/// structural EOF (the mux's implicit-EOF sentinel on the dropped
/// write half), and the connection stays healthy — a fresh associate
/// succeeds immediately after.
#[tokio::test]
async fn drop_teardown_reaps_and_connection_stays_healthy() {
    let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;

    let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect("the association associates");
    let channel_id = assoc.channel_id();
    assoc
        .send_datagram(TargetAddr::Ip("10.0.0.5:53".parse().unwrap()), b"mid")
        .await
        .expect("send before the drop");
    let (_, echoed) =
        tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(&echoed[..], b"mid");

    // Drop mid-stream (the teardown-ownership posture).
    drop(assoc);
    assert!(
        !topo.consumer.manager().has_channel(channel_id),
        "the adopted channel entry was reaped on Drop"
    );
    no_leaked_channels(&topo).await;

    // The connection is healthy: a fresh associate round-trips.
    let mut fresh = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect("fresh associate after the drop");
    let target = TargetAddr::Ip("10.0.0.5:53".parse().unwrap());
    fresh
        .send_datagram(&target, b"again")
        .await
        .expect("send after the drop");
    let (_, echoed) =
        tokio::time::timeout(std::time::Duration::from_secs(10), fresh.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(&echoed[..], b"again");
    fresh.shutdown().await.expect("shutdown");
    no_leaked_channels(&topo).await;
}

/// A missing egress seam: the ASSOCIATE refusal surfaces the RFC
/// reply code through the session type (`CommandNotSupported` 0x07 —
/// ADR-008's missing-callback class), and no phantom channel remains
/// on either side (teardown-at-birth).
#[tokio::test]
async fn missing_egress_refusal_surfaces_rfc_reply_code_no_phantom_channel() {
    let topo = wire_consumer_side_with(echo_dial(), None, None, None).await;

    let err = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect_err("no egress wired — the ASSOCIATE must refuse");

    match &err {
        SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => {
            assert_eq!(code.as_u8(), consts::SOCKS5_REPLY_COMMAND_NOT_SUPPORTED);
        }
        other => panic!("expected an Rfc reply-code refusal, got {other:?}"),
    }

    no_leaked_channels(&topo).await;

    // The connection is healthy: wiring the egress serves ASSOCIATE.
    let topo2 = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
    let mut assoc = AssociateSession::associate(topo2.consumer.as_ref())
        .await
        .expect("fresh associate after the refusal");
    assoc
        .send_datagram(TargetAddr::Ip("10.0.0.6:53".parse().unwrap()), b"fresh")
        .await
        .expect("send");
    let (_, echoed) =
        tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(&echoed[..], b"fresh");
    assoc.shutdown().await.expect("shutdown");
    no_leaked_channels(&topo2).await;
}

/// An egress-factory failure: the mapped RFC reply code surfaces from
/// `associate` (reply#1's code — the factory refused before the
/// reply), and no phantom channel remains.
#[tokio::test]
async fn egress_factory_refusal_surfaces_the_mapped_reply_code() {
    let topo = wire_consumer_side_with(
        echo_dial(),
        Some(refusing_egress(EgressError::Unreachable(
            "no egress".into(),
        ))),
        None,
        None,
    )
    .await;

    let err = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect_err("the egress factory refused");
    match &err {
        SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => {
            assert_eq!(code.as_u8(), consts::SOCKS5_REPLY_NETWORK_UNREACHABLE);
        }
        other => panic!("expected an Rfc reply-code refusal, got {other:?}"),
    }
    no_leaked_channels(&topo).await;
}

/// CONNECT + ASSOCIATE on the same registration (the regression
/// guard extended): one ALPN, both commands, interleaved — an
/// association and a CONNECT session live concurrently on the same
/// registration, both serving.
#[tokio::test]
async fn connect_and_associate_served_on_the_same_registration() {
    let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;

    let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
        .await
        .expect("the association associates");
    let mut connect = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("echo.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("the CONNECT session connects");

    // The association round-trips while the CONNECT pipe is open.
    let target = TargetAddr::Ip("10.0.0.7:53".parse().unwrap());
    assoc
        .send_datagram(&target, b"udp")
        .await
        .expect("send datagram");
    let (_, echoed) =
        tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
            .await
            .expect("recv timed out")
            .expect("a datagram (not eof)")
            .expect("a datagram");
    assert_eq!(&echoed[..], b"udp");

    // The CONNECT pipe round-trips while the association is open.
    connect
        .split()
        .expect("pipe")
        .1
        .write_all(b"tcp")
        .await
        .expect("write");
    let (mut read, _) = connect.split().expect("pipe");
    let mut buf = [0u8; 3];
    tokio::time::timeout(
        std::time::Duration::from_secs(10),
        read.read_exact(&mut buf),
    )
    .await
    .expect("echo timed out")
    .expect("read the echo");
    assert_eq!(&buf, b"tcp");

    assoc.shutdown().await.expect("shutdown");
    connect.close().await;
    no_leaked_channels(&topo).await;
}