shadowvpn 0.5.0

A UDP-based, pre-shared-key (PSK), user-mode VPN using the shadowsocks AEAD UDP wire scheme.
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
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
//! ShadowVPN client.
//!
//! The client owns a TUN device (assigned the client tunnel IP, e.g.
//! `10.7.0.2/24`) and a single UDP socket *connected* to the server. It runs two
//! concurrent loops:
//!
//! * **Loop A (TUN -> net):** read one raw IP packet from the TUN device,
//!   encrypt it into a single shadowsocks-AEAD UDP datagram
//!   (`salt ++ AEAD(ciphertext ++ tag)`), and send it to the server.
//! * **Loop B (net -> TUN):** receive one UDP datagram from the server, decrypt
//!   it back into a raw IP packet, and write that packet to the TUN device.
//!
//! Because UDP datagram boundaries are the frame boundaries (see
//! [`shadowvpn::protocol`]), one IP packet maps to exactly one datagram; there is
//! no length prefix or reassembly.
//!
//! # Keepalive
//!
//! The client also runs a lightweight keepalive: it periodically encrypts and
//! sends a tiny dummy packet so that (a) a stateful NAT/firewall on the path
//! keeps the UDP mapping open, and (b) the server learns the client's current
//! source address even before the client sends any real traffic. We send a
//! 5-byte plaintext (`0x00` marker + our 4-byte tunnel IP, see
//! [`keepalive_payload`]); a real IP packet is always larger than this, and
//! the server is expected to drop sub-IP-header datagrams, so the keepalive is
//! harmless if it ever reaches the TUN-write path. (This is a ShadowVPN
//! convention, not part of the shadowsocks wire spec.)
//!
//! # Routing (NOT done automatically)
//!
//! The client deliberately does **not** touch the system routing table or the
//! default route — doing so silently is dangerous and platform-specific. After
//! the interface comes up, the client logs the suggested commands to route
//! traffic through the tunnel. See [`print_routing_hint`].

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

use anyhow::{Context, Result};
use clap::Parser;
use log::{debug, info, warn};
use tokio::net::UdpSocket;
use tokio::sync::mpsc;

use shadowvpn::config::{ClientArgs, ClientConfig, TunConfig};
use shadowvpn::crypto::{decrypt_packet, encrypt_packet, Cipher};
use shadowvpn::mesh::{self, RouteAdvert, RouteInstaller};
use shadowvpn::obfs::{self, Obfuscator};
use shadowvpn::protocol::{max_datagram_size, MAX_IP_PACKET};
use shadowvpn::tun_device::TunDevice;

/// Depth of the hand-off channel between each relay loop's I/O reader and its
/// processor (see the server for the rationale). Bounded for backpressure.
const CHANNEL_DEPTH: usize = 1024;

/// Pause after a transient receive error before retrying: queued ICMP errors
/// surface back-to-back, and without a breather a condition that persists for
/// a few seconds would spin the receive loop.
const TRANSIENT_RETRY_DELAY: Duration = Duration::from_millis(100);

/// Plaintext payload of a keepalive datagram: a `0x00` marker byte followed by
/// the client's 4-byte tunnel IP. At 5 bytes it is smaller than any real IP
/// packet header, so the server can distinguish/drop it cheaply; the announced
/// tunnel IP lets the server learn/refresh this client's UDP source address
/// from the keepalive alone, before any real traffic flows. (Servers predating
/// the address suffix simply drop the datagram, same as the old bare `0x00`.)
fn keepalive_payload(tun_ip: Ipv4Addr) -> [u8; 5] {
    let [a, b, c, d] = tun_ip.octets();
    [0u8, a, b, c, d]
}

#[tokio::main]
async fn main() -> Result<()> {
    // Default to `info` logging; override with `RUST_LOG`.
    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();

    let args = ClientArgs::parse();

    // Journal-only recovery mode: put the resolver back after a run that died
    // without cleaning up (typically invoked by the desktop app's elevated
    // helper), then exit without bringing up a tunnel.
    if args.restore_dns {
        if !shadowvpn::policy::dnsconf::restore_from_journal()? {
            info!("no DNS restore journal found; nothing to do");
        }
        return Ok(());
    }

    let cfg = args
        .resolve()
        .context("failed to resolve client configuration")?;

    run(cfg).await
}

/// Bring up the TUN device + UDP socket and drive the two relay loops until one
/// of them fails (or the process is signalled).
async fn run(cfg: ClientConfig) -> Result<()> {
    // The master key length is guaranteed to match the cipher by `resolve()`.
    let cipher = cfg.cipher;
    let master_key: Arc<[u8]> = Arc::from(cfg.master_key.into_boxed_slice());

    // Carrier obfuscation, matching the server. When enabled, every datagram is
    // wrapped on send and unwrapped on recv; `None` is the plain envelope. Both
    // ends must agree (see `obfs`).
    let obfuscator: Option<Arc<Obfuscator>> = cfg
        .obfs
        .as_deref()
        .and_then(Obfuscator::from_name)
        .map(Arc::new);
    if let Some(name) = cfg.obfs.as_deref() {
        info!("carrier obfuscation: {name}");
    }

    // --- UDP socket ---------------------------------------------------------
    // Bind to an ephemeral local port on the unspecified address, then
    // `connect()` to the server so we can use send/recv (no per-call addr) and
    // benefit from kernel-side source-address selection + ICMP error reporting.
    //
    // This MUST happen *before* the TUN device is brought up. On Windows the
    // freshly-created Wintun adapter perturbs source-address selection, and a
    // `connect()` issued while it is up fails with `WSAEHOSTUNREACH` even though
    // the physical default route is unchanged. Connecting first resolves the
    // route against the pristine table and pins the socket to the physical
    // 5-tuple, so the tunnel coming up afterwards no longer affects it.
    let socket = shadowvpn::net::bind_udp("0.0.0.0:0".parse().expect("valid bind address"))
        .context("failed to bind local UDP socket")?;
    // Resolve the server's address with the built-in DNS client (querying the
    // clean/local upstreams directly) rather than the OS resolver, which may be
    // pinned at a not-yet-listening split-DNS proxy on 127.0.0.1 left over from a
    // previous run. The tunnel is not up yet, so these queries egress the
    // physical interface like the tunnel datagrams themselves.
    let server_addr = shadowvpn::net::resolve_server(
        &cfg.server,
        &[cfg.policy.dns_remote, cfg.policy.dns_local],
        cfg.policy.dns_timeout,
    )
    .await
    .with_context(|| format!("failed to resolve server address {}", cfg.server))?;
    if server_addr.to_string() != cfg.server {
        info!("resolved server {} -> {server_addr}", cfg.server);
    }
    socket.connect(server_addr).await.with_context(|| {
        format!(
            "failed to connect UDP socket to server {} ({server_addr})",
            cfg.server
        )
    })?;
    // The physical source address the OS chose to reach the server. Policy
    // routing binds direct (domestic) DNS queries to it on Windows so they don't
    // get mis-routed into the tunnel once it is up.
    let direct_src = socket
        .local_addr()
        .map(|a| a.ip())
        .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
    let local_addr = socket
        .local_addr()
        .map(|a| a.to_string())
        .unwrap_or_else(|_| "<unknown>".to_string());
    info!("UDP socket {local_addr} connected to server {}", cfg.server);
    let socket = Arc::new(socket);

    // --- TUN device ---------------------------------------------------------
    let tun = TunDevice::create(&cfg.tun).with_context(|| {
        format!(
            "failed to create TUN device (need root/elevated privileges); \
             requested ip={} peer={} mtu={}",
            cfg.tun.ip, cfg.tun.peer_ip, cfg.tun.mtu
        )
    })?;
    let tun = Arc::new(tun);

    let iface_name = tun.name().unwrap_or_else(|_| {
        cfg.tun
            .name
            .clone()
            .unwrap_or_else(|| "<unknown>".to_string())
    });
    info!(
        "TUN up: iface={iface_name} ip={} peer={} netmask={} mtu={}",
        cfg.tun.ip, cfg.tun.peer_ip, cfg.tun.netmask, cfg.tun.mtu
    );

    // --- Policy routing (optional) -----------------------------------------
    // In `gfwlist`/`chinadns` mode the client runs a split-DNS proxy and
    // programs per-destination routes into the tun (user-mode, via the OS
    // routing socket) so that only selected destinations go through the tunnel.
    // In `full` mode (the default) we touch nothing and just print the manual
    // routing hint, preserving the historical behavior.
    let mut policy_handle = if cfg.policy.mode.is_enabled() {
        info!(
            "policy routing mode = {}; only matched destinations are tunneled",
            cfg.policy.mode.name()
        );
        Some(
            shadowvpn::policy::spawn(
                &cfg.policy,
                &iface_name,
                cfg.tun.ip,
                server_addr.ip(),
                direct_src,
            )
            .await
            .context("failed to start policy routing")?,
        )
    } else {
        // Tell the user how to actually route traffic through the tunnel; in
        // full mode we never mutate the routing table ourselves.
        print_routing_hint(&cfg.tun, &cfg.server);
        None
    };

    // --- Mesh subnet routing (Tailscale-like) ------------------------------
    // Advertised routes ride the keepalive tick; accepted routes are pushed
    // back by the server and installed onto the tun by the RouteInstaller.
    let mesh_active = cfg.accept_routes || !cfg.advertise_routes.is_empty();
    if !cfg.advertise_routes.is_empty() {
        info!(
            "advertising subnet routes to the server: {}",
            cfg.advertise_routes
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(", ")
        );
    }
    let route_installer = if cfg.accept_routes {
        info!("accepting subnet routes pushed by the server");
        Some(Arc::new(
            RouteInstaller::new(&iface_name, cfg.tun.ip, server_addr.ip())
                .context("setting up the mesh route installer")?,
        ))
    } else {
        None
    };
    // Cleanup guard: removes installed routes when `run` returns, even though
    // the net->tun task keeps its own reference to the installer.
    let _route_guard = route_installer.clone().map(mesh::InstallerGuard::new);

    // The periodic datagram: a plain keepalive, or a route advert (which the
    // server also treats as a keepalive) once mesh routing is in play.
    let periodic_payload: Vec<u8> = if mesh_active {
        RouteAdvert {
            tunnel_ip: cfg.tun.ip,
            tunnel_ip6: cfg.tun.ip6.map(|net| net.ip()),
            accept_routes: cfg.accept_routes,
            routes: cfg.advertise_routes.clone(),
        }
        .encode()
    } else {
        keepalive_payload(cfg.tun.ip).to_vec()
    };

    // --- Relay + keepalive tasks -------------------------------------------
    // Loop A: TUN -> net (read IP packet, encrypt, send UDP).
    let up = tokio::spawn(tun_to_net(
        Arc::clone(&tun),
        Arc::clone(&socket),
        cipher,
        Arc::clone(&master_key),
        obfuscator.clone(),
    ));

    // Loop B: net -> TUN (recv UDP, decrypt, write IP packet).
    let down = tokio::spawn(net_to_tun(
        Arc::clone(&tun),
        Arc::clone(&socket),
        cipher,
        Arc::clone(&master_key),
        obfuscator.clone(),
        route_installer,
    ));

    // Keepalive: periodic tiny encrypted datagram so the server learns/refreshes
    // our address and NAT mappings stay open (and mesh adverts stay fresh).
    let keepalive = tokio::spawn(keepalive_loop(
        Arc::clone(&socket),
        cipher,
        Arc::clone(&master_key),
        obfuscator.clone(),
        cfg.keepalive,
        periodic_payload,
    ));

    // The DNS-proxy task, when policy routing is active. When it is not (or on
    // non-Linux), this future stays pending forever so it never wins the select.
    // Keeping `policy_handle` owned here also keeps the teardown guard alive for
    // the lifetime of the client.
    let policy_fut = async {
        if let Some(handle) = policy_handle.as_mut() {
            return match (&mut handle.task).await {
                Ok(inner) => inner.context("DNS proxy loop failed"),
                Err(join) => Err(anyhow::Error::new(join).context("DNS proxy task panicked")),
            };
        }
        std::future::pending::<Result<()>>().await
    };
    tokio::pin!(policy_fut);

    // Whichever arm fires first ends the client (a returning relay loop means a
    // fatal IO error; the keepalive loop only returns on a fatal send error; the
    // policy loop only returns on a fatal DNS-proxy error; a signal is a clean
    // shutdown request). Exiting gracefully drops the policy handle, whose guards
    // restore the system DNS, remove the tunnel routes, and save the cache.
    tokio::select! {
        r = up => propagate("tun->net", r),
        r = down => propagate("net->tun", r),
        r = keepalive => propagate("keepalive", r),
        r = &mut policy_fut => r,
        _ = shutdown_signal() => { info!("received shutdown signal; shutting down"); Ok(()) }
    }
}

/// Resolve when the OS asks the process to terminate (Ctrl-C / SIGTERM on Unix,
/// Ctrl-C / close / shutdown on Windows), so the run loop can exit gracefully.
#[cfg(unix)]
async fn shutdown_signal() {
    use tokio::signal::unix::{signal, SignalKind};
    match signal(SignalKind::terminate()) {
        Ok(mut term) => {
            tokio::select! {
                _ = tokio::signal::ctrl_c() => {}
                _ = term.recv() => {}
            }
        }
        Err(_) => {
            let _ = tokio::signal::ctrl_c().await;
        }
    }
}

/// See the Unix variant; on Windows there is no SIGTERM, so we watch the console
/// control events instead.
#[cfg(windows)]
async fn shutdown_signal() {
    use tokio::signal::windows;
    let mut close = windows::ctrl_close().expect("install ctrl-close handler");
    let mut shutdown = windows::ctrl_shutdown().expect("install ctrl-shutdown handler");
    tokio::select! {
        _ = tokio::signal::ctrl_c() => {}
        _ = close.recv() => {}
        _ = shutdown.recv() => {}
    }
}

/// Flatten a `JoinHandle` result + inner loop result into a single `Result`,
/// tagging which loop produced it.
fn propagate(which: &str, joined: Result<Result<()>, tokio::task::JoinError>) -> Result<()> {
    match joined {
        Ok(inner) => inner.with_context(|| format!("{which} loop failed")),
        Err(join_err) => {
            Err(anyhow::Error::new(join_err).context(format!("{which} task panicked/aborted")))
        }
    }
}

/// I/O errors on the connected UDP socket that reflect a transient *network*
/// condition rather than a broken socket: an ICMP unreachable bounced back
/// while a NAT on the path rebinds (`ECONNREFUSED`/`ECONNRESET`/
/// `EHOSTUNREACH`/`ENETUNREACH`), the physical interface flapping
/// (`ENETDOWN`/`EADDRNOTAVAIL`), or a momentarily full output queue
/// (`ENOBUFS`, common on macOS under load). Exiting on one of these turns a
/// seconds-long blip into a dead tunnel (a ~1 AM home-router NAT reset used
/// to take the client down for the rest of the night), so the relay and
/// keepalive loops log, drop the affected datagram, and keep going — the
/// path heals on its own.
fn is_transient_udp_error(e: &std::io::Error) -> bool {
    use std::io::ErrorKind;
    // ENOBUFS has no dedicated `ErrorKind`; match the raw OS error.
    #[cfg(unix)]
    if e.raw_os_error() == Some(libc::ENOBUFS) {
        return true;
    }
    #[cfg(windows)]
    if e.raw_os_error() == Some(10055) {
        // WSAENOBUFS
        return true;
    }
    matches!(
        e.kind(),
        ErrorKind::ConnectionRefused
            | ErrorKind::ConnectionReset
            | ErrorKind::ConnectionAborted
            | ErrorKind::HostUnreachable
            | ErrorKind::NetworkUnreachable
            | ErrorKind::NetworkDown
            | ErrorKind::AddrNotAvailable
            | ErrorKind::Interrupted
    )
}

/// Loop A: read raw IP packets from TUN, encrypt, and send to the server.
///
/// Pipelined so TUN reads overlap the per-packet encryption + UDP send: a
/// **reader** drains the TUN device into a bounded channel, and a single
/// **processor** encrypts, obfuscates, and sends (order preserved).
async fn tun_to_net(
    tun: Arc<TunDevice>,
    socket: Arc<UdpSocket>,
    cipher: Cipher,
    master_key: Arc<[u8]>,
    obfuscator: Option<Arc<Obfuscator>>,
) -> Result<()> {
    let (tx, mut rx) = mpsc::channel::<Vec<u8>>(CHANNEL_DEPTH);

    // Reader: pull IP packets off the TUN device and hand each to the processor.
    let reader = tokio::spawn(async move {
        // Plaintext buffer sized for the largest IP packet we might read.
        let mut buf = vec![0u8; MAX_IP_PACKET];
        loop {
            let n = tun
                .recv(&mut buf)
                .await
                .context("failed to read from TUN device")?;
            if n == 0 {
                continue;
            }
            if tx.send(buf[..n].to_vec()).await.is_err() {
                return Ok(());
            }
        }
    });

    // Processor: encrypt, obfuscate, and send to the server.
    let processor = tokio::spawn(async move {
        // Consecutive transient send failures (see `is_transient_udp_error`):
        // warn once when a burst starts, then stay quiet until it clears.
        let mut send_failures: u64 = 0;
        while let Some(pkt) = rx.recv().await {
            let n = pkt.len();

            // Encrypt this IP packet into one on-wire datagram. A crypto failure
            // here is non-fatal (skip the packet) — it should not normally happen
            // since we control the key and input.
            let datagram = match encrypt_packet(cipher, &master_key, &pkt) {
                Ok(d) => d,
                Err(e) => {
                    warn!("failed to encrypt a {n}-byte packet, dropping: {e}");
                    continue;
                }
            };

            // Apply carrier obfuscation (if enabled) just before the wire.
            let wire = match obfuscator {
                Some(ref o) => o.wrap(&datagram),
                None => datagram,
            };

            // A transient path error drops this packet (the peers' transport
            // protocols retransmit); anything else is fatal.
            if let Err(e) = socket.send(&wire).await {
                if is_transient_udp_error(&e) {
                    send_failures += 1;
                    if send_failures == 1 {
                        warn!("transient send error, dropping packets until the path clears: {e}");
                    } else {
                        debug!("transient send error #{send_failures}: {e}");
                    }
                    continue;
                }
                return Err(e).context("failed to send datagram to server");
            }
            if send_failures > 0 {
                info!("send path recovered after {send_failures} dropped packet(s)");
                send_failures = 0;
            }
            debug!(
                "tun->net: {n} bytes plaintext -> {} bytes on wire",
                wire.len()
            );
        }
        Ok(())
    });

    let mut reader = reader;
    let mut processor = processor;
    tokio::select! {
        r = &mut reader => { processor.abort(); r.context("tun->net reader task panicked")? }
        r = &mut processor => { reader.abort(); r.context("tun->net processor task panicked")? }
    }
}

/// Loop B: receive datagrams from the server, decrypt, and write the resulting
/// IP packet to the TUN device.
///
/// Pipelined so UDP receives overlap decryption + the TUN write: a **reader**
/// drains the socket into a bounded channel (so reply bursts are not dropped
/// while a packet is being decrypted), and a single **processor** de-obfuscates,
/// decrypts, and writes to TUN (order preserved).
async fn net_to_tun(
    tun: Arc<TunDevice>,
    socket: Arc<UdpSocket>,
    cipher: Cipher,
    master_key: Arc<[u8]>,
    obfuscator: Option<Arc<Obfuscator>>,
    route_installer: Option<Arc<RouteInstaller>>,
) -> Result<()> {
    let (tx, mut rx) = mpsc::channel::<Vec<u8>>(CHANNEL_DEPTH);

    // Reader: pull datagrams off the socket and hand each to the processor.
    let reader = tokio::spawn(async move {
        // UDP buffer sized for the encrypted form of the largest IP packet, plus
        // headroom for the obfs prefix when obfuscation is enabled.
        let mut buf = vec![0u8; max_datagram_size(cipher) + obfs::MAX_HEADER];
        // Consecutive transient receive failures (see `is_transient_udp_error`):
        // warn once when a burst starts, then stay quiet until it clears.
        let mut recv_failures: u64 = 0;
        loop {
            let n = match socket.recv(&mut buf).await {
                Ok(n) => n,
                // A transient path error (typically an ICMP unreachable queued
                // on the connected socket) is retried, with a breather so a
                // persistent condition doesn't spin this loop.
                Err(e) if is_transient_udp_error(&e) => {
                    recv_failures += 1;
                    if recv_failures == 1 {
                        warn!("transient receive error, retrying until the path clears: {e}");
                    } else {
                        debug!("transient receive error #{recv_failures}: {e}");
                    }
                    tokio::time::sleep(TRANSIENT_RETRY_DELAY).await;
                    continue;
                }
                Err(e) => return Err(e).context("failed to receive datagram from server"),
            };
            if recv_failures > 0 {
                info!("receive path recovered after {recv_failures} transient error(s)");
                recv_failures = 0;
            }
            if tx.send(buf[..n].to_vec()).await.is_err() {
                return Ok(());
            }
        }
    });

    // Processor: de-obfuscate, decrypt, and write to TUN.
    let processor = tokio::spawn(async move {
        while let Some(pkt) = rx.recv().await {
            let n = pkt.len();

            // De-obfuscate (if enabled); a packet that doesn't match the configured
            // obfuscation is noise/probe traffic — drop it. `decoded` (a `Cow`)
            // borrows from `pkt` for QUIC and owns for base64.
            let decoded;
            let datagram: &[u8] = match obfuscator {
                Some(ref o) => match o.unwrap(&pkt) {
                    Some(inner) => {
                        decoded = inner;
                        &decoded
                    }
                    None => {
                        debug!("dropping {n}-byte non-obfs datagram");
                        continue;
                    }
                },
                None => &pkt,
            };

            // Bad/forged/corrupt datagrams (too short or failing AEAD auth) are
            // dropped, not fatal — this is normal on an open UDP port.
            let plaintext = match decrypt_packet(cipher, &master_key, datagram) {
                Ok(p) => p,
                Err(e) => {
                    debug!("dropping undecryptable {n}-byte datagram: {e}");
                    continue;
                }
            };

            // Control payloads (marker byte 0x00) never reach the TUN. The one
            // the client acts on is the server's route push; anything else is
            // dropped, matching the server's treatment of unknown controls.
            if mesh::is_control(&plaintext) {
                match mesh::parse_control(&plaintext) {
                    Some(mesh::Control::RoutePush(push)) => match &route_installer {
                        Some(installer) => installer.apply(&push.routes),
                        None => {
                            debug!("ignoring route push: accept_routes is not enabled")
                        }
                    },
                    other => debug!(
                        "dropping {}-byte control payload ({other:?})",
                        plaintext.len()
                    ),
                }
                continue;
            }

            // Drop keepalive-sized payloads: anything too small to be an IP packet
            // (an IPv4 header alone is 20 bytes) must not be written to the TUN.
            if plaintext.len() < 20 {
                debug!("dropping {}-byte sub-IP-header payload", plaintext.len());
                continue;
            }

            // A write failure to our own TUN device is fatal.
            tun.send(&plaintext)
                .await
                .context("failed to write packet to TUN device")?;
            debug!(
                "net->tun: {n} bytes datagram -> {} bytes plaintext",
                plaintext.len()
            );
        }
        Ok(())
    });

    let mut reader = reader;
    let mut processor = processor;
    tokio::select! {
        r = &mut reader => { processor.abort(); r.context("net->tun reader task panicked")? }
        r = &mut processor => { reader.abort(); r.context("net->tun processor task panicked")? }
    }
}

/// Periodically send a tiny encrypted keepalive (or mesh route advert) to the
/// server.
///
/// This refreshes NAT mappings and lets the server learn our source address
/// before we send real traffic; when mesh routing is active, the payload is a
/// route advert, which the server treats as a keepalive too (and answers with
/// a route push for accept-routes clients). Encryption failures and transient
/// send errors are logged and skipped (the next tick retries); any other send
/// failure is fatal (the socket itself is broken).
async fn keepalive_loop(
    socket: Arc<UdpSocket>,
    cipher: Cipher,
    master_key: Arc<[u8]>,
    obfuscator: Option<Arc<Obfuscator>>,
    interval: Duration,
    payload: Vec<u8>,
) -> Result<()> {
    let mut ticker = tokio::time::interval(interval);
    // Don't fire a burst if we ever fall behind schedule.
    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);

    loop {
        ticker.tick().await;
        let datagram = match encrypt_packet(cipher, &master_key, &payload) {
            Ok(d) => d,
            Err(e) => {
                warn!("failed to encrypt keepalive, skipping: {e}");
                continue;
            }
        };
        // Keepalives ride the same obfs framing so the whole flow is uniform.
        let wire = match obfuscator {
            Some(ref o) => o.wrap(&datagram),
            None => datagram,
        };
        if let Err(e) = socket.send(&wire).await {
            if is_transient_udp_error(&e) {
                warn!("transient keepalive send error, retrying next tick: {e}");
                continue;
            }
            return Err(e).context("failed to send keepalive to server");
        }
        debug!("sent {}-byte keepalive", wire.len());
    }
}

/// Print the routing commands the user should run to send traffic through the
/// tunnel. We never modify the routing table automatically.
///
/// `server` is the remote `host:port`; only its host part matters for the
/// "host route to the server" hint, and only when it is a literal IP.
fn print_routing_hint(tun: &TunConfig, server: &str) {
    let peer = tun.peer_ip;
    let local = tun.ip;

    info!("-----------------------------------------------------------------");
    info!("Tunnel is up (local {local}, peer {peer}). It does NOT change your");
    info!("routing table. To send traffic through the tunnel, add routes by hand.");
    info!("");

    // A host route for the server itself must go via the *physical* gateway, or
    // the encrypted UDP would loop back into the tunnel. We can only fully spell
    // this out when the server host is a literal IP.
    let server_host = server.rsplit_once(':').map(|(h, _)| h).unwrap_or(server);
    let server_ip = server_host.parse::<Ipv4Addr>().ok();

    #[cfg(target_os = "linux")]
    {
        info!("Linux:");
        if let Some(ip) = server_ip {
            info!("  # keep the server reachable over your real link (replace GW/DEV):");
            info!("  sudo ip route add {ip}/32 via <YOUR_DEFAULT_GW> dev <YOUR_WAN_DEV>");
        } else {
            info!("  # first add a host route for the server's resolved IP via your real");
            info!("  # gateway, so encrypted UDP does not re-enter the tunnel.");
        }
        info!("  # then route everything (or a subnet) through the tunnel peer:");
        info!("  sudo ip route add 0.0.0.0/1 via {peer}");
        info!("  sudo ip route add 128.0.0.0/1 via {peer}");
        info!("  # (the two /1 routes override the default without deleting it)");
    }

    #[cfg(target_os = "macos")]
    {
        info!("macOS:");
        if let Some(ip) = server_ip {
            info!("  # keep the server reachable over your real link (replace GW):");
            info!("  sudo route -n add -host {ip} <YOUR_DEFAULT_GW>");
        } else {
            info!("  # first add a host route for the server's resolved IP via your real");
            info!("  # gateway, so encrypted UDP does not re-enter the tunnel.");
        }
        info!("  # then route everything through the tunnel peer:");
        info!("  sudo route -n add -net 0.0.0.0/1 {peer}");
        info!("  sudo route -n add -net 128.0.0.0/1 {peer}");
    }

    #[cfg(windows)]
    {
        info!("Windows (run in an elevated prompt):");
        if let Some(ip) = server_ip {
            info!("  :: keep the server reachable over your real link (replace GW):");
            info!("  route add {ip} mask 255.255.255.255 <YOUR_DEFAULT_GW>");
        } else {
            info!("  :: first add a host route for the server's resolved IP via your real");
            info!("  :: gateway, so encrypted UDP does not re-enter the tunnel.");
        }
        info!("  :: then route everything through the tunnel peer:");
        info!("  route add 0.0.0.0 mask 128.0.0.0 {peer}");
        info!("  route add 128.0.0.0 mask 128.0.0.0 {peer}");
    }

    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
    {
        let _ = server_ip;
        info!("Add a host route to the server via your real gateway, then route the");
        info!("desired destinations via the tunnel peer {peer}.");
    }

    info!("");
    info!("To stop using the tunnel, delete the routes you added above.");
    info!("-----------------------------------------------------------------");

    if server_ip.is_none() {
        warn!(
            "server '{server}' is a hostname, not a literal IP: resolve it and add a \
             host route for that IP via your real gateway before routing all traffic."
        );
    }
}