Binding an IPv6 Address Pool to a New NIC on a BIRD2 Host and Enabling Multi-Source IPv6 Outbound Traffic

Machine-translated (anthropic, claude-haiku-5-5) from the Chinese original. Read the original in 中文

Contents

Preface

The goal this time is very clear:

  • The machine is already running BIRD2 and is correctly advertising IPv6 prefixes via BGP
  • Now I want to use a /47 IPv6 prefix as an IPv6 address pool usable locally
  • At the same time, to keep the structure clearer, I plan to "attach" this address range to a new logical network interface
  • The final result should be:
    • Applications can bind any IPv6 address within this /47
    • Traffic still leaves through the real physical network interface
    • BIRD is only responsible for advertising and does not interfere with the system default route or address management

Note: In this article, IPv4, IPv6, ASN, BGP neighbors, authentication passwords and other information have all been replaced with documentation example values, to record the configuration approach and avoid exposing real production environment information.


Review of the Existing BIRD2 Configuration

The BIRD2 configuration on the current machine has roughly a few key points:

  1. protocol direct Only tracks dummy* interfaces
  2. protocol kernel Is import none; export none; for both IPv4 and IPv6
  3. In protocol static static_v6, the IPv6 prefixes to be advertised are put into the BIRD RIB via reject
  4. BGP only exports the IPv6 prefixes of RTS_STATIC to the outside

In other words, this design itself already separates responsibilities very clearly:

  • BIRD is responsible for advertising
  • The kernel is responsible for real forwarding and local address binding
  • BIRD will not modify the kernel's default route

This approach is correct and should be kept.


To state the conclusion first: this task should not only involve changing BIRD

When many people first do this kind of configuration, they intuitively think "since it's an advertised prefix, just bind it to the interface in BIRD."

In reality, that's not how it works.

The Role of BIRD

BIRD only needs to do one thing:

  • Keep announcing the target /47 prefix

The Role of the Linux Kernel

What truly allows this /47 to:

  • You can run it locally with bind()
  • Be used as source addresses when initiating connections
  • Send traffic out through the real physical network interface

is the Linux network stack, not BIRD.

So the final plan is:

  • Create a new dummy interface, for example dummy47
  • Assign it an "anchor address" from the pool
  • Use ip -6 route add local ... to tell the kernel that this entire /47 belongs to the local host
  • Enable net.ipv6.ip_nonlocal_bind=1 to allow applications to bind addresses that were not explicitly added to an interface

Why Use a Dummy Interface

Rather than directly putting the entire /47 onto the real physical interface, I created a separate logical interface dummy47, for three reasons:

  1. Clearer responsibilities

    • dummy47 indicates "this is a locally managed business address pool"
    • The physical interface is only responsible for actually sending and receiving packets
  2. Easier troubleshooting

    • When looking at the interface configuration, you can tell at a glance what this address pool is specifically for
  3. Compatible with the existing BIRD configuration

    • The current protocol direct already matches dummy*
    • No major changes are needed after creating dummy47

Note the following:

dummy47 is not the actual outgoing network interface.
Actual packet transmission still goes through the physical interface corresponding to the default route, such as eth0 or ens3.


Reference Approach: local route + ip_nonlocal_bind

The core idea of this configuration is:

  • Don't enumerate and add large numbers of static addresses
  • Don't directly addr add the entire prefix as ordinary addresses
  • Instead, through:
bash
ip -6 route add local 2001:db8:4700::/47 dev lo
sysctl net.ipv6.ip_nonlocal_bind=1

Let the kernel consider:

  • This entire /47 as "locally receivable" addresses
  • Processes can directly bind to any address within it

These are the two most critical steps.


Sanitized BIRD2 Configuration Example

Below is a sanitized configuration skeleton, used to illustrate the key parts relevant to this article.

bird
################################################
#                 Variables                     #
################################################

define OWNAS   = 64500;
define OWNIP   = 198.51.100.10;
define OWNIPv6 = 2001:db8:100::10;

define OWNNETSET_V4 = [];

define OWNNETSET_V6 = [
    2001:db8:4700::/47
];

################################################
#                 Global                        #
################################################

router id OWNIP;

protocol device {
    scan time 10;
}

################################################
#                  Direct                       #
################################################

protocol direct {
    ipv4;
    ipv6;
    interface "dummy*";
}

################################################
#              Kernel isolation                 #
################################################

protocol kernel krt_ipv4 {
    scan time 20;

    ipv4 {
        import none;
        export none;
    };
}

protocol kernel krt_ipv6 {
    scan time 20;

    ipv6 {
        import none;
        export none;
    };
}

################################################
#              Static announcements             #
################################################

protocol static static_v6 {
    route 2001:db8:4700::/47 reject;

    ipv6 {
        import all;
        export none;
    };
}

################################################
#               BGP template                    #
################################################

template bgp tpl_bgp {
    local as OWNAS;
    path metric 1;
    graceful restart;

    ipv4 {
        import none;
        export filter { reject; };
        export limit 100 action disable;
        extended next hop on;
    };

    ipv6 {
        import none;
        export filter {
            if source != RTS_STATIC then reject;
            accept;
        };
        export limit 100 action disable;
    };
}

################################################
#                 Upstream BGP                  #
################################################

protocol bgp bgp_upstream_v4 from tpl_bgp {
    source address OWNIP;
    neighbor 169.254.0.1 as 64496;
    multihop 2;
    password "REDACTED_PASSWORD";
}

protocol bgp bgp_upstream_v6 from tpl_bgp {
    source address OWNIPv6;
    neighbor 2001:db8:ffff::1 as 64496;
    multihop 2;
    password "REDACTED_PASSWORD";
}

Does the BIRD2 Configuration Need Changes?

Conclusion

In most cases, almost no changes are needed.

Because there are already:

bird
protocol direct {
    ipv4;
    ipv6;
    interface "dummy*";
}

So as long as the new logical interface is named dummy47, it will naturally be matched.

And the IPv6 static advertisement part is itself correct:

bird
protocol static static_v6 {
    route 2001:db8:4700::/47 reject;

    ipv6 {
        import all;
        export none;
    };
}

Why not change /47 from reject

Because the reject route here is only for:

  • Getting the prefix into the BIRD RIB
  • Satisfying the BGP export conditions
  • Serving as a declaration that "I own and advertise this prefix"

It is not meant for real forwarding or local address binding by the Linux kernel.

So it is reasonable to keep the /47 approach with reject.


Actual Configuration Steps

Next, we start the Linux network configuration.


Step 1: Create the New Dummy Interface

First load the module and create the logical interface:

bash
modprobe dummy

ip link add dummy47 type dummy
ip link set dummy47 up

After execution, you can confirm with the following command:

bash
ip link show dummy47

Step 2: Add an anchor address to dummy47

Although we will use the entire /47 address pool, it is still recommended to place an explicit single address on dummy47 as an "anchor":

bash
ip -6 addr add 2001:db8:4700::1/128 dev dummy47

Using /128 is enough here. There is no need to add the entire /47 as a regular interface address.

Why not do that:

bash
ip -6 addr add 2001:db8:4700::/47 dev dummy47

Because this does not achieve the goal of "the host can bind the entire /47."
It only gives the interface connected route semantics, which does not make the whole prefix a directly bindable local address pool.


Step 3: Add the entire /47 as a local route

This step is the most critical.

bash
ip -6 route replace local 2001:db8:4700::/47 dev lo

With this, the kernel will consider:

2001:db8:4700::/47 as a whole belonging to the host's local address space

In other words, as long as applications later bind any IPv6 address within this range, the kernel will accept it.

Why attach it to lo

Because this is a local route, which does not mean "send packets from loopback" but rather:

  • This prefix is treated as a local address
  • It is held by the local host

This is the standard way Linux handles this kind of scenario.


Step 4: Enable ip_nonlocal_bind

Even with local route in place, many programs still by default cannot freely bind addresses that are not explicitly assigned to an interface.

So you also need to enable:

bash
sysctl -w net.ipv6.ip_nonlocal_bind=1

For persistence, it is recommended to write it into a separate sysctl configuration file:

bash
cat >/etc/sysctl.d/99-ipv6-pool.conf <<'EOF'
net.ipv6.ip_nonlocal_bind = 1
EOF

sysctl --system

Step 5: Reload BIRD2

If the BIRD configuration itself has not changed, this step is theoretically unnecessary.
However, if you made any configuration adjustments along the way, reloading once is the safest option:

bash
birdc configure

Then check whether the route is in the BIRD RIB:

bash
birdc show route for 2001:db8:4700::/47

Verification

Check the addresses on dummy47

bash
ip -6 addr show dev dummy47

You should see at least:

text
inet6 2001:db8:4700::1/128 scope global

Check the local route

bash
ip -6 route show table local | grep '2001:db8:4700::/47'

You should see something similar to:

text
local 2001:db8:4700::/47 dev lo

Test outbound connectivity using a specified source IPv6 address

Do not specify the interface name dummy47 here; instead, bind directly to the specific source address:

bash
curl -6 --interface 2001:db8:4700::100 https://ipv6.ip.sb
curl -6 --interface 2001:db8:4700::200 https://ipv6.ip.sb
curl -6 --interface 2001:db8:4701::1234 https://ipv6.ip.sb

If the returned values are the source addresses you specified, the configuration is successful.


A pitfall to watch out for: binding to an "interface" is not the same as binding to a "source address"

Many people instinctively test it like this:

bash
curl -6 --interface dummy47 https://ipv6.ip.sb

This testing method is not suitable here.

This is because dummy47 is not a physical egress NIC; it is just a logical carrier interface.
Actual outbound traffic still depends on the system default route, which generally goes out through a real physical NIC such as eth0 or ens3.

So the correct approach is:

  • Bind to the specific source IPv6 address
  • rather than binding to the dummy47 interface name

That is:

bash
curl -6 --interface 2001:db8:4700::100 https://ipv6.ip.sb

About ndppd: generally not needed in this scenario

Some articles on "binding and directly using an entire IPv6 range" additionally mention ndppd.
That is usually because the upstream provides the IPv6 prefix to the machine as an on-link network, requiring NDP neighbor discovery for each address.

But the scenario here is different:

  • The machine already advertises this prefix via BGP
  • The upstream is more like routing this prefix to this machine
  • rather than requiring the host to respond to NDP for each address across the whole range at Layer 2

So in this design, generally:

  • ndppd is not needed
  • You only need:
    • BIRD to advertise the prefix normally
    • The kernel to have local route
    • ip_nonlocal_bind to be enabled

Only when the provider explicitly requires that this prefix be Layer 2 directly connected and that neighbor discovery be performed for target addresses should you additionally consider ndppd or proxy_ndp.


Persistence solution: systemd service

To avoid losing the configuration after a reboot, I finally wrote this setup into a simple systemd service.

Create the file:

bash
vim /etc/systemd/system/dummy47-v6pool.service

The content is as follows:

ini
[Unit]
Description=IPv6 pool on dummy47
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/sbin/modprobe dummy
ExecStart=/sbin/ip link add dummy47 type dummy
ExecStart=/sbin/ip link set dummy47 up
ExecStart=/sbin/ip -6 addr add 2001:db8:4700::1/128 dev dummy47
ExecStart=/sbin/ip -6 route replace local 2001:db8:4700::/47 dev lo
ExecStart=/sbin/sysctl -w net.ipv6.ip_nonlocal_bind=1

ExecStop=/sbin/ip -6 route del local 2001:db8:4700::/47 dev lo
ExecStop=/sbin/ip -6 addr del 2001:db8:4700::1/128 dev dummy47
ExecStop=/sbin/ip link del dummy47

[Install]
WantedBy=multi-user.target

Then run:

bash
systemctl daemon-reload
systemctl enable --now dummy47-v6pool.service

A summary of the complete operation commands

For easy copying, the full steps are summarised here in one block:

bash
modprobe dummy

ip link add dummy47 type dummy || true
ip link set dummy47 up

ip -6 addr add 2001:db8:4700::1/128 dev dummy47 || true
ip -6 route replace local 2001:db8:4700::/47 dev lo

sysctl -w net.ipv6.ip_nonlocal_bind=1

birdc configure
birdc show route for 2001:db8:4700::/47

Verification:

bash
ip -6 addr show dev dummy47
ip -6 route show table local | grep '2001:db8:4700::/47'

curl -6 --interface 2001:db8:4700::100 https://ipv6.ip.sb
curl -6 --interface 2001:db8:4700::200 https://ipv6.ip.sb
curl -6 --interface 2001:db8:4701::1234 https://ipv6.ip.sb

My final understanding

After completing this configuration, the overall structure becomes very clear:

What BIRD2 does

  • Continues to advertise the target /47 via BGP
  • Continues to place the prefix into the BIRD RIB using static ... reject
  • Does not import or export Linux kernel routes

What the Linux kernel does

  • Provides the logical carrier via dummy47
  • Marks the entire /47 as local address space via local route
  • Allows applications to bind any IPv6 address in the pool via ip_nonlocal_bind

How applications use it

  • Do not bind to dummy47
  • Instead, bind to a specific IPv6 address within the target /47 as the source address
  • Traffic still ultimately follows the system default route and exits through the real physical interface

Afterword

At first, I also easily confused "prefix advertisement" with "local address binding" as one and the same thing.
But once I separated them, it became clear that these are actually two different layers of concern:

  • Control plane for routing: BIRD/BGP
  • Data plane on the host: Linux network stack

With these two handled separately, the overall setup becomes very stable and is easier to maintain and troubleshoot.

If I later expand this machine to host more IPv6 address pools, I would prioritize continuing with this approach:

  • BIRD is only responsible for advertising
  • The business address pool is managed through dummy + local route + ip_nonlocal_bind

This keeps the structure the cleanest.

Comments