Repository: scionproto/scion
Pull request #4737 (2025-03-13 18:07:07)

router: refactor phase 4
Made the router almost entirely oblivious of underlay addresses. The router now understands only
one underlay address: its own local address on the internal network (and still relies on the assumption that the internal link is on the udpip underlay.)

The router still provides storage for the remote address in the packet structure, which is a net.UdpAddr. It does not use it but resets it when taking a free packet from the pool. This will take more effort to eliminate: we'll need to provide underlay agnostic storage for RemoteAddr.

Sorry for the size of the change. Nothing very fundamental but there was a lot a nitty-gritty affected. There is one loosely related change that might be undesirable: I acted the deprecation of the Public and Bind address fields in topology configs. It just reduced the amount of code to adjust but it could be reverted if we know ppl still depend on it.

Contributes to: [#4593](https://github.com/scionproto/scion/issues/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/4737)
<!-- Reviewable:end -->

romshark: 




*[`router/export_test.go` line 96 at r6](https://reviewable.io/reviews/scionproto/scion/4737#-OMMzem7C7QZ51cN_5GQ:-OMMzem7C7QZ51cN_5GR:bj4xc9i) ([raw file](https://github.com/scionproto/scion/blob/e200c3817078964a1c34e062a4a83516be3b490a/router/export_test.go#L96)):*
> ```Go
> // New returns a BatchConn as the udpip underlay might have. If the field conn is non-nil, then that
> // is what New returns. That enables tests to supply a specific BatchConn implementation. Else new
> // returns an instance of MockBatchConn that is just a place holder; calling any of the methods will
> ```

BTW  "placeholder", typo?


<!-- Sent from Reviewable.io -->


Patch:
diff --git a/private/topology/BUILD.bazel b/private/topology/BUILD.bazel
index a8a38baf9..5cbbb7a3b 100644
--- a/private/topology/BUILD.bazel
+++ b/private/topology/BUILD.bazel
@@ -6,7 +6,6 @@ go_library(
         "doc.go",
         "interface.go",
         "linktype.go",
-        "raw.go",
         "reload.go",
         "servicetype.go",
         "topology.go",
@@ -22,6 +21,7 @@ go_library(
         "//pkg/segment/iface:go_default_library",
         "//private/topology/json:go_default_library",
         "//private/topology/underlay:go_default_library",
+        "//private/underlay/conn:go_default_library",
     ],
 )
 
diff --git a/private/topology/interface.go b/private/topology/interface.go
index a04d9b7ac..e282ed316 100644
--- a/private/topology/interface.go
+++ b/private/topology/interface.go
@@ -44,13 +44,13 @@ type Topology interface {
 	// in which endhost listen for SCION/UDP application using the UDP/IP underlay.
 	PortRange() (uint16, uint16)
 
-	// PublicAddress gets the public address of a server with the requested type and name, and nil
-	// if no such server exists.
+	// PublicAddress gets the public SCION host address of a server with the requested type and
+	// name, or nil if no such server exists.
 	PublicAddress(svc addr.SVC, name string) *net.UDPAddr
 
-	// Anycast returns the address for an arbitrary server of the requested type.
+	// Anycast returns the SCION host address for an arbitrary server of the requested type.
 	Anycast(svc addr.SVC) (*net.UDPAddr, error)
-	// Multicast returns all addresses for the requested type.
+	// Multicast returns all the SCION host addresses for the requested type.
 	Multicast(svc addr.SVC) ([]*net.UDPAddr, error)
 
 	// UnderlayAnycast returns the underlay address for an arbitrary server of the requested type.
diff --git a/private/topology/json/json.go b/private/topology/json/json.go
index d0c68138d..0bcd6722d 100644
--- a/private/topology/json/json.go
+++ b/private/topology/json/json.go
@@ -118,10 +118,9 @@ type BRInterface struct {
 
 // Underlay is the underlay information for a BR interface.
 type Underlay struct {
-	Local            string `json:"local,omitempty"`
-	DeprecatedBind   string `json:"bind,omitempty"`   // superseded by "local", for backwards compat
-	DeprecatedPublic string `json:"public,omitempty"` // superseded by "local", for backwards compat
-	Remote           string `json:"remote,omitempty"`
+	Provider string `json:"provider,omitempty"`
+	Local    string `json:"local,omitempty"`
+	Remote   string `json:"remote,omitempty"`
 }
 
 // BFD configuration.
diff --git a/private/topology/topology.go b/private/topology/topology.go
index 01657fc54..df256953b 100644
--- a/private/topology/topology.go
+++ b/private/topology/topology.go
@@ -33,6 +33,7 @@ import (
 	"github.com/scionproto/scion/pkg/segment/iface"
 	jsontopo "github.com/scionproto/scion/private/topology/json"
 	"github.com/scionproto/scion/private/topology/underlay"
+	"github.com/scionproto/scion/private/underlay/conn"
 )
 
 const (
@@ -92,7 +93,7 @@ type (
 	// struct.
 	BRInfo struct {
 		Name string
-		// InternalAddr is the local data-plane address.
+		// InternalAddr is the local data-plane address (for now it has to be a UDP underlay).
 		InternalAddr netip.AddrPort
 		// IfIDs is a sorted list of the interface IDs.
 		IfIDs []iface.ID
@@ -105,19 +106,28 @@ type (
 
 	// IFInfo describes a border router link to another AS, including the internal data-plane
 	// address applications should send traffic to and information about the link itself and the
-	// remote side of it.
+	// remote side of it. The fields can be confusing. Remember that this describes a connection
+	// to a remote AS. There are three routers and three addresses involved.
+	// * The router that is configured by using this IFInfo: local router.
+	// * The other router in the same AS that owns the interface: owning router.
+	// * The router in the remote AS, at the end of the connection: far router.
+	// * The internal addr of the owning router: internal address.
+	// * The address on the link on the owning router's side: local address.
+	// * The address on the link on the far router's side: remote address.
+	// None of this describes the local router itself. Notably, InternalAddr is NOT that of the
+	// local router.
 	IFInfo struct {
-		// ID is the interface ID. It is unique per AS.
-		ID           iface.ID
-		BRName       string
-		InternalAddr netip.AddrPort
-		Local        netip.AddrPort
-		Remote       netip.AddrPort
-		RemoteIfID   iface.ID
-		IA           addr.IA
-		LinkType     LinkType
-		MTU          int
-		BFD          BFD
+		ID           iface.ID       // ID of this interface in the local AS.
+		BRName       string         // of the owning router
+		InternalAddr netip.AddrPort // of the owning router. It must be a udpip address.
+		Provider     string         // Underlay provider name
+		Local        string         // Underlay addr on owning router side
+		Remote       string         // Underlay addr on far router side
+		RemoteIfID   iface.ID       // ID of this interface for the far router
+		IA           addr.IA        // IA number of the remote AS
+		LinkType     LinkType       // (Child, Parent, Core or Peering)
+		MTU          int            // of the link (configured - could be wrong and needs update)
+		BFD          BFD            // (configuration of)
 	}
 
 	// IDAddrMap maps process IDs to their topology addresses.
@@ -260,7 +270,7 @@ func (t *RWTopology) populateBR(raw *jsontopo.Topology) error {
 		if rawBr.InternalAddr == "" {
 			return serrors.New("Missing Internal Address", "br", name)
 		}
-		intAddr, err := resolveAddrPort(rawBr.InternalAddr)
+		intAddr, err := conn.ResolveAddrPort(rawBr.InternalAddr)
 		if err != nil {
 			return serrors.Wrap("unable to extract underlay internal data-plane address", err)
 		}
@@ -309,16 +319,12 @@ func (t *RWTopology) populateBR(raw *jsontopo.Topology) error {
 				t.IFInfoMap[ifID] = ifinfo
 				continue
 			}
-			if ifinfo.Local, err = rawBRIntfLocalAddr(&rawIntf.Underlay); err != nil {
-				return serrors.Wrap("unable to extract "+
-					"underlay external data-plane local address", err)
-
-			}
-			if ifinfo.Remote, err = resolveAddrPort(rawIntf.Underlay.Remote); err != nil {
-				return serrors.Wrap("unable to extract "+
-					"underlay external data-plane remote address", err)
-
+			ifinfo.Provider = rawIntf.Underlay.Provider
+			if ifinfo.Provider == "" { // Backward compatible with older configs
+				ifinfo.Provider = "udpip"
 			}
+			ifinfo.Local = rawIntf.Underlay.Local
+			ifinfo.Remote = rawIntf.Underlay.Remote
 			brInfo.IFs[ifID] = &ifinfo
 			t.IFInfoMap[ifID] = ifinfo
 		}
@@ -512,7 +518,7 @@ func (svc *svcInfo) getAllTopoAddrs() []TopoAddr {
 func svcMapFromRaw(ras map[string]*jsontopo.ServerInfo) (IDAddrMap, error) {
 	svcMap := make(IDAddrMap)
 	for name, svc := range ras {
-		a, err := resolveAddrPort(svc.Addr)
+		a, err := conn.ResolveAddrPort(svc.Addr)
 		if err != nil {
 			return nil, serrors.Wrap("could not parse address", err,
 				"address", svc.Addr, "process_name", name)
@@ -530,13 +536,13 @@ func svcMapFromRaw(ras map[string]*jsontopo.ServerInfo) (IDAddrMap, error) {
 func gatewayMapFromRaw(ras map[string]*jsontopo.GatewayInfo) (map[string]GatewayInfo, error) {
 	ret := make(map[string]GatewayInfo)
 	for name, svc := range ras {
-		c, err := resolveAddrPort(svc.CtrlAddr)
+		c, err := conn.ResolveAddrPort(svc.CtrlAddr)
 		if err != nil {
 			return nil, serrors.Wrap("could not parse control address", err,
 				"address", svc.CtrlAddr, "process_name", name)
 
 		}
-		d, err := resolveAddrPort(svc.DataAddr)
+		d, err := conn.ResolveAddrPort(svc.DataAddr)
 		if err != nil {
 			return nil, serrors.Wrap("could not parse data address", err,
 				"address", svc.DataAddr, "process_name", name)
@@ -546,7 +552,7 @@ func gatewayMapFromRaw(ras map[string]*jsontopo.GatewayInfo) (map[string]Gateway
 		// default (ctrl address & port 30856):
 		probeAddr := netip.AddrPortFrom(c.Addr(), 30856)
 		if svc.ProbeAddr != "" {
-			probeAddr, err = resolveAddrPort(svc.ProbeAddr)
+			probeAddr, err = conn.ResolveAddrPort(svc.ProbeAddr)
 			if err != nil {
 				return nil, serrors.Wrap("could not parse probe address", err,
 					"address", svc.ProbeAddr, "process_name", name)
diff --git a/private/underlay/conn/BUILD.bazel b/private/underlay/conn/BUILD.bazel
index 74847c406..4d0faab0a 100644
--- a/private/underlay/conn/BUILD.bazel
+++ b/private/underlay/conn/BUILD.bazel
@@ -1,8 +1,9 @@
-load("//tools/lint:go.bzl", "go_library")
+load("//tools/lint:go.bzl", "go_library", "go_test")
 
 go_library(
     name = "go_default_library",
     srcs = [
+        "address.go",
         "conn.go",
         "flags.go",
         "flags_linux.go",
@@ -17,3 +18,10 @@ go_library(
         "@org_golang_x_net//ipv6:go_default_library",
     ],
 )
+
+go_test(
+    name = "go_default_test",
+    srcs = ["address_test.go"],
+    embed = [":go_default_library"],
+    deps = ["@com_github_stretchr_testify//assert:go_default_library"],
+)
diff --git a/private/topology/raw.go b/private/underlay/conn/address.go
similarity index 55%
rename from private/topology/raw.go
rename to private/underlay/conn/address.go
index 2f9f2b898..463676ce5 100644
--- a/private/topology/raw.go
+++ b/private/underlay/conn/address.go
@@ -13,7 +13,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package topology
+package conn
 
 import (
 	"net"
@@ -21,38 +21,14 @@ import (
 	"strconv"
 
 	"github.com/scionproto/scion/pkg/private/serrors"
-	jsontopo "github.com/scionproto/scion/private/topology/json"
 )
 
-func rawBRIntfLocalAddr(u *jsontopo.Underlay) (netip.AddrPort, error) {
-	if (u.DeprecatedPublic != "" || u.DeprecatedBind != "") && u.Local != "" {
-		return netip.AddrPort{},
-			serrors.New(`deprecated "public" and "bind" fields cannot be combined with "local"`,
-				"underlay", u)
-	}
-
-	// handle _deprecated_ "public" and "bind" fields
-	if u.DeprecatedPublic != "" {
-		ret, err := resolveAddrPort(u.DeprecatedPublic)
-		if err != nil {
-			return netip.AddrPort{}, err
-		}
-		if u.DeprecatedBind != "" {
-			bindIP, err := netip.ParseAddr(u.DeprecatedBind)
-			if err != nil {
-				return netip.AddrPort{}, err
-			}
-			ret = netip.AddrPortFrom(bindIP.Unmap(), ret.Port())
-		}
-		return ret, nil
-	}
-
-	// the new normal, parse "local"
-	return resolveAddrPortOrPort(u.Local)
-}
+// These functions are string parsers for Udpip network addresses which comply
+// with our configuration conventions. Moved here from topology as the parsing
+// of addresses is being migrated outside the topology component.
 
-// resolveAddrPortOrPort parses a string in the format "IP:port", "hostname:port" or just ":port".
-func resolveAddrPortOrPort(s string) (netip.AddrPort, error) {
+// ResolveAddrPortOrPort parses a string in the format "IP:port", "hostname:port" or just ":port".
+func ResolveAddrPortOrPort(s string) (netip.AddrPort, error) {
 	rh, rp, err := net.SplitHostPort(s)
 	if err != nil {
 		return netip.AddrPort{}, serrors.Wrap("failed to split host port", err)
@@ -64,11 +40,11 @@ func resolveAddrPortOrPort(s string) (netip.AddrPort, error) {
 		}
 		return netip.AddrPortFrom(netip.Addr{}, uint16(port)), nil
 	}
-	return resolveAddrPort(s)
+	return ResolveAddrPort(s)
 }
 
-// resolveAddrPort parses a string in the format "IP:port" or "hostname:port".
-func resolveAddrPort(s string) (netip.AddrPort, error) {
+// ResolveAddrPort parses a string in the format "IP:port" or "hostname:port".
+func ResolveAddrPort(s string) (netip.AddrPort, error) {
 	// detour via "legacy" net.UDPAddr; there is no corresponding function for netip.AddrPort
 	udpAddr, err := net.ResolveUDPAddr("udp", s)
 	if err != nil {
diff --git a/router/bfd/session.go b/router/bfd/session.go
index 3ae486c63..b1fa6c91c 100644
--- a/router/bfd/session.go
+++ b/router/bfd/session.go
@@ -226,7 +226,6 @@ func (s *Session) Run(ctx context.Context) error {
 
 	s.desiredMinTXInterval = defaultTransmissionInterval
 	sendTimer := time.NewTimer(s.desiredMinTXInterval)
-
 	pkt := &layers.BFD{}
 MainLoop:
 	for {
@@ -254,7 +253,6 @@ MainLoop:
 			if s.Metrics.PacketsReceived != nil {
 				s.Metrics.PacketsReceived.Add(1)
 			}
-
 			s.remoteState = state(msg.State)
 			s.remoteMinRxInterval = bfdIntervalToDuration(msg.RequiredMinRxInterval)
 			if s.getRemoteDiscriminator() == 0 {
@@ -426,6 +424,7 @@ func (s *Session) ReceiveMessage(msg *layers.BFD) {
 		return
 	}
 
+	// The packet will be returning to the pool. We do not keep a reference to any part of it.
 	s.messages <- bfdMessage{
 		State:                 msg.State,
 		DetectMultiplier:      msg.DetectMultiplier,
diff --git a/router/connector.go b/router/connector.go
index b11490a9a..4c4540196 100644
--- a/router/connector.go
+++ b/router/connector.go
@@ -23,7 +23,6 @@ import (
 	"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"
 )
@@ -38,9 +37,9 @@ type Connector struct {
 	internalInterfaces []control.InternalInterface
 	externalInterfaces map[uint16]control.ExternalInterface
 	siblingInterfaces  map[uint16]control.SiblingInterface
+	ReceiveBufferSize  int
+	SendBufferSize     int
 
-	ReceiveBufferSize   int
-	SendBufferSize      int
 	BFD                 config.BFD
 	DispatchedPortStart *int
 	DispatchedPortEnd   *int
@@ -57,6 +56,8 @@ func NewConnector(config config.RouterConfig, features env.Features) *Connector
 				NumProcessors:         config.NumProcessors,
 				NumSlowPathProcessors: config.NumSlowPathProcessors,
 				BatchSize:             config.BatchSize,
+				ReceiveBufferSize:     config.ReceiveBufferSize,
+				SendBufferSize:        config.SendBufferSize,
 			},
 			features.ExperimentalSCMPAuthentication,
 		),
@@ -88,21 +89,16 @@ func (c *Connector) AddInternalInterface(ia addr.IA, local netip.AddrPort) error
 	if !c.ia.Equal(ia) {
 		return serrors.JoinNoStack(errMultiIA, nil, "current", c.ia, "new", ia)
 	}
-	connection, err := conn.New(local, netip.AddrPort{},
-		&conn.Config{ReceiveBufferSize: c.ReceiveBufferSize, SendBufferSize: c.SendBufferSize})
-	if err != nil {
-		return err
-	}
 	c.internalInterfaces = append(c.internalInterfaces, control.InternalInterface{
 		IA:   ia,
 		Addr: local,
 	})
-	return c.DataPlane.AddInternalInterface(connection, local.Addr())
+	return c.DataPlane.AddInternalInterface(local)
 }
 
 // AddExternalInterface adds a link between the local and remote address.
-func (c *Connector) AddExternalInterface(localIfID iface.ID, link control.LinkInfo,
-	owned bool) error {
+func (c *Connector) AddExternalInterface(
+	localIfID iface.ID, link control.LinkInfo, localHost, remoteHost addr.Host, owned bool) error {
 
 	c.mtx.Lock()
 	defer c.mtx.Unlock()
@@ -118,69 +114,57 @@ func (c *Connector) AddExternalInterface(localIfID iface.ID, link control.LinkIn
 	if !c.ia.Equal(link.Local.IA) {
 		return serrors.JoinNoStack(errMultiIA, nil, "current", c.ia, "new", link.Local.IA)
 	}
-	if err := c.DataPlane.AddLinkType(intf, link.LinkTo); err != nil {
-		return serrors.Wrap("adding link type", err, "if_id", localIfID)
-	}
 	if err := c.DataPlane.AddNeighborIA(intf, link.Remote.IA); err != nil {
 		return serrors.Wrap("adding neighboring IA", err, "if_id", localIfID)
 	}
 
 	link.BFD = c.applyBFDDefaults(link.BFD)
-	if owned {
-		if len(c.externalInterfaces) == 0 {
-			c.externalInterfaces = make(map[uint16]control.ExternalInterface)
-		}
-		c.externalInterfaces[intf] = control.ExternalInterface{
-			IfID:  intf,
-			Link:  link,
-			State: control.InterfaceDown,
-		}
-	} else {
+	if !owned {
 		if len(c.siblingInterfaces) == 0 {
 			c.siblingInterfaces = make(map[uint16]control.SiblingInterface)
 		}
 		c.siblingInterfaces[intf] = control.SiblingInterface{
-			IfID:              intf,
-			InternalInterface: link.Remote.Addr,
-			Relationship:      link.LinkTo,
-			MTU:               link.MTU,
-			NeighborIA:        link.Remote.IA,
-			State:             control.InterfaceDown,
+			IfID:            intf,
+			InternalAddress: link.Remote.Addr, // address of the sibling router
+			Relationship:    link.LinkTo,
+			MTU:             link.MTU,
+			NeighborIA:      link.Remote.IA,
+			State:           control.InterfaceDown,
 		}
-		return c.DataPlane.AddNextHop(intf, link.Local.Addr, link.Remote.Addr,
-			link.BFD, link.Instance)
+		return c.DataPlane.AddNextHop(intf, link, localHost, remoteHost)
 	}
 
-	connection, err := conn.New(link.Local.Addr, link.Remote.Addr,
-		&conn.Config{ReceiveBufferSize: c.ReceiveBufferSize, SendBufferSize: c.SendBufferSize})
-	if err != nil {
-		return err
+	if len(c.externalInterfaces) == 0 {
+		c.externalInterfaces = make(map[uint16]control.ExternalInterface)
 	}
-
-	return c.DataPlane.AddExternalInterface(intf, connection, link.Local, link.Remote, link.BFD)
+	c.externalInterfaces[intf] = control.ExternalInterface{
+		IfID:  intf,
+		Link:  link,
+		State: control.InterfaceDown,
+	}
+	return c.DataPlane.AddExternalInterface(intf, link, localHost, remoteHost)
 }
 
 // AddSvc adds the service address for the given ISD-AS.
-func (c *Connector) AddSvc(ia addr.IA, svc addr.SVC, a netip.AddrPort) error {
-
+func (c *Connector) AddSvc(ia addr.IA, svc addr.SVC, a addr.Host, p uint16) error {
 	c.mtx.Lock()
 	defer c.mtx.Unlock()
 	log.Debug("Adding service", "isd_as", ia, "svc", svc, "address", a)
 	if !c.ia.Equal(ia) {
 		return serrors.JoinNoStack(errMultiIA, nil, "current", c.ia, "new", a)
 	}
-	return c.DataPlane.AddSvc(svc, a)
+	return c.DataPlane.AddSvc(svc, a, p)
 }
 
 // DelSvc deletes the service entry for the given ISD-AS and IP pair.
-func (c *Connector) DelSvc(ia addr.IA, svc addr.SVC, a netip.AddrPort) error {
+func (c *Connector) DelSvc(ia addr.IA, svc addr.SVC, a addr.Host, p uint16) error {
 	c.mtx.Lock()
 	defer c.mtx.Unlock()
 	log.Debug("Deleting service", "isd_as", ia, "svc", svc, "address", a)
 	if !c.ia.Equal(ia) {
 		return serrors.JoinNoStack(errMultiIA, nil, "current", c.ia, "new", a)
 	}
-	return c.DataPlane.DelSvc(svc, a)
+	return c.DataPlane.DelSvc(svc, a, p)
 }
 
 // SetKey sets the key for the given ISD-AS at the given index.
diff --git a/router/control/conf.go b/router/control/conf.go
index fa755fc39..a03d7a23e 100644
--- a/router/control/conf.go
+++ b/router/control/conf.go
@@ -34,9 +34,10 @@ import (
 type Dataplane interface {
 	CreateIACtx(ia addr.IA) error
 	AddInternalInterface(ia addr.IA, local netip.AddrPort) error
-	AddExternalInterface(localIfID iface.ID, info LinkInfo, owned bool) error
-	AddSvc(ia addr.IA, svc addr.SVC, a netip.AddrPort) error
-	DelSvc(ia addr.IA, svc addr.SVC, a netip.AddrPort) error
+	AddExternalInterface(
+		localIfID iface.ID, info LinkInfo, localHost, remoteHost addr.Host, owned bool) error
+	AddSvc(ia addr.IA, svc addr.SVC, a addr.Host, port uint16) error
+	DelSvc(ia addr.IA, svc addr.SVC, a addr.Host, port uint16) error
 	SetKey(ia addr.IA, index int, key []byte) error
 	SetPortRange(start, end uint16)
 }
@@ -47,6 +48,7 @@ type BFD topology.BFD
 // LinkInfo contains the information about a link between an internal and
 // external router.
 type LinkInfo struct {
+	Provider string
 	Local    LinkEnd
 	Remote   LinkEnd
 	Instance string
@@ -58,7 +60,7 @@ type LinkInfo struct {
 // LinkEnd represents one end of a link.
 type LinkEnd struct {
 	IA   addr.IA
-	Addr netip.AddrPort
+	Addr string
 	IfID iface.ID
 }
 
@@ -71,7 +73,7 @@ type ObservableDataplane interface {
 // InternalInterface represents the internal underlay interface of a router.
 type InternalInterface struct {
 	IA   addr.IA
-	Addr netip.AddrPort
+	Addr netip.AddrPort // Still restricted to UDP/IP for now.
 }
 
 // ExternalInterface represents an external underlay interface of a router.
@@ -84,14 +86,18 @@ type ExternalInterface struct {
 	State InterfaceState
 }
 
-// SiblingInterface represents a sibling underlay interface of a router. A sibling interface
-// is an external interface owned by another router in the same AS.
+// SiblingInterface represents an external interface owned by another router in the same AS. This
+// data structure informs the creation of a sibling link between the local router and the sibling
+// router for the purpose of directing traffic to the sibling interface via the sibling router.
 type SiblingInterface struct {
 	// InterfaceID is the identifier of the external interface.
 	IfID uint16
-	// InternalInterfaces is the local address (internal interface)
-	// of the sibling router that owns this interface.
-	InternalInterface netip.AddrPort
+	// InternalAddress is the local address of an inner-facing interface of the sibling router that
+	// owns that interface. It is on an AS internal network but may not be the same as that router's
+	// InternalInterface (for example it could be dedicated to this sibling link or even use a
+	// different underlay protocol). However, currently sibling links share the regular internal
+	// interfaces.
+	InternalAddress string // Underlay agnostic address format
 	// Relationship describes the type of inter-AS links.
 	Relationship topology.LinkType
 	// MTU is the maximum Transmission Unit for SCION packets.
@@ -135,6 +141,8 @@ func ConfigDataplane(dp Dataplane, cfg *Config) error {
 	// Add internal interfaces
 	if cfg.BR != nil {
 		if cfg.BR.InternalAddr != (netip.AddrPort{}) {
+			// TODO: Investigate what legitimate reason there would be to not have an internal addr.
+			// Currently the router would panic upon Run().
 			if err := dp.AddInternalInterface(cfg.IA, cfg.BR.InternalAddr); err != nil {
 				return err
 			}
@@ -181,6 +189,7 @@ func confExternalInterfaces(dp Dataplane, cfg *Config) error {
 	for _, ifID := range ifIDs {
 		iface := infoMap[ifID]
 		linkInfo := LinkInfo{
+			Provider: iface.Provider,
 			Local: LinkEnd{
 				IA:   cfg.IA,
 				Addr: iface.Local,
@@ -197,21 +206,49 @@ func confExternalInterfaces(dp Dataplane, cfg *Config) error {
 			MTU:      iface.MTU,
 		}
 
+		// TODO(multi_underlay): Host addresses are currently constructed from a hosts's underlay
+		// address under the assumption that it is always a UDP/IP address. That assumption extends
+		// to external links: eventhough a SCION host address should be irrelevant there, BFD
+		// packets include it, so we oblige, to retain backward compatibility for now. Otherwise,
+		// we would: "localHost := addr.HostIP(cfg.BR.InternalAddr.Addr())".
+		// For remoteHost, it should also be underlay-idenpendent or derived from the
+		// the remote internal underlay address, but the configuration doesn't provide it yet.
+
+		localAddr, err := netip.ParseAddrPort(linkInfo.Local.Addr)
+		if err != nil {
+			return serrors.Wrap("unparsable remote address", err)
+		}
+		localHost := addr.HostIP(localAddr.Addr())
+
+		remoteAddr, err := netip.ParseAddrPort(linkInfo.Remote.Addr)
+		if err != nil {
+			return serrors.Wrap("unparsable remote address", err)
+		}
+		remoteHost := addr.HostIP(remoteAddr.Addr())
+
 		_, owned := cfg.BR.IFs[ifID]
 		if !owned {
-			// The current implementation effectively uses IP/UDP tunnels to create the SCION
-			// network as an overlay, with forwarding to local hosts being a special case. When
-			// setting up external interfaces that belong to other routers in the AS, they are
-			// basically IP/UDP tunnels between the two border routers. Those are described as a
-			// link from the internal address of the local router to the internal address of the
-			// sibling router; and not to the router in the remote AS.
-			linkInfo.Local.Addr = cfg.BR.InternalAddr
-			linkInfo.Remote.Addr = iface.InternalAddr // i.e. via sibling router.
+			// When an interface is not "owned", it means that it's proximal end is at another
+			// router in the same AS (the owning router a.k.a. the sibling router). From the
+			// point-of-view of the local router, the traffic must go through an intermediate
+			// link that connects the local router to its sibling. Until the config schema catches
+			// up, we use internal interfaces for sibling links.
+			linkInfo.Provider = "udpip" // For now, all internal interfaces use udp/ip.
+			linkInfo.Local.Addr = cfg.BR.InternalAddr.String()
+			linkInfo.Remote.Addr = iface.InternalAddr.String() // i.e. via sibling router.
+			localHost = addr.HostIP(cfg.BR.InternalAddr.Addr())
+			remoteHost = addr.HostIP(iface.InternalAddr.Addr())
+
+			// The link is between two AS-local routers. TODO(multi_underlay): double check it's
+			// not used for other purposes where the far router's AS is expected.
+			// If not, we should add: linkInfo.Remote.IA = linkInfo.Local.IA
+
 			// For internal BFD always use the default configuration.
 			linkInfo.BFD = BFD{}
 		}
 
-		if err := dp.AddExternalInterface(ifID, linkInfo, owned); err != nil {
+		if err := dp.AddExternalInterface(
+			ifID, linkInfo, localHost, remoteHost, owned); err != nil {
 			return err
 		}
 	}
@@ -224,6 +261,7 @@ var svcTypes = []addr.SVC{
 }
 
 func confServices(dp Dataplane, cfg *Config) error {
+
 	if cfg.Topo == nil {
 		// nothing to tdo
 		return nil
@@ -238,8 +276,16 @@ func confServices(dp Dataplane, cfg *Config) error {
 		sort.Slice(addrs, func(i, j int) bool {
 			return addrs[i].IP.String() < addrs[j].IP.String()
 		})
+
+		// Topo.Multicast returns SCION host addresses (which just happen to be identical to udp/ip
+		// addresses). So, in theory, these are *not* underlay addresses. While the topology API
+		// represents them openly as UDPAddr, the router doesn't make that assumption. It does not
+		// know what a UDPAddr is; that's underlay business. So, addr.Host is what we give to the
+		// router. The underlays are in change of resolving the corresponding underlay address.
 		for _, a := range addrs {
-			if err := dp.AddSvc(cfg.IA, svc, a.AddrPort()); err != nil {
+			addrPort := a.AddrPort()
+			host := addr.HostIP(addrPort.Addr())
+			if err := dp.AddSvc(cfg.IA, svc, host, addrPort.Port()); err != nil {
 				return err
 			}
 		}
diff --git a/router/dataplane.go b/router/dataplane.go
index 229ebc5f7..4eacc4193 100644
--- a/router/dataplane.go
+++ b/router/dataplane.go
@@ -85,15 +85,17 @@ type BatchConn interface {
 	Close() error
 }
 
-// underlay is a pointer to our underlay provider.
-//
-// TODO(multi_underlay): this allows for a single underlay. In the future, each link could be
-// via a different underlay. That would have to be supported by the configuration code and there
-// would likely be a registry of underlays. For now, That's the whole registry.
-var newUnderlay func(int) UnderlayProvider
+// underlayProviders is a map of our underlay providers. Each entry associates a name with a
+// NewProviderFn. A new instance of that provider is created by every invocation so multiple
+// dataplane instances can co-exist (as is routinely done by tests).
+var underlayProviders map[string]NewProviderFn
 
-func AddUnderlay(newProvider func(int) UnderlayProvider) {
-	newUnderlay = newProvider
+// AddUnderlay registers the named factory function.
+func AddUnderlay(name string, newProvider func(int, int, int) UnderlayProvider) {
+	if underlayProviders == nil {
+		underlayProviders = make(map[string]NewProviderFn)
+	}
+	underlayProviders[name] = newProvider
 }
 
 type disposition int
@@ -176,12 +178,11 @@ func (p *Packet) Reset() {
 // from multiple sockets, performs routing, and sends them to their destinations
 // (after updating the path, if that is needed).
 type dataPlane struct {
-	underlay            UnderlayProvider
+	underlays           map[string]UnderlayProvider
 	interfaces          map[uint16]Link
 	linkTypes           map[uint16]topology.LinkType
 	neighborIAs         map[uint16]addr.IA
 	internalIP          netip.Addr
-	svc                 *services
 	macFactory          func() hash.Hash
 	localIA             addr.IA
 	mtx                 sync.Mutex
@@ -206,6 +207,8 @@ type dataPlane struct {
 }
 
 var (
+	UnsupportedV4MappedV6Address  = errors.New("unsupported v4mapped IP v6 address")
+	UnsupportedUnspecifiedAddress = errors.New("unsupported unspecified address")
 	alreadySet                    = errors.New("already set")
 	invalidSrcIA                  = errors.New("invalid source ISD-AS")
 	invalidDstIA                  = errors.New("invalid destination ISD-AS")
@@ -215,11 +218,9 @@ var (
 	emptyValue                    = errors.New("empty value")
 	malformedPath                 = errors.New("malformed path content")
 	modifyExisting                = errors.New("modifying a running dataplane is not allowed")
-	noSVCBackend                  = errors.New("cannot find internal IP for the SVC")
+	NoSVCBackend                  = errors.New("cannot find internal IP for the SVC")
 	unsupportedPathType           = errors.New("unsupported path type")
 	unsupportedPathTypeNextHeader = errors.New("unsupported combination")
-	unsupportedV4MappedV6Address  = errors.New("unsupported v4mapped IP v6 address")
-	unsupportedUnspecifiedAddress = errors.New("unsupported unspecified address")
 	noBFDSessionFound             = errors.New("no BFD session was found")
 	errPeeringEmptySeg0           = errors.New("zero-length segment[0] in peering path")
 	errPeeringEmptySeg1           = errors.New("zero-length segment[1] in peering path")
@@ -262,12 +263,20 @@ func newDataPlane(runConfig RunConfig, authSCMP bool) *dataPlane {
 // but returns by value to facilitate the initialization of composed structs without an temporary
 // copy.
 func makeDataPlane(runConfig RunConfig, authSCMP bool) dataPlane {
+	// So many tests need the udpip underlay provider instantiated early that we do it here rather
+	// than in AddInternalInterface. Currently there can be no dataplane without the udpip provider,
+	// therefore not having a registered factory for it is a panicable offsense. We have no plan B.
+
 	return dataPlane{
-		underlay:                       newUnderlay(runConfig.BatchSize),
+		underlays: map[string]UnderlayProvider{
+			"udpip": underlayProviders["udpip"](
+				runConfig.BatchSize,
+				runConfig.SendBufferSize,
+				runConfig.ReceiveBufferSize,
+			)},
 		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,
@@ -300,7 +309,9 @@ func (d *dataPlane) isRunning() bool {
 func (d *dataPlane) Shutdown() {
 	d.mtx.Lock() // make sure we're not racing with initialization.
 	defer d.mtx.Unlock()
-	d.underlay.Stop()
+	for _, u := range d.underlays {
+		u.Stop()
+	}
 	d.setStopping()
 }
 
@@ -355,22 +366,26 @@ 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(local netip.AddrPort) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
 	if d.isRunning() {
 		return modifyExisting
 	}
-	if conn == nil {
-		return emptyValue
-	}
 	if d.interfaces[0] != nil {
 		return alreadySet
 	}
+
 	d.addForwardingMetrics(0, Internal)
-	d.interfaces[0] = d.underlay.NewInternalLink(
-		conn, d.RunConfig.BatchSize, d.forwardingMetrics[0])
-	d.internalIP = ip
+
+	// The "udpip" underlay is instantiated at construction to simplify some tests.
+	lk, err := d.underlays["udpip"].NewInternalLink(
+		local, d.RunConfig.BatchSize, d.forwardingMetrics[0])
+	if err != nil {
+		return err
+	}
+	d.interfaces[0] = lk
+	d.internalIP = local.Addr()
 
 	return nil
 }
@@ -378,29 +393,53 @@ 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,
-	src, dst control.LinkEnd, cfg control.BFD,
-) error {
+func (d *dataPlane) AddExternalInterface(
+	ifID uint16, link control.LinkInfo, localHost, remoteHost addr.Host) error {
+
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
 
 	if d.isRunning() {
 		return modifyExisting
 	}
-	if conn == nil || !src.Addr.IsValid() || !dst.Addr.IsValid() {
-		return emptyValue
-	}
-	bfd, err := d.newExternalInterfaceBFD(ifID, src, dst, cfg)
+	bfd, err := d.newExternalInterfaceBFD(ifID, link, localHost, remoteHost)
 	if err != nil {
 		return serrors.Wrap("adding external BFD", err, "if_id", ifID)
 	}
 	if _, exists := d.interfaces[ifID]; exists {
 		return serrors.JoinNoStack(alreadySet, nil, "ifID", ifID)
 	}
+	if link.Remote.Addr == "" {
+		return emptyValue
+	}
+
+	underlay, instantiated := d.underlays[link.Provider]
+	if !instantiated {
+		underlayProvider, exists := underlayProviders[link.Provider]
+		if !exists {
+			panic(fmt.Sprintf("no provider for underlay: %q", link.Provider))
+		}
+		underlay = underlayProvider(
+			d.RunConfig.BatchSize,
+			d.RunConfig.SendBufferSize,
+			d.RunConfig.ReceiveBufferSize,
+		)
+		d.underlays[link.Provider] = underlay
+	}
+	d.linkTypes[ifID] = link.LinkTo
 	d.addForwardingMetrics(ifID, External)
-	d.interfaces[ifID], err = d.underlay.NewExternalLink(
-		conn, d.RunConfig.BatchSize, bfd, dst.Addr, ifID, d.forwardingMetrics[ifID])
-	return err
+	lk, err := underlay.NewExternalLink(
+		d.RunConfig.BatchSize,
+		bfd,
+		link.Local.Addr,
+		link.Remote.Addr,
+		ifID,
+		d.forwardingMetrics[ifID])
+	if err != nil {
+		return err
+	}
+	d.interfaces[ifID] = lk
+	return nil
 }
 
 // AddNeighborIA adds the neighboring IA for a given interface ID. If an IA for
@@ -422,27 +461,11 @@ func (d *dataPlane) AddNeighborIA(ifID uint16, remote addr.IA) error {
 	return nil
 }
 
-// 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 {
-	d.mtx.Lock()
-	defer d.mtx.Unlock()
-	if d.isRunning() {
-		return modifyExisting
-	}
-	if _, exists := d.linkTypes[ifID]; exists {
-		return serrors.JoinNoStack(alreadySet, nil, "ifID", ifID)
-	}
-	d.linkTypes[ifID] = linkTo
-	return nil
-}
-
 // newExternalInterfaceBFD adds the inter AS connection BFD session.
 func (d *dataPlane) newExternalInterfaceBFD(
-	ifID uint16, src, dst control.LinkEnd, cfg control.BFD,
-) (*bfd.Session, error) {
-	if *cfg.Disable {
+	ifID uint16, link control.LinkInfo, localHost, remoteHost addr.Host) (*bfd.Session, error) {
+
+	if *link.BFD.Disable {
 		return nil, nil
 	}
 	var m bfd.Metrics
@@ -450,7 +473,7 @@ func (d *dataPlane) newExternalInterfaceBFD(
 		labels := prometheus.Labels{
 			"interface":       fmt.Sprint(ifID),
 			"isd_as":          d.localIA.String(),
-			"neighbor_isd_as": dst.IA.String(),
+			"neighbor_isd_as": link.Remote.IA.String(),
 		}
 		m = bfd.Metrics{
 			Up:              d.Metrics.InterfaceUp.With(labels),
@@ -459,11 +482,11 @@ func (d *dataPlane) newExternalInterfaceBFD(
 			PacketsReceived: d.Metrics.BFDPacketsReceived.With(labels),
 		}
 	}
-	s, err := newBFDSend(d, src.IA, dst.IA, src.Addr, dst.Addr, ifID, d.macFactory())
+	s, err := newBFDSend(d, link, localHost, remoteHost, ifID, false, d.macFactory())
 	if err != nil {
 		return nil, err
 	}
-	return bfd.NewSession(s, cfg, m)
+	return bfd.NewSession(s, link.BFD, m)
 }
 
 // getInterfaceState checks if there is a bfd session for the input interfaceID and
@@ -479,13 +502,13 @@ 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 addr.Host, p uint16) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
-	if !a.IsValid() {
-		return emptyValue
+	// TODO: underlay choice should really be "interfaces[0].provider"
+	if err := d.underlays["udpip"].AddSvc(svc, a, p); err != nil {
+		return err
 	}
-	d.svc.AddSvc(svc, a)
 	if d.Metrics != nil {
 		labels := serviceLabels(d.localIA, svc)
 		d.Metrics.ServiceInstanceChanges.With(labels).Add(1)
@@ -495,13 +518,13 @@ 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 addr.Host, p uint16) error {
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
-	if !a.IsValid() {
-		return emptyValue
+	// TODO: underlay choice should really be "interfaces[0].provider"
+	if err := d.underlays["udpip"].DelSvc(svc, a, p); err != nil {
+		return err
 	}
-	d.svc.DelSvc(svc, a)
 	if d.Metrics != nil {
 		labels := serviceLabels(d.localIA, svc)
 		d.Metrics.ServiceInstanceChanges.With(labels).Add(1)
@@ -513,44 +536,73 @@ 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,
-	sibling string,
+func (d *dataPlane) AddNextHop(
+	ifID uint16,
+	link control.LinkInfo,
+	localHost, remoteHost addr.Host,
 ) error {
+
 	d.mtx.Lock()
 	defer d.mtx.Unlock()
 
 	if d.isRunning() {
 		return modifyExisting
 	}
-	if !dst.IsValid() || !src.IsValid() {
-		return emptyValue
-	}
-	bfd, err := d.newNextHopBFD(src, dst, cfg, sibling)
+	bfd, err := d.newNextHopBFD(ifID, link, localHost, remoteHost)
 	if err != nil {
 		return serrors.Wrap("adding next hop BFD", err, "if_id", ifID)
 	}
 	if _, exists := d.interfaces[ifID]; exists {
 		return serrors.JoinNoStack(alreadySet, nil, "ifID", ifID)
 	}
+	if link.Remote.Addr == "" {
+		return emptyValue
+	}
+	underlay, instantiated := d.underlays[link.Provider]
+	if !instantiated {
+		underlayProvider, exists := underlayProviders[link.Provider]
+		if !exists {
+			panic(fmt.Sprintf("no provider for underlay: %q", link.Provider))
+		}
+		underlay = underlayProvider(
+			d.RunConfig.BatchSize,
+			d.RunConfig.SendBufferSize,
+			d.RunConfig.ReceiveBufferSize,
+		)
+		d.underlays[link.Provider] = underlay
+	}
+	d.linkTypes[ifID] = link.LinkTo
 	d.addForwardingMetrics(ifID, Sibling)
+
 	// Note that a link to the same sibling router might already exist. If so, it will be
-	// returned instead of creating a new one. As a result, the bfd session will be remain unused,
-	// and since isn't started it will simply be garbage collected.
-	d.interfaces[ifID] = d.underlay.NewSiblingLink(
-		d.RunConfig.BatchSize, bfd, dst, d.forwardingMetrics[ifID])
+	// returned instead of creating a new one. As a result, the bfd session will be ignored,
+	// and since it isn't started it will simply be garbage collected. Note that in that case,
+	// the one real link associated with the sibling will be the first one to be created, so
+	// it will have the ifID of that first sibling interface...somewhat arbitrary. That ifID
+	// is reflected in metrics and the BFD session. May be we should use ifID 0 for sibling bfd
+	// and sibling metrics.
+	lk, err := underlay.NewSiblingLink(
+		d.RunConfig.BatchSize, bfd, link.Local.Addr, link.Remote.Addr, d.forwardingMetrics[ifID])
+	if err != nil {
+		return err
+	}
+	d.interfaces[ifID] = lk
 	return nil
 }
 
 // AddNextHopBFD adds the BFD session for the next hop address.
 func (d *dataPlane) newNextHopBFD(
-	src, dst netip.AddrPort, cfg control.BFD, sibling string,
+	ifID uint16,
+	link control.LinkInfo,
+	localHost, remoteHost addr.Host,
 ) (*bfd.Session, error) {
-	if *cfg.Disable {
+
+	if *link.BFD.Disable {
 		return nil, nil
 	}
 	var m bfd.Metrics
 	if d.Metrics != nil {
-		labels := prometheus.Labels{"isd_as": d.localIA.String(), "sibling": sibling}
+		labels := prometheus.Labels{"isd_as": d.localIA.String(), "sibling": link.Instance}
 		m = bfd.Metrics{
 			Up:              d.Metrics.SiblingReachable.With(labels),
 			StateChanges:    d.Metrics.SiblingBFDStateChanges.With(labels),
@@ -559,11 +611,11 @@ func (d *dataPlane) newNextHopBFD(
 		}
 	}
 
-	s, err := newBFDSend(d, d.localIA, d.localIA, src, dst, 0, d.macFactory())
+	s, err := newBFDSend(d, link, localHost, remoteHost, ifID, true, d.macFactory())
 	if err != nil {
 		return nil, err
 	}
-	return bfd.NewSession(s, cfg, m)
+	return bfd.NewSession(s, link.BFD, m)
 }
 
 func max(a int, b int) int {
@@ -577,6 +629,8 @@ type RunConfig struct {
 	NumProcessors         int
 	NumSlowPathProcessors int
 	BatchSize             int
+	ReceiveBufferSize     int
+	SendBufferSize        int
 }
 
 func (d *dataPlane) Run(ctx context.Context) error {
@@ -595,14 +649,20 @@ func (d *dataPlane) Run(ctx context.Context) error {
 		log.Error("Could not initialize processmetrics", "err", err)
 	}
 
+	numConnections := 0
+	for _, u := range d.underlays {
+		numConnections += u.NumConnections()
+	}
 	processorQueueSize := max(
-		d.underlay.NumConnections()*d.RunConfig.BatchSize/d.RunConfig.NumProcessors,
-		d.RunConfig.BatchSize)
+		numConnections*d.RunConfig.BatchSize/d.RunConfig.NumProcessors,
+		d.RunConfig.BatchSize,
+	)
 	d.initPacketPool(processorQueueSize)
 	procQs, slowQs := d.initQueues(processorQueueSize)
 	d.setRunning()
-	d.underlay.Start(ctx, d.packetPool, procQs)
-
+	for _, u := range d.underlays {
+		u.Start(ctx, d.packetPool, procQs)
+	}
 	for i := 0; i < d.RunConfig.NumProcessors; i++ {
 		go func(i int) {
 			defer log.HandlePanic()
@@ -939,7 +999,7 @@ func (p *scionPacketProcessor) processBFD(data []byte) disposition {
 		return errorDiscard("error", err)
 	}
 	session.ReceiveMessage(bfd)
-	return pDiscard // All's fine. That packet's journey ends here.
+	return pDone // All's fine. That packet's journey ends here.
 }
 
 func (p *scionPacketProcessor) processSCION() disposition {
@@ -1237,13 +1297,13 @@ func (p *scionPacketProcessor) validateTransitUnderlaySrc() disposition {
 // to another AS directly, or via a sibling router.
 func (p *scionPacketProcessor) validateEgressID() disposition {
 	egressID := p.pkt.egress
-	link, found := p.d.interfaces[egressID]
+	egressLink, found := p.d.interfaces[egressID]
 
 	// egress interface must be a known interface
 	// egress is never the internal interface (already checked)
 	// 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.ingressFromLink == 0 && link.Scope() == Sibling) {
+	if !found || (p.ingressFromLink == 0 && egressLink.Scope() == Sibling) {
 		errCode := slayers.SCMPCodeUnknownHopFieldEgress
 		if !p.infoField.ConsDir {
 			errCode = slayers.SCMPCodeUnknownHopFieldIngress
@@ -1364,19 +1424,19 @@ func (p *scionPacketProcessor) verifyCurrentMAC() disposition {
 
 func (p *scionPacketProcessor) resolveInbound() disposition {
 	// The internal link is by definition unbound; we need to update the destination.
-	err := p.d.resolveLocalDst(p.pkt.RemoteAddr, p.scionLayer, p.lastLayer)
+	err := p.d.resolveLocalDst(p.pkt, p.scionLayer, p.lastLayer)
 
 	switch err {
 	case nil:
 		return pForward
-	case noSVCBackend:
+	case NoSVCBackend:
 		log.Debug("SCMP response", "cause", err)
 		p.pkt.slowPathRequest = slowPathRequest{
 			spType: slowPathType(slayers.SCMPTypeDestinationUnreachable),
 			code:   slayers.SCMPCodeNoRoute,
 		}
 		return pSlowPath
-	case invalidDstAddr, unsupportedV4MappedV6Address, unsupportedUnspecifiedAddress:
+	case invalidDstAddr, UnsupportedV4MappedV6Address, UnsupportedUnspecifiedAddress:
 		log.Debug("SCMP response", "cause", err)
 		p.pkt.slowPathRequest = slowPathRequest{
 			spType: slowPathType(slayers.SCMPTypeParameterProblem),
@@ -1576,7 +1636,7 @@ func (p *scionPacketProcessor) validateSrcHost() disposition {
 	}
 	src, err := p.scionLayer.SrcAddr()
 	if err == nil && src.IP().Is4In6() {
-		err = unsupportedV4MappedV6Address
+		err = UnsupportedV4MappedV6Address
 	}
 	if err == nil {
 		return pForward
@@ -1763,7 +1823,7 @@ func (p *scionPacketProcessor) processOHP() disposition {
 	if err := updateSCIONLayer(p.pkt.RawPacket, s); err != nil {
 		return errorDiscard("error", err)
 	}
-	err := p.d.resolveLocalDst(p.pkt.RemoteAddr, s, p.lastLayer)
+	err := p.d.resolveLocalDst(p.pkt, s, p.lastLayer)
 	if err != nil {
 		return errorDiscard("error", err)
 	}
@@ -1771,58 +1831,38 @@ func (p *scionPacketProcessor) processOHP() disposition {
 	return pForward
 }
 
-// resolveLocalDst translates a SCION address (host or service) into an underlay address. With
-// significant effort, we could remove the assumption that a SCION host address is a bit-for-bit
-// copy of the local IP. That would, among other things, require that the underlay performs any
-// necessary address resolution.
+// resolveLocalDst updates the packet's remote address (from then on, its destination) by
+// translating the given SCION address (host or service) into an underlay address.
 func (d *dataPlane) resolveLocalDst(
-	resolvedDst *net.UDPAddr,
+	packet *Packet,
 	s slayers.SCION,
 	lastLayer gopacket.DecodingLayer,
 ) error {
-	dst, err := s.DstAddr()
+
+	a, err := s.DstAddr()
 	if err != nil {
 		return invalidDstAddr
 	}
-	switch dstType := dst.Type(); dstType {
-	case addr.HostTypeSVC:
-		// For map lookup use the Base address, i.e. strip the multi cast
-		// information, because we only register base addresses in the map.
-		a, ok := d.svc.Any(dst.SVC().Base())
-		if !ok {
-			return noSVCBackend
-		}
-		// if SVC address is outside the configured port range we send to the fix
-		// port.
-		if a.Port() < d.dispatchedPortStart || a.Port() > d.dispatchedPortEnd {
-			updateNetAddrFromAddrAndPort(resolvedDst, a.Addr(), topology.EndhostPort)
-		} else {
-			updateNetAddrFromAddrPort(resolvedDst, a)
+
+	p := uint16(0)
+	if a.Type() == addr.HostTypeIP {
+		// In this case, we must find the destination SCION port so we have a chance to dispatch to
+		// to the UDP port of the same number directly instead of going through the dispatcher.
+		// It's our job to figure that; the underlay doesn't know the SCION header.
+		p, err = d.dstScionPort(lastLayer)
+		if err != nil {
+			return err
 		}
-		return nil
-	case addr.HostTypeIP:
-		// Parse UPD port and rewrite underlay IP/UDP port
-		// TODO(jiceatscion): IP() is returned by value. The cost of copies adds up.
-		dstIP := dst.IP()
-		if dstIP.Is4In6() {
-			return unsupportedV4MappedV6Address
-		}
-		// Zero IP addresses (per IsUnspecified()) are not supported. Zero valued netip.Addr objects
-		// (per IsInvalid()) cannot happen here as dstIP is initialized from packet header data.
-		if dstIP.IsUnspecified() {
-			return unsupportedUnspecifiedAddress
-		}
-		return d.addEndhostPort(resolvedDst, lastLayer, dstIP)
-	default:
-		panic("unexpected address type returned from DstAddr: " + dstType.String())
 	}
+
+	// Let the internal (it better be) link resolve the destination to an underlay address.
+	return d.interfaces[packet.egress].Resolve(packet, a, p)
 }
 
-func (d *dataPlane) addEndhostPort(
-	resolvedDst *net.UDPAddr,
+func (d *dataPlane) dstScionPort(
 	lastLayer gopacket.DecodingLayer,
-	dst netip.Addr,
-) error {
+) (uint16, error) {
+
 	// Parse UPD port and rewrite underlay IP/UDP port
 	l4Type := nextHdr(lastLayer)
 	port := uint16(topology.EndhostPort)
@@ -1831,44 +1871,33 @@ func (d *dataPlane) addEndhostPort(
 	case slayers.L4UDP:
 		if len(lastLayer.LayerPayload()) < 8 {
 			// TODO(JordiSubira): Treat this as a parameter problem
-			return serrors.New("SCION/UDP header len too small", "length",
+			return 0, serrors.New("SCION/UDP header len too small", "length",
 				len(lastLayer.LayerPayload()))
 		}
 		port = binary.BigEndian.Uint16(lastLayer.LayerPayload()[2:])
-		if port < d.dispatchedPortStart || port > d.dispatchedPortEnd {
-			port = topology.EndhostPort
-		}
 	case slayers.L4TCP:
 		if len(lastLayer.LayerPayload()) < 20 {
 			// TODO: Treat this as a parameter problem
-			return serrors.New("SCION/TCP header len too small", "length",
+			return 0, serrors.New("SCION/TCP header len too small", "length",
 				len(lastLayer.LayerPayload()))
 		}
 		port = binary.BigEndian.Uint16(lastLayer.LayerPayload()[2:])
-		if port < d.dispatchedPortStart || port > d.dispatchedPortEnd {
-			port = topology.EndhostPort
-		}
 	case slayers.L4SCMP:
 		var scmpLayer slayers.SCMP
 		err := scmpLayer.DecodeFromBytes(lastLayer.LayerPayload(), gopacket.NilDecodeFeedback)
 		if err != nil {
 			// TODO(JordiSubira): Treat this as a parameter problem.
-			return serrors.Wrap("decoding SCMP layer for extracting endhost dst port", err)
+			return 0, serrors.Wrap("decoding SCMP layer for extracting endhost dst port", err)
 		}
 		port, err = getDstPortSCMP(&scmpLayer)
 		if err != nil {
 			// TODO(JordiSubira): Treat this as a parameter problem.
-			return serrors.Wrap("getting dst port from SCMP message", err)
-		}
-		// if the SCMP dst port is outside the range, we send it to the EndhostPort
-		if port < d.dispatchedPortStart || port > d.dispatchedPortEnd {
-			port = topology.EndhostPort
+			return 0, serrors.Wrap("getting dst port from SCMP message", err)
 		}
 	default:
 		log.Debug("msg", "protocol", l4Type)
 	}
-	updateNetAddrFromAddrAndPort(resolvedDst, dst, port)
-	return nil
+	return port, nil
 }
 
 func getDstPortSCMP(scmp *slayers.SCMP) (uint16, error) {
@@ -1991,48 +2020,47 @@ func updateSCIONLayer(rawPkt []byte, s slayers.SCION) error {
 }
 
 type bfdSend struct {
-	dataPlane        *dataPlane
-	ifID             uint16
-	srcAddr, dstAddr netip.AddrPort
-	scn              *slayers.SCION
-	ohp              *onehop.Path
-	mac              hash.Hash
-	macBuffer        []byte
+	dataPlane *dataPlane
+	name      string // for logs
+	ifID      uint16
+	scn       *slayers.SCION
+	ohp       *onehop.Path
+	mac       hash.Hash
+	macBuffer []byte
 }
 
 // newBFDSend creates and initializes a BFD Sender
-func newBFDSend(d *dataPlane, srcIA, dstIA addr.IA, srcAddr, dstAddr netip.AddrPort,
-	ifID uint16, mac hash.Hash,
+func newBFDSend(
+	d *dataPlane,
+	link control.LinkInfo,
+	localHost, remoteHost addr.Host,
+	ifID uint16,
+	isIntraAS bool,
+	mac hash.Hash,
 ) (*bfdSend, error) {
+
 	scn := &slayers.SCION{
 		Version:      0,
 		TrafficClass: 0xb8,
 		FlowID:       0xdead,
 		NextHdr:      slayers.L4BFD,
-		SrcIA:        srcIA,
-		DstIA:        dstIA,
-	}
-
-	if !srcAddr.IsValid() {
-		return nil, serrors.New("invalid source IP", "ip", srcAddr)
+		SrcIA:        link.Local.IA,
 	}
-	if !dstAddr.IsValid() {
-		return nil, serrors.New("invalid source IP", "ip", srcAddr)
+	if err := scn.SetSrcAddr(localHost); err != nil {
+		panic(err)
 	}
-
-	srcAddrIP := srcAddr.Addr()
-	dstAddrIP := dstAddr.Addr()
-	if err := scn.SetSrcAddr(addr.HostIP(srcAddrIP)); err != nil {
-		panic(err) // Must work
+	if err := scn.SetDstAddr(remoteHost); err != nil {
+		panic(err)
 	}
-	if err := scn.SetDstAddr(addr.HostIP(dstAddrIP)); err != nil {
-		panic(err) // Must work
+	if ifID == 0 {
+		// There is no reason to support this any more.
+		panic("Sending BFD packets on the internal link")
 	}
-
 	var ohp *onehop.Path
-	if ifID == 0 {
+	if isIntraAS {
 		scn.PathType = empty.PathType
 		scn.Path = &empty.Path{}
+		scn.DstIA = scn.SrcIA
 	} else {
 		ohp = &onehop.Path{
 			Info: path.InfoField{
@@ -2046,6 +2074,7 @@ func newBFDSend(d *dataPlane, srcIA, dstIA addr.IA, srcAddr, dstAddr netip.AddrP
 		}
 		scn.PathType = onehop.PathType
 		scn.Path = ohp
+		scn.DstIA = link.Remote.IA
 	}
 
 	// bfdSend includes a reference to the dataplane. In general this must not be used until the
@@ -2054,9 +2083,8 @@ func newBFDSend(d *dataPlane, srcIA, dstIA addr.IA, srcAddr, dstAddr netip.AddrP
 
 	return &bfdSend{
 		dataPlane: d,
+		name:      link.Remote.Addr,
 		ifID:      ifID,
-		srcAddr:   srcAddr,
-		dstAddr:   dstAddr,
 		scn:       scn,
 		ohp:       ohp,
 		mac:       mac,
@@ -2065,7 +2093,7 @@ func newBFDSend(d *dataPlane, srcIA, dstIA addr.IA, srcAddr, dstAddr netip.AddrP
 }
 
 func (b *bfdSend) String() string {
-	return b.srcAddr.String()
+	return b.name
 }
 
 // Send sends out a BFD message.
@@ -2099,11 +2127,6 @@ func (b *bfdSend) Send(bfd *layers.BFD) error {
 	// the forwarding queue is an serious internal error. Let that panic.
 	fwLink := b.dataPlane.interfaces[b.ifID]
 
-	if b.ifID == 0 {
-		// Using the internal interface: must specify the destination address
-		updateNetAddrFromAddrPort(p.RemoteAddr, b.dstAddr)
-	}
-
 	if !fwLink.Send(p) {
 		// We do not care if some BFD packets get bounced under high load. If it becomes a problem,
 		// the solution is do use BFD's demand-mode. To be considered in a future refactoring.
@@ -2422,39 +2445,3 @@ 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
-// given netip.AddrPort. newDst.Addr() returns the IP by value. The compiler may or
-// may not inline the call and optimize out the copy. It is doubtful that manually inlining
-// increases the chances that the copy get elided. TODO(jiceatscion): experiment.
-func updateNetAddrFromAddrPort(netAddr *net.UDPAddr, newDst netip.AddrPort) {
-	updateNetAddrFromAddrAndPort(netAddr, newDst.Addr(), newDst.Port())
-}
-
-// updateNetAddrFromAddrAndPort() updates a net.UDPAddr address to be the same IP and port as
-// the given netip.Addr and unigned port.
-//
-// We handle dstAddr so we don't make the GC work. The issue is the IP address slice
-// that's in dstAddr. The packet, along with its address and IP slice, gets copied from a channel
-// into a local variable. Then after we modify it, all gets copied to the some other channel and
-// eventually it gets copied back to the pool. If we replace the destAddr.IP at any point,
-// the old backing array behind the destAddr.IP slice ends-up on the garbage pile. To prevent that,
-// we update the IP address in-place (we make the length 0 to represent the 0 address).
-func updateNetAddrFromAddrAndPort(netAddr *net.UDPAddr, addr netip.Addr, port uint16) {
-	netAddr.Port = int(port)
-	netAddr.Zone = addr.Zone()
-	if addr.Is4() {
-		outIpBytes := addr.As4()     // Must store explicitly in order to copy
-		netAddr.IP = netAddr.IP[0:4] // Update slice
-		copy(netAddr.IP, outIpBytes[:])
-	} else if addr.Is6() {
-		outIpBytes := addr.As16()
-		netAddr.IP = netAddr.IP[0:16]
-		copy(netAddr.IP, outIpBytes[:])
-	} else {
-		// That's a zero address. We translate in to something resembling a nil IP.
-		// Nothing gets discarded as we keep the slice (and its reference to the backing array).
-		// To that end, we cannot make it nil. We have to make its length zero.
-		netAddr.IP = netAddr.IP[0:0]
-	}
-}
diff --git a/router/mgmtapi/api.go b/router/mgmtapi/api.go
index 69e92532b..1c985e6ca 100644
--- a/router/mgmtapi/api.go
+++ b/router/mgmtapi/api.go
@@ -106,7 +106,7 @@ func (s *Server) GetInterfaces(w http.ResponseWriter, r *http.Request) {
 			InterfaceId:       int(intf.IfID), // nolint - name from published API.
 			InternalInterface: findInternalInterface(intf.Link.Local.IA),
 			Neighbor: InterfaceNeighbor{
-				Address: intf.Link.Remote.Addr.String(),
+				Address: intf.Link.Remote.Addr,
 				IsdAs:   intf.Link.Remote.IA.String(),
 			},
 			Relationship: LinkRelationship(intf.Link.LinkTo.String()),
@@ -119,8 +119,9 @@ func (s *Server) GetInterfaces(w http.ResponseWriter, r *http.Request) {
 
 	for _, intf := range siblingInterfaces {
 		siblingInterface := SiblingInterface{
-			InterfaceId:       int(intf.IfID), // nolint - name from published API.
-			InternalInterface: intf.InternalInterface.String(),
+			InterfaceId: int(intf.IfID), // nolint - name from published API.
+			// The name InternalInterface is poorly chosen but enshrined in the schema.
+			InternalInterface: intf.InternalAddress,
 			Neighbor: SiblingNeighbor{
 				IsdAs: intf.NeighborIA.String(),
 			},
diff --git a/router/mgmtapi/spec.gen.go b/router/mgmtapi/spec.gen.go
index a344f75ee..38fca8ea2 100644
--- a/router/mgmtapi/spec.gen.go
+++ b/router/mgmtapi/spec.gen.go
@@ -18,45 +18,45 @@ import (
 // Base64 encoded, gzipped, json marshaled Swagger object
 var swaggerSpec = []string{
 
-	"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",
+	"H4sIAAAAAAAC/9xZ23LjNhL9FRSSh0yFutgz2WT05rE9iapmxipfKg+J1wURLRIxCDAAKFvr1b9vNQBS",
+	"JEVfZnaT1ObJFgmgD06fbnSDDzTVRakVKGfp7IEasKVWFvyPd4yfw+8VWIe/Uq0cKP8vK0spUuaEVpPf",
+	"rFb4zKY5FAz/+9rAis7oV5Pd0pPw1k4uHFOcGX5qjDZ0u90mlINNjShxMTpDm8REo/g2TvRw3p/gn9Lo",
+	"EowTASMHKwzwm0IoUVTFjbu/EcqBWTMZX7cWv8yBxIGkHkWW4O4AFHGGKVsIa4VWRK/Iu/cnBPdstCQl",
+	"S2/BWeJy5ojLgSAE5rQhwb4dk8tcWLJmsgIiLGF8jRgtcOK0n1ECmITk+g7WYPwTlrqKyR2QCkcLS2wJ",
+	"qVgJ4GS5IY7dCpX58QW798j1Klrlo7iZkbsfNcswxf3wgEWv/A8DhXbgme1MNJCCWMMOhJ81pgmFe1aU",
+	"EuiMHk6nhaUJdZsSf1pnhMqo95yDFKm9KSrpRCkFmGHSVVUswSCYDpNFZR1Zok9sZIpDKpkB4pBNC8EZ",
+	"zBKu7xRyDKQxusO80oFQ9Fg9R1iSMplWkrlAZIS4qdns0KMg0074oR0Z7ESyCZD26XndEIODMzDIDCi2",
+	"lMD3yZgrHgMHTd/l4HIwHriwJM7yHky1WomsMsCJVsG2B7Niade+MxU0EJZaS2AKIdSubiIjuvozoyLO",
+	"4k+FA7pqYx0UxOa6kpzYqiy1cc8HRZQlxgY+EoEd6Mh95dOBSjfkGzGGcdLFOgpYGuCvGuSPAkYkaQql",
+	"Q7ZrJFKnTMZtvEj+LYrp7Jcn89AjkbKTyRPeuk6oE84DeSe4MGEZJsl7be6Y4SjnkyYkatU0CmOqK5u4",
+	"Cb38DVKHMpnXb/dT63LFn0vnmJK3CW1M3IgBzV8cz88+7WAQwUE5THDm+UDysxSTN6KNc1+/jHMD1uKW",
+	"6ymkb7dOhbpyPdP04O3h+OAfP4wPx4ez1wfT6XQo3SkQWb7U5jlSGko/1RO8VqR3is1F+dwCH4S6PW+P",
+	"9+egV4+rnj1hceDHyys/yTEHL7F24Qf2Nd1xa2v/bTSJl0ltqrfPQf+1FB08NN95SLU89KRaP7V80VVt",
+	"VMK+TK5OFpP5glSKg5Fs05YMGu1h+QJ9CMtvQqXypDosP7L7VIe5SQO/xVK9Vwz1vqZB8VIL5epdSKFu",
+	"n45zex5LvH3qmmXDLweFfbHW0UY0yoxhGy8/sZRCZTdfsO5FmPrE8tsdQfWOiBTWIUshmeMxGiGQFoQh",
+	"crxPZg9tj49Wq+l0Np0dHKCzS+ZQyHRG//nrr/zb0Te/sNFqOnp7/XCQvNnOXj0cbruPXv0bx31Ndyjn",
+	"Fyejowsyb7LfkIb2Qh9BqapAjRyfnZ/ShB7/NP9wQhO6ODo//XSJ/5yenqNeduDrIYPLX9RJoV73akET",
+	"enL286fuIv7x/go6+wBrkPvqkfXjbth90FnmfeJfJ41VDssq8xlipfGxbwg6AOKbp8/dsOz1gFMXRi8l",
+	"FEMtg2NiAOkRyauCYcnDuC8N4L6UTIWzNBblaagXhCU6TStjQO0OljIYbIqMHGS5qiTOQEHGsqYeherM",
+	"sPRmfC1C7sv1HQ4ujU4B+Jj8bIRzgCc4OVWZFDb3sxp8WPeCyoQCMDYhla2YlBuitCO2EljM4giFWRXS",
+	"XAlf4Th2C7mWHIz1q+FoHy/iX8C7We9YKxULCyzNmWNLZoE4UWBVWrnBJKisY2romD4iV+dzYmAFgbVA",
+	"Ux0N1pPTsPwouwmBcTbGepxxX/wwsjIsK0C1FjNEG2Kr5ahkLm8asNo9mxLG5CPbYOdRxWK05SCjdUyn",
+	"wjaTRDiZrK5MCiTVvHdATOLASdpwNvKS/srpW1Aj1PIIHTfy7I0CeyttCubojFZGjBpmhmjF47Wyw7XP",
+	"T5eXCxIGeGQkA4XdaWwgsVs1IhOKWDDYe4Z26SkJd/b23fR1QmMxTmffvX2b0Fik0tnBdDpUtcWUt68A",
+	"m2uD4iwKZjZ7ceMd81eL/gKMj8crxdZMSLQ55JDwAHe4YpVEH7KlrtxsKZm6pclLtF8p8XsFctMPgjYf",
+	"RCscENTnb2DuXYu3teDAydFiPiZnZalbnVUdSSy2yuT8/fHo+x+m3ydE+OykQPje00CqiwIUD3OX2GHX",
+	"QD3hyFeoMZwmLOTIUeMOrtMKgy/YUdqQTOqld0nYX9Odd9z8suD5jBDpHQsxXmopDp0PTaE83BDH7rNz",
+	"HVAp5E6R5caB9RsL9VhsL2O/a6A0YLGcqfftdKqlT6BhiW8WJ1evuoWnZBswnmthG1G3bjCYbSCdot8U",
+	"OFKyjdSMkxGZL8hPwDgYMiJXJ/WPDssHb74/HIrVvUrr8bLwL+nu5nU716vX68ruD2/nIkF/s2ZugPpH",
+	"O7xeTxeAtNu46Ir5k0V2n8d9nf33/dP/umvqXlfvIYb6cVeyfjQpwFqWPZ+qmsq3Z327jcXx/jm6mDdZ",
+	"NWztvOmY65bIPyD1YXa0mNOErsHYsMJ0PB0f4AZ1CYqVgs7o6/F0fBg6ndxvbhIuk/DfDPy1f7j0FlrN",
+	"OZ3RH8EdhxFJ97PB4XTa+16Ap9aklEz0vhT0idn7GnBRpSlYi1X0WW0cYb8JJoZ00kCZtD5f+C8Joeqg",
+	"M7owok7Ol2cfP/RuzVZChqsylln0Dx6PWtFrXGNSO+QxRuahZ/n/4uMdsyIluDU8a5GDkmVAfEHTFB5G",
+	"S19Aopx8h2LtEyy1G/7IVa8tFNa1BLybEWojZmDvCrx9dTdAfCv3PEP/l3++GrhFGfCS35te7W1t3HLV",
+	"I3BiJfTt58GqW90BLHO1ZlI039TGPdc/6oaWa1tXd967UmeTptl/LBCae4I/0BuNjT8tUn4E7Bu6Fxp7",
+	"EZDQshog5aJHil//neabP4WP+hqmbT+cQM5UsP1beeniJV7yU3xDjM8faGUkndHcuXI2mTzk2rrt7KHU",
+	"xm0nrBST9QEeoMwI7Ho8Rzik2wH6jtI/Rg1o03v9evrmzSGycN3A2asc1mA2Lvd1LYTC3+mBPJJQxYq6",
+	"mK4vSPuLHfut4tlP4D40h8tNXCxm8vZSkZnt9fY/AQAA//9q2WLTFSAAAA==",
 }
 
 // GetSwagger returns the content of the embedded swagger specification file
diff --git a/router/mgmtapi/types.gen.go b/router/mgmtapi/types.gen.go
index 322c17b32..d832a315e 100644
--- a/router/mgmtapi/types.gen.go
+++ b/router/mgmtapi/types.gen.go
@@ -113,7 +113,7 @@ type SiblingInterface struct {
 	// InterfaceId SCION interface identifier.
 	InterfaceId int `json:"interface_id"`
 
-	// InternalInterface Internal address of the local router.
+	// InternalInterface Internal address of the sibling router.
 	InternalInterface string           `json:"internal_interface"`
 	Neighbor          SiblingNeighbor  `json:"neighbor"`
 	Relationship      LinkRelationship `json:"relationship"`
diff --git a/router/svc.go b/router/svc.go
index c13ba5b6d..8811eb109 100644
--- a/router/svc.go
+++ b/router/svc.go
@@ -16,23 +16,25 @@ package router
 
 import (
 	"math/rand/v2"
-	"net/netip"
 	"slices"
 	"sync"
 
 	"github.com/scionproto/scion/pkg/addr"
 )
 
-type services struct {
+// A generic anycast address map. This could in fact be generalized to be a key->anyOneValue
+// with no specific networking semantics, but there's no obvious other usage at the moment.
+// This is for use by all underlay providers to implement the service mapping.
+type Services[addrT comparable] struct {
 	mtx sync.Mutex
-	m   map[addr.SVC][]netip.AddrPort
+	m   map[addr.SVC][]addrT
 }
 
-func newServices() *services {
-	return &services{m: make(map[addr.SVC][]netip.AddrPort)}
+func NewServices[addrT comparable]() *Services[addrT] {
+	return &Services[addrT]{m: make(map[addr.SVC][]addrT)}
 }
 
-func (s *services) AddSvc(svc addr.SVC, a netip.AddrPort) {
+func (s *Services[addrT]) AddSvc(svc addr.SVC, a addrT) {
 	s.mtx.Lock()
 	defer s.mtx.Unlock()
 
@@ -43,7 +45,7 @@ func (s *services) AddSvc(svc addr.SVC, a netip.AddrPort) {
 	s.m[svc] = append(addrs, a)
 }
 
-func (s *services) DelSvc(svc addr.SVC, a netip.AddrPort) {
+func (s *Services[addrT]) DelSvc(svc addr.SVC, a addrT) {
 	s.mtx.Lock()
 	defer s.mtx.Unlock()
 
@@ -53,17 +55,19 @@ func (s *services) DelSvc(svc addr.SVC, a netip.AddrPort) {
 		return
 	}
 	addrs[index] = addrs[len(addrs)-1]
-	addrs[len(addrs)-1] = netip.AddrPort{}
+	var zeroAddr addrT
+	addrs[len(addrs)-1] = zeroAddr
 	s.m[svc] = addrs[:len(addrs)-1]
 }
 
-func (s *services) Any(svc addr.SVC) (netip.AddrPort, bool) {
+func (s *Services[addrT]) Any(svc addr.SVC) (addrT, bool) {
 	s.mtx.Lock()
 	defer s.mtx.Unlock()
 
 	addrs := s.m[svc]
 	if len(addrs) == 0 {
-		return netip.AddrPort{}, false
+		var zeroAddr addrT
+		return zeroAddr, false
 	}
 	return addrs[rand.IntN(len(addrs))], true
 }
diff --git a/router/underlay.go b/router/underlay.go
index b02cc3841..d26249182 100644
--- a/router/underlay.go
+++ b/router/underlay.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"net/netip"
 
+	"github.com/scionproto/scion/pkg/addr"
 	"github.com/scionproto/scion/router/bfd"
 )
 
@@ -43,11 +44,15 @@ const (
 // difference in behaviour is hidden from the rest of the router. The router only needs to
 // associate an interface ID with a link. If the interface ID belongs to a sibling router, then
 // the link is a sibling link. If the interface ID is zero, then the link is the internal link.
+//
+// Note about Resolve. It resolves the given SCION host/svc address to an address on this underlay.
+// This functionality is really only needed on the internal link,
 type Link interface {
 	IsUp() bool
 	IfID() uint16
 	Scope() LinkScope
 	BFDSession() *bfd.Session
+	Resolve(p *Packet, dst addr.Host, port uint16) error
 	Send(p *Packet) bool
 	SendBlocking(p *Packet)
 }
@@ -61,9 +66,29 @@ type Link interface {
 // make them opaque; to be interpreted only by the underlay implementation.
 type UnderlayProvider interface {
 
+	// SetConnNewer is a unit testing device: it allows the replacement of the function
+	// that creates new underlay connections. Underlay implementations can, at their
+	// choice, implement this properly, or panic if it is called. The newer can be anything
+	// that suits the underlay implementation, so tests that use this must match the code of
+	// a specific underlay provider.
+	SetConnNewer(newer any)
+
 	// NumConnections returns the current number of configured connections.
 	NumConnections() int
 
+	// SetDispatchPorts sets the range of auto-dispatched ports and default endhost port (the shim
+	// dispatcher port). When translating a SCION port into an underlay port, any port between the
+	// values of start and end remains unchanged, while any other will be replaced by the value of
+	// redirect. Not all underlays have to provide that service and it might not be meaningful for a
+	// non-ip underlay. In such cases, this method simply has no effect.
+	SetDispatchPorts(start, end, redirect uint16)
+
+	// AddSvc adds the address for the given service. This can be called multiple times per service.
+	AddSvc(svc addr.SVC, a addr.Host, p uint16) error
+
+	// DelSvc deletes the address for the given service.
+	DelSvc(svc addr.SVC, a addr.Host, p uint16) error
+
 	// 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
@@ -81,10 +106,10 @@ type UnderlayProvider interface {
 	// do not need an underlay destination as metadata. Incoming packets have a defined ingress
 	// ifID.
 	NewExternalLink(
-		conn BatchConn,
 		qSize int,
 		bfd *bfd.Session,
-		remote netip.AddrPort,
+		local string,
+		remote string,
 		ifID uint16,
 		metrics InterfaceMetrics,
 	) (Link, error)
@@ -96,12 +121,16 @@ type UnderlayProvider interface {
 	NewSiblingLink(
 		qSize int,
 		bfd *bfd.Session,
-		remote netip.AddrPort,
+		local string,
+		remote string,
 		metrics InterfaceMetrics,
-	) Link
+	) (Link, error)
 
 	// 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
+	// given neither ifID nor remote address. Outgoing packets need to have a destination address as
 	// metadata. Incoming packets have no defined ingress ifID.
-	NewInternalLink(conn BatchConn, qSize int, metrics InterfaceMetrics) Link
+	NewInternalLink(localAddr netip.AddrPort, qSize int, metrics InterfaceMetrics) (Link, error)
 }
+
+// NewProviderFn is a function that instantiates an underlay provider.
+type NewProviderFn func(batchSize, receiveBufferSize, sendBufferSize int) UnderlayProvider
diff --git a/router/underlayproviders/udpip/udpip.go b/router/underlayproviders/udpip/udpip.go
index 58778a841..2e54c8620 100644
--- a/router/underlayproviders/udpip/udpip.go
+++ b/router/underlayproviders/udpip/udpip.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"crypto/rand"
 	"errors"
+	"fmt"
 	"maps"
 	"net"
 	"net/netip"
@@ -29,11 +30,32 @@ import (
 	"github.com/scionproto/scion/pkg/log"
 	"github.com/scionproto/scion/pkg/private/serrors"
 	"github.com/scionproto/scion/pkg/slayers"
-	underlayconn "github.com/scionproto/scion/private/underlay/conn"
+	"github.com/scionproto/scion/private/underlay/conn"
 	"github.com/scionproto/scion/router"
 	"github.com/scionproto/scion/router/bfd"
 )
 
+var (
+	errResolveOnSiblingLink  = errors.New("unsupported address resolution on sibling link")
+	errResolveOnExternalLink = errors.New("unsupported address resolution on external link")
+	errInvalidServiceAddress = errors.New("invalid service address")
+	errShortPacket           = errors.New("packet is too short")
+	errDuplicateRemote       = errors.New("duplicate remote address")
+)
+
+// An interface to enable unit testing.
+type ConnNewer interface {
+	New(l netip.AddrPort, r netip.AddrPort, c *conn.Config) (router.BatchConn, error)
+}
+
+// The default ConnNewer for this underlay: opens an udp BatchConn.
+type un struct {
+}
+
+func (_ un) New(l netip.AddrPort, r netip.AddrPort, c *conn.Config) (router.BatchConn, error) {
+	return conn.New(l, r, c)
+}
+
 // 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
@@ -41,15 +63,20 @@ import (
 // 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.
+	batchSize          int
 	allLinks           map[netip.AddrPort]udpLink
 	allConnections     []*udpConnection
+	connNewer          ConnNewer // un{}, except for unit tests
+	svc                *router.Services[netip.AddrPort]
 	internalConnection *udpConnection // Because we share it w/ siblinglinks
 	internalHashSeed   uint32         // As a result, this too is shared.
+	receiveBufferSize  int
+	sendBufferSize     int
+	dispatchStart      uint16
+	dispatchEnd        uint16
+	dispatchRedirect   uint16
 }
 
-var errShortPacket = errors.New("Packet is too short")
-
 type udpLink interface {
 	router.Link
 	start(ctx context.Context, procQs []chan *router.Packet, pool chan *router.Packet)
@@ -61,23 +88,61 @@ 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)
+	router.AddUnderlay("udpip", newProvider)
 }
 
 // New instantiates a new instance of the provider for exclusive use by the caller.
-func newProvider(batchSize int) router.UnderlayProvider {
+// TODO(multi_underlay): batchSize should be an underlay-specific config.
+func newProvider(batchSize int, receiveBufferSize int, sendBufferSize int) router.UnderlayProvider {
 	return &provider{
-		batchSize: batchSize,
-		allLinks:  make(map[netip.AddrPort]udpLink),
+		batchSize:         batchSize,
+		allLinks:          make(map[netip.AddrPort]udpLink),
+		connNewer:         un{},
+		svc:               router.NewServices[netip.AddrPort](),
+		receiveBufferSize: receiveBufferSize,
+		sendBufferSize:    sendBufferSize,
 	}
 }
 
+// SetConnNewer installs the given newer. newer must be an implementation of ConnNewer or
+// panic will ensue. Only for use in unit tests.
+func (u *provider) SetConnNewer(newer any) {
+	u.connNewer = newer.(ConnNewer)
+}
+
 func (u *provider) NumConnections() int {
 	u.mu.Lock()
 	defer u.mu.Unlock()
 	return len(u.allLinks)
 }
 
+func (u *provider) SetDispatchPorts(start, end, redirect uint16) {
+	u.dispatchStart = start
+	u.dispatchEnd = end
+	u.dispatchRedirect = redirect
+}
+
+// AddSvc adds the address for the given service.
+func (u *provider) AddSvc(svc addr.SVC, a addr.Host, p uint16) error {
+	// We pre-resolve the addresses, which is trivial for this underlay.
+	addr := netip.AddrPortFrom(a.IP(), p)
+	if !addr.IsValid() {
+		return errInvalidServiceAddress
+	}
+	u.svc.AddSvc(svc, addr)
+	return nil
+}
+
+// DelSvc deletes the address for the given service.
+func (u *provider) DelSvc(svc addr.SVC, a addr.Host, p uint16) error {
+	addr := netip.AddrPortFrom(a.IP(), p)
+	if !addr.IsValid() {
+		return errInvalidServiceAddress
+	}
+	u.svc.DelSvc(svc, addr)
+	return nil
+}
+
 // 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(
@@ -92,9 +157,8 @@ func (u *provider) Start(
 	linkSnapshot := slices.Collect(maps.Values(u.allLinks))
 	u.mu.Unlock()
 
-	// Links MUST be started before connections. They need procQs and pool to process
-	// incoming packets. Given that this is an internal mater, we don't pay the
-	// price of checking at use time.
+	// Links MUST be started before connections. Given that this is an internal mater, we don't pay
+	// the price of checking at use time.
 	for _, l := range linkSnapshot {
 		l.start(ctx, procQs, pool)
 	}
@@ -179,7 +243,7 @@ func (u *udpConnection) receive(batchSize int, pool chan *router.Packet) {
 
 	// 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)
+	msgs := conn.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.
@@ -290,7 +354,7 @@ func (u *udpConnection) send(batchSize int, pool chan *router.Packet) {
 
 	// We use this as a temporary buffer, but allocate it just once
 	// to save on garbage handling.
-	msgs := make(underlayconn.Messages, batchSize)
+	msgs := make(conn.Messages, batchSize)
 	for i := range msgs {
 		msgs[i].Buffers = make([][]byte, 1)
 	}
@@ -376,25 +440,36 @@ type externalLink struct {
 // don't keep the remote address, but in the future, we will be making the connections (and
 // the conn argument will be gone), so we will need the address for that.
 func (u *provider) NewExternalLink(
-	conn router.BatchConn,
 	qSize int,
 	bfd *bfd.Session,
-	remote netip.AddrPort,
+	local string,
+	remote string,
 	ifID uint16,
 	metrics router.InterfaceMetrics,
 ) (router.Link, error) {
-	if remote == (netip.AddrPort{}) {
-		// The router doesn't do this. This is an internal error.
-		panic("Zero address not supported")
+
+	localAddr, err := conn.ResolveAddrPortOrPort(local)
+	if err != nil {
+		return nil, serrors.Wrap("resolving local address", err)
+	}
+	remoteAddr, err := conn.ResolveAddrPort(remote)
+	if err != nil {
+		return nil, serrors.Wrap("resolving remote address", err)
 	}
 
 	u.mu.Lock()
 	defer u.mu.Unlock()
 
-	if l := u.allLinks[remote]; l != nil {
+	if l := u.allLinks[remoteAddr]; l != nil {
 		// This is a really bad idea. We can't just panic because it may be a configuration error.
 		// So, we have to return an error.
-		return nil, serrors.New("remote address reused")
+		return nil, serrors.Join(errDuplicateRemote, nil, "addr", remote)
+	}
+
+	conn, err := u.connNewer.New(localAddr, remoteAddr,
+		&conn.Config{ReceiveBufferSize: u.receiveBufferSize, SendBufferSize: u.sendBufferSize})
+	if err != nil {
+		return nil, err
 	}
 
 	queue := make(chan *router.Packet, qSize)
@@ -409,13 +484,13 @@ func (u *provider) NewExternalLink(
 		conn:         conn,
 		queue:        queue,
 		metrics:      metrics, // send() needs them :-(
-		name:         remote.String(),
+		name:         remote,
 		receiverDone: make(chan struct{}),
 		senderDone:   make(chan struct{}),
 		link:         el,
 	}
 	u.allConnections = append(u.allConnections, c)
-	u.allLinks[remote] = el
+	u.allLinks[remoteAddr] = el
 	return el, nil
 }
 
@@ -462,6 +537,11 @@ func (l *externalLink) IsUp() bool {
 	return l.bfdSession == nil || l.bfdSession.IsUp()
 }
 
+// Resolve should not be useful on an external link so we don't implement it yet.
+func (l *externalLink) Resolve(p *router.Packet, host addr.Host, port uint16) error {
+	return errResolveOnExternalLink
+}
+
 func (l *externalLink) Send(p *router.Packet) bool {
 	select {
 	case l.egressQ <- p:
@@ -511,25 +591,27 @@ type siblingLink struct {
 // 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
+// batchConn with the sibling router. This is coming. Now the router gives us what we need
+// to make the connection ourselves, but we still reuse the internal one as a transition.
+// The router gives us a BFDSession in all cases and we might throw it away (there
 // are no permanent resources attached to it). This could be fixed by moving some BFD related code
 // in-here.
 func (u *provider) NewSiblingLink(
 	qSize int,
 	bfd *bfd.Session,
-	remote netip.AddrPort,
+	local string,
+	remote string,
 	metrics router.InterfaceMetrics,
-) router.Link {
-	if remote == (netip.AddrPort{}) {
-		// The router doesn't do this. This is an internal error.
-		panic("Zero address not supported")
+) (router.Link, error) {
+
+	// We don't currently use the address, but that will change. It *must* be valid.
+	_, err := conn.ResolveAddrPortOrPort(local)
+	if err != nil {
+		return nil, serrors.Wrap("resolving local address", err)
+	}
+	remoteAddr, err := conn.ResolveAddrPort(remote)
+	if err != nil {
+		return nil, serrors.Wrap("resolving remote address", err)
 	}
 
 	u.mu.Lock()
@@ -537,8 +619,8 @@ func (u *provider) NewSiblingLink(
 
 	// We silently deduplicate sibling links, so the router doesn't need to be aware or keep track
 	// of link sharing.
-	if l := u.allLinks[remote]; l != nil {
-		return l
+	if l := u.allLinks[remoteAddr]; l != nil {
+		return l, nil
 	}
 
 	// All sibling links re-use the internal connection. This used to be a late binding (packets to
@@ -555,12 +637,12 @@ func (u *provider) NewSiblingLink(
 		egressQ:    c.queue, // And therefore we do not use qsize for now.
 		metrics:    metrics,
 		bfdSession: bfd,
-		remote:     net.UDPAddrFromAddrPort(remote),
+		remote:     net.UDPAddrFromAddrPort(remoteAddr),
 		seed:       u.internalHashSeed, // per connection, but used only by link.
 	}
-	c.links[remote] = sl
-	u.allLinks[remote] = sl
-	return sl
+	c.links[remoteAddr] = sl
+	u.allLinks[remoteAddr] = sl
+	return sl, nil
 }
 
 func (l *siblingLink) start(
@@ -606,6 +688,11 @@ func (l *siblingLink) IsUp() bool {
 	return l.bfdSession == nil || l.bfdSession.IsUp()
 }
 
+// Resolve should not be useful on a sibling link so we don't implement it yet.
+func (l *siblingLink) Resolve(p *router.Packet, host addr.Host, port uint16) error {
+	return errResolveOnSiblingLink
+}
+
 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.
@@ -630,12 +717,12 @@ func (l *siblingLink) receive(size int, srcAddr *net.UDPAddr, p *router.Packet)
 	sc := router.ClassOfSize(size)
 	metrics[sc].InputPacketsTotal.Inc()
 	metrics[sc].InputBytesTotal.Add(float64(size))
-
 	procID, err := computeProcID(p.RawPacket, len(l.procQs), l.seed)
 	if err != nil {
 		log.Debug("Error while computing procID", "err", err)
 		l.pool <- p
 		metrics[sc].DroppedPacketsInvalid.Inc()
+		return
 	}
 
 	p.Link = l
@@ -648,20 +735,24 @@ func (l *siblingLink) receive(size int, srcAddr *net.UDPAddr, p *router.Packet)
 }
 
 type internalLink struct {
-	procQs  []chan *router.Packet
-	egressQ chan *router.Packet
-	metrics router.InterfaceMetrics
-	pool    chan *router.Packet
-	seed    uint32
+	procQs           []chan *router.Packet
+	egressQ          chan *router.Packet
+	metrics          router.InterfaceMetrics
+	pool             chan *router.Packet
+	svc              *router.Services[netip.AddrPort]
+	seed             uint32
+	dispatchStart    uint16
+	dispatchEnd      uint16
+	dispatchRedirect uint16
 }
 
 // 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.
+// TODO(multi_underlay): We still go with the assumption that internal links are always
+// udpip, so we don't expect a string here. That should change.
 func (u *provider) NewInternalLink(
-	conn router.BatchConn, qSize int, metrics router.InterfaceMetrics,
-) router.Link {
+	localAddr netip.AddrPort, qSize int, metrics router.InterfaceMetrics) (router.Link, error) {
+
 	u.mu.Lock()
 	defer u.mu.Unlock()
 
@@ -670,12 +761,23 @@ func (u *provider) NewInternalLink(
 		panic("More than one internal link")
 	}
 
+	conn, err := u.connNewer.New(
+		localAddr, netip.AddrPort{},
+		&conn.Config{ReceiveBufferSize: u.receiveBufferSize, SendBufferSize: u.sendBufferSize})
+
+	if err != nil {
+		return nil, err
+	}
 	u.internalHashSeed = makeHashSeed()
 	queue := make(chan *router.Packet, qSize)
 	il := &internalLink{
-		egressQ: queue,
-		metrics: metrics,
-		seed:    u.internalHashSeed,
+		egressQ:          queue,
+		metrics:          metrics,
+		svc:              u.svc,
+		seed:             u.internalHashSeed,
+		dispatchStart:    u.dispatchStart,
+		dispatchEnd:      u.dispatchEnd,
+		dispatchRedirect: u.dispatchRedirect,
 	}
 	c := &udpConnection{
 		conn:         conn,
@@ -690,7 +792,7 @@ func (u *provider) NewInternalLink(
 	u.allLinks[netip.AddrPort{}] = il
 	u.internalConnection = c
 	u.allConnections = append(u.allConnections, c)
-	return il
+	return il, nil
 }
 
 func (l *internalLink) start(
@@ -723,6 +825,42 @@ func (l *internalLink) IsUp() bool {
 	return true
 }
 
+// Resolve updates the packet's underlay destination according to the given SCION host/service
+// address and SCION port number.  On the udpip underlay, host addresses are bit-for-bit identical
+// to underlay addresses. The port space is the same, except if the packet is redirected to the shim
+// dispatcher.  TODO(jiceatscion): make cheaper; copy addr less.
+func (l *internalLink) Resolve(p *router.Packet, dst addr.Host, port uint16) error {
+	var dstAddr netip.Addr
+	switch dst.Type() {
+	case addr.HostTypeSVC:
+		// For map lookup use the Base address, i.e. strip the multi cast information, because we
+		// only register base addresses in the map.
+		a, ok := l.svc.Any(dst.SVC().Base())
+		if !ok {
+			return router.NoSVCBackend
+		}
+		dstAddr = a.Addr()
+		// Supplied port is irrelevant. Port is in svc record.
+		port = a.Port()
+	case addr.HostTypeIP:
+		dstAddr = dst.IP()
+		if dstAddr.Is4In6() {
+			return router.UnsupportedV4MappedV6Address
+		}
+		if dstAddr.IsUnspecified() {
+			return router.UnsupportedUnspecifiedAddress
+		}
+	default:
+		panic(fmt.Sprintf("unexpected address type returned from DstAddr: %s", dst.Type()))
+	}
+	// if port is outside the configured port range we send to the fixed port.
+	if port < l.dispatchStart && port > l.dispatchEnd {
+		port = l.dispatchRedirect
+	}
+	updateRemoteAddrFromAddrAndPort(p.RemoteAddr, dstAddr, port)
+	return nil
+}
+
 // The packet's destination is already in the packet's meta-data.
 func (l *internalLink) Send(p *router.Packet) bool {
 	select {
@@ -802,3 +940,31 @@ func updateRemoteAddr(p *router.Packet, newAddr *net.UDPAddr) {
 	rAddr.IP = rAddr.IP[0:len(newAddr.IP)]
 	copy(rAddr.IP, newAddr.IP)
 }
+
+// updateNetAddrFromAddrAndPort() updates a net.UDPAddr address to be the same IP and port as
+// the given netip.Addr and port.
+//
+// We handle netAddr so we don't make the GC work. The issue is the IP address slice
+// that's in netAddr. The packet, along with its address and IP slice, gets copied from a channel
+// into a local variable. Then after we modify it, all gets copied to the some other channel and
+// eventually it gets copied back to the pool. If we replace the destAddr.IP at any point,
+// the old backing array behind the destAddr.IP slice ends-up on the garbage pile. To prevent that,
+// we update the IP address in-place (we make the length 0 to represent the 0 address).
+func updateRemoteAddrFromAddrAndPort(netAddr *net.UDPAddr, addr netip.Addr, port uint16) {
+	netAddr.Port = int(port)
+	netAddr.Zone = addr.Zone()
+	if addr.Is4() {
+		outIpBytes := addr.As4()     // Must store explicitly in order to copy
+		netAddr.IP = netAddr.IP[0:4] // Update slice
+		copy(netAddr.IP, outIpBytes[:])
+	} else if addr.Is6() {
+		outIpBytes := addr.As16()
+		netAddr.IP = netAddr.IP[0:16]
+		copy(netAddr.IP, outIpBytes[:])
+	} else {
+		// That's a zero address. We translate in to something resembling a nil IP.
+		// Nothing gets discarded as we keep the slice (and its reference to the backing array).
+		// To that end, we cannot make it nil. We have to make its length zero.
+		netAddr.IP = netAddr.IP[0:0]
+	}
+}
diff --git a/spec/router.gen.yml b/spec/router.gen.yml
index 9c925b403..d09656d7b 100644
--- a/spec/router.gen.yml
+++ b/spec/router.gen.yml
@@ -241,7 +241,7 @@ components:
           type: integer
           example: 3
         internal_interface:
-          description: Internal address of the local router.
+          description: Internal address of the sibling router.
           type: string
           example: 192.168.2.2:31000
         neighbor:
diff --git a/spec/router/interfaces.yml b/spec/router/interfaces.yml
index 0f702b57b..a98a5a33d 100644
--- a/spec/router/interfaces.yml
+++ b/spec/router/interfaces.yml
@@ -125,7 +125,7 @@ components:
           type: integer
           example: 3
         internal_interface:
-          description: Internal address of the local router.
+          description: Internal address of the sibling router.
           type: string
           example: 192.168.2.2:31000
         neighbor:


Test patch:
diff --git a/private/topology/export_test.go b/private/topology/export_test.go
index 09110424f..6a95b1917 100644
--- a/private/topology/export_test.go
+++ b/private/topology/export_test.go
@@ -15,10 +15,6 @@
 
 package topology
 
-var (
-	RawBRIntfLocalAddr = rawBRIntfLocalAddr
-)
-
 // SetFile allows to change the file for testing. This is helpful because we
 // don't need to modify the base file in the testdata but can write arbitrary
 // files and load from them.
diff --git a/private/topology/testdata/deprecated-public-bind.json b/private/topology/testdata/deprecated-public-bind.json
deleted file mode 100644
index ebf88f202..000000000
--- a/private/topology/testdata/deprecated-public-bind.json
+++ /dev/null
@@ -1,42 +0,0 @@
-{
-  "isd_as": "1-ff00:0:311",
-  "mtu": 1472,
-  "attributes": [],
-  "border_routers": {
-    "br1-ff00:0:311-1": {
-      "internal_addr": "10.1.0.1:0",
-      "interfaces": {
-        "1": {
-          "underlay": {
-            "public": "192.0.2.1:44997",
-            "remote": "192.0.2.2:44998",
-            "bind": "10.0.0.1"
-          },
-          "isd_as": "1-ff00:0:312",
-          "link_to": "PARENT",
-          "mtu": 1472
-        },
-        "3": {
-          "underlay": {
-            "public": "[2001:db8:a0b:12f0::1]:44997",
-            "remote": "[2001:db8:a0b:12f0::2]:44998",
-            "bind": "2001:db8:a0b:12f0::8"
-          },
-          "isd_as": "1-ff00:0:314",
-          "link_to": "CHILD",
-          "mtu": 4430
-        },
-        "8": {
-          "underlay": {
-            "public": "192.0.2.2:44997",
-            "remote": "192.0.2.3:44998"
-          },
-          "isd_as": "1-ff00:0:313",
-          "link_to": "PEER",
-          "mtu": 1480
-        }
-      }
-    }
-  }
-}
-
diff --git a/private/topology/topology_test.go b/private/topology/topology_test.go
index 65cfcbb63..7a69c2249 100644
--- a/private/topology/topology_test.go
+++ b/private/topology/topology_test.go
@@ -26,7 +26,6 @@ import (
 
 	"github.com/scionproto/scion/pkg/addr"
 	"github.com/scionproto/scion/pkg/segment/iface"
-	jsontopo "github.com/scionproto/scion/private/topology/json"
 )
 
 func TestMeta(t *testing.T) {
@@ -195,8 +194,9 @@ func TestIFInfoMap(t *testing.T) {
 			ID:           1,
 			BRName:       "br1-ff00:0:311-1",
 			InternalAddr: netip.MustParseAddrPort("10.1.0.1:0"),
-			Local:        netip.MustParseAddrPort("192.0.2.1:44997"),
-			Remote:       netip.MustParseAddrPort("192.0.2.2:44998"),
+			Provider:     "udpip",
+			Local:        "192.0.2.1:44997",
+			Remote:       "192.0.2.2:44998",
 			IA:           addr.MustParseIA("1-ff00:0:312"),
 			LinkType:     Parent,
 			MTU:          1472,
@@ -210,8 +210,9 @@ func TestIFInfoMap(t *testing.T) {
 			ID:           3,
 			BRName:       "br1-ff00:0:311-1",
 			InternalAddr: netip.MustParseAddrPort("10.1.0.1:0"),
-			Local:        netip.MustParseAddrPort("[2001:db8:a0b:12f0::1]:44997"),
-			Remote:       netip.MustParseAddrPort("[2001:db8:a0b:12f0::2]:44998"),
+			Provider:     "udpip",
+			Local:        "[2001:db8:a0b:12f0::1]:44997",
+			Remote:       "[2001:db8:a0b:12f0::2]:44998",
 			IA:           addr.MustParseIA("1-ff00:0:314"),
 			LinkType:     Child,
 			MTU:          4430,
@@ -220,8 +221,9 @@ func TestIFInfoMap(t *testing.T) {
 			ID:           8,
 			BRName:       "br1-ff00:0:311-1",
 			InternalAddr: netip.MustParseAddrPort("10.1.0.1:0"),
-			Local:        netip.AddrPortFrom(netip.Addr{}, 44997),
-			Remote:       netip.MustParseAddrPort("192.0.2.3:44998"),
+			Provider:     "udpip",
+			Local:        ":44997",
+			Remote:       "192.0.2.3:44998",
 			IA:           addr.MustParseIA("1-ff00:0:313"),
 			LinkType:     Peer,
 			MTU:          1480,
@@ -230,8 +232,9 @@ func TestIFInfoMap(t *testing.T) {
 			ID:           11,
 			BRName:       "br1-ff00:0:311-2",
 			InternalAddr: netip.MustParseAddrPort(`[2001:db8:a0b:12f0::1%some-internal-zone]:0`),
-			Local:        netip.MustParseAddrPort(`[2001:db8:a0b:12f0::1%some-local-zone]:44897`),
-			Remote:       netip.MustParseAddrPort(`[2001:db8:a0b:12f0::2%some-remote-zone]:44898`),
+			Provider:     "udpip",
+			Local:        `[2001:db8:a0b:12f0::1%some-local-zone]:44897`,
+			Remote:       `[2001:db8:a0b:12f0::2%some-remote-zone]:44898`,
 			IA:           addr.MustParseIA("1-ff00:0:314"),
 			LinkType:     Child,
 			MTU:          4430,
@@ -240,46 +243,6 @@ func TestIFInfoMap(t *testing.T) {
 	assert.Equal(t, ifm, c.IFInfoMap)
 }
 
-func TestIFInfoMapDeprecatedPublicBind(t *testing.T) {
-	c := MustLoadTopo(t, "testdata/deprecated-public-bind.json")
-	ifm := IfInfoMap{
-		// local: bind IP, public port
-		1: IFInfo{
-			ID:           1,
-			BRName:       "br1-ff00:0:311-1",
-			InternalAddr: netip.MustParseAddrPort("10.1.0.1:0"),
-			Local:        netip.MustParseAddrPort("10.0.0.1:44997"),
-			Remote:       netip.MustParseAddrPort("192.0.2.2:44998"),
-			IA:           addr.MustParseIA("1-ff00:0:312"),
-			LinkType:     Parent,
-			MTU:          1472,
-		},
-		// local: bind IP, public port
-		3: IFInfo{
-			ID:           3,
-			BRName:       "br1-ff00:0:311-1",
-			InternalAddr: netip.MustParseAddrPort("10.1.0.1:0"),
-			Local:        netip.MustParseAddrPort("[2001:db8:a0b:12f0::8]:44997"),
-			Remote:       netip.MustParseAddrPort("[2001:db8:a0b:12f0::2]:44998"),
-			IA:           addr.MustParseIA("1-ff00:0:314"),
-			LinkType:     Child,
-			MTU:          4430,
-		},
-		// local: public, no bind
-		8: IFInfo{
-			ID:           8,
-			BRName:       "br1-ff00:0:311-1",
-			InternalAddr: netip.MustParseAddrPort("10.1.0.1:0"),
-			Local:        netip.MustParseAddrPort("192.0.2.2:44997"),
-			Remote:       netip.MustParseAddrPort("192.0.2.3:44998"),
-			IA:           addr.MustParseIA("1-ff00:0:313"),
-			LinkType:     Peer,
-			MTU:          1480,
-		},
-	}
-	assert.Equal(t, ifm, c.IFInfoMap)
-}
-
 func TestIFInfoMapCoreAS(t *testing.T) {
 
 	c := MustLoadTopo(t, "testdata/core.json")
@@ -288,8 +251,9 @@ func TestIFInfoMapCoreAS(t *testing.T) {
 			ID:           91,
 			BRName:       "borderrouter6-ff00:0:362-1",
 			InternalAddr: netip.MustParseAddrPort("10.1.0.1:0"),
-			Local:        netip.MustParseAddrPort("192.0.2.1:4997"),
-			Remote:       netip.MustParseAddrPort("192.0.2.2:4998"),
+			Provider:     "udpip",
+			Local:        "192.0.2.1:4997",
+			Remote:       "192.0.2.2:4998",
 			IA:           addr.MustParseIA("6-ff00:0:363"),
 			LinkType:     Core,
 			MTU:          1472,
@@ -298,8 +262,9 @@ func TestIFInfoMapCoreAS(t *testing.T) {
 			ID:           32,
 			BRName:       "borderrouter6-ff00:0:362-9",
 			InternalAddr: netip.MustParseAddrPort("[2001:db8:a0b:12f0::2]:0"),
-			Local:        netip.MustParseAddrPort("[2001:db8:a0b:12f0::1]:4997"),
-			Remote:       netip.MustParseAddrPort("[2001:db8:a0b:12f0::2]:4998"),
+			Provider:     "udpip",
+			Local:        "[2001:db8:a0b:12f0::1]:4997",
+			Remote:       "[2001:db8:a0b:12f0::2]:4998",
 			IA:           addr.MustParseIA("6-ff00:0:364"),
 			LinkType:     Child,
 			MTU:          4430,
@@ -337,134 +302,6 @@ func TestCopy(t *testing.T) {
 	assert.Equal(t, topo, newTopo)
 }
 
-func TestExternalDataPlanePort(t *testing.T) {
-	testCases := []struct {
-		Name            string
-		Raw             *jsontopo.Underlay
-		ExpectedAddress netip.AddrPort
-		ExpectedError   assert.ErrorAssertionFunc
-	}{
-		{
-			Name:          "Empty",
-			Raw:           &jsontopo.Underlay{},
-			ExpectedError: assert.Error,
-		},
-		{
-			Name: "Port only",
-			Raw: &jsontopo.Underlay{
-				Local: ":42",
-			},
-			ExpectedError:   assert.NoError,
-			ExpectedAddress: netip.AddrPortFrom(netip.Addr{}, 42),
-		},
-		{
-			Name: "Good IPv4",
-			Raw: &jsontopo.Underlay{
-				Local: "127.0.0.1:42",
-			},
-			ExpectedError:   assert.NoError,
-			ExpectedAddress: netip.MustParseAddrPort("127.0.0.1:42"),
-		},
-		{
-			Name: "Good IPv6",
-			Raw: &jsontopo.Underlay{
-				Local: "[::1]:42",
-			},
-			ExpectedError:   assert.NoError,
-			ExpectedAddress: netip.MustParseAddrPort("[::1]:42"),
-		},
-		{
-			Name: "Good IPv6 with zone",
-			Raw: &jsontopo.Underlay{
-				Local: "[::1%some-zone]:42",
-			},
-			ExpectedError:   assert.NoError,
-			ExpectedAddress: netip.MustParseAddrPort(`[::1%some-zone]:42`),
-		},
-		// Deprecated Public / Bind
-		{
-			Name: "Both deprecated public and local",
-			Raw: &jsontopo.Underlay{
-				DeprecatedPublic: "something:42",
-				Local:            "fnord:99",
-			},
-			ExpectedError: assert.Error,
-		},
-		{
-			Name: "Deprecated Bad invalid public",
-			Raw: &jsontopo.Underlay{
-				DeprecatedPublic: "thishostdoesnotexist:42",
-			},
-			ExpectedError: assert.Error,
-		},
-		{
-			Name: "Deprecated Good IPv4 only",
-			Raw: &jsontopo.Underlay{
-				DeprecatedPublic: "127.0.0.1:42",
-			},
-			ExpectedError:   assert.NoError,
-			ExpectedAddress: netip.MustParseAddrPort("127.0.0.1:42"),
-		},
-		{
-			Name: "Deprecated IPv4 with bind underlay",
-			Raw: &jsontopo.Underlay{
-				DeprecatedPublic: "127.0.0.1:42",
-				DeprecatedBind:   "127.255.255.255",
-			},
-			ExpectedError:   assert.NoError,
-			ExpectedAddress: netip.MustParseAddrPort("127.255.255.255:42"),
-		},
-		{
-			Name: "Deprecated IPv4 with bad bind",
-			Raw: &jsontopo.Underlay{
-				DeprecatedPublic: "127.0.0.1:42",
-				DeprecatedBind:   "thishostdoesnotexist",
-			},
-			ExpectedError: assert.Error,
-		},
-		{
-			Name: "Deprecated Good IPv6 only",
-			Raw: &jsontopo.Underlay{
-				DeprecatedPublic: "[::1]:42",
-			},
-			ExpectedError:   assert.NoError,
-			ExpectedAddress: netip.MustParseAddrPort("[::1]:42"),
-		},
-		{
-			Name: "Deprecated Good IPv6 only with zone",
-			Raw: &jsontopo.Underlay{
-				DeprecatedPublic: "[::1%some-zone]:42",
-			},
-			ExpectedError:   assert.NoError,
-			ExpectedAddress: netip.MustParseAddrPort(`[::1%some-zone]:42`),
-		},
-		{
-			Name: "Deprecated IPv6 with bind underlay",
-			Raw: &jsontopo.Underlay{
-				DeprecatedPublic: "[::1]:42",
-				DeprecatedBind:   "2001:db8::1",
-			},
-			ExpectedError:   assert.NoError,
-			ExpectedAddress: netip.MustParseAddrPort(`[2001:db8::1]:42`),
-		},
-		{
-			Name: "Deprecated IPv6 with bad bind underlay",
-			Raw: &jsontopo.Underlay{
-				DeprecatedPublic: "[::1]:42",
-				DeprecatedBind:   "thishostdoesnotexist",
-			},
-			ExpectedError: assert.Error,
-		},
-	}
-	for _, tc := range testCases {
-		t.Run(tc.Name, func(t *testing.T) {
-			topoBRAddr, err := RawBRIntfLocalAddr(tc.Raw)
-			tc.ExpectedError(t, err)
-			assert.Equal(t, tc.ExpectedAddress, topoBRAddr)
-		})
-	}
-}
-
 func TestRawAddrMap_ToTopoAddr(t *testing.T) {
 	testCases := []struct {
 		name        string
diff --git a/private/underlay/conn/address_test.go b/private/underlay/conn/address_test.go
new file mode 100644
index 000000000..1e9c666dc
--- /dev/null
+++ b/private/underlay/conn/address_test.go
@@ -0,0 +1,70 @@
+// Copyright 2016 ETH Zurich
+// Copyright 2018 ETH Zurich, 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.
+// 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 conn
+
+import (
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAddresses(t *testing.T) {
+	testCases := []struct {
+		Name            string
+		Raw             string
+		ExpectedAddress netip.AddrPort
+		ExpectedError   assert.ErrorAssertionFunc
+	}{
+		{
+			Name:          "Empty",
+			Raw:           "",
+			ExpectedError: assert.Error,
+		},
+		{
+			Name:            "Port only",
+			Raw:             ":42",
+			ExpectedError:   assert.NoError,
+			ExpectedAddress: netip.AddrPortFrom(netip.Addr{}, 42),
+		},
+		{
+			Name:            "Good IPv4",
+			Raw:             "127.0.0.1:42",
+			ExpectedError:   assert.NoError,
+			ExpectedAddress: netip.MustParseAddrPort("127.0.0.1:42"),
+		},
+		{
+			Name:            "Good IPv6",
+			Raw:             "[::1]:42",
+			ExpectedError:   assert.NoError,
+			ExpectedAddress: netip.MustParseAddrPort("[::1]:42"),
+		},
+		{
+			Name:            "Good IPv6 with zone",
+			Raw:             "[::1%some-zone]:42",
+			ExpectedError:   assert.NoError,
+			ExpectedAddress: netip.MustParseAddrPort(`[::1%some-zone]:42`),
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.Name, func(t *testing.T) {
+			topoBRAddr, err := ResolveAddrPortOrPort(tc.Raw)
+			tc.ExpectedError(t, err)
+			assert.Equal(t, tc.ExpectedAddress, topoBRAddr)
+		})
+	}
+}
diff --git a/router/dataplane_internal_test.go b/router/dataplane_internal_test.go
index c4e11f654..acb32ae84 100644
--- a/router/dataplane_internal_test.go
+++ b/router/dataplane_internal_test.go
@@ -88,13 +88,15 @@ func TestReceiver(t *testing.T) {
 		},
 	).Times(1)
 
-	_ = dp.AddInternalInterface(mInternal, netip.Addr{})
+	dp.underlays["udpip"].SetConnNewer(MockConnNewer{Ctrl: ctrl, Conn: mInternal})
+
+	_ = dp.AddInternalInterface(netip.AddrPort{})
 
 	dp.initPacketPool(64)
 	procCh, _ := dp.initQueues(64)
 	initialPoolSize := len(dp.packetPool)
 	dp.setRunning()
-	dp.underlay.Start(context.Background(), dp.packetPool, procCh)
+	dp.underlays["udpip"].Start(context.Background(), dp.packetPool, procCh)
 	ptrMap := make(map[uintptr]struct{})
 	for i := 0; i < 21; i++ {
 		select {
@@ -122,7 +124,7 @@ func TestReceiver(t *testing.T) {
 
 	// 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()
+	dp.underlays["udpip"].Stop()
 }
 
 // TestForwarder sets up a mocked batchConn, starts the forwarder that will write to
@@ -132,6 +134,7 @@ func TestReceiver(t *testing.T) {
 func TestForwarder(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	done := make(chan struct{})
+
 	prepareDP := func(ctrl *gomock.Controller) *dataPlane {
 		ret := newDataPlane(
 			RunConfig{NumProcessors: 20, BatchSize: 64, NumSlowPathProcessors: 1}, false)
@@ -190,19 +193,30 @@ func TestForwarder(t *testing.T) {
 
 				return len(ms), nil
 			}).AnyTimes()
-		if err := ret.AddInternalInterface(mConn, netip.Addr{}); err != nil {
+
+		ret.underlays["udpip"].SetConnNewer(MockConnNewer{Ctrl: ctrl, Conn: mConn})
+
+		if err := ret.AddInternalInterface(netip.AddrPort{}); err != nil {
 			panic(err)
 		}
 		l := control.LinkEnd{
 			IA:   addr.MustParseIA("1-ff00:0:1"),
-			Addr: netip.MustParseAddrPort("10.0.0.100:0"),
+			Addr: "10.0.0.100:0",
 		}
 		r := control.LinkEnd{
 			IA:   addr.MustParseIA("1-ff00:0:3"),
-			Addr: netip.MustParseAddrPort("10.0.0.200:0"),
+			Addr: "10.0.0.200:0",
 		}
+		lh := addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
+		rh := addr.HostIP(netip.MustParseAddrPort(r.Addr).Addr())
 		nobfd := control.BFD{Disable: ptr.To(true)}
-		if err := ret.AddExternalInterface(42, mConn, l, r, nobfd); err != nil {
+		link := control.LinkInfo{
+			Provider: "udpip",
+			Local:    l,
+			Remote:   r,
+			BFD:      nobfd,
+		}
+		if err := ret.AddExternalInterface(42, link, lh, rh); err != nil {
 			panic(err)
 		}
 		return ret
@@ -214,7 +228,7 @@ func TestForwarder(t *testing.T) {
 	extf := dp.interfaces[42]
 	initialPoolSize := len(dp.packetPool)
 	dp.setRunning()
-	dp.underlay.Start(context.Background(), dp.packetPool, procQs)
+	dp.underlays["udpip"].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
@@ -245,12 +259,12 @@ func TestForwarder(t *testing.T) {
 
 	select {
 	case <-done:
-		dp.underlay.Stop()
+		dp.underlays["udpip"].Stop()
 		dp.setStopping()
 		time.Sleep(100 * time.Millisecond)
 		assert.Equal(t, initialPoolSize, len(dp.packetPool))
 	case <-time.After(100 * time.Millisecond):
-		dp.underlay.Stop()
+		dp.underlays["udpip"].Stop()
 		dp.setStopping()
 		t.Fail()
 	}
@@ -265,7 +279,6 @@ func TestSlowPathProcessing(t *testing.T) {
 	// * InternalNextHops may not be nil. Empty is ok for all the test cases we have.
 	mockExternalInterfaces := []uint16{1}
 	mockInternalNextHops := map[uint16]netip.AddrPort{}
-	mockServices := map[addr.SVC][]netip.AddrPort{}
 
 	testCases := map[string]struct {
 		mockMsg                 func() []byte
@@ -279,9 +292,8 @@ func TestSlowPathProcessing(t *testing.T) {
 				return newDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
+					MockConnNewer{Ctrl: ctrl},
 					mockInternalNextHops,
-					mockServices,
 					addr.MustParseIA("1-ff00:0:110"),
 					nil, testKey)
 			},
@@ -304,9 +316,8 @@ func TestSlowPathProcessing(t *testing.T) {
 				return newDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
+					MockConnNewer{Ctrl: ctrl},
 					mockInternalNextHops,
-					mockServices,
 					addr.MustParseIA("1-ff00:0:110"), nil, testKey)
 			},
 			mockMsg: func() []byte {
@@ -328,9 +339,8 @@ func TestSlowPathProcessing(t *testing.T) {
 				return newDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
+					MockConnNewer{Ctrl: ctrl},
 					mockInternalNextHops,
-					mockServices,
 					addr.MustParseIA("1-ff00:0:110"), nil, testKey)
 			},
 			mockMsg: func() []byte {
@@ -352,9 +362,8 @@ func TestSlowPathProcessing(t *testing.T) {
 				return newDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
+					MockConnNewer{Ctrl: ctrl},
 					mockInternalNextHops,
-					mockServices,
 					addr.MustParseIA("1-ff00:0:110"), nil, testKey)
 			},
 			mockMsg: func() []byte {
@@ -376,9 +385,8 @@ func TestSlowPathProcessing(t *testing.T) {
 				return newDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
+					MockConnNewer{Ctrl: ctrl},
 					mockInternalNextHops,
-					mockServices,
 					addr.MustParseIA("1-ff00:0:110"), nil, testKey)
 			},
 			mockMsg: func() []byte {
@@ -402,9 +410,8 @@ func TestSlowPathProcessing(t *testing.T) {
 				return newDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
+					MockConnNewer{Ctrl: ctrl},
 					mockInternalNextHops,
-					mockServices,
 					addr.MustParseIA("1-ff00:0:110"), nil, testKey)
 			},
 			mockMsg: func() []byte {
@@ -426,9 +433,8 @@ func TestSlowPathProcessing(t *testing.T) {
 				return newDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
+					MockConnNewer{Ctrl: ctrl},
 					mockInternalNextHops,
-					mockServices,
 					addr.MustParseIA("1-ff00:0:111"), nil, testKey)
 			},
 			mockMsg: func() []byte {
diff --git a/router/dataplane_test.go b/router/dataplane_test.go
index 10f95954a..571d5f8ff 100644
--- a/router/dataplane_test.go
+++ b/router/dataplane_test.go
@@ -44,7 +44,7 @@ import (
 	"github.com/scionproto/scion/pkg/slayers/path/onehop"
 	"github.com/scionproto/scion/pkg/slayers/path/scion"
 	"github.com/scionproto/scion/private/topology"
-	underlayconn "github.com/scionproto/scion/private/underlay/conn"
+	"github.com/scionproto/scion/private/underlay/conn"
 	"github.com/scionproto/scion/router"
 	"github.com/scionproto/scion/router/control"
 	"github.com/scionproto/scion/router/mock_router"
@@ -56,32 +56,29 @@ var (
 )
 
 func TestDataPlaneAddInternalInterface(t *testing.T) {
-	internalIP := netip.MustParseAddr("198.51.100.1")
+	localAddr := netip.MustParseAddrPort("198.51.100.1:2222")
 	t.Run("fails after serve", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 
 		d := router.NewDPRaw(router.RunConfig{}, false)
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
 		d.MockStart()
-		assert.Error(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), internalIP))
-	})
-	t.Run("setting nil value is not allowed", func(t *testing.T) {
-		gomock.NewController(t)
-
-		d := router.NewDPRaw(router.RunConfig{}, false)
-		assert.Error(t, d.AddInternalInterface(nil, netip.Addr{}))
+		assert.Error(t, d.AddInternalInterface(localAddr))
 	})
 	t.Run("single set works", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 
 		d := router.NewDPRaw(router.RunConfig{}, false)
-		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), internalIP))
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
+		assert.NoError(t, d.AddInternalInterface(localAddr))
 	})
 	t.Run("double set fails", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 
 		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))
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
+		assert.NoError(t, d.AddInternalInterface(localAddr))
+		assert.Error(t, d.AddInternalInterface(localAddr))
 	})
 }
 
@@ -110,71 +107,95 @@ func TestDataPlaneSetKey(t *testing.T) {
 func TestDataPlaneAddExternalInterface(t *testing.T) {
 	l := control.LinkEnd{
 		IA:   addr.MustParseIA("1-ff00:0:1"),
-		Addr: netip.MustParseAddrPort("10.0.0.100:0"),
+		Addr: "10.0.0.100:0",
 	}
 	r1 := control.LinkEnd{
 		IA:   addr.MustParseIA("1-ff00:0:3"),
-		Addr: netip.MustParseAddrPort("10.0.0.200:0"),
+		Addr: "10.0.0.200:0",
 	}
 	r2 := control.LinkEnd{
 		IA:   addr.MustParseIA("1-ff00:0:4"),
-		Addr: netip.MustParseAddrPort("10.0.0.201:0"),
+		Addr: "10.0.0.201:0",
 	}
+	lh := addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
+	rh1 := addr.HostIP(netip.MustParseAddrPort(r1.Addr).Addr())
+	rh2 := addr.HostIP(netip.MustParseAddrPort(r2.Addr).Addr())
 	nobfd := control.BFD{Disable: ptr.To(true)}
-	t.Run("fails after serve", func(t *testing.T) {
+	link1 := control.LinkInfo{
+		Provider: "udpip",
+		Local:    l,
+		Remote:   r1,
+		BFD:      nobfd,
+	}
+	link2 := control.LinkInfo{
+		Provider: "udpip",
+		Local:    l,
+		Remote:   r2,
+		BFD:      nobfd,
+	}
+	t.Run("fails after start", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 
 		d := router.NewDPRaw(router.RunConfig{}, false)
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
 		d.MockStart()
-		assert.Error(t, d.AddExternalInterface(
-			42, mock_router.NewMockBatchConn(ctrl), l, r1, nobfd))
-	})
-	t.Run("setting nil conn is not allowed", func(t *testing.T) {
-		gomock.NewController(t)
-
-		d := router.NewDPRaw(router.RunConfig{}, false)
-		assert.Error(t, d.AddExternalInterface(42, nil, l, r1, nobfd))
+		assert.Error(t, d.AddExternalInterface(42, link1, lh, rh1))
 	})
 	t.Run("setting blank src is not allowed", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 
 		d := router.NewDPRaw(router.RunConfig{}, false)
-		assert.Error(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl),
-			control.LinkEnd{}, r1, nobfd))
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
+		link3 := control.LinkInfo{
+			Provider: "udpip",
+			Local:    control.LinkEnd{},
+			Remote:   r1,
+			BFD:      nobfd,
+		}
+		assert.Error(t, d.AddExternalInterface(42, link3, lh, rh1))
 	})
 	t.Run("setting blank dst is not allowed", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 
 		d := router.NewDPRaw(router.RunConfig{}, false)
-		assert.Error(t, d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl),
-			l, control.LinkEnd{}, nobfd))
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
+		link3 := control.LinkInfo{
+			Provider: "udpip",
+			Local:    l,
+			Remote:   control.LinkEnd{},
+			BFD:      nobfd,
+		}
+		assert.Error(t, d.AddExternalInterface(42, link3, lh, rh1))
 	})
 	t.Run("normal add works", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 
 		d := router.NewDPRaw(router.RunConfig{}, false)
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
 		assert.NoError(t,
-			d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r1, nobfd))
+			d.AddExternalInterface(42, link1, lh, rh1))
 		assert.NoError(t,
-			d.AddExternalInterface(45, mock_router.NewMockBatchConn(ctrl), l, r2, nobfd))
+			d.AddExternalInterface(45, link2, lh, rh2))
 	})
 	t.Run("overwrite ifID fails", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 
 		d := router.NewDPRaw(router.RunConfig{}, false)
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
 		assert.NoError(t,
-			d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r1, nobfd))
+			d.AddExternalInterface(42, link1, lh, rh1))
 		assert.Error(t,
-			d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r1, nobfd))
+			d.AddExternalInterface(42, link2, lh, rh2))
 	})
 	t.Run("reuse dst addr fails", func(t *testing.T) {
 		ctrl := gomock.NewController(t)
 
 		d := router.NewDPRaw(router.RunConfig{}, false)
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
 		assert.NoError(t,
-			d.AddExternalInterface(42, mock_router.NewMockBatchConn(ctrl), l, r1, nobfd))
+			d.AddExternalInterface(42, link1, lh, rh1))
 		assert.Error(t,
-			d.AddExternalInterface(45, mock_router.NewMockBatchConn(ctrl), l, r1, nobfd))
+			d.AddExternalInterface(45, link1, lh, rh1))
 	})
 }
 
@@ -182,64 +203,107 @@ func TestDataPlaneAddSVC(t *testing.T) {
 	t.Run("succeeds after serve", func(t *testing.T) {
 		d := router.NewDPRaw(router.RunConfig{}, false)
 		d.MockStart()
-		assert.NoError(t, d.AddSvc(addr.SvcCS, netip.AddrPortFrom(netip.IPv4Unspecified(), 0)))
+		assert.NoError(t, d.AddSvc(addr.SvcCS, addr.HostIP(netip.IPv4Unspecified()), 0))
 	})
 	t.Run("adding empty value is not allowed", func(t *testing.T) {
 		d := router.NewDPRaw(router.RunConfig{}, false)
-		assert.Error(t, d.AddSvc(addr.SvcCS, netip.AddrPort{}))
+		assert.Error(t, d.AddSvc(addr.SvcCS, addr.HostIP(netip.Addr{}), 0))
 	})
 	t.Run("normal set works", func(t *testing.T) {
 		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)))
+		assert.NoError(t, d.AddSvc(addr.SvcCS, addr.HostIP(netip.IPv4Unspecified()), 0))
+		assert.NoError(t, d.AddSvc(addr.SvcDS, addr.HostIP(netip.IPv4Unspecified()), 0))
 	})
 	t.Run("set multiple times works", func(t *testing.T) {
 		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)))
+		assert.NoError(t, d.AddSvc(addr.SvcCS, addr.HostIP(netip.IPv4Unspecified()), 0))
+		assert.NoError(t, d.AddSvc(addr.SvcCS, addr.HostIP(netip.IPv4Unspecified()), 0))
 	})
 }
 
 func TestDataPlaneAddNextHop(t *testing.T) {
-	l := netip.AddrPortFrom(netip.IPv4Unspecified(), 0)
-	r := netip.AddrPortFrom(netip.IPv4Unspecified(), 0)
-	nilAddrPort := netip.AddrPort{}
-	nilAddr := netip.Addr{}
-
+	l := control.LinkEnd{
+		IA:   addr.MustParseIA("1-ff00:0:1"),
+		Addr: "10.0.0.100:0",
+	}
+	r1 := control.LinkEnd{
+		IA:   addr.MustParseIA("1-ff00:0:3"),
+		Addr: "10.0.0.200:0",
+	}
+	r2 := control.LinkEnd{
+		IA:   addr.MustParseIA("1-ff00:0:4"),
+		Addr: "10.0.0.201:0",
+	}
+	internal := netip.MustParseAddrPort("10.10.0.1:2222")
+	lh := addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
+	rh1 := addr.HostIP(netip.MustParseAddrPort(r1.Addr).Addr())
+	rh2 := addr.HostIP(netip.MustParseAddrPort(r2.Addr).Addr())
 	nobfd := control.BFD{Disable: ptr.To(true)}
+	link1 := control.LinkInfo{
+		Provider: "udpip",
+		Local:    l,
+		Remote:   r1,
+		BFD:      nobfd,
+	}
+	link2 := control.LinkInfo{
+		Provider: "udpip",
+		Local:    l,
+		Remote:   r2,
+		BFD:      nobfd,
+	}
 
-	t.Run("fails after serve", func(t *testing.T) {
+	t.Run("fails after start", func(t *testing.T) {
 		d := router.NewDPRaw(router.RunConfig{}, false)
 		ctrl := gomock.NewController(t)
-		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), nilAddr))
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
+		assert.NoError(t, d.AddInternalInterface(internal))
 		d.MockStart()
-		assert.Error(t, d.AddNextHop(45, l, r, nobfd, ""))
+		assert.Error(t, d.AddNextHop(45, link1, lh, rh1))
 	})
-	t.Run("setting nil dst is not allowed", func(t *testing.T) {
+	t.Run("setting nil src is not allowed", func(t *testing.T) {
 		d := router.NewDPRaw(router.RunConfig{}, false)
 		ctrl := gomock.NewController(t)
-		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), nilAddr))
-		assert.Error(t, d.AddNextHop(45, l, nilAddrPort, nobfd, ""))
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
+		link3 := control.LinkInfo{
+			Provider: "udpip",
+			Local:    control.LinkEnd{},
+			Remote:   r1,
+			BFD:      nobfd,
+		}
+		assert.NoError(t, d.AddInternalInterface(internal))
+		assert.Error(t, d.AddNextHop(45, link3, lh, rh1))
 	})
-	t.Run("setting nil src is not allowed", func(t *testing.T) {
+
+	t.Run("setting nil dst is not allowed", func(t *testing.T) {
 		d := router.NewDPRaw(router.RunConfig{}, false)
 		ctrl := gomock.NewController(t)
-		assert.NoError(t, d.AddInternalInterface(mock_router.NewMockBatchConn(ctrl), nilAddr))
-		assert.Error(t, d.AddNextHop(45, nilAddrPort, r, nobfd, ""))
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
+		link3 := control.LinkInfo{
+			Provider: "udpip",
+			Local:    l,
+			Remote:   control.LinkEnd{},
+			BFD:      nobfd,
+		}
+		assert.NoError(t, d.AddInternalInterface(internal))
+		assert.Error(t, d.AddNextHop(45, link3, lh, rh1))
 	})
+
 	t.Run("normal add works", func(t *testing.T) {
 		d := router.NewDPRaw(router.RunConfig{}, false)
 		ctrl := gomock.NewController(t)
-		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, ""))
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
+		assert.NoError(t, d.AddInternalInterface(internal))
+		assert.NoError(t, d.AddNextHop(45, link1, lh, rh1))
+		assert.NoError(t, d.AddNextHop(43, link2, lh, rh2))
 	})
+
 	t.Run("overwrite fails", func(t *testing.T) {
 		d := router.NewDPRaw(router.RunConfig{}, false)
 		ctrl := gomock.NewController(t)
-		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, ""))
+		d.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl})
+		assert.NoError(t, d.AddInternalInterface(internal))
+		assert.NoError(t, d.AddNextHop(45, link1, lh, rh1))
+		assert.Error(t, d.AddNextHop(45, link2, lh, rh1))
 	})
 }
 
@@ -260,6 +324,7 @@ func TestDataPlaneRun(t *testing.T) {
 					},
 					false,
 				)
+
 				key := []byte("testkey_xxxxxxxx")
 				local := addr.MustParseIA("1-ff00:0:110")
 
@@ -268,7 +333,7 @@ func TestDataPlaneRun(t *testing.T) {
 				mInternal.EXPECT().ReadBatch(gomock.Any()).Return(0, nil).AnyTimes()
 
 				mInternal.EXPECT().WriteBatch(gomock.Any(), 0).DoAndReturn(
-					func(ms underlayconn.Messages, flags int) (int, error) {
+					func(ms conn.Messages, flags int) (int, error) {
 						if totalCount == 0 {
 							t.Fail()
 							return 0, nil
@@ -285,11 +350,12 @@ func TestDataPlaneRun(t *testing.T) {
 						}
 						return len(ms), nil
 					}).AnyTimes()
-				_ = ret.AddInternalInterface(mInternal, netip.Addr{})
+				ret.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl, Conn: mInternal})
+				_ = ret.AddInternalInterface(netip.AddrPort{})
 
 				mExternal := mock_router.NewMockBatchConn(ctrl)
 				mExternal.EXPECT().ReadBatch(gomock.Any()).DoAndReturn(
-					func(m underlayconn.Messages) (int, error) {
+					func(m conn.Messages) (int, error) {
 						// 10 scion messages to external
 						for i := 0; i < totalCount; i++ {
 							spkt, dpath := prepBaseMsg(time.Now())
@@ -328,17 +394,27 @@ func TestDataPlaneRun(t *testing.T) {
 				).Times(1)
 				mExternal.EXPECT().ReadBatch(gomock.Any()).Return(0, nil).AnyTimes()
 				mExternal.EXPECT().WriteBatch(gomock.Any(), gomock.Any()).Return(0, nil).AnyTimes()
+
 				l := control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:1"),
-					Addr: netip.MustParseAddrPort("10.0.0.100:0"),
+					Addr: "10.0.0.100:0",
 				}
 				r := control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:3"),
-					Addr: netip.MustParseAddrPort("10.0.0.200:0"),
+					Addr: "10.0.0.200:0",
 				}
+				lh := addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
+				rh := addr.HostIP(netip.MustParseAddrPort(r.Addr).Addr())
 				nobfd := control.BFD{Disable: ptr.To(true)}
+				link := control.LinkInfo{
+					Provider: "udpip",
+					Local:    l,
+					Remote:   r,
+					BFD:      nobfd,
+				}
 
-				_ = ret.AddExternalInterface(1, mExternal, l, r, nobfd)
+				ret.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl, Conn: mExternal})
+				_ = ret.AddExternalInterface(1, link, lh, rh)
 
 				_ = ret.SetIA(local)
 				_ = ret.SetKey(key)
@@ -385,7 +461,7 @@ func TestDataPlaneRun(t *testing.T) {
 
 				mInternal := mock_router.NewMockBatchConn(ctrl)
 				mInternal.EXPECT().ReadBatch(gomock.Any()).DoAndReturn(
-					func(m underlayconn.Messages) (int, error) {
+					func(m conn.Messages) (int, error) {
 						i := 0
 						for k := range routers { // post a BFD from each neighbor router
 							disc := layers.BFDDiscriminator(i)
@@ -402,7 +478,7 @@ func TestDataPlaneRun(t *testing.T) {
 				).Times(1)
 				mInternal.EXPECT().ReadBatch(gomock.Any()).Return(0, nil).AnyTimes()
 				mInternal.EXPECT().WriteBatch(gomock.Any(), gomock.Any()).DoAndReturn(
-					func(msgs underlayconn.Messages, _ int) (int, error) {
+					func(msgs conn.Messages, _ int) (int, error) {
 						pkt := gopacket.NewPacket(msgs[0].Buffers[0],
 							slayers.LayerTypeSCION, gopacket.Default)
 						if b := pkt.Layer(layers.LayerTypeBFD); b != nil {
@@ -420,12 +496,27 @@ func TestDataPlaneRun(t *testing.T) {
 					}).MinTimes(1)
 				mInternal.EXPECT().WriteBatch(gomock.Any(), gomock.Any()).Return(0, nil).AnyTimes()
 
-				local := netip.MustParseAddrPort("10.0.200.100:0")
 				_ = ret.SetKey([]byte("randomkeyformacs"))
-				_ = ret.AddInternalInterface(mInternal, netip.Addr{})
+				ret.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl, Conn: mInternal})
+				_ = ret.AddInternalInterface(netip.AddrPort{})
+				l := control.LinkEnd{
+					IA:   addr.MustParseIA("1-ff00:0:1"),
+					Addr: "10.0.200.100:0",
+				}
+				lh := addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
 				for remote, ifIDs := range routers {
+					r := control.LinkEnd{
+						Addr: remote.String(),
+					}
+					rh := addr.HostIP(netip.MustParseAddrPort(r.Addr).Addr())
+					link := control.LinkInfo{
+						Provider: "udpip",
+						Local:    l,
+						Remote:   r,
+						BFD:      bfd(),
+					}
 					for _, ifID := range ifIDs {
-						_ = ret.AddNextHop(ifID, local, remote, bfd(), "")
+						_ = ret.AddNextHop(ifID, link, lh, rh)
 					}
 				}
 				return ret
@@ -441,11 +532,27 @@ func TestDataPlaneRun(t *testing.T) {
 					},
 					false,
 				)
-				localAddr := netip.MustParseAddrPort("10.0.200.100:0")
-				remoteAddr := netip.MustParseAddrPort("10.0.200.200:0")
+
+				l := control.LinkEnd{
+					IA:   addr.MustParseIA("1-ff00:0:1"),
+					Addr: "10.0.200.100:0",
+				}
+				lh := addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
+				r := control.LinkEnd{
+					IA:   addr.MustParseIA("1-ff00:0:1"),
+					Addr: "10.0.200.200:0",
+				}
+				rh := addr.HostIP(netip.MustParseAddrPort(r.Addr).Addr())
+				link := control.LinkInfo{
+					Provider: "udpip",
+					Local:    l,
+					Remote:   r,
+					BFD:      bfd(),
+				}
+
 				mInternal := mock_router.NewMockBatchConn(ctrl)
 				mInternal.EXPECT().WriteBatch(gomock.Any(), gomock.Any()).DoAndReturn(
-					func(msgs underlayconn.Messages, _ int) (int, error) {
+					func(msgs conn.Messages, _ int) (int, error) {
 						pkt := gopacket.NewPacket(msgs[0].Buffers[0],
 							slayers.LayerTypeSCION, gopacket.Default)
 
@@ -459,7 +566,7 @@ func TestDataPlaneRun(t *testing.T) {
 							if err != nil {
 								return 1, nil
 							}
-							if a.IP() != localAddr.Addr() {
+							if a.IP() != netip.MustParseAddrPort(l.Addr).Addr() {
 								return 1, nil
 							}
 
@@ -467,7 +574,7 @@ func TestDataPlaneRun(t *testing.T) {
 							if err != nil {
 								return 1, nil
 							}
-							if b.IP() != remoteAddr.Addr() {
+							if b.IP() != netip.MustParseAddrPort(r.Addr).Addr() {
 								return 1, nil
 							}
 
@@ -484,8 +591,10 @@ func TestDataPlaneRun(t *testing.T) {
 				mInternal.EXPECT().ReadBatch(gomock.Any()).Return(0, nil).AnyTimes()
 
 				_ = ret.SetKey([]byte("randomkeyformacs"))
-				_ = ret.AddInternalInterface(mInternal, netip.Addr{})
-				_ = ret.AddNextHop(3, localAddr, remoteAddr, bfd(), "")
+				ret.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl, Conn: mInternal})
+				_ = ret.AddInternalInterface(netip.AddrPort{})
+				ret.SetConnNewer("udpip", router.MockConnNewer{})
+				_ = ret.AddNextHop(3, link, lh, rh)
 				return ret
 			},
 		},
@@ -507,7 +616,7 @@ func TestDataPlaneRun(t *testing.T) {
 				mExternal := mock_router.NewMockBatchConn(ctrl)
 				mExternal.EXPECT().ReadBatch(gomock.Any()).Return(0, nil).AnyTimes()
 				mExternal.EXPECT().WriteBatch(gomock.Any(), gomock.Any()).DoAndReturn(
-					func(msgs underlayconn.Messages, _ int) (int, error) {
+					func(msgs conn.Messages, _ int) (int, error) {
 						pkt := gopacket.NewPacket(msgs[0].Buffers[0],
 							slayers.LayerTypeSCION, gopacket.Default)
 
@@ -535,17 +644,29 @@ func TestDataPlaneRun(t *testing.T) {
 					}).MinTimes(1)
 				mExternal.EXPECT().WriteBatch(gomock.Any(), gomock.Any()).Return(0, nil).AnyTimes()
 
-				local := control.LinkEnd{
+				l := control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:1"),
-					Addr: netip.MustParseAddrPort("10.0.0.100:0"),
+					Addr: "10.0.0.100:0",
 				}
-				remote := control.LinkEnd{
+				r := control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:3"),
-					Addr: netip.MustParseAddrPort("10.0.0.200:0"),
+					Addr: "10.0.0.200:0",
+				}
+				lh := addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
+				rh := addr.HostIP(netip.MustParseAddrPort(r.Addr).Addr())
+
+				link := control.LinkInfo{
+					Provider: "udpip",
+					Local:    l,
+					Remote:   r,
+					BFD:      bfd(),
 				}
+
 				_ = ret.SetKey([]byte("randomkeyformacs"))
-				_ = ret.AddInternalInterface(mInternal, netip.Addr{})
-				_ = ret.AddExternalInterface(ifID, mExternal, local, remote, bfd())
+				ret.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl, Conn: mInternal})
+				_ = ret.AddInternalInterface(netip.AddrPort{})
+				ret.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl, Conn: mExternal})
+				_ = ret.AddExternalInterface(ifID, link, lh, rh)
 				return ret
 			},
 		},
@@ -559,7 +680,6 @@ func TestDataPlaneRun(t *testing.T) {
 					},
 					false,
 				)
-
 				postExternalBFD := func(id layers.BFDDiscriminator, fromIfID uint16) []byte {
 					scn := &slayers.SCION{
 						NextHdr:  slayers.L4BFD,
@@ -589,7 +709,7 @@ func TestDataPlaneRun(t *testing.T) {
 
 				mExternal := mock_router.NewMockBatchConn(ctrl)
 				mExternal.EXPECT().ReadBatch(gomock.Any()).DoAndReturn(
-					func(m underlayconn.Messages) (int, error) {
+					func(m conn.Messages) (int, error) {
 						raw := postExternalBFD(2, 1)
 						expectRemoteDiscriminators[2] = struct{}{}
 						copy(m[0].Buffers[0], raw)
@@ -602,7 +722,7 @@ func TestDataPlaneRun(t *testing.T) {
 				mExternal.EXPECT().ReadBatch(gomock.Any()).Return(0, nil).AnyTimes()
 
 				mExternal.EXPECT().WriteBatch(gomock.Any(), gomock.Any()).DoAndReturn(
-					func(msgs underlayconn.Messages, _ int) (int, error) {
+					func(msgs conn.Messages, _ int) (int, error) {
 						pkt := gopacket.NewPacket(msgs[0].Buffers[0],
 							slayers.LayerTypeSCION, gopacket.Default)
 
@@ -620,17 +740,28 @@ func TestDataPlaneRun(t *testing.T) {
 					}).MinTimes(1)
 				mExternal.EXPECT().WriteBatch(gomock.Any(), gomock.Any()).Return(0, nil).AnyTimes()
 
-				local := control.LinkEnd{
+				l := control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:1"),
-					Addr: netip.MustParseAddrPort("10.0.0.100:0"),
+					Addr: "10.0.0.100:0",
 				}
-				remote := control.LinkEnd{
+				r := control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:3"),
-					Addr: netip.MustParseAddrPort("10.0.0.200:0"),
+					Addr: "10.0.0.200:0",
+				}
+				lh := addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
+				rh := addr.HostIP(netip.MustParseAddrPort(r.Addr).Addr())
+				link := control.LinkInfo{
+					Provider: "udpip",
+					Local:    l,
+					Remote:   r,
+					BFD:      bfd(),
 				}
+
 				_ = ret.SetKey([]byte("randomkeyformacs"))
-				_ = ret.AddInternalInterface(mInternal, netip.Addr{})
-				_ = ret.AddExternalInterface(1, mExternal, local, remote, bfd())
+				ret.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl, Conn: mInternal})
+				_ = ret.AddInternalInterface(netip.AddrPort{})
+				ret.SetConnNewer("udpip", router.MockConnNewer{Ctrl: ctrl, Conn: mExternal})
+				_ = ret.AddExternalInterface(1, link, lh, rh)
 				return ret
 			},
 		},
@@ -699,8 +830,8 @@ func TestProcessPkt(t *testing.T) {
 				return router.NewDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -731,8 +862,8 @@ func TestProcessPkt(t *testing.T) {
 				return router.NewDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -776,8 +907,8 @@ func TestProcessPkt(t *testing.T) {
 					map[uint16]topology.LinkType{
 						1: topology.Child,
 					},
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -809,8 +940,8 @@ func TestProcessPkt(t *testing.T) {
 						1: topology.Parent,
 						2: topology.Child,
 					},
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -841,8 +972,8 @@ func TestProcessPkt(t *testing.T) {
 						2: topology.Parent,
 						1: topology.Child,
 					},
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -875,8 +1006,8 @@ func TestProcessPkt(t *testing.T) {
 						1: topology.Peer,
 						2: topology.Child,
 					},
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -942,8 +1073,8 @@ func TestProcessPkt(t *testing.T) {
 						1: topology.Peer,
 						2: topology.Child,
 					},
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -1016,8 +1147,8 @@ func TestProcessPkt(t *testing.T) {
 						1: topology.Peer,
 						2: topology.Child,
 					},
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -1087,8 +1218,8 @@ func TestProcessPkt(t *testing.T) {
 						1: topology.Peer,
 						2: topology.Child,
 					},
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -1166,10 +1297,10 @@ func TestProcessPkt(t *testing.T) {
 						1: topology.Core,
 						3: topology.Core,
 					},
-					mock_router.NewMockBatchConn(ctrl),
+					nil, // No special connNewer.
 					map[uint16]netip.AddrPort{
 						uint16(3): netip.MustParseAddrPort("10.0.200.200:30043"),
-					}, nil, addr.MustParseIA("1-ff00:0:110"), nil, key)
+					}, addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
 				spkt, dpath := prepBaseMsg(now)
@@ -1199,10 +1330,10 @@ func TestProcessPkt(t *testing.T) {
 						51: topology.Child,
 						3:  topology.Core,
 					},
-					mock_router.NewMockBatchConn(ctrl),
+					nil, // No special connNewer.
 					map[uint16]netip.AddrPort{
 						uint16(3): netip.MustParseAddrPort("10.0.200.200:30043"),
-					}, nil, addr.MustParseIA("1-ff00:0:110"), nil, key)
+					}, addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
 				spkt, _ := prepBaseMsg(now)
@@ -1251,20 +1382,21 @@ func TestProcessPkt(t *testing.T) {
 		},
 		"svc": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
-				return router.NewDP(
+				dp := router.NewDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
+					nil, // No special connNewer.
 					mockInternalNextHops,
-					map[addr.SVC][]netip.AddrPort{
-						addr.SvcCS: {
-							netip.AddrPortFrom(
-								netip.MustParseAddr("10.0.200.200"),
-								uint16(dstUDPPort),
-							),
-						},
-					},
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
+				assert.NoError(
+					t,
+					dp.AddSvc(
+						addr.SvcCS,
+						addr.MustParseHost("10.0.200.200"),
+						uint16(dstUDPPort),
+					),
+				)
+				return dp
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
 				spkt, dpath := prepBaseMsg(now)
@@ -1292,22 +1424,24 @@ func TestProcessPkt(t *testing.T) {
 		},
 		"onehop inbound": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
-				return router.NewDP(
+				dp := router.NewDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl), nil,
-					map[addr.SVC][]netip.AddrPort{
-						addr.SvcCS: {
-							netip.AddrPortFrom(
-								netip.MustParseAddr("172.0.2.10"),
-								uint16(dstUDPPort),
-							),
-						},
-					},
+					nil, // No special connNewer.
+					nil,
 					addr.MustParseIA("1-ff00:0:110"),
 					map[uint16]addr.IA{
 						uint16(1): addr.MustParseIA("1-ff00:0:111"),
 					}, key)
+				assert.NoError(
+					t,
+					dp.AddSvc(
+						addr.SvcCS,
+						addr.MustParseHost("172.0.2.10"),
+						uint16(dstUDPPort),
+					),
+				)
+				return dp
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
 				spkt, _ := prepBaseMsg(now)
@@ -1358,8 +1492,8 @@ func TestProcessPkt(t *testing.T) {
 				return router.NewDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"),
 					map[uint16]addr.IA{
 						uint16(1): addr.MustParseIA("1-ff00:0:111"),
@@ -1395,20 +1529,21 @@ func TestProcessPkt(t *testing.T) {
 		},
 		"reversed onehop outbound": {
 			prepareDP: func(ctrl *gomock.Controller) *router.DataPlane {
-				return router.NewDP(
+				dp := router.NewDP(
 					[]uint16{1},
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
+					nil, // No special connNewer.
 					mockInternalNextHops,
-					map[addr.SVC][]netip.AddrPort{
-						addr.SvcCS: {
-							netip.AddrPortFrom(
-								netip.MustParseAddr("172.0.2.10"),
-								uint16(dstUDPPort),
-							),
-						},
-					},
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
+				assert.NoError(
+					t,
+					dp.AddSvc(
+						addr.SvcCS,
+						addr.MustParseHost("172.0.2.10"),
+						uint16(dstUDPPort),
+					),
+				)
+				return dp
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
 				spkt, _ := prepBaseMsg(now)
@@ -1456,8 +1591,8 @@ func TestProcessPkt(t *testing.T) {
 				return router.NewDP(
 					[]uint16{2},
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"),
 					map[uint16]addr.IA{
 						uint16(2): addr.MustParseIA("1-ff00:0:111"),
@@ -1499,8 +1634,8 @@ func TestProcessPkt(t *testing.T) {
 				return router.NewDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -1517,8 +1652,8 @@ func TestProcessPkt(t *testing.T) {
 				return router.NewDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -1536,8 +1671,8 @@ func TestProcessPkt(t *testing.T) {
 				return router.NewDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
@@ -1557,8 +1692,8 @@ func TestProcessPkt(t *testing.T) {
 				return router.NewDP(
 					mockExternalInterfaces,
 					nil,
-					mock_router.NewMockBatchConn(ctrl),
-					mockInternalNextHops, nil,
+					nil, // No special connNewer.
+					mockInternalNextHops,
 					addr.MustParseIA("1-ff00:0:110"), nil, key)
 			},
 			mockMsg: func(afterProcessing bool) *router.Packet {
diff --git a/router/export_test.go b/router/export_test.go
index c7df26548..46ef1b784 100644
--- a/router/export_test.go
+++ b/router/export_test.go
@@ -17,13 +17,19 @@
 package router
 
 import (
+	"fmt"
 	"net"
 	"net/netip"
 
+	"github.com/golang/mock/gomock"
+
 	"github.com/scionproto/scion/pkg/addr"
+	"github.com/scionproto/scion/pkg/private/ptr"
 	"github.com/scionproto/scion/private/topology"
+	"github.com/scionproto/scion/private/underlay/conn"
 	"github.com/scionproto/scion/router/bfd"
 	"github.com/scionproto/scion/router/control"
+	"github.com/scionproto/scion/router/mock_router"
 )
 
 var (
@@ -35,8 +41,6 @@ func GetMetrics() *Metrics {
 	return metrics
 }
 
-var NewServices = newServices
-
 type Disposition disposition
 
 const PDiscard = Disposition(pDiscard)
@@ -46,22 +50,21 @@ type MockLink struct {
 	ifID uint16
 }
 
-var _ Link = new(MockLink)
+func (l *MockLink) IsUp() bool                                           { return true }
+func (l *MockLink) IfID() uint16                                         { return l.ifID }
+func (l *MockLink) Scope() LinkScope                                     { return Internal }
+func (l *MockLink) BFDSession() *bfd.Session                             { return nil }
+func (l *MockLink) Resolve(p *Packet, host addr.Host, port uint16) error { return nil }
+func (l *MockLink) Send(p *Packet) bool                                  { return true }
+func (l *MockLink) SendBlocking(p *Packet)                               {}
 
-func (l *MockLink) IsUp() bool                   { return true }
-func (l *MockLink) IfID() uint16                 { return l.ifID }
-func (l *MockLink) Scope() LinkScope             { return Internal }
-func (l *MockLink) BFDSession() *bfd.Session     { return nil }
-func (l *MockLink) CheckPktSrc(pkt *Packet) bool { return true }
-func (l *MockLink) Send(p *Packet) bool          { return true }
-func (l *MockLink) SendBlocking(p *Packet)       {}
+var _ Link = new(MockLink)
 
 func newMockLink(ingress uint16) Link { return &MockLink{ifID: ingress} }
 
-// NewPacket makes a mock packet. It has one shortcoming which makes it unsuited for some tests:
-// The packet buffer is strictly no bigger than the supplied bytes; which means that it cannot
-// be used to respond via SCMP. Also, it refers to a mock link that has the scope Internal, yet
-// will confirm being the carrier of any kind of packet.
+// NewPacket makes a mock packet. It has one shortcoming which makes it unsuited for some tests: The
+// packet buffer is strictly no bigger than the supplied bytes; which means that it cannot be used
+// to respond via SCMP. Also, it refers to a mock link that has the scope Internal in all cases.
 func NewPacket(raw []byte, src, dst *net.UDPAddr, ingress, egress uint16) *Packet {
 	p := Packet{
 		RemoteAddr: &net.UDPAddr{IP: make(net.IP, 0, net.IPv6len)},
@@ -79,16 +82,37 @@ func NewPacket(raw []byte, src, dst *net.UDPAddr, ingress, egress uint16) *Packe
 	return &p
 }
 
+// MockConnNewer implements the udpip ConnNewer interface with a method that returns a mock
+// connection for testing purposes. An instance of this ConnNewer can be installed in a dataplane
+// by way of the SetConnNewer method, exported here by the Dataplane type, or by way of
+// dp.underlays[underlay].SetConnNewer(newer) for internal tests that use the dataPlane type.
+type MockConnNewer struct {
+	Ctrl *gomock.Controller
+	Conn BatchConn
+}
+
+// New returns a BatchConn as the udpip underlay might have. If the field conn is non-nil, then that
+// is what New returns. That enables tests to supply a specific BatchConn implementation. Else new
+// returns an instance of MockBatchConn that is just a placeholder; calling any of the methods will
+// cause the test to fail.
+func (m MockConnNewer) New(l netip.AddrPort, r netip.AddrPort, c *conn.Config) (BatchConn, error) {
+	var bc BatchConn
+	if m.Conn != nil {
+		return m.Conn, nil
+	}
+	bc = mock_router.NewMockBatchConn(m.Ctrl)
+	return bc, nil
+}
+
 // mustMakeDP initializes a dataplane structure configured per the test requirements.
-// external interfaces are given arbitrary addresses in the range 203.0.113/24 block and
+// External interfaces are given arbitrary addresses in the range 203.0.113/24 block and
 // are not meant to actually carry traffic. The underlying connection is the same as
 // the internal one, just to satisfy constraints.
 func mustMakeDP(
 	external []uint16,
 	linkTypes map[uint16]topology.LinkType,
-	internal BatchConn,
+	connNewer any, // Some implementation of BatchConnNewer, or nil for the default.
 	internalNextHops map[uint16]netip.AddrPort,
-	svc map[addr.SVC][]netip.AddrPort,
 	local addr.IA,
 	neighbors map[uint16]addr.IA,
 	key []byte) (dp dataPlane) {
@@ -98,11 +122,6 @@ func mustMakeDP(
 	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)
@@ -110,42 +129,61 @@ func mustMakeDP(
 	}
 	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)
-			}
-		}
+	if connNewer == nil {
+		dp.underlays["udpip"].SetConnNewer(MockConnNewer{})
+	} else {
+		dp.underlays["udpip"].SetConnNewer(connNewer)
 	}
 
 	// Make dummy interfaces, as requested by the test. Only the internal interface is ever used to
 	// send or receive and then, not always. The external interfaces are given non-zero
 	// addresses in order to satisfy constraints.
-	if err := dp.AddInternalInterface(internal, netip.MustParseAddr("198.51.100.1")); err != nil {
+	if err := dp.AddInternalInterface(netip.MustParseAddrPort("198.51.100.1:3333")); err != nil {
 		panic(err)
 	}
-	yes := true
-	dummySrc := netip.AddrFrom4([4]byte{203, 0, 113, 0})
+	l := control.LinkEnd{
+		IA:   addr.MustParseIA("1-ff00:0:1"),
+		Addr: "203.0.113.0:3333",
+	}
+	lh := addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
+	nobfd := control.BFD{Disable: ptr.To(true)}
 	for _, i := range external {
-		dummyDst := netip.AddrFrom4([4]byte{203, 0, 113, byte(i)})
-		if err := dp.AddExternalInterface(
-			i,
-			internal, // Just so it isn't nil... do not use!
-			control.LinkEnd{Addr: netip.AddrPortFrom(dummySrc, 3333)},
-			control.LinkEnd{Addr: netip.AddrPortFrom(dummyDst, 3333)},
-			control.BFD{Disable: &yes},
-		); err != nil {
+		r := control.LinkEnd{
+			IA:   addr.MustParseIA("1-ff00:0:3"),
+			Addr: fmt.Sprintf("203.0.113.%d:3333", i),
+		}
+		rh := addr.HostIP(netip.MustParseAddrPort(r.Addr).Addr())
+		link := control.LinkInfo{
+			Provider: "udpip",
+			Local:    l,
+			Remote:   r,
+			BFD:      nobfd,
+			LinkTo:   linkTypes[i],
+		}
+		if err := dp.AddExternalInterface(i, link, lh, rh); err != nil {
 			panic(err)
 		}
 	}
-	for i, addr := range internalNextHops {
-		if err := dp.AddNextHop(
-			i,
-			netip.MustParseAddrPort("198.51.100.1:3333"),
-			addr,
-			control.BFD{Disable: &yes},
-			"dummy",
-		); err != nil {
+
+	l = control.LinkEnd{
+		IA:   addr.MustParseIA("1-ff00:0:1"),
+		Addr: "198.51.100.1:3333",
+	}
+	lh = addr.HostIP(netip.MustParseAddrPort(l.Addr).Addr())
+	for i, a := range internalNextHops {
+		r := control.LinkEnd{
+			IA:   addr.MustParseIA(fmt.Sprintf("1-ff00:0:%d", 3+i)),
+			Addr: a.String(),
+		}
+		rh := addr.HostIP(netip.MustParseAddrPort(r.Addr).Addr())
+		link := control.LinkInfo{
+			Provider: "udpip",
+			Local:    l,
+			Remote:   r,
+			BFD:      nobfd,
+			LinkTo:   linkTypes[i],
+		}
+		if err := dp.AddNextHop(i, link, lh, rh); err != nil {
 			panic(err)
 		}
 	}
@@ -164,19 +202,18 @@ func mustMakeDP(
 	return
 }
 
-// newDP constructs a dataPlane structure with makeDataPlane and returns it by reference. It
+// newDP constructs a dataPlane structure with mustMakeDP 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,
+	connNewer any, // Some implementation of BatchConnNewer, or nil for the default.
 	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)
+	dp := mustMakeDP(external, linkTypes, connNewer, internalNextHops, local, neighbors, key)
 	return &dp
 }
 
@@ -185,21 +222,20 @@ type DataPlane struct {
 	dataPlane
 }
 
-// NewDP constructs a DataPlane structure with makeDataPlane and returns it by reference. It
+// NewDP constructs a DataPlane structure with mustMakeDP 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,
+	connNewer any, // Some implementation of BatchConnNewer, or nil for the default.
 	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),
+		mustMakeDP(external, linkTypes, connNewer, internalNextHops, local, neighbors, key),
 	}
 }
 
@@ -227,6 +263,12 @@ func (d *DataPlane) ProcessPkt(pkt *Packet) Disposition {
 	return Disposition(disp)
 }
 
-func ExtractServices(s *services) map[addr.SVC][]netip.AddrPort {
+func ExtractServices(s *Services[netip.AddrPort]) map[addr.SVC][]netip.AddrPort {
 	return s.m
 }
+
+// We cannot know which tests are going to mock which underlay and what the newer's
+// signature is. So we'll let the underlay implementation type-assert it.
+func (dp *DataPlane) SetConnNewer(underlay string, newer any) {
+	dp.underlays[underlay].SetConnNewer(newer)
+}
diff --git a/router/mgmtapi/api_test.go b/router/mgmtapi/api_test.go
index 9eed45a34..4037f37d3 100644
--- a/router/mgmtapi/api_test.go
+++ b/router/mgmtapi/api_test.go
@@ -156,11 +156,11 @@ func createExternalIntfs(t *testing.T) []control.ExternalInterface {
 			Link: control.LinkInfo{
 				Local: control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:110"),
-					Addr: netip.MustParseAddrPort("172.20.0.3:50000"),
+					Addr: "172.20.0.3:50000",
 				},
 				Remote: control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:111"),
-					Addr: netip.MustParseAddrPort("172.20.0.2:50000"),
+					Addr: "172.20.0.2:50000",
 				},
 				Instance: "br1-ff00_0_110-1",
 				LinkTo:   topology.Core,
@@ -179,11 +179,11 @@ func createExternalIntfs(t *testing.T) []control.ExternalInterface {
 			Link: control.LinkInfo{
 				Local: control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:110"),
-					Addr: netip.MustParseAddrPort("172.20.0.3:50000"),
+					Addr: "172.20.0.3:50000",
 				},
 				Remote: control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:112"),
-					Addr: netip.MustParseAddrPort("172.20.0.2:50000"),
+					Addr: "172.20.0.2:50000",
 				},
 				Instance: "br1-ff00_0_110-1",
 				LinkTo:   topology.Child,
@@ -202,11 +202,11 @@ func createExternalIntfs(t *testing.T) []control.ExternalInterface {
 			Link: control.LinkInfo{
 				Local: control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:111"),
-					Addr: netip.MustParseAddrPort("172.20.0.7:50000"),
+					Addr: "172.20.0.7:50000",
 				},
 				Remote: control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:113"),
-					Addr: netip.MustParseAddrPort("172.20.0.6:50000"),
+					Addr: "172.20.0.6:50000",
 				},
 				Instance: "br1-ff00_0_111-1",
 				LinkTo:   topology.Child,
@@ -225,11 +225,11 @@ func createExternalIntfs(t *testing.T) []control.ExternalInterface {
 			Link: control.LinkInfo{
 				Local: control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:112"),
-					Addr: netip.MustParseAddrPort("172.20.0.78:50000"),
+					Addr: "172.20.0.78:50000",
 				},
 				Remote: control.LinkEnd{
 					IA:   addr.MustParseIA("1-ff00:0:113"),
-					Addr: netip.MustParseAddrPort("172.20.0.10:50000"),
+					Addr: "172.20.0.10:50000",
 				},
 				Instance: "br1-ff00_0_112-1",
 				LinkTo:   topology.Child,
@@ -262,12 +262,12 @@ func createInternalIntfs(t *testing.T) []control.InternalInterface {
 func createSiblingIntfs(t *testing.T) []control.SiblingInterface {
 	return []control.SiblingInterface{
 		{
-			IfID:              5,
-			InternalInterface: netip.MustParseAddrPort("172.20.0.20:30042"),
-			Relationship:      topology.Parent,
-			MTU:               1280,
-			NeighborIA:        addr.MustParseIA("1-ff00:0:112"),
-			State:             control.InterfaceUp,
+			IfID:            5,
+			InternalAddress: "172.20.0.20:30042",
+			Relationship:    topology.Parent,
+			MTU:             1280,
+			NeighborIA:      addr.MustParseIA("1-ff00:0:112"),
+			State:           control.InterfaceUp,
 		},
 	}
 }
diff --git a/router/svc_test.go b/router/svc_test.go
index 854368454..c14d2f904 100644
--- a/router/svc_test.go
+++ b/router/svc_test.go
@@ -30,7 +30,7 @@ func TestServicesAddSvc(t *testing.T) {
 	host1Port := netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), 1338)
 	all := []netip.AddrPort{host1, host2, host1Port}
 
-	s := router.NewServices()
+	s := router.NewServices[netip.AddrPort]()
 	s.AddSvc(addr.SvcCS, host1)
 	s.AddSvc(addr.SvcCS, host2)
 	s.AddSvc(addr.SvcCS, host1Port)
@@ -46,7 +46,7 @@ func TestServiceDelSvc(t *testing.T) {
 	host2 := netip.AddrPortFrom(netip.MustParseAddr("192.0.2.2"), 1337)
 	all := []netip.AddrPort{host1, host2}
 
-	s := router.NewServices()
+	s := router.NewServices[netip.AddrPort]()
 	assert.NotPanics(t, func() { s.DelSvc(addr.SvcCS, host1) })
 
 	s.AddSvc(addr.SvcCS, host1)
@@ -61,7 +61,7 @@ func TestServicesAny(t *testing.T) {
 	host1 := netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), 1337)
 	host2 := netip.AddrPortFrom(netip.MustParseAddr("192.0.2.2"), 1337)
 
-	s := router.NewServices()
+	s := router.NewServices[netip.AddrPort]()
 	s.AddSvc(addr.SvcCS, host1)
 	s.AddSvc(addr.SvcCS, host2)
 
diff --git a/router/underlayproviders/udpip/udpip_test.go b/router/underlayproviders/udpip/udpip_test.go
index 7b09cc6c4..8dfef7bb0 100644
--- a/router/underlayproviders/udpip/udpip_test.go
+++ b/router/underlayproviders/udpip/udpip_test.go
@@ -231,7 +231,7 @@ func TestComputeProcIdErrorCases(t *testing.T) {
 	testCases := map[string]test{
 		"packet shorter than common header len": {
 			data:          make([]byte, 10),
-			expectedError: serrors.New("Packet is too short"),
+			expectedError: serrors.New("packet is too short"),
 		},
 		"packet len = CmnHdrLen + addrHdrLen": {
 			data: []byte{
@@ -251,7 +251,7 @@ func TestComputeProcIdErrorCases(t *testing.T) {
 				0, 0, 0, 0, 0, 0, 0, 0,
 				0, 0, 0,
 			},
-			expectedError: serrors.New("Packet is too short"),
+			expectedError: serrors.New("packet is too short"),
 		},
 		"packet len = CmnHdrLen + addrHdrLen (16IP)": {
 			data: []byte{
@@ -277,7 +277,7 @@ func TestComputeProcIdErrorCases(t *testing.T) {
 				0, 0, 0, 0, 0, 0, 0, 0,
 				0, 0, 0,
 			},
-			expectedError: serrors.New("Packet is too short"),
+			expectedError: serrors.New("packet is too short"),
 		},
 	}
 	for name, tc := range testCases {