zlayer-overlay 0.11.21

Encrypted overlay networking for containers using boringtun userspace WireGuard
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
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
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
//! End-to-end integration tests for `ZLayer` overlay networking.
//!
//! Tests marked with `#[ignore]` require root or `CAP_NET_ADMIN` capability.
//! Run them with:
//!
//! ```sh
//! cargo test -p zlayer-overlay --test overlay_e2e -- --ignored
//! ```

use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::Duration;
use tokio::process::Command;
use x25519_dalek::{PublicKey, StaticSecret};
use zlayer_overlay::config::{OverlayConfig, PeerInfo};
use zlayer_overlay::transport::OverlayTransport;

// ---------------------------------------------------------------------------
// 1. Key generation test
// ---------------------------------------------------------------------------

#[tokio::test]
async fn test_native_key_generation_produces_valid_keys() {
    let (private_key, public_key) = OverlayTransport::generate_keys()
        .await
        .expect("generate_keys should succeed");

    // Overlay keys are 32 bytes encoded as standard base64 => 44 characters
    assert_eq!(
        private_key.len(),
        44,
        "Private key should be 44 characters (32 bytes base64-encoded), got {}",
        private_key.len()
    );
    assert_eq!(
        public_key.len(),
        44,
        "Public key should be 44 characters (32 bytes base64-encoded), got {}",
        public_key.len()
    );

    // Decode and verify raw byte lengths
    let priv_bytes = STANDARD
        .decode(&private_key)
        .expect("Private key must be valid base64");
    let pub_bytes = STANDARD
        .decode(&public_key)
        .expect("Public key must be valid base64");

    assert_eq!(
        priv_bytes.len(),
        32,
        "Decoded private key must be exactly 32 bytes"
    );
    assert_eq!(
        pub_bytes.len(),
        32,
        "Decoded public key must be exactly 32 bytes"
    );

    // Verify public key is correctly derived from private key
    let secret =
        StaticSecret::from(<[u8; 32]>::try_from(priv_bytes.as_slice()).expect("32-byte slice"));
    let expected_public = PublicKey::from(&secret);
    assert_eq!(
        pub_bytes.as_slice(),
        expected_public.as_bytes(),
        "Public key must be the x25519 derivation of the private key"
    );

    // Verify successive calls produce unique keys
    let (private_key_2, _) = OverlayTransport::generate_keys()
        .await
        .expect("second generate_keys should succeed");
    assert_ne!(
        private_key, private_key_2,
        "Two consecutive key generations must produce distinct private keys"
    );
}

// ---------------------------------------------------------------------------
// 2. Key compatibility test (optional: requires `wg` binary)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn test_native_keys_compatible_with_wg_tool() {
    // Skip if `wg` is not available -- the wg binary is no longer required
    // at runtime, so this test is purely for validating key format compatibility.
    let wg_available = Command::new("which")
        .arg("wg")
        .output()
        .await
        .is_ok_and(|o| o.status.success());

    if !wg_available {
        eprintln!("SKIP: `wg` binary not found; skipping key compatibility test (wg is optional)");
        return;
    }

    // Generate keys via native Rust implementation
    let (native_priv, native_pub) = OverlayTransport::generate_keys()
        .await
        .expect("native generate_keys should succeed");

    // Generate keys via `wg genkey` / `wg pubkey`
    let wg_genkey_output = Command::new("wg")
        .arg("genkey")
        .output()
        .await
        .expect("wg genkey should execute");
    assert!(
        wg_genkey_output.status.success(),
        "wg genkey failed: {}",
        String::from_utf8_lossy(&wg_genkey_output.stderr)
    );
    let wg_priv = String::from_utf8(wg_genkey_output.stdout)
        .expect("wg genkey output should be valid UTF-8")
        .trim()
        .to_string();

    // Use a standard (sync) Command for piped stdin to keep it simple
    let wg_pubkey_output = {
        let mut child = std::process::Command::new("wg")
            .arg("pubkey")
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .spawn()
            .expect("wg pubkey should spawn");
        {
            use std::io::Write;
            let stdin = child.stdin.as_mut().expect("stdin should be available");
            stdin
                .write_all(wg_priv.as_bytes())
                .expect("writing to stdin should succeed");
        }
        child.wait_with_output().expect("wg pubkey should complete")
    };
    assert!(
        wg_pubkey_output.status.success(),
        "wg pubkey failed: {}",
        String::from_utf8_lossy(&wg_pubkey_output.stderr)
    );
    let wg_pub = String::from_utf8(wg_pubkey_output.stdout)
        .expect("wg pubkey output should be valid UTF-8")
        .trim()
        .to_string();

    // Both native and wg-generated keys must have the same format
    assert_eq!(
        native_priv.len(),
        wg_priv.len(),
        "Native and wg private keys must have the same length"
    );
    assert_eq!(
        native_pub.len(),
        wg_pub.len(),
        "Native and wg public keys must have the same length"
    );

    // Both must decode to 32 bytes
    let native_priv_bytes = STANDARD
        .decode(&native_priv)
        .expect("native private key must be valid base64");
    let wg_priv_bytes = STANDARD
        .decode(&wg_priv)
        .expect("wg private key must be valid base64");
    assert_eq!(native_priv_bytes.len(), 32);
    assert_eq!(wg_priv_bytes.len(), 32);

    let native_pub_bytes = STANDARD
        .decode(&native_pub)
        .expect("native public key must be valid base64");
    let wg_pub_bytes = STANDARD
        .decode(&wg_pub)
        .expect("wg public key must be valid base64");
    assert_eq!(native_pub_bytes.len(), 32);
    assert_eq!(wg_pub_bytes.len(), 32);

    // Verify cross-derivation: feeding the wg-generated private key to our
    // x25519-dalek derivation must produce the same public key `wg pubkey` gave.
    let wg_secret =
        StaticSecret::from(<[u8; 32]>::try_from(wg_priv_bytes.as_slice()).expect("32-byte slice"));
    let derived_pub = PublicKey::from(&wg_secret);
    assert_eq!(
        derived_pub.as_bytes(),
        wg_pub_bytes.as_slice(),
        "x25519-dalek derivation of the wg-generated private key must match wg pubkey output"
    );
}

// ---------------------------------------------------------------------------
// 3. Overlay config construction test
// ---------------------------------------------------------------------------

#[tokio::test]
async fn test_overlay_config_and_peer_config_format() {
    let (private_key, public_key) = OverlayTransport::generate_keys()
        .await
        .expect("key generation should succeed");

    // Build an OverlayConfig with the generated keys
    let config = OverlayConfig {
        local_endpoint: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), 51820),
        private_key: private_key.clone(),
        public_key: public_key.clone(),
        overlay_cidr: "10.200.0.1/16".to_string(),
        cluster_cidr: None,
        peer_discovery_interval: Duration::from_secs(30),
        #[cfg(feature = "nat")]
        nat: zlayer_overlay::nat::NatConfig::default(),
    };

    assert_eq!(config.local_endpoint.port(), 51820);
    assert_eq!(config.overlay_cidr, "10.200.0.1/16");
    assert_eq!(config.private_key, private_key);
    assert_eq!(config.public_key, public_key);

    // Build a PeerInfo and verify its peer config block format
    let peer = PeerInfo::new(
        public_key.clone(),
        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)), 51820),
        "10.200.0.2/32",
        Duration::from_secs(25),
    );

    let peer_config = peer.to_peer_config();

    assert!(
        peer_config.contains("[Peer]"),
        "Peer config must contain [Peer] section header"
    );
    assert!(
        peer_config.contains(&format!("PublicKey = {public_key}")),
        "Peer config must contain the correct public key"
    );
    assert!(
        peer_config.contains("Endpoint = 192.168.1.20:51820"),
        "Peer config must contain the correct endpoint"
    );
    assert!(
        peer_config.contains("AllowedIPs = 10.200.0.2/32"),
        "Peer config must contain the correct allowed IPs"
    );
    assert!(
        peer_config.contains("PersistentKeepalive = 25"),
        "Peer config must contain the correct keepalive interval"
    );

    // Verify the full config block format.
    // It concatenates [Interface] + [Peer] blocks.
    let full_config = format!(
        "[Interface]\nPrivateKey = {}\nListenPort = {}\n{}",
        config.private_key,
        config.local_endpoint.port(),
        peer_config,
    );
    assert!(
        full_config.starts_with("[Interface]"),
        "Full config must start with [Interface] section"
    );
    assert!(
        full_config.contains(&format!("PrivateKey = {private_key}")),
        "Full config must contain the private key"
    );
    assert!(
        full_config.contains("ListenPort = 51820"),
        "Full config must contain the listen port"
    );
    assert!(
        full_config.contains("[Peer]"),
        "Full config must contain [Peer] section"
    );
}

// ---------------------------------------------------------------------------
// 4. Interface lifecycle test (requires root or CAP_NET_ADMIN)
// ---------------------------------------------------------------------------
// Run with: cargo test -p zlayer-overlay --test overlay_e2e -- --ignored

#[tokio::test]
#[ignore = "requires root or CAP_NET_ADMIN"]
async fn test_overlay_interface_lifecycle() {
    let iface_name = "wg-test-life";

    // Generate keys for the interface
    let (private_key, public_key) = OverlayTransport::generate_keys()
        .await
        .expect("key generation should succeed");

    let config = OverlayConfig {
        local_endpoint: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 51830),
        private_key,
        public_key,
        overlay_cidr: "10.250.0.1/24".to_string(),
        cluster_cidr: None,
        peer_discovery_interval: Duration::from_secs(30),
        #[cfg(feature = "nat")]
        nat: zlayer_overlay::nat::NatConfig::default(),
    };

    let mut manager = OverlayTransport::new(config, iface_name.to_string());

    // Cleanup any leftover interface from a previous failed run
    let _ = Command::new("ip")
        .args(["link", "del", "dev", iface_name])
        .output()
        .await;

    // Create the overlay interface
    manager
        .create_interface()
        .await
        .expect("create_interface should succeed");

    // Verify the interface exists via `ip link show`
    let link_output = Command::new("ip")
        .args(["link", "show", "dev", iface_name])
        .output()
        .await
        .expect("ip link show should execute");
    assert!(
        link_output.status.success(),
        "Interface {} should exist after create_interface, stderr: {}",
        iface_name,
        String::from_utf8_lossy(&link_output.stderr)
    );
    let link_stdout = String::from_utf8_lossy(&link_output.stdout);
    assert!(
        link_stdout.contains(iface_name),
        "ip link show output should contain the interface name"
    );

    // Configure the interface (assign IP, bring up)
    manager
        .configure(&[])
        .await
        .expect("configure_interface should succeed with no peers");

    // Verify the interface is UP
    let link_output = Command::new("ip")
        .args(["link", "show", "dev", iface_name])
        .output()
        .await
        .expect("ip link show should execute");
    let link_stdout = String::from_utf8_lossy(&link_output.stdout);
    assert!(
        link_stdout.contains("UP") || link_stdout.contains("up"),
        "Interface should be UP after configure_interface, got: {link_stdout}",
    );

    // Verify the overlay IP is assigned
    let addr_output = Command::new("ip")
        .args(["addr", "show", "dev", iface_name])
        .output()
        .await
        .expect("ip addr show should execute");
    let addr_stdout = String::from_utf8_lossy(&addr_output.stdout);
    assert!(
        addr_stdout.contains("10.250.0.1"),
        "Interface should have overlay IP 10.250.0.1 assigned, got: {addr_stdout}",
    );

    // Tear down the interface via transport shutdown
    manager.shutdown();

    // Allow a brief moment for cleanup to complete
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Verify the interface is gone
    let link_output = Command::new("ip")
        .args(["link", "show", "dev", iface_name])
        .output()
        .await
        .expect("ip link show should execute");
    assert!(
        !link_output.status.success(),
        "Interface {iface_name} should no longer exist after shutdown",
    );
}

// ---------------------------------------------------------------------------
// 5. Dual-interface connectivity test (requires root or CAP_NET_ADMIN)
// ---------------------------------------------------------------------------
// Run with: cargo test -p zlayer-overlay --test overlay_e2e -- --ignored

#[tokio::test]
#[ignore = "requires root or CAP_NET_ADMIN"]
#[allow(clippy::too_many_lines)]
async fn test_dual_overlay_connectivity() {
    let iface_a = "wg-test-a";
    let iface_b = "wg-test-b";
    let port_a: u16 = 51840;
    let port_b: u16 = 51841;
    let ip_a = "10.251.0.1";
    let ip_b = "10.251.0.2";
    let subnet = "/24";

    // Generate keys for both interfaces
    let (priv_a, pub_a) = OverlayTransport::generate_keys()
        .await
        .expect("key generation for A should succeed");
    let (priv_b, pub_b) = OverlayTransport::generate_keys()
        .await
        .expect("key generation for B should succeed");

    let config_a = OverlayConfig {
        local_endpoint: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port_a),
        private_key: priv_a,
        public_key: pub_a.clone(),
        overlay_cidr: format!("{ip_a}{subnet}"),
        cluster_cidr: None,
        peer_discovery_interval: Duration::from_secs(30),
        #[cfg(feature = "nat")]
        nat: zlayer_overlay::nat::NatConfig::default(),
    };

    let config_b = OverlayConfig {
        local_endpoint: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port_b),
        private_key: priv_b,
        public_key: pub_b.clone(),
        overlay_cidr: format!("{ip_b}{subnet}"),
        cluster_cidr: None,
        peer_discovery_interval: Duration::from_secs(30),
        #[cfg(feature = "nat")]
        nat: zlayer_overlay::nat::NatConfig::default(),
    };

    let mut manager_a = OverlayTransport::new(config_a, iface_a.to_string());
    let mut manager_b = OverlayTransport::new(config_b, iface_b.to_string());

    // Cleanup any leftover interfaces
    let _ = Command::new("ip")
        .args(["link", "del", "dev", iface_a])
        .output()
        .await;
    let _ = Command::new("ip")
        .args(["link", "del", "dev", iface_b])
        .output()
        .await;

    // Create both interfaces
    manager_a
        .create_interface()
        .await
        .expect("create_interface A should succeed");
    manager_b
        .create_interface()
        .await
        .expect("create_interface B should succeed");

    // Peer B is a peer of A: endpoint is 127.0.0.1:port_b, allowed IP is B's overlay IP
    let peer_b_for_a = PeerInfo::new(
        pub_b.clone(),
        SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port_b),
        &format!("{ip_b}/32"),
        Duration::from_secs(25),
    );

    // Peer A is a peer of B: endpoint is 127.0.0.1:port_a, allowed IP is A's overlay IP
    let peer_a_for_b = PeerInfo::new(
        pub_a.clone(),
        SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port_a),
        &format!("{ip_a}/32"),
        Duration::from_secs(25),
    );

    // Configure both interfaces with their respective peers
    manager_a
        .configure(&[peer_b_for_a])
        .await
        .expect("configure_interface A with peer B should succeed");
    manager_b
        .configure(&[peer_a_for_b])
        .await
        .expect("configure_interface B with peer A should succeed");

    // Allow a brief moment for the tunnel to establish
    tokio::time::sleep(Duration::from_millis(500)).await;

    // Ping from A's overlay IP to B's overlay IP through the overlay tunnel
    let ping_output = Command::new("ping")
        .args([
            "-c", "3", // 3 packets
            "-W", "5", // 5 second timeout
            "-I", iface_a, // source interface
            ip_b,    // destination
        ])
        .output()
        .await
        .expect("ping command should execute");

    let ping_stdout = String::from_utf8_lossy(&ping_output.stdout);
    let ping_stderr = String::from_utf8_lossy(&ping_output.stderr);

    assert!(
        ping_output.status.success(),
        "Ping from {iface_a} ({ip_a}) to {iface_b} ({ip_b}) should succeed.\nstdout: {ping_stdout}\nstderr: {ping_stderr}",
    );

    // Verify we received replies (not 100% packet loss)
    assert!(
        !ping_stdout.contains("100% packet loss"),
        "Ping should not have 100% packet loss.\nstdout: {ping_stdout}",
    );

    // Also ping in the reverse direction
    let ping_reverse = Command::new("ping")
        .args(["-c", "3", "-W", "5", "-I", iface_b, ip_a])
        .output()
        .await
        .expect("reverse ping should execute");

    assert!(
        ping_reverse.status.success(),
        "Reverse ping from {} ({}) to {} ({}) should succeed.\nstdout: {}\nstderr: {}",
        iface_b,
        ip_b,
        iface_a,
        ip_a,
        String::from_utf8_lossy(&ping_reverse.stdout),
        String::from_utf8_lossy(&ping_reverse.stderr),
    );

    // Cleanup: shut down both transports
    manager_a.shutdown();
    manager_b.shutdown();

    // Allow a brief moment for cleanup to complete
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Verify both are gone
    let check_a = Command::new("ip")
        .args(["link", "show", "dev", iface_a])
        .output()
        .await
        .expect("ip link show should execute");
    assert!(
        !check_a.status.success(),
        "Interface {iface_a} should be removed after shutdown",
    );

    let check_b = Command::new("ip")
        .args(["link", "show", "dev", iface_b])
        .output()
        .await
        .expect("ip link show should execute");
    assert!(
        !check_b.status.success(),
        "Interface {iface_b} should be removed after shutdown",
    );
}

// ---------------------------------------------------------------------------
// 6. IPv6 overlay config construction test
// ---------------------------------------------------------------------------

#[tokio::test]
async fn test_overlay_config_and_peer_config_format_ipv6() {
    let (private_key, public_key) = OverlayTransport::generate_keys()
        .await
        .expect("key generation should succeed");

    // Build an OverlayConfig with IPv6 addressing
    let config = OverlayConfig {
        local_endpoint: SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 51820),
        private_key: private_key.clone(),
        public_key: public_key.clone(),
        overlay_cidr: "fd00:200::1/48".to_string(),
        cluster_cidr: None,
        peer_discovery_interval: Duration::from_secs(30),
        #[cfg(feature = "nat")]
        nat: zlayer_overlay::nat::NatConfig::default(),
    };

    assert_eq!(config.local_endpoint.port(), 51820);
    assert!(config.local_endpoint.ip().is_ipv6());
    assert_eq!(config.overlay_cidr, "fd00:200::1/48");
    assert_eq!(config.private_key, private_key);
    assert_eq!(config.public_key, public_key);

    // Build a PeerInfo with an IPv6 endpoint and verify its peer config block format
    let peer = PeerInfo::new(
        public_key.clone(),
        SocketAddr::new(
            IpAddr::V6(Ipv6Addr::new(0xfd00, 0x200, 0, 0, 0, 0, 0, 0x20)),
            51820,
        ),
        "fd00:200::2/128",
        Duration::from_secs(25),
    );

    let peer_config = peer.to_peer_config();

    assert!(
        peer_config.contains("[Peer]"),
        "Peer config must contain [Peer] section header"
    );
    assert!(
        peer_config.contains(&format!("PublicKey = {public_key}")),
        "Peer config must contain the correct public key"
    );
    // IPv6 endpoints use bracket notation in WireGuard configs
    assert!(
        peer_config.contains("Endpoint = [fd00:200::20]:51820"),
        "Peer config must contain the correctly formatted IPv6 endpoint, got: {peer_config}"
    );
    assert!(
        peer_config.contains("AllowedIPs = fd00:200::2/128"),
        "Peer config must contain the correct IPv6 allowed IPs"
    );
    assert!(
        peer_config.contains("PersistentKeepalive = 25"),
        "Peer config must contain the correct keepalive interval"
    );
}

// ---------------------------------------------------------------------------
// 7. Mixed IPv4/IPv6 peer config test
// ---------------------------------------------------------------------------

#[tokio::test]
async fn test_overlay_config_mixed_v4_v6_peers() {
    let (private_key, public_key) = OverlayTransport::generate_keys()
        .await
        .expect("key generation should succeed");

    let (_, peer_pub_v4) = OverlayTransport::generate_keys()
        .await
        .expect("key generation for v4 peer should succeed");

    let (_, peer_pub_v6) = OverlayTransport::generate_keys()
        .await
        .expect("key generation for v6 peer should succeed");

    // IPv4 peer
    let peer_v4 = PeerInfo::new(
        peer_pub_v4.clone(),
        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)), 51820),
        "10.200.0.2/32",
        Duration::from_secs(25),
    );

    // IPv6 peer
    let peer_v6 = PeerInfo::new(
        peer_pub_v6.clone(),
        SocketAddr::new(
            IpAddr::V6(Ipv6Addr::new(0xfd00, 0x200, 0, 0, 0, 0, 0, 0x20)),
            51820,
        ),
        "fd00:200::20/128",
        Duration::from_secs(25),
    );

    let config_v4 = peer_v4.to_peer_config();
    let config_v6 = peer_v6.to_peer_config();

    // Verify IPv4 peer config uses plain IP notation
    assert!(
        config_v4.contains("Endpoint = 192.168.1.20:51820"),
        "IPv4 peer should have plain IP:port endpoint"
    );
    assert!(
        config_v4.contains("AllowedIPs = 10.200.0.2/32"),
        "IPv4 peer should have IPv4 allowed IPs"
    );

    // Verify IPv6 peer config uses bracket notation
    assert!(
        config_v6.contains("Endpoint = [fd00:200::20]:51820"),
        "IPv6 peer should have [IP]:port endpoint"
    );
    assert!(
        config_v6.contains("AllowedIPs = fd00:200::20/128"),
        "IPv6 peer should have IPv6 allowed IPs"
    );

    // Both should have their own public keys
    assert!(config_v4.contains(&format!("PublicKey = {peer_pub_v4}")));
    assert!(config_v6.contains(&format!("PublicKey = {peer_pub_v6}")));

    // The full config should be able to combine both peer blocks
    let full_config = format!(
        "[Interface]\nPrivateKey = {}\nListenPort = {}\n{}\n{}",
        private_key, 51820, config_v4, config_v6,
    );
    assert!(full_config.starts_with("[Interface]"));
    assert_eq!(
        full_config.matches("[Peer]").count(),
        2,
        "Full config should contain exactly 2 [Peer] sections"
    );
    let _ = public_key; // used to generate config above
}

// ---------------------------------------------------------------------------
// 8. IPv6 interface lifecycle test (requires root or CAP_NET_ADMIN)
// ---------------------------------------------------------------------------
// Run with: cargo test -p zlayer-overlay --test overlay_e2e -- --ignored

#[tokio::test]
#[ignore = "requires root or CAP_NET_ADMIN"]
async fn test_overlay_interface_lifecycle_ipv6() {
    let iface_name = "wg-test-v6";

    // Generate keys for the interface
    let (private_key, public_key) = OverlayTransport::generate_keys()
        .await
        .expect("key generation should succeed");

    let config = OverlayConfig {
        local_endpoint: SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 51850),
        private_key,
        public_key,
        overlay_cidr: "fd00:250::1/48".to_string(),
        cluster_cidr: None,
        peer_discovery_interval: Duration::from_secs(30),
        #[cfg(feature = "nat")]
        nat: zlayer_overlay::nat::NatConfig::default(),
    };

    let mut manager = OverlayTransport::new(config, iface_name.to_string());

    // Cleanup any leftover interface from a previous failed run
    let _ = Command::new("ip")
        .args(["link", "del", "dev", iface_name])
        .output()
        .await;

    // Create the overlay interface
    manager
        .create_interface()
        .await
        .expect("create_interface should succeed with IPv6 config");

    // Verify the interface exists via `ip link show`
    let link_output = Command::new("ip")
        .args(["link", "show", "dev", iface_name])
        .output()
        .await
        .expect("ip link show should execute");
    assert!(
        link_output.status.success(),
        "Interface {} should exist after create_interface, stderr: {}",
        iface_name,
        String::from_utf8_lossy(&link_output.stderr)
    );

    // Configure the interface (assign IPv6, bring up)
    manager
        .configure(&[])
        .await
        .expect("configure_interface should succeed with IPv6 and no peers");

    // Verify the interface is UP
    let link_output = Command::new("ip")
        .args(["link", "show", "dev", iface_name])
        .output()
        .await
        .expect("ip link show should execute");
    let link_stdout = String::from_utf8_lossy(&link_output.stdout);
    assert!(
        link_stdout.contains("UP") || link_stdout.contains("up"),
        "Interface should be UP after configure_interface, got: {link_stdout}",
    );

    // Verify the IPv6 overlay address is assigned
    let addr_output = Command::new("ip")
        .args(["-6", "addr", "show", "dev", iface_name])
        .output()
        .await
        .expect("ip -6 addr show should execute");
    let addr_stdout = String::from_utf8_lossy(&addr_output.stdout);
    assert!(
        addr_stdout.contains("fd00:250::1"),
        "Interface should have IPv6 overlay address fd00:250::1 assigned, got: {addr_stdout}",
    );

    // Tear down
    manager.shutdown();
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Verify the interface is gone
    let link_output = Command::new("ip")
        .args(["link", "show", "dev", iface_name])
        .output()
        .await
        .expect("ip link show should execute");
    assert!(
        !link_output.status.success(),
        "Interface {iface_name} should no longer exist after shutdown",
    );
}