icmp monitor the exit node for connectivity and provide in api status

This commit is contained in:
Owen
2026-08-05 10:33:43 -04:00
parent 9f2fc77fd7
commit 202606917c
6 changed files with 298 additions and 21 deletions

View File

@@ -62,6 +62,15 @@ type OlmError struct {
Message string `json:"message"`
}
// ExitNodeStatus represents the connectivity status of the client's own exit
// node connection (used for site resources hosted on the exit node).
type ExitNodeStatus struct {
Connected bool `json:"connected"`
RTT time.Duration `json:"rtt"`
LastSeen time.Time `json:"lastSeen"`
Endpoint string `json:"endpoint,omitempty"`
}
// StatusResponse is returned by the status endpoint
type StatusResponse struct {
Connected bool `json:"connected"`
@@ -73,6 +82,7 @@ type StatusResponse struct {
OrgID string `json:"orgId,omitempty"`
PeerStatuses map[int]*PeerStatus `json:"peers,omitempty"`
NetworkSettings network.NetworkSettings `json:"networkSettings,omitempty"`
ExitNodeStatus *ExitNodeStatus `json:"exitNode,omitempty"`
}
type MetadataChangeRequest struct {
@@ -103,13 +113,14 @@ type API struct {
onPowerMode func(PowerModeRequest) error
onJITConnect func(JITConnectionRequest) error
statusMu sync.RWMutex
peerStatuses map[int]*PeerStatus
connectedAt time.Time
isConnected bool
isRegistered bool
isTerminated bool
olmError *OlmError
statusMu sync.RWMutex
peerStatuses map[int]*PeerStatus
exitNodeStatus *ExitNodeStatus
connectedAt time.Time
isConnected bool
isRegistered bool
isTerminated bool
olmError *OlmError
version string
agent string
@@ -409,6 +420,25 @@ func (s *API) UpdatePeerHolepunchStatus(siteID int, holepunchConnected bool) {
status.HolepunchConnected = holepunchConnected
}
// SetExitNodeStatus sets the connectivity status of the client's own exit node connection
func (s *API) SetExitNodeStatus(connected bool, rtt time.Duration, endpoint string) {
s.statusMu.Lock()
defer s.statusMu.Unlock()
s.exitNodeStatus = &ExitNodeStatus{
Connected: connected,
RTT: rtt,
LastSeen: time.Now(),
Endpoint: endpoint,
}
}
// ClearExitNodeStatus removes the exit node status, e.g. when disconnecting from it
func (s *API) ClearExitNodeStatus() {
s.statusMu.Lock()
defer s.statusMu.Unlock()
s.exitNodeStatus = nil
}
// handleConnect handles the /connect endpoint
func (s *API) handleConnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -473,6 +503,7 @@ func (s *API) handleStatus(w http.ResponseWriter, r *http.Request) {
OrgID: s.orgID,
PeerStatuses: s.peerStatuses,
NetworkSettings: network.GetSettings(),
ExitNodeStatus: s.exitNodeStatus,
}
s.statusMu.RUnlock()
@@ -640,6 +671,7 @@ func (s *API) GetStatus() StatusResponse {
OrgID: s.orgID,
PeerStatuses: s.peerStatuses,
NetworkSettings: network.GetSettings(),
ExitNodeStatus: s.exitNodeStatus,
}
}

2
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/godbus/dbus/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
github.com/miekg/dns v1.1.70
golang.org/x/net v0.56.0
golang.org/x/sys v0.46.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
@@ -23,7 +24,6 @@ require (
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.43.0 // indirect

View File

@@ -118,6 +118,10 @@ persistent_keepalive_interval=%d`, util.FixKey(cfg.PublicKey), allowedIP, resolv
}
}
if pm := o.getPeerManager(); pm != nil {
pm.SetExitNode(strings.Split(cfg.ServerIP, "/")[0])
}
logger.Info("Connected to exit node at %s", resolvedEndpoint)
return nil
}
@@ -139,6 +143,10 @@ func (o *Olm) removeExitNodePeerLocked() error {
cfg := o.exitNode
o.exitNode = nil
if pm := o.getPeerManager(); pm != nil {
pm.ClearExitNode()
}
if o.dnsProxy != nil {
serverIP := net.ParseIP(cfg.ServerIP)
if serverIP != nil {

View File

@@ -127,6 +127,24 @@ func (pm *PeerManager) GetPeerMonitor() *monitor.PeerMonitor {
return pm.peerMonitor
}
// SetExitNode starts (or updates) ICMP connectivity monitoring of the given exit node
func (pm *PeerManager) SetExitNode(serverIP string) {
pm.mu.RLock()
defer pm.mu.RUnlock()
if pm.peerMonitor != nil {
pm.peerMonitor.SetExitNode(serverIP)
}
}
// ClearExitNode stops ICMP connectivity monitoring of the exit node
func (pm *PeerManager) ClearExitNode() {
pm.mu.RLock()
defer pm.mu.RUnlock()
if pm.peerMonitor != nil {
pm.peerMonitor.ClearExitNode()
}
}
// SetPublicDNS replaces the DNS servers used to resolve WireGuard peer
// endpoints and hole-punch targets. The servers must be in "host:port" format
// (e.g. "8.8.8.8:53"). The change takes effect for all future peer

196
peers/monitor/exitnode.go Normal file
View File

@@ -0,0 +1,196 @@
package monitor
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"net/netip"
"time"
"github.com/fosrl/newt/logger"
"golang.org/x/net/icmp"
xipv4 "golang.org/x/net/ipv4"
"gvisor.dev/gvisor/pkg/tcpip"
gipv4 "gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
gicmp "gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
"gvisor.dev/gvisor/pkg/waiter"
)
const (
exitNodePingInterval = 3 * time.Second
exitNodePingTimeout = 1 * time.Second
exitNodePingMaxAttempts = 3
)
// SetExitNode starts (or, if the server address changed, restarts) ICMP
// connectivity monitoring of the exit node at serverIP. serverIP must be a
// bare IP address (no CIDR suffix).
func (pm *PeerMonitor) SetExitNode(serverIP string) {
pm.exitNodeMu.Lock()
if pm.exitNodeCancel != nil && pm.exitNodeServerIP == serverIP {
pm.exitNodeMu.Unlock()
return
}
if pm.exitNodeCancel != nil {
pm.exitNodeCancel()
}
pm.exitNodeServerIP = serverIP
ctx, cancel := context.WithCancel(context.Background())
pm.exitNodeCancel = cancel
pm.exitNodeMu.Unlock()
logger.Info("Started exit node connectivity monitor for %s", serverIP)
go pm.runExitNodeMonitor(ctx, serverIP)
}
// ClearExitNode stops ICMP monitoring of the exit node and clears its status
// from the API.
func (pm *PeerMonitor) ClearExitNode() {
pm.exitNodeMu.Lock()
if pm.exitNodeCancel != nil {
pm.exitNodeCancel()
pm.exitNodeCancel = nil
}
pm.exitNodeServerIP = ""
pm.exitNodeMu.Unlock()
if pm.apiServer != nil {
pm.apiServer.ClearExitNodeStatus()
}
logger.Info("Stopped exit node connectivity monitor")
}
// runExitNodeMonitor periodically pings the exit node and reports its status
// to the API server until ctx is cancelled.
func (pm *PeerMonitor) runExitNodeMonitor(ctx context.Context, serverIP string) {
check := func() {
var (
connected bool
rtt time.Duration
)
for attempt := 0; attempt < exitNodePingMaxAttempts; attempt++ {
if d, err := pm.pingExitNode(serverIP, exitNodePingTimeout); err == nil {
connected = true
rtt = d
break
}
select {
case <-ctx.Done():
return
default:
}
}
if pm.apiServer != nil {
pm.apiServer.SetExitNodeStatus(connected, rtt, serverIP)
}
}
check()
ticker := time.NewTicker(exitNodePingInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
check()
}
}
}
// pingExitNode sends a single ICMP echo request to dst and waits up to timeout
// for the matching reply. The request is built and read directly on the peer
// monitor's gvisor netstack, so it's injected into (and intercepted from) the
// WireGuard device via MiddleDevice - it never touches the host's real
// network stack, matching how the UDP peer tests above work.
func (pm *PeerMonitor) pingExitNode(dst string, timeout time.Duration) (time.Duration, error) {
pm.mutex.Lock()
st := pm.stack
localIPStr := pm.localIP
pm.mutex.Unlock()
if st == nil {
return 0, fmt.Errorf("netstack not initialized")
}
dstAddr, err := netip.ParseAddr(dst)
if err != nil {
return 0, fmt.Errorf("invalid destination address: %w", err)
}
localAddr, err := netip.ParseAddr(localIPStr)
if err != nil {
return 0, fmt.Errorf("invalid local address: %w", err)
}
var wq waiter.Queue
ep, tcpipErr := st.NewEndpoint(gicmp.ProtocolNumber4, gipv4.ProtocolNumber, &wq)
if tcpipErr != nil {
return 0, fmt.Errorf("failed to create ICMP endpoint: %s", tcpipErr)
}
defer ep.Close()
if tcpipErr := ep.Bind(tcpip.FullAddress{NIC: 1, Addr: tcpip.AddrFromSlice(localAddr.AsSlice())}); tcpipErr != nil {
return 0, fmt.Errorf("failed to bind ICMP endpoint: %s", tcpipErr)
}
if tcpipErr := ep.Connect(tcpip.FullAddress{NIC: 1, Addr: tcpip.AddrFromSlice(dstAddr.AsSlice())}); tcpipErr != nil {
return 0, fmt.Errorf("failed to connect ICMP endpoint: %s", tcpipErr)
}
var idBuf [2]byte
if _, err := rand.Read(idBuf[:]); err != nil {
return 0, fmt.Errorf("failed to generate echo ID: %w", err)
}
echoID := int(binary.BigEndian.Uint16(idBuf[:]))
requestPing := icmp.Echo{
ID: echoID,
Seq: 1,
Data: []byte("olmping"),
}
icmpBytes, err := (&icmp.Message{Type: xipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
if err != nil {
return 0, fmt.Errorf("failed to marshal ICMP message: %w", err)
}
waitEntry, notifyCh := waiter.NewChannelEntry(waiter.EventIn)
wq.EventRegister(&waitEntry)
defer wq.EventUnregister(&waitEntry)
start := time.Now()
if _, tcpipErr := ep.Write(bytes.NewReader(icmpBytes), tcpip.WriteOptions{}); tcpipErr != nil {
return 0, fmt.Errorf("failed to write ICMP echo request: %s", tcpipErr)
}
deadline := time.NewTimer(timeout)
defer deadline.Stop()
readBuf := make([]byte, 1500)
for {
select {
case <-deadline.C:
return 0, fmt.Errorf("ping to %s timed out", dst)
case <-notifyCh:
w := tcpip.SliceWriter(readBuf)
res, tcpipErr := ep.Read(&w, tcpip.ReadOptions{})
if tcpipErr != nil {
continue
}
reply, err := icmp.ParseMessage(1, readBuf[:res.Count])
if err != nil {
continue
}
replyEcho, ok := reply.Body.(*icmp.Echo)
if !ok || replyEcho.ID != echoID || replyEcho.Seq != requestPing.Seq {
continue
}
return time.Since(start), nil
}
}
}

View File

@@ -25,6 +25,7 @@ import (
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
)
@@ -105,6 +106,14 @@ type PeerMonitor struct {
wgConnectionStatus map[int]bool // siteID -> WG connected status
wgConnectionRTT map[int]time.Duration // siteID -> last known RTT
statusChangeCallback func(siteId int) // called when any peer's connection status changes
// Exit node ICMP monitoring fields. The exit node is a single peer (not a
// site), pinged over the same gvisor netstack used for the peer UDP tests
// above, so the probe never touches the host's real network stack - it's
// injected directly into the WireGuard device via MiddleDevice.
exitNodeMu sync.Mutex
exitNodeServerIP string
exitNodeCancel context.CancelFunc
}
// NewPeerMonitor creates a new peer monitor with the given callback
@@ -1152,6 +1161,14 @@ func (pm *PeerMonitor) Close() {
// Stop holepunch monitor first (outside of mutex to avoid deadlock)
pm.stopHolepunchMonitor()
// Stop exit node ICMP monitor, if running
pm.exitNodeMu.Lock()
if pm.exitNodeCancel != nil {
pm.exitNodeCancel()
pm.exitNodeCancel = nil
}
pm.exitNodeMu.Unlock()
// Stop all pending relay senders
pm.relaySendMu.Lock()
for chainId, stop := range pm.relaySends {
@@ -1288,7 +1305,7 @@ func (pm *PeerMonitor) initNetstack() error {
// Create gvisor netstack
stackOpts := stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
TransportProtocols: []stack.TransportProtocolFactory{udp.NewProtocol},
TransportProtocols: []stack.TransportProtocolFactory{udp.NewProtocol, icmp.NewProtocol4, icmp.NewProtocol6},
HandleLocal: true,
}
@@ -1331,24 +1348,30 @@ func (pm *PeerMonitor) initNetstack() error {
// handlePacket is called by MiddleDevice when a packet arrives for our IP
func (pm *PeerMonitor) handlePacket(packet []byte) bool {
// Check if it's UDP
proto, ok := util.GetProtocol(packet)
if !ok || proto != 17 { // UDP
return false
}
// Check destination port
port, ok := util.GetDestPort(packet)
if !ok {
return false
}
// Check if we are listening on this port
pm.portsLock.RLock()
active := pm.activePorts[uint16(port)]
pm.portsLock.RUnlock()
switch proto {
case 1, 58: // ICMPv4, ICMPv6 - always ours, used only by the exit node ping probe
// no per-port filtering needed
case 17: // UDP
// Check destination port
port, ok := util.GetDestPort(packet)
if !ok {
return false
}
if !active {
// Check if we are listening on this port
pm.portsLock.RLock()
active := pm.activePorts[uint16(port)]
pm.portsLock.RUnlock()
if !active {
return false
}
default:
return false
}