Repository: scionproto/scion
Pull request #4479 (2024-03-07 14:14:52)

testing: simplify the process of configuring routers, brload for benchmarking
Summary of changes:

* Package the benchmark router configuration as an openwrt package.
* Add the global BFD config to the router config schema. Retire the corresponding env vars.
* Make brload open the host interfaces that it is configured with regardless of their names; as opposed to all interfaces named veth_\*_host
* No-longer pass MAC addresses on the brload command line (one still optional - used for CI). brload finds them.
* No-longer require static arp entries on the router's side. brload responds to ARP.


matzf: 

<!-- Reviewable:start -->
This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/scionproto/scion/4479)
<!-- Reviewable:end -->


Patch:
diff --git a/acceptance/router_benchmark/BUILD.bazel b/acceptance/router_benchmark/BUILD.bazel
index 8046d61ef..4ba18b294 100644
--- a/acceptance/router_benchmark/BUILD.bazel
+++ b/acceptance/router_benchmark/BUILD.bazel
@@ -1,9 +1,16 @@
 load("//acceptance/common:raw.bzl", "raw_test")
 
-exports_files([
-    "conf",
-    "test.py",
-])
+exports_files(
+    [
+        "conf",
+        "test.py",
+        "conf/router.toml",
+        "conf/topology.json",
+        "conf/keys/master0.key",
+        "conf/keys/master1.key",
+    ],
+    visibility = ["//visibility:public"],
+)
 
 args = [
     "--executable",
diff --git a/acceptance/router_benchmark/brload/main.go b/acceptance/router_benchmark/brload/main.go
index 3d764255f..24662a5b4 100644
--- a/acceptance/router_benchmark/brload/main.go
+++ b/acceptance/router_benchmark/brload/main.go
@@ -19,11 +19,9 @@ import (
 	"errors"
 	"fmt"
 	"hash"
-	"net"
 	"os"
 	"path/filepath"
 	"reflect"
-	"strings"
 	"time"
 
 	"github.com/google/gopacket"
@@ -107,10 +105,9 @@ func main() {
 	runCmd.Flags().StringVar(&dir, "artifacts", "", "Artifacts directory")
 	runCmd.Flags().Var(&caseToRun, "case", "Case to run. "+caseToRun.Allowed())
 	runCmd.Flags().StringArrayVar(&interfaces, "interface", []string{},
-		`label=host_interface,mac,peer_mac where:
-    host_interface: use this to exchange traffic with interface <label>
-    mac: the mac address of interface <label>
-    peer_mac: the mac address of <host_interface>`)
+		`label=<host_interface>[,<MACaddr>] where <host_interface> is the host device that matches
+ the <label> requirement from --show-interfaces and <MACaddr> is the local address to assume for it.
+ <MACaddr> defaults to the real address assigned to the device`)
 	runCmd.MarkFlagRequired("case")
 	runCmd.MarkFlagRequired("interface")
 
@@ -153,8 +150,8 @@ func run(cmd *cobra.Command) int {
 		return 1
 	}
 
-	cases.InitInterfaces(interfaces)
-	handles, err := openDevices()
+	interfaceNames := cases.InitInterfaces(interfaces)
+	handles, err := openDevices(interfaceNames)
 	if err != nil {
 		log.Error("Loading devices failed", "err", err)
 		return 1
@@ -272,26 +269,17 @@ func receivePackets(packetChan chan gopacket.Packet, payload string) int {
 	}
 }
 
-// initDevices inventories the available network interfaces, picks the ones that a case may inject
-// traffic into, and associates them with a AF Packet interface. It returns the packet interfaces
-// corresponding to each network interface.
-func openDevices() (map[string]*afpacket.TPacket, error) {
-	devs, err := net.Interfaces()
-	if err != nil {
-		return nil, serrors.WrapStr("listing network interfaces", err)
-	}
-
+// initDevices associates each network interfaces into which a case may inject traffic with a AF
+// Packet interface. It returns the packet interfaces corresponding to each network interface.
+func openDevices(interfaceNames []string) (map[string]*afpacket.TPacket, error) {
 	handles := make(map[string]*afpacket.TPacket)
 
-	for _, dev := range devs {
-		if !strings.HasPrefix(dev.Name, "veth_") || !strings.HasSuffix(dev.Name, "_host") {
-			continue
-		}
-		handle, err := afpacket.NewTPacket(afpacket.OptInterface(dev.Name))
+	for _, intf := range interfaceNames {
+		handle, err := afpacket.NewTPacket(afpacket.OptInterface(intf))
 		if err != nil {
 			return nil, serrors.WrapStr("creating TPacket", err)
 		}
-		handles[dev.Name] = handle
+		handles[intf] = handle
 	}
 
 	return handles, nil
diff --git a/acceptance/router_benchmark/cases/BUILD.bazel b/acceptance/router_benchmark/cases/BUILD.bazel
index 98c50124b..d527e51a5 100644
--- a/acceptance/router_benchmark/cases/BUILD.bazel
+++ b/acceptance/router_benchmark/cases/BUILD.bazel
@@ -16,6 +16,7 @@ go_library(
     ],
     deps = [
         "//pkg/addr:go_default_library",
+        "//pkg/log:go_default_library",
         "//pkg/private/util:go_default_library",
         "//pkg/private/xtest:go_default_library",
         "//pkg/scrypto:go_default_library",
@@ -24,5 +25,7 @@ go_library(
         "//pkg/slayers/path/scion:go_default_library",
         "@com_github_google_gopacket//:go_default_library",
         "@com_github_google_gopacket//layers:go_default_library",
+        "@com_github_mdlayher_arp//:go_default_library",
+        "@com_github_mdlayher_ethernet//:go_default_library",
     ],
 )
diff --git a/acceptance/router_benchmark/cases/topo.go b/acceptance/router_benchmark/cases/topo.go
index 95f6144c9..70f35b877 100644
--- a/acceptance/router_benchmark/cases/topo.go
+++ b/acceptance/router_benchmark/cases/topo.go
@@ -21,10 +21,14 @@ import (
 	"net/netip"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/google/gopacket/layers"
+	"github.com/mdlayher/arp"
+	"github.com/mdlayher/ethernet"
 
 	"github.com/scionproto/scion/pkg/addr"
+	"github.com/scionproto/scion/pkg/log"
 	"github.com/scionproto/scion/pkg/private/xtest"
 	"github.com/scionproto/scion/pkg/scrypto"
 )
@@ -170,28 +174,94 @@ var (
 	macAddrs map[netip.Addr]net.HardwareAddr
 )
 
-// InitInterfaces collects the names and mac addresses for the interfaces setup by the invoker
-// according to instructions given via listInterfaces().
+// InitInterfaces collects the names (OS device name) and mac addresses for the interfaces setup by
+// the invoker according to instructions given via listInterfaces().
 // This information is indexed by our own interface labels.
-func InitInterfaces(pairs []string) {
+//
+// Returns a list of unique device names that any label maps to. (i.e. devices that at least one
+// test will need to open.
+func InitInterfaces(pairs []string) []string {
+	asSet := make(map[string]struct{}, len(pairs))
 	deviceNames = make(map[string]string, len(pairs))
 	macAddrs = make(map[netip.Addr]net.HardwareAddr, len(pairs)*2)
 	for _, pair := range pairs {
 		p := strings.Split(pair, "=")
 		label := p[0]
 		info := strings.Split(p[1], ",")
-		addr, err := net.ParseMAC(info[1])
+		name := info[0]
+		deviceNames[label] = name // host-side name (brload's side)
+		asSet[name] = struct{}{}
+		subjectIP := intfMap[label].ip
+		peerIP := intfMap[label].peerIP
+
+		// Now find the MAC addresses, so we don't have to be told.
+		device, err := net.InterfaceByName(name)
+		if err != nil {
+			panic(err)
+		}
+
+		// PeerMac (our side): By default we use the real one, but we can be told to use another.
+		// (If the link is virtual ethernet, using the real mac address causes serious performance
+		// issues, the cause of which has yet to be found).
+		peerMAC := device.HardwareAddr
+		if len(info) > 1 {
+			peerMAC, err = net.ParseMAC(info[1])
+			if err != nil {
+				panic(err)
+			}
+		}
+
+		// The subject's MAC needs to be arp'ed.
+		arpClient, err := arp.Dial(device)
+		if err != nil {
+			panic(err)
+		}
+		err = arpClient.SetReadDeadline(time.Now().Add(5 * time.Second))
 		if err != nil {
 			panic(err)
 		}
-		peerAddr, err := net.ParseMAC(info[2])
+		subjectMAC, err := arpClient.Resolve(subjectIP)
 		if err != nil {
 			panic(err)
 		}
-		deviceNames[label] = info[0]               // host-side name
-		macAddrs[intfMap[label].ip] = addr         // ip->mac
-		macAddrs[intfMap[label].peerIP] = peerAddr // peerIP->peerMAC
+
+		// Done.
+		macAddrs[subjectIP] = subjectMAC // ip->mac (side of router under test)
+		macAddrs[peerIP] = peerMAC       // peerIP->peerMAC (side mocked by brload)
+
+		// Respond to arp requests so there's no need to add a static arp entry on the router
+		// side. We can't assign our address to the interface, so the kernel won't do that for us.
+		err = arpClient.SetReadDeadline(time.Time{})
+		if err != nil {
+			panic(err)
+		}
+		go func() {
+			defer log.HandlePanic()
+			// We only respond to the subject, so the reply is always the same.
+			reply := arp.Packet{
+				HardwareType:       1,
+				ProtocolType:       uint16(ethernet.EtherTypeIPv4),
+				HardwareAddrLength: 6,
+				IPLength:           4,
+				Operation:          arp.OperationReply,
+				SenderHardwareAddr: peerMAC, // peer is us.
+				SenderIP:           peerIP,  // peer is us
+				TargetHardwareAddr: subjectMAC,
+				TargetIP:           subjectIP,
+			}
+			for {
+				p, _, err := arpClient.Read()
+				if err == nil && p.SenderIP == subjectIP {
+					_ = arpClient.WriteTo(&reply, subjectMAC)
+				}
+			}
+		}()
 	}
+	deduped := make([]string, 0, len(asSet))
+	for n := range asSet {
+		deduped = append(deduped, n)
+	}
+	return deduped
 }
 
 // interfaceName returns the name of the host interface that this test must use in order to exchange
@@ -210,6 +280,9 @@ func MACAddr(ip netip.Addr) net.HardwareAddr {
 		return mac
 	}
 	as4 := ip.As4()
+
+	// This component makes no assumption regarding how the topology is used. We have to support all
+	// hosts that the topology describes, even fictional ones, should a test case refer to it.
 	return net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, as4[2], as4[3]}
 }
 
diff --git a/acceptance/router_benchmark/conf/router.toml b/acceptance/router_benchmark/conf/router.toml
index 671a4fecd..d2ceddf4b 100644
--- a/acceptance/router_benchmark/conf/router.toml
+++ b/acceptance/router_benchmark/conf/router.toml
@@ -7,6 +7,9 @@ prometheus = "10.123.10.1:30442"
 
 [features]
 
+[router.bfd]
+disable = true
+
 [api]
 addr = "10.123.10.1:31142"
 
diff --git a/acceptance/router_multi/conf/router.toml b/acceptance/router_multi/conf/router.toml
index 7e9e3d991..01562b992 100644
--- a/acceptance/router_multi/conf/router.toml
+++ b/acceptance/router_multi/conf/router.toml
@@ -5,6 +5,5 @@
 [features]
   experimental_scmp_authentication = true
 
-[log]
-  [log.console]
-    level = "debug"
+[log.console]
+  level = "debug"
diff --git a/acceptance/router_multi/conf/router_nobfd.toml b/acceptance/router_multi/conf/router_nobfd.toml
new file mode 100644
index 000000000..8d44aab32
--- /dev/null
+++ b/acceptance/router_multi/conf/router_nobfd.toml
@@ -0,0 +1,12 @@
+[general]
+  id = "brA"
+  config_dir = "/etc/scion"
+
+[features]
+  experimental_scmp_authentication = true
+
+[router.bfd]
+  disable = true
+
+[log.console]
+  level = "debug"
diff --git a/control/beacon/BUILD.bazel b/control/beacon/BUILD.bazel
index 3366d61fc..543cb27bf 100644
--- a/control/beacon/BUILD.bazel
+++ b/control/beacon/BUILD.bazel
@@ -15,6 +15,7 @@ go_library(
         "//pkg/addr:go_default_library",
         "//pkg/log:go_default_library",
         "//pkg/private/common:go_default_library",
+        "//pkg/private/ptr:go_default_library",
         "//pkg/private/serrors:go_default_library",
         "//pkg/proto/control_plane:go_default_library",
         "//pkg/segment:go_default_library",
diff --git a/control/beacon/policy.go b/control/beacon/policy.go
index 9630520e2..ee0ea5ac3 100644
--- a/control/beacon/policy.go
+++ b/control/beacon/policy.go
@@ -20,6 +20,7 @@ import (
 	yaml "gopkg.in/yaml.v2"
 
 	"github.com/scionproto/scion/pkg/addr"
+	"github.com/scionproto/scion/pkg/private/ptr"
 	"github.com/scionproto/scion/pkg/private/serrors"
 )
 
@@ -253,8 +254,7 @@ func (f *Filter) InitDefaults() {
 		f.MaxHopsLength = DefaultMaxHopsLength
 	}
 	if f.AllowIsdLoop == nil {
-		t := true
-		f.AllowIsdLoop = &t
+		f.AllowIsdLoop = ptr.To(true)
 	}
 }
 
diff --git a/dist/BUILD.bazel b/dist/BUILD.bazel
index d8ebe16b1..21550979b 100644
--- a/dist/BUILD.bazel
+++ b/dist/BUILD.bazel
@@ -188,6 +188,10 @@ scion_pkg_ipk(
     name = "testconfig_ipk",
 )
 
+scion_pkg_ipk(
+    name = "bmconfig_ipk",
+)
+
 # multiplatform_filegroup creates two targets: <name> and <name>_all.
 # <name> means "local platform".
 # So "build //dist/openwrt" means "make me ipk packages for the local platform only. It wont work,
@@ -211,6 +215,7 @@ multiplatform_filegroup(
 multiplatform_filegroup(
     name = "openwrt_testing",
     srcs = [
+        "bmconfig_ipk",
         "coremark_ipk",
         "testconfig_ipk",
     ],
diff --git a/dist/openwrt/BUILD.external.bazel b/dist/openwrt/BUILD.external.bazel
index 3dc0a86c1..dc101ec5a 100644
--- a/dist/openwrt/BUILD.external.bazel
+++ b/dist/openwrt/BUILD.external.bazel
@@ -82,8 +82,8 @@ ipk_pkg(
     deps = ["daemon_ipk"],  # Force sequential build; openwrt make isn't reentrant.
 )
 
-# These two package are not normally distributed. They are only installed
-# to facilitate regression tests.
+# These three package are not normally distributed. They are only installed
+# to facilitate regression tests and benchmarks.
 ipk_pkg(
     name = "testconfig_ipk",
     overrides = [
@@ -99,12 +99,26 @@ ipk_pkg(
     deps = ["tools_ipk"],  # Force sequential build; openwrt make isn't reentrant.
 )
 
+ipk_pkg(
+    name = "bmconfig_ipk",
+    overrides = [
+        "@@//acceptance/router_benchmark:conf/keys/master0.key",
+        "@@//acceptance/router_benchmark:conf/keys/master1.key",
+        "@@//acceptance/router_benchmark:conf/router.toml",
+        "@@//acceptance/router_benchmark:conf/topology.json",
+    ],
+    overridesroot = "@@//acceptance/router_benchmark:conf",
+    pkg = "bmconfig",
+    visibility = ["//visibility:public"],
+    deps = ["testconfig_ipk"],  # Force sequential build; openwrt make isn't reentrant.
+)
+
 ipk_pkg(
     name = "coremark_ipk",
     executables = ["@@//tools/coremark:coremark"],  # No _compressed; it's small, not Go.
     pkg = "coremark",
     visibility = ["//visibility:public"],
-    deps = ["testconfig_ipk"],  # Force sequential build; openwrt make isn't reentrant.
+    deps = ["bmconfig_ipk"],  # Force sequential build; openwrt make isn't reentrant.
 )
 
 # Assemble a bazel toolchain out of the openwrt sdk binaries.
diff --git a/doc/Makefile b/doc/Makefile
index 719138594..a7c09b9df 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -21,6 +21,10 @@ html latex latexpdf linkcheck help:
 autobuild:
 	sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(filter-out --keep-going,$(SPHINXOPTS))
 
+.PHONY: clean
+clean:
+	rm -rf _build
+
 # Generate commandline docs by building the tools and running their `gendocus` subcommand
 .PHONY: command-gendocs
 command-gendocs:
diff --git a/doc/manuals/common.rst b/doc/manuals/common.rst
index 33f620174..0e521f4ba 100644
--- a/doc/manuals/common.rst
+++ b/doc/manuals/common.rst
@@ -292,20 +292,22 @@ of the individual fields below.
          instance with :option:`general.id <router-conf-toml general.id>` matching
          :option:`<router-id> <topology-json <router-id>>`.
 
-         .. option:: disable = <bool>, default false
+         .. option:: disable = <bool>, default router.bfd.disable
+
+            See :option:`router.bfd.disabled <router-conf-toml disabled>`.
 
             Disable BFD, unconditionally consider the connection alive.
 
-            Takes precedence over :envvar:`SCION_EXPERIMENTAL_BFD_DISABLE`.
+         .. option:: detect_mult = <uint8>, default router.bfd.detect_mult
 
-         .. option:: detect_mult = <uint8>, default 3
+            See :option:`router.bfd.detect_mult <router-conf-toml detect_mult>`.
 
             After ``detect_mult`` consecutively missing control packets, the BFD session is
             considered "down" and is reset.
 
-            Takes precedence over :envvar:`SCION_EXPERIMENTAL_BFD_DETECT_MULT`.
+         .. option:: desired_min_tx_interval = <duration>, default router.bfd.desired_min_tx_interval
 
-         .. option:: desired_min_tx_interval = <duration>, default 200ms
+            See :option:`router.bfd.disired_min_tx_interval <router-conf-toml desired_min_tx_interval>`.
 
             Defines the frequency at which this router should send BFD control messages for this
             inter-domain link.
@@ -313,9 +315,8 @@ of the individual fields below.
             session establishment;
             the value will be ``max(desired_min_tx_interval, remote.required_min_rx_interval)``.
 
-            Takes precedence over :envvar:`SCION_EXPERIMENTAL_BFD_DESIRED_MIN_TX`.
-
-         .. option:: required_min_rx_interval = <duration>, default 200ms
+         .. option:: required_min_rx_interval = <duration>, default router.bfd.required_min_rx_interval
+            See :option:`router.bfd.required_min_rx_interval <router-conf-toml required_min_rx_interval>`.
 
             Defines an upper bound for the frequency at which this router wants to receive BFD
             control messages for this inter-domain link.
@@ -323,8 +324,6 @@ of the individual fields below.
             result of negotiating with the remote router during session establishment;
             the value will be ``max(remote.desired_min_tx_interval, required_min_rx_interval)``.
 
-            Takes precedence over :envvar:`SCION_EXPERIMENTAL_BFD_REQUIRED_MIN_RX`.
-
 .. option:: control_service
 
    .. option:: <cs-id>
diff --git a/doc/manuals/router.rst b/doc/manuals/router.rst
index 1d70764cd..9dd5d462a 100644
--- a/doc/manuals/router.rst
+++ b/doc/manuals/router.rst
@@ -56,47 +56,6 @@ Options
 Environment Variables
 ---------------------
 
-.. envvar:: SCION_EXPERIMENTAL_BFD_DISABLE
-
-   Disable :term:`BFD`, unconditionally consider the connection alive.
-
-   Applies to BFD sessions to all sibling routers (other routers in the same AS).
-   Can be overridden for specific inter-AS BFD sessions with :option:`bfd.disable <topology-json disable>`
-   in an interface entry in the ``topology.json`` configuration.
-
-   :Type: bool (``0``/``f``/``F``/``FALSE``/``false``/``False``,  ``1``/``t``/``T``/``TRUE``/``true``/``True``)
-   :Default: ``false``
-
-.. envvar:: SCION_EXPERIMENTAL_BFD_DETECT_MULT
-
-   Set the :term:`BFD` detection time multiplier.
-
-   Same applicability as above; can be overridden for specific inter-AS BFD sessions with
-   :option:`bfd.detect_mult <topology-json detect_mult>`.
-
-   :Type: unsigned integer
-   :Default: ``3``
-
-.. envvar:: SCION_EXPERIMENTAL_BFD_DESIRED_MIN_TX
-
-   Defines the frequence at which this router should send :term:`BFD` control messages.
-
-   Same applicability as above; can be overridden for specific inter-AS BFD sessions with
-   :option:`bfd.desired_min_tx_interval <topology-json desired_min_tx_interval>`.
-
-   :Type: :ref:`duration <common-conf-duration>`
-   :Default: ``200ms``
-
-.. envvar:: SCION_EXPERIMENTAL_BFD_REQUIRED_MIN_RX
-
-   Defines an frequence at which this router should send :term:`BFD` control messages.
-
-   Same applicability as above; can be overridden for specific inter-AS BFD sessions with
-   :option:`bfd.required_min_rx_interval <topology-json required_min_rx_interval>`.
-
-   :Type: :ref:`duration <common-conf-duration>`
-   :Default: ``200ms``
-
 .. object:: SCION_TESTING_DRKEY_EPOCH_DURATION
 
    For **testing only**.
@@ -214,14 +173,57 @@ considers the following options.
 
    .. option:: router.num_slow_processors = <int> (Default: 1)
 
-      Number of goroutines started for the slow-path processing which includes all SCMP traffic and traceroutes.
-      A minimum of 1 slow-path processor is required.
+      Number of goroutines started for the slow-path processing which includes all SCMP traffic and
+      traceroutes. A minimum of 1 slow-path processor is required.
 
    .. option:: router.batch_size = <int> (Default: 256)
 
       The batch size used by the receiver and forwarder to
       read or write from / to the network socket.
 
+   .. object:: bfd
+
+      .. option:: disabled = <bool> (Default: false)
+
+         Set whether the :term:`BFD` feature is disabled by default.
+
+         This setting applies to BFD sessions to all neighboring routers, including sibling routers
+         (other routers in the same AS).
+
+         Can be overridden for specific inter-AS BFD
+         sessions with :option:`bfd.disable <topology-json disable>`.
+ 
+      .. option:: detect_mult = <uint8>, default 3
+
+         Set the :term:`BFD` detection time multiplier.
+
+         After ``detect_mult`` consecutively missing control packets, the BFD session is
+         considered "down" and is reset.
+
+         Can be overridden for specific inter-AS BFD sessions with
+         :option:`bfd.detect_mult <topology-json detect_mult>`.
+
+      .. option:: desired_min_tx_interval = <duration>, default 200ms
+
+         Defines the frequency at which this router should send :term:`BFD` control messages.
+         The effective interval is the result of negotiating with the remote router during
+         session establishment;
+         the value will be ``max(desired_min_tx_interval, remote.required_min_rx_interval)``.
+
+         Can be overridden for specific inter-AS BFD sessions with
+         :option:`bfd.desired_min_tx_interval <topology-json desired_min_tx_interval>`.
+
+      .. option:: required_min_rx_interval = <duration>, default 200ms
+
+         Defines an upper bound for the frequency at which this router wants to receive
+         :term:`BFD` control messages.
+         The effective interval at which the remote router will send control messages is the
+         result of negotiating with the remote router during session establishment;
+         the value will be ``max(remote.desired_min_tx_interval, required_min_rx_interval)``
+
+         Can be overridden for specific inter-AS BFD sessions with
+         :option:`bfd.required_min_rx_interval <topology-json required_min_rx_interval>`.
+
 .. _router-conf-topo:
 
 topology.json
diff --git a/go.mod b/go.mod
index 440894823..bd2895b7d 100644
--- a/go.mod
+++ b/go.mod
@@ -22,6 +22,8 @@ require (
 	github.com/lestrrat-go/jwx v1.2.25
 	github.com/mattn/go-isatty v0.0.19
 	github.com/mattn/go-sqlite3 v1.14.17
+	github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875
+	github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118
 	github.com/oapi-codegen/runtime v1.0.0
 	github.com/olekukonko/tablewriter v0.0.5
 	github.com/opentracing/opentracing-go v1.2.0
@@ -78,6 +80,7 @@ require (
 	github.com/inconshreveable/mousetrap v1.0.1 // indirect
 	github.com/invopop/yaml v0.1.0 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
+	github.com/josharian/native v1.0.0 // indirect
 	github.com/jtolds/gls v4.20.0+incompatible // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
@@ -90,6 +93,8 @@ require (
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-runewidth v0.0.9 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
+	github.com/mdlayher/packet v1.0.0 // indirect
+	github.com/mdlayher/socket v0.2.1 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
 	github.com/onsi/ginkgo/v2 v2.9.5 // indirect
diff --git a/go.sum b/go.sum
index 64962f23e..023b07c59 100644
--- a/go.sum
+++ b/go.sum
@@ -181,6 +181,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
@@ -236,6 +238,8 @@ github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc=
 github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
+github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -294,6 +298,14 @@ github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6
 github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875 h1:ql8x//rJsHMjS+qqEag8n3i4azw1QneKh5PieH9UEbY=
+github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875/go.mod h1:kfOoFJuHWp76v1RgZCb9/gVUc7XdY877S2uVYbNliGc=
+github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 h1:2oDp6OOhLxQ9JBoUuysVz9UZ9uI6oLUbvAZu0x8o+vE=
+github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118/go.mod h1:ZFUnHIVchZ9lJoWoEGUg8Q3M4U8aNNWA3CVSUTkW4og=
+github.com/mdlayher/packet v1.0.0 h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8=
+github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU=
+github.com/mdlayher/socket v0.2.1 h1:F2aaOwb53VsBE+ebRS9bLd7yPOfYUMC8lOODdCBDY6w=
+github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -612,8 +624,10 @@ golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/go_deps.bzl b/go_deps.bzl
index 89d2e6c20..488a3eba3 100644
--- a/go_deps.bzl
+++ b/go_deps.bzl
@@ -673,6 +673,12 @@ def go_deps():
         sum = "h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=",
         version = "v1.0.0",
     )
+    go_repository(
+        name = "com_github_josharian_native",
+        importpath = "github.com/josharian/native",
+        sum = "h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=",
+        version = "v1.0.0",
+    )
     go_repository(
         name = "com_github_jpillora_backoff",
         importpath = "github.com/jpillora/backoff",
@@ -913,6 +919,30 @@ def go_deps():
         sum = "h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=",
         version = "v1.0.1",
     )
+    go_repository(
+        name = "com_github_mdlayher_arp",
+        importpath = "github.com/mdlayher/arp",
+        sum = "h1:ql8x//rJsHMjS+qqEag8n3i4azw1QneKh5PieH9UEbY=",
+        version = "v0.0.0-20220512170110-6706a2966875",
+    )
+    go_repository(
+        name = "com_github_mdlayher_ethernet",
+        importpath = "github.com/mdlayher/ethernet",
+        sum = "h1:2oDp6OOhLxQ9JBoUuysVz9UZ9uI6oLUbvAZu0x8o+vE=",
+        version = "v0.0.0-20220221185849-529eae5b6118",
+    )
+    go_repository(
+        name = "com_github_mdlayher_packet",
+        importpath = "github.com/mdlayher/packet",
+        sum = "h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8=",
+        version = "v1.0.0",
+    )
+    go_repository(
+        name = "com_github_mdlayher_socket",
+        importpath = "github.com/mdlayher/socket",
+        sum = "h1:F2aaOwb53VsBE+ebRS9bLd7yPOfYUMC8lOODdCBDY6w=",
+        version = "v0.2.1",
+    )
     go_repository(
         name = "com_github_microcosm_cc_bluemonday",
         importpath = "github.com/microcosm-cc/bluemonday",
diff --git a/pkg/private/ptr/BUILD.bazel b/pkg/private/ptr/BUILD.bazel
new file mode 100644
index 000000000..b78b8f228
--- /dev/null
+++ b/pkg/private/ptr/BUILD.bazel
@@ -0,0 +1,8 @@
+load("//tools/lint:go.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["ptr.go"],
+    importpath = "github.com/scionproto/scion/pkg/private/ptr",
+    visibility = ["//visibility:public"],
+)
diff --git a/pkg/private/ptr/ptr.go b/pkg/private/ptr/ptr.go
new file mode 100644
index 000000000..f8268ba4a
--- /dev/null
+++ b/pkg/private/ptr/ptr.go
@@ -0,0 +1,22 @@
+// Copyright 2024 SCION Association
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ptr
+
+// To generates a pointer to a variable containing the given value.
+// In Go, addressible constant is an oxymoron, so the expression "&true", for example, is invalid.
+// This function makes that less annoying.
+func To[T any](v T) *T {
+	return &v
+}
diff --git a/private/topology/json/json.go b/private/topology/json/json.go
index 25999725d..0af0c692e 100644
--- a/private/topology/json/json.go
+++ b/private/topology/json/json.go
@@ -125,7 +125,7 @@ type Underlay struct {
 
 // BFD configuration.
 type BFD struct {
-	Disable               bool         `json:"disable,omitempty"`
+	Disable               *bool        `json:"disable,omitempty"`
 	DetectMult            uint8        `json:"detect_mult,omitempty"`
 	DesiredMinTxInterval  util.DurWrap `json:"desired_min_tx_interval,omitempty"`
 	RequiredMinRxInterval util.DurWrap `json:"required_min_rx_interval,omitempty"`
diff --git a/private/topology/topology.go b/private/topology/topology.go
index 35117b79d..b7c6ce443 100644
--- a/private/topology/topology.go
+++ b/private/topology/topology.go
@@ -125,8 +125,11 @@ type (
 	}
 
 	// BFD is the configuration for a BFD session
+	// Disable can be set from two sources: the topology configuration for the link (here), and
+	// the dataplane's bfd global configuration. This is actually a pointer to boolean. nil
+	// means unspecified.
 	BFD struct {
-		Disable               bool
+		Disable               *bool
 		DetectMult            uint8
 		DesiredMinTxInterval  time.Duration
 		RequiredMinRxInterval time.Duration
diff --git a/router/BUILD.bazel b/router/BUILD.bazel
index ed41f1d88..bcdad6612 100644
--- a/router/BUILD.bazel
+++ b/router/BUILD.bazel
@@ -31,6 +31,7 @@ go_library(
         "//private/topology:go_default_library",
         "//private/underlay/conn:go_default_library",
         "//router/bfd:go_default_library",
+        "//router/config:go_default_library",
         "//router/control:go_default_library",
         "@com_github_google_gopacket//:go_default_library",
         "@com_github_google_gopacket//layers:go_default_library",
@@ -52,6 +53,7 @@ go_test(
     deps = [
         "//pkg/addr:go_default_library",
         "//pkg/experimental/epic:go_default_library",
+        "//pkg/private/ptr:go_default_library",
         "//pkg/private/serrors:go_default_library",
         "//pkg/private/util:go_default_library",
         "//pkg/private/xtest:go_default_library",
diff --git a/router/cmd/router/main.go b/router/cmd/router/main.go
index f458ab254..835b066f1 100644
--- a/router/cmd/router/main.go
+++ b/router/cmd/router/main.go
@@ -63,6 +63,7 @@ func realMain(ctx context.Context) error {
 		},
 		ReceiveBufferSize: globalCfg.Router.ReceiveBufferSize,
 		SendBufferSize:    globalCfg.Router.SendBufferSize,
+		BFD:               globalCfg.Router.BFD,
 	}
 	iaCtx := &control.IACtx{
 		Config: controlConfig,
diff --git a/router/config/BUILD.bazel b/router/config/BUILD.bazel
index 21da85ac3..fd66c04a0 100644
--- a/router/config/BUILD.bazel
+++ b/router/config/BUILD.bazel
@@ -11,6 +11,7 @@ go_library(
     deps = [
         "//pkg/log:go_default_library",
         "//pkg/private/serrors:go_default_library",
+        "//pkg/private/util:go_default_library",
         "//private/config:go_default_library",
         "//private/env:go_default_library",
         "//private/mgmtapi:go_default_library",
diff --git a/router/config/config.go b/router/config/config.go
index c4d78bc68..01a0e8291 100644
--- a/router/config/config.go
+++ b/router/config/config.go
@@ -20,9 +20,11 @@ package config
 import (
 	"io"
 	"runtime"
+	"time"
 
 	"github.com/scionproto/scion/pkg/log"
 	"github.com/scionproto/scion/pkg/private/serrors"
+	"github.com/scionproto/scion/pkg/private/util"
 	"github.com/scionproto/scion/private/config"
 	"github.com/scionproto/scion/private/env"
 	api "github.com/scionproto/scion/private/mgmtapi"
@@ -45,6 +47,16 @@ type RouterConfig struct {
 	NumProcessors         int `toml:"num_processors,omitempty"`
 	NumSlowPathProcessors int `toml:"num_slow_processors,omitempty"`
 	BatchSize             int `toml:"batch_size,omitempty"`
+	BFD                   BFD `toml:"bfd,omitempty"`
+}
+
+// BFD configuration. Unfortunately cannot be shared with topology.BFD
+// as one is toml and the other json. Eventhough the semantics are identical.
+type BFD struct {
+	Disable               bool         `toml:"disable,omitempty"`
+	DetectMult            uint8        `toml:"detect_mult,omitempty"`
+	DesiredMinTxInterval  util.DurWrap `toml:"desired_min_tx_interval,omitempty"`
+	RequiredMinRxInterval util.DurWrap `toml:"required_min_rx_interval,omitempty"`
 }
 
 func (cfg *RouterConfig) ConfigName() string {
@@ -100,6 +112,15 @@ func (cfg *RouterConfig) InitDefaults() {
 	if cfg.BatchSize == 0 {
 		cfg.BatchSize = 256
 	}
+	if cfg.BFD.DetectMult == 0 {
+		cfg.BFD.DetectMult = 3
+	}
+	if cfg.BFD.DesiredMinTxInterval.Duration == 0 {
+		cfg.BFD.DesiredMinTxInterval = util.DurWrap{Duration: 200 * time.Millisecond}
+	}
+	if cfg.BFD.RequiredMinRxInterval.Duration == 0 {
+		cfg.BFD.RequiredMinRxInterval = util.DurWrap{Duration: 200 * time.Millisecond}
+	}
 }
 
 func (cfg *RouterConfig) Sample(dst io.Writer, path config.Path, ctx config.CtxMap) {
diff --git a/router/connector.go b/router/connector.go
index e92cf5727..78adffb0c 100644
--- a/router/connector.go
+++ b/router/connector.go
@@ -25,6 +25,7 @@ import (
 	"github.com/scionproto/scion/pkg/private/serrors"
 	"github.com/scionproto/scion/private/topology"
 	"github.com/scionproto/scion/private/underlay/conn"
+	"github.com/scionproto/scion/router/config"
 	"github.com/scionproto/scion/router/control"
 )
 
@@ -41,6 +42,7 @@ type Connector struct {
 
 	ReceiveBufferSize int
 	SendBufferSize    int
+	BFD               config.BFD
 }
 
 var errMultiIA = serrors.New("different IA not allowed")
@@ -88,7 +90,10 @@ func (c *Connector) AddExternalInterface(localIfID common.IFIDType, link control
 	log.Debug("Adding external interface", "interface", localIfID,
 		"local_isd_as", link.Local.IA, "local_addr", link.Local.Addr,
 		"remote_isd_as", link.Remote.IA, "remote_addr", link.Remote.Addr,
-		"owned", owned, "bfd", !link.BFD.Disable)
+		"owned", owned,
+		"link_bfd_configured", link.BFD.Disable != nil,
+		"link_bfd_enabled", link.BFD.Disable == nil || !*link.BFD.Disable,
+		"dataplane_bfd_enabled", !c.BFD.Disable)
 
 	if !c.ia.Equal(link.Local.IA) {
 		return serrors.WithCtx(errMultiIA, "current", c.ia, "new", link.Local.IA)
@@ -100,6 +105,7 @@ func (c *Connector) AddExternalInterface(localIfID common.IFIDType, link control
 		return serrors.WrapStr("adding neighboring IA", err, "if_id", localIfID)
 	}
 
+	link.BFD = c.applyBFDDefaults(link.BFD)
 	if owned {
 		if len(c.externalInterfaces) == 0 {
 			c.externalInterfaces = make(map[uint16]control.ExternalInterface)
@@ -121,14 +127,8 @@ func (c *Connector) AddExternalInterface(localIfID common.IFIDType, link control
 			NeighborIA:        link.Remote.IA,
 			State:             control.InterfaceDown,
 		}
-		if !link.BFD.Disable {
-			err := c.DataPlane.AddNextHopBFD(intf, link.Local.Addr, link.Remote.Addr,
-				link.BFD, link.Instance)
-			if err != nil {
-				return serrors.WrapStr("adding next hop BFD", err, "if_id", localIfID)
-			}
-		}
-		return c.DataPlane.AddNextHop(intf, link.Remote.Addr)
+		return c.DataPlane.AddNextHop(intf, link.Local.Addr, link.Remote.Addr,
+			link.BFD, link.Instance)
 	}
 
 	connection, err := conn.New(link.Local.Addr, link.Remote.Addr,
@@ -136,14 +136,8 @@ func (c *Connector) AddExternalInterface(localIfID common.IFIDType, link control
 	if err != nil {
 		return err
 	}
-	if !link.BFD.Disable {
-		err := c.DataPlane.AddExternalInterfaceBFD(intf, connection, link.Local,
-			link.Remote, link.BFD)
-		if err != nil {
-			return serrors.WrapStr("adding external BFD", err, "if_id", localIfID)
-		}
-	}
-	return c.DataPlane.AddExternalInterface(intf, connection)
+
+	return c.DataPlane.AddExternalInterface(intf, connection, link.Local, link.Remote, link.BFD)
 }
 
 // AddSvc adds the service address for the given ISD-AS.
@@ -215,3 +209,26 @@ func (c *Connector) ListSiblingInterfaces() ([]control.SiblingInterface, error)
 	}
 	return siblingInterfaceList, nil
 }
+
+// applyBFDDefaults updates the given cfg object with the global default BFD settings.
+// Link-specific settings, if configured, remain unchanged.  IMPORTANT: cfg.Disable isn't a boolean
+// but a pointer to boolean, allowing a simple representation of the unconfigured state: nil. This
+// means that using a cfg object that hasn't been processed by this function may lead to a NPE.
+// In particular, "control.BFD{}" is invalid.
+func (c *Connector) applyBFDDefaults(cfg control.BFD) control.BFD {
+
+	if cfg.Disable == nil {
+		disable := c.BFD.Disable
+		cfg.Disable = &disable
+	}
+	if cfg.DetectMult == 0 {
+		cfg.DetectMult = c.BFD.DetectMult
+	}
+	if cfg.DesiredMinTxInterval == 0 {
+		cfg.DesiredMinTxInterval = c.BFD.DesiredMinTxInterval.Duration
+	}
+	if cfg.RequiredMinRxInterval == 0 {
+		cfg.RequiredMinRxInterval = c.BFD.RequiredMinRxInterval.Duration
+	}
+	return cfg
+}
diff --git a/router/control/BUILD.bazel b/router/control/BUILD.bazel
index e5facedea..5cd4a3bee 100644
--- a/router/control/BUILD.bazel
+++ b/router/control/BUILD.bazel
@@ -3,7 +3,6 @@ load("//tools/lint:go.bzl", "go_library", "go_test")
 go_library(
     name = "go_default_library",
     srcs = [
-        "bfd.go",
         "conf.go",
         "iactx.go",
     ],
diff --git a/router/control/bfd.go b/router/control/bfd.go
deleted file mode 100644
index b1b9d8572..000000000
--- a/router/control/bfd.go
+++ /dev/null
@@ -1,98 +0,0 @@
-// Copyright 2020 Anapaya Systems
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package control
-
-import (
-	"fmt"
-	"os"
-	"strconv"
-	"time"
-
-	"github.com/scionproto/scion/private/topology"
-)
-
-const (
-	envDisable = "SCION_EXPERIMENTAL_BFD_DISABLE"
-	envMult    = "SCION_EXPERIMENTAL_BFD_DETECT_MULT"
-	envMinTx   = "SCION_EXPERIMENTAL_BFD_DESIRED_MIN_TX"
-	envMinRx   = "SCION_EXPERIMENTAL_BFD_REQUIRED_MIN_RX"
-)
-
-// BFD is the configuration for the BFD sessions.
-type BFD topology.BFD
-
-// XXX(sgmonroy) note that env values only affect defaults, which in turn are only used
-// if there were no BFD related settings in the topology.
-func WithDefaults(cfg BFD) BFD {
-	// If default is disable, BFD is globally disabled.
-	if BFDDefaults.Disable {
-		cfg.Disable = true
-	}
-	if cfg.DetectMult == 0 {
-		cfg.DetectMult = BFDDefaults.DetectMult
-	}
-	if cfg.DesiredMinTxInterval == 0 {
-		cfg.DesiredMinTxInterval = BFDDefaults.DesiredMinTxInterval
-	}
-	if cfg.RequiredMinRxInterval == 0 {
-		cfg.RequiredMinRxInterval = BFDDefaults.RequiredMinRxInterval
-	}
-	return cfg
-}
-
-var (
-	BFDDefaults = BFD{
-		DetectMult:            3,
-		DesiredMinTxInterval:  200 * time.Millisecond,
-		RequiredMinRxInterval: 200 * time.Millisecond,
-		// Disable indicates if BFD is disabled globally.
-		Disable: false,
-	}
-)
-
-func init() {
-	if val := os.Getenv(envDisable); val != "" {
-		if disabled, err := strconv.ParseBool(val); err == nil {
-			BFDDefaults.Disable = disabled
-			fmt.Fprintf(os.Stderr, "%s=%v\n", envDisable, disabled)
-		} else {
-			fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", envDisable, err)
-		}
-	}
-	if val := os.Getenv(envMult); val != "" {
-		if p, err := strconv.ParseUint(val, 10, 8); err == nil {
-			BFDDefaults.DetectMult = uint8(p)
-			fmt.Fprintf(os.Stderr, "%s=%v\n", envMult, BFDDefaults.DetectMult)
-		} else {
-			fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", envMult, err)
-		}
-	}
-	if val := os.Getenv(envMinTx); val != "" {
-		if d, err := time.ParseDuration(val); err == nil {
-			BFDDefaults.DesiredMinTxInterval = d
-			fmt.Fprintf(os.Stderr, "%s=%v\n", envMinTx, BFDDefaults.DesiredMinTxInterval)
-		} else {
-			fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", envMinTx, err)
-		}
-	}
-	if val := os.Getenv(envMinRx); val != "" {
-		if d, err := time.ParseDuration(val); err == nil {
-			BFDDefaults.RequiredMinRxInterval = d
-			fmt.Fprintf(os.Stderr, "%s=%v\n", envMinRx, BFDDefaults.RequiredMinRxInterval)
-		} else {
-			fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", envMinRx, err)
-		}
-	}
-}
diff --git a/router/control/conf.go b/router/control/conf.go
index 669e4d3f7..46be29aff 100644
--- a/router/control/conf.go
+++ b/router/control/conf.go
@@ -39,6 +39,9 @@ type Dataplane interface {
 	SetKey(ia addr.IA, index int, key []byte) error
 }
 
+// BFD is the configuration for the BFD sessions.
+type BFD topology.BFD
+
 // LinkInfo contains the information about a link between an internal and
 // external router.
 type LinkInfo struct {
@@ -183,7 +186,7 @@ func confExternalInterfaces(dp Dataplane, cfg *Config) error {
 				IFID: iface.RemoteIFID,
 			},
 			Instance: iface.BRName,
-			BFD:      WithDefaults(BFD(iface.BFD)),
+			BFD:      BFD(iface.BFD),
 			LinkTo:   iface.LinkType,
 			MTU:      iface.MTU,
 		}
@@ -197,9 +200,8 @@ func confExternalInterfaces(dp Dataplane, cfg *Config) error {
 			// configured in the data plane.
 			linkInfo.Local.Addr = net.UDPAddrFromAddrPort(cfg.BR.InternalAddr)
 			linkInfo.Remote.Addr = net.UDPAddrFromAddrPort(iface.InternalAddr)
-			// For internal BFD always use the default configuration, which can be modified with
-			// the env variables.
-			linkInfo.BFD = BFDDefaults
+			// For internal BFD always use the default configuration.
+			linkInfo.BFD = BFD{}
 		}
 
 		if err := dp.AddExternalInterface(ifid, linkInfo, owned); err != nil {
diff --git a/router/dataplane.go b/router/dataplane.go
index 5a880ba97..4a307e1b1 100644
--- a/router/dataplane.go
+++ b/router/dataplane.go
@@ -90,10 +90,6 @@ type BatchConn interface {
 // DataPlane contains a SCION Border Router's forwarding logic. It reads packets
 // from multiple sockets, performs routing, and sends them to their destinations
 // (after updating the path, if that is needed).
-//
-// XXX(lukedirtwalker): this is still in development and not feature complete.
-// Currently, only the following features are supported:
-//   - initializing connections; MUST be done prior to calling Run
 type DataPlane struct {
 	interfaces        map[uint16]BatchConn
 	external          map[uint16]BatchConn
@@ -133,7 +129,6 @@ var (
 	unsupportedPathTypeNextHeader = errors.New("unsupported combination")
 	noBFDSessionFound             = errors.New("no BFD session was found")
 	noBFDSessionConfigured        = errors.New("no BFD sessions have been configured")
-	errBFDDisabled                = errors.New("BFD is disabled")
 	errPeeringEmptySeg0           = errors.New("zero-length segment[0] in peering path")
 	errPeeringEmptySeg1           = errors.New("zero-length segment[1] in peering path")
 	errPeeringNonemptySeg2        = errors.New("non-zero-length segment[2] in peering path")
@@ -230,17 +225,21 @@ func (d *DataPlane) AddInternalInterface(conn BatchConn, ip netip.Addr) error {
 // AddExternalInterface adds the inter AS connection for the given interface ID.
 // If a connection for the given ID is already set this method will return an
 // error. This can only be called on a not yet running dataplane.
-func (d *DataPlane) AddExternalInterface(ifID uint16, conn BatchConn) error {
+func (d *DataPlane) AddExternalInterface(ifID uint16, conn BatchConn,
+	src, dst control.LinkEnd, cfg control.BFD) error {
+
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
+
 	if d.running {
 		return modifyExisting
 	}
-	if conn == nil {
+	if conn == nil || src.Addr == nil || dst.Addr == nil {
 		return emptyValue
 	}
-	if _, exists := d.external[ifID]; exists {
-		return serrors.WithCtx(alreadySet, "ifID", ifID)
+	err := d.addExternalInterfaceBFD(ifID, conn, src, dst, cfg)
+	if err != nil {
+		return serrors.WrapStr("adding external BFD", err, "if_id", ifID)
 	}
 	if d.external == nil {
 		d.external = make(map[uint16]BatchConn)
@@ -248,6 +247,9 @@ func (d *DataPlane) AddExternalInterface(ifID uint16, conn BatchConn) error {
 	if d.interfaces == nil {
 		d.interfaces = make(map[uint16]BatchConn)
 	}
+	if _, exists := d.external[ifID]; exists {
+		return serrors.WithCtx(alreadySet, "ifID", ifID)
+	}
 	d.interfaces[ifID] = conn
 	d.external[ifID] = conn
 	return nil
@@ -308,16 +310,11 @@ func (d *DataPlane) AddRemotePeer(local, remote uint16) error {
 }
 
 // AddExternalInterfaceBFD adds the inter AS connection BFD session.
-func (d *DataPlane) AddExternalInterfaceBFD(ifID uint16, conn BatchConn,
+func (d *DataPlane) addExternalInterfaceBFD(ifID uint16, conn BatchConn,
 	src, dst control.LinkEnd, cfg control.BFD) error {
 
-	d.mtx.Lock()
-	defer d.mtx.Unlock()
-	if d.running {
-		return modifyExisting
-	}
-	if conn == nil {
-		return emptyValue
+	if *cfg.Disable {
+		return nil
 	}
 	var m bfd.Metrics
 	if d.Metrics != nil {
@@ -354,9 +351,6 @@ func (d *DataPlane) getInterfaceState(interfaceID uint16) control.InterfaceState
 func (d *DataPlane) addBFDController(ifID uint16, s *bfdSend, cfg control.BFD,
 	metrics bfd.Metrics) error {
 
-	if cfg.Disable {
-		return errBFDDisabled
-	}
 	if d.bfdSessions == nil {
 		d.bfdSessions = make(map[uint16]bfdSession)
 	}
@@ -422,41 +416,41 @@ func (d *DataPlane) DelSvc(svc addr.SVC, a *net.UDPAddr) error {
 // AddNextHop sets the next hop address for the given interface ID. If the
 // interface ID already has an address associated this operation fails. This can
 // only be called on a not yet running dataplane.
-func (d *DataPlane) AddNextHop(ifID uint16, a *net.UDPAddr) error {
+func (d *DataPlane) AddNextHop(ifID uint16, src, dst *net.UDPAddr, cfg control.BFD,
+	sibling string) error {
+
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
+
 	if d.running {
 		return modifyExisting
 	}
-	if a == nil {
+	if dst == nil || src == nil {
 		return emptyValue
 	}
-	if _, exists := d.internalNextHops[ifID]; exists {
-		return serrors.WithCtx(alreadySet, "ifID", ifID)
+	err := d.addNextHopBFD(ifID, src, dst, cfg, sibling)
+	if err != nil {
+		return serrors.WrapStr("adding next hop BFD", err, "if_id", ifID)
 	}
 	if d.internalNextHops == nil {
 		d.internalNextHops = make(map[uint16]*net.UDPAddr)
 	}
-	d.internalNextHops[ifID] = a
+	if _, exists := d.internalNextHops[ifID]; exists {
+		return serrors.WithCtx(alreadySet, "ifID", ifID)
+	}
+	d.internalNextHops[ifID] = dst
 	return nil
 }
 
 // AddNextHopBFD adds the BFD session for the next hop address.
 // If the remote ifID belongs to an existing address, the existing
 // BFD session will be re-used.
-func (d *DataPlane) AddNextHopBFD(ifID uint16, src, dst *net.UDPAddr, cfg control.BFD,
+func (d *DataPlane) addNextHopBFD(ifID uint16, src, dst *net.UDPAddr, cfg control.BFD,
 	sibling string) error {
 
-	d.mtx.Lock()
-	defer d.mtx.Unlock()
-	if d.running {
-		return modifyExisting
-	}
-
-	if dst == nil {
-		return emptyValue
+	if *cfg.Disable {
+		return nil
 	}
-
 	for k, v := range d.internalNextHops {
 		if v.String() == dst.String() {
 			if c, ok := d.bfdSessions[k]; ok {
diff --git a/router/mgmtapi/BUILD.bazel b/router/mgmtapi/BUILD.bazel
index 9d5f48759..42d4c8550 100644
--- a/router/mgmtapi/BUILD.bazel
+++ b/router/mgmtapi/BUILD.bazel
@@ -44,6 +44,7 @@ go_test(
     data = glob(["testdata/**"]),
     embed = [":go_default_library"],
     deps = [
+        "//pkg/private/ptr:go_default_library",
         "//pkg/private/serrors:go_default_library",
         "//pkg/private/xtest:go_default_library",
         "//private/topology:go_default_library",
diff --git a/router/mgmtapi/api.go b/router/mgmtapi/api.go
index 92d969e91..639b8832e 100644
--- a/router/mgmtapi/api.go
+++ b/router/mgmtapi/api.go
@@ -99,7 +99,7 @@ func (s *Server) GetInterfaces(w http.ResponseWriter, r *http.Request) {
 			Bfd: BFD{
 				DesiredMinimumTxInterval: intf.Link.BFD.DesiredMinTxInterval.String(),
 				DetectionMultiplier:      int(intf.Link.BFD.DetectMult),
-				Enabled:                  !intf.Link.BFD.Disable,
+				Enabled:                  !*(intf.Link.BFD.Disable),
 				RequiredMinimumReceive:   intf.Link.BFD.RequiredMinRxInterval.String(),
 			},
 			InterfaceId:       int(intf.InterfaceID),
diff --git a/tools/topology/docker.py b/tools/topology/docker.py
index 694b6ab25..00a69962b 100644
--- a/tools/topology/docker.py
+++ b/tools/topology/docker.py
@@ -147,16 +147,7 @@ class DockerGenerator(object):
                 'image': image,
                 'networks': {},
                 'user': self.user,
-                'volumes':
-                ['%s:/etc/scion:ro' % base],
-                'environment': {
-                    'SCION_EXPERIMENTAL_BFD_DETECT_MULT':
-                    '${SCION_EXPERIMENTAL_BFD_DETECT_MULT:-}',
-                    'SCION_EXPERIMENTAL_BFD_DESIRED_MIN_TX':
-                    '${SCION_EXPERIMENTAL_BFD_DESIRED_MIN_TX:-}',
-                    'SCION_EXPERIMENTAL_BFD_REQUIRED_MIN_RX':
-                    '${SCION_EXPERIMENTAL_BFD_REQUIRED_MIN_RX:-}',
-                },
+                'volumes': ['%s:/etc/scion:ro' % base],
                 'command': ['--config', '/etc/scion/%s.toml' % k]
             }
             # add data networks:


Test patch:
diff --git a/acceptance/router_benchmark/test.py b/acceptance/router_benchmark/test.py
index fa9abfd72..366ec545b 100644
--- a/acceptance/router_benchmark/test.py
+++ b/acceptance/router_benchmark/test.py
@@ -22,11 +22,14 @@ from collections import defaultdict, namedtuple
 from plumbum import cli
 from plumbum.cmd import docker, whoami, lscpu
 from plumbum import cmd
+from random import randint
 
 from acceptance.common import base
 
 import logging
 import json
+import ipaddress
+
 from http.client import HTTPConnection
 from urllib.parse import urlencode
 
@@ -252,21 +255,37 @@ class RouterBMTest(base.TestBase):
         logger.info(f"brload cpus: {self.brload_cpus}")
 
     def create_interface(self, req: IntfReq, ns: str):
-        """
-        Creates a pair of virtual interfaces, with one end in the given network namespace and the
+        """Creates a pair of virtual interfaces, with one end in the given network namespace and the
         other in the host stack.
 
         The outcome is the pair of interfaces and a record in intfMap, which associates the given
         label with the network interface's host-side name and two mac addresses; one for each end
         of the pair. The mac addresses, if they can be chosen, are not chosen by the invoker, but
-        by this function.
+        by this function. When we can choose, we follow a convention to facilitate debugging.
+        Otherwise the values don't matter. brload has no expectations.
 
-        We could add:
+        We do not:
           sudo("ip", "addr", "add", f"{req.peerIp}/{req.prefixLen}", "dev", hostIntf)
+
+        It causes trouble: if an IP is assigned, the kernel responds with "unbound port" icmp
+        messages to the router traffic, which breaks the bound UDP connections that the router uses
+        for external interfaces.
+
+        We do not:
+          sudo("ip", "netns", "exec", ns, "ip", "neigh", "add", req.peerIp,
+               "lladdr", peerMac, "nud", "permanent", "dev", brIntf)
+
+        This isn't need because brload now responds to arp requests.
+
+        We do not:
           sudo("ip", "link", "set", hostIntf, "address", peerMac)
-        But it not necessary. The ARP seeding on the other end is enough. By not setting these
-        addresses on the host side we make it look a little weird for anyone who would look at it
-        but we avoid the risk of colliding with actual addresses of the host.
+
+        If we do that, the interface address matches the dst addr in router->brload packets. This
+        might seem desirable, even necessary, but is neither: since we're using veth pairs, the
+        packets arrive regardless of address. However, if the address matches the one assigned then
+        the kernel processes the packets in some way and the overall performance is reduced by 50%!
+        When dealing with real NICs, brload uses the real mac addr. In this test, we tell it what to
+        use.
 
         Args:
           IntfReq: A requested router-side network interface. It comprises:
@@ -281,8 +300,7 @@ class RouterBMTest(base.TestBase):
         hostIntf = f"veth_{physlabel}_host"
         brIntf = f"veth_{physlabel}"
 
-        # The interfaces
-        # We do multiplex most requested br interfaces onto one physical interface pairs, so, we
+        # We do multiplex most requested router interfaces onto one physical interface pairs, so, we
         # must check that we haven't already created the physical pair.
         for i in self.intfMap.values():
             if i.name == hostIntf:
@@ -294,28 +312,40 @@ class RouterBMTest(base.TestBase):
             mac = mac_for_ip(req.ip)
             sudo("ip", "link", "add", hostIntf, "type", "veth", "peer", "name", brIntf)
             sudo("ip", "link", "set", hostIntf, "mtu", "8000")
-            sudo("ip", "link", "set", brIntf, "mtu", "8000")
+            sudo("ip", "link", "set", hostIntf, "arp", "off")  # Make sure the real addr isn't used
+
+            # Do not assign the host addresses but create one link-local addr.
+            # Brload needs some src IP to send arp requests.
+            sudo("ip", "addr", "add", f"169.254.{randint(0, 255)}.{randint(0, 255)}/16",
+                 "broadcast", "169.254.255.255",
+                 "dev", hostIntf, "scope", "link")
+
             sudo("sysctl", "-qw", f"net.ipv6.conf.{hostIntf}.disable_ipv6=1")
             sudo("ethtool", "-K", brIntf, "rx", "off", "tx", "off")
+            sudo("ip", "link", "set", brIntf, "mtu", "8000")
             sudo("ip", "link", "set", brIntf, "address", mac)
 
             # The network namespace
             sudo("ip", "link", "set", brIntf, "netns", ns)
             sudo("ip", "netns", "exec", ns,
                  "sysctl", "-qw", f"net.ipv6.conf.{brIntf}.disable_ipv6=1")
+            sudo("ip", "netns", "exec", ns,
+                 "sysctl", "-qw", "net.ipv4.conf.all.rp_filter=0")
+            sudo("ip", "netns", "exec", ns,
+                 "sysctl", "-qw", f"net.ipv4.conf.{brIntf}.rp_filter=0")
 
-        # The addresses (presumably must be done once the br interface is in the namespace).
-        sudo("ip", "netns", "exec", ns,
-             "ip", "addr", "add", f"{req.ip}/{req.prefixLen}", "dev", brIntf)
+        # Add the router side IP addresses (even if we're multiplexing on an existing interface).
         sudo("ip", "netns", "exec", ns,
-             "ip", "neigh", "add", req.peerIp, "lladdr", peerMac, "nud", "permanent",
+             "ip", "addr", "add", f"{req.ip}/{req.prefixLen}",
+             "broadcast",
+             ipaddress.ip_network(f"{req.ip}/{req.prefixLen}", strict=False).broadcast_address,
              "dev", brIntf)
 
         # Fit for duty.
         sudo("ip", "link", "set", hostIntf, "up")
-        sudo("ip", "netns", "exec", ns,
-             "ip", "link", "set", brIntf, "up")
+        sudo("ip", "netns", "exec", ns, "ip", "link", "set", brIntf, "up")
 
+        # Ship it.
         self.intfMap[req.label] = Intf(hostIntf, mac, peerMac)
 
     def setup_prepare(self):
@@ -376,7 +406,6 @@ class RouterBMTest(base.TestBase):
         docker("run",
                "-v", f"{self.artifacts}/conf:/etc/scion",
                "-d",
-               "-e", "SCION_EXPERIMENTAL_BFD_DISABLE=true",
                "-e", "GOMAXPROCS=3",
                "--network", "container:prometheus",
                "--name", "router",
@@ -505,7 +534,7 @@ class RouterBMTest(base.TestBase):
         # Build the interface mapping arg
         mapArgs = []
         for label, intf in self.intfMap.items():
-            mapArgs.extend(["--interface", f"{label}={intf.name},{intf.mac},{intf.peerMac}"])
+            mapArgs.extend(["--interface", f"{label}={intf.name},{intf.peerMac}"])
 
         # Run one test (30% size) as warm-up to trigger the frequency scaling, else the first test
         # gets much lower performance.
diff --git a/acceptance/router_multi/test.py b/acceptance/router_multi/test.py
index 62d07d5db..fc7da04a7 100644
--- a/acceptance/router_multi/test.py
+++ b/acceptance/router_multi/test.py
@@ -94,14 +94,15 @@ class RouterTest(base.TestBase):
     def setup_start(self):
         super().setup_start()
 
-        envs = ["-e SCION_EXPERIMENTAL_BFD_DISABLE=true"]
         if self.bfd:
-            envs = []
-
-        exec_docker("run -v %s/conf:/etc/scion -d %s --network container:%s \
-                    --name router %s" % (self.artifacts, " ".join(envs),
-                    "pause", "scion/router:latest"))
-
+            exec_docker(f"run -v {self.artifacts}/conf:/etc/scion -d "
+                        "--network container:pause --name router "
+                        "scion/router:latest")
+        else:
+            exec_docker(f"run -v {self.artifacts}/conf:/etc/scion -d "
+                        "--network container:pause --name router "
+                        "scion/router:latest "
+                        "--config /etc/scion/router_nobfd.toml")
         time.sleep(1)
 
     def _run(self):
diff --git a/router/dataplane_test.go b/router/dataplane_test.go
index 26678aa8e..105a0ef6a 100644
--- a/router/dataplane_test.go
+++ b/router/dataplane_test.go
@@ -34,6 +34,7 @@ import (
 
 	"github.com/scionproto/scion/pkg/addr"
 	libepic "github.com/scionproto/scion/pkg/experimental/epic"
+	"github.com/scionproto/scion/pkg/private/ptr"
 	"github.com/scionproto/scion/pkg/private/serrors"
 	"github.com/scionproto/scion/pkg/private/util"
 	"github.com/scionproto/scion/pkg/private/xtest"
@@ -110,36 +111,65 @@ func TestDataPlaneSetKey(t *testing.T) {
 }
 
 func TestDataPlaneAddExternalInterface(t *testing.T) {
+	l := control.LinkEnd{
+		IA:   xtest.MustParseIA("1-ff00:0:1"),
+		Addr: &net.UDPAddr{IP: net.ParseIP("10.0.0.100")},
+	}
+	r := control.LinkEnd{
+		IA:   xtest.MustParseIA("1-ff00:0:3"),
+		Addr: &net.UDPAddr{IP: net.ParseIP("10.0.0.200")},
+	}
+	nobfd := control.BFD{Disable: ptr.To(true)}
 	t.Run("fails after serve", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 		defer ctrl.Finish()
 
 		d := &router.DataPlane{}
 		d.FakeStart()
-		assert.Error(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl)))
+		assert.Error(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r, nobfd))
 	})
-	t.Run("setting nil value is not allowed", func(t *testing.T) {
+	t.Run("setting nil conn is not allowed", func(t *testing.T) {
+		ctrl := gomock.NewController(t)
+		defer ctrl.Finish()
+
+		d := &router.DataPlane{}
+		assert.Error(t, d.AddExternalInterface(42, nil, l, r, nobfd))
+	})
+	t.Run("setting blank src is not allowed", func(t *testing.T) {
+		ctrl := gomock.NewController(t)
+		defer ctrl.Finish()
+
+		d := &router.DataPlane{}
+		assert.Error(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl),
+			control.LinkEnd{}, r, nobfd))
+	})
+	t.Run("setting blank dst is not allowed", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 		defer ctrl.Finish()
 
 		d := &router.DataPlane{}
-		assert.Error(t, d.AddExternalInterface(42, nil))
+		assert.Error(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl),
+			l, control.LinkEnd{}, nobfd))
 	})
 	t.Run("normal add works", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 		defer ctrl.Finish()
 
 		d := &router.DataPlane{}
-		assert.NoError(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl)))
-		assert.NoError(t, d.AddExternalInterface(45, mock_router.NewMockBatchConn(ctrl)))
+		assert.NoError(t,
+			d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r, nobfd))
+		assert.NoError(t,
+			d.AddExternalInterface(45, mock_router.NewMockBatchConn(ctrl), l, r, nobfd))
 	})
 	t.Run("overwrite fails", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 		defer ctrl.Finish()
 
 		d := &router.DataPlane{}
-		assert.NoError(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl)))
-		assert.Error(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl)))
+		assert.NoError(t,
+			d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r, nobfd))
+		assert.Error(t,
+			d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r, nobfd))
 	})
 }
 
@@ -166,24 +196,32 @@ func TestDataPlaneAddSVC(t *testing.T) {
 }
 
 func TestDataPlaneAddNextHop(t *testing.T) {
+	l := &net.UDPAddr{}
+	r := &net.UDPAddr{}
+	nobfd := control.BFD{Disable: ptr.To(true)}
+
 	t.Run("fails after serve", func(t *testing.T) {
 		d := &router.DataPlane{}
 		d.FakeStart()
-		assert.Error(t, d.AddNextHop(45, &net.UDPAddr{}))
+		assert.Error(t, d.AddNextHop(45, l, r, nobfd, ""))
 	})
-	t.Run("setting nil value is not allowed", func(t *testing.T) {
+	t.Run("setting nil dst is not allowed", func(t *testing.T) {
+		d := &router.DataPlane{}
+		assert.Error(t, d.AddNextHop(45, l, nil, nobfd, ""))
+	})
+	t.Run("setting nil src is not allowed", func(t *testing.T) {
 		d := &router.DataPlane{}
-		assert.Error(t, d.AddNextHop(45, nil))
+		assert.Error(t, d.AddNextHop(45, nil, r, nobfd, ""))
 	})
 	t.Run("normal add works", func(t *testing.T) {
 		d := &router.DataPlane{}
-		assert.NoError(t, d.AddNextHop(45, &net.UDPAddr{}))
-		assert.NoError(t, d.AddNextHop(43, &net.UDPAddr{}))
+		assert.NoError(t, d.AddNextHop(45, l, r, nobfd, ""))
+		assert.NoError(t, d.AddNextHop(43, l, r, nobfd, ""))
 	})
 	t.Run("overwrite fails", func(t *testing.T) {
 		d := &router.DataPlane{}
-		assert.NoError(t, d.AddNextHop(45, &net.UDPAddr{}))
-		assert.Error(t, d.AddNextHop(45, &net.UDPAddr{}))
+		assert.NoError(t, d.AddNextHop(45, l, r, nobfd, ""))
+		assert.Error(t, d.AddNextHop(45, l, r, nobfd, ""))
 	})
 }
 
@@ -257,8 +295,17 @@ func TestDataPlaneRun(t *testing.T) {
 				).Times(1)
 				mExternal.EXPECT().ReadBatch(gomock.Any()).Return(0, nil).AnyTimes()
 				mExternal.EXPECT().WriteTo(gomock.Any(), gomock.Any()).Return(0, nil).AnyTimes()
+				l := control.LinkEnd{
+					IA:   xtest.MustParseIA("1-ff00:0:1"),
+					Addr: &net.UDPAddr{IP: net.ParseIP("10.0.0.100")},
+				}
+				r := control.LinkEnd{
+					IA:   xtest.MustParseIA("1-ff00:0:3"),
+					Addr: &net.UDPAddr{IP: net.ParseIP("10.0.0.200")},
+				}
+				nobfd := control.BFD{Disable: ptr.To(true)}
 
-				_ = ret.AddExternalInterface(1, mExternal)
+				_ = ret.AddExternalInterface(1, mExternal, l, r, nobfd)
 
 				_ = ret.SetIA(local)
 				_ = ret.SetKey(key)
@@ -340,8 +387,7 @@ func TestDataPlaneRun(t *testing.T) {
 				_ = ret.AddInternalInterface(mInternal, netip.Addr{})
 				for remote, ifIDs := range routers {
 					for _, ifID := range ifIDs {
-						_ = ret.AddNextHop(ifID, remote.(*net.UDPAddr))
-						_ = ret.AddNextHopBFD(ifID, local, remote.(*net.UDPAddr), bfd(), "")
+						_ = ret.AddNextHop(ifID, local, remote.(*net.UDPAddr), bfd(), "")
 					}
 				}
 				return ret
@@ -394,9 +440,7 @@ func TestDataPlaneRun(t *testing.T) {
 
 				_ = ret.SetKey([]byte("randomkeyformacs"))
 				_ = ret.AddInternalInterface(mInternal, netip.Addr{})
-				_ = ret.AddNextHop(3, localAddr)
-				_ = ret.AddNextHopBFD(3, localAddr, remoteAddr, bfd(), "")
-
+				_ = ret.AddNextHop(3, localAddr, remoteAddr, bfd(), "")
 				return ret
 			},
 		},
@@ -448,9 +492,7 @@ func TestDataPlaneRun(t *testing.T) {
 				}
 				_ = ret.SetKey([]byte("randomkeyformacs"))
 				_ = ret.AddInternalInterface(mInternal, netip.Addr{})
-				_ = ret.AddExternalInterface(ifID, mExternal)
-				_ = ret.AddExternalInterfaceBFD(ifID, mExternal, local, remote, bfd())
-
+				_ = ret.AddExternalInterface(ifID, mExternal, local, remote, bfd())
 				return ret
 			},
 		},
@@ -528,9 +570,7 @@ func TestDataPlaneRun(t *testing.T) {
 				}
 				_ = ret.SetKey([]byte("randomkeyformacs"))
 				_ = ret.AddInternalInterface(mInternal, netip.Addr{})
-				_ = ret.AddExternalInterface(1, mExternal)
-				_ = ret.AddExternalInterfaceBFD(1, mExternal, local, remote, bfd())
-
+				_ = ret.AddExternalInterface(1, mExternal, local, remote, bfd())
 				return ret
 			},
 		},
@@ -1677,6 +1717,7 @@ func computeFullMAC(t *testing.T, key []byte, info path.InfoField, hf path.HopFi
 
 func bfd() control.BFD {
 	return control.BFD{
+		Disable:               ptr.To(false),
 		DetectMult:            3,
 		DesiredMinTxInterval:  1 * time.Millisecond,
 		RequiredMinRxInterval: 25 * time.Millisecond,
diff --git a/router/mgmtapi/api_test.go b/router/mgmtapi/api_test.go
index fe72deeec..42d764d25 100644
--- a/router/mgmtapi/api_test.go
+++ b/router/mgmtapi/api_test.go
@@ -25,6 +25,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
+	"github.com/scionproto/scion/pkg/private/ptr"
 	"github.com/scionproto/scion/pkg/private/serrors"
 	"github.com/scionproto/scion/pkg/private/xtest"
 	"github.com/scionproto/scion/private/topology"
@@ -164,7 +165,7 @@ func createExternalIntfs(t *testing.T) []control.ExternalInterface {
 				Instance: "br1-ff00_0_110-1",
 				LinkTo:   topology.Core,
 				BFD: control.BFD{
-					Disable:               false,
+					Disable:               ptr.To(false),
 					DetectMult:            3,
 					DesiredMinTxInterval:  200 * time.Millisecond,
 					RequiredMinRxInterval: 300 * time.Millisecond,
@@ -187,7 +188,7 @@ func createExternalIntfs(t *testing.T) []control.ExternalInterface {
 				Instance: "br1-ff00_0_110-1",
 				LinkTo:   topology.Child,
 				BFD: control.BFD{
-					Disable:               false,
+					Disable:               ptr.To(false),
 					DetectMult:            3,
 					DesiredMinTxInterval:  200 * time.Millisecond,
 					RequiredMinRxInterval: 200 * time.Millisecond,
@@ -210,7 +211,7 @@ func createExternalIntfs(t *testing.T) []control.ExternalInterface {
 				Instance: "br1-ff00_0_111-1",
 				LinkTo:   topology.Child,
 				BFD: control.BFD{
-					Disable:               false,
+					Disable:               ptr.To(false),
 					DetectMult:            3,
 					DesiredMinTxInterval:  150 * time.Millisecond,
 					RequiredMinRxInterval: 150 * time.Millisecond,
@@ -233,7 +234,7 @@ func createExternalIntfs(t *testing.T) []control.ExternalInterface {
 				Instance: "br1-ff00_0_112-1",
 				LinkTo:   topology.Child,
 				BFD: control.BFD{
-					Disable:               false,
+					Disable:               ptr.To(false),
 					DetectMult:            3,
 					DesiredMinTxInterval:  150 * time.Millisecond,
 					RequiredMinRxInterval: 150 * time.Millisecond,