Repository: scionproto/scion
Pull request #4658 (2024-11-29 18:58:39)

router: make room for multiple underlay impls phase 1
The goal is to eventually remove all underlay-specific code from the main router code and place it in a plugin, such that there can then be multiple underlay plugins. This PR isn't attempting to fully accomplish that. It avoids moving some code, so that the diff is easier to read. The price to pay is a few extra spaghetti left between main code and plugin.

The code that should eventually move to the underlay is:
* runReceiver and runForwarder.
* likely some of the BFD code.
* opening of connections (currently done by connector.go)

Other changes being planned:
* Stop reusing the internal connection for sibling links (so we can take advantage of bound connections).
* Add knowledge of multiple underlay into the configurator.
* Make underlay addresses opaque to the router.
* Demultiplex to links on ingest, so ifID and srcAddress (when they are defined by the link) are obtained in the most efficient way (directly from the link's fields for example).

Contributes to: #4593 

jiceatscion: 

<!-- 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/4658)
<!-- Reviewable:end -->


Patch:
diff --git a/router/BUILD.bazel b/router/BUILD.bazel
index fcea078cc..ae14a8073 100644
--- a/router/BUILD.bazel
+++ b/router/BUILD.bazel
@@ -10,6 +10,7 @@ go_library(
         "metrics.go",
         "serialize_proxy.go",
         "svc.go",
+        "underlay.go",
     ],
     importpath = "github.com/scionproto/scion/router",
     visibility = ["//visibility:public"],
@@ -69,6 +70,7 @@ go_test(
         "//private/underlay/conn:go_default_library",
         "//router/control:go_default_library",
         "//router/mock_router:go_default_library",
+        "//router/underlayproviders:go_default_library",
         "@com_github_golang_mock//gomock:go_default_library",
         "@com_github_gopacket_gopacket//:go_default_library",
         "@com_github_gopacket_gopacket//layers:go_default_library",
diff --git a/router/bfd/BUILD.bazel b/router/bfd/BUILD.bazel
index 390333556..1911f2009 100644
--- a/router/bfd/BUILD.bazel
+++ b/router/bfd/BUILD.bazel
@@ -14,6 +14,7 @@ go_library(
     deps = [
         "//pkg/log:go_default_library",
         "//pkg/private/serrors:go_default_library",
+        "//router/control:go_default_library",
         "@com_github_gopacket_gopacket//layers:go_default_library",
         "@com_github_prometheus_client_golang//prometheus:go_default_library",
     ],
diff --git a/router/bfd/session.go b/router/bfd/session.go
index 290a08177..4fd850968 100644
--- a/router/bfd/session.go
+++ b/router/bfd/session.go
@@ -16,8 +16,10 @@ package bfd
 
 import (
 	"context"
+	"crypto/rand"
 	"fmt"
 	"math"
+	"math/big"
 	"sync"
 	"time"
 
@@ -25,6 +27,7 @@ import (
 
 	"github.com/scionproto/scion/pkg/log"
 	"github.com/scionproto/scion/pkg/private/serrors"
+	"github.com/scionproto/scion/router/control"
 )
 
 const (
@@ -161,6 +164,33 @@ type Session struct {
 	testLogger log.Logger
 }
 
+// NewSession returns a new BFD session, configured as specified and updating the
+// given metrics. BFD packets are transmitted via the given Sender. Up to 10 incoming BFD packets
+// per session can be queued waiting for processing; excess traffic will be blocked.
+// A random discriminator is generated automatically. This can be used by the recipient to route
+// packets to the correct session.
+//
+// TODO(jiceatscion): blocking incoming traffic (*all of it*) when the BFD queue is full is
+// probably the wrong thing to do, but this is what we have been doing so far.
+func NewSession(s Sender, cfg control.BFD, metrics Metrics) (*Session, error) {
+
+	// Generate random discriminator. It can't be zero.
+	discInt, err := rand.Int(rand.Reader, big.NewInt(0xfffffffe))
+	if err != nil {
+		return nil, err
+	}
+	disc := layers.BFDDiscriminator(uint32(discInt.Uint64()) + 1)
+	return &Session{
+		Sender:                s,
+		DetectMult:            layers.BFDDetectMultiplier(cfg.DetectMult),
+		DesiredMinTxInterval:  cfg.DesiredMinTxInterval,
+		RequiredMinRxInterval: cfg.RequiredMinRxInterval,
+		LocalDiscriminator:    disc,
+		ReceiveQueueSize:      10,
+		Metrics:               metrics,
+	}, nil
+}
+
 func (s *Session) String() string {
 	return fmt.Sprintf("local_disc %v, remote_disc %v, sender %v",
 		s.LocalDiscriminator, s.getRemoteDiscriminator(), s.Sender)
diff --git a/router/cmd/router/BUILD.bazel b/router/cmd/router/BUILD.bazel
index f1014ba34..20b4f28c4 100644
--- a/router/cmd/router/BUILD.bazel
+++ b/router/cmd/router/BUILD.bazel
@@ -17,6 +17,7 @@ go_library(
         "//router/config:go_default_library",
         "//router/control:go_default_library",
         "//router/mgmtapi:go_default_library",
+        "//router/underlayproviders:go_default_library",
         "@com_github_go_chi_chi_v5//:go_default_library",
         "@com_github_go_chi_cors//:go_default_library",
         "@org_golang_x_sync//errgroup:go_default_library",
diff --git a/router/cmd/router/main.go b/router/cmd/router/main.go
index 181747504..fb599391b 100644
--- a/router/cmd/router/main.go
+++ b/router/cmd/router/main.go
@@ -37,6 +37,7 @@ import (
 	"github.com/scionproto/scion/router/config"
 	"github.com/scionproto/scion/router/control"
 	api "github.com/scionproto/scion/router/mgmtapi"
+	_ "github.com/scionproto/scion/router/underlayproviders"
 )
 
 var globalCfg config.Config
@@ -64,6 +65,11 @@ func realMain(ctx context.Context) error {
 		DataPlane: router.DataPlane{
 			Metrics:                        metrics,
 			ExperimentalSCMPAuthentication: globalCfg.Features.ExperimentalSCMPAuthentication,
+			RunConfig: router.RunConfig{
+				NumProcessors:         globalCfg.Router.NumProcessors,
+				NumSlowPathProcessors: globalCfg.Router.NumSlowPathProcessors,
+				BatchSize:             globalCfg.Router.BatchSize,
+			},
 		},
 		ReceiveBufferSize:   globalCfg.Router.ReceiveBufferSize,
 		SendBufferSize:      globalCfg.Router.SendBufferSize,
@@ -131,12 +137,7 @@ func realMain(ctx context.Context) error {
 	})
 	g.Go(func() error {
 		defer log.HandlePanic()
-		runConfig := &router.RunConfig{
-			NumProcessors:         globalCfg.Router.NumProcessors,
-			NumSlowPathProcessors: globalCfg.Router.NumSlowPathProcessors,
-			BatchSize:             globalCfg.Router.BatchSize,
-		}
-		if err := dp.DataPlane.Run(errCtx, runConfig); err != nil {
+		if err := dp.DataPlane.Run(errCtx); err != nil {
 			return serrors.Wrap("running dataplane", err)
 		}
 		return nil
diff --git a/router/dataplane.go b/router/dataplane.go
index 6512224e8..f3cb2a1df 100644
--- a/router/dataplane.go
+++ b/router/dataplane.go
@@ -24,7 +24,6 @@ import (
 	"errors"
 	"fmt"
 	"hash"
-	"math/big"
 	"net"
 	"net/netip"
 	"sync"
@@ -78,7 +77,7 @@ const (
 	is32bit = 1 - (ptrSize-4)/4
 )
 
-type bfdSession interface {
+type BFDSession interface {
 	Run(ctx context.Context) error
 	ReceiveMessage(*layers.BFD)
 	IsUp() bool
@@ -91,6 +90,17 @@ type BatchConn interface {
 	Close() error
 }
 
+// underlay is a pointer to our underlay provider.
+//
+// TODO(multi_underlay): this allows for a single underlay. In the future, each link could be
+// via a different underlay. That would have to be supported by the configuration code and there
+// would likely be a registry of underlays. For now, That's the whole registry.
+var newUnderlay func() UnderlayProvider
+
+func AddUnderlay(newProvider func() UnderlayProvider) {
+	newUnderlay = newProvider
+}
+
 type disposition int
 
 const (
@@ -100,7 +110,7 @@ const (
 	pDone
 )
 
-// packet aggregates buffers and ancillary metadata related to one packet.
+// Packet aggregates buffers and ancillary metadata related to one packet.
 // That is everything we need to pass-around while processing a packet. The motivation is to save on
 // copy (pass everything via one reference) AND garbage collection (reuse everything).
 // The buffer is allocated in a separate location (but still reused) to keep the packet structures
@@ -110,7 +120,7 @@ const (
 // arch) until Slowpath request which is 6 bytes long. The rest is in decreasing order of size and
 // size-aligned. We want to fit neatly into cache lines, so we need to fit in 64 bytes. The padding
 // required to occupy exactly 64 bytes depends on the architecture.
-type packet struct {
+type Packet struct {
 	// The useful part of the raw packet at a point in time (i.e. a slice of the full buffer).
 	// It can be any portion of the full buffer; not necessarily the start.
 	rawPacket []byte
@@ -122,7 +132,7 @@ type packet struct {
 	srcAddr *net.UDPAddr
 	// The address to where we are forwarding the packet.
 	// Will be set by the processing routine; it is updated in-place.
-	dstAddr *net.UDPAddr
+	DstAddr *net.UDPAddr
 	// Additional metadata in case the packet is put on the slow path. Updated in-place.
 	slowPathRequest slowPathRequest
 	// The ingress on which this packet arrived. This is set by the receiver.
@@ -146,25 +156,25 @@ type slowPathRequest struct {
 }
 
 // Make sure that the packet structure has the size we expect.
-const _ uintptr = 64 - unsafe.Sizeof(packet{}) // assert 64 >= sizeof(packet)
-const _ uintptr = unsafe.Sizeof(packet{}) - 64 // assert sizeof(packet) >= 64
+const _ uintptr = 64 - unsafe.Sizeof(Packet{}) // assert 64 >= sizeof(Packet)
+const _ uintptr = unsafe.Sizeof(Packet{}) - 64 // assert sizeof(Packet) >= 64
 
 // initPacket configures the given blank packet (and returns it, for convenience).
-func (p *packet) init(buffer *[bufSize]byte) *packet {
+func (p *Packet) init(buffer *[bufSize]byte) *Packet {
 	p.buffer = buffer
 	p.rawPacket = p.buffer[:]
-	p.dstAddr = &net.UDPAddr{IP: make(net.IP, net.IPv6len)}
+	p.DstAddr = &net.UDPAddr{IP: make(net.IP, net.IPv6len)}
 	return p
 }
 
 // reset() makes the packet ready to receive a new underlay message.
 // A cleared dstAddr is represented with a zero-length IP so we keep reusing the IP storage bytes.
-func (p *packet) reset() {
-	p.dstAddr.IP = p.dstAddr.IP[0:0] // We're keeping the object, just blank it.
-	*p = packet{
+func (p *Packet) reset() {
+	p.DstAddr.IP = p.DstAddr.IP[0:0] // We're keeping the object, just blank it.
+	*p = Packet{
 		buffer:    p.buffer,    // keep the buffer
 		rawPacket: p.buffer[:], // restore the full packet capacity
-		dstAddr:   p.dstAddr,   // keep the dstAddr and so the IP slice and bytes
+		DstAddr:   p.DstAddr,   // keep the dstAddr and so the IP slice and bytes
 	}
 	// Everything else is reset to zero value.
 }
@@ -173,16 +183,13 @@ func (p *packet) reset() {
 // from multiple sockets, performs routing, and sends them to their destinations
 // (after updating the path, if that is needed).
 type DataPlane struct {
-	interfaces          map[uint16]BatchConn
-	external            map[uint16]BatchConn
+	underlay            UnderlayProvider
+	interfaces          map[uint16]Link
 	linkTypes           map[uint16]topology.LinkType
 	neighborIAs         map[uint16]addr.IA
-	peerInterfaces      map[uint16]uint16
 	internalIP          netip.Addr
-	internalNextHops    map[uint16]netip.AddrPort
 	svc                 *services
 	macFactory          func() hash.Hash
-	bfdSessions         map[uint16]bfdSession
 	localIA             addr.IA
 	mtx                 sync.Mutex
 	running             atomic.Bool
@@ -192,10 +199,7 @@ type DataPlane struct {
 	dispatchedPortEnd   uint16
 
 	ExperimentalSCMPAuthentication bool
-
-	// The forwarding queues. Each is consumed by a forwarder process and fed by
-	// one bfd sender and the packet processors.
-	fwQs map[uint16]chan *packet
+	RunConfig                      RunConfig
 
 	// The pool that stores all the packet buffers as described in the design document. See
 	// https://github.com/scionproto/scion/blob/master/doc/dev/design/BorderRouter.rst
@@ -205,7 +209,7 @@ type DataPlane struct {
 	// packet structure is fetched from the pool passed-around through the various channels and
 	// returned to the pool. To reduce the cost of copying, the packet structure is passed by
 	// reference.
-	packetPool chan *packet
+	packetPool chan *Packet
 }
 
 var (
@@ -224,7 +228,6 @@ var (
 	unsupportedV4MappedV6Address  = errors.New("unsupported v4mapped IP v6 address")
 	unsupportedUnspecifiedAddress = errors.New("unsupported unspecified address")
 	noBFDSessionFound             = errors.New("no BFD session was found")
-	noBFDSessionConfigured        = errors.New("no BFD sessions have been configured")
 	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")
@@ -338,13 +341,18 @@ func (d *DataPlane) AddInternalInterface(conn BatchConn, ip netip.Addr) error {
 	if conn == nil {
 		return emptyValue
 	}
+	if d.underlay == nil {
+		d.underlay = newUnderlay()
+	}
 	if d.interfaces == nil {
-		d.interfaces = make(map[uint16]BatchConn)
+		d.interfaces = make(map[uint16]Link)
 	} else if d.interfaces[0] != nil {
 		return alreadySet
 	}
-	d.interfaces[0] = conn
+
+	d.interfaces[0] = d.underlay.NewInternalLink(conn, d.RunConfig.BatchSize)
 	d.internalIP = ip
+
 	return nil
 }
 
@@ -363,21 +371,21 @@ func (d *DataPlane) AddExternalInterface(ifID uint16, conn BatchConn,
 	if conn == nil || !src.Addr.IsValid() || !dst.Addr.IsValid() {
 		return emptyValue
 	}
-	err := d.addExternalInterfaceBFD(ifID, src, dst, cfg)
+	if d.underlay == nil {
+		d.underlay = newUnderlay()
+	}
+	bfd, err := d.newExternalInterfaceBFD(ifID, src, dst, cfg)
 	if err != nil {
 		return serrors.Wrap("adding external BFD", err, "if_id", ifID)
 	}
-	if d.external == nil {
-		d.external = make(map[uint16]BatchConn)
-	}
 	if d.interfaces == nil {
-		d.interfaces = make(map[uint16]BatchConn)
+		d.interfaces = make(map[uint16]Link)
 	}
-	if _, exists := d.external[ifID]; exists {
+	if _, exists := d.interfaces[ifID]; exists {
 		return serrors.JoinNoStack(alreadySet, nil, "ifID", ifID)
 	}
-	d.interfaces[ifID] = conn
-	d.external[ifID] = conn
+	d.interfaces[ifID] = d.underlay.NewExternalLink(
+		conn, d.RunConfig.BatchSize, bfd, dst.Addr, ifID)
 	return nil
 }
 
@@ -422,30 +430,12 @@ func (d *DataPlane) AddLinkType(ifID uint16, linkTo topology.LinkType) error {
 	return nil
 }
 
-// AddRemotePeer adds the remote peering interface ID for local
-// interface ID.  If the link type for the given ID is already set to
-// a different type, this method will return an error. This can only
-// be called on a not yet running dataplane.
-func (d *DataPlane) AddRemotePeer(local, remote uint16) error {
-	if t, ok := d.linkTypes[local]; ok && t != topology.Peer {
-		return serrors.JoinNoStack(unsupportedPathType, nil, "type", t)
-	}
-	if _, exists := d.peerInterfaces[local]; exists {
-		return serrors.JoinNoStack(alreadySet, nil, "local_interface", local)
-	}
-	if d.peerInterfaces == nil {
-		d.peerInterfaces = make(map[uint16]uint16)
-	}
-	d.peerInterfaces[local] = remote
-	return nil
-}
-
 // AddExternalInterfaceBFD adds the inter AS connection BFD session.
-func (d *DataPlane) addExternalInterfaceBFD(ifID uint16,
-	src, dst control.LinkEnd, cfg control.BFD) error {
+func (d *DataPlane) newExternalInterfaceBFD(ifID uint16,
+	src, dst control.LinkEnd, cfg control.BFD) (BFDSession, error) {
 
 	if *cfg.Disable {
-		return nil
+		return nil, nil
 	}
 	var m bfd.Metrics
 	if d.Metrics != nil {
@@ -463,47 +453,21 @@ func (d *DataPlane) addExternalInterfaceBFD(ifID uint16,
 	}
 	s, err := newBFDSend(d, src.IA, dst.IA, src.Addr, dst.Addr, ifID, d.macFactory())
 	if err != nil {
-		return err
+		return nil, err
 	}
-	return d.addBFDController(ifID, s, cfg, m)
+	return bfd.NewSession(s, cfg, m)
 }
 
 // getInterfaceState checks if there is a bfd session for the input interfaceID and
-// returns InterfaceUp if the relevant bfdsession state is up, or if there is no BFD
+// returns InterfaceUp if the relevant BFDSession state is up, or if there is no BFD
 // session. Otherwise, it returns InterfaceDown.
 func (d *DataPlane) getInterfaceState(ifID uint16) control.InterfaceState {
-	bfdSessions := d.bfdSessions
-	if bfdSession, ok := bfdSessions[ifID]; ok && !bfdSession.IsUp() {
+	if link := d.interfaces[ifID]; link != nil && !link.IsUp() {
 		return control.InterfaceDown
 	}
 	return control.InterfaceUp
 }
 
-func (d *DataPlane) addBFDController(ifID uint16, s bfd.Sender, cfg control.BFD,
-	metrics bfd.Metrics) error {
-
-	if d.bfdSessions == nil {
-		d.bfdSessions = make(map[uint16]bfdSession)
-	}
-
-	// Generate random discriminator. It can't be zero.
-	discInt, err := rand.Int(rand.Reader, big.NewInt(0xfffffffe))
-	if err != nil {
-		return err
-	}
-	disc := layers.BFDDiscriminator(uint32(discInt.Uint64()) + 1)
-	d.bfdSessions[ifID] = &bfd.Session{
-		Sender:                s,
-		DetectMult:            layers.BFDDetectMultiplier(cfg.DetectMult),
-		DesiredMinTxInterval:  cfg.DesiredMinTxInterval,
-		RequiredMinRxInterval: cfg.RequiredMinRxInterval,
-		LocalDiscriminator:    disc,
-		ReceiveQueueSize:      10,
-		Metrics:               metrics,
-	}
-	return nil
-}
-
 // AddSvc adds the address for the given service. This can be called multiple
 // times for the same service, with the address added to the list of addresses
 // that provide the service.
@@ -559,36 +523,32 @@ func (d *DataPlane) AddNextHop(ifID uint16, src, dst netip.AddrPort, cfg control
 	if !dst.IsValid() || !src.IsValid() {
 		return emptyValue
 	}
-	err := d.addNextHopBFD(ifID, src, dst, cfg, sibling)
+	if d.underlay == nil {
+		d.underlay = newUnderlay()
+	}
+	bfd, err := d.newNextHopBFD(ifID, src, dst, cfg, sibling)
 	if err != nil {
 		return serrors.Wrap("adding next hop BFD", err, "if_id", ifID)
 	}
-	if d.internalNextHops == nil {
-		d.internalNextHops = make(map[uint16]netip.AddrPort)
+	if d.interfaces == nil {
+		d.interfaces = make(map[uint16]Link)
 	}
-	if _, exists := d.internalNextHops[ifID]; exists {
+	if _, exists := d.interfaces[ifID]; exists {
 		return serrors.JoinNoStack(alreadySet, nil, "ifID", ifID)
 	}
-	d.internalNextHops[ifID] = dst
+
+	d.interfaces[ifID] = d.underlay.NewSiblingLink(d.RunConfig.BatchSize, bfd, 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 netip.AddrPort, cfg control.BFD,
-	sibling string) error {
+func (d *DataPlane) newNextHopBFD(ifID uint16, src, dst netip.AddrPort, cfg control.BFD,
+	sibling string) (BFDSession, error) {
 
 	if *cfg.Disable {
-		return nil
-	}
-	for k, v := range d.internalNextHops {
-		if v.String() == dst.String() {
-			if c, ok := d.bfdSessions[k]; ok {
-				d.bfdSessions[ifID] = c
-				return nil
-			}
-		}
+		return nil, nil
 	}
 	var m bfd.Metrics
 	if d.Metrics != nil {
@@ -603,9 +563,9 @@ func (d *DataPlane) addNextHopBFD(ifID uint16, src, dst netip.AddrPort, cfg cont
 
 	s, err := newBFDSend(d, d.localIA, d.localIA, src, dst, 0, d.macFactory())
 	if err != nil {
-		return err
+		return nil, err
 	}
-	return d.addBFDController(ifID, s, cfg, m)
+	return bfd.NewSession(s, cfg, m)
 }
 
 func max(a int, b int) int {
@@ -621,49 +581,56 @@ type RunConfig struct {
 	BatchSize             int
 }
 
-func (d *DataPlane) Run(ctx context.Context, cfg *RunConfig) error {
+func (d *DataPlane) Run(ctx context.Context) error {
 	d.mtx.Lock()
 	d.initMetrics()
 
+	// TODO(multi_underlay): we leave all the ingest work here for now, so we get the set of
+	// connections from the underlay.
+	underlayConnections := d.underlay.Connections()
 	processorQueueSize := max(
-		len(d.interfaces)*cfg.BatchSize/cfg.NumProcessors,
-		cfg.BatchSize)
+		len(underlayConnections)*d.RunConfig.BatchSize/d.RunConfig.NumProcessors,
+		d.RunConfig.BatchSize)
 
-	d.initPacketPool(cfg, processorQueueSize)
-	procQs, fwQs, slowQs := initQueues(cfg, d.interfaces, processorQueueSize)
-	d.fwQs = fwQs // Shared with BFD senders
+	d.initPacketPool(processorQueueSize)
+	procQs, slowQs := d.initQueues(processorQueueSize)
 
 	d.setRunning()
-	for ifID, conn := range d.interfaces {
-		go func(ifID uint16, conn BatchConn) {
+	for _, c := range underlayConnections {
+		go func(c UnderlayConn) {
 			defer log.HandlePanic()
-			d.runReceiver(ifID, conn, cfg, procQs)
-		}(ifID, conn)
-		go func(ifID uint16, conn BatchConn) {
+			d.runReceiver(c, procQs)
+		}(c)
+		go func(c UnderlayConn) {
 			defer log.HandlePanic()
-			d.runForwarder(ifID, conn, cfg, fwQs[ifID])
-		}(ifID, conn)
+			d.runForwarder(c)
+		}(c)
 	}
-	for i := 0; i < cfg.NumProcessors; i++ {
+
+	for i := 0; i < d.RunConfig.NumProcessors; i++ {
 		go func(i int) {
 			defer log.HandlePanic()
-			d.runProcessor(i, procQs[i], fwQs, slowQs[i%cfg.NumSlowPathProcessors])
+			d.runProcessor(i, procQs[i], slowQs[i%d.RunConfig.NumSlowPathProcessors])
 		}(i)
 	}
-	for i := 0; i < cfg.NumSlowPathProcessors; i++ {
+	for i := 0; i < d.RunConfig.NumSlowPathProcessors; i++ {
 		go func(i int) {
 			defer log.HandlePanic()
-			d.runSlowPathProcessor(i, slowQs[i], fwQs)
+			d.runSlowPathProcessor(i, slowQs[i])
 		}(i)
 	}
 
-	for k, v := range d.bfdSessions {
-		go func(ifID uint16, c bfdSession) {
+	for addr, link := range d.underlay.Links() {
+		s := link.BFDSession()
+		if s == nil {
+			continue
+		}
+		go func(addr netip.AddrPort) {
 			defer log.HandlePanic()
-			if err := c.Run(ctx); err != nil && err != bfd.AlreadyRunning {
-				log.Error("BFD session failed to start", "ifID", ifID, "err", err)
+			if err := s.Run(ctx); err != nil && err != bfd.AlreadyRunning {
+				log.Error("BFD session failed to start", "remote address", addr, "err", err)
 			}
-		}(k, v)
+		}(addr)
 	}
 
 	d.mtx.Unlock()
@@ -673,43 +640,41 @@ func (d *DataPlane) Run(ctx context.Context, cfg *RunConfig) error {
 
 // initializePacketPool calculates the size of the packet pool based on the
 // current dataplane settings and allocates all the buffers
-func (d *DataPlane) initPacketPool(cfg *RunConfig, processorQueueSize int) {
-	poolSize := len(d.interfaces)*cfg.BatchSize +
-		(cfg.NumProcessors+cfg.NumSlowPathProcessors)*(processorQueueSize+1) +
-		len(d.interfaces)*(2*cfg.BatchSize)
+func (d *DataPlane) initPacketPool(processorQueueSize int) {
+	poolSize := len(d.interfaces)*d.RunConfig.BatchSize +
+		(d.RunConfig.NumProcessors+d.RunConfig.NumSlowPathProcessors)*(processorQueueSize+1) +
+		len(d.interfaces)*(2*d.RunConfig.BatchSize)
 
 	log.Debug("Initialize packet pool of size", "poolSize", poolSize)
-	d.packetPool = make(chan *packet, poolSize)
+	d.packetPool = make(chan *Packet, poolSize)
 	pktBuffers := make([][bufSize]byte, poolSize)
-	pktStructs := make([]packet, poolSize)
+	pktStructs := make([]Packet, poolSize)
 	for i := 0; i < poolSize; i++ {
 		d.packetPool <- pktStructs[i].init(&pktBuffers[i])
 	}
 }
 
-// initializes the processing routines and forwarders queues
-func initQueues(cfg *RunConfig, interfaces map[uint16]BatchConn,
-	processorQueueSize int) ([]chan *packet, map[uint16]chan *packet, []chan *packet) {
+// initializes the processing routines and queues
+func (d *DataPlane) initQueues(processorQueueSize int) ([]chan *Packet, []chan *Packet) {
 
-	procQs := make([]chan *packet, cfg.NumProcessors)
-	for i := 0; i < cfg.NumProcessors; i++ {
-		procQs[i] = make(chan *packet, processorQueueSize)
+	procQs := make([]chan *Packet, d.RunConfig.NumProcessors)
+	for i := 0; i < d.RunConfig.NumProcessors; i++ {
+		procQs[i] = make(chan *Packet, processorQueueSize)
 	}
-	slowQs := make([]chan *packet, cfg.NumSlowPathProcessors)
-	for i := 0; i < cfg.NumSlowPathProcessors; i++ {
-		slowQs[i] = make(chan *packet, processorQueueSize)
+	slowQs := make([]chan *Packet, d.RunConfig.NumSlowPathProcessors)
+	for i := 0; i < d.RunConfig.NumSlowPathProcessors; i++ {
+		slowQs[i] = make(chan *Packet, processorQueueSize)
 	}
-	fwQs := make(map[uint16]chan *packet)
-	for ifID := range interfaces {
-		fwQs[ifID] = make(chan *packet, cfg.BatchSize)
-	}
-	return procQs, fwQs, slowQs
+	return procQs, slowQs
 }
 
-func (d *DataPlane) runReceiver(ifID uint16, conn BatchConn, cfg *RunConfig,
-	procQs []chan *packet) {
+// runReceiver handles incoming traffic from the given connection.
+//
+// TODO(multi_underlay): we should not have direct knowledge of connections. Later we will move
+// parts of this to the connection itself, so that we don't have to know.
+func (d *DataPlane) runReceiver(u UnderlayConn, procQs []chan *Packet) {
 
-	log.Debug("Run receiver for", "interface", ifID)
+	log.Debug("Run receiver", "connection", u.Name())
 
 	// Each receiver (therefore each input interface) has a unique random seed for the procID hash
 	// function.
@@ -724,21 +689,22 @@ func (d *DataPlane) runReceiver(ifID uint16, conn BatchConn, cfg *RunConfig,
 
 	// A collection of socket messages, as the readBatch API expects them. We keep using the same
 	// collection, call after call; only replacing the buffer.
-	msgs := underlayconn.NewReadMessages(cfg.BatchSize)
+	msgs := underlayconn.NewReadMessages(d.RunConfig.BatchSize)
 
 	// An array of corresponding packet references. Each corresponds to one msg.
 	// The packet owns the buffer that we set in the matching msg, plus the metadata that we'll add.
-	packets := make([]*packet, cfg.BatchSize)
+	packets := make([]*Packet, d.RunConfig.BatchSize)
 
-	numReusable := 0                     // unused buffers from previous loop
+	numReusable := 0 // unused buffers from previous loop
+	ifID := u.IfID()
 	metrics := d.forwardingMetrics[ifID] // If receiver exists, fw metrics exist too.
 
-	enqueueForProcessing := func(size int, srcAddr *net.UDPAddr, pkt *packet) {
+	enqueueForProcessing := func(size int, srcAddr *net.UDPAddr, pkt *Packet) {
 		sc := classOfSize(size)
 		metrics[sc].InputPacketsTotal.Inc()
 		metrics[sc].InputBytesTotal.Add(float64(size))
 
-		procID, err := computeProcID(pkt.rawPacket, cfg.NumProcessors, hashSeed)
+		procID, err := computeProcID(pkt.rawPacket, d.RunConfig.NumProcessors, hashSeed)
 		if err != nil {
 			log.Debug("Error while computing procID", "err", err)
 			d.returnPacketToPool(pkt)
@@ -747,6 +713,9 @@ func (d *DataPlane) runReceiver(ifID uint16, conn BatchConn, cfg *RunConfig,
 		}
 
 		pkt.rawPacket = pkt.rawPacket[:size] // Update size; readBatch does not.
+		// TODO(multi_underlay): We should begin with finding the link and get the ifID
+		// from there. We will do that once we actually move these pre-processing tasks directly
+		// into the underlay.
 		pkt.ingress = ifID
 		pkt.srcAddr = srcAddr
 		select {
@@ -757,11 +726,12 @@ func (d *DataPlane) runReceiver(ifID uint16, conn BatchConn, cfg *RunConfig,
 		}
 	}
 
+	conn := u.Conn()
 	for d.IsRunning() {
 		// collect packets.
 
 		// Give a new buffer to the msgs elements that have been used in the previous loop.
-		for i := 0; i < cfg.BatchSize-numReusable; i++ {
+		for i := 0; i < d.RunConfig.BatchSize-numReusable; i++ {
 			p := d.getPacketFromPool()
 			p.reset()
 			packets[i] = p
@@ -808,16 +778,15 @@ func computeProcID(data []byte, numProcRoutines int, hashSeed uint32) (uint32, e
 	return s % uint32(numProcRoutines), nil
 }
 
-func (d *DataPlane) getPacketFromPool() *packet {
+func (d *DataPlane) getPacketFromPool() *Packet {
 	return <-d.packetPool
 }
 
-func (d *DataPlane) returnPacketToPool(pkt *packet) {
+func (d *DataPlane) returnPacketToPool(pkt *Packet) {
 	d.packetPool <- pkt
 }
 
-func (d *DataPlane) runProcessor(id int, q <-chan *packet,
-	fwQs map[uint16]chan *packet, slowQ chan<- *packet) {
+func (d *DataPlane) runProcessor(id int, q <-chan *Packet, slowQ chan<- *Packet) {
 
 	log.Debug("Initialize processor with", "id", id)
 	processor := newPacketProcessor(d)
@@ -856,25 +825,21 @@ func (d *DataPlane) runProcessor(id int, q <-chan *packet,
 			d.returnPacketToPool(p)
 			continue
 		}
-		fwCh, ok := fwQs[p.egress]
+		fwLink, ok := d.interfaces[p.egress]
 		if !ok {
 			log.Debug("Error determining forwarder. Egress is invalid", "egress", p.egress)
 			metrics.DroppedPacketsInvalid.Inc()
 			d.returnPacketToPool(p)
 			continue
 		}
-
-		select {
-		case fwCh <- p:
-		default:
+		if !fwLink.Send(p) {
 			d.returnPacketToPool(p)
 			metrics.DroppedPacketsBusyForwarder.Inc()
 		}
 	}
 }
 
-func (d *DataPlane) runSlowPathProcessor(id int, q <-chan *packet,
-	fwQs map[uint16]chan *packet) {
+func (d *DataPlane) runSlowPathProcessor(id int, q <-chan *Packet) {
 
 	log.Debug("Initialize slow-path processor with", "id", id)
 	processor := newSlowPathProcessor(d)
@@ -892,15 +857,13 @@ func (d *DataPlane) runSlowPathProcessor(id int, q <-chan *packet,
 			d.returnPacketToPool(p)
 			continue
 		}
-		fwCh, ok := fwQs[p.egress]
+		fwLink, ok := d.interfaces[p.egress]
 		if !ok {
 			log.Debug("Error determining forwarder. Egress is invalid", "egress", p.egress)
 			d.returnPacketToPool(p)
 			continue
 		}
-		select {
-		case fwCh <- p:
-		default:
+		if !fwLink.Send(p) {
 			d.returnPacketToPool(p)
 		}
 	}
@@ -923,7 +886,7 @@ func newSlowPathProcessor(d *DataPlane) *slowPathPacketProcessor {
 
 type slowPathPacketProcessor struct {
 	d   *DataPlane
-	pkt *packet
+	pkt *Packet
 
 	scionLayer slayers.SCION
 	hbhLayer   slayers.HopByHopExtnSkipper
@@ -950,7 +913,7 @@ func (p *slowPathPacketProcessor) reset() {
 	p.e2eLayer = slayers.EndToEndExtnSkipper{}
 }
 
-func (p *slowPathPacketProcessor) processPacket(pkt *packet) error {
+func (p *slowPathPacketProcessor) processPacket(pkt *Packet) error {
 	var err error
 	p.reset()
 	p.pkt = pkt
@@ -1008,7 +971,7 @@ func (p *slowPathPacketProcessor) processPacket(pkt *packet) error {
 	}
 }
 
-func updateOutputMetrics(metrics interfaceMetrics, packets []*packet) {
+func updateOutputMetrics(metrics interfaceMetrics, packets []*Packet) {
 	// We need to collect stats by traffic type and size class.
 	// Try to reduce the metrics lookup penalty by using some
 	// simpler staging data structure.
@@ -1031,32 +994,51 @@ func updateOutputMetrics(metrics interfaceMetrics, packets []*packet) {
 	}
 }
 
-func (d *DataPlane) runForwarder(ifID uint16, conn BatchConn, cfg *RunConfig, c <-chan *packet) {
+// TODO(jiceatscion): There is a big issue with metrics and ifID. If an underlay connection must be
+// shared between links (for example, sibling links), then we don't have a specific ifID in the
+// connection per se. It changes for each packet. As a result, in the shared case, either we account
+// all metrics to whatever placeholder ifID we have (i.e. 0), or we have to use pkt.egress and
+// lookup the metrics in the map for each packet. This is too expensive.
+//
+// Mitigations:
+//   - use ifID even if it is 0 for sibling links - no worse than before, since sibling links were
+//     already redirected to interface 0 (...until we have fully shared forwarders - like with an
+//     XDP underlay impl).
+//   - stage our own internal metrics map, sorted by ifID = pkt.egress, and batch update the
+//     metrics... might not be much cheaper than the naive way.
+//   - Use one fw queue per ifID in each connection... but then have to round-robin for fairness....
+//     smaller batches?
+//
+// For now, we do the first option. Whether that is good enough is still TBD.
+
+func (d *DataPlane) runForwarder(u UnderlayConn) {
 
-	log.Debug("Initialize forwarder for", "interface", ifID)
+	log.Debug("Run forwarder", "connection", u.Name())
 
 	// We use this somewhat like a ring buffer.
-	pkts := make([]*packet, cfg.BatchSize)
+	pkts := make([]*Packet, d.RunConfig.BatchSize)
 
 	// We use this as a temporary buffer, but allocate it just once
 	// to save on garbage handling.
-	msgs := make(underlayconn.Messages, cfg.BatchSize)
+	msgs := make(underlayconn.Messages, d.RunConfig.BatchSize)
 	for i := range msgs {
 		msgs[i].Buffers = make([][]byte, 1)
 	}
 
-	metrics := d.forwardingMetrics[ifID]
-
+	c := u.Queue()
+	conn := u.Conn()
+	metrics := d.forwardingMetrics[u.IfID()]
 	toWrite := 0
 	for d.IsRunning() {
-		toWrite += readUpTo(c, cfg.BatchSize-toWrite, toWrite == 0, pkts[toWrite:])
+		// Top-up our batch.
+		toWrite += readUpTo(c, d.RunConfig.BatchSize-toWrite, toWrite == 0, pkts[toWrite:])
 
 		// Turn the packets into underlay messages that WriteBatch can send.
 		for i, p := range pkts[:toWrite] {
 			msgs[i].Buffers[0] = p.rawPacket
 			msgs[i].Addr = nil
-			if len(p.dstAddr.IP) != 0 {
-				msgs[i].Addr = p.dstAddr
+			if len(p.DstAddr.IP) != 0 {
+				msgs[i].Addr = p.DstAddr
 			}
 		}
 		written, _ := conn.WriteBatch(msgs[:toWrite], 0)
@@ -1089,7 +1071,7 @@ func (d *DataPlane) runForwarder(ifID uint16, conn BatchConn, cfg *RunConfig, c
 	}
 }
 
-func readUpTo(c <-chan *packet, n int, needsBlocking bool, pkts []*packet) int {
+func readUpTo(c <-chan *Packet, n int, needsBlocking bool, pkts []*Packet) int {
 	i := 0
 	if needsBlocking {
 		p, ok := <-c
@@ -1149,7 +1131,7 @@ func errorDiscard(ctx ...any) disposition {
 	return pDiscard
 }
 
-func (p *scionPacketProcessor) processPkt(pkt *packet) disposition {
+func (p *scionPacketProcessor) processPkt(pkt *Packet) disposition {
 	if err := p.reset(); err != nil {
 		return errorDiscard("error", err)
 	}
@@ -1191,26 +1173,33 @@ func (p *scionPacketProcessor) processPkt(pkt *packet) disposition {
 }
 
 func (p *scionPacketProcessor) processInterBFD(oh *onehop.Path, data []byte) disposition {
-	if len(p.d.bfdSessions) == 0 {
-		return errorDiscard("error", noBFDSessionConfigured)
-	}
 
+	// If this is an inter-AS BFD, it can via an interface we own. So the ifID matches one link
+	// and the ifID better be valid. In the future that will be checked upstream from here.
+	link, exists := p.d.interfaces[p.pkt.ingress]
+	if !exists {
+		return errorDiscard("error", noBFDSessionFound)
+	}
+	session := link.BFDSession()
+	if session == nil {
+		return errorDiscard("error", noBFDSessionFound)
+	}
 	bfd := &p.bfdLayer
 	if err := bfd.DecodeFromBytes(data, gopacket.NilDecodeFeedback); err != nil {
 		return errorDiscard("error", err)
 	}
-
-	if v, ok := p.d.bfdSessions[p.pkt.ingress]; ok {
-		v.ReceiveMessage(bfd)
-		return pDiscard // All's fine. That packet's journey ends here.
-	}
-
-	return errorDiscard("error", noBFDSessionFound)
+	session.ReceiveMessage(bfd)
+	return pDiscard // All's fine. That packet's journey ends here.
 }
 
 func (p *scionPacketProcessor) processIntraBFD(data []byte) disposition {
-	if len(p.d.bfdSessions) == 0 {
-		return errorDiscard("error", noBFDSessionConfigured)
+
+	// This packet came over a link that doesn't have a define ifID. We have to find it
+	// by srcAddress. We always find one. The internal link matches anything that is not known.
+	src := p.pkt.srcAddr.AddrPort() // POSSIBLY EXPENSIVE CONVERSION
+	session := p.d.underlay.Link(src).BFDSession()
+	if session == nil {
+		return errorDiscard("error", noBFDSessionFound)
 	}
 
 	bfd := &p.bfdLayer
@@ -1218,21 +1207,8 @@ func (p *scionPacketProcessor) processIntraBFD(data []byte) disposition {
 		return errorDiscard("error", err)
 	}
 
-	ifID := uint16(0)
-	src := p.pkt.srcAddr.AddrPort() // POSSIBLY EXPENSIVE CONVERSION
-	for k, v := range p.d.internalNextHops {
-		if src == v {
-			ifID = k
-			break
-		}
-	}
-
-	if v, ok := p.d.bfdSessions[ifID]; ok {
-		v.ReceiveMessage(bfd)
-		return pDiscard // All's fine. That packet's journey ends here.
-	}
-
-	return errorDiscard("error", noBFDSessionFound)
+	session.ReceiveMessage(bfd)
+	return pDiscard // All's fine. That packet's journey ends here.
 }
 
 func (p *scionPacketProcessor) processSCION() disposition {
@@ -1301,7 +1277,7 @@ type scionPacketProcessor struct {
 	// d is a reference to the dataplane instance that initiated this processor.
 	d *DataPlane
 	// pkt is the packet currently being processed by this processor.
-	pkt *packet
+	pkt *Packet
 	// mac is the hasher for the MAC computation.
 	mac hash.Hash
 
@@ -1369,7 +1345,7 @@ func (p *slowPathPacketProcessor) packSCMP(
 	// The original traffic type, if one had been set, no-longer applies.
 	p.pkt.trafficType = ttOther
 	p.pkt.egress = p.pkt.ingress
-	updateNetAddrFromNetAddr(p.pkt.dstAddr, p.pkt.srcAddr)
+	updateNetAddrFromNetAddr(p.pkt.DstAddr, p.pkt.srcAddr)
 	return nil
 }
 
@@ -1524,34 +1500,43 @@ func (p *scionPacketProcessor) respInvalidDstIA() disposition {
 // Provided that underlying network infrastructure prevents address spoofing,
 // this check prevents malicious end hosts in the local AS from bypassing the
 // SrcIA checks by disguising packets as transit traffic.
+//
+// TODO(multi_underlay): All or part of this check should move to the underlay.
 func (p *scionPacketProcessor) validateTransitUnderlaySrc() disposition {
 	if p.path.IsFirstHop() || p.pkt.ingress != 0 {
 		// not a transit packet, nothing to check
 		return pForward
 	}
 	pktIngressID := p.ingressInterface()
-	expectedSrc, okE := p.d.internalNextHops[pktIngressID]
-	if !okE {
+	ingressLink := p.d.interfaces[pktIngressID]
+	if ingressLink.Scope() != Sibling {
 		// Drop
 		return errorDiscard("error", invalidSrcAddrForTransit)
 	}
 	src, okS := netip.AddrFromSlice(p.pkt.srcAddr.IP)
-	if !(okS && expectedSrc.Addr() == src) {
+	if !(okS && ingressLink.Remote().Addr() == src) {
 		// Drop
 		return errorDiscard("error", invalidSrcAddrForTransit)
 	}
 	return pForward
 }
 
-// Validates the egress interface referenced by the current hop.
+// Validates the egress interface referenced by the current hop. This is not called for
+// packets to be delivered to the local AS, so pkt.egress is never 0.
+// If pkt.ingress is zero, the packet can be coming from either a local end-host or a
+// sibling router. In either of these cases, it must be leaving via a locally owned external
+// interface (i.e. it can be going to a sibling router or to a local end-host). On the other
+// hand, a packet coming directly from another AS can be going anywhere: local delivery,
+// to another AS directly, or via a sibling router.
 func (p *scionPacketProcessor) validateEgressID() disposition {
 	egressID := p.pkt.egress
-	_, ih := p.d.internalNextHops[egressID]
-	_, eh := p.d.external[egressID]
+	link, found := p.d.interfaces[egressID]
+
 	// egress interface must be a known interface
+	// egress is never the internalInterface
 	// packet coming from internal interface, must go to an external interface
-	// packet coming from external interface can go to either internal or external interface
-	if !ih && !eh || (p.pkt.ingress == 0) && !eh {
+	// Note that, for now, ingress == 0 is also true for sibling interfaces. That might change.
+	if !found || (p.pkt.ingress == 0 && link.Scope() == Sibling) {
 		errCode := slayers.SCMPCodeUnknownHopFieldEgress
 		if !p.infoField.ConsDir {
 			errCode = slayers.SCMPCodeUnknownHopFieldIngress
@@ -1669,7 +1654,7 @@ func (p *scionPacketProcessor) verifyCurrentMAC() disposition {
 }
 
 func (p *scionPacketProcessor) resolveInbound() disposition {
-	err := p.d.resolveLocalDst(p.pkt.dstAddr, p.scionLayer, p.lastLayer)
+	err := p.d.resolveLocalDst(p.pkt.DstAddr, p.scionLayer, p.lastLayer)
 
 	switch err {
 	case nil:
@@ -1760,22 +1745,21 @@ func (p *scionPacketProcessor) egressInterface() uint16 {
 
 func (p *scionPacketProcessor) validateEgressUp() disposition {
 	egressID := p.pkt.egress
-	if v, ok := p.d.bfdSessions[egressID]; ok {
-		if !v.IsUp() {
-			log.Debug("SCMP response", "cause", errBFDSessionDown)
-			if _, external := p.d.external[p.pkt.egress]; !external {
-				p.pkt.slowPathRequest = slowPathRequest{
-					scmpType: slayers.SCMPTypeInternalConnectivityDown,
-					code:     0,
-				}
-			} else {
-				p.pkt.slowPathRequest = slowPathRequest{
-					scmpType: slayers.SCMPTypeExternalInterfaceDown,
-					code:     0,
-				}
+	egressLink := p.d.interfaces[egressID]
+	if !egressLink.IsUp() {
+		log.Debug("SCMP response", "cause", errBFDSessionDown)
+		if egressLink.Scope() != External {
+			p.pkt.slowPathRequest = slowPathRequest{
+				scmpType: slayers.SCMPTypeInternalConnectivityDown,
+				code:     0,
+			}
+		} else {
+			p.pkt.slowPathRequest = slowPathRequest{
+				scmpType: slayers.SCMPTypeExternalInterfaceDown,
+				code:     0,
 			}
-			return pSlowPath
 		}
+		return pSlowPath
 	}
 	return pForward
 }
@@ -1810,7 +1794,7 @@ func (p *scionPacketProcessor) handleEgressRouterAlert() disposition {
 	if !*alert {
 		return pForward
 	}
-	if _, ok := p.d.external[p.pkt.egress]; !ok {
+	if p.d.interfaces[p.pkt.egress].Scope() != External {
 		// the egress router is not this one.
 		return pForward
 	}
@@ -1981,7 +1965,7 @@ func (p *scionPacketProcessor) process() disposition {
 	if disp := p.validateEgressUp(); disp != pForward {
 		return disp
 	}
-	if _, ok := p.d.external[egressID]; ok {
+	if p.d.interfaces[egressID].Scope() == External {
 		// Not ASTransit in
 		if disp := p.processEgress(); disp != pForward {
 			return disp
@@ -2003,25 +1987,9 @@ func (p *scionPacketProcessor) process() disposition {
 	}
 
 	// ASTransit in: pkt leaving this AS through another BR.
-	if a, ok := p.d.internalNextHops[egressID]; ok {
-		p.pkt.trafficType = ttInTransit
-		updateNetAddrFromAddrPort(p.pkt.dstAddr, a)
-		// The packet must go to the other router via the internal interface.
-		p.pkt.egress = 0
-		return pForward
-	}
-
-	errCode := slayers.SCMPCodeUnknownHopFieldEgress
-	if !p.infoField.ConsDir {
-		errCode = slayers.SCMPCodeUnknownHopFieldIngress
-	}
-	log.Debug("SCMP response", "cause", cannotRoute)
-	p.pkt.slowPathRequest = slowPathRequest{
-		scmpType: slayers.SCMPTypeParameterProblem,
-		code:     errCode,
-		pointer:  p.currentHopPointer(),
-	}
-	return pSlowPath
+	// We already know the egressID is valid. The packet can go straight to forwarding.
+	p.pkt.trafficType = ttInTransit
+	return pForward
 }
 
 func (p *scionPacketProcessor) processOHP() disposition {
@@ -2086,7 +2054,7 @@ func (p *scionPacketProcessor) processOHP() disposition {
 	if err := updateSCIONLayer(p.pkt.rawPacket, s); err != nil {
 		return errorDiscard("error", err)
 	}
-	err := p.d.resolveLocalDst(p.pkt.dstAddr, s, p.lastLayer)
+	err := p.d.resolveLocalDst(p.pkt.DstAddr, s, p.lastLayer)
 	if err != nil {
 		return errorDiscard("error", err)
 	}
@@ -2117,7 +2085,7 @@ func (d *DataPlane) resolveLocalDst(
 		if a.Port() < d.dispatchedPortStart || a.Port() > d.dispatchedPortEnd {
 			updateNetAddrFromAddrAndPort(resolvedDst, a.Addr(), topology.EndhostPort)
 		} else {
-			updateNetAddrFromAddrPort(resolvedDst, a)
+			UpdateNetAddrFromAddrPort(resolvedDst, a)
 		}
 		return nil
 	case addr.HostTypeIP:
@@ -2408,16 +2376,14 @@ func (b *bfdSend) Send(bfd *layers.BFD) error {
 
 	// BfdControllers and fwQs are initialized from the same set of ifIDs. So not finding
 	// the forwarding queue is an serious internal error. Let that panic.
-	fwChan := b.dataPlane.fwQs[b.ifID]
+	fwLink := b.dataPlane.interfaces[b.ifID]
 
 	if b.ifID == 0 {
 		// Using the internal interface: must specify the destination address
-		updateNetAddrFromAddrPort(p.dstAddr, b.dstAddr)
+		UpdateNetAddrFromAddrPort(p.DstAddr, b.dstAddr)
 	}
 	// No need to specify pkt.egress. It isn't used downstream from here.
-	select {
-	case fwChan <- p:
-	default:
+	if !fwLink.Send(p) {
 		// We do not care if some BFD packets get bounced under high load. If it becomes a problem,
 		// the solution is do use BFD's demand-mode. To be considered in a future refactoring.
 		b.dataPlane.returnPacketToPool(p)
@@ -2484,8 +2450,8 @@ func (p *slowPathPacketProcessor) prepareSCMP(
 	}
 	// If the packet is sent to an external router, we need to increment the
 	// path to prepare it for the next hop.
-	_, external := p.d.external[p.pkt.ingress]
-	if external {
+	// This is an SCMP response to pkt, so egress will be pkt.ingress.
+	if p.d.interfaces[p.pkt.ingress].Scope() == External {
 		infoField := &revPath.InfoFields[revPath.PathMeta.CurrINF]
 		if infoField.ConsDir && !peering {
 			hopField := revPath.HopFields[revPath.PathMeta.CurrHF]
@@ -2611,7 +2577,7 @@ func (p *slowPathPacketProcessor) prepareSCMP(
 	}
 	p.pkt.rawPacket = serBuf.Bytes()
 
-	log.Debug("scmp", "typecode", scmpH.TypeCode)
+	log.Debug("SCMP", "typecode", scmpH.TypeCode)
 	return nil
 }
 
@@ -2737,12 +2703,9 @@ func nextHdr(layer gopacket.DecodingLayer) slayers.L4ProtocolType {
 // forwarding.
 func (d *DataPlane) initMetrics() {
 	d.forwardingMetrics = make(map[uint16]interfaceMetrics)
-	d.forwardingMetrics[0] = newInterfaceMetrics(d.Metrics, 0, d.localIA, d.neighborIAs)
-	for ifID := range d.external {
-		if _, notOwned := d.internalNextHops[ifID]; notOwned {
-			continue
-		}
-		d.forwardingMetrics[ifID] = newInterfaceMetrics(d.Metrics, ifID, d.localIA, d.neighborIAs)
+	for ifID, link := range d.interfaces {
+		d.forwardingMetrics[ifID] = newInterfaceMetrics(
+			d.Metrics, ifID, d.localIA, link.Scope(), d.neighborIAs)
 	}
 
 	// Start our custom /proc/pid/stat collector to export iowait time and (in the future) other
@@ -2759,7 +2722,7 @@ func (d *DataPlane) initMetrics() {
 // given netip.AddrPort. newDst.Addr() returns the IP by value. The compiler may or
 // may not inline the call and optimize out the copy. It is doubtful that manually inlining
 // increases the chances that the copy get elided. TODO(jiceatscion): experiment.
-func updateNetAddrFromAddrPort(netAddr *net.UDPAddr, newDst netip.AddrPort) {
+func UpdateNetAddrFromAddrPort(netAddr *net.UDPAddr, newDst netip.AddrPort) {
 	updateNetAddrFromAddrAndPort(netAddr, newDst.Addr(), newDst.Port())
 }
 
diff --git a/router/metrics.go b/router/metrics.go
index 5253bba76..d26b2aa9e 100644
--- a/router/metrics.go
+++ b/router/metrics.go
@@ -282,9 +282,10 @@ func newInterfaceMetrics(
 	metrics *Metrics,
 	id uint16,
 	localIA addr.IA,
+	scope LinkScope,
 	neighbors map[uint16]addr.IA) interfaceMetrics {
 
-	ifLabels := interfaceLabels(id, localIA, neighbors)
+	ifLabels := interfaceLabels(id, localIA, scope, neighbors)
 	m := interfaceMetrics{}
 	for sc := minSizeClass; sc < maxSizeClass; sc++ {
 		scLabels := prometheus.Labels{"sizeclass": sc.String()}
@@ -355,7 +356,9 @@ func newOutputMetrics(
 	return om
 }
 
-func interfaceLabels(id uint16, localIA addr.IA, neighbors map[uint16]addr.IA) prometheus.Labels {
+func interfaceLabels(
+	id uint16, localIA addr.IA, scope LinkScope, neighbors map[uint16]addr.IA) prometheus.Labels {
+
 	if id == 0 {
 		return prometheus.Labels{
 			"isd_as":          localIA.String(),
@@ -363,10 +366,16 @@ func interfaceLabels(id uint16, localIA addr.IA, neighbors map[uint16]addr.IA) p
 			"neighbor_isd_as": localIA.String(),
 		}
 	}
+	viaSibling := "->"
+	neighbor := "unknown"
+	if scope == External {
+		viaSibling = ""
+		neighbor = neighbors[id].String()
+	}
 	return prometheus.Labels{
 		"isd_as":          localIA.String(),
-		"interface":       strconv.FormatUint(uint64(id), 10),
-		"neighbor_isd_as": neighbors[id].String(),
+		"interface":       viaSibling + strconv.FormatUint(uint64(id), 10),
+		"neighbor_isd_as": neighbor,
 	}
 }
 
diff --git a/router/underlay.go b/router/underlay.go
new file mode 100644
index 000000000..e073a4968
--- /dev/null
+++ b/router/underlay.go
@@ -0,0 +1,118 @@
+// Copyright 2025 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.
+
+// This module defines the interfaces between the router and the underlay network implementations.
+
+package router
+
+import (
+	"net/netip"
+)
+
+// LinkScope describes the kind (or scope) of a link: internal, sibling, or external.
+type LinkScope int
+
+const (
+	Internal LinkScope = iota // to/from end-hosts in the local AS
+	Sibling                   // to/from (external interfaces owned by) a sibling router
+	External                  // to/from routers in another AS
+)
+
+// Link embodies the router's idea of a point to point connection. A link associates the underlay
+// connection with a BFDSession, a destination address, etc. It also allows the concrete send
+// operation to be delegated to different underlay implementations. The association between
+// link and underlay connection is a channel, on the sending side, and should be a demultiplexer on
+// the receiving side. The demultiplexer must have a src-addr:link map in all cases where links
+// share connections.
+//
+// Regardless of underlay, links come in three scopes: internal, sibling, and external. The
+// difference in behaviour is hidden from the rest of the router. The router only needs to
+// associate an interface ID with a link. If the interface ID belongs to a sibling router, then
+// the link is a sibling link. If the interface ID is zero, then the link is the internal link.
+type Link interface {
+	Scope() LinkScope
+	BFDSession() BFDSession
+	IsUp() bool
+	IfID() uint16
+	Remote() netip.AddrPort // TODO(multi_underlay): using code will move to underlay.
+	Send(p *Packet) bool
+	SendBlocking(p *Packet)
+}
+
+// A provider of connectivity over some underlay implementation
+//
+// For any given underlay, there are three kinds of Link implementations to choose from.
+// The difference between them is the intent regarding addressing.
+//
+// TODO(multi_underlay): addresses are still explicitly IP/port. In the next step, we have to
+// make them opaque; to be interpreted only by the underlay implementation.
+type UnderlayProvider interface {
+
+	// NewExternalLink returns a link that addresses a single remote AS at a unique underlay
+	// address. So, it is given an ifID and a underlay remote address at creation. Outgoing packets
+	// do not need an underlay destination as metadata. Incoming packets have a defined ingress
+	// ifID.
+	NewExternalLink(
+		conn BatchConn, qSize int, bfd BFDSession, remote netip.AddrPort, ifID uint16,
+	) Link
+
+	// NewSinblingLink returns a link that addresses any number of remote ASes via a single sibling
+	// router. So, it is not given an ifID at creation, but it is given a remote underlay address:
+	// that of the sibling router. Outgoing packets do not need an underlay destination as metadata.
+	// Incoming packets have no defined ingress ifID.
+	NewSiblingLink(qSize int, bfd BFDSession, remote netip.AddrPort) Link
+
+	// NewIternalLink returns a link that addresses any host internal to the enclosing AS, so it is
+	// given neither ifID nor address. Outgoing packets need to have a destination address as
+	// metadata. Incoming packets have no defined ingress ifID.
+	NewInternalLink(conn BatchConn, qSize int) Link
+
+	// Connections returns the set of configured distinct connections in the provider.
+	//
+	// TODO(multi_underlay): this exists so most of the receiving code can stay in the main
+	// dataplane code for now. There may be fewer connections than links. For example, right now
+	// all sibling links and the internal link use a shared un-bound connection.
+	Connections() map[netip.AddrPort]UnderlayConn
+
+	// Links returns the set of configured distinct links in the provider.
+	//
+	// TODO(multi_underlay): this exists so most of the receiving code can stay in-here for now.
+	// There may be fewer links than ifIDs. For example, all interfaces owned by one given sibling
+	// router are connected via the same link because the remote address is the same.
+	Links() map[netip.AddrPort]Link
+
+	// Link returns a link that matches the given source address. If the address is not that of
+	// a known link, then the internal link is returned.
+	//
+	// TODO(multi_underlay): This has to exist until incoming packets are "demuxed" (i.e.
+	// matched with a link), on ingest by the underlay. That would imply moving a part of the
+	// runReceiver routine to the underlay. We will do that in the next step.
+	Link(netip.AddrPort) Link
+}
+
+// UnderlayConn defines the minimum interface that the router expects from an underlay
+// connection.
+//
+// TODO(multi_underlay): this will eventually be reduced to nothing at all because the sender
+// receiver tasks will be part of the underlay.
+type UnderlayConn interface {
+	// Conn returns the BatchConn associated with the connection.
+	Conn() BatchConn
+	// Queue returns the channel associated with the connection.
+	Queue() <-chan *Packet
+	// Name returns the name (for logging) associated with the connection.
+	Name() string
+	// IfID returns the IfID associated with the connection.
+	IfID() uint16
+}
diff --git a/router/underlayproviders/BUILD.bazel b/router/underlayproviders/BUILD.bazel
new file mode 100644
index 000000000..1f5ab2386
--- /dev/null
+++ b/router/underlayproviders/BUILD.bazel
@@ -0,0 +1,9 @@
+load("//tools/lint:go.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["udpip.go"],
+    importpath = "github.com/scionproto/scion/router/underlayproviders",
+    visibility = ["//visibility:public"],
+    deps = ["//router:go_default_library"],
+)
diff --git a/router/underlayproviders/udpip.go b/router/underlayproviders/udpip.go
new file mode 100644
index 000000000..72e78717e
--- /dev/null
+++ b/router/underlayproviders/udpip.go
@@ -0,0 +1,326 @@
+// Copyright 2025 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 underlayproviders
+
+import (
+	"maps"
+	"net/netip"
+
+	"github.com/scionproto/scion/router"
+)
+
+// provider implements UnderlayProvider by making and returning Udp/Ip links.
+//
+// This is currently the only implementation. The goal of splitting out this code from the router
+// is to enable other implementations. However, as a first step, we continue assuming that the
+// batchConn is given to us and is a UDP socket and that, in the case of externalLink, it is bound.
+type provider struct {
+	allLinks       map[netip.AddrPort]router.Link
+	allConnections map[netip.AddrPort]*udpConnection
+}
+
+func init() {
+	// Register ourselves as an underlay provider. The registration consists of a constructor, not
+	// a provider object, because multiple router instances each must have their own underlay
+	// provider. The provider is not re-entrant.
+	router.AddUnderlay(newProvider)
+}
+
+// New instantiates a new instance of the provider for exclusive use by the caller.
+func newProvider() router.UnderlayProvider {
+	return &provider{
+		allLinks:       make(map[netip.AddrPort]router.Link),
+		allConnections: make(map[netip.AddrPort]*udpConnection),
+	}
+}
+
+func (u *provider) Connections() map[netip.AddrPort]router.UnderlayConn {
+	// A map of interfaces and a map of concrete implementations aren't compatible.
+	// For the same reason, we cannot have the map of concrete implementations as our return type;
+	// it does not satisfy the Connections() interface (so much for the "don't return
+	// interfaces" rule)... Brilliant, Go.
+	// Since we do not want to store our own things as interfaces, we have to translate.
+	// Good thing it doesn't happen often.
+	m := make(map[netip.AddrPort]router.UnderlayConn)
+	for a, c := range u.allConnections {
+		m[a] = c // Yeah that's exactly as stupid as it looks.
+	}
+	return m
+}
+
+func (u *provider) Links() map[netip.AddrPort]router.Link {
+	return maps.Clone(u.allLinks)
+}
+
+func (u *provider) Link(addr netip.AddrPort) router.Link {
+	// There is one link for every address. The internal Link catches all.
+	l, found := u.allLinks[addr]
+	if found {
+		return l
+	}
+	return u.allLinks[netip.AddrPort{}]
+}
+
+// udpConnection is simply the combination of a BatchConn and sending queue (plus metadata for
+// logs and such). This allows UDP connections to be shared between links. Bundling link and
+// connection together is possible and simpler for the code here, but leaks more refactoring changes
+// in the main router code. Specifically, either:
+//   - sibling links would each need an independent socket to the sibling router, which
+//     the router cannot provide at the moment.
+//   - the internal links and sibling links would be the same, which means the router needs to
+//     special case the sibling links: which we want to remove from the main code.
+type udpConnection struct {
+	conn  router.BatchConn
+	queue chan *router.Packet
+	ifID  uint16 // for metrics. All sibling links plus the internal link will be zero, though.
+	name  string // for logs. It's more informative than ifID.
+}
+
+// TODO(multi_underlay): The following implements UnderlayConn so some of the code
+// that needs to interact with it can stay in the main router code. This will be removed in the
+// next step
+
+func (u *udpConnection) Conn() router.BatchConn {
+	return u.conn
+}
+
+func (u *udpConnection) Queue() <-chan *router.Packet {
+	return u.queue
+}
+
+func (u *udpConnection) Name() string {
+	return u.name
+}
+
+func (u *udpConnection) IfID() uint16 {
+	return u.ifID
+}
+
+// todo(jiceatscion): use inheritance between implementations?
+
+type externalLink struct {
+	queue      chan<- *router.Packet
+	bfdSession router.BFDSession
+	ifID       uint16
+	remote     netip.AddrPort // We keep this only for Remote()
+}
+
+// NewExternalLink returns an external link over the UdpIpUnderlay.
+//
+// TODO(multi_underlay): we get the connection ready-made and require it to be bound. So, we
+// don't keep the remote address, but in the future, we will be making the connections, and
+// the conn argument will be gone.
+func (u *provider) NewExternalLink(
+	conn router.BatchConn,
+	qSize int,
+	bfd router.BFDSession,
+	remote netip.AddrPort,
+	ifID uint16,
+) router.Link {
+
+	queue := make(chan *router.Packet, qSize)
+	c := &udpConnection{
+		conn:  conn,
+		queue: queue,
+		ifID:  ifID,
+		name:  remote.String(),
+	}
+	u.allConnections[remote] = c
+	l := &externalLink{
+		queue:      queue,
+		bfdSession: bfd,
+		ifID:       ifID,
+	}
+	u.allLinks[remote] = l
+	return l
+}
+
+func (l *externalLink) Scope() router.LinkScope {
+	return router.External
+}
+
+func (l *externalLink) BFDSession() router.BFDSession {
+	return l.bfdSession
+}
+
+func (l *externalLink) IsUp() bool {
+	return l.bfdSession == nil || l.bfdSession.IsUp()
+}
+
+func (l *externalLink) IfID() uint16 {
+	return l.ifID
+}
+
+func (l *externalLink) Remote() netip.AddrPort {
+	return l.remote
+}
+
+func (l *externalLink) Send(p *router.Packet) bool {
+	select {
+	case l.queue <- p:
+	default:
+		return false
+	}
+	return true
+}
+
+func (l *externalLink) SendBlocking(p *router.Packet) {
+	l.queue <- p
+}
+
+type siblingLink struct {
+	queue      chan<- *router.Packet
+	bfdSession router.BFDSession
+	remote     netip.AddrPort
+}
+
+// newSiblingLink returns a sibling link over the UdpIpUnderlay.
+//
+// TODO(multi_underlay): this can only be an improvement over internalLink if we have a bound
+// batchConn with the sibling router. However, currently the caller doesn't have one to give us;
+// the main code has so far been reusing the internal connection. So, that's what we do for now.
+// As a result, we keep the remote address; as we need to supply it for every packet being sent
+// (something we will get rid of eventually).
+// In the future we will be making one connection per remote address and we might even be able
+// to erase the separation between link and connection for this implementation. Side effect
+// of moving the address:link map here: the router does not know if there is an existing link. As
+// a result it has to give us a BFDSession in all cases and we might throw it away (there
+// are no permanent resources attached to it). This will be fixed by moving some BFD related code
+// in-here.
+func (u *provider) NewSiblingLink(
+	qSize int, bfd router.BFDSession, remote netip.AddrPort) router.Link {
+
+	// There is exactly one sibling link per sibling router address.
+	l, exists := u.allLinks[remote]
+	if exists {
+		return l.(*siblingLink)
+	}
+
+	// All sibling links re-use the internal connection. This used to be a late binding (packets to
+	// siblings would get routed through the internal interface at run-time). But now this binding
+	// happens right now and it can't work if this is called before newInternalLink.
+	c, exists := u.allConnections[netip.AddrPort{}]
+	if !exists {
+		// TODO(multi_underlay):That doesn't actually happen.
+		// It is only required until we stop sharing the internal connection.
+		panic("newSiblingLink called before newInternalLink")
+	}
+
+	s := &siblingLink{
+		queue:      c.queue,
+		bfdSession: bfd,
+		remote:     remote,
+	}
+	u.allLinks[remote] = s
+	return s
+}
+
+func (l *siblingLink) Scope() router.LinkScope {
+	return router.Sibling
+}
+
+func (l *siblingLink) BFDSession() router.BFDSession {
+	return l.bfdSession
+}
+
+func (l *siblingLink) IsUp() bool {
+	return l.bfdSession == nil || l.bfdSession.IsUp()
+}
+
+func (l *siblingLink) IfID() uint16 {
+	return 0
+}
+
+func (l *siblingLink) Remote() netip.AddrPort {
+	return l.remote
+}
+
+func (l *siblingLink) Send(p *router.Packet) bool {
+	// We use an unbound connection but we offer a connection-oriented service. So, we need to
+	// supply the packet's destination address.
+	router.UpdateNetAddrFromAddrPort(p.DstAddr, l.remote)
+	select {
+	case l.queue <- p:
+	default:
+		return false
+	}
+	return true
+}
+
+func (l *siblingLink) SendBlocking(p *router.Packet) {
+	// We use an unbound connection but we offer a connection-oriented service. So, we need to
+	// supply the packet's destination address.
+	router.UpdateNetAddrFromAddrPort(p.DstAddr, l.remote)
+	l.queue <- p
+}
+
+type internalLink struct {
+	queue chan *router.Packet
+}
+
+// newSiblingLink returns a sibling link over the UdpIpUnderlay.
+//
+// TODO(multi_underlay): we get the connection ready made. In the future we will be making it
+// and the conn argument will be gone.
+func (u *provider) NewInternalLink(conn router.BatchConn, qSize int) router.Link {
+	queue := make(chan *router.Packet, qSize)
+	c := &udpConnection{
+		conn:  conn,
+		queue: queue,
+		name:  "internal",
+		ifID:  0,
+	}
+	u.allConnections[netip.AddrPort{}] = c
+	l := &internalLink{
+		queue: queue,
+	}
+	u.allLinks[netip.AddrPort{}] = l
+	return l
+}
+
+func (l *internalLink) Scope() router.LinkScope {
+	return router.Internal
+}
+
+func (l *internalLink) IsUp() bool {
+	return true
+}
+
+func (l *internalLink) BFDSession() router.BFDSession {
+	return nil
+}
+
+func (l *internalLink) IfID() uint16 {
+	return 0
+}
+
+func (l *internalLink) Remote() netip.AddrPort {
+	return netip.AddrPort{}
+}
+
+// The packet's destination is already in the packet's meta-data.
+func (l *internalLink) Send(p *router.Packet) bool {
+	select {
+	case l.queue <- p:
+	default:
+		return false
+	}
+	return true
+}
+
+// The packet's destination is already in the packet's meta-data.
+func (l *internalLink) SendBlocking(p *router.Packet) {
+	l.queue <- p
+}


Test patch:
diff --git a/router/dataplane_internal_test.go b/router/dataplane_internal_test.go
index a560e18b2..3ce3eead4 100644
--- a/router/dataplane_internal_test.go
+++ b/router/dataplane_internal_test.go
@@ -53,7 +53,15 @@ var (
 func TestReceiver(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
-	dp := &DataPlane{Metrics: metrics}
+	dp := &DataPlane{
+		underlay:   newUnderlay(),
+		interfaces: make(map[uint16]Link),
+		Metrics:    metrics,
+		RunConfig: RunConfig{
+			NumProcessors: 1,
+			BatchSize:     64,
+		},
+	}
 	counter := 0
 	mInternal := mock_router.NewMockBatchConn(ctrl)
 	done := make(chan bool)
@@ -80,17 +88,13 @@ func TestReceiver(t *testing.T) {
 
 	_ = dp.AddInternalInterface(mInternal, netip.Addr{})
 
-	runConfig := &RunConfig{
-		NumProcessors: 1,
-		BatchSize:     64,
-	}
-	dp.initPacketPool(runConfig, 64)
-	procCh, _, _ := initQueues(runConfig, dp.interfaces, 64)
+	dp.initPacketPool(64)
+	procCh, _ := dp.initQueues(64)
 	initialPoolSize := len(dp.packetPool)
 	dp.setRunning()
 	dp.initMetrics()
 	go func() {
-		dp.runReceiver(0, dp.interfaces[0], runConfig, procCh)
+		dp.runReceiver(dp.underlay.Connections()[netip.AddrPort{}], procCh)
 	}()
 	ptrMap := make(map[uintptr]struct{})
 	for i := 0; i < 21; i++ {
@@ -116,7 +120,7 @@ func TestReceiver(t *testing.T) {
 	}
 	<-done
 	// make sure that the packet pool has the expected size after the test
-	assert.Equal(t, initialPoolSize-runConfig.BatchSize-20, len(dp.packetPool))
+	assert.Equal(t, initialPoolSize-dp.RunConfig.BatchSize-20, len(dp.packetPool))
 }
 
 // TestForwarder sets up a mocked batchConn, starts the forwarder that will write to
@@ -128,7 +132,15 @@ func TestForwarder(t *testing.T) {
 	defer ctrl.Finish()
 	done := make(chan struct{})
 	prepareDP := func(ctrl *gomock.Controller) *DataPlane {
-		ret := &DataPlane{Metrics: metrics}
+		ret := &DataPlane{
+			underlay: newUnderlay(),
+			Metrics:  metrics,
+			RunConfig: RunConfig{
+				NumProcessors:         20,
+				BatchSize:             64,
+				NumSlowPathProcessors: 1,
+			},
+		}
 
 		mInternal := mock_router.NewMockBatchConn(ctrl)
 		totalCount := 0
@@ -174,16 +186,14 @@ func TestForwarder(t *testing.T) {
 		return ret
 	}
 	dp := prepareDP(ctrl)
-	runConfig := &RunConfig{
-		NumProcessors: 20,
-		BatchSize:     64,
-	}
-	dp.initPacketPool(runConfig, 64)
-	_, fwCh, _ := initQueues(runConfig, dp.interfaces, 64)
+	dp.initPacketPool(64)
+	dp.initQueues(64)
+	conn := dp.underlay.Connections()[netip.AddrPort{}]
+	intf := dp.interfaces[0]
 	initialPoolSize := len(dp.packetPool)
 	dp.setRunning()
 	dp.initMetrics()
-	go dp.runForwarder(0, dp.interfaces[0], runConfig, fwCh[0])
+	go dp.runForwarder(conn)
 
 	dstAddr := &net.UDPAddr{IP: net.IP{10, 0, 200, 200}}
 	for i := 0; i < 255; i++ {
@@ -192,19 +202,18 @@ func TestForwarder(t *testing.T) {
 		pkt.rawPacket = pkt.rawPacket[:1]
 		pkt.rawPacket[0] = byte(i)
 		if i < 100 {
-			pkt.dstAddr.IP = pkt.dstAddr.IP[:4]
-			copy(pkt.dstAddr.IP, dstAddr.IP)
+			pkt.DstAddr.IP = pkt.DstAddr.IP[:4]
+			copy(pkt.DstAddr.IP, dstAddr.IP)
 		}
 		pkt.srcAddr = &net.UDPAddr{} // Receiver always sets this.
 		pkt.ingress = 0
 
 		assert.NotEqual(t, initialPoolSize, len(dp.packetPool))
 
-		select {
-		case fwCh[0] <- pkt:
-		case <-done:
-		}
-
+		// Normal use would be
+		// intf.Send(pkt):
+		// However we want to exclude queue overflow from the test. So we want a blocking send.
+		intf.SendBlocking(pkt)
 	}
 	select {
 	case <-done:
@@ -433,7 +442,7 @@ func TestSlowPathProcessing(t *testing.T) {
 	// ProcessPacket assumes some pre-conditions:
 	// * The ingress interface has to exist. This fake map is good for the test cases we have.
 	// * InternalNextHops may not be nil. Empty is ok for all the test cases we have.
-	fakeExternalInterfaces := map[uint16]BatchConn{1: nil}
+	fakeExternalInterfaces := []uint16{1}
 	fakeInternalNextHops := map[uint16]netip.AddrPort{}
 	fakeServices := map[addr.SVC][]netip.AddrPort{}
 
@@ -620,7 +629,7 @@ func TestSlowPathProcessing(t *testing.T) {
 			dp.initMetrics()
 
 			rp := tc.mockMsg()
-			pkt := packet{}
+			pkt := Packet{}
 			pkt.init(&[bufSize]byte{})
 			pkt.reset()
 			pkt.ingress = tc.srcInterface
diff --git a/router/dataplane_test.go b/router/dataplane_test.go
index 942a39a3b..404e0de64 100644
--- a/router/dataplane_test.go
+++ b/router/dataplane_test.go
@@ -47,6 +47,7 @@ import (
 	"github.com/scionproto/scion/router"
 	"github.com/scionproto/scion/router/control"
 	"github.com/scionproto/scion/router/mock_router"
+	_ "github.com/scionproto/scion/router/underlayproviders"
 )
 
 var (
@@ -200,29 +201,45 @@ func TestDataPlaneAddNextHop(t *testing.T) {
 	l := netip.AddrPortFrom(netip.IPv4Unspecified(), 0)
 	r := netip.AddrPortFrom(netip.IPv4Unspecified(), 0)
 	nilAddrPort := netip.AddrPort{}
+	nilAddr := netip.Addr{}
 
 	nobfd := control.BFD{Disable: ptr.To(true)}
 
 	t.Run("fails after serve", func(t *testing.T) {
 		d := &router.DataPlane{}
+		ctrl := gomock.NewController(t)
+		defer ctrl.Finish()
+		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), nilAddr))
 		d.FakeStart()
 		assert.Error(t, d.AddNextHop(45, l, r, nobfd, ""))
 	})
 	t.Run("setting nil dst is not allowed", func(t *testing.T) {
 		d := &router.DataPlane{}
+		ctrl := gomock.NewController(t)
+		defer ctrl.Finish()
+		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), nilAddr))
 		assert.Error(t, d.AddNextHop(45, l, nilAddrPort, nobfd, ""))
 	})
 	t.Run("setting nil src is not allowed", func(t *testing.T) {
 		d := &router.DataPlane{}
+		ctrl := gomock.NewController(t)
+		defer ctrl.Finish()
+		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), nilAddr))
 		assert.Error(t, d.AddNextHop(45, nilAddrPort, r, nobfd, ""))
 	})
 	t.Run("normal add works", func(t *testing.T) {
 		d := &router.DataPlane{}
+		ctrl := gomock.NewController(t)
+		defer ctrl.Finish()
+		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), nilAddr))
 		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{}
+		ctrl := gomock.NewController(t)
+		defer ctrl.Finish()
+		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), nilAddr))
 		assert.NoError(t, d.AddNextHop(45, l, r, nobfd, ""))
 		assert.Error(t, d.AddNextHop(45, l, r, nobfd, ""))
 	})
@@ -238,7 +255,14 @@ func TestDataPlaneRun(t *testing.T) {
 	}{
 		"route 10 msg from external to internal": {
 			prepareDP: func(ctrl *gomock.Controller, done chan<- struct{}) *router.DataPlane {
-				ret := &router.DataPlane{Metrics: metrics}
+				ret := &router.DataPlane{
+					Metrics: metrics,
+					RunConfig: router.RunConfig{
+						NumProcessors:         8,
+						BatchSize:             256,
+						NumSlowPathProcessors: 1,
+					},
+				}
 				key := []byte("testkey_xxxxxxxx")
 				local := addr.MustParseIA("1-ff00:0:110")
 
@@ -326,7 +350,14 @@ func TestDataPlaneRun(t *testing.T) {
 		},
 		"bfd bootstrap internal session": {
 			prepareDP: func(ctrl *gomock.Controller, done chan<- struct{}) *router.DataPlane {
-				ret := &router.DataPlane{Metrics: metrics}
+				ret := &router.DataPlane{
+					Metrics: metrics,
+					RunConfig: router.RunConfig{
+						NumProcessors:         8,
+						BatchSize:             256,
+						NumSlowPathProcessors: 1,
+					},
+				}
 
 				postInternalBFD := func(id layers.BFDDiscriminator, src netip.AddrPort) []byte {
 					scn := &slayers.SCION{
@@ -405,7 +436,14 @@ func TestDataPlaneRun(t *testing.T) {
 		},
 		"bfd sender internal": {
 			prepareDP: func(ctrl *gomock.Controller, done chan<- struct{}) *router.DataPlane {
-				ret := &router.DataPlane{Metrics: metrics}
+				ret := &router.DataPlane{
+					Metrics: metrics,
+					RunConfig: router.RunConfig{
+						NumProcessors:         8,
+						BatchSize:             256,
+						NumSlowPathProcessors: 1,
+					},
+				}
 				localAddr := netip.MustParseAddrPort("10.0.200.100:0")
 				remoteAddr := netip.MustParseAddrPort("10.0.200.200:0")
 				mInternal := mock_router.NewMockBatchConn(ctrl)
@@ -456,7 +494,15 @@ func TestDataPlaneRun(t *testing.T) {
 		},
 		"bfd sender external": {
 			prepareDP: func(ctrl *gomock.Controller, done chan<- struct{}) *router.DataPlane {
-				ret := &router.DataPlane{Metrics: metrics}
+				ret := &router.DataPlane{
+					Metrics: metrics,
+					RunConfig: router.RunConfig{
+						NumProcessors:         8,
+						BatchSize:             256,
+						NumSlowPathProcessors: 1,
+					},
+				}
+
 				ifID := uint16(1)
 				mInternal := mock_router.NewMockBatchConn(ctrl)
 				mInternal.EXPECT().ReadBatch(gomock.Any()).Return(0, nil).AnyTimes()
@@ -508,7 +554,14 @@ func TestDataPlaneRun(t *testing.T) {
 		},
 		"bfd bootstrap external session": {
 			prepareDP: func(ctrl *gomock.Controller, done chan<- struct{}) *router.DataPlane {
-				ret := &router.DataPlane{Metrics: metrics}
+				ret := &router.DataPlane{
+					Metrics: metrics,
+					RunConfig: router.RunConfig{
+						NumProcessors:         8,
+						BatchSize:             256,
+						NumSlowPathProcessors: 1,
+					},
+				}
 
 				postExternalBFD := func(id layers.BFDDiscriminator, fromIfID uint16) []byte {
 					scn := &slayers.SCION{
@@ -589,18 +642,13 @@ func TestDataPlaneRun(t *testing.T) {
 		name, tc := name, tc
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
-			runConfig := &router.RunConfig{
-				NumProcessors:         8,
-				BatchSize:             256,
-				NumSlowPathProcessors: 1,
-			}
 			ch := make(chan struct{})
 			dp := tc.prepareDP(ctrl, ch)
 			errors := make(chan error)
 			ctx, cancelF := context.WithCancel(context.Background())
 			defer cancelF()
 			go func() {
-				errors <- dp.Run(ctx, runConfig)
+				errors <- dp.Run(ctx)
 			}()
 
 			for done := false; !done; {
@@ -643,7 +691,7 @@ func TestProcessPkt(t *testing.T) {
 	// * The ingress interface has to exist. This fake map is good for most test cases.
 	//   Others need a custom one.
 	// * InternalNextHops may not be nil. Empty is ok (sufficient unless testing AS transit).
-	fakeExternalInterfaces := map[uint16]router.BatchConn{1: nil, 2: nil, 3: nil}
+	fakeExternalInterfaces := []uint16{1, 2, 3}
 	fakeInternalNextHops := map[uint16]netip.AddrPort{}
 
 	testCases := map[string]struct {
@@ -725,9 +773,7 @@ func TestProcessPkt(t *testing.T) {
 		"outbound": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(1): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{1},
 					map[uint16]topology.LinkType{
 						1: topology.Child,
 					},
@@ -759,10 +805,7 @@ func TestProcessPkt(t *testing.T) {
 		"brtransit": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(1): mock_router.NewMockBatchConn(ctrl),
-						uint16(2): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{1, 2},
 					map[uint16]topology.LinkType{
 						1: topology.Parent,
 						2: topology.Child,
@@ -794,10 +837,7 @@ func TestProcessPkt(t *testing.T) {
 		"brtransit non consdir": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(1): mock_router.NewMockBatchConn(ctrl),
-						uint16(2): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{1, 2},
 					map[uint16]topology.LinkType{
 						2: topology.Parent,
 						1: topology.Child,
@@ -830,10 +870,7 @@ func TestProcessPkt(t *testing.T) {
 		"brtransit peering consdir": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(1): mock_router.NewMockBatchConn(ctrl),
-						uint16(2): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{1, 2},
 					map[uint16]topology.LinkType{
 						1: topology.Peer,
 						2: topology.Child,
@@ -900,10 +937,7 @@ func TestProcessPkt(t *testing.T) {
 		"brtransit peering non consdir": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(1): mock_router.NewMockBatchConn(ctrl),
-						uint16(2): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{1, 2},
 					map[uint16]topology.LinkType{
 						1: topology.Peer,
 						2: topology.Child,
@@ -977,10 +1011,7 @@ func TestProcessPkt(t *testing.T) {
 			// happens on the next hop.
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(1): mock_router.NewMockBatchConn(ctrl),
-						uint16(2): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{1, 2},
 					map[uint16]topology.LinkType{
 						1: topology.Peer,
 						2: topology.Child,
@@ -1051,10 +1082,7 @@ func TestProcessPkt(t *testing.T) {
 		"peering non consdir upstream": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(1): mock_router.NewMockBatchConn(ctrl),
-						uint16(2): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{1, 2},
 					map[uint16]topology.LinkType{
 						1: topology.Peer,
 						2: topology.Child,
@@ -1133,11 +1161,7 @@ func TestProcessPkt(t *testing.T) {
 		"astransit direct": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(1): mock_router.NewMockBatchConn(ctrl),
-						// Interface 3 isn't in the external interfaces of this router
-						// another router has it.
-					},
+					[]uint16{1}, // Interface 3 is in the external interfaces of a sibling router
 					map[uint16]topology.LinkType{
 						1: topology.Core,
 						3: topology.Core,
@@ -1157,9 +1181,11 @@ func TestProcessPkt(t *testing.T) {
 				dpath.HopFields[1].Mac = computeMAC(t, key, dpath.InfoFields[0], dpath.HopFields[1])
 				var dstAddr *net.UDPAddr
 				ingress := uint16(1)
-				egress := uint16(0) // Internal forward to the egress router
+				egress := uint16(0) // To make sure it gets updated.
 				if afterProcessing {
-					dstAddr = &net.UDPAddr{IP: net.ParseIP("10.0.200.200").To4(), Port: 30043}
+					egress = uint16(3) // The sibling router is locally mapped to the egress ifID.
+					// The link is specific to the sibling. It has the address. So we don't expect:
+					// dstAddr = &net.UDPAddr{IP: net.ParseIP("10.0.200.200").To4(), Port: 30043}
 				}
 				return router.NewPacket(toBytes(t, spkt, dpath), nil, dstAddr, ingress, egress)
 			},
@@ -1168,9 +1194,7 @@ func TestProcessPkt(t *testing.T) {
 		"astransit xover": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(51): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{51},
 					map[uint16]topology.LinkType{
 						51: topology.Child,
 						3:  topology.Core,
@@ -1210,11 +1234,13 @@ func TestProcessPkt(t *testing.T) {
 
 				var dstAddr *net.UDPAddr
 				ingress := uint16(51) // == consEgress, bc non-consdir
-				egress := uint16(0)   // Cross-over. The egress happens in the next segment.
+				egress := uint16(0)   // To check that it is updated
 				if afterProcessing {
 					dpath.PathMeta.CurrHF++
 					dpath.PathMeta.CurrINF++
-					dstAddr = &net.UDPAddr{IP: net.ParseIP("10.0.200.200").To4(), Port: 30043}
+					egress = uint16(3) // Internal hop => egress points at sibling router.
+					// The link is specific to the sibling. It has the address. So we don't expect:
+					// dstAddr = &net.UDPAddr{IP: net.ParseIP("10.0.200.200").To4(), Port: 30043}
 				} else {
 					dpath.InfoFields[0].UpdateSegID(dpath.HopFields[1].Mac)
 				}
@@ -1365,9 +1391,7 @@ func TestProcessPkt(t *testing.T) {
 		"reversed onehop outbound": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(1): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{1},
 					nil,
 					mock_router.NewMockBatchConn(ctrl),
 					fakeInternalNextHops,
@@ -1425,9 +1449,7 @@ func TestProcessPkt(t *testing.T) {
 		"onehop outbound": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
 				return router.NewDP(
-					map[uint16]router.BatchConn{
-						uint16(2): mock_router.NewMockBatchConn(ctrl),
-					},
+					[]uint16{2},
 					nil,
 					mock_router.NewMockBatchConn(ctrl),
 					fakeInternalNextHops, nil,
diff --git a/router/export_test.go b/router/export_test.go
index 1c87bcce8..c67f8effb 100644
--- a/router/export_test.go
+++ b/router/export_test.go
@@ -36,37 +36,31 @@ func GetMetrics() *Metrics {
 
 var NewServices = newServices
 
-// Export the Packet struct so dataplane test can call ProcessPkt
-type Packet struct {
-	packet
-}
-
 type Disposition disposition
 
 const PDiscard = Disposition(pDiscard)
 
 func NewPacket(raw []byte, src, dst *net.UDPAddr, ingress, egress uint16) *Packet {
 	p := Packet{
-		packet: packet{
-			dstAddr:   &net.UDPAddr{IP: make(net.IP, 0, net.IPv6len)},
-			srcAddr:   &net.UDPAddr{IP: make(net.IP, 0, net.IPv6len)},
-			rawPacket: make([]byte, len(raw)),
-			ingress:   ingress,
-			egress:    egress,
-		},
+		DstAddr:   &net.UDPAddr{IP: make(net.IP, 0, net.IPv6len)},
+		srcAddr:   &net.UDPAddr{IP: make(net.IP, 0, net.IPv6len)},
+		rawPacket: make([]byte, len(raw)),
+		ingress:   ingress,
+		egress:    egress,
 	}
+
 	if src != nil {
 		p.srcAddr = src
 	}
 	if dst != nil {
-		p.dstAddr = dst
+		p.DstAddr = dst
 	}
 	copy(p.rawPacket, raw)
 	return &p
 }
 
 func NewDP(
-	external map[uint16]BatchConn,
+	external []uint16,
 	linkTypes map[uint16]topology.LinkType,
 	internal BatchConn,
 	internalNextHops map[uint16]netip.AddrPort,
@@ -76,12 +70,11 @@ func NewDP(
 	key []byte) *DataPlane {
 
 	dp := &DataPlane{
-		interfaces:          map[uint16]BatchConn{0: internal},
+		underlay:            newUnderlay(),
+		interfaces:          make(map[uint16]Link),
 		localIA:             local,
-		external:            external,
 		linkTypes:           linkTypes,
 		neighborIAs:         neighbors,
-		internalNextHops:    internalNextHops,
 		dispatchedPortStart: uint16(dispatchedPortStart),
 		dispatchedPortEnd:   uint16(dispatchedPortEnd),
 		svc:                 &services{m: svc},
@@ -89,6 +82,19 @@ func NewDP(
 		Metrics:             metrics,
 	}
 
+	dp.interfaces[0] = dp.underlay.NewInternalLink(internal, 64)
+
+	// Make dummy external interfaces, as requested by the test. They are not actually used to send
+	// or receive. The blank address might cause issues, though.
+	for _, i := range external {
+		dp.interfaces[i] = dp.underlay.NewExternalLink(nil, 64, nil, netip.AddrPort{}, i)
+	}
+
+	// Make dummy sibling interfaces, as requestes by the test.
+	for i, addr := range internalNextHops {
+		dp.interfaces[i] = dp.underlay.NewSiblingLink(64, nil, addr)
+	}
+
 	if err := dp.SetKey(key); err != nil {
 		panic(err)
 	}
@@ -103,7 +109,7 @@ func (d *DataPlane) FakeStart() {
 func (d *DataPlane) ProcessPkt(pkt *Packet) Disposition {
 
 	p := newPacketProcessor(d)
-	disp := p.processPkt(&(pkt.packet))
+	disp := p.processPkt(pkt)
 	// Erase trafficType; we don't set it in the expected results.
 	pkt.trafficType = ttOther
 	return Disposition(disp)