Repository: scionproto/scion
Pull request #4697 (2025-02-08 18:46:42)

router: refactor phase 2
* Moved the sender and receiver tasks to the underlay. Metrics and a a few other things had to change a bit as a result.
* Got sick of maintaining the fiction that a zero-valued Dataplane makes sense. so created constructors and remove the bug-prone and merit-less lazy initializations. While at it, unexported the dataPlane struct altogether so there cannot be any ctor bypassing in the future.
* Moved the udpip provider to its own subdirectory.
* Tests adjusted as needed.

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/4697)
<!-- Reviewable:end -->


Patch:
diff --git a/router/BUILD.bazel b/router/BUILD.bazel
index ae14a8073..a7f08c725 100644
--- a/router/BUILD.bazel
+++ b/router/BUILD.bazel
@@ -6,7 +6,6 @@ go_library(
         "connector.go",
         "dataplane.go",
         "doc.go",
-        "fnv1aCheap.go",
         "metrics.go",
         "serialize_proxy.go",
         "svc.go",
@@ -32,6 +31,7 @@ go_library(
         "//pkg/slayers/path/scion:go_default_library",
         "//pkg/spao:go_default_library",
         "//private/drkey/drkeyutil:go_default_library",
+        "//private/env:go_default_library",
         "//private/topology:go_default_library",
         "//private/underlay/conn:go_default_library",
         "//router/bfd:go_default_library",
@@ -51,13 +51,13 @@ go_test(
         "dataplane_test.go",
         "export_test.go",
         "svc_test.go",
+        "underlay_import_test.go",
     ],
     embed = [":go_default_library"],
     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/scrypto:go_default_library",
         "//pkg/slayers:go_default_library",
@@ -70,7 +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",
+        "//router/underlayproviders/udpip: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/cmd/router/BUILD.bazel b/router/cmd/router/BUILD.bazel
index 20b4f28c4..0623d0c80 100644
--- a/router/cmd/router/BUILD.bazel
+++ b/router/cmd/router/BUILD.bazel
@@ -17,7 +17,7 @@ go_library(
         "//router/config:go_default_library",
         "//router/control:go_default_library",
         "//router/mgmtapi:go_default_library",
-        "//router/underlayproviders:go_default_library",
+        "//router/underlayproviders/udpip: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 fb599391b..acd3df1a7 100644
--- a/router/cmd/router/main.go
+++ b/router/cmd/router/main.go
@@ -1,4 +1,5 @@
 // Copyright 2020 Anapaya Systems
+// 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.
@@ -37,7 +38,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"
+	_ "github.com/scionproto/scion/router/underlayproviders/udpip"
 )
 
 var globalCfg config.Config
@@ -59,24 +60,7 @@ func realMain(ctx context.Context) error {
 		return err
 	}
 	g, errCtx := errgroup.WithContext(ctx)
-	metrics := router.NewMetrics()
-
-	dp := &router.Connector{
-		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,
-		BFD:                 globalCfg.Router.BFD,
-		DispatchedPortStart: globalCfg.Router.DispatchedPortStart,
-		DispatchedPortEnd:   globalCfg.Router.DispatchedPortEnd,
-	}
+	dp := router.NewConnector(globalCfg.Router, globalCfg.Features)
 	iaCtx := &control.IACtx{
 		Config: controlConfig,
 		DP:     dp,
diff --git a/router/connector.go b/router/connector.go
index 8ae078877..b11490a9a 100644
--- a/router/connector.go
+++ b/router/connector.go
@@ -22,15 +22,16 @@ import (
 	"github.com/scionproto/scion/pkg/log"
 	"github.com/scionproto/scion/pkg/private/serrors"
 	"github.com/scionproto/scion/pkg/segment/iface"
+	"github.com/scionproto/scion/private/env"
 	"github.com/scionproto/scion/private/underlay/conn"
 	"github.com/scionproto/scion/router/config"
 	"github.com/scionproto/scion/router/control"
 )
 
 // Connector implements the Dataplane interface used by the router control API. It sets
-// up connections for the DataPlane.
+// up connections for the data plane.
 type Connector struct {
-	DataPlane DataPlane
+	DataPlane dataPlane
 
 	ia                 addr.IA
 	mtx                sync.Mutex
@@ -47,6 +48,26 @@ type Connector struct {
 
 var errMultiIA = serrors.New("different IA not allowed")
 
+// NewConnector returns a new connector: a data plane decorated with
+// a configuration interface.
+func NewConnector(config config.RouterConfig, features env.Features) *Connector {
+	return &Connector{
+		DataPlane: makeDataPlane(
+			RunConfig{
+				NumProcessors:         config.NumProcessors,
+				NumSlowPathProcessors: config.NumSlowPathProcessors,
+				BatchSize:             config.BatchSize,
+			},
+			features.ExperimentalSCMPAuthentication,
+		),
+		ReceiveBufferSize:   config.ReceiveBufferSize,
+		SendBufferSize:      config.SendBufferSize,
+		BFD:                 config.BFD,
+		DispatchedPortStart: config.DispatchedPortStart,
+		DispatchedPortEnd:   config.DispatchedPortEnd,
+	}
+}
+
 // CreateIACtx creates the context for ISD-AS.
 func (c *Connector) CreateIACtx(ia addr.IA) error {
 	c.mtx.Lock()
diff --git a/router/dataplane.go b/router/dataplane.go
index 93f44522c..3fe15130b 100644
--- a/router/dataplane.go
+++ b/router/dataplane.go
@@ -1,6 +1,6 @@
 // Copyright 2020 Anapaya Systems
 // Copyright 2023 ETH Zurich
-// Copyright 2024 SCION Association
+// 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.
@@ -18,7 +18,6 @@ package router
 
 import (
 	"context"
-	"crypto/rand"
 	"crypto/subtle"
 	"encoding/binary"
 	"errors"
@@ -62,6 +61,8 @@ const (
 	// theory, PayloadLen in SCION header is 16 bits long, supporting a maximum
 	// payload size of 64KB. At the moment we are limited by Ethernet size
 	// usually ~1500B, but 9000B to support jumbo frames.
+	// TODO(multi_underlay): The buffer size should be a function of the collection of
+	// underlays (the largest frame size of all the enabled ones).
 	bufSize = 9000
 
 	// hopFieldDefaultExpTime is the default validity of the hop field
@@ -80,6 +81,7 @@ const (
 type BFDSession interface {
 	Run(ctx context.Context) error
 	ReceiveMessage(*layers.BFD)
+	Close() error
 	IsUp() bool
 }
 
@@ -95,9 +97,9 @@ type BatchConn interface {
 // 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
+var newUnderlay func(int) UnderlayProvider
 
-func AddUnderlay(newProvider func() UnderlayProvider) {
+func AddUnderlay(newProvider func(int) UnderlayProvider) {
 	newUnderlay = newProvider
 }
 
@@ -123,20 +125,20 @@ const (
 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
+	RawPacket []byte
 	// The entire packet buffer. We don't need it as a slice; we know its size.
 	buffer *[bufSize]byte
 	// The source address. Will be set by the receiver from smsg.Addr. We could update it in-place,
 	// but the IP address bytes in it are allocated by readbatch, so if we copy into a recyclable
 	// location, it's the original we throw away. No gain (may be a tiny bit?).
-	srcAddr *net.UDPAddr
+	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
 	// 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.
-	ingress uint16
+	Ingress uint16
 	// The egress on which this packet must leave. This is set by the processing routine.
 	egress uint16
 	// The type of traffic. This is used for metrics at the forwarding stage, but is most
@@ -162,18 +164,18 @@ 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 {
 	p.buffer = buffer
-	p.rawPacket = p.buffer[:]
+	p.RawPacket = p.buffer[:]
 	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() {
+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
+		RawPacket: p.buffer[:], // restore the full packet capacity
 		DstAddr:   p.DstAddr,   // keep the dstAddr and so the IP slice and bytes
 	}
 	// Everything else is reset to zero value.
@@ -182,7 +184,7 @@ func (p *Packet) reset() {
 // 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).
-type DataPlane struct {
+type dataPlane struct {
 	underlay            UnderlayProvider
 	interfaces          map[uint16]Link
 	linkTypes           map[uint16]topology.LinkType
@@ -194,7 +196,7 @@ type DataPlane struct {
 	mtx                 sync.Mutex
 	running             atomic.Bool
 	Metrics             *Metrics
-	forwardingMetrics   map[uint16]interfaceMetrics
+	forwardingMetrics   map[uint16]InterfaceMetrics
 	dispatchedPortStart uint16
 	dispatchedPortEnd   uint16
 
@@ -231,7 +233,6 @@ var (
 	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")
-	errShortPacket                = errors.New("Packet is too short")
 	errBFDSessionDown             = errors.New("bfd session down")
 	expiredHop                    = errors.New("expired hop")
 	ingressInterfaceInvalid       = errors.New("ingress interface invalid")
@@ -241,6 +242,8 @@ var (
 	// zeroBuffer will be used to reset the Authenticator option in the
 	// scionPacketProcessor.OptAuth
 	zeroBuffer = make([]byte, 16)
+
+	metrics = NewMetrics() // There can be only one currently.
 )
 
 type drkeyProvider interface {
@@ -253,39 +256,68 @@ type drkeyProvider interface {
 	) (drkey.ASHostKey, error)
 }
 
+// newDataPlane returns a zero-valued data plane structure. The difference between
+// that and &dataPlane{} is that there are no nil pointers (i.e. maps are empty but exist and some
+// key objects like the underlay provider have been created) except for such things that cannot be
+// initialized at the beginning (i.e. packet pool and macFactory). Do not use a true zero valued
+// struct for anything. Support for lazy initialization has been removed. It was much too
+// bug-friendly.
+func newDataPlane(runConfig RunConfig, authSCMP bool) *dataPlane {
+	x := makeDataPlane(runConfig, authSCMP)
+	return &x
+}
+
+// makeDataPlane returns a zero-valued data plane structure. This is the same as newDataPlane
+// but returns by value to facilitate the initialization of composed structs without an temporary
+// copy.
+func makeDataPlane(runConfig RunConfig, authSCMP bool) dataPlane {
+	return dataPlane{
+		underlay:                       newUnderlay(runConfig.BatchSize),
+		interfaces:                     make(map[uint16]Link),
+		linkTypes:                      make(map[uint16]topology.LinkType),
+		neighborIAs:                    make(map[uint16]addr.IA),
+		svc:                            newServices(),
+		Metrics:                        metrics,
+		forwardingMetrics:              make(map[uint16]InterfaceMetrics),
+		ExperimentalSCMPAuthentication: authSCMP,
+		RunConfig:                      runConfig,
+	}
+}
+
 // setRunning() Configures the running state of the data plane to true. setRunning() is called once
 // the dataplane is finished initializing and is ready to process packets.
-func (d *DataPlane) setRunning() {
+func (d *dataPlane) setRunning() {
 	d.running.Store(true)
 }
 
 // setStopping() Configures the running state of the data plane to false. This should not be called
 // during the dataplane initialization. Calling this before initialization starts has no effect.
-func (d *DataPlane) setStopping() {
+func (d *dataPlane) setStopping() {
 	d.running.Store(false)
 }
 
-// IsRunning() Indicates the running state of the data plane. If true, the dataplane is initialized
+// isRunning() Indicates the running state of the data plane. If true, the dataplane is initialized
 // and ready to process or already processing packets. In this case some configuration changes are
 // not permitted. If false, the data plane is not ready to process packets yet, or is shutting
 // down.
-func (d *DataPlane) IsRunning() bool {
+func (d *dataPlane) isRunning() bool {
 	return d.running.Load()
 }
 
 // Shutdown() causes the dataplane to stop accepting packets and then terminate. Note that
 // in that case the router is committed to shutting down. There is no mechanism to restart it.
-func (d *DataPlane) Shutdown() {
-	d.mtx.Lock() // make sure we're nor racing with initialization.
+func (d *dataPlane) Shutdown() {
+	d.mtx.Lock() // make sure we're not racing with initialization.
 	defer d.mtx.Unlock()
+	d.underlay.Stop()
 	d.setStopping()
 }
 
 // SetIA sets the local IA for the dataplane.
-func (d *DataPlane) SetIA(ia addr.IA) error {
+func (d *dataPlane) SetIA(ia addr.IA) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
-	if d.IsRunning() {
+	if d.isRunning() {
 		return modifyExisting
 	}
 	if ia.IsZero() {
@@ -300,10 +332,10 @@ func (d *DataPlane) SetIA(ia addr.IA) error {
 
 // SetKey sets the key used for MAC verification. The key provided here should
 // already be derived as in scrypto.HFMacFactory.
-func (d *DataPlane) SetKey(key []byte) error {
+func (d *dataPlane) SetKey(key []byte) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
-	if d.IsRunning() {
+	if d.isRunning() {
 		return modifyExisting
 	}
 	if len(key) == 0 {
@@ -323,7 +355,7 @@ func (d *DataPlane) SetKey(key []byte) error {
 	return nil
 }
 
-func (d *DataPlane) SetPortRange(start, end uint16) {
+func (d *dataPlane) SetPortRange(start, end uint16) {
 	d.dispatchedPortStart = start
 	d.dispatchedPortEnd = end
 }
@@ -332,25 +364,21 @@ func (d *DataPlane) SetPortRange(start, end uint16) {
 // send/receive traffic in the local AS. This can only be called once; future
 // calls will return an error. This can only be called on a not yet running
 // dataplane.
-func (d *DataPlane) AddInternalInterface(conn BatchConn, ip netip.Addr) error {
+func (d *dataPlane) AddInternalInterface(conn BatchConn, ip netip.Addr) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
-	if d.IsRunning() {
+	if d.isRunning() {
 		return modifyExisting
 	}
 	if conn == nil {
 		return emptyValue
 	}
-	if d.underlay == nil {
-		d.underlay = newUnderlay()
-	}
-	if d.interfaces == nil {
-		d.interfaces = make(map[uint16]Link)
-	} else if d.interfaces[0] != nil {
+	if d.interfaces[0] != nil {
 		return alreadySet
 	}
-
-	d.interfaces[0] = d.underlay.NewInternalLink(conn, d.RunConfig.BatchSize)
+	d.addForwardingMetrics(0, Internal)
+	d.interfaces[0] = d.underlay.NewInternalLink(
+		conn, d.RunConfig.BatchSize, d.forwardingMetrics[0])
 	d.internalIP = ip
 
 	return nil
@@ -359,43 +387,38 @@ 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,
+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.IsRunning() {
+	if d.isRunning() {
 		return modifyExisting
 	}
 	if conn == nil || !src.Addr.IsValid() || !dst.Addr.IsValid() {
 		return emptyValue
 	}
-	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.interfaces == nil {
-		d.interfaces = make(map[uint16]Link)
-	}
 	if _, exists := d.interfaces[ifID]; exists {
 		return serrors.JoinNoStack(alreadySet, nil, "ifID", ifID)
 	}
+	d.addForwardingMetrics(ifID, External)
 	d.interfaces[ifID] = d.underlay.NewExternalLink(
-		conn, d.RunConfig.BatchSize, bfd, dst.Addr, ifID)
+		conn, d.RunConfig.BatchSize, bfd, dst.Addr, ifID, d.forwardingMetrics[ifID])
 	return nil
 }
 
 // AddNeighborIA adds the neighboring IA for a given interface ID. If an IA 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) AddNeighborIA(ifID uint16, remote addr.IA) error {
+func (d *dataPlane) AddNeighborIA(ifID uint16, remote addr.IA) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
-	if d.IsRunning() {
+	if d.isRunning() {
 		return modifyExisting
 	}
 	if remote.IsZero() {
@@ -404,9 +427,6 @@ func (d *DataPlane) AddNeighborIA(ifID uint16, remote addr.IA) error {
 	if _, exists := d.neighborIAs[ifID]; exists {
 		return serrors.JoinNoStack(alreadySet, nil, "ifID", ifID)
 	}
-	if d.neighborIAs == nil {
-		d.neighborIAs = make(map[uint16]addr.IA)
-	}
 	d.neighborIAs[ifID] = remote
 	return nil
 }
@@ -414,24 +434,21 @@ func (d *DataPlane) AddNeighborIA(ifID uint16, remote addr.IA) error {
 // AddLinkType adds the link type for a given interface ID. If a link type 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) AddLinkType(ifID uint16, linkTo topology.LinkType) error {
+func (d *dataPlane) AddLinkType(ifID uint16, linkTo topology.LinkType) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
-	if d.IsRunning() {
+	if d.isRunning() {
 		return modifyExisting
 	}
 	if _, exists := d.linkTypes[ifID]; exists {
 		return serrors.JoinNoStack(alreadySet, nil, "ifID", ifID)
 	}
-	if d.linkTypes == nil {
-		d.linkTypes = make(map[uint16]topology.LinkType)
-	}
 	d.linkTypes[ifID] = linkTo
 	return nil
 }
 
-// AddExternalInterfaceBFD adds the inter AS connection BFD session.
-func (d *DataPlane) newExternalInterfaceBFD(ifID uint16,
+// newExternalInterfaceBFD adds the inter AS connection BFD session.
+func (d *dataPlane) newExternalInterfaceBFD(ifID uint16,
 	src, dst control.LinkEnd, cfg control.BFD) (BFDSession, error) {
 
 	if *cfg.Disable {
@@ -461,7 +478,7 @@ func (d *DataPlane) newExternalInterfaceBFD(ifID uint16,
 // 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
 // session. Otherwise, it returns InterfaceDown.
-func (d *DataPlane) getInterfaceState(ifID uint16) control.InterfaceState {
+func (d *dataPlane) getInterfaceState(ifID uint16) control.InterfaceState {
 	if link := d.interfaces[ifID]; link != nil && !link.IsUp() {
 		return control.InterfaceDown
 	}
@@ -471,15 +488,12 @@ func (d *DataPlane) getInterfaceState(ifID uint16) control.InterfaceState {
 // 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.
-func (d *DataPlane) AddSvc(svc addr.SVC, a netip.AddrPort) error {
+func (d *dataPlane) AddSvc(svc addr.SVC, a netip.AddrPort) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
 	if !a.IsValid() {
 		return emptyValue
 	}
-	if d.svc == nil {
-		d.svc = newServices()
-	}
 	d.svc.AddSvc(svc, a)
 	if d.Metrics != nil {
 		labels := serviceLabels(d.localIA, svc)
@@ -490,15 +504,12 @@ func (d *DataPlane) AddSvc(svc addr.SVC, a netip.AddrPort) error {
 }
 
 // DelSvc deletes the address for the given service.
-func (d *DataPlane) DelSvc(svc addr.SVC, a netip.AddrPort) error {
+func (d *dataPlane) DelSvc(svc addr.SVC, a netip.AddrPort) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
 	if !a.IsValid() {
 		return emptyValue
 	}
-	if d.svc == nil {
-		return nil
-	}
 	d.svc.DelSvc(svc, a)
 	if d.Metrics != nil {
 		labels := serviceLabels(d.localIA, svc)
@@ -511,40 +522,35 @@ func (d *DataPlane) DelSvc(svc addr.SVC, a netip.AddrPort) 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, src, dst netip.AddrPort, cfg control.BFD,
+func (d *dataPlane) AddNextHop(ifID uint16, src, dst netip.AddrPort, cfg control.BFD,
 	sibling string) error {
 
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
 
-	if d.IsRunning() {
+	if d.isRunning() {
 		return modifyExisting
 	}
 	if !dst.IsValid() || !src.IsValid() {
 		return emptyValue
 	}
-	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.interfaces == nil {
-		d.interfaces = make(map[uint16]Link)
-	}
 	if _, exists := d.interfaces[ifID]; exists {
 		return serrors.JoinNoStack(alreadySet, nil, "ifID", ifID)
 	}
-
-	d.interfaces[ifID] = d.underlay.NewSiblingLink(d.RunConfig.BatchSize, bfd, dst)
+	d.addForwardingMetrics(ifID, Sibling)
+	d.interfaces[ifID] = d.underlay.NewSiblingLink(
+		d.RunConfig.BatchSize, bfd, dst, d.forwardingMetrics[ifID])
 	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) newNextHopBFD(ifID uint16, src, dst netip.AddrPort, cfg control.BFD,
+func (d *dataPlane) newNextHopBFD(ifID uint16, src, dst netip.AddrPort, cfg control.BFD,
 	sibling string) (BFDSession, error) {
 
 	if *cfg.Disable {
@@ -581,31 +587,30 @@ type RunConfig struct {
 	BatchSize             int
 }
 
-func (d *DataPlane) Run(ctx context.Context) error {
+func (d *dataPlane) Run(ctx context.Context) error {
 	d.mtx.Lock()
-	d.initMetrics()
+	if len(d.interfaces) == 0 {
+		// Not stritcly an error but we really can't do anything; most maps aren't even allocated,
+		// due to lazy initialization.
+		return nil
+	}
+
+	// Start our custom /proc/pid/stat collector to export iowait time and (in the future) other
+	// process-wide metrics that prometheus does not.
+	err := processmetrics.Init()
+
+	// we can live without these metrics. Just log the error.
+	if err != nil {
+		log.Error("Could not initialize processmetrics", "err", err)
+	}
 
-	// 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(underlayConnections)*d.RunConfig.BatchSize/d.RunConfig.NumProcessors,
+		d.underlay.NumConnections()*d.RunConfig.BatchSize/d.RunConfig.NumProcessors,
 		d.RunConfig.BatchSize)
-
 	d.initPacketPool(processorQueueSize)
 	procQs, slowQs := d.initQueues(processorQueueSize)
-
 	d.setRunning()
-	for _, c := range underlayConnections {
-		go func(c UnderlayConn) {
-			defer log.HandlePanic()
-			d.runReceiver(c, procQs)
-		}(c)
-		go func(c UnderlayConn) {
-			defer log.HandlePanic()
-			d.runForwarder(c)
-		}(c)
-	}
+	d.underlay.Start(ctx, d.packetPool, procQs)
 
 	for i := 0; i < d.RunConfig.NumProcessors; i++ {
 		go func(i int) {
@@ -620,19 +625,6 @@ func (d *DataPlane) Run(ctx context.Context) error {
 		}(i)
 	}
 
-	for addr, link := range d.underlay.Links() {
-		s := link.BFDSession()
-		if s == nil {
-			continue
-		}
-		go func(addr netip.AddrPort) {
-			defer log.HandlePanic()
-			if err := s.Run(ctx); err != nil && err != bfd.AlreadyRunning {
-				log.Error("BFD session failed to start", "remote address", addr, "err", err)
-			}
-		}(addr)
-	}
-
 	d.mtx.Unlock()
 	<-ctx.Done()
 	return nil
@@ -640,7 +632,7 @@ func (d *DataPlane) Run(ctx context.Context) error {
 
 // initializePacketPool calculates the size of the packet pool based on the
 // current dataplane settings and allocates all the buffers
-func (d *DataPlane) initPacketPool(processorQueueSize int) {
+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)
@@ -655,7 +647,7 @@ func (d *DataPlane) initPacketPool(processorQueueSize int) {
 }
 
 // initializes the processing routines and queues
-func (d *DataPlane) initQueues(processorQueueSize int) ([]chan *Packet, []chan *Packet) {
+func (d *dataPlane) initQueues(processorQueueSize int) ([]chan *Packet, []chan *Packet) {
 
 	procQs := make([]chan *Packet, d.RunConfig.NumProcessors)
 	for i := 0; i < d.RunConfig.NumProcessors; i++ {
@@ -668,137 +660,27 @@ func (d *DataPlane) initQueues(processorQueueSize int) ([]chan *Packet, []chan *
 	return procQs, slowQs
 }
 
-// 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", "connection", u.Name())
-
-	// Each receiver (therefore each input interface) has a unique random seed for the procID hash
-	// function.
-	hashSeed := fnv1aOffset32
-	randomBytes := make([]byte, 4)
-	if _, err := rand.Read(randomBytes); err != nil {
-		panic("Error while generating random value")
-	}
-	for _, c := range randomBytes {
-		hashSeed = hashFNV1a(hashSeed, c)
-	}
-
-	// 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(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, d.RunConfig.BatchSize)
-
-	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) {
-		sc := classOfSize(size)
-		metrics[sc].InputPacketsTotal.Inc()
-		metrics[sc].InputBytesTotal.Add(float64(size))
-
-		procID, err := computeProcID(pkt.rawPacket, d.RunConfig.NumProcessors, hashSeed)
-		if err != nil {
-			log.Debug("Error while computing procID", "err", err)
-			d.returnPacketToPool(pkt)
-			metrics[sc].DroppedPacketsInvalid.Inc()
-			return
-		}
-
-		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 {
-		case procQs[procID] <- pkt:
-		default:
-			d.returnPacketToPool(pkt)
-			metrics[sc].DroppedPacketsBusyProcessor.Inc()
-		}
-	}
-
-	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 < d.RunConfig.BatchSize-numReusable; i++ {
-			p := d.getPacketFromPool()
-			p.reset()
-			packets[i] = p
-			msgs[i].Buffers[0] = p.rawPacket
-		}
-
-		// Fill the packets
-		numPkts, err := conn.ReadBatch(msgs)
-		numReusable = len(msgs) - numPkts
-		if err != nil {
-			log.Debug("Error while reading batch", "interfaceID", ifID, "err", err)
-			continue
-		}
-		for i, msg := range msgs[:numPkts] {
-			enqueueForProcessing(msg.N, msg.Addr.(*net.UDPAddr), packets[i])
-		}
-	}
-}
-
-func computeProcID(data []byte, numProcRoutines int, hashSeed uint32) (uint32, error) {
-	if len(data) < slayers.CmnHdrLen {
-		return 0, errShortPacket
-	}
-	dstHostAddrLen := slayers.AddrType(data[9] >> 4 & 0xf).Length()
-	srcHostAddrLen := slayers.AddrType(data[9] & 0xf).Length()
-	addrHdrLen := 2*addr.IABytes + srcHostAddrLen + dstHostAddrLen
-	if len(data) < slayers.CmnHdrLen+addrHdrLen {
-		return 0, errShortPacket
-	}
-
-	s := hashSeed
-
-	// inject the flowID
-	s = hashFNV1a(s, data[1]&0xF) // The left 4 bits aren't part of the flowID.
-	for _, c := range data[2:4] {
-		s = hashFNV1a(s, c)
-	}
-
-	// Inject the src/dst addresses
-	for _, c := range data[slayers.CmnHdrLen : slayers.CmnHdrLen+addrHdrLen] {
-		s = hashFNV1a(s, c)
-	}
-
-	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, 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)
-	for d.IsRunning() {
+	for d.isRunning() {
 		p, ok := <-q
 		if !ok {
 			continue
 		}
 		disp := processor.processPkt(p)
 
-		sc := classOfSize(len(p.rawPacket))
-		metrics := d.forwardingMetrics[p.ingress][sc]
+		sc := ClassOfSize(len(p.RawPacket))
+		metrics := d.forwardingMetrics[p.Ingress][sc]
 		metrics.ProcessedPackets.Inc()
 
 		switch disp {
@@ -839,18 +721,18 @@ func (d *DataPlane) runProcessor(id int, q <-chan *Packet, slowQ chan<- *Packet)
 	}
 }
 
-func (d *DataPlane) runSlowPathProcessor(id int, q <-chan *Packet) {
+func (d *dataPlane) runSlowPathProcessor(id int, q <-chan *Packet) {
 
 	log.Debug("Initialize slow-path processor with", "id", id)
 	processor := newSlowPathProcessor(d)
-	for d.IsRunning() {
+	for d.isRunning() {
 		p, ok := <-q
 		if !ok {
 			continue
 		}
 		err := processor.processPacket(p)
-		sc := classOfSize(len(p.rawPacket))
-		metrics := d.forwardingMetrics[p.ingress][sc]
+		sc := ClassOfSize(len(p.RawPacket))
+		metrics := d.forwardingMetrics[p.Ingress][sc]
 		if err != nil {
 			log.Debug("Error processing packet", "err", err)
 			metrics.DroppedPacketsInvalid.Inc()
@@ -869,7 +751,7 @@ func (d *DataPlane) runSlowPathProcessor(id int, q <-chan *Packet) {
 	}
 }
 
-func newSlowPathProcessor(d *DataPlane) *slowPathPacketProcessor {
+func newSlowPathProcessor(d *dataPlane) *slowPathPacketProcessor {
 	p := &slowPathPacketProcessor{
 		d:              d,
 		macInputBuffer: make([]byte, spao.MACBufferSize),
@@ -885,7 +767,7 @@ func newSlowPathProcessor(d *DataPlane) *slowPathPacketProcessor {
 }
 
 type slowPathPacketProcessor struct {
-	d   *DataPlane
+	d   *dataPlane
 	pkt *Packet
 
 	scionLayer slayers.SCION
@@ -918,7 +800,7 @@ func (p *slowPathPacketProcessor) processPacket(pkt *Packet) error {
 	p.reset()
 	p.pkt = pkt
 
-	p.lastLayer, err = decodeLayers(pkt.rawPacket, &p.scionLayer, &p.hbhLayer, &p.e2eLayer)
+	p.lastLayer, err = decodeLayers(pkt.RawPacket, &p.scionLayer, &p.hbhLayer, &p.e2eLayer)
 	if err != nil {
 		return err
 	}
@@ -958,12 +840,12 @@ func (p *slowPathPacketProcessor) processPacket(pkt *Packet) error {
 				IfID: uint64(p.pkt.egress)}
 		case slayers.SCMPTypeInternalConnectivityDown:
 			layer = &slayers.SCMPInternalConnectivityDown{IA: p.d.localIA,
-				Ingress: uint64(p.pkt.ingress), Egress: uint64(p.pkt.egress)}
+				Ingress: uint64(p.pkt.Ingress), Egress: uint64(p.pkt.egress)}
 		}
 		return p.packSCMP(s.scmpType, s.code, layer, true)
 
 	case slowPathRouterAlertIngress: //Traceroute
-		return p.handleSCMPTraceRouteRequest(p.pkt.ingress)
+		return p.handleSCMPTraceRouteRequest(p.pkt.Ingress)
 	case slowPathRouterAlertEgress: //Traceroute
 		return p.handleSCMPTraceRouteRequest(p.pkt.egress)
 	default:
@@ -971,133 +853,7 @@ func (p *slowPathPacketProcessor) processPacket(pkt *Packet) error {
 	}
 }
 
-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.
-	writtenPkts := [ttMax][maxSizeClass]int{}
-	writtenBytes := [ttMax][maxSizeClass]int{}
-	for _, p := range packets {
-		s := len(p.rawPacket)
-		sc := classOfSize(s)
-		tt := p.trafficType
-		writtenPkts[tt][sc]++
-		writtenBytes[tt][sc] += s
-	}
-	for t := ttOther; t < ttMax; t++ {
-		for sc := minSizeClass; sc < maxSizeClass; sc++ {
-			if writtenPkts[t][sc] > 0 {
-				metrics[sc].Output[t].OutputPacketsTotal.Add(float64(writtenPkts[t][sc]))
-				metrics[sc].Output[t].OutputBytesTotal.Add(float64(writtenBytes[t][sc]))
-			}
-		}
-	}
-}
-
-// 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("Run forwarder", "connection", u.Name())
-
-	// We use this somewhat like a ring buffer.
-	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, d.RunConfig.BatchSize)
-	for i := range msgs {
-		msgs[i].Buffers = make([][]byte, 1)
-	}
-
-	c := u.Queue()
-	conn := u.Conn()
-	metrics := d.forwardingMetrics[u.IfID()]
-	toWrite := 0
-	for d.IsRunning() {
-		// 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
-			}
-		}
-		written, _ := conn.WriteBatch(msgs[:toWrite], 0)
-		if written < 0 {
-			// WriteBatch returns -1 on error, we just consider this as
-			// 0 packets written
-			written = 0
-		}
-
-		updateOutputMetrics(metrics, pkts[:written])
-
-		for _, p := range pkts[:written] {
-			d.returnPacketToPool(p)
-		}
-
-		if written != toWrite {
-			// Only one is dropped at this time. We'll retry the rest.
-			sc := classOfSize(len(pkts[written].rawPacket))
-			metrics[sc].DroppedPacketsInvalid.Inc()
-			d.returnPacketToPool(pkts[written])
-			toWrite -= (written + 1)
-			// Shift the leftovers to the head of the buffers.
-			for i := 0; i < toWrite; i++ {
-				pkts[i] = pkts[i+written+1]
-			}
-
-		} else {
-			toWrite = 0
-		}
-	}
-}
-
-func readUpTo(c <-chan *Packet, n int, needsBlocking bool, pkts []*Packet) int {
-	i := 0
-	if needsBlocking {
-		p, ok := <-c
-		if !ok {
-			return i
-		}
-		pkts[i] = p
-		i++
-	}
-
-	for ; i < n; i++ {
-		select {
-		case p, ok := <-c:
-			if !ok {
-				return i
-			}
-			pkts[i] = p
-		default:
-			return i
-		}
-
-	}
-	return i
-}
-
-func newPacketProcessor(d *DataPlane) *scionPacketProcessor {
+func newPacketProcessor(d *dataPlane) *scionPacketProcessor {
 	p := &scionPacketProcessor{
 		d:              d,
 		mac:            d.macFactory(),
@@ -1139,7 +895,7 @@ func (p *scionPacketProcessor) processPkt(pkt *Packet) disposition {
 
 	// parse SCION header and skip extensions;
 	var err error
-	p.lastLayer, err = decodeLayers(pkt.rawPacket, &p.scionLayer, &p.hbhLayer, &p.e2eLayer)
+	p.lastLayer, err = decodeLayers(pkt.RawPacket, &p.scionLayer, &p.hbhLayer, &p.e2eLayer)
 	if err != nil {
 		return errorDiscard("error", err)
 	}
@@ -1176,7 +932,7 @@ func (p *scionPacketProcessor) processInterBFD(oh *onehop.Path, data []byte) dis
 
 	// 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]
+	link, exists := p.d.interfaces[p.pkt.Ingress]
 	if !exists {
 		return errorDiscard("error", noBFDSessionFound)
 	}
@@ -1196,7 +952,9 @@ func (p *scionPacketProcessor) processIntraBFD(data []byte) disposition {
 
 	// 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
+	// TODO(multi_underlay): The underlay should find the Link for all packets (bfd or not), either
+	// by src address or by connection.
+	src := p.pkt.SrcAddr.AddrPort() // POSSIBLY EXPENSIVE CONVERSION
 	session := p.d.underlay.Link(src).BFDSession()
 	if session == nil {
 		return errorDiscard("error", noBFDSessionFound)
@@ -1275,7 +1033,7 @@ func (p *scionPacketProcessor) processEPIC() disposition {
 // mutable state and context information which should be reused.
 type scionPacketProcessor struct {
 	// d is a reference to the dataplane instance that initiated this processor.
-	d *DataPlane
+	d *dataPlane
 	// pkt is the packet currently being processed by this processor.
 	pkt *Packet
 	// mac is the hasher for the MAC computation.
@@ -1344,8 +1102,8 @@ func (p *slowPathPacketProcessor) packSCMP(
 	// We're about to send a packet that has little to do with the one we received.
 	// 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)
+	p.pkt.egress = p.pkt.Ingress
+	updateNetAddrFromNetAddr(p.pkt.DstAddr, p.pkt.SrcAddr)
 	return nil
 }
 
@@ -1417,7 +1175,7 @@ func (p *scionPacketProcessor) validateHopExpiry() disposition {
 		return pForward
 	}
 	log.Debug("SCMP response", "cause", expiredHop,
-		"cons_dir", p.infoField.ConsDir, "if_id", p.pkt.ingress,
+		"cons_dir", p.infoField.ConsDir, "if_id", p.pkt.Ingress,
 		"curr_inf", p.path.PathMeta.CurrINF, "curr_hf", p.path.PathMeta.CurrHF)
 	p.pkt.slowPathRequest = slowPathRequest{
 		scmpType: slayers.SCMPTypeParameterProblem,
@@ -1434,9 +1192,9 @@ func (p *scionPacketProcessor) validateIngressID() disposition {
 		hdrIngressID = p.hopField.ConsEgress
 		errCode = slayers.SCMPCodeUnknownHopFieldEgress
 	}
-	if p.pkt.ingress != 0 && p.pkt.ingress != hdrIngressID {
+	if p.pkt.Ingress != 0 && p.pkt.Ingress != hdrIngressID {
 		log.Debug("SCMP response", "cause", ingressInterfaceInvalid,
-			"pkt_ingress", hdrIngressID, "router_ingress", p.pkt.ingress)
+			"pkt_ingress", hdrIngressID, "router_ingress", p.pkt.Ingress)
 		p.pkt.slowPathRequest = slowPathRequest{
 			scmpType: slayers.SCMPTypeParameterProblem,
 			code:     errCode,
@@ -1450,7 +1208,7 @@ func (p *scionPacketProcessor) validateIngressID() disposition {
 func (p *scionPacketProcessor) validateSrcDstIA() disposition {
 	srcIsLocal := (p.scionLayer.SrcIA == p.d.localIA)
 	dstIsLocal := (p.scionLayer.DstIA == p.d.localIA)
-	if p.pkt.ingress == 0 {
+	if p.pkt.Ingress == 0 {
 		// Outbound
 		// Only check SrcIA if first hop, for transit this already checked by ingress router.
 		// Note: SCMP error messages triggered by the sibling router may use paths that
@@ -1503,7 +1261,7 @@ func (p *scionPacketProcessor) respInvalidDstIA() disposition {
 //
 // 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 {
+	if p.path.IsFirstHop() || p.pkt.Ingress != 0 {
 		// not a transit packet, nothing to check
 		return pForward
 	}
@@ -1513,7 +1271,7 @@ func (p *scionPacketProcessor) validateTransitUnderlaySrc() disposition {
 		// Drop
 		return errorDiscard("error", invalidSrcAddrForTransit)
 	}
-	src, okS := netip.AddrFromSlice(p.pkt.srcAddr.IP)
+	src, okS := netip.AddrFromSlice(p.pkt.SrcAddr.IP)
 	if !(okS && ingressLink.Remote().Addr() == src) {
 		// Drop
 		return errorDiscard("error", invalidSrcAddrForTransit)
@@ -1523,7 +1281,7 @@ func (p *scionPacketProcessor) validateTransitUnderlaySrc() disposition {
 
 // 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
+// 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,
@@ -1536,7 +1294,7 @@ func (p *scionPacketProcessor) validateEgressID() disposition {
 	// egress is never the internalInterface
 	// packet coming from internal interface, must go to an external interface
 	// Note that, for now, ingress == 0 is also true for sibling interfaces. That might change.
-	if !found || (p.pkt.ingress == 0 && link.Scope() == Sibling) {
+	if !found || (p.pkt.Ingress == 0 && link.Scope() == Sibling) {
 		errCode := slayers.SCMPCodeUnknownHopFieldEgress
 		if !p.infoField.ConsDir {
 			errCode = slayers.SCMPCodeUnknownHopFieldIngress
@@ -1550,14 +1308,14 @@ func (p *scionPacketProcessor) validateEgressID() disposition {
 		return pSlowPath
 	}
 
-	ingressLT, egressLT := p.d.linkTypes[p.pkt.ingress], p.d.linkTypes[egressID]
+	ingressLT, egressLT := p.d.linkTypes[p.pkt.Ingress], p.d.linkTypes[egressID]
 	if !p.effectiveXover {
 		// Check that the interface pair is valid within a single segment.
 		// No check required if the packet is received from an internal interface.
 		// This case applies to peering hops as a peering hop isn't an effective
 		// cross-over (eventhough it is a segment change).
 		switch {
-		case p.pkt.ingress == 0:
+		case p.pkt.Ingress == 0:
 			return pForward
 		case ingressLT == topology.Core && egressLT == topology.Core:
 			return pForward
@@ -1571,7 +1329,7 @@ func (p *scionPacketProcessor) validateEgressID() disposition {
 			return pForward
 		default: // malicious
 			log.Debug("SCMP response", "cause", cannotRoute,
-				"ingress_id", p.pkt.ingress, "ingress_type", ingressLT,
+				"ingress_id", p.pkt.Ingress, "ingress_type", ingressLT,
 				"egress_id", egressID, "egress_type", egressLT)
 			p.pkt.slowPathRequest = slowPathRequest{
 				scmpType: slayers.SCMPTypeParameterProblem,
@@ -1596,7 +1354,7 @@ func (p *scionPacketProcessor) validateEgressID() disposition {
 		return pForward
 	default:
 		log.Debug("SCMP response", "cause", cannotRoute,
-			"ingress_id", p.pkt.ingress, "ingress_type", ingressLT,
+			"ingress_id", p.pkt.Ingress, "ingress_type", ingressLT,
 			"egress_id", egressID, "egress_type", egressLT)
 		p.pkt.slowPathRequest = slowPathRequest{
 			scmpType: slayers.SCMPTypeParameterProblem,
@@ -1611,7 +1369,7 @@ func (p *scionPacketProcessor) updateNonConsDirIngressSegID() disposition {
 	// against construction dir the ingress router updates the SegID, ifID == 0
 	// means this comes from this AS itself, so nothing has to be done.
 	// For packets destined to peer links this shouldn't be updated.
-	if !p.infoField.ConsDir && p.pkt.ingress != 0 && !p.peering {
+	if !p.infoField.ConsDir && p.pkt.Ingress != 0 && !p.peering {
 		p.infoField.UpdateSegID(p.hopField.Mac)
 		if err := p.path.SetInfoField(p.infoField, int(p.path.PathMeta.CurrINF)); err != nil {
 			return errorDiscard("error", err)
@@ -1637,7 +1395,7 @@ func (p *scionPacketProcessor) verifyCurrentMAC() disposition {
 			"expected", fullMac[:path.MacLen],
 			"actual", p.hopField.Mac[:path.MacLen],
 			"cons_dir", p.infoField.ConsDir,
-			"if_id", p.pkt.ingress, "curr_inf", p.path.PathMeta.CurrINF,
+			"if_id", p.pkt.Ingress, "curr_inf", p.path.PathMeta.CurrINF,
 			"curr_hf", p.path.PathMeta.CurrHF, "seg_id", p.infoField.SegID)
 		p.pkt.slowPathRequest = slowPathRequest{
 			scmpType: slayers.SCMPTypeParameterProblem,
@@ -1765,7 +1523,7 @@ func (p *scionPacketProcessor) validateEgressUp() disposition {
 }
 
 func (p *scionPacketProcessor) handleIngressRouterAlert() disposition {
-	if p.pkt.ingress == 0 {
+	if p.pkt.Ingress == 0 {
 		return pForward
 	}
 	alert := p.ingressRouterAlertFlag()
@@ -1975,7 +1733,7 @@ func (p *scionPacketProcessor) process() disposition {
 		if p.scionLayer.SrcIA == p.d.localIA {
 			// Pure outbound
 			tt = ttOut
-		} else if p.pkt.ingress == 0 {
+		} else if p.pkt.Ingress == 0 {
 			// ASTransit out
 			tt = ttOutTransit
 		} else {
@@ -2005,7 +1763,7 @@ func (p *scionPacketProcessor) processOHP() disposition {
 	}
 
 	// OHP leaving our IA
-	if p.pkt.ingress == 0 {
+	if p.pkt.Ingress == 0 {
 		if !p.d.localIA.Equal(s.SrcIA) {
 			// TODO parameter problem -> invalid path
 			return errorDiscard("error", cannotRoute)
@@ -2025,7 +1783,7 @@ func (p *scionPacketProcessor) processOHP() disposition {
 		}
 		ohp.Info.UpdateSegID(ohp.FirstHop.Mac)
 
-		if err := updateSCIONLayer(p.pkt.rawPacket, s); err != nil {
+		if err := updateSCIONLayer(p.pkt.RawPacket, s); err != nil {
 			return errorDiscard("error", err)
 		}
 		p.pkt.egress = ohp.FirstHop.ConsEgress
@@ -2036,13 +1794,13 @@ func (p *scionPacketProcessor) processOHP() disposition {
 	if !p.d.localIA.Equal(s.DstIA) {
 		return errorDiscard("error", cannotRoute)
 	}
-	neighborIA := p.d.neighborIAs[p.pkt.ingress]
+	neighborIA := p.d.neighborIAs[p.pkt.Ingress]
 	if !neighborIA.Equal(s.SrcIA) {
 		return errorDiscard("error", cannotRoute)
 	}
 
 	ohp.SecondHop = path.HopField{
-		ConsIngress: p.pkt.ingress,
+		ConsIngress: p.pkt.Ingress,
 		ExpTime:     ohp.FirstHop.ExpTime,
 	}
 	// XXX(roosd): Here we leak the buffer into the SCION packet header.
@@ -2051,7 +1809,7 @@ func (p *scionPacketProcessor) processOHP() disposition {
 	ohp.SecondHop.Mac = path.MAC(p.mac, ohp.Info, ohp.SecondHop,
 		p.macInputBuffer[:path.MACBufferSize])
 
-	if err := updateSCIONLayer(p.pkt.rawPacket, s); err != nil {
+	if err := updateSCIONLayer(p.pkt.RawPacket, s); err != nil {
 		return errorDiscard("error", err)
 	}
 	err := p.d.resolveLocalDst(p.pkt.DstAddr, s, p.lastLayer)
@@ -2062,7 +1820,7 @@ func (p *scionPacketProcessor) processOHP() disposition {
 	return pForward
 }
 
-func (d *DataPlane) resolveLocalDst(
+func (d *dataPlane) resolveLocalDst(
 	resolvedDst *net.UDPAddr,
 	s slayers.SCION,
 	lastLayer gopacket.DecodingLayer,
@@ -2106,7 +1864,7 @@ func (d *DataPlane) resolveLocalDst(
 	}
 }
 
-func (d *DataPlane) addEndhostPort(
+func (d *dataPlane) addEndhostPort(
 	resolvedDst *net.UDPAddr,
 	lastLayer gopacket.DecodingLayer,
 	dst netip.Addr,
@@ -2269,7 +2027,7 @@ func decodeSCMP(scmp *slayers.SCMP) ([]gopacket.SerializableLayer, error) {
 // it with the serialization of the given new SCION header. This works only if the new header is of
 // the same size as the old one. This function has no knowledge of the actual size of the headers;
 // it only ensures that the new one ends exactly where the old one did. It is possible to use this
-// function to replace a header with a smaller one; but the rawPacket's slice must be fixed
+// function to replace a header with a smaller one; but the RawPacket's slice must be fixed
 // afterwards (and the preceding headers, if any).
 func updateSCIONLayer(rawPkt []byte, s slayers.SCION) error {
 	payloadOffset := len(rawPkt) - len(s.LayerPayload())
@@ -2280,7 +2038,7 @@ func updateSCIONLayer(rawPkt []byte, s slayers.SCION) error {
 }
 
 type bfdSend struct {
-	dataPlane        *DataPlane
+	dataPlane        *dataPlane
 	ifID             uint16
 	srcAddr, dstAddr netip.AddrPort
 	scn              *slayers.SCION
@@ -2290,7 +2048,7 @@ type bfdSend struct {
 }
 
 // newBFDSend creates and initializes a BFD Sender
-func newBFDSend(d *DataPlane, srcIA, dstIA addr.IA, srcAddr, dstAddr netip.AddrPort,
+func newBFDSend(d *dataPlane, srcIA, dstIA addr.IA, srcAddr, dstAddr netip.AddrPort,
 	ifID uint16, mac hash.Hash) (*bfdSend, error) {
 
 	scn := &slayers.SCION{
@@ -2369,11 +2127,11 @@ func (b *bfdSend) Send(bfd *layers.BFD) error {
 	}
 
 	p := b.dataPlane.getPacketFromPool()
-	p.reset()
+	p.Reset()
 
-	serBuf := newSerializeProxy(p.rawPacket) // set for prepend-only by default. Perfect here.
+	serBuf := newSerializeProxy(p.RawPacket) // set for prepend-only by default. Perfect here.
 
-	// serialized bytes lend directly into p.rawPacket (alignedd at the end).
+	// serialized bytes lend directly into p.RawPacket (alignedd at the end).
 	err := gopacket.SerializeLayers(&serBuf, gopacket.SerializeOptions{FixLengths: true},
 		b.scn, bfd)
 	if err != nil {
@@ -2382,7 +2140,7 @@ func (b *bfdSend) Send(bfd *layers.BFD) error {
 
 	// The useful part of the buffer is given by Bytes. We don't copy the bytes; just the slice's
 	// metadata.
-	p.rawPacket = serBuf.Bytes()
+	p.RawPacket = serBuf.Bytes()
 
 	// 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.
@@ -2460,8 +2218,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.
-	// This is an SCMP response to pkt, so egress will be pkt.ingress.
-	if p.d.interfaces[p.pkt.ingress].Scope() == 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]
@@ -2519,14 +2277,14 @@ func (p *slowPathPacketProcessor) prepareSCMP(
 		default:
 			hdrLen += 8
 		}
-		quote = p.pkt.rawPacket
+		quote = p.pkt.RawPacket
 		maxQuoteLen := slayers.MaxSCMPPacketLen - hdrLen
 		if len(quote) > maxQuoteLen {
 			quote = quote[:maxQuoteLen]
 		}
 	}
 
-	serBuf := newSerializeProxy(p.pkt.rawPacket) // Prepend-only by default. It's all we need.
+	serBuf := newSerializeProxy(p.pkt.RawPacket) // Prepend-only by default. It's all we need.
 	sopts := gopacket.SerializeOptions{
 		ComputeChecksums: true,
 		FixLengths:       true,
@@ -2585,7 +2343,7 @@ func (p *slowPathPacketProcessor) prepareSCMP(
 	if err := scionL.SerializeTo(&serBuf, sopts); err != nil {
 		return serrors.JoinNoStack(cannotRoute, err, "details", "serializing SCION header")
 	}
-	p.pkt.rawPacket = serBuf.Bytes()
+	p.pkt.RawPacket = serBuf.Bytes()
 
 	log.Debug("SCMP", "typecode", scmpH.TypeCode)
 	return nil
@@ -2708,24 +2466,11 @@ func nextHdr(layer gopacket.DecodingLayer) slayers.L4ProtocolType {
 	}
 }
 
-// initMetrics initializes the metrics related to packet forwarding. The counters are already
-// instantiated for all the relevant interfaces so this will not have to be repeated during packet
-// forwarding.
-func (d *DataPlane) initMetrics() {
-	d.forwardingMetrics = make(map[uint16]interfaceMetrics)
-	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
-	// process-wide metrics that prometheus does not.
-	err := processmetrics.Init()
-
-	// we can live without these metrics. Just log the error.
-	if err != nil {
-		log.Error("Could not initialize processmetrics", "err", err)
-	}
+// addForwardingMetrics adds interface-specific metrics for the given ifID.
+// These merics are used by the dataplane and all the underlay providers.
+func (d *dataPlane) addForwardingMetrics(ifID uint16, scope LinkScope) {
+	d.forwardingMetrics[ifID] = newInterfaceMetrics(
+		d.Metrics, ifID, d.localIA, scope, d.neighborIAs)
 }
 
 // updateNetAddrFromAddrPort() updates a net.UDPAddr address to be the same as the
diff --git a/router/metrics.go b/router/metrics.go
index d26b2aa9e..2ce34ae1c 100644
--- a/router/metrics.go
+++ b/router/metrics.go
@@ -1,4 +1,5 @@
 // Copyright 2020 Anapaya Systems
+// 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.
@@ -218,7 +219,7 @@ const _ = uint(1<<(maxSizeClass-1) - 1 - bufSize)
 // All smaller classes are conflated with this one.
 const minSizeClass sizeClass = 6
 
-func classOfSize(pktSize int) sizeClass {
+func ClassOfSize(pktSize int) sizeClass {
 	cs := sizeClass(bits.Len32(uint32(pktSize)))
 	if cs > maxSizeClass-1 {
 		return maxSizeClass - 1
@@ -255,7 +256,7 @@ func (sc sizeClass) String() string {
 //	dataplane.forwardingMetrics[interface][size-class].
 //
 // trafficMetrics.Output is an array of outputMetrics indexed by traffic type.
-type interfaceMetrics map[sizeClass]trafficMetrics
+type InterfaceMetrics map[sizeClass]trafficMetrics
 
 // trafficMetrics groups all the metrics instances that all share the same interface AND
 // sizeClass label values (but have different names - i.e. they count different things).
@@ -283,10 +284,10 @@ func newInterfaceMetrics(
 	id uint16,
 	localIA addr.IA,
 	scope LinkScope,
-	neighbors map[uint16]addr.IA) interfaceMetrics {
+	neighbors map[uint16]addr.IA) InterfaceMetrics {
 
 	ifLabels := interfaceLabels(id, localIA, scope, neighbors)
-	m := interfaceMetrics{}
+	m := InterfaceMetrics{}
 	for sc := minSizeClass; sc < maxSizeClass; sc++ {
 		scLabels := prometheus.Labels{"sizeclass": sc.String()}
 		m[sc] = newTrafficMetrics(metrics, ifLabels, scLabels)
@@ -385,3 +386,29 @@ func serviceLabels(localIA addr.IA, svc addr.SVC) prometheus.Labels {
 		"service": svc.BaseString(),
 	}
 }
+
+// UpdateOutputMetrics updates the given InterfaceMetrics in bulk according
+// to the given set of just sent packets. This is much faster than looking up
+// the right set of metrics by size class and traffic type for each 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.
+	writtenPkts := [ttMax][maxSizeClass]int{}
+	writtenBytes := [ttMax][maxSizeClass]int{}
+	for _, p := range packets {
+		s := len(p.RawPacket)
+		sc := ClassOfSize(s)
+		tt := p.trafficType
+		writtenPkts[tt][sc]++
+		writtenBytes[tt][sc] += s
+	}
+	for t := ttOther; t < ttMax; t++ {
+		for sc := minSizeClass; sc < maxSizeClass; sc++ {
+			if writtenPkts[t][sc] > 0 {
+				metrics[sc].Output[t].OutputPacketsTotal.Add(float64(writtenPkts[t][sc]))
+				metrics[sc].Output[t].OutputBytesTotal.Add(float64(writtenBytes[t][sc]))
+			}
+		}
+	}
+}
diff --git a/router/underlay.go b/router/underlay.go
index e073a4968..0e61b73e4 100644
--- a/router/underlay.go
+++ b/router/underlay.go
@@ -17,6 +17,7 @@
 package router
 
 import (
+	"context"
 	"net/netip"
 )
 
@@ -44,7 +45,6 @@ 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)
@@ -59,38 +59,44 @@ type Link interface {
 // make them opaque; to be interpreted only by the underlay implementation.
 type UnderlayProvider interface {
 
+	// NumConnections returns the current number of configured connections.
+	NumConnections() int
+
+	// Start puts the provider in the running state. In that state, the provider can deliver
+	// incoming packets to its output channels and will send packets present on its input
+	// channels. Only connection in existence at the time of calling Start() will be
+	// started. Calling Start has no effect on already running connections.
+	Start(ctx context.Context, pool chan *Packet, proQs []chan *Packet)
+
+	// Stop puts the provider in the stopped state. In that state, the provider no longer delivers
+	// incoming packets and ignores packets present on its input channels. The provider is fully
+	// stopped when this method returns. Only connections in existence at the time of calling Stop
+	// will be stopped. Calling Stop() has no effect on already stopped connections.
+	Stop()
+
 	// 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,
+		conn BatchConn,
+		qSize int,
+		bfd BFDSession,
+		remote netip.AddrPort,
+		ifID uint16,
+		metrics InterfaceMetrics,
 	) 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
+	NewSiblingLink(qSize int, bfd BFDSession, remote netip.AddrPort, metrics InterfaceMetrics) 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
+	NewInternalLink(conn BatchConn, qSize int, metrics InterfaceMetrics) 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.
@@ -100,19 +106,3 @@ type UnderlayProvider interface {
 	// 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
deleted file mode 100644
index 1f5ab2386..000000000
--- a/router/underlayproviders/BUILD.bazel
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index 72e78717e..000000000
--- a/router/underlayproviders/udpip.go
+++ /dev/null
@@ -1,326 +0,0 @@
-// 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
-}
diff --git a/router/underlayproviders/udpip/BUILD.bazel b/router/underlayproviders/udpip/BUILD.bazel
new file mode 100644
index 000000000..5c5a6215d
--- /dev/null
+++ b/router/underlayproviders/udpip/BUILD.bazel
@@ -0,0 +1,37 @@
+load("//tools/lint:go.bzl", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "fnv1acheap.go",
+        "udpip.go",
+    ],
+    importpath = "github.com/scionproto/scion/router/underlayproviders/udpip",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//pkg/addr:go_default_library",
+        "//pkg/log:go_default_library",
+        "//pkg/slayers:go_default_library",
+        "//private/underlay/conn:go_default_library",
+        "//router:go_default_library",
+        "//router/bfd:go_default_library",
+    ],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = ["udpip_test.go"],
+    embed = [":go_default_library"],
+    deps = [
+        "//pkg/addr:go_default_library",
+        "//pkg/private/serrors:go_default_library",
+        "//pkg/private/util:go_default_library",
+        "//pkg/scrypto:go_default_library",
+        "//pkg/slayers:go_default_library",
+        "//pkg/slayers/path:go_default_library",
+        "//pkg/slayers/path/scion:go_default_library",
+        "@com_github_gopacket_gopacket//:go_default_library",
+        "@com_github_stretchr_testify//assert:go_default_library",
+        "@com_github_stretchr_testify//require:go_default_library",
+    ],
+)
diff --git a/router/fnv1aCheap.go b/router/underlayproviders/udpip/fnv1acheap.go
similarity index 93%
rename from router/fnv1aCheap.go
rename to router/underlayproviders/udpip/fnv1acheap.go
index 280d56706..02591c2a1 100644
--- a/router/fnv1aCheap.go
+++ b/router/underlayproviders/udpip/fnv1acheap.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package router
+package udpip
 
 // fnv1aOffset32 is an initial offset that can be used as initial state when calling
 // hashFNV1a.
@@ -22,7 +22,7 @@ const fnv1aOffset32 uint32 = 2166136261
 // To get a hash for a sequence of bytes, invoke for each byte, passing the returned value
 // of one call as the state for the next. Example. s1 = hashFNV1a(fnv1aOffset, byte1)
 // s2 = hashFNV1a(s1, byte2) etc. It is valid and recommended to use a value obtained
-// from calla to hashFNV1a() as the initial state rather than fnv1aOffset32 itself.
+// from calls to hashFNV1a() as the initial state rather than fnv1aOffset32 itself.
 func hashFNV1a(state uint32, c byte) uint32 {
 	const prime32 = 16777619
 	return (state ^ uint32(c)) * prime32
diff --git a/router/underlayproviders/udpip/udpip.go b/router/underlayproviders/udpip/udpip.go
new file mode 100644
index 000000000..2895b56aa
--- /dev/null
+++ b/router/underlayproviders/udpip/udpip.go
@@ -0,0 +1,664 @@
+// 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 udpip
+
+import (
+	"context"
+	"crypto/rand"
+	"errors"
+	"maps"
+	"net"
+	"net/netip"
+	"sync"
+	"sync/atomic"
+
+	"github.com/scionproto/scion/pkg/addr"
+	"github.com/scionproto/scion/pkg/log"
+	"github.com/scionproto/scion/pkg/slayers"
+	underlayconn "github.com/scionproto/scion/private/underlay/conn"
+	"github.com/scionproto/scion/router"
+	"github.com/scionproto/scion/router/bfd"
+)
+
+// 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 {
+	mu             sync.Mutex // Prevents race between adding connections and Start/Stop.
+	batchSize      int        // TODO(multi_underlay): Should have an underlay-specific config.
+	allLinks       map[netip.AddrPort]udpLink
+	allConnections map[netip.AddrPort]*udpConnection
+}
+
+var (
+	errShortPacket = errors.New("Packet is too short")
+)
+
+type udpLink interface {
+	router.Link
+	start(ctx context.Context)
+	stop()
+}
+
+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(batchSize int) router.UnderlayProvider {
+	return &provider{
+		batchSize:      batchSize,
+		allLinks:       make(map[netip.AddrPort]udpLink),
+		allConnections: make(map[netip.AddrPort]*udpConnection),
+	}
+}
+
+func (u *provider) NumConnections() int {
+	u.mu.Lock()
+	defer u.mu.Unlock()
+	return len(u.allLinks)
+}
+
+// The queues to be used by the receiver task are supplied at this point because they must be
+// sized according to the number of connections that will be started.
+func (u *provider) Start(
+	ctx context.Context, pool chan *router.Packet, procQs []chan *router.Packet) {
+	u.mu.Lock()
+	connSnapShot := maps.Clone(u.allConnections)
+	linkSnapShot := maps.Clone(u.allLinks)
+	u.mu.Unlock()
+
+	for _, c := range connSnapShot {
+		c.start(u.batchSize, pool, procQs)
+	}
+
+	for _, l := range linkSnapShot {
+		l.start(ctx)
+	}
+}
+
+func (u *provider) Stop() {
+	u.mu.Lock()
+	connSnapShot := maps.Clone(u.allConnections)
+	linkSnapShot := maps.Clone(u.allLinks)
+	u.mu.Unlock()
+
+	for _, c := range connSnapShot {
+		c.stop()
+	}
+	for _, l := range linkSnapShot {
+		l.stop()
+	}
+}
+
+func (u *provider) Link(addr netip.AddrPort) router.Link {
+	u.mu.Lock()
+	defer u.mu.Unlock()
+
+	// 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 essentially a BatchConn with a sending queue. The rest is about logs and
+// metrics. 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 // TODO(multi_underlay): temorary (is 0 for sibling and internal links)
+	name         string // for logs. It's more informative than ifID.
+	running      atomic.Bool
+	metrics      router.InterfaceMetrics
+	receiverDone chan struct{}
+	senderDone   chan struct{}
+}
+
+// start puts the connection in the running state. In that state, the connection can deliver
+// incoming packets and ignores packets present on its input channel.
+func (u *udpConnection) start(
+	batchSize int, pool chan *router.Packet, procQs []chan *router.Packet) {
+
+	wasRunning := u.running.Swap(true)
+	if wasRunning || len(procQs) == 0 { // Pointless to receive without any processor.
+		return
+	}
+
+	// Receiver task
+	go func() {
+		defer log.HandlePanic()
+		u.receive(batchSize, pool, procQs)
+		close(u.receiverDone)
+
+	}()
+
+	// Forwarder task
+	go func() {
+		defer log.HandlePanic()
+		u.send(batchSize, pool)
+		close(u.senderDone)
+	}()
+}
+
+// stop() puts the connection in the stopped state. In that state, the connection no longer delivers
+// incoming packets and ignores packets present on its input channel. The connection is fully
+// stopped when this method returns.
+func (u *udpConnection) stop() {
+	wasRunning := u.running.Swap(false)
+
+	if wasRunning {
+		u.conn.Close() // Unblock receiver
+		close(u.queue) // Unblock sender
+		<-u.receiverDone
+		<-u.senderDone
+	}
+}
+
+func (u *udpConnection) receive(
+	batchSize int, pool chan *router.Packet, procQs []chan *router.Packet) {
+
+	log.Debug("Receive", "connection", u.name)
+
+	// Each receive loop (therefore each input interface) has a unique random seed for the procID
+	// hash function.
+	hashSeed := fnv1aOffset32
+	randomBytes := make([]byte, 4)
+	if _, err := rand.Read(randomBytes); err != nil {
+		panic("Error while generating random value")
+	}
+	for _, c := range randomBytes {
+		hashSeed = hashFNV1a(hashSeed, c)
+	}
+
+	// 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(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([]*router.Packet, batchSize)
+
+	numReusable := 0 // unused buffers from previous loop
+	ifID := u.ifID
+	metrics := u.metrics
+
+	enqueueForProcessing := func(size int, srcAddr *net.UDPAddr, pkt *router.Packet) {
+		sc := router.ClassOfSize(size)
+		metrics[sc].InputPacketsTotal.Inc()
+		metrics[sc].InputBytesTotal.Add(float64(size))
+
+		procID, err := computeProcID(pkt.RawPacket, len(procQs), hashSeed)
+		if err != nil {
+			log.Debug("Error while computing procID", "err", err)
+			pool <- pkt
+			metrics[sc].DroppedPacketsInvalid.Inc()
+			return
+		}
+
+		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.
+		pkt.Ingress = ifID
+		pkt.SrcAddr = srcAddr
+		select {
+		case procQs[procID] <- pkt:
+		default:
+			pool <- pkt
+			metrics[sc].DroppedPacketsBusyProcessor.Inc()
+		}
+	}
+
+	conn := u.conn
+	for u.running.Load() {
+		// collect packets.
+
+		// Give a new buffer to the msgs elements that have been used in the previous loop.
+		for i := 0; i < batchSize-numReusable; i++ {
+			p := <-pool
+			p.Reset()
+			packets[i] = p
+			msgs[i].Buffers[0] = p.RawPacket
+		}
+
+		// Fill the packets
+		numPkts, err := conn.ReadBatch(msgs)
+		numReusable = len(msgs) - numPkts
+		if err != nil {
+			log.Debug("Error while reading batch", "interfaceID", ifID, "err", err)
+			continue
+		}
+		for i, msg := range msgs[:numPkts] {
+			enqueueForProcessing(msg.N, msg.Addr.(*net.UDPAddr), packets[i])
+		}
+	}
+	for _, pkt := range packets[batchSize-numReusable : batchSize] {
+		pool <- pkt
+	}
+}
+
+// TODO(multi_underlay): simplify this a bit by making it a method of udpConnection.
+func readUpTo(queue <-chan *router.Packet, n int, needsBlocking bool, pkts []*router.Packet) int {
+	i := 0
+	if needsBlocking {
+		p, ok := <-queue
+		if !ok {
+			return i
+		}
+		pkts[i] = p
+		i++
+	}
+
+	for ; i < n; i++ {
+		select {
+		case p, ok := <-queue:
+			if !ok {
+				return i
+			}
+			pkts[i] = p
+		default:
+			return i
+		}
+
+	}
+	return i
+}
+
+// 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 (u *udpConnection) send(batchSize int, pool chan *router.Packet) {
+	log.Debug("Send", "connection", u.name)
+
+	// We use this somewhat like a ring buffer.
+	pkts := make([]*router.Packet, batchSize)
+
+	// We use this as a temporary buffer, but allocate it just once
+	// to save on garbage handling.
+	msgs := make(underlayconn.Messages, batchSize)
+	for i := range msgs {
+		msgs[i].Buffers = make([][]byte, 1)
+	}
+
+	queue := u.queue
+	conn := u.conn
+	metrics := u.metrics
+	toWrite := 0
+
+	for u.running.Load() {
+		// Top-up our batch.
+		toWrite += readUpTo(queue, 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
+			}
+		}
+
+		written, _ := conn.WriteBatch(msgs[:toWrite], 0)
+		if written < 0 {
+			// WriteBatch returns -1 on error, we just consider this as
+			// 0 packets written
+			written = 0
+		}
+		router.UpdateOutputMetrics(metrics, pkts[:written])
+		for _, p := range pkts[:written] {
+			pool <- p
+		}
+		if written != toWrite {
+			// Only one is dropped at this time. We'll retry the rest.
+			sc := router.ClassOfSize(len(pkts[written].RawPacket))
+			metrics[sc].DroppedPacketsInvalid.Inc()
+			pool <- pkts[written]
+			toWrite -= (written + 1)
+			// Shift the leftovers to the head of the buffers.
+			for i := 0; i < toWrite; i++ {
+				pkts[i] = pkts[i+written+1]
+			}
+		} else {
+			toWrite = 0
+		}
+	}
+}
+
+// TODO(jiceatscion): use more inheritance between implementations?
+
+type externalLink struct {
+	queue      chan<- *router.Packet
+	ifID       uint16
+	bfdSession router.BFDSession
+	remote     netip.AddrPort
+}
+
+// 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,
+	metrics router.InterfaceMetrics,
+) router.Link {
+
+	queue := make(chan *router.Packet, qSize)
+	c := &udpConnection{
+		conn:         conn,
+		queue:        queue,
+		ifID:         ifID,
+		name:         remote.String(),
+		metrics:      metrics,
+		receiverDone: make(chan struct{}),
+		senderDone:   make(chan struct{}),
+	}
+	l := &externalLink{
+		bfdSession: bfd,
+		queue:      queue,
+		ifID:       ifID,
+	}
+
+	u.mu.Lock()
+	defer u.mu.Unlock()
+
+	u.allConnections[remote] = c
+	u.allLinks[remote] = l
+	return l
+}
+
+func (l *externalLink) start(ctx context.Context) {
+	if l.bfdSession == nil {
+		return
+	}
+	go func() {
+		defer log.HandlePanic()
+		if err := l.bfdSession.Run(ctx); err != nil && err != bfd.AlreadyRunning {
+			log.Error("BFD session failed to start", "remote address", l.remote, "err", err)
+		}
+	}()
+}
+
+func (l *externalLink) stop() {
+	if l.bfdSession == nil {
+		return
+	}
+	l.bfdSession.Close()
+}
+
+func (l *externalLink) Scope() router.LinkScope {
+	return router.External
+}
+
+func (l *externalLink) IsUp() bool {
+	return l.bfdSession == nil || l.bfdSession.IsUp()
+}
+
+func (l *externalLink) BFDSession() router.BFDSession {
+	return l.bfdSession
+}
+
+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,
+	metrics router.InterfaceMetrics,
+) router.Link {
+
+	u.mu.Lock()
+	defer u.mu.Unlock()
+
+	// 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, // And therefor we do not use qsize for now.
+		bfdSession: bfd,
+		remote:     remote,
+	}
+	u.allLinks[remote] = s
+	return s
+}
+
+func (l *siblingLink) start(ctx context.Context) {
+	if l.bfdSession == nil {
+		return
+	}
+	go func() {
+		defer log.HandlePanic()
+		if err := l.bfdSession.Run(ctx); err != nil && err != bfd.AlreadyRunning {
+			log.Error("BFD session failed to start", "remote address", l.remote, "err", err)
+		}
+	}()
+}
+
+func (l *siblingLink) stop() {
+	if l.bfdSession == nil {
+		return
+	}
+	l.bfdSession.Close()
+}
+
+func (l *siblingLink) Scope() router.LinkScope {
+	return router.Sibling
+}
+
+func (l *siblingLink) IsUp() bool {
+	return l.bfdSession == nil || l.bfdSession.IsUp()
+}
+
+func (l *siblingLink) BFDSession() router.BFDSession {
+	return l.bfdSession
+}
+
+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
+}
+
+// NewInternalLink returns a internal 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, metrics router.InterfaceMetrics) router.Link {
+
+	queue := make(chan *router.Packet, qSize)
+	c := &udpConnection{
+		conn:         conn,
+		queue:        queue,
+		name:         "internal",
+		ifID:         0,
+		metrics:      metrics,
+		receiverDone: make(chan struct{}),
+		senderDone:   make(chan struct{}),
+	}
+	l := &internalLink{
+		queue: queue,
+	}
+
+	u.mu.Lock()
+	defer u.mu.Unlock()
+
+	u.allConnections[netip.AddrPort{}] = c
+	u.allLinks[netip.AddrPort{}] = l
+	return l
+}
+
+func (l *internalLink) start(ctx context.Context) {
+}
+
+func (l *internalLink) stop() {
+}
+
+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) 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
+}
+
+// TODO(multi_underlay): For bound connections we could make this cheaper.
+func computeProcID(data []byte, numProcRoutines int, hashSeed uint32) (uint32, error) {
+	if len(data) < slayers.CmnHdrLen {
+		return 0, errShortPacket
+	}
+	dstHostAddrLen := slayers.AddrType(data[9] >> 4 & 0xf).Length()
+	srcHostAddrLen := slayers.AddrType(data[9] & 0xf).Length()
+	addrHdrLen := 2*addr.IABytes + srcHostAddrLen + dstHostAddrLen
+	if len(data) < slayers.CmnHdrLen+addrHdrLen {
+		return 0, errShortPacket
+	}
+
+	s := hashSeed
+
+	// inject the flowID
+	s = hashFNV1a(s, data[1]&0xF) // The left 4 bits aren't part of the flowID.
+	for _, c := range data[2:4] {
+		s = hashFNV1a(s, c)
+	}
+
+	// Inject the src/dst addresses
+	for _, c := range data[slayers.CmnHdrLen : slayers.CmnHdrLen+addrHdrLen] {
+		s = hashFNV1a(s, c)
+	}
+
+	return s % uint32(numProcRoutines), nil
+}


Test patch:
diff --git a/router/dataplane_internal_test.go b/router/dataplane_internal_test.go
index 3ce3eead4..a2ff745f4 100644
--- a/router/dataplane_internal_test.go
+++ b/router/dataplane_internal_test.go
@@ -1,4 +1,5 @@
 // Copyright 2023 ETH Zurich
+// 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.
@@ -16,9 +17,7 @@ package router
 
 import (
 	"bytes"
-	"crypto/rand"
-	"encoding/binary"
-	"hash/fnv"
+	"context"
 	mrand "math/rand/v2"
 	"net"
 	"net/netip"
@@ -32,7 +31,6 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/scionproto/scion/pkg/addr"
-	"github.com/scionproto/scion/pkg/private/serrors"
 	"github.com/scionproto/scion/pkg/private/util"
 	"github.com/scionproto/scion/pkg/scrypto"
 	"github.com/scionproto/scion/pkg/slayers"
@@ -53,18 +51,21 @@ var (
 func TestReceiver(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
-	dp := &DataPlane{
-		underlay:   newUnderlay(),
-		interfaces: make(map[uint16]Link),
-		Metrics:    metrics,
-		RunConfig: RunConfig{
-			NumProcessors: 1,
-			BatchSize:     64,
-		},
-	}
+	dp := newDataPlane(RunConfig{NumProcessors: 1, BatchSize: 64}, false)
 	counter := 0
 	mInternal := mock_router.NewMockBatchConn(ctrl)
 	done := make(chan bool)
+	closeChan := make(chan struct{})
+	mInternal.EXPECT().Close().DoAndReturn(
+		func() error {
+			close(closeChan)
+			return nil
+		}).AnyTimes()
+	mInternal.EXPECT().WriteBatch(gomock.Any(), 0).DoAndReturn(
+		func(ms underlayconn.Messages, flags int) (int, error) {
+			<-closeChan
+			return 0, nil
+		}).AnyTimes()
 	mInternal.EXPECT().ReadBatch(gomock.Any()).DoAndReturn(
 		func(m underlayconn.Messages) (int, error) {
 			for i := 0; i < 10; i++ {
@@ -80,8 +81,8 @@ func TestReceiver(t *testing.T) {
 	).Times(2)
 	mInternal.EXPECT().ReadBatch(gomock.Any()).DoAndReturn(
 		func(m underlayconn.Messages) (int, error) {
-			dp.setStopping()
 			done <- true
+			<-closeChan // Nothing more to read. Wait for shutdown.
 			return 0, nil
 		},
 	).Times(1)
@@ -92,10 +93,7 @@ func TestReceiver(t *testing.T) {
 	procCh, _ := dp.initQueues(64)
 	initialPoolSize := len(dp.packetPool)
 	dp.setRunning()
-	dp.initMetrics()
-	go func() {
-		dp.runReceiver(dp.underlay.Connections()[netip.AddrPort{}], procCh)
-	}()
+	dp.underlay.Start(context.Background(), dp.packetPool, procCh)
 	ptrMap := make(map[uintptr]struct{})
 	for i := 0; i < 21; i++ {
 		select {
@@ -103,24 +101,27 @@ func TestReceiver(t *testing.T) {
 			// make sure that the pool size has decreased
 			assert.Greater(t, initialPoolSize, len(dp.packetPool))
 			// make sure that the packet has the right size
-			assert.Equal(t, 84+i%10*18, len(pkt.rawPacket))
+			assert.Equal(t, 84+i%10*18, len(pkt.RawPacket))
 			// make sure that the source address was set correctly
-			assert.Equal(t, net.UDPAddr{IP: net.IP{10, 0, 200, 200}}, *pkt.srcAddr)
+			assert.Equal(t, net.UDPAddr{IP: net.IP{10, 0, 200, 200}}, *pkt.SrcAddr)
 			// make sure that the received pkt buffer has not been seen before
-			ptr := reflect.ValueOf(pkt.rawPacket).Pointer()
+			ptr := reflect.ValueOf(pkt.RawPacket).Pointer()
 			assert.NotContains(t, ptrMap, ptr)
 			ptrMap[ptr] = struct{}{}
 		case <-time.After(50 * time.Millisecond):
 			// make sure that the processing routine received exactly 20 messages
 			if i != 20 {
 				t.Fail()
-				dp.setStopping()
 			}
 		}
 	}
 	<-done
+
+	dp.setStopping()
+
 	// make sure that the packet pool has the expected size after the test
 	assert.Equal(t, initialPoolSize-dp.RunConfig.BatchSize-20, len(dp.packetPool))
+	dp.underlay.Stop()
 }
 
 // TestForwarder sets up a mocked batchConn, starts the forwarder that will write to
@@ -131,20 +132,23 @@ func TestForwarder(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
 	done := make(chan struct{})
-	prepareDP := func(ctrl *gomock.Controller) *DataPlane {
-		ret := &DataPlane{
-			underlay: newUnderlay(),
-			Metrics:  metrics,
-			RunConfig: RunConfig{
-				NumProcessors:         20,
-				BatchSize:             64,
-				NumSlowPathProcessors: 1,
-			},
-		}
-
+	prepareDP := func(ctrl *gomock.Controller) *dataPlane {
+		ret := newDataPlane(
+			RunConfig{NumProcessors: 20, BatchSize: 64, NumSlowPathProcessors: 1}, false)
 		mInternal := mock_router.NewMockBatchConn(ctrl)
 		totalCount := 0
 		expectedPktId := byte(0)
+		closeChan := make(chan struct{})
+		mInternal.EXPECT().Close().DoAndReturn(
+			func() error {
+				close(closeChan)
+				return nil
+			}).AnyTimes()
+		mInternal.EXPECT().ReadBatch(gomock.Any()).DoAndReturn(
+			func(ms underlayconn.Messages) (int, error) {
+				<-closeChan
+				return 0, nil
+			}).AnyTimes()
 		mInternal.EXPECT().WriteBatch(gomock.Any(), 0).DoAndReturn(
 			func(ms underlayconn.Messages, flags int) (int, error) {
 				if totalCount == 255 {
@@ -173,7 +177,6 @@ func TestForwarder(t *testing.T) {
 					}
 				}
 				if totalCount == 255 {
-					ret.setStopping()
 					done <- struct{}{}
 				}
 				if len(ms) == 0 {
@@ -187,26 +190,23 @@ func TestForwarder(t *testing.T) {
 	}
 	dp := prepareDP(ctrl)
 	dp.initPacketPool(64)
-	dp.initQueues(64)
-	conn := dp.underlay.Connections()[netip.AddrPort{}]
+	procQs, _ := dp.initQueues(64)
 	intf := dp.interfaces[0]
 	initialPoolSize := len(dp.packetPool)
 	dp.setRunning()
-	dp.initMetrics()
-	go dp.runForwarder(conn)
-
+	dp.underlay.Start(context.Background(), dp.packetPool, procQs)
 	dstAddr := &net.UDPAddr{IP: net.IP{10, 0, 200, 200}}
 	for i := 0; i < 255; i++ {
 		pkt := <-dp.packetPool
-		pkt.reset()
-		pkt.rawPacket = pkt.rawPacket[:1]
-		pkt.rawPacket[0] = byte(i)
+		pkt.Reset()
+		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.srcAddr = &net.UDPAddr{} // Receiver always sets this.
-		pkt.ingress = 0
+		pkt.SrcAddr = &net.UDPAddr{} // Receiver always sets this.
+		pkt.Ingress = 0
 
 		assert.NotEqual(t, initialPoolSize, len(dp.packetPool))
 
@@ -215,228 +215,22 @@ func TestForwarder(t *testing.T) {
 		// However we want to exclude queue overflow from the test. So we want a blocking send.
 		intf.SendBlocking(pkt)
 	}
+
 	select {
 	case <-done:
+		dp.underlay.Stop()
+		dp.setStopping()
 		time.Sleep(100 * time.Millisecond)
 		assert.Equal(t, initialPoolSize, len(dp.packetPool))
 	case <-time.After(100 * time.Millisecond):
-		t.Fail()
+		dp.underlay.Stop()
 		dp.setStopping()
-	}
-}
-
-func TestComputeProcId(t *testing.T) {
-	randomValueBytes := []byte{1, 2, 3, 4}
-	numProcs := 10000
-
-	// ComputeProcID expects the per-receiver random number to be pre-hashed into the seed that we
-	// pass.
-	hashSeed := fnv1aOffset32
-	for _, c := range randomValueBytes {
-		hashSeed = hashFNV1a(hashSeed, c)
-	}
-
-	// this function returns the procID as we expect it by using the  slayers.SCION serialization
-	// implementation.
-	referenceHash := func(s *slayers.SCION) uint32 {
-		flowBuf := make([]byte, 4)
-		binary.BigEndian.PutUint32(flowBuf, s.FlowID)
-		flowBuf[0] &= 0xF
-		tmpBuffer := make([]byte, 100)
-		hasher := fnv.New32a()
-		hasher.Write(randomValueBytes)
-		hasher.Write(flowBuf[1:4])
-		if err := s.SerializeAddrHdr(tmpBuffer); err != nil {
-			panic(err)
-		}
-		hasher.Write(tmpBuffer[:s.AddrHdrLen()])
-		return hasher.Sum32() % uint32(numProcs)
-	}
-
-	// this helper returns the procID as the router actually makes it by using the extraction
-	// from dataplane.computeProcID() along with hashFNV1a() for the seed.
-	computeProcIDHelper := func(payload []byte, s *slayers.SCION) (uint32, error) {
-		buffer := gopacket.NewSerializeBuffer()
-		err := gopacket.SerializeLayers(buffer,
-			gopacket.SerializeOptions{FixLengths: true},
-			s, gopacket.Payload(payload))
-		require.NoError(t, err)
-		raw := buffer.Bytes()
-
-		return computeProcID(raw, numProcs, hashSeed)
-	}
-	type ret struct {
-		payload []byte
-		s       *slayers.SCION
-	}
-	// Each testcase has a function that returns a set of ret structs where
-	// all rets of that set are expected to return the same hash value
-	testCases := map[string]func(t *testing.T) []ret{
-		"basic": func(t *testing.T) []ret {
-			payload := []byte("x")
-			return []ret{
-				{
-					payload: payload,
-					s:       prepBaseMsg(t, payload, (1<<20)-1),
-				},
-			}
-		},
-		"different payload does not affect hashing": func(t *testing.T) []ret {
-			rets := make([]ret, 10)
-			for i := 0; i < 10; i++ {
-				rets[i].payload = make([]byte, 100)
-				_, err := rand.Read(rets[i].payload)
-				spkt := prepBaseMsg(t, rets[i].payload, 1)
-				assert.NoError(t, err)
-				rets[i].s = spkt
-			}
-			return rets
-		},
-		"flowID is extracted correctly independing of trafficId": func(t *testing.T) []ret {
-			rets := make([]ret, 16)
-			payload := make([]byte, 100)
-			for i := 0; i < 16; i++ {
-				rets[i].payload = payload
-				spkt := prepBaseMsg(t, rets[i].payload, 1)
-				spkt.TrafficClass = uint8(i)
-				rets[i].s = spkt
-			}
-			return rets
-		},
-		"ipv4 to ipv4": func(t *testing.T) []ret {
-			payload := make([]byte, 100)
-			spkt := prepBaseMsg(t, payload, 1)
-			_ = spkt.SetDstAddr(addr.HostIP(netip.AddrFrom4([4]byte{10, 0, 200, 200})))
-			_ = spkt.SetSrcAddr(addr.HostIP(netip.AddrFrom4([4]byte{10, 0, 200, 200})))
-			assert.Equal(t, slayers.T4Ip, spkt.DstAddrType)
-			assert.Equal(t, slayers.T4Ip, spkt.SrcAddrType)
-			return []ret{
-				{
-					payload: payload,
-					s:       spkt,
-				},
-			}
-		},
-		"ipv6 to ipv4": func(t *testing.T) []ret {
-			payload := make([]byte, 100)
-			spkt := prepBaseMsg(t, payload, 1)
-			_ = spkt.SetDstAddr(addr.HostIP(netip.AddrFrom4([4]byte{10, 0, 200, 200})))
-			_ = spkt.SetSrcAddr(addr.HostIP(netip.MustParseAddr("2001:db8::68")))
-			assert.Equal(t, slayers.T4Ip, spkt.DstAddrType)
-			assert.Equal(t, slayers.T16Ip, int(spkt.SrcAddrType))
-			return []ret{
-				{
-					payload: payload,
-					s:       spkt,
-				},
-			}
-		},
-		"svc to ipv4": func(t *testing.T) []ret {
-			payload := make([]byte, 100)
-			spkt := prepBaseMsg(t, payload, 1)
-			spkt.DstAddrType = slayers.T4Ip
-			_ = spkt.SetDstAddr(addr.HostIP(netip.AddrFrom4([4]byte{10, 0, 200, 200})))
-			_ = spkt.SetSrcAddr(addr.HostSVC(addr.SvcWildcard))
-			assert.Equal(t, slayers.T4Ip, spkt.DstAddrType)
-			assert.Equal(t, slayers.T4Svc, int(spkt.SrcAddrType))
-			return []ret{
-				{
-					payload: payload,
-					s:       spkt,
-				},
-			}
-		},
-	}
-	for name, tc := range testCases {
-		t.Run(name, func(t *testing.T) {
-			rets := tc(t)
-			if len(rets) == 0 {
-				return
-			}
-			expected := referenceHash(rets[0].s)
-			for _, r := range rets {
-				actual, err := computeProcIDHelper(r.payload, r.s)
-				// this tests do not test errors, hence no errors should occur
-				assert.NoError(t, err)
-				assert.Equal(t, expected, actual)
-			}
-		})
-	}
-}
-
-func TestComputeProcIdErrorCases(t *testing.T) {
-	type test struct {
-		data          []byte
-		expectedError error
-	}
-	testCases := map[string]test{
-		"packet shorter than common header len": {
-			data:          make([]byte, 10),
-			expectedError: serrors.New("Packet is too short"),
-		},
-		"packet len = CmnHdrLen + addrHdrLen": {
-			data: []byte{
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0,
-			},
-			expectedError: nil,
-		},
-		"packet len < CmnHdrLen + addrHdrLen": {
-			data: []byte{
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0,
-			},
-			expectedError: serrors.New("Packet is too short"),
-		},
-		"packet len = CmnHdrLen + addrHdrLen (16IP)": {
-			data: []byte{
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0x33, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0,
-			},
-			expectedError: nil,
-		},
-		"packet len < CmnHdrLen + addrHdrLen (16IP)": {
-			data: []byte{
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0x33, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0, 0, 0, 0, 0, 0,
-				0, 0, 0,
-			},
-			expectedError: serrors.New("Packet is too short"),
-		},
-	}
-	for name, tc := range testCases {
-		t.Run(name, func(t *testing.T) {
-			randomValue := uint32(1234) // not a proper hash seed, but hash result is irrelevant.
-			_, actualErr := computeProcID(tc.data, 10000, randomValue)
-			if tc.expectedError != nil {
-				assert.Equal(t, tc.expectedError.Error(), actualErr.Error())
-			} else {
-				assert.NoError(t, actualErr)
-			}
-		})
+		t.Fail()
 	}
 }
 
 func TestSlowPathProcessing(t *testing.T) {
 	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
 	payload := []byte("actualpayloadbytes")
 
 	// ProcessPacket assumes some pre-conditions:
@@ -448,14 +242,14 @@ func TestSlowPathProcessing(t *testing.T) {
 
 	testCases := map[string]struct {
 		mockMsg                 func() []byte
-		prepareDP               func(*gomock.Controller) *DataPlane
+		prepareDP               func(*gomock.Controller) *dataPlane
 		expectedSlowPathRequest slowPathRequest
 		srcInterface            uint16
 		expectedLayerType       gopacket.LayerType
 	}{
 		"svc nobackend": {
-			prepareDP: func(ctrl *gomock.Controller) *DataPlane {
-				return NewDP(fakeExternalInterfaces,
+			prepareDP: func(ctrl *gomock.Controller) *dataPlane {
+				return newDP(fakeExternalInterfaces,
 					nil, mock_router.NewMockBatchConn(ctrl),
 					fakeInternalNextHops,
 					fakeServices,
@@ -478,8 +272,8 @@ func TestSlowPathProcessing(t *testing.T) {
 			expectedLayerType: slayers.LayerTypeSCMPDestinationUnreachable,
 		},
 		"svc invalid": {
-			prepareDP: func(ctrl *gomock.Controller) *DataPlane {
-				return NewDP(fakeExternalInterfaces,
+			prepareDP: func(ctrl *gomock.Controller) *dataPlane {
+				return newDP(fakeExternalInterfaces,
 					nil, mock_router.NewMockBatchConn(ctrl),
 					fakeInternalNextHops,
 					fakeServices,
@@ -501,8 +295,8 @@ func TestSlowPathProcessing(t *testing.T) {
 			expectedLayerType: slayers.LayerTypeSCMPDestinationUnreachable,
 		},
 		"invalid dest": {
-			prepareDP: func(ctrl *gomock.Controller) *DataPlane {
-				return NewDP(fakeExternalInterfaces,
+			prepareDP: func(ctrl *gomock.Controller) *dataPlane {
+				return newDP(fakeExternalInterfaces,
 					nil, mock_router.NewMockBatchConn(ctrl),
 					fakeInternalNextHops,
 					fakeServices,
@@ -524,8 +318,8 @@ func TestSlowPathProcessing(t *testing.T) {
 			expectedLayerType: slayers.LayerTypeSCMPParameterProblem,
 		},
 		"invalid dest addr": {
-			prepareDP: func(ctrl *gomock.Controller) *DataPlane {
-				return NewDP(fakeExternalInterfaces,
+			prepareDP: func(ctrl *gomock.Controller) *dataPlane {
+				return newDP(fakeExternalInterfaces,
 					nil, mock_router.NewMockBatchConn(ctrl),
 					fakeInternalNextHops,
 					fakeServices,
@@ -547,8 +341,8 @@ func TestSlowPathProcessing(t *testing.T) {
 			expectedLayerType: slayers.LayerTypeSCMPParameterProblem,
 		},
 		"invalid dest v4mapped": {
-			prepareDP: func(ctrl *gomock.Controller) *DataPlane {
-				return NewDP(fakeExternalInterfaces,
+			prepareDP: func(ctrl *gomock.Controller) *dataPlane {
+				return newDP(fakeExternalInterfaces,
 					nil, mock_router.NewMockBatchConn(ctrl),
 					fakeInternalNextHops,
 					fakeServices,
@@ -572,8 +366,8 @@ func TestSlowPathProcessing(t *testing.T) {
 			expectedLayerType: slayers.LayerTypeSCMPParameterProblem,
 		},
 		"invalid dest unspecified": {
-			prepareDP: func(ctrl *gomock.Controller) *DataPlane {
-				return NewDP(fakeExternalInterfaces,
+			prepareDP: func(ctrl *gomock.Controller) *dataPlane {
+				return newDP(fakeExternalInterfaces,
 					nil, mock_router.NewMockBatchConn(ctrl),
 					fakeInternalNextHops,
 					fakeServices,
@@ -595,8 +389,8 @@ func TestSlowPathProcessing(t *testing.T) {
 			expectedLayerType: slayers.LayerTypeSCMPParameterProblem,
 		},
 		"invalid src v4mapped": {
-			prepareDP: func(ctrl *gomock.Controller) *DataPlane {
-				return NewDP(fakeExternalInterfaces,
+			prepareDP: func(ctrl *gomock.Controller) *dataPlane {
+				return newDP(fakeExternalInterfaces,
 					nil, mock_router.NewMockBatchConn(ctrl),
 					fakeInternalNextHops,
 					fakeServices,
@@ -621,26 +415,25 @@ func TestSlowPathProcessing(t *testing.T) {
 			expectedLayerType: slayers.LayerTypeSCMPParameterProblem,
 		},
 	}
+
 	for name, tc := range testCases {
 		name, tc := name, tc
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
 			dp := tc.prepareDP(ctrl)
-			dp.initMetrics()
 
 			rp := tc.mockMsg()
 			pkt := Packet{}
 			pkt.init(&[bufSize]byte{})
-			pkt.reset()
-			pkt.ingress = tc.srcInterface
-			pkt.srcAddr = &net.UDPAddr{} // The receiver always sets this.
-			pkt.rawPacket = pkt.rawPacket[:len(rp)]
-			copy(pkt.rawPacket, rp)
+			pkt.Reset()
+			pkt.Ingress = tc.srcInterface
+			pkt.SrcAddr = &net.UDPAddr{} // The receiver always sets this.
+			pkt.RawPacket = pkt.RawPacket[:len(rp)]
+			copy(pkt.RawPacket, rp)
 
 			processor := newPacketProcessor(dp)
 			disp := processor.processPkt(&pkt)
 			assert.Equal(t, pSlowPath, disp)
-
 			assert.Equal(t, tc.expectedSlowPathRequest, pkt.slowPathRequest)
 			slowPathProcessor := newSlowPathProcessor(dp)
 			err := slowPathProcessor.processPacket(&pkt)
@@ -648,7 +441,7 @@ func TestSlowPathProcessing(t *testing.T) {
 
 			// here we parse the outgoing packet to verify that it contains the correct SCMP
 			// header and typecodes.
-			packet := gopacket.NewPacket(pkt.rawPacket, slayers.LayerTypeSCION, gopacket.Default)
+			packet := gopacket.NewPacket(pkt.RawPacket, slayers.LayerTypeSCION, gopacket.Default)
 			scmp := packet.Layer(slayers.LayerTypeSCMP).(*slayers.SCMP)
 			expectedTypeCode := slayers.CreateSCMPTypeCode(tc.expectedSlowPathRequest.scmpType,
 				tc.expectedSlowPathRequest.code)
diff --git a/router/dataplane_test.go b/router/dataplane_test.go
index 404e0de64..707ce731d 100644
--- a/router/dataplane_test.go
+++ b/router/dataplane_test.go
@@ -1,5 +1,6 @@
 // Copyright 2020 Anapaya Systems
 // Copyright 2023 ETH Zurich
+// 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.
@@ -47,11 +48,9 @@ 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 (
-	metrics    = router.GetMetrics()
 	srcUDPPort = 50001
 	dstUDPPort = 50002
 )
@@ -60,31 +59,27 @@ func TestDataPlaneAddInternalInterface(t *testing.T) {
 	internalIP := netip.MustParseAddr("198.51.100.1")
 	t.Run("fails after serve", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
-		defer ctrl.Finish()
 
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		d.FakeStart()
 		assert.Error(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), internalIP))
 	})
 	t.Run("setting nil value is not allowed", func(t *testing.T) {
-		ctrl := gomock.NewController(t)
-		defer ctrl.Finish()
+		gomock.NewController(t)
 
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.Error(t, d.AddInternalInterface(nil, netip.Addr{}))
 	})
 	t.Run("single set works", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
-		defer ctrl.Finish()
 
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), internalIP))
 	})
 	t.Run("double set fails", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
-		defer ctrl.Finish()
 
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), internalIP))
 		assert.Error(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), internalIP))
 	})
@@ -92,21 +87,21 @@ func TestDataPlaneAddInternalInterface(t *testing.T) {
 
 func TestDataPlaneSetKey(t *testing.T) {
 	t.Run("fails after serve", func(t *testing.T) {
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		d.FakeStart()
 		assert.Error(t, d.SetKey([]byte("dummy")))
 	})
 	t.Run("setting nil value is not allowed", func(t *testing.T) {
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		d.FakeStart()
 		assert.Error(t, d.SetKey(nil))
 	})
 	t.Run("single set works", func(t *testing.T) {
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.NoError(t, d.SetKey([]byte("dummy key xxxxxx")))
 	})
 	t.Run("double set fails", func(t *testing.T) {
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.NoError(t, d.SetKey([]byte("dummy key xxxxxx")))
 		assert.Error(t, d.SetKey([]byte("dummy key xxxxxx")))
 	})
@@ -124,40 +119,35 @@ func TestDataPlaneAddExternalInterface(t *testing.T) {
 	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 := router.NewDPRaw(router.RunConfig{}, false)
 		d.FakeStart()
 		assert.Error(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r, nobfd))
 	})
 	t.Run("setting nil conn is not allowed", func(t *testing.T) {
-		ctrl := gomock.NewController(t)
-		defer ctrl.Finish()
+		gomock.NewController(t)
 
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		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{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		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{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		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{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.NoError(t,
 			d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r, nobfd))
 		assert.NoError(t,
@@ -165,9 +155,8 @@ func TestDataPlaneAddExternalInterface(t *testing.T) {
 	})
 	t.Run("overwrite fails", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
-		defer ctrl.Finish()
 
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.NoError(t,
 			d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r, nobfd))
 		assert.Error(t,
@@ -177,21 +166,21 @@ func TestDataPlaneAddExternalInterface(t *testing.T) {
 
 func TestDataPlaneAddSVC(t *testing.T) {
 	t.Run("succeeds after serve", func(t *testing.T) {
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		d.FakeStart()
 		assert.NoError(t, d.AddSvc(addr.SvcCS, netip.AddrPortFrom(netip.IPv4Unspecified(), 0)))
 	})
 	t.Run("adding empty value is not allowed", func(t *testing.T) {
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.Error(t, d.AddSvc(addr.SvcCS, netip.AddrPort{}))
 	})
 	t.Run("normal set works", func(t *testing.T) {
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.NoError(t, d.AddSvc(addr.SvcCS, netip.AddrPortFrom(netip.IPv4Unspecified(), 0)))
 		assert.NoError(t, d.AddSvc(addr.SvcDS, netip.AddrPortFrom(netip.IPv4Unspecified(), 0)))
 	})
 	t.Run("set multiple times works", func(t *testing.T) {
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		assert.NoError(t, d.AddSvc(addr.SvcCS, netip.AddrPortFrom(netip.IPv4Unspecified(), 0)))
 		assert.NoError(t, d.AddSvc(addr.SvcCS, netip.AddrPortFrom(netip.IPv4Unspecified(), 0)))
 	})
@@ -206,39 +195,34 @@ func TestDataPlaneAddNextHop(t *testing.T) {
 	nobfd := control.BFD{Disable: ptr.To(true)}
 
 	t.Run("fails after serve", func(t *testing.T) {
-		d := &router.DataPlane{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		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{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		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{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		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{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		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{}
+		d := router.NewDPRaw(router.RunConfig{}, false)
 		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, ""))
@@ -248,21 +232,20 @@ func TestDataPlaneAddNextHop(t *testing.T) {
 func TestDataPlaneRun(t *testing.T) {
 	t.Parallel()
 	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
 
 	testCases := map[string]struct {
 		prepareDP func(*gomock.Controller, chan<- struct{}) *router.DataPlane
 	}{
 		"route 10 msg from external to internal": {
 			prepareDP: func(ctrl *gomock.Controller, done chan<- struct{}) *router.DataPlane {
-				ret := &router.DataPlane{
-					Metrics: metrics,
-					RunConfig: router.RunConfig{
+				ret := router.NewDPRaw(
+					router.RunConfig{
 						NumProcessors:         8,
 						BatchSize:             256,
 						NumSlowPathProcessors: 1,
 					},
-				}
+					false,
+				)
 				key := []byte("testkey_xxxxxxxx")
 				local := addr.MustParseIA("1-ff00:0:110")
 
@@ -350,14 +333,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,
-					RunConfig: router.RunConfig{
+				ret := router.NewDPRaw(
+					router.RunConfig{
 						NumProcessors:         8,
 						BatchSize:             256,
 						NumSlowPathProcessors: 1,
 					},
-				}
+					false,
+				)
 
 				postInternalBFD := func(id layers.BFDDiscriminator, src netip.AddrPort) []byte {
 					scn := &slayers.SCION{
@@ -436,14 +419,14 @@ func TestDataPlaneRun(t *testing.T) {
 		},
 		"bfd sender internal": {
 			prepareDP: func(ctrl *gomock.Controller, done chan<- struct{}) *router.DataPlane {
-				ret := &router.DataPlane{
-					Metrics: metrics,
-					RunConfig: router.RunConfig{
+				ret := router.NewDPRaw(
+					router.RunConfig{
 						NumProcessors:         8,
 						BatchSize:             256,
 						NumSlowPathProcessors: 1,
 					},
-				}
+					false,
+				)
 				localAddr := netip.MustParseAddrPort("10.0.200.100:0")
 				remoteAddr := netip.MustParseAddrPort("10.0.200.200:0")
 				mInternal := mock_router.NewMockBatchConn(ctrl)
@@ -494,14 +477,14 @@ func TestDataPlaneRun(t *testing.T) {
 		},
 		"bfd sender external": {
 			prepareDP: func(ctrl *gomock.Controller, done chan<- struct{}) *router.DataPlane {
-				ret := &router.DataPlane{
-					Metrics: metrics,
-					RunConfig: router.RunConfig{
+				ret := router.NewDPRaw(
+					router.RunConfig{
 						NumProcessors:         8,
 						BatchSize:             256,
 						NumSlowPathProcessors: 1,
 					},
-				}
+					false,
+				)
 
 				ifID := uint16(1)
 				mInternal := mock_router.NewMockBatchConn(ctrl)
@@ -554,14 +537,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,
-					RunConfig: router.RunConfig{
+				ret := router.NewDPRaw(
+					router.RunConfig{
 						NumProcessors:         8,
 						BatchSize:             256,
 						NumSlowPathProcessors: 1,
 					},
-				}
+					false,
+				)
 
 				postExternalBFD := func(id layers.BFDDiscriminator, fromIfID uint16) []byte {
 					scn := &slayers.SCION{
@@ -679,7 +662,6 @@ func notDiscarded(t *testing.T, disp router.Disposition) {
 
 func TestProcessPkt(t *testing.T) {
 	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
 
 	key := []byte("testkey_xxxxxxxx")
 	otherKey := []byte("testkey_yyyyyyyy")
diff --git a/router/export_test.go b/router/export_test.go
index c67f8effb..9d7e9ed64 100644
--- a/router/export_test.go
+++ b/router/export_test.go
@@ -1,5 +1,6 @@
 // Copyright 2020 Anapaya Systems
 // Copyright 2023 ETH Zurich
+// 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.
@@ -28,8 +29,6 @@ var (
 	dispatchedPortEnd   = 1<<16 - 1
 )
 
-var metrics = NewMetrics()
-
 func GetMetrics() *Metrics {
 	return metrics
 }
@@ -43,23 +42,24 @@ const PDiscard = Disposition(pDiscard)
 func NewPacket(raw []byte, src, dst *net.UDPAddr, ingress, egress uint16) *Packet {
 	p := 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,
+		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
+		p.SrcAddr = src
 	}
 	if dst != nil {
 		p.DstAddr = dst
 	}
-	copy(p.rawPacket, raw)
+	copy(p.RawPacket, raw)
 	return &p
 }
 
-func NewDP(
+// mustMakeDP initializes a dataplane structure configured per the test requirements.
+func mustMakeDP(
 	external []uint16,
 	linkTypes map[uint16]topology.LinkType,
 	internal BatchConn,
@@ -67,39 +67,114 @@ func NewDP(
 	svc map[addr.SVC][]netip.AddrPort,
 	local addr.IA,
 	neighbors map[uint16]addr.IA,
-	key []byte) *DataPlane {
+	key []byte) (dp dataPlane) {
 
-	dp := &DataPlane{
-		underlay:            newUnderlay(),
-		interfaces:          make(map[uint16]Link),
-		localIA:             local,
-		linkTypes:           linkTypes,
-		neighborIAs:         neighbors,
-		dispatchedPortStart: uint16(dispatchedPortStart),
-		dispatchedPortEnd:   uint16(dispatchedPortEnd),
-		svc:                 &services{m: svc},
-		internalIP:          netip.MustParseAddr("198.51.100.1"),
-		Metrics:             metrics,
+	dp = makeDataPlane(RunConfig{NumProcessors: 1, BatchSize: 64}, false)
+
+	if err := dp.SetIA(local); err != nil {
+		panic(err)
+	}
+	for i, t := range linkTypes {
+		if err := dp.AddLinkType(i, t); err != nil {
+			panic(err)
+		}
+	}
+	for i, n := range neighbors {
+		if err := dp.AddNeighborIA(i, n); err != nil {
+			panic(err)
+		}
+	}
+	dp.SetPortRange(uint16(dispatchedPortStart), uint16(dispatchedPortEnd))
+
+	for id, addresses := range svc {
+		for _, addr := range addresses {
+			if err := dp.AddSvc(id, addr); err != nil {
+				panic(err)
+			}
+		}
 	}
 
-	dp.interfaces[0] = dp.underlay.NewInternalLink(internal, 64)
+	// Make dummy interfaces, as requested by the test. Only the internal interface is ever used to
+	// send or receive and then, not always. So tests can set a nil connections and an empty
+	// address. We do it by hand as the Add*Interface methods do not tolerate nil connections and
+	// empty addresses. It only makes sense in unit tests.
+	dp.addForwardingMetrics(0, Internal)
+	dp.interfaces[0] = dp.underlay.NewInternalLink(
+		internal, dp.RunConfig.BatchSize, dp.forwardingMetrics[0])
+	dp.internalIP = netip.MustParseAddr("198.51.100.1")
 
-	// 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)
+		dp.addForwardingMetrics(i, External)
+		dp.interfaces[i] = dp.underlay.NewExternalLink(
+			nil, 64, nil, netip.AddrPort{}, i, dp.forwardingMetrics[i])
 	}
-
-	// Make dummy sibling interfaces, as requestes by the test.
 	for i, addr := range internalNextHops {
-		dp.interfaces[i] = dp.underlay.NewSiblingLink(64, nil, addr)
+		dp.addForwardingMetrics(i, Sibling)
+		dp.interfaces[i] = dp.underlay.NewSiblingLink(64, nil, addr, dp.forwardingMetrics[i])
 	}
 
 	if err := dp.SetKey(key); err != nil {
 		panic(err)
 	}
-	dp.initMetrics()
-	return dp
+
+	// The rest is normally done by Run(); it is up to the invoking test:
+	// add packet pool
+	// add procQs slowQs
+	// setRunning
+	// start processor and slowPath processor
+	// start underlay
+
+	return
+}
+
+// newDP constructs a dataPlane structure with makeDataPlane and returns it by reference. It
+// returns a pointer to the unexported type, which is usable by internal tests.
+func newDP(
+	external []uint16,
+	linkTypes map[uint16]topology.LinkType,
+	internal BatchConn,
+	internalNextHops map[uint16]netip.AddrPort,
+	svc map[addr.SVC][]netip.AddrPort,
+	local addr.IA,
+	neighbors map[uint16]addr.IA,
+	key []byte) *dataPlane {
+
+	dp := mustMakeDP(external, linkTypes, internal, internalNextHops, svc, local, neighbors, key)
+	return &dp
+}
+
+// dataPlane is a dataplane structure as an exported type for use by non-internal tests.
+type DataPlane struct {
+	dataPlane
+}
+
+// NewDP constructs a DataPlane structure with makeDataPlane and returns it by reference. It
+// returns a pointer to the exported type, which is usable by non-internal tests. A couple of
+// methods are added to the exported type.
+func NewDP(
+	external []uint16,
+	linkTypes map[uint16]topology.LinkType,
+	internal BatchConn,
+	internalNextHops map[uint16]netip.AddrPort,
+	svc map[addr.SVC][]netip.AddrPort,
+	local addr.IA,
+	neighbors map[uint16]addr.IA,
+	key []byte) *DataPlane {
+
+	return &DataPlane{
+		mustMakeDP(external, linkTypes, internal, internalNextHops, svc, local, neighbors, key),
+	}
+}
+
+// NewDPRaw constructs a minimaly initialized DataPlane and returns it by reference. This is useful
+// to non-internal tests that do not want any dataplane configuration beyond the strictly necessary.
+// This is equivalent to router.newDataPlane, but returns an exported type.
+func NewDPRaw(runConfig RunConfig, authSCMP bool) *DataPlane {
+
+	edp := &DataPlane{
+		makeDataPlane(runConfig, authSCMP),
+	}
+	return edp
 }
 
 func (d *DataPlane) FakeStart() {
@@ -108,7 +183,7 @@ func (d *DataPlane) FakeStart() {
 
 func (d *DataPlane) ProcessPkt(pkt *Packet) Disposition {
 
-	p := newPacketProcessor(d)
+	p := newPacketProcessor(&d.dataPlane)
 	disp := p.processPkt(pkt)
 	// Erase trafficType; we don't set it in the expected results.
 	pkt.trafficType = ttOther
diff --git a/router/underlay_import_test.go b/router/underlay_import_test.go
new file mode 100644
index 000000000..2953d46af
--- /dev/null
+++ b/router/underlay_import_test.go
@@ -0,0 +1,29 @@
+// 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 router_test
+
+import (
+	_ "github.com/scionproto/scion/router/underlayproviders/udpip"
+)
+
+// This file exists for the sole purpose of importing underlayproviders, so white-box tests, which
+// are part of the router package have working underlay providers without having to create
+// mocks. Nothing in the router package can import an underlay provider, since non-trivial underlay
+// providers have to import the router.
+//
+// This file has to be in the router_test package as this is the only package that can get mixed
+// in the same test without the router package importing it.
+//
+// Outside of tests, underlay providers are imported by the main or config packages.
diff --git a/router/underlayproviders/udpip/udpip_test.go b/router/underlayproviders/udpip/udpip_test.go
new file mode 100644
index 000000000..7b09cc6c4
--- /dev/null
+++ b/router/underlayproviders/udpip/udpip_test.go
@@ -0,0 +1,294 @@
+// Copyright 2023 ETH Zurich
+// 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 udpip
+
+import (
+	"crypto/rand"
+	"encoding/binary"
+	"hash/fnv"
+	"net/netip"
+	"testing"
+	"time"
+
+	"github.com/gopacket/gopacket"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/scionproto/scion/pkg/addr"
+	"github.com/scionproto/scion/pkg/private/serrors"
+	"github.com/scionproto/scion/pkg/private/util"
+	"github.com/scionproto/scion/pkg/scrypto"
+	"github.com/scionproto/scion/pkg/slayers"
+	"github.com/scionproto/scion/pkg/slayers/path"
+	"github.com/scionproto/scion/pkg/slayers/path/scion"
+)
+
+var (
+	testKey = []byte("testkey_xxxxxxxx")
+)
+
+func computeMAC(t *testing.T, key []byte, info path.InfoField, hf path.HopField) [path.MacLen]byte {
+	mac, err := scrypto.InitMac(key)
+	require.NoError(t, err)
+	return path.MAC(mac, info, hf, nil)
+}
+
+// Prepares a message that is arriving at its last hop, incoming through interface 1.
+func prepBaseMsg(t *testing.T, flowId uint32) *slayers.SCION {
+	spkt := &slayers.SCION{
+		Version:      0,
+		TrafficClass: 0xb8,
+		FlowID:       flowId,
+		NextHdr:      slayers.L4UDP,
+		PathType:     scion.PathType,
+		DstIA:        addr.MustParseIA("1-ff00:0:110"),
+		SrcIA:        addr.MustParseIA("1-ff00:0:111"),
+		Path:         &scion.Raw{},
+		PayloadLen:   18,
+	}
+
+	dpath := &scion.Decoded{
+		Base: scion.Base{
+			PathMeta: scion.MetaHdr{
+				CurrHF: 2,
+				SegLen: [3]uint8{3, 0, 0},
+			},
+			NumINF:  1,
+			NumHops: 3,
+		},
+		InfoFields: []path.InfoField{
+			{SegID: 0x111, ConsDir: true, Timestamp: util.TimeToSecs(time.Now())},
+		},
+
+		HopFields: []path.HopField{
+			{ConsIngress: 41, ConsEgress: 40},
+			{ConsIngress: 31, ConsEgress: 30},
+			{ConsIngress: 1, ConsEgress: 0},
+		},
+	}
+	dpath.HopFields[2].Mac = computeMAC(t, testKey, dpath.InfoFields[0], dpath.HopFields[2])
+	spkt.Path = dpath
+	return spkt
+}
+
+func TestComputeProcId(t *testing.T) {
+	randomValueBytes := []byte{1, 2, 3, 4}
+	numProcs := 10000
+
+	// ComputeProcID expects the per-receiver random number to be pre-hashed into the seed that we
+	// pass.
+	hashSeed := fnv1aOffset32
+	for _, c := range randomValueBytes {
+		hashSeed = hashFNV1a(hashSeed, c)
+	}
+
+	// this function returns the procID as we expect it by using the  slayers.SCION serialization
+	// implementation.
+	referenceHash := func(s *slayers.SCION) uint32 {
+		flowBuf := make([]byte, 4)
+		binary.BigEndian.PutUint32(flowBuf, s.FlowID)
+		flowBuf[0] &= 0xF
+		tmpBuffer := make([]byte, 100)
+		hasher := fnv.New32a()
+		hasher.Write(randomValueBytes)
+		hasher.Write(flowBuf[1:4])
+		if err := s.SerializeAddrHdr(tmpBuffer); err != nil {
+			panic(err)
+		}
+		hasher.Write(tmpBuffer[:s.AddrHdrLen()])
+		return hasher.Sum32() % uint32(numProcs)
+	}
+
+	// this helper returns the procID as the router actually makes it by using the extraction
+	// from dataplane.computeProcID() along with hashFNV1a() for the seed.
+	computeProcIDHelper := func(payload []byte, s *slayers.SCION) (uint32, error) {
+		buffer := gopacket.NewSerializeBuffer()
+		err := gopacket.SerializeLayers(buffer,
+			gopacket.SerializeOptions{FixLengths: true},
+			s, gopacket.Payload(payload))
+		require.NoError(t, err)
+		raw := buffer.Bytes()
+
+		return computeProcID(raw, numProcs, hashSeed)
+	}
+	type ret struct {
+		payload []byte
+		s       *slayers.SCION
+	}
+	// Each testcase has a function that returns a set of ret structs where
+	// all rets of that set are expected to return the same hash value
+	testCases := map[string]func(t *testing.T) []ret{
+		"basic": func(t *testing.T) []ret {
+			payload := []byte("x")
+			return []ret{
+				{
+					payload: payload,
+					s:       prepBaseMsg(t, (1<<20)-1),
+				},
+			}
+		},
+		"different payload does not affect hashing": func(t *testing.T) []ret {
+			rets := make([]ret, 10)
+			for i := 0; i < 10; i++ {
+				rets[i].payload = make([]byte, 100)
+				_, err := rand.Read(rets[i].payload)
+				spkt := prepBaseMsg(t, 1)
+				assert.NoError(t, err)
+				rets[i].s = spkt
+			}
+			return rets
+		},
+		"flowID is extracted correctly independing of trafficId": func(t *testing.T) []ret {
+			rets := make([]ret, 16)
+			payload := make([]byte, 100)
+			for i := 0; i < 16; i++ {
+				rets[i].payload = payload
+				spkt := prepBaseMsg(t, 1)
+				spkt.TrafficClass = uint8(i)
+				rets[i].s = spkt
+			}
+			return rets
+		},
+		"ipv4 to ipv4": func(t *testing.T) []ret {
+			payload := make([]byte, 100)
+			spkt := prepBaseMsg(t, 1)
+			_ = spkt.SetDstAddr(addr.HostIP(netip.AddrFrom4([4]byte{10, 0, 200, 200})))
+			_ = spkt.SetSrcAddr(addr.HostIP(netip.AddrFrom4([4]byte{10, 0, 200, 200})))
+			assert.Equal(t, slayers.T4Ip, spkt.DstAddrType)
+			assert.Equal(t, slayers.T4Ip, spkt.SrcAddrType)
+			return []ret{
+				{
+					payload: payload,
+					s:       spkt,
+				},
+			}
+		},
+		"ipv6 to ipv4": func(t *testing.T) []ret {
+			payload := make([]byte, 100)
+			spkt := prepBaseMsg(t, 1)
+			_ = spkt.SetDstAddr(addr.HostIP(netip.AddrFrom4([4]byte{10, 0, 200, 200})))
+			_ = spkt.SetSrcAddr(addr.HostIP(netip.MustParseAddr("2001:db8::68")))
+			assert.Equal(t, slayers.T4Ip, spkt.DstAddrType)
+			assert.Equal(t, slayers.T16Ip, int(spkt.SrcAddrType))
+			return []ret{
+				{
+					payload: payload,
+					s:       spkt,
+				},
+			}
+		},
+		"svc to ipv4": func(t *testing.T) []ret {
+			payload := make([]byte, 100)
+			spkt := prepBaseMsg(t, 1)
+			spkt.DstAddrType = slayers.T4Ip
+			_ = spkt.SetDstAddr(addr.HostIP(netip.AddrFrom4([4]byte{10, 0, 200, 200})))
+			_ = spkt.SetSrcAddr(addr.HostSVC(addr.SvcWildcard))
+			assert.Equal(t, slayers.T4Ip, spkt.DstAddrType)
+			assert.Equal(t, slayers.T4Svc, int(spkt.SrcAddrType))
+			return []ret{
+				{
+					payload: payload,
+					s:       spkt,
+				},
+			}
+		},
+	}
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			rets := tc(t)
+			if len(rets) == 0 {
+				return
+			}
+			expected := referenceHash(rets[0].s)
+			for _, r := range rets {
+				actual, err := computeProcIDHelper(r.payload, r.s)
+				// this tests do not test errors, hence no errors should occur
+				assert.NoError(t, err)
+				assert.Equal(t, expected, actual)
+			}
+		})
+	}
+}
+
+func TestComputeProcIdErrorCases(t *testing.T) {
+	type test struct {
+		data          []byte
+		expectedError error
+	}
+	testCases := map[string]test{
+		"packet shorter than common header len": {
+			data:          make([]byte, 10),
+			expectedError: serrors.New("Packet is too short"),
+		},
+		"packet len = CmnHdrLen + addrHdrLen": {
+			data: []byte{
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0,
+			},
+			expectedError: nil,
+		},
+		"packet len < CmnHdrLen + addrHdrLen": {
+			data: []byte{
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0,
+			},
+			expectedError: serrors.New("Packet is too short"),
+		},
+		"packet len = CmnHdrLen + addrHdrLen (16IP)": {
+			data: []byte{
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0x33, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0,
+			},
+			expectedError: nil,
+		},
+		"packet len < CmnHdrLen + addrHdrLen (16IP)": {
+			data: []byte{
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0x33, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0,
+			},
+			expectedError: serrors.New("Packet is too short"),
+		},
+	}
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			randomValue := uint32(1234) // not a proper hash seed, but hash result is irrelevant.
+			_, actualErr := computeProcID(tc.data, 10000, randomValue)
+			if tc.expectedError != nil {
+				assert.Equal(t, tc.expectedError.Error(), actualErr.Error())
+			} else {
+				assert.NoError(t, actualErr)
+			}
+		})
+	}
+}