From f67c862ba5f54b3f5bdcc5f43d188924cc24835f Mon Sep 17 00:00:00 2001 From: Semen Kolesnikov Date: Fri, 23 Oct 2026 11:11:16 +0300 Subject: [PATCH] fix(net): health-check unmanaged-Site gateways through the tunnel --- cmd/unbounded-net-node/node_types.go | 2 +- cmd/unbounded-net-node/peer_healthcheck.go | 6 +- .../peer_healthcheck_test.go | 117 +++++++++++++++++ cmd/unbounded-net-node/tunnel_config.go | 71 ++++++++++- internal/net/healthcheck/manager.go | 17 ++- internal/net/healthcheck/sendvia_linux.go | 33 +++++ .../net/healthcheck/sendvia_linux_test.go | 120 ++++++++++++++++++ internal/net/healthcheck/sendvia_other.go | 15 +++ internal/net/healthcheck/session.go | 43 ++++++- 9 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 internal/net/healthcheck/sendvia_linux.go create mode 100644 internal/net/healthcheck/sendvia_linux_test.go create mode 100644 internal/net/healthcheck/sendvia_other.go diff --git a/cmd/unbounded-net-node/node_types.go b/cmd/unbounded-net-node/node_types.go index eb2dc3376e..8d4252b47f 100644 --- a/cmd/unbounded-net-node/node_types.go +++ b/cmd/unbounded-net-node/node_types.go @@ -192,7 +192,7 @@ type gatewayPeerInfo struct { LearnedRoutes map[string]unboundednetv1alpha1.GatewayNodeRoute PodCIDRs []string // Gateway node's podCIDRs for specific routing SkipPodCIDRRoutes bool // Keep tunnel reachability host routes but skip explicit PodCIDR routes - UnmanagedCNISite bool // Gateway's Site has manageCniPlugin=false, so it never gets a podCIDR + UnmanagedCNISite bool // Gateway's Site has manageCniPlugin=false, so it may have no podCIDR TunnelProtocol string // Resolved tunnel protocol for this peer (WireGuard or GENEVE) TunnelMTU int // Effective MTU after route, encapsulation, and CRD limits } diff --git a/cmd/unbounded-net-node/peer_healthcheck.go b/cmd/unbounded-net-node/peer_healthcheck.go index 43e6c3b0dd..724c1c8e53 100644 --- a/cmd/unbounded-net-node/peer_healthcheck.go +++ b/cmd/unbounded-net-node/peer_healthcheck.go @@ -111,7 +111,7 @@ func registerPeersWithHealthCheck( // Gateway peers. for _, gwPeer := range gatewayPeers { - overlayIP := getHealthIPFromPodCIDRs(gwPeer.PodCIDRs) + overlayIP, viaIf := gatewayHealthTarget(gwPeer, state.nodePodCIDRs) if overlayIP == "" { continue } @@ -145,10 +145,10 @@ func registerPeersWithHealthCheck( state.gatewayPeerHealthCheckEnabled[ifName] = true state.mu.Unlock() - if err := state.healthCheckManager.AddPeer(gwPeer.Name, net.ParseIP(overlayIP), settings); err != nil { + if err := state.healthCheckManager.AddPeerVia(gwPeer.Name, net.ParseIP(overlayIP), viaIf, settings); err != nil { registrationErrors = append(registrationErrors, fmt.Errorf("register gateway peer %s at %s: %w", gwPeer.Name, overlayIP, err)) } else { - klog.V(4).Infof("Healthcheck: registered gateway peer %s at %s (iface %s)", gwPeer.Name, overlayIP, ifName) + klog.V(4).Infof("Healthcheck: registered gateway peer %s at %s (iface %s, via %q)", gwPeer.Name, overlayIP, ifName, viaIf) } } diff --git a/cmd/unbounded-net-node/peer_healthcheck_test.go b/cmd/unbounded-net-node/peer_healthcheck_test.go index 8c4a6decfa..99da69497c 100644 --- a/cmd/unbounded-net-node/peer_healthcheck_test.go +++ b/cmd/unbounded-net-node/peer_healthcheck_test.go @@ -6,6 +6,7 @@ package main import ( "context" "errors" + "net/netip" "testing" "time" @@ -246,3 +247,119 @@ func TestReconciliationPassesFreshHealthProfilesBeforeStateCommit(t *testing.T) t.Fatalf("registration used stale state: %+v %v", got, err) } } + +func TestGatewayHealthTarget(t *testing.T) { + routed := []string{"10.32.130.0/23"} + v4Overlay := []string{"192.168.20.0/25"} + + cases := map[string]struct { + peer gatewayPeerInfo + local []string + wantIP string + wantViaDev string + }{ + "overlay address from pod CIDR": { + peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, PodCIDRs: []string{"10.244.1.0/24"}}, + wantIP: "10.244.1.1", + }, + "unmanaged gateway probed at its IPv4 address through the tunnel": { + peer: gatewayPeerInfo{InternalIPs: []string{"fd00::4", "10.0.0.4"}, RoutedCidrs: routed, UnmanagedCNISite: true}, + local: v4Overlay, + wantIP: "10.0.0.4", + wantViaDev: unbounded0DeviceName, + }, + "unmanaged IPv6-only gateway is not probed": { + peer: gatewayPeerInfo{InternalIPs: []string{"fd00::4"}, RoutedCidrs: routed, UnmanagedCNISite: true}, + local: v4Overlay, + }, + "no IPv4 overlay address on this node to probe from": { + peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, RoutedCidrs: routed, UnmanagedCNISite: true}, + local: []string{"fd10::/64"}, + }, + "managed gateway still waiting for its pod CIDR": { + peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, RoutedCidrs: routed}, + local: v4Overlay, + }, + "unmanaged gateway over WireGuard": { + peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, RoutedCidrs: routed, UnmanagedCNISite: true, TunnelProtocol: "WireGuard"}, + local: v4Overlay, + }, + "unmanaged gateway without routed CIDRs": { + peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, UnmanagedCNISite: true}, + local: v4Overlay, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ip, via := gatewayHealthTarget(tc.peer, tc.local) + if ip != tc.wantIP || via != tc.wantViaDev { + t.Fatalf("gatewayHealthTarget() = (%q, %q), want (%q, %q)", ip, via, tc.wantIP, tc.wantViaDev) + } + }) + } +} + +// TestGatewayProbePins checks that the probe target gets exactly one BPF +// entry: pinned when localCidrs exclude it, and not added a second time when +// gatewayBPFPrefixes already has it (two nexthops would be health-filtered). +func TestGatewayProbePins(t *testing.T) { + peer := gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, RoutedCidrs: []string{"10.32.130.0/23"}, UnmanagedCNISite: true} + local := []string{"192.168.20.0/25"} + + excluded := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/27")} + if got := gatewayProbePins(peer, excluded, local); len(got) != 1 || got[0] != "10.0.0.4/32" { + t.Fatalf("pins with the gateway IP in localCidrs = %v, want [10.0.0.4/32]", got) + } + + if got := gatewayProbePins(peer, nil, local); len(got) != 0 { + t.Fatalf("pins without localCidrs = %v, want none (gatewayBPFPrefixes has the /32)", got) + } + + managed := peer + managed.UnmanagedCNISite = false + + if got := gatewayProbePins(managed, excluded, local); len(got) != 0 { + t.Fatalf("pins for a managed gateway = %v, want none", got) + } +} + +// TestRegisterUnmanagedGatewayHealthCheck covers a gateway in a Site with +// manageCniPlugin=false: it has no pod CIDR, and used to be skipped, so its +// BPF entries were never health-checked or failed over. +func TestRegisterUnmanagedGatewayHealthCheck(t *testing.T) { + manager, err := healthcheck.NewManager("local", 0, nil) + if err != nil { + t.Fatal(err) + } + defer manager.Stop() + + state := &wireGuardState{ + healthCheckManager: manager, + meshPeerHealthCheckEnabled: make(map[string]bool), + gatewayPeerHealthCheckEnabled: make(map[string]bool), + nodePodCIDRs: []string{"192.168.20.0/25"}, + } + gateways := []gatewayPeerInfo{{ + Name: "gw", + PoolName: "pool", + InternalIPs: []string{"10.0.0.4"}, + RoutedCidrs: []string{"10.32.130.0/23"}, + UnmanagedCNISite: true, + }} + + desired, err := registerPeersWithHealthCheck(nil, gateways, "remote", false, map[string]string{"remote": "site"}, nil, nil, nil, nil, + map[string]healthcheck.HealthCheckSettings{"site": healthcheck.DefaultSettings()}, state, + func(gatewayPeerInfo) string { return "geneve0" }, true) + if err != nil { + t.Fatal(err) + } + + if !desired["gw"] { + t.Fatalf("unmanaged gateway was not registered for health checks: %v", desired) + } + + if _, err := manager.GetPeerSettings("gw"); err != nil { + t.Fatalf("no health check session for the unmanaged gateway: %v", err) + } +} diff --git a/cmd/unbounded-net-node/tunnel_config.go b/cmd/unbounded-net-node/tunnel_config.go index b8727b2e2c..89e577bb42 100644 --- a/cmd/unbounded-net-node/tunnel_config.go +++ b/cmd/unbounded-net-node/tunnel_config.go @@ -7,8 +7,10 @@ import ( "context" "fmt" "net" + "net/netip" "os" "path/filepath" + "slices" "strings" "github.com/vishvananda/netlink" @@ -499,6 +501,13 @@ func configureTunnelPeers( // LAN. The host CIDR is a longest-prefix match so it overrides the // supernet ECMP without disturbing pod-CIDR traffic. addPeerBPFEntries(bpfEntries, gatewayBPFPrefixes(gwPeer, state.siteRouting.localCIDRs), underlayIP, uint32(cfg.GeneveVNI), peerIfIdx, peerProto, gwPeer.Name) + + // A gateway without pod CIDRs is health-checked at its own IP through + // the tunnel (see gatewayHealthTarget). Pin that IP in the BPF map when + // localCidrs keep it out: only probes sent out of unbounded0 explicitly + // use this entry, so no kernel route is added and the GENEVE outer + // packet still takes the underlay. + addPeerBPFEntries(bpfEntries, gatewayProbePins(gwPeer, state.siteRouting.localCIDRs, state.nodePodCIDRs), underlayIP, uint32(cfg.GeneveVNI), peerIfIdx, peerProto, gwPeer.Name) } // Store entries for deferred reconcile (after VXLAN and WG entries are added). @@ -540,8 +549,9 @@ func configureTunnelPeers( // gatewayPeerReadyForTunnel reports whether a gateway peer can be programmed // into the tunnel BPF map. A gateway in a managed Site without a podCIDR is // still waiting for CNI setup (it publishes its WireGuard key first), so it -// stays out of the map. A gateway in a Site with manageCniPlugin=false never -// gets a podCIDR, so its routed CIDRs are what make it usable. +// stays out of the map. A gateway in a Site with manageCniPlugin=false gets +// no podCIDR unless one is assigned explicitly, so its routed CIDRs are what +// make it usable. func gatewayPeerReadyForTunnel(gwPeer gatewayPeerInfo) bool { if len(gwPeer.InternalIPs) == 0 { return false @@ -554,6 +564,63 @@ func gatewayPeerReadyForTunnel(gwPeer gatewayPeerInfo) bool { return gwPeer.UnmanagedCNISite && len(gwPeer.RoutedCidrs) > 0 } +// gatewayHealthTarget returns the address to health-check a shared-tunnel +// gateway peer at, and the interface the probes must leave through ("" for +// normal routing). Gateways with pod CIDRs answer on their overlay address. +// A gateway in a Site with manageCniPlugin=false may have none; it is then probed at +// its internal IPv4 address out of unbounded0: the eBPF program sends the +// probe through the tunnel, where firewalls between Sites already allow the +// encapsulation, instead of over the underlay on the health-check port. +// +// That needs an IPv4 overlay address on this node (localPodCIDRs) as the probe +// source, so the reply also comes back through the tunnel. IPv6 has no on-link +// fallback without a route through unbounded0, so IPv6-only gateways are not +// probed this way. +func gatewayHealthTarget(gwPeer gatewayPeerInfo, localPodCIDRs []string) (string, string) { + if ip := getHealthIPFromPodCIDRs(gwPeer.PodCIDRs); ip != "" { + return ip, "" + } + + if gwPeer.TunnelProtocol == "WireGuard" || !gatewayPeerReadyForTunnel(gwPeer) || !hasIPv4CIDR(localPodCIDRs) { + return "", "" + } + + for _, raw := range gwPeer.InternalIPs { + if ip := net.ParseIP(raw); ip != nil && ip.To4() != nil { + return ip.String(), unbounded0DeviceName + } + } + + return "", "" +} + +// gatewayProbePins returns the host CIDR to pin in the BPF map for a gateway +// probed through the tunnel, unless gatewayBPFPrefixes already includes it +// (a second nexthop on the same key would make the entry health-filtered). +func gatewayProbePins(gwPeer gatewayPeerInfo, localCIDRs []netip.Prefix, localPodCIDRs []string) []string { + ip, viaIf := gatewayHealthTarget(gwPeer, localPodCIDRs) + if viaIf == "" { + return nil + } + + pin := ipsToHostCIDRs([]string{ip}) + if slices.Contains(gatewayBPFPrefixes(gwPeer, localCIDRs), pin[0]) { + return nil + } + + return pin +} + +func hasIPv4CIDR(cidrs []string) bool { + for _, cidr := range cidrs { + if prefix, err := netip.ParsePrefix(cidr); err == nil && prefix.Addr().Is4() { + return true + } + } + + return false +} + // addPeerBPFEntries adds LPM trie entries for a set of CIDRs pointing to a // tunnel endpoint. Healthy=true is set on every new nexthop so it is // forwarded immediately; the healthcheck callback later clears Healthy if diff --git a/internal/net/healthcheck/manager.go b/internal/net/healthcheck/manager.go index 5bc291f58d..5869bbb9e0 100644 --- a/internal/net/healthcheck/manager.go +++ b/internal/net/healthcheck/manager.go @@ -118,6 +118,13 @@ func (m *Manager) Stop() { // AddPeer registers a new peer for health checking. If the manager is // already running, the session starts immediately. func (m *Manager) AddPeer(peerHostname string, overlayIP net.IP, settings HealthCheckSettings) error { + return m.AddPeerVia(peerHostname, overlayIP, "", settings) +} + +// AddPeerVia registers a peer whose probes leave through the named interface +// regardless of the routing table, for example a tunnel device whose eBPF +// program forwards them to the peer. An empty viaIf routes probes normally. +func (m *Manager) AddPeerVia(peerHostname string, overlayIP net.IP, viaIf string, settings HealthCheckSettings) error { if err := settings.validate(); err != nil { return err } @@ -127,7 +134,7 @@ func (m *Manager) AddPeer(peerHostname string, overlayIP net.IP, settings Health m.mu.Lock() if existing, exists := m.sessions[peerHostname]; exists { - if existing.overlayIP.Equal(overlayIP) { + if existing.overlayIP.Equal(overlayIP) && existing.viaIf == viaIf { existing.updateSettings(settings) m.mu.Unlock() @@ -146,6 +153,7 @@ func (m *Manager) AddPeer(peerHostname string, overlayIP net.IP, settings Health s := newSession(sessionConfig{ peerHostname: peerHostname, overlayIP: overlayIP, + viaIf: viaIf, port: m.port, localHostname: m.localHostname, settings: settings, @@ -161,7 +169,12 @@ func (m *Manager) AddPeer(peerHostname string, overlayIP net.IP, settings Health } updatePeerGauges(m.sessions) - klog.Infof("healthcheck: added peer %s (%s)", peerHostname, overlayIP) + + if viaIf != "" { + klog.Infof("healthcheck: added peer %s (%s via %s)", peerHostname, overlayIP, viaIf) + } else { + klog.Infof("healthcheck: added peer %s (%s)", peerHostname, overlayIP) + } return nil } diff --git a/internal/net/healthcheck/sendvia_linux.go b/internal/net/healthcheck/sendvia_linux.go new file mode 100644 index 0000000000..d1ceff564d --- /dev/null +++ b/internal/net/healthcheck/sendvia_linux.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +package healthcheck + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// writeVia sends data to addr out of the interface with index ifIndex. The +// interface is set per packet with IP_PKTINFO/IPV6_PKTINFO, so the kernel uses +// it even when the routing table would pick another one, while replies still +// arrive on the shared socket. +func writeVia(conn net.PacketConn, data []byte, addr *net.UDPAddr, ifIndex int) error { + udp, ok := conn.(*net.UDPConn) + if !ok { + return fmt.Errorf("connection is %T, not *net.UDPConn", conn) + } + + var oob []byte + if addr.IP.To4() != nil { + oob = unix.PktInfo4(&unix.Inet4Pktinfo{Ifindex: int32(ifIndex)}) //nolint:gosec // kernel interface indexes fit in int32 + } else { + oob = unix.PktInfo6(&unix.Inet6Pktinfo{Ifindex: uint32(ifIndex)}) //nolint:gosec // kernel interface indexes are positive + } + + _, _, err := udp.WriteMsgUDP(data, oob, addr) + + return err +} diff --git a/internal/net/healthcheck/sendvia_linux_test.go b/internal/net/healthcheck/sendvia_linux_test.go new file mode 100644 index 0000000000..c26b5909ea --- /dev/null +++ b/internal/net/healthcheck/sendvia_linux_test.go @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +package healthcheck + +import ( + "context" + "net" + "testing" + "time" +) + +// TestManagerProbesViaInterface runs a session whose probes leave through a +// named interface and checks that it comes up. +func TestManagerProbesViaInterface(t *testing.T) { + portA := freePort(t) + portB := freePort(t) + changes := make(chan stateEvt, 20) + + mgrA, err := NewManager("node-a", portA, func(peer string, newState, oldState SessionState) { + changes <- stateEvt{peer, newState, oldState} + }) + if err != nil { + t.Fatal(err) + } + + mgrB, err := NewManager("node-b", portB, nil) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + for _, m := range []*Manager{mgrA, mgrB} { + if err := m.Start(ctx); err != nil { + t.Fatal(err) + } + defer m.Stop() + } + + settings := HealthCheckSettings{TransmitInterval: 50 * time.Millisecond, ReceiveInterval: 50 * time.Millisecond, DetectMultiplier: 3} + if err := mgrA.AddPeerVia("node-b", net.ParseIP("127.0.0.1"), "lo", settings); err != nil { + t.Fatal(err) + } + + mgrA.mu.Lock() + sess := mgrA.sessions["node-b"] + mgrA.mu.Unlock() + sess.mu.Lock() + sess.port = portB + sess.mu.Unlock() + + waitForState(t, changes, "node-b", StateUp, 5*time.Second) +} + +func TestSessionWriteViaUnknownInterface(t *testing.T) { + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer conn.Close() //nolint:errcheck + + s := newSession(sessionConfig{peerHostname: "peer", viaIf: "no-such-if0", conn: conn, settings: DefaultSettings()}) + if err := s.write([]byte("x"), &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9}); err == nil { + t.Fatal("expected an error for a missing interface") + } +} + +func TestAddPeerViaKeepsSessionWhenUnchanged(t *testing.T) { + m, err := NewManager("mgr-via-same", 0, nil) + if err != nil { + t.Fatal(err) + } + + ip := net.ParseIP("10.0.0.1") + if err := m.AddPeerVia("peer", ip, "unbounded0", DefaultSettings()); err != nil { + t.Fatal(err) + } + + m.mu.RLock() + first := m.sessions["peer"] + m.mu.RUnlock() + + if err := m.AddPeerVia("peer", ip, "unbounded0", DefaultSettings()); err != nil { + t.Fatal(err) + } + + m.mu.RLock() + second := m.sessions["peer"] + m.mu.RUnlock() + + if first != second { + t.Fatal("re-adding an unchanged peer replaced its session") + } +} + +func TestAddPeerViaReplacesSessionWhenInterfaceChanges(t *testing.T) { + m, err := NewManager("mgr-via", 0, nil) + if err != nil { + t.Fatal(err) + } + + ip := net.ParseIP("10.0.0.1") + if err := m.AddPeer("peer", ip, DefaultSettings()); err != nil { + t.Fatal(err) + } + + if err := m.AddPeerVia("peer", ip, "unbounded0", DefaultSettings()); err != nil { + t.Fatal(err) + } + + m.mu.RLock() + got := m.sessions["peer"].viaIf + m.mu.RUnlock() + + if got != "unbounded0" { + t.Fatalf("session viaIf = %q, want unbounded0", got) + } +} diff --git a/internal/net/healthcheck/sendvia_other.go b/internal/net/healthcheck/sendvia_other.go new file mode 100644 index 0000000000..9673986353 --- /dev/null +++ b/internal/net/healthcheck/sendvia_other.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux + +package healthcheck + +import "net" + +// writeVia sends data to addr. Choosing the interface per packet is Linux +// only; elsewhere the routing table picks it. +func writeVia(conn net.PacketConn, data []byte, addr *net.UDPAddr, _ int) error { + _, err := conn.WriteTo(data, addr) + return err +} diff --git a/internal/net/healthcheck/session.go b/internal/net/healthcheck/session.go index 9e9468afef..0feeb2ba45 100644 --- a/internal/net/healthcheck/session.go +++ b/internal/net/healthcheck/session.go @@ -5,6 +5,7 @@ package healthcheck import ( "context" + "fmt" "net" "sync" "sync/atomic" @@ -33,6 +34,8 @@ const flapWindowDuration = 5 * time.Minute type session struct { peerHostname string overlayIP net.IP + viaIf string + viaIfIndex int // cached index of viaIf; only used by sendProbe port int localHost string probePhaseSeed uint64 @@ -65,6 +68,7 @@ type session struct { type sessionConfig struct { peerHostname string overlayIP net.IP + viaIf string // when set, probes leave through this interface port int localHostname string settings HealthCheckSettings @@ -86,6 +90,7 @@ func newSession(cfg sessionConfig) *session { return &session{ peerHostname: cfg.peerHostname, overlayIP: cfg.overlayIP, + viaIf: cfg.viaIf, port: cfg.port, localHost: cfg.localHostname, probePhaseSeed: seed, @@ -400,8 +405,15 @@ func (s *session) sendProbe() { addr := &net.UDPAddr{IP: s.overlayIP, Port: s.port} s.mu.Unlock() - if _, err := s.conn.WriteTo(data, addr); err != nil { - klog.V(4).Infof("healthcheck: failed to send probe to %s (%s): %v", + if err := s.write(data, addr); err != nil { + // A probe through a named interface fails for configuration reasons + // (missing device, no route), not transient loss, so make it visible. + level := klog.Level(4) + if s.viaIf != "" { + level = 2 + } + + klog.V(level).Infof("healthcheck: failed to send probe to %s (%s): %v", s.peerHostname, addr, err) return @@ -413,6 +425,33 @@ func (s *session) sendProbe() { metricPacketsSent.Inc() } +// write sends a probe, through viaIf when the session has one. The interface +// index is cached and looked up again after a failed send, since the device +// may have been recreated. +func (s *session) write(data []byte, addr *net.UDPAddr) error { + if s.viaIf == "" { + _, err := s.conn.WriteTo(data, addr) + return err + } + + if s.viaIfIndex == 0 { + ifi, err := net.InterfaceByName(s.viaIf) + if err != nil { + return fmt.Errorf("send via %s: %w", s.viaIf, err) + } + + s.viaIfIndex = ifi.Index + } + + if err := writeVia(s.conn, data, addr, s.viaIfIndex); err != nil { + s.viaIfIndex = 0 + + return fmt.Errorf("send via %s: %w", s.viaIf, err) + } + + return nil +} + func (s *session) setState(newState SessionState) { s.setStateIf(newState, nil) }