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
/47IPv6 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
- Applications can bind any IPv6 address within this
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:
protocol directOnly tracksdummy*interfacesprotocol kernelIsimport none; export none;for both IPv4 and IPv6- In
protocol static static_v6, the IPv6 prefixes to be advertised are put into the BIRD RIB viareject - BGP only exports the IPv6 prefixes of
RTS_STATICto 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
/47prefix
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/47belongs to the local host - Enable
net.ipv6.ip_nonlocal_bind=1to 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:
-
Clearer responsibilities
dummy47indicates "this is a locally managed business address pool"- The physical interface is only responsible for actually sending and receiving packets
-
Easier troubleshooting
- When looking at the interface configuration, you can tell at a glance what this address pool is specifically for
-
Compatible with the existing BIRD configuration
- The current
protocol directalready matchesdummy* - No major changes are needed after creating
dummy47
- The current
Note the following:
dummy47is not the actual outgoing network interface.
Actual packet transmission still goes through the physical interface corresponding to the default route, such aseth0orens3.
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 addthe entire prefix as ordinary addresses - Instead, through:
ip -6 route add local 2001:db8:4700::/47 dev lo
sysctl net.ipv6.ip_nonlocal_bind=1Let the kernel consider:
- This entire
/47as "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.
################################################
# 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:
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:
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:
modprobe dummy
ip link add dummy47 type dummy
ip link set dummy47 upAfter execution, you can confirm with the following command:
ip link show dummy47Step 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":
ip -6 addr add 2001:db8:4700::1/128 dev dummy47Using /128 is enough here. There is no need to add the entire /47 as a regular interface address.
Why not do that:
ip -6 addr add 2001:db8:4700::/47 dev dummy47Because 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.
ip -6 route replace local 2001:db8:4700::/47 dev loWith this, the kernel will consider:
2001:db8:4700::/47as 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:
sysctl -w net.ipv6.ip_nonlocal_bind=1For persistence, it is recommended to write it into a separate sysctl configuration file:
cat >/etc/sysctl.d/99-ipv6-pool.conf <<'EOF'
net.ipv6.ip_nonlocal_bind = 1
EOF
sysctl --systemStep 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:
birdc configureThen check whether the route is in the BIRD RIB:
birdc show route for 2001:db8:4700::/47Verification
Check the addresses on dummy47
ip -6 addr show dev dummy47You should see at least:
inet6 2001:db8:4700::1/128 scope globalCheck the local route
ip -6 route show table local | grep '2001:db8:4700::/47'You should see something similar to:
local 2001:db8:4700::/47 dev loTest outbound connectivity using a specified source IPv6 address
Do not specify the interface name dummy47 here; instead, bind directly to the specific source address:
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.sbIf 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:
curl -6 --interface dummy47 https://ipv6.ip.sbThis 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
dummy47interface name
That is:
curl -6 --interface 2001:db8:4700::100 https://ipv6.ip.sbAbout 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:
ndppdis not needed- You only need:
- BIRD to advertise the prefix normally
- The kernel to have
local route ip_nonlocal_bindto 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:
vim /etc/systemd/system/dummy47-v6pool.serviceThe content is as follows:
[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.targetThen run:
systemctl daemon-reload
systemctl enable --now dummy47-v6pool.serviceA summary of the complete operation commands
For easy copying, the full steps are summarised here in one block:
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::/47Verification:
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.sbMy final understanding
After completing this configuration, the overall structure becomes very clear:
What BIRD2 does
- Continues to advertise the target
/47via 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
/47as local address space vialocal 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
/47as 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