Skip to content

Conversation

@Rishikpulhani
Copy link
Contributor

@Rishikpulhani Rishikpulhani commented Aug 25, 2025

Fixes #1072

This commit introduces full support for stateful IPv6 address assignment via DHCPv6, resolving the issue where containers would not receive a global IPv6 address on macvlan networks.

The implementation follows the standard IPv6 design of separating gateway discovery (via Router Advertisements) from stateful address assignment (via DHCPv6).

Design

The implementation follows the standard IPv6 model:

  1. Gateway Discovery via Router Advertisements: The container's kernel is configured via sysctl (accept_ra=1, autoconf=0) to automatically learn its default gateway from RAs while preventing SLAAC. Netavark does not parse RA messages itself.
  2. Stateful Address Assignment via DHCPv6: A new DhcpV6Service in the DHCP proxy uses the mozim library to acquire a lease, providing the container with a stable IP address and other options like DNS.

Key changes include:

  • Kernel Configuration: Netavark now configures the container's kernel to accept Router Advertisements (accept_ra=1) for automatic gateway discovery, while disabling SLAAC (autoconf=0) to ensure a managed, deterministic network environment.

  • DHCPv6 Service: A new DhcpV6Service is added to the DHCP proxy. It uses the mozim library to acquire IPv6 leases and correctly generates a stable DUID-LL from the container's static MAC address to ensure a persistent network identity.

  • gRPC Layer: The gRPC Lease object and its From implementations have been updated to act as a universal carrier for both IPv4 and IPv6 lease information.

  • Generic Proxy Logic: Core functions like process_client_stream and update_lease_ip have been refactored to handle both DHCPv4 and DHCPv6 services generically, with conditional logic to correctly handle the differences between the protocols (e.g., gateway handling).

Summary by Sourcery

Add comprehensive DHCPv6 support alongside existing DHCPv4 functionality, allowing macvlan and bridge networks to obtain stateful IPv6 addresses via DHCPv6 while preserving deterministic gateway discovery via Router Advertisements.

New Features:

  • Introduce DhcpV6Service to acquire stateful IPv6 leases using the mozim library and generate stable DUID-LL identifiers.
  • Unify DHCPv4 and DHCPv6 handling under a DhcpService enum and extend the gRPC Lease object to carry both IPv4 and IPv6 lease data.
  • Enable dual-stack DHCP in get_dhcp_lease, performing IPv4 first then conditional IPv6 based on IPAM configuration.
  • Configure container kernel via sysctl to disable SLAAC (autoconf=0) and enable Router Advertisements (accept_ra=2) for IPv6 gateways.

Enhancements:

  • Refactor process_client_stream and update_lease_ip to operate generically over MozimLease (V4/V6) and compare leases uniformly.
  • Extend MacVLAN Address trait for IPv6, splitting setup into protocol-specific functions and skipping gateway setup for IPv6.
  • Update network drivers (macvlan, bridge, VLAN) to pass IPAM info and invoke IPv6 path during container setup.

Build:

  • Add dhcproto and hex crates for DHCPv6 option parsing and DUID conversion.

Tests:

  • Enhance test-dhcp helpers and BATS configuration to launch IPv4 or IPv6 dnsmasq based on test names and exercise DHCPv6 flows.

@sourcery-ai
Copy link

sourcery-ai bot commented Aug 25, 2025

Reviewer's Guide

This PR adds full stateful DHCPv6 support alongside DHCPv4 by refactoring the proxy layer into a unified DhcpService enum, introducing a new DhcpV6Service using the mozim library, extending gRPC Lease messages for IPv6, adjusting network drivers to configure IPv6 sysctls, updating client code to perform sequential IPv4/IPv6 leases, and enhancing test scripts and dependencies to cover IPv6 scenarios.

Class diagram for unified DHCP service and lease types

classDiagram
    class DhcpService {
        +get_net_config()
        +get_previous_lease()
        +set_previous_lease(lease)
        +release_lease()
    }
    class DhcpV4Service {
        +client
        +network_config
        +previous_lease
        +release_lease()
    }
    class DhcpV6Service {
        +client
        +network_config
        +previous_lease
        +get_lease()
        +release_lease()
    }
    class MozimLease {
    }
    class MozimV4Lease {
    }
    class MozimV6Lease {
    }
    DhcpService <|-- DhcpV4Service
    DhcpService <|-- DhcpV6Service
    MozimLease <|-- MozimV4Lease
    MozimLease <|-- MozimV6Lease
    DhcpV4Service --> MozimV4Lease : previous_lease
    DhcpV6Service --> MozimV6Lease : previous_lease
    DhcpService --> MozimLease : get_previous_lease/set_previous_lease
Loading

File-Level Changes

Change Details Files
Refactor proxy to a unified DhcpService with generic lease processing
  • Introduced DhcpService enum wrapping V4 and V6 services
  • Added MozimLease enum and helper methods for previous lease handling
  • Updated process_client_stream and lease update logic to handle both protocols
  • Generalized update_lease_ip to match on IPv4 vs IPv6
  • Extracted lease_has_changed helper for protocol-agnostic comparisons
src/dhcp_proxy/dhcp_service.rs
Implement stateful DHCPv6 service
  • Added DhcpV6Service with new, get_lease, and release_lease methods
  • Configured DUID-LL generation from container MAC for stable identity
  • Processed mozim v6 client next() stream into NetavarkLease
src/dhcp_proxy/dhcp_service.rs
Extend gRPC Lease to support IPv6 leases
  • Implemented From for Lease to populate yiaddr, prefix_len, DNS, domain search and NTP options
  • Repurposed Lease fields and marked is_v6 flag
  • Imported dhcproto option types for parsing
src/dhcp_proxy/lib.rs
Refactor DHCP client to perform sequential IPv4 and IPv6 leasing
  • Updated get_dhcp_lease to request IPv4 then conditional IPv6 leases
  • Introduced get_lease_from_proxy and parse_lease helpers
  • Merged DNS and domain lists between IPv4/IPv6 results
src/network/dhcp.rs
Configure IPv6 sysctls and pass IPAM to network drivers
  • Apply accept_ra=2 and disable autoconf for IPv6 on macvlan and bridge interfaces
  • Passed IPAMAddresses into get_dhcp_lease calls
  • Kept existing IPv4 sysctls intact
src/network/bridge.rs
src/network/vlan.rs
Enable DHCPv6 in proxy command handler
  • Extended commands/dhcp_proxy to spawn DhcpService::V6 on version=1
  • Configured task lifecycle and stream processing for IPv6
src/commands/dhcp_proxy.rs
Update test suite for IPv6 scenarios
  • Modified helpers.bash to detect "ipv6" tests and set up IPv6 env
  • Enhanced dnsmasq config snippets for DHCPv6
  • Added (disabled) IPv6-specific BATS test
  • Updated sample.conf with IPv6 options
test-dhcp/helpers.bash
test-dhcp/dnsmasqfiles/sample.conf
test-dhcp/002-setup.bats
Add new dependencies for IPv6 option parsing
  • Added hex and dhcproto crates for encoding/decoding DHCPv6 options
Cargo.toml

Assessment against linked issues

Issue Objective Addressed Explanation
#1072 Ensure that containers attached to a podman network using the macvlan driver with ipv6 enabled and ipam-driver dhcp receive a global IPv6 address assigned via DHCPv6.
#1072 Update code and test infrastructure to support and verify DHCPv6 address assignment for macvlan (and bridge) networks, including correct kernel sysctl configuration for IPv6 gateway discovery via Router Advertisements.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@Rishikpulhani Rishikpulhani changed the title feat(dhcpv6): Add DHCPv6 support for macvlan and bridge drivers [WIP]feat(dhcpv6): Add DHCPv6 support for macvlan and bridge drivers Aug 25, 2025
@Rishikpulhani Rishikpulhani force-pushed the dhcp-ipv6-support branch 3 times, most recently from eba4a4b to 68032d7 Compare August 26, 2025 08:03
@packit-as-a-service
Copy link

Ephemeral COPR build failed. @containers/packit-build please check.

5 similar comments
@packit-as-a-service
Copy link

Ephemeral COPR build failed. @containers/packit-build please check.

@packit-as-a-service
Copy link

Ephemeral COPR build failed. @containers/packit-build please check.

@packit-as-a-service
Copy link

Ephemeral COPR build failed. @containers/packit-build please check.

@packit-as-a-service
Copy link

Ephemeral COPR build failed. @containers/packit-build please check.

@packit-as-a-service
Copy link

Ephemeral COPR build failed. @containers/packit-build please check.

@Rishikpulhani Rishikpulhani force-pushed the dhcp-ipv6-support branch 7 times, most recently from 29ba56a to 7b52545 Compare September 21, 2025 13:52
@Rishikpulhani Rishikpulhani marked this pull request as ready for review September 21, 2025 14:13
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • lease_has_changed’s catch-all arm treats any mismatched pair as ‘changed’, which may hide logic errors—replace the wildcard with unreachable!() or explicit error handling.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- lease_has_changed’s catch-all arm treats any mismatched pair as ‘changed’, which may hide logic errors—replace the wildcard with unreachable!() or explicit error handling.

## Individual Comments

### Comment 1
<location> `src/dhcp_proxy/dhcp_service.rs:296` </location>
<code_context>
+        (MozimLease::V6(old_v6), MozimLease::V6(new_v6)) => {
+            old_v6.addr != new_v6.addr || old_v6.prefix_len != new_v6.prefix_len
+        }
+        _ => true, // could have used unreachable!()
+    }
+}
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Using 'true' for mismatched lease types in lease_has_changed may mask logic errors.

Using 'true' here may conceal incorrect mixing of lease types. Instead, consider unreachable!() or logging to highlight these cases during development.

```suggestion
        _ => unreachable!("Mismatched MozimLease types in lease_has_changed"),
```
</issue_to_address>

### Comment 2
<location> `src/dhcp_proxy/dhcp_service.rs:369` </location>
<code_context>
             }
+            // NO gateway logic for IPv6. This is intentional.
         }
+        _ => return Err(NetavarkError::msg("Lease type mismatch during IP update")),
     }
-
</code_context>

<issue_to_address>
**suggestion:** Returning a generic error for lease type mismatch may obscure the root cause.

Include the actual lease types in the error message to make debugging easier.

```suggestion
        _ => {
            return Err(NetavarkError::msg(format!(
                "Lease type mismatch during IP update: new lease type is {:?}, old lease type is {:?}",
                new_lease, old_lease
            )));
        }
```
</issue_to_address>

### Comment 3
<location> `src/network/dhcp.rs:67-70` </location>
<code_context>
+
+    // If IPv4 fails but IPv6 is requested, should we continue or fail all?
+    // For now, we'll let it continue.
+    let v4_lease = match get_lease_from_proxy(nvp_config_v4) {
+        Ok(l) => l,
+        Err(e) => {
+            return Err(NetavarkError::msg(format!("unable to obtain lease: {e}")));
+        }
+    };
</code_context>

<issue_to_address>
**suggestion:** Failing immediately on IPv4 lease error may prevent IPv6-only containers from working.

If ipam.ipv6_enabled is true and no IPv4 addresses are needed, consider allowing the process to continue when IPv4 lease acquisition fails.

Suggested implementation:

```rust
    let v4_lease = match get_lease_from_proxy(nvp_config_v4) {
        Ok(l) => l,
        Err(e) => {
            // If IPv6 is enabled and no IPv4 addresses are needed, allow to continue
            if ipam.ipv6_enabled && nvp_config_v4.is_none() {
                // No IPv4 lease, but that's acceptable for IPv6-only containers
                None
            } else {
                return Err(NetavarkError::msg(format!("unable to obtain lease: {e}")));
            }
        }
    };

```

You may need to ensure that `nvp_config_v4` is an `Option` and that downstream code can handle `v4_lease` being `None`. If `v4_lease` is expected to always be present, update its type to `Option<LeaseType>` (or whatever type is returned by `get_lease_from_proxy`). Also, make sure that the logic for handling leases later in the code can handle the case where `v4_lease` is `None`.
</issue_to_address>

### Comment 4
<location> `test-dhcp/002-setup.bats:33-42` </location>
<code_context>
+: <<'DISABLED_TEST'
</code_context>

<issue_to_address>
**issue (testing):** IPv6 setup test is currently disabled.

Please enable the test to verify DHCPv6 functionality. If it cannot be enabled now, add a note explaining the reason and a plan for future activation.
</issue_to_address>

### Comment 5
<location> `test-dhcp/helpers.bash:344-353` </location>
<code_context>
+function run_dhcp() {
</code_context>

<issue_to_address>
**suggestion (testing):** No negative/edge case tests for DHCPv6 lease failures.

Please add tests for scenarios such as no available addresses, misconfigured dnsmasq, and timeouts to ensure proper error handling and messaging.
</issue_to_address>

### Comment 6
<location> `src/dhcp_proxy/dhcp_service.rs:300` </location>
<code_context>
+    }
+}
+
 fn update_lease_ip(
     netns: &str,
     interface: &str,
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the large match blocks in `update_lease_ip` and `lease_has_changed` into smaller helper functions for each IP version to improve readability and maintainability.

Here’s one way to tame the nesting in `update_lease_ip` (and the free‐fn `lease_has_changed`) by pulling each IP/version case into its own small helper.  You’ll end up with a single `match` that just calls into two focused routines.

```rust
fn update_lease_ip(
    netns: &str,
    interface: &str,
    old_lease: &MozimLease,
    new_lease: &MozimLease,
) -> NetavarkResult<()> {
    let (_, ns) = core_utils::open_netlink_sockets(netns)
        .wrap("failed to open netlink socket in netns")?;
    let mut sock = ns.netlink;

    match (old_lease, new_lease) {
        (MozimLease::V4(o), MozimLease::V4(n)) =>
            update_lease_ip_v4(&mut sock, interface, o, n),
        (MozimLease::V6(o), MozimLease::V6(n)) =>
            update_lease_ip_v6(&mut sock, interface, o, n),
        _ => Err(NetavarkError::msg("Lease type mismatch during IP update")),
    }
}

fn update_lease_ip_v4(
    sock: &mut NetlinkSocket, interface: &str,
    old: &MozimV4Lease, new: &MozimV4Lease
) -> NetavarkResult<()> {
    let old_net = ipnet::Ipv4Net::with_netmask(old.yiaddr, old.subnet_mask)?;
    let new_net = ipnet::Ipv4Net::with_netmask(new.yiaddr, new.subnet_mask)?;
    if new_net != old_net {
        let link = sock.get_link(LinkID::Name(interface.to_string()))?
            .wrap("get interface in netns")?;
        sock.add_addr(link.header.index, &ipnet::IpNet::V4(new_net))
            .wrap("add new addr")?;
        sock.del_addr(link.header.index, &ipnet::IpNet::V4(old_net))
            .wrap("remove old addr")?;
    }
    // ... default‐gateway logic here ...
    Ok(())
}

fn update_lease_ip_v6(
    sock: &mut NetlinkSocket, interface: &str,
    old: &MozimV6Lease, new: &MozimV6Lease
) -> NetavarkResult<()> {
    let old_net = ipnet::Ipv6Net::new(old.addr, old.prefix_len)?;
    let new_net = ipnet::Ipv6Net::new(new.addr, new.prefix_len)?;
    if new_net != old_net {
        let link = sock.get_link(LinkID::Name(interface.to_string()))?
            .wrap("get interface in netns")?;
        sock.add_addr(link.header.index, &ipnet::IpNet::V6(new_net))?;
        sock.del_addr(link.header.index, &ipnet::IpNet::V6(old_net))?;
    }
    Ok(())
}
```

And likewise push `lease_has_changed` onto the enum itself:

```rust
impl MozimLease {
    fn has_changed(&self, other: &MozimLease) -> bool {
        match (self, other) {
          (MozimLease::V4(a), MozimLease::V4(b)) =>
            a.yiaddr   != b.yiaddr ||
            a.subnet_mask != b.subnet_mask ||
            a.gateways != b.gateways,
          (MozimLease::V6(a), MozimLease::V6(b)) =>
            a.addr     != b.addr ||
            a.prefix_len != b.prefix_len,
          _ => true,
        }
    }
}
```

This flattening pulls each version’s logic out of one huge `match` block and into small, self‐contained functions, making both the control flow and each protocol’s behavior easier to read & maintain.
</issue_to_address>

### Comment 7
<location> `src/network/dhcp.rs:54` </location>
<code_context>
+    // --- Perform DHCPv4 Lease ---
+    // For now, we assume IPv4 is always desired unless explicitly disabled in future logic.
+    // You could make this conditional based on `ipam.container_addresses` if needed.
+    let nvp_config_v4 = NetworkConfig {
+        version: 0, // Explicitly set for IPv4
         host_iface: host_network_interface.to_string(),
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting a helper function for NetworkConfig creation and using Option::or to simplify merging logic.

```rust
// 1. Extract a helper to remove the v4/v6 NetworkConfig duplication:
fn build_network_config(
    host_iface: &str,
    container_iface: &str,
    ns_path: &str,
    mac: &str,
    hostname: &str,
    container_id: &str,
    version: u8,
) -> NetworkConfig {
    NetworkConfig {
        version,
        host_iface: host_iface.to_string(),
        container_iface: container_iface.to_string(),
        ns_path: ns_path.to_string(),
        container_mac_addr: mac.to_string(),
        host_name: hostname.to_string(),
        container_id: container_id.to_string(),
        domain_name: "".into(),
    }
}

// Then in get_dhcp_lease:
let nvp_v4 = build_network_config(
    host_network_interface,
    container_network_interface,
    ns_path,
    container_macvlan_mac,
    container_hostname,
    container_id,
    0,
);
let v4_lease = get_lease_from_proxy(nvp_v4)?;
// ...
if ipam.ipv6_enabled {
    let nvp_v6 = build_network_config(
        host_network_interface,
        container_network_interface,
        ns_path,
        container_macvlan_mac,
        container_hostname,
        container_id,
        1,
    );
    let (v6_subnet, v6_dns, v6_domain) = parse_lease(get_lease_from_proxy(nvp_v6)?)?;
    subnets.push(v6_subnet);

    // 2. Simplify the merge logic with `Option::or`:
    all_dns_servers   = all_dns_servers.or(v6_dns);
    all_domain_names  = all_domain_names.or(v6_domain);
}
```
</issue_to_address>

### Comment 8
<location> `src/dhcp_proxy/lib.rs:117` </location>
<code_context>

+    // DhcpV6Lease
+    // here the fields of DhcpV4Lease are repurposed tarry their equivalents in the DhcpV6Lease
+    impl From<DhcpV6Lease> for Lease {
+        fn from(l: DhcpV6Lease) -> Lease {
+            let mut domain_name = String::new();
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the option-parsing loop into separate helper functions for clarity and maintainability.

```markdown
You can collapse that 70-line loop into three small, self-contained parsers. For example:

```rust
impl From<DhcpV6Lease> for Lease {
    fn from(l: DhcpV6Lease) -> Lease {
        let dns_servers   = parse_dns_servers(&l.dhcp_opts);
        let domain_name   = parse_domain_search(&l.dhcp_opts);
        let ntp_servers   = parse_ntp_servers(&l.dhcp_opts);

        Lease {
            t1:            l.t1,
            t2:            l.t2,
            lease_time:    l.valid_life,
            yiaddr:        l.addr.to_string(),
            siaddr:        l.srv_ip.to_string(),
            srv_id:        hex::encode(l.srv_duid),
            is_v6:         true,
            domain_name,
            dns_servers,
            ntp_servers,
            subnet_mask:   l.prefix_len.to_string(),
            mac_address:   String::new(),
            mtu:           0,
            broadcast_addr:String::new(),
            gateways:      Vec::new(),
            host_name:     String::new(),
        }
    }
}

fn parse_dns_servers(opts: &[DhcpOption]) -> Vec<String> {
    opts.iter()
        .find_map(|opt| {
            if let DhcpOption::DomainNameServers(servers) = opt {
                Some(servers.iter().map(ToString::to_string).collect())
            } else {
                None
            }
        })
        .unwrap_or_default()
}

fn parse_domain_search(opts: &[DhcpOption]) -> String {
    opts.iter()
        .find_map(|opt| {
            if let DhcpOption::DomainSearchList(domains) = opt {
                Some(domains.iter()
                            .map(ToString::to_string)
                            .collect::<Vec<_>>()
                            .join(" "))
            } else {
                None
            }
        })
        .unwrap_or_default()
}

fn parse_ntp_servers(opts: &[DhcpOption]) -> Vec<String> {
    opts.iter()
        .filter_map(|opt| match opt {
            DhcpOption::NtpServer(subopts) => Some(subopts),
            _ => None,
        })
        .flat_map(|subopts| subopts.iter().filter_map(|sub| match sub {
            NtpSuboption::ServerAddress(addr)
            | NtpSuboption::MulticastAddress(addr) => Some(addr.to_string()),
            NtpSuboption::FQDN(name) => Some(name.to_string()),
        }))
        .collect()
}
```

This keeps all the functionality but breaks the logic into focused, testable units.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

(MozimLease::V6(old_v6), MozimLease::V6(new_v6)) => {
old_v6.addr != new_v6.addr || old_v6.prefix_len != new_v6.prefix_len
}
_ => true, // could have used unreachable!()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Using 'true' for mismatched lease types in lease_has_changed may mask logic errors.

Using 'true' here may conceal incorrect mixing of lease types. Instead, consider unreachable!() or logging to highlight these cases during development.

Suggested change
_ => true, // could have used unreachable!()
_ => unreachable!("Mismatched MozimLease types in lease_has_changed"),

}
// NO gateway logic for IPv6. This is intentional.
}
_ => return Err(NetavarkError::msg("Lease type mismatch during IP update")),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Returning a generic error for lease type mismatch may obscure the root cause.

Include the actual lease types in the error message to make debugging easier.

Suggested change
_ => return Err(NetavarkError::msg("Lease type mismatch during IP update")),
_ => {
return Err(NetavarkError::msg(format!(
"Lease type mismatch during IP update: new lease type is {:?}, old lease type is {:?}",
new_lease, old_lease
)));
}

Comment on lines +67 to +70
let v4_lease = match get_lease_from_proxy(nvp_config_v4) {
Ok(l) => l,
Err(e) => {
return Err(NetavarkError::msg(format!("unable to obtain lease: {e}")));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Failing immediately on IPv4 lease error may prevent IPv6-only containers from working.

If ipam.ipv6_enabled is true and no IPv4 addresses are needed, consider allowing the process to continue when IPv4 lease acquisition fails.

Suggested implementation:

    let v4_lease = match get_lease_from_proxy(nvp_config_v4) {
        Ok(l) => l,
        Err(e) => {
            // If IPv6 is enabled and no IPv4 addresses are needed, allow to continue
            if ipam.ipv6_enabled && nvp_config_v4.is_none() {
                // No IPv4 lease, but that's acceptable for IPv6-only containers
                None
            } else {
                return Err(NetavarkError::msg(format!("unable to obtain lease: {e}")));
            }
        }
    };

You may need to ensure that nvp_config_v4 is an Option and that downstream code can handle v4_lease being None. If v4_lease is expected to always be present, update its type to Option<LeaseType> (or whatever type is returned by get_lease_from_proxy). Also, make sure that the logic for handling leases later in the code can handle the case where v4_lease is None.

Comment on lines 33 to 42
: <<'DISABLED_TEST'
@test "basic ipv6 setup" {
read -r -d '\0' input_config <<EOF
{
"host_iface": "veth1",
"container_iface": "veth0",
"container_mac_addr": "$CONTAINER_MAC",
"domain_name": "example.com",
"host_name": "foobar-v6",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): IPv6 setup test is currently disabled.

Please enable the test to verify DHCPv6 functionality. If it cannot be enabled now, add a note explaining the reason and a plan for future activation.

Comment on lines 331 to 353
function run_dhcp() {
gw=$(gateway_from_subnet "$SUBNET_CIDR")
stripped_subnet=$(strip_last_octet_from_subnet)

read -r -d '\0' dnsmasq_config <<EOF
local version=${1:-4}
local gw
gw=$(gateway_from_subnet "$SUBNET_CIDR")
local dnsmasq_config=""

if [ "$version" == "6" ]; then
# --- FIX IS HERE ---
# Get the IPv6 network prefix from the full CIDR.
# For example, turn "fd1d:5139:5cb5:1a99::/64" into "fd1d:5139:5cb5:1a99::"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): No negative/edge case tests for DHCPv6 lease failures.

Please add tests for scenarios such as no available addresses, misconfigured dnsmasq, and timeouts to ensure proper error handling and messaging.

}
}

fn update_lease_ip(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring the large match blocks in update_lease_ip and lease_has_changed into smaller helper functions for each IP version to improve readability and maintainability.

Here’s one way to tame the nesting in update_lease_ip (and the free‐fn lease_has_changed) by pulling each IP/version case into its own small helper. You’ll end up with a single match that just calls into two focused routines.

fn update_lease_ip(
    netns: &str,
    interface: &str,
    old_lease: &MozimLease,
    new_lease: &MozimLease,
) -> NetavarkResult<()> {
    let (_, ns) = core_utils::open_netlink_sockets(netns)
        .wrap("failed to open netlink socket in netns")?;
    let mut sock = ns.netlink;

    match (old_lease, new_lease) {
        (MozimLease::V4(o), MozimLease::V4(n)) =>
            update_lease_ip_v4(&mut sock, interface, o, n),
        (MozimLease::V6(o), MozimLease::V6(n)) =>
            update_lease_ip_v6(&mut sock, interface, o, n),
        _ => Err(NetavarkError::msg("Lease type mismatch during IP update")),
    }
}

fn update_lease_ip_v4(
    sock: &mut NetlinkSocket, interface: &str,
    old: &MozimV4Lease, new: &MozimV4Lease
) -> NetavarkResult<()> {
    let old_net = ipnet::Ipv4Net::with_netmask(old.yiaddr, old.subnet_mask)?;
    let new_net = ipnet::Ipv4Net::with_netmask(new.yiaddr, new.subnet_mask)?;
    if new_net != old_net {
        let link = sock.get_link(LinkID::Name(interface.to_string()))?
            .wrap("get interface in netns")?;
        sock.add_addr(link.header.index, &ipnet::IpNet::V4(new_net))
            .wrap("add new addr")?;
        sock.del_addr(link.header.index, &ipnet::IpNet::V4(old_net))
            .wrap("remove old addr")?;
    }
    // ... default‐gateway logic here ...
    Ok(())
}

fn update_lease_ip_v6(
    sock: &mut NetlinkSocket, interface: &str,
    old: &MozimV6Lease, new: &MozimV6Lease
) -> NetavarkResult<()> {
    let old_net = ipnet::Ipv6Net::new(old.addr, old.prefix_len)?;
    let new_net = ipnet::Ipv6Net::new(new.addr, new.prefix_len)?;
    if new_net != old_net {
        let link = sock.get_link(LinkID::Name(interface.to_string()))?
            .wrap("get interface in netns")?;
        sock.add_addr(link.header.index, &ipnet::IpNet::V6(new_net))?;
        sock.del_addr(link.header.index, &ipnet::IpNet::V6(old_net))?;
    }
    Ok(())
}

And likewise push lease_has_changed onto the enum itself:

impl MozimLease {
    fn has_changed(&self, other: &MozimLease) -> bool {
        match (self, other) {
          (MozimLease::V4(a), MozimLease::V4(b)) =>
            a.yiaddr   != b.yiaddr ||
            a.subnet_mask != b.subnet_mask ||
            a.gateways != b.gateways,
          (MozimLease::V6(a), MozimLease::V6(b)) =>
            a.addr     != b.addr ||
            a.prefix_len != b.prefix_len,
          _ => true,
        }
    }
}

This flattening pulls each version’s logic out of one huge match block and into small, self‐contained functions, making both the control flow and each protocol’s behavior easier to read & maintain.

// --- Perform DHCPv4 Lease ---
// For now, we assume IPv4 is always desired unless explicitly disabled in future logic.
// You could make this conditional based on `ipam.container_addresses` if needed.
let nvp_config_v4 = NetworkConfig {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting a helper function for NetworkConfig creation and using Option::or to simplify merging logic.

// 1. Extract a helper to remove the v4/v6 NetworkConfig duplication:
fn build_network_config(
    host_iface: &str,
    container_iface: &str,
    ns_path: &str,
    mac: &str,
    hostname: &str,
    container_id: &str,
    version: u8,
) -> NetworkConfig {
    NetworkConfig {
        version,
        host_iface: host_iface.to_string(),
        container_iface: container_iface.to_string(),
        ns_path: ns_path.to_string(),
        container_mac_addr: mac.to_string(),
        host_name: hostname.to_string(),
        container_id: container_id.to_string(),
        domain_name: "".into(),
    }
}

// Then in get_dhcp_lease:
let nvp_v4 = build_network_config(
    host_network_interface,
    container_network_interface,
    ns_path,
    container_macvlan_mac,
    container_hostname,
    container_id,
    0,
);
let v4_lease = get_lease_from_proxy(nvp_v4)?;
// ...
if ipam.ipv6_enabled {
    let nvp_v6 = build_network_config(
        host_network_interface,
        container_network_interface,
        ns_path,
        container_macvlan_mac,
        container_hostname,
        container_id,
        1,
    );
    let (v6_subnet, v6_dns, v6_domain) = parse_lease(get_lease_from_proxy(nvp_v6)?)?;
    subnets.push(v6_subnet);

    // 2. Simplify the merge logic with `Option::or`:
    all_dns_servers   = all_dns_servers.or(v6_dns);
    all_domain_names  = all_domain_names.or(v6_domain);
}


// DhcpV6Lease
// here the fields of DhcpV4Lease are repurposed tarry their equivalents in the DhcpV6Lease
impl From<DhcpV6Lease> for Lease {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring the option-parsing loop into separate helper functions for clarity and maintainability.

You can collapse that 70-line loop into three small, self-contained parsers. For example:

```rust
impl From<DhcpV6Lease> for Lease {
    fn from(l: DhcpV6Lease) -> Lease {
        let dns_servers   = parse_dns_servers(&l.dhcp_opts);
        let domain_name   = parse_domain_search(&l.dhcp_opts);
        let ntp_servers   = parse_ntp_servers(&l.dhcp_opts);

        Lease {
            t1:            l.t1,
            t2:            l.t2,
            lease_time:    l.valid_life,
            yiaddr:        l.addr.to_string(),
            siaddr:        l.srv_ip.to_string(),
            srv_id:        hex::encode(l.srv_duid),
            is_v6:         true,
            domain_name,
            dns_servers,
            ntp_servers,
            subnet_mask:   l.prefix_len.to_string(),
            mac_address:   String::new(),
            mtu:           0,
            broadcast_addr:String::new(),
            gateways:      Vec::new(),
            host_name:     String::new(),
        }
    }
}

fn parse_dns_servers(opts: &[DhcpOption]) -> Vec<String> {
    opts.iter()
        .find_map(|opt| {
            if let DhcpOption::DomainNameServers(servers) = opt {
                Some(servers.iter().map(ToString::to_string).collect())
            } else {
                None
            }
        })
        .unwrap_or_default()
}

fn parse_domain_search(opts: &[DhcpOption]) -> String {
    opts.iter()
        .find_map(|opt| {
            if let DhcpOption::DomainSearchList(domains) = opt {
                Some(domains.iter()
                            .map(ToString::to_string)
                            .collect::<Vec<_>>()
                            .join(" "))
            } else {
                None
            }
        })
        .unwrap_or_default()
}

fn parse_ntp_servers(opts: &[DhcpOption]) -> Vec<String> {
    opts.iter()
        .filter_map(|opt| match opt {
            DhcpOption::NtpServer(subopts) => Some(subopts),
            _ => None,
        })
        .flat_map(|subopts| subopts.iter().filter_map(|sub| match sub {
            NtpSuboption::ServerAddress(addr)
            | NtpSuboption::MulticastAddress(addr) => Some(addr.to_string()),
            NtpSuboption::FQDN(name) => Some(name.to_string()),
        }))
        .collect()
}

This keeps all the functionality but breaks the logic into focused, testable units.

@openshift-ci
Copy link
Contributor

openshift-ci bot commented Sep 21, 2025

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: Rishikpulhani, sourcery-ai[bot]
Once this PR has been reviewed and has the lgtm label, please assign luap99 for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@Rishikpulhani Rishikpulhani force-pushed the dhcp-ipv6-support branch 6 times, most recently from fdfbbca to 2f136af Compare September 21, 2025 19:41
@Rishikpulhani Rishikpulhani force-pushed the dhcp-ipv6-support branch 2 times, most recently from 92215c5 to 16ed14e Compare September 22, 2025 05:57
@Rishikpulhani Rishikpulhani changed the title [WIP]feat(dhcpv6): Add DHCPv6 support for macvlan and bridge drivers feat(dhcpv6): Add DHCPv6 support for macvlan and bridge drivers Sep 22, 2025
This commit introduces full support for stateful IPv6 address assignment via DHCPv6, resolving the issue where containers would not receive a global IPv6 address on macvlan networks.

The implementation follows the standard IPv6 design of separating gateway discovery (via Router Advertisements) from stateful address assignment (via DHCPv6).

Key changes include:

- **Kernel Configuration:** Netavark now configures the container's kernel to accept Router Advertisements (`accept_ra=2`) for automatic gateway discovery, while disabling SLAAC (`autoconf=0`) to ensure a managed, deterministic network environment.

- **DHCPv6 Service:** A new `DhcpV6Service` is added to the DHCP proxy. It uses the `mozim` library to acquire IPv6 leases and correctly generates a stable DUID-LL from the container's static MAC address to ensure a persistent network identity.

- **gRPC Layer:** The gRPC `Lease` object and its `From` implementations have been updated to act as a universal carrier for both IPv4 and IPv6 lease information.

- **Generic Proxy Logic:** Core functions like `process_client_stream` and `update_lease_ip` have been refactored to handle both DHCPv4 and DHCPv6 services generically, with conditional logic to correctly handle the differences between the protocols (e.g., gateway handling).

Signed-off-by: Rishikpulhani <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

network driver macvlan does not receive an ipv6 address from dhcp

1 participant