dig-nat 0.14.1

Abstract NAT traversal for DIG Node peer connections — one connect() API over direct, UPnP/IGD, NAT-PMP, PCP, relay-coordinated hole-punch, and relay.dig.net as last-resort fallback; establishes an mTLS peer connection with peer_id = SHA256(TLS SPKI DER).
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
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
//! Unit tests for the tier-6 relayed dial: an mTLS session carried OVER a relay byte tunnel presents
//! the SAME identity as a direct one. These live in-crate (not `tests/`) because they drive the
//! `#[cfg(test)]` loopback-relay harness ([`crate::relay::loopback_reservation_pair`]) + the private
//! tunnel routing, wiring two relay reservations to forward RLY-002 frames to each other with no
//! network. The security claim under test: a relayed [`crate::PeerConnection`] carries the identical
//! dig-tls mTLS — CA chain + `peer_id` pin + #1204 BLS binding — as a direct connection.

use std::sync::Arc;
use std::time::Duration;

use sha2::{Digest, Sha256};
use tokio_rustls::TlsAcceptor;

use crate::dialer::MtlsDialer;
use crate::method::relayed::ReservationRelayedTransport;
use crate::method::{MethodOutcome, TraversalKind};
use crate::mux::{AvailabilityAnswer, AvailabilityItem, AvailabilityRequest, AvailabilityResponse};
use crate::peer::PeerTarget;
use crate::relay::loopback_reservation_pair;
use crate::strategy::Dialer;
use crate::tunnel::RelayTunnelStream;
use crate::{BindingPolicy, NodeCert, PeerSession};
use dig_tls::bls::{public_key_bytes, SecretKey};
use tokio::io::AsyncWriteExt;

/// A deterministic BLS identity secret key from a label — derived (never an integer-literal secret,
/// so CodeQL does not flag a hard-coded crypto value).
fn test_bls_sk(label: &str) -> SecretKey {
    let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
    SecretKey::from_seed(&seed)
}

/// A CA-signed dig-tls [`NodeCert`] for `label`.
fn test_node(label: &str) -> Arc<NodeCert> {
    Arc::new(NodeCert::generate_signed(&test_bls_sk(label)).expect("generate node cert"))
}

const RELAY_ENDPOINT: &str = "127.0.0.1:3478";
const NET: &str = "DIG_MAINNET";

/// A full mTLS session over the relay tunnel verifies the peer's `peer_id` AND captures its #1204 BLS
/// binding — proving the relayed tier is NOT a weaker connection than a direct dial. The relay
/// forwards only ciphertext (it is wired as a dumb byte forwarder); identity is proven by the mTLS
/// layer, not the relay.
#[tokio::test]
async fn relayed_dial_preserves_mtls_peer_id_and_bls_binding() {
    let server = test_node("relayed/server");
    let client = test_node("relayed/client");
    let server_id = server.peer_id();
    let client_hex = client.peer_id().to_hex();
    let server_hex = server_id.to_hex();
    let expected_bls = public_key_bytes(&test_bls_sk("relayed/server"));

    // Two reservations forwarding RLY-002 frames to each other — a loopback relay, no network.
    let (client_status, server_status) = loopback_reservation_pair(&client_hex, &server_hex);

    // Server side: open its tunnel (registers inbound routing BEFORE the client sends ClientHello),
    // then run an mTLS server (Required binding) + yamux server answering one availability query.
    let server_tunnel = server_status.open_server_tunnel(&client_hex, NET);
    let server_tls = dig_tls::server_config(&server, BindingPolicy::Required)
        .expect("server config")
        .config;
    let acceptor = TlsAcceptor::from(server_tls);
    tokio::spawn(async move {
        let stream = RelayTunnelStream::new(server_tunnel);
        let tls = acceptor.accept(stream).await.expect("server mTLS accept");
        let mut session = PeerSession::server(tls);
        while let Some(mut s) = session.accept_stream().await {
            tokio::spawn(async move {
                if let Ok(req) = AvailabilityRequest::decode(&mut s).await {
                    let resp = AvailabilityResponse {
                        items: req
                            .items
                            .iter()
                            .map(|_| AvailabilityAnswer {
                                available: true,
                                roots: None,
                                total_length: Some(77),
                                chunk_count: Some(1),
                                complete: Some(true),
                            })
                            .collect(),
                    };
                    let _ = s.write_all(&resp.encode().unwrap()).await;
                    let _ = s.shutdown().await;
                }
            });
        }
    });

    // Client side: dial the relayed tier over its reservation. The dialer opens the tunnel to the
    // server and runs the SAME dig-tls client handshake (peer_id pin + binding capture) over it.
    let transport = Arc::new(ReservationRelayedTransport::new(
        Arc::clone(&client_status),
        RELAY_ENDPOINT.parse().unwrap(),
    ));
    let dialer = MtlsDialer::new(Arc::clone(&client))
        .with_binding_policy(BindingPolicy::Required)
        .with_relayed_dialer(transport);
    let peer = PeerTarget::relay_only(server_id, NET);
    let outcome = MethodOutcome::single(TraversalKind::Relayed, RELAY_ENDPOINT.parse().unwrap());

    let mut conn = tokio::time::timeout(Duration::from_secs(5), dialer.dial(&peer, &outcome))
        .await
        .expect("relayed dial completes")
        .expect("relayed dial succeeds");

    assert_eq!(conn.peer_id, server_id, "relayed peer_id == server cert id");
    assert_eq!(
        conn.method,
        TraversalKind::Relayed,
        "reports the relayed tier"
    );
    assert_eq!(
        conn.peer_bls_pub,
        Some(expected_bls),
        "relayed dial captured the server's #1204 BLS binding — same as a direct dial"
    );

    // The mux round-trips over the relay tunnel exactly like a direct connection.
    let resp = conn
        .query_availability(vec![AvailabilityItem {
            store_id: "bb".repeat(32),
            root: None,
            retrieval_key: None,
        }])
        .await
        .expect("availability over relayed mTLS");
    assert_eq!(resp.items.len(), 1);
    assert!(resp.items[0].available);
    assert_eq!(resp.items[0].total_length, Some(77));
}

/// REGRESSION (#1536): a relay circuit negotiates ROLES — the dialer is the mTLS client, the
/// reservation-holder that RECEIVES the introduced circuit is the mTLS server — and the handshake
/// completes. The holder does NOT pre-open a tunnel to the client (in production it cannot know who
/// will dial it); it only enables the RESPONDER path ([`RelayStatus::enable_accept`]) and serves
/// whatever introduced circuit surfaces via a [`RelayAcceptor`] (`PeerSession::server`).
///
/// Before the fix there was no responder path: the holder's `route_relayed` DROPPED a frame from a
/// peer with no open tunnel, so the dialer's ClientHello never reached a server-side accept — the
/// handshake deadlocked (`got ClientHello when expecting ServerHello`; both ends were TLS clients).
#[tokio::test]
async fn relayed_connect_negotiates_client_and_server_roles() {
    use crate::RelayAcceptor;

    let server = test_node("relayed/role-server");
    let client = test_node("relayed/role-client");
    let server_id = server.peer_id();
    let client_id = client.peer_id();
    let client_hex = client_id.to_hex();
    let server_hex = server_id.to_hex();

    let (client_status, server_status) = loopback_reservation_pair(&client_hex, &server_hex);

    // Server: enable the responder path (NO pre-opened tunnel), then serve the introduced circuit as
    // the mTLS SERVER. Report the peer_id it authenticated so the test proves exactly one client +
    // one server negotiated correctly.
    let mut inbound = server_status.enable_accept();
    let acceptor =
        RelayAcceptor::new(Arc::clone(&server)).with_binding_policy(BindingPolicy::Required);
    let (served_tx, served_rx) = tokio::sync::oneshot::channel();
    tokio::spawn(async move {
        let tunnel = inbound
            .recv()
            .await
            .expect("introduced circuit surfaces to the acceptor");
        let mut conn = acceptor
            .accept(tunnel)
            .await
            .expect("server accepts + completes the mTLS handshake");
        let _ = served_tx.send(conn.peer_id);
        while let Some(mut s) = conn.session.accept_stream().await {
            tokio::spawn(async move {
                if let Ok(req) = AvailabilityRequest::decode(&mut s).await {
                    let resp = AvailabilityResponse {
                        items: req
                            .items
                            .iter()
                            .map(|_| AvailabilityAnswer {
                                available: true,
                                roots: None,
                                total_length: Some(42),
                                chunk_count: Some(1),
                                complete: Some(true),
                            })
                            .collect(),
                    };
                    let _ = s.write_all(&resp.encode().unwrap()).await;
                    let _ = s.shutdown().await;
                }
            });
        }
    });

    // Client: dial the relayed tier — runs the mTLS CLIENT (the initiator).
    let transport = Arc::new(ReservationRelayedTransport::new(
        Arc::clone(&client_status),
        RELAY_ENDPOINT.parse().unwrap(),
    ));
    let dialer = MtlsDialer::new(Arc::clone(&client))
        .with_binding_policy(BindingPolicy::Required)
        .with_relayed_dialer(transport);
    let peer = PeerTarget::relay_only(server_id, NET);
    let outcome = MethodOutcome::single(TraversalKind::Relayed, RELAY_ENDPOINT.parse().unwrap());

    let mut conn = tokio::time::timeout(Duration::from_secs(5), dialer.dial(&peer, &outcome))
        .await
        .expect("relayed dial completes (no double-ClientHello deadlock)")
        .expect("relayed dial succeeds");
    assert_eq!(
        conn.peer_id, server_id,
        "client verified the server's peer_id"
    );

    let served = tokio::time::timeout(Duration::from_secs(5), served_rx)
        .await
        .expect("server completed its accept")
        .expect("server reported the authenticated peer_id");
    assert_eq!(served, client_id, "server verified the client's peer_id");

    let resp = conn
        .query_availability(vec![AvailabilityItem {
            store_id: "cc".repeat(32),
            root: None,
            retrieval_key: None,
        }])
        .await
        .expect("availability round-trips over the role-negotiated relay mTLS");
    assert_eq!(resp.items.len(), 1);
    assert!(resp.items[0].available);
    assert_eq!(resp.items[0].total_length, Some(42));
}

/// A MALICIOUS relay that redirects the client to an IMPOSTOR (labelling the impostor with the honest
/// peer's routing id) is rejected by mTLS: the client pinned the honest `peer_id`, so the impostor's
/// cert derives a different id and the handshake fails. This is the security crux — the relay is an
/// untrusted forwarder and cannot substitute a peer, because identity is proven by the certificate,
/// not by the relay's routing.
#[tokio::test]
async fn relayed_dial_rejects_malicious_relay_redirect_to_impostor() {
    let honest = test_node("relayed/honest"); // the peer the client WANTS
    let impostor = test_node("relayed/impostor"); // who the malicious relay actually connects
    let client = test_node("relayed/client-b");
    let honest_id = honest.peer_id();
    let client_hex = client.peer_id().to_hex();
    let honest_hex = honest_id.to_hex();

    // The relay labels the impostor's leg with the HONEST peer's routing id (the redirect attack).
    let (client_status, impostor_status) = loopback_reservation_pair(&client_hex, &honest_hex);
    let imp_tunnel = impostor_status.open_server_tunnel(&client_hex, NET);
    let imp_tls = dig_tls::server_config(&impostor, BindingPolicy::Off)
        .unwrap()
        .config;
    let acceptor = TlsAcceptor::from(imp_tls);
    tokio::spawn(async move {
        let stream = RelayTunnelStream::new(imp_tunnel);
        let _ = acceptor.accept(stream).await; // client will reject the impostor's cert
    });

    let transport = Arc::new(ReservationRelayedTransport::new(
        Arc::clone(&client_status),
        RELAY_ENDPOINT.parse().unwrap(),
    ));
    let dialer = MtlsDialer::new(Arc::clone(&client)).with_relayed_dialer(transport);
    // Pin the HONEST peer — the relay tries to substitute the impostor.
    let peer = PeerTarget::relay_only(honest_id, NET);
    let outcome = MethodOutcome::single(TraversalKind::Relayed, RELAY_ENDPOINT.parse().unwrap());

    let err = tokio::time::timeout(Duration::from_secs(20), dialer.dial(&peer, &outcome))
        .await
        .expect("relayed dial completes")
        .unwrap_err();
    assert_eq!(err.kind, TraversalKind::Relayed);
    assert!(
        err.reason.contains("mtls handshake") || err.reason.contains("peer_id"),
        "relayed handshake rejects the impostor substituted by the relay, got: {}",
        err.reason
    );
}

/// REGRESSION (#1536 GLARE): two NAT'd peers that BOTH fall to the relay tier and dial EACH OTHER
/// simultaneously (neither pre-opens) — the common two-NAT'd-peer flywheel case. Without the
/// deterministic tie-break each side opens a CLIENT tunnel and each side's ClientHello routes into the
/// OTHER's client session → both ends are TLS clients → the #1536 double-ClientHello deadlock
/// re-manifests (the dial would hang; this test would time out).
///
/// With the tie-break (numerically-LOWER `peer_id` becomes SERVER) the crossed pair resolves to
/// exactly ONE client + ONE server under this dial ordering: the higher-id side's `dial` completes as
/// the mTLS client, the lower-id side's `dial` self-cancels and its `RelayAcceptor` serves the circuit
/// as the mTLS server. A yamux availability query then round-trips over the negotiated session.
#[tokio::test]
async fn mutual_simultaneous_relayed_dial_resolves_glare_to_one_client_one_server() {
    use crate::peer::PeerConnection;
    use crate::relay::RelayTunnel;
    use crate::RelayAcceptor;
    use tokio::sync::mpsc as tokio_mpsc;
    use tokio::sync::oneshot;

    let node_a = test_node("glare/a");
    let node_b = test_node("glare/b");
    let a_id = node_a.peer_id();
    let b_id = node_b.peer_id();
    let a_hex = a_id.to_hex();
    let b_hex = b_id.to_hex();

    let (a_status, b_status) = loopback_reservation_pair(&a_hex, &b_hex);

    // Each side enables the responder path — a real node both dials (the ladder) AND accepts
    // introduced circuits (a `RelayAcceptor`) at the same time. Whichever side yields to the server
    // role under the glare tie-break serves here; the other side's acceptor stays idle.
    let a_inbound = a_status.enable_accept();
    let b_inbound = b_status.enable_accept();

    // Serve one accepted circuit as the mTLS SERVER, reporting the authenticated client peer_id and
    // answering one availability query so the round-trip can be proven. Kept alive by the returned
    // JoinHandle; the loser-of-glare side's oneshot fires, the winner's never does.
    fn spawn_acceptor(
        node: Arc<NodeCert>,
        mut inbound: tokio_mpsc::Receiver<RelayTunnel>,
    ) -> (
        oneshot::Receiver<crate::PeerId>,
        tokio::task::JoinHandle<()>,
    ) {
        let (served_tx, served_rx) = oneshot::channel();
        let handle = tokio::spawn(async move {
            let acceptor = RelayAcceptor::new(node).with_binding_policy(BindingPolicy::Required);
            let Some(tunnel) = inbound.recv().await else {
                return;
            };
            let Ok(mut conn): Result<PeerConnection, _> = acceptor.accept(tunnel).await else {
                return;
            };
            let _ = served_tx.send(conn.peer_id);
            while let Some(mut s) = conn.session.accept_stream().await {
                tokio::spawn(async move {
                    if let Ok(req) = AvailabilityRequest::decode(&mut s).await {
                        let resp = AvailabilityResponse {
                            items: req
                                .items
                                .iter()
                                .map(|_| AvailabilityAnswer {
                                    available: true,
                                    roots: None,
                                    total_length: Some(99),
                                    chunk_count: Some(1),
                                    complete: Some(true),
                                })
                                .collect(),
                        };
                        let _ = s.write_all(&resp.encode().unwrap()).await;
                        let _ = s.shutdown().await;
                    }
                });
            }
        });
        (served_rx, handle)
    }

    let (a_served_rx, _a_srv) = spawn_acceptor(Arc::clone(&node_a), a_inbound);
    let (b_served_rx, _b_srv) = spawn_acceptor(Arc::clone(&node_b), b_inbound);

    // Build each side's relayed dialer over its own reservation.
    let a_dialer = MtlsDialer::new(Arc::clone(&node_a))
        .with_binding_policy(BindingPolicy::Required)
        .with_relayed_dialer(Arc::new(ReservationRelayedTransport::new(
            Arc::clone(&a_status),
            RELAY_ENDPOINT.parse().unwrap(),
        )));
    let b_dialer = MtlsDialer::new(Arc::clone(&node_b))
        .with_binding_policy(BindingPolicy::Required)
        .with_relayed_dialer(Arc::new(ReservationRelayedTransport::new(
            Arc::clone(&b_status),
            RELAY_ENDPOINT.parse().unwrap(),
        )));
    let a_peer = PeerTarget::relay_only(b_id, NET); // A dials B
    let b_peer = PeerTarget::relay_only(a_id, NET); // B dials A — simultaneously
    let outcome = MethodOutcome::single(TraversalKind::Relayed, RELAY_ENDPOINT.parse().unwrap());

    // BOTH dial at once. A deadlock (the un-fixed behavior) would hang here → the timeout fails loudly.
    let (a_dial, b_dial) = tokio::time::timeout(Duration::from_secs(10), async {
        tokio::join!(
            a_dialer.dial(&a_peer, &outcome),
            b_dialer.dial(&b_peer, &outcome),
        )
    })
    .await
    .expect("mutual relayed dial resolves (no glare deadlock)");

    // Exactly ONE side won the client role; the other yielded to server (its dial self-cancelled).
    let (mut client_conn, expect_client_id, served_rx) = match (a_dial, b_dial) {
        (Ok(c), Err(_)) => (c, a_id, b_served_rx), // A client, B server
        (Err(_), Ok(c)) => (c, b_id, a_served_rx), // B client, A server
        (Ok(_), Ok(_)) => panic!("glare must yield exactly one client, got two"),
        (Err(ea), Err(eb)) => panic!("both relayed dials failed: {ea:?} / {eb:?}"),
    };

    // The client verified the SERVER's identity, and the server verified the CLIENT's — one clean
    // mutually-authenticated mTLS session, not two deadlocked clients.
    let expect_server_id = if expect_client_id == a_id { b_id } else { a_id };
    assert_eq!(
        client_conn.peer_id, expect_server_id,
        "client verified the yielding peer's server identity"
    );
    let served = tokio::time::timeout(Duration::from_secs(5), served_rx)
        .await
        .expect("server side completed its accept")
        .expect("server reported the authenticated client peer_id");
    assert_eq!(
        served, expect_client_id,
        "server verified the winning peer's client identity"
    );

    // The mux round-trips over the glare-negotiated relay mTLS.
    let resp = client_conn
        .query_availability(vec![AvailabilityItem {
            store_id: "dd".repeat(32),
            root: None,
            retrieval_key: None,
        }])
        .await
        .expect("availability round-trips over the glare-resolved relay mTLS");
    assert_eq!(resp.items.len(), 1);
    assert!(resp.items[0].available);
    assert_eq!(resp.items[0].total_length, Some(99));
}

/// The [`RelayTunnelStream`] adapter round-trips bytes both directions over the loopback relay, and a
/// single inbound payload satisfies several small reads (the carry-over buffer) — the property TLS
/// relies on when it reads a record header then its body.
#[tokio::test]
async fn relay_tunnel_stream_round_trips_bytes() {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};

    let (a_status, b_status) = loopback_reservation_pair("aa", "bb");
    let a_tunnel = a_status.open_tunnel("bb", NET).unwrap();
    let b_tunnel = b_status.open_tunnel("aa", NET).unwrap();
    let mut a = RelayTunnelStream::new(a_tunnel);
    let mut b = RelayTunnelStream::new(b_tunnel);

    // A → B: one write, read back in two small chunks (exercises the carry-over buffer).
    a.write_all(b"hello world").await.unwrap();
    a.flush().await.unwrap();
    let mut first = [0u8; 5];
    b.read_exact(&mut first).await.unwrap();
    assert_eq!(&first, b"hello");
    let mut rest = [0u8; 6];
    b.read_exact(&mut rest).await.unwrap();
    assert_eq!(&rest, b" world");

    // B → A: the reverse direction works too.
    b.write_all(b"pong").await.unwrap();
    b.flush().await.unwrap();
    let mut back = [0u8; 4];
    a.read_exact(&mut back).await.unwrap();
    assert_eq!(&back, b"pong");
}

/// REGRESSION (#1761): after a relayed circuit's local tunnel is RELEASED — what
/// [`crate::fast_connect`] does to the per-peer relay tunnel on a relayed→direct promotion — the
/// peer's still-in-flight frames MUST NOT manufacture a fresh server-role circuit.
///
/// This is the live failure reproduced with real rustls records, not synthesised bytes: seed1 logged
/// `mtls accept: got ServerHello when expecting ClientHello` and seed2 `fatal alert:
/// UnexpectedMessage`, because the dialer's own side re-entered the RESPONDER path on the server's
/// reply and ran a SECOND TLS server against it. Both ends were servers, so no relayed circuit could
/// carry a byte.
///
/// Driven in production order and asserted on RECEIVER-SIDE state (the dialer's accept channel + its
/// tunnel table), never on a sender-side log line: the dialer's accept path is ENABLED throughout —
/// exactly as a real node's is — so nothing but the frame-direction gate can keep it quiet.
#[tokio::test]
async fn released_tunnel_does_not_re_enter_the_responder_path_on_the_peers_reply() {
    use crate::RelayAcceptor;

    let server = test_node("relayed/released-server");
    let client = test_node("relayed/released-client");
    let server_id = server.peer_id();
    let client_hex = client.peer_id().to_hex();
    let server_hex = server_id.to_hex();

    let (client_status, server_status) = loopback_reservation_pair(&client_hex, &server_hex);

    // The SERVER holds its accepted session open and, on request, writes real TLS records back to the
    // client by opening a yamux stream — the in-flight traffic a promotion races.
    let mut server_inbound = server_status.enable_accept();
    let acceptor =
        RelayAcceptor::new(Arc::clone(&server)).with_binding_policy(BindingPolicy::Required);
    let (write_now_tx, write_now_rx) = tokio::sync::oneshot::channel::<()>();
    tokio::spawn(async move {
        let tunnel = server_inbound.recv().await.expect("introduced circuit");
        let mut conn = acceptor.accept(tunnel).await.expect("server mTLS accept");
        let _ = write_now_rx.await;
        // Records written after the client released its tunnel — these are the stray frames.
        if let Ok(mut s) = conn.session.open_stream().await {
            let _ = s.write_all(b"post-promotion traffic").await;
            let _ = s.flush().await;
        }
    });

    // The dialer ALSO runs the responder path (every real node both dials and accepts) — this receiver
    // is the observable the assertion rests on.
    let mut client_inbound = client_status.enable_accept();

    let transport = Arc::new(ReservationRelayedTransport::new(
        Arc::clone(&client_status),
        RELAY_ENDPOINT.parse().unwrap(),
    ));
    let dialer = MtlsDialer::new(Arc::clone(&client))
        .with_binding_policy(BindingPolicy::Required)
        .with_relayed_dialer(transport);
    let peer = PeerTarget::relay_only(server_id, NET);
    let outcome = MethodOutcome::single(TraversalKind::Relayed, RELAY_ENDPOINT.parse().unwrap());

    let conn = tokio::time::timeout(Duration::from_secs(5), dialer.dial(&peer, &outcome))
        .await
        .expect("relayed dial completes")
        .expect("relayed dial succeeds");
    assert_eq!(conn.peer_id, server_id);

    // PROMOTION: release the relayed transport (fast-connect drops the swapped-out slot), which
    // deregisters the per-peer tunnel while the peer is still sending.
    // Dropping the connection ends the mux driver, which tears down the mTLS stream and so the
    // tunnel; the peer's session teardown records are already in flight at this moment.
    drop(conn);
    tokio::time::sleep(Duration::from_millis(300)).await;
    // The peer's remaining application records arrive on the released circuit too.
    let _ = write_now_tx.send(());
    tokio::time::sleep(Duration::from_millis(300)).await;

    assert!(
        client_inbound.try_recv().is_err(),
        "the peer's reply on a released circuit must not surface as an introduced circuit \
         (that is what put BOTH ends in the TLS server role, #1761)"
    );
    assert!(
        !client_status.open_tunnel_exists(&server_hex),
        "the released circuit stays released — a peer that merely replied on it must not get a \
         fresh server-role circuit registered under its key"
    );
}

/// With NO relay data-plane wired, a relayed outcome fails cleanly (never a silent broken dial).
#[tokio::test]
async fn relayed_dial_without_transport_is_clean_error() {
    let dialer = MtlsDialer::new(test_node("relayed/client-c"));
    let peer = PeerTarget::relay_only(crate::PeerId::from_bytes([1u8; 32]), NET);
    let outcome = MethodOutcome::single(TraversalKind::Relayed, RELAY_ENDPOINT.parse().unwrap());
    let err = dialer.dial(&peer, &outcome).await.unwrap_err();
    assert_eq!(err.kind, TraversalKind::Relayed);
    assert!(err.reason.contains("no relay data-plane"));
}