Files
netbird/client/internal/networkmonitor/check_change_darwin.go

345 lines
9.5 KiB
Go

//go:build darwin && !ios
package networkmonitor
import (
"context"
"errors"
"fmt"
"hash/fnv"
"net/netip"
"os/exec"
"syscall"
"time"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/net/route"
"golang.org/x/sys/unix"
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
)
// todo: refactor to not use static functions
func checkChange(ctx context.Context, nexthopv4, nexthopv6 systemops.Nexthop) error {
fd, err := unix.Socket(syscall.AF_ROUTE, syscall.SOCK_RAW, syscall.AF_UNSPEC)
if err != nil {
return fmt.Errorf("open routing socket: %v", err)
}
defer func() {
err := unix.Close(fd)
if err != nil && !errors.Is(err, unix.EBADF) {
log.Warnf("Network monitor: failed to close routing socket: %v", err)
}
}()
routeChanged := make(chan struct{})
go func() {
routeCheck(ctx, fd, nexthopv4, nexthopv6)
close(routeChanged)
}()
wakeUp := make(chan struct{})
go func() {
wakeUpListen(ctx)
close(wakeUp)
}()
gatewayChanged := make(chan string)
go func() {
gatewayPoll(ctx, nexthopv4, nexthopv6, gatewayChanged)
}()
select {
case <-ctx.Done():
return ctx.Err()
case <-routeChanged:
log.Infof("route change detected via routing socket")
return nil
case <-wakeUp:
log.Infof("wakeup detected via sleep hash change")
return nil
case reason := <-gatewayChanged:
log.Infof("gateway change detected via polling: %s", reason)
return nil
}
}
func routeCheck(ctx context.Context, fd int, nexthopv4 systemops.Nexthop, nexthopv6 systemops.Nexthop) {
for {
if ctx.Err() != nil {
return
}
buf := make([]byte, 2048)
n, err := unix.Read(fd, buf)
if err != nil {
if !errors.Is(err, unix.EBADF) && !errors.Is(err, unix.EINVAL) {
log.Warnf("Network monitor: failed to read from routing socket: %v", err)
}
continue
}
if n < unix.SizeofRtMsghdr {
log.Debugf("Network monitor: read from routing socket returned less than expected: %d bytes", n)
continue
}
msg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0]))
switch msg.Type {
// handle route changes
case unix.RTM_ADD, syscall.RTM_DELETE:
route, err := parseRouteMessage(buf[:n])
if err != nil {
log.Debugf("Network monitor: error parsing routing message: %v", err)
continue
}
if route.Dst.Bits() != 0 {
continue
}
intf := "<nil>"
if route.Interface != nil {
intf = route.Interface.Name
}
switch msg.Type {
case unix.RTM_ADD:
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
return
case unix.RTM_DELETE:
if nexthopv4.Intf != nil && route.Gw.Compare(nexthopv4.IP) == 0 || nexthopv6.Intf != nil && route.Gw.Compare(nexthopv6.IP) == 0 {
log.Infof("Network monitor: default route removed: via %s, interface %s", route.Gw, intf)
return
}
}
}
}
}
func parseRouteMessage(buf []byte) (*systemops.Route, error) {
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
if err != nil {
return nil, fmt.Errorf("parse RIB: %v", err)
}
if len(msgs) != 1 {
return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
}
msg, ok := msgs[0].(*route.RouteMessage)
if !ok {
return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
}
return systemops.MsgToRoute(msg)
}
func wakeUpListen(ctx context.Context) {
log.Infof("start to watch for system wakeups")
var (
initialHash uint32
err error
)
// Keep retrying until initial sysctl succeeds or context is canceled
for {
select {
case <-ctx.Done():
log.Info("exit from wakeUpListen initial hash detection due to context cancellation")
return
default:
initialHash, err = readSleepTimeHash()
if err != nil {
log.Errorf("failed to detect initial sleep time: %v", err)
select {
case <-ctx.Done():
log.Info("exit from wakeUpListen initial hash detection due to context cancellation")
return
case <-time.After(3 * time.Second):
continue
}
}
log.Infof("initial wakeup hash: %d", initialHash)
break
}
break
}
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
lastCheck := time.Now()
const maxTickerDrift = 1 * time.Minute
for {
select {
case <-ctx.Done():
log.Info("context canceled, stopping wakeUpListen")
return
case <-ticker.C:
now := time.Now()
elapsed := now.Sub(lastCheck)
// If more time passed than expected, system likely slept (informational only)
if elapsed > maxTickerDrift {
upOut, err := exec.Command("uptime").Output()
if err != nil {
log.Errorf("failed to run uptime command: %v", err)
upOut = []byte("unknown")
}
log.Infof("Time drift detected (potential wakeup): expected ~5s, actual %s, uptime: %s", elapsed, upOut)
currentV4, errV4 := systemops.GetNextHop(netip.IPv4Unspecified())
currentV6, errV6 := systemops.GetNextHop(netip.IPv6Unspecified())
if errV4 == nil {
log.Infof("Current IPv4 default gateway: %s via %s", currentV4.IP, currentV4.Intf.Name)
} else {
log.Debugf("No IPv4 default gateway: %v", errV4)
}
if errV6 == nil {
log.Infof("Current IPv6 default gateway: %s via %s", currentV6.IP, currentV6.Intf.Name)
} else {
log.Debugf("No IPv6 default gateway: %v", errV6)
}
}
newHash, err := readSleepTimeHash()
if err != nil {
log.Errorf("failed to read sleep time hash: %v", err)
lastCheck = now
continue
}
if newHash == initialHash {
log.Debugf("no wakeup detected (hash unchanged: %d, time drift: %s)", initialHash, elapsed)
lastCheck = now
continue
}
upOut, err := exec.Command("uptime").Output()
if err != nil {
log.Errorf("failed to run uptime command: %v", err)
upOut = []byte("unknown")
}
log.Infof("Wakeup detected via hash change: %d -> %d, uptime: %s", initialHash, newHash, upOut)
currentV4, errV4 := systemops.GetNextHop(netip.IPv4Unspecified())
currentV6, errV6 := systemops.GetNextHop(netip.IPv6Unspecified())
if errV4 == nil {
log.Infof("Current IPv4 default gateway after wakeup: %s via %s", currentV4.IP, currentV4.Intf.Name)
} else {
log.Debugf("No IPv4 default gateway after wakeup: %v", errV4)
}
if errV6 == nil {
log.Infof("Current IPv6 default gateway after wakeup: %s via %s", currentV6.IP, currentV6.Intf.Name)
} else {
log.Debugf("No IPv6 default gateway after wakeup: %v", errV6)
}
return
}
}
}
func readSleepTimeHash() (uint32, error) {
cmd := exec.Command("sysctl", "kern.sleeptime")
out, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("failed to run sysctl: %w", err)
}
h, err := hash(out)
if err != nil {
return 0, fmt.Errorf("failed to compute hash: %w", err)
}
return h, nil
}
func hash(data []byte) (uint32, error) {
hasher := fnv.New32a()
if _, err := hasher.Write(data); err != nil {
return 0, err
}
return hasher.Sum32(), nil
}
// gatewayPoll polls the default gateway every 5 seconds to detect changes that might be missed by routing socket or wake-up detection.
func gatewayPoll(ctx context.Context, initialV4, initialV6 systemops.Nexthop, changed chan<- string) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
log.Infof("Gateway polling started - initial v4: %s via %v, v6: %s via %v",
initialV4.IP, initialV4.Intf, initialV6.IP, initialV6.Intf)
for {
select {
case <-ctx.Done():
log.Debug("context canceled, stopping gateway polling")
return
case <-ticker.C:
currentV4, errV4 := systemops.GetNextHop(netip.IPv4Unspecified())
currentV6, errV6 := systemops.GetNextHop(netip.IPv6Unspecified())
var reason string
if errV4 == nil && initialV4.IP.IsValid() {
if currentV4.IP.Compare(initialV4.IP) != 0 {
reason = fmt.Sprintf("IPv4 gateway changed from %s to %s", initialV4.IP, currentV4.IP)
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
if initialV4.Intf != nil && currentV4.Intf != nil && currentV4.Intf.Name != initialV4.Intf.Name {
reason = fmt.Sprintf("IPv4 interface changed from %s to %s", initialV4.Intf.Name, currentV4.Intf.Name)
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
} else if errV4 == nil && !initialV4.IP.IsValid() {
reason = "IPv4 gateway appeared"
log.Infof("Gateway poll detected change: %s (new: %s)", reason, currentV4.IP)
changed <- reason
return
} else if errV4 != nil && initialV4.IP.IsValid() {
reason = "IPv4 gateway disappeared"
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
if errV6 == nil && initialV6.IP.IsValid() {
if currentV6.IP.Compare(initialV6.IP) != 0 {
reason = fmt.Sprintf("IPv6 gateway changed from %s to %s", initialV6.IP, currentV6.IP)
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
if initialV6.Intf != nil && currentV6.Intf != nil && currentV6.Intf.Name != initialV6.Intf.Name {
reason = fmt.Sprintf("IPv6 interface changed from %s to %s", initialV6.Intf.Name, currentV6.Intf.Name)
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
} else if errV6 == nil && !initialV6.IP.IsValid() {
reason = "IPv6 gateway appeared"
log.Infof("Gateway poll detected change: %s (new: %s)", reason, currentV6.IP)
changed <- reason
return
} else if errV6 != nil && initialV6.IP.IsValid() {
reason = "IPv6 gateway disappeared"
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
log.Debugf("Gateway poll: no change detected")
}
}
}