Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions cmd/unbounded-net-node/status_proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ func statusUnixNano(t time.Time) int64 {
return t.UnixNano()
}

func nodeSummaryToProto(summary *NodeStatusOverview) *statusproto.NodeStatusOverview {
if summary == nil {
return nil
}

result := &statusproto.NodeStatusOverview{
TimestampUnixNs: statusUnixNano(summary.Timestamp),
NodeInfo: nodeInfoToProto(&summary.NodeInfo),
HealthCheck: healthCheckStatusToProto(summary.HealthCheck),
NodeErrors: nodeErrorsToProto(summary.NodeErrors),
FetchError: summary.FetchError,
StatusSource: summary.StatusSource,
NodePodInfo: nodePodInfoToProto(summary.NodePodInfo),
PeerCount: int32(summary.PeerCount),
HealthyPeers: int32(summary.HealthyPeers),
RouteCount: int32(summary.RouteCount),
RouteMismatch: summary.RouteMismatch,
}
if summary.LastPushTime != nil {
result.LastPushTimeUnixNs = statusUnixNano(*summary.LastPushTime)
}

return result
}

// nodeStatusToProto converts a Go NodeStatusResponse to the protobuf NodeStatusFull message.
func nodeStatusToProto(status *NodeStatusResponse) *statusproto.NodeStatusFull {
if status == nil {
Expand Down
66 changes: 48 additions & 18 deletions cmd/unbounded-net-node/status_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/coder/websocket"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/protobuf/proto"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
Expand Down Expand Up @@ -465,7 +466,7 @@ func (tm *hmacTokenManager) requestToken() error {
return fmt.Errorf("all HMAC token endpoints failed: %s", strings.Join(endpointErrors, "; "))
}

func startHealthServer(port int, healthState *nodeHealthState) {
func newHealthMux(healthState *nodeHealthState) *http.ServeMux {
mux := http.NewServeMux()

metrics.Register(mux)
Expand Down Expand Up @@ -528,14 +529,21 @@ func startHealthServer(port int, healthState *nodeHealthState) {
}
})

// Same local routing and authentication policy as the full status endpoint.
mux.HandleFunc("/status/summary", healthState.handleStatusSummary)

return mux
}

func startHealthServer(port int, healthState *nodeHealthState) {
addr := fmt.Sprintf(":%d", port)
klog.Infof("Starting health server on %s", addr)

httpMiddleware := metrics.NewHTTPMiddleware("unbounded_cni_node")

server := &http.Server{
Addr: addr,
Handler: httpMiddleware.Wrap("all", mux),
Handler: httpMiddleware.Wrap("all", newHealthMux(healthState)),
ReadHeaderTimeout: 10 * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
Expand Down Expand Up @@ -2291,6 +2299,18 @@ type nodeStatusServer struct {
// the host kernel's actual routing table. Production callers leave this
// nil and the helpers fall back to the real netlink package.
netlinkOps statusServerNetlinkOps

// Optional collector override for tests; summaries never invoke this.
bpfCollector func() []BpfEntry
wireGuardDevice func(*unboundednetnetlink.WireGuardManager) (*wgtypes.Device, error)
}

func (s *nodeStatusServer) getWireGuardDevice(manager *unboundednetnetlink.WireGuardManager) (*wgtypes.Device, error) {
if s.wireGuardDevice != nil {
return s.wireGuardDevice(manager)
}

return manager.GetDevice()
}

// statusServerNetlinkOps abstracts the netlink reads that
Expand Down Expand Up @@ -2550,7 +2570,7 @@ func (s *nodeStatusServer) inspectNodePeers(visit func(WireGuardPeerStatus)) *no

// Get WireGuard device info if available (netlink syscall)
if wgManager != nil {
if device, err := wgManager.GetDevice(); err == nil {
if device, err := s.getWireGuardDevice(wgManager); err == nil {
status.NodeInfo.WireGuard.ListenPort = device.ListenPort
status.NodeInfo.WireGuard.PeerCount = len(device.Peers)

Expand Down Expand Up @@ -2627,7 +2647,7 @@ func (s *nodeStatusServer) inspectNodePeers(visit func(WireGuardPeerStatus)) *no
for _, gw := range gwSnapshots {
// Get WireGuard peer info for this gateway interface (netlink syscall)
if gw.wgManager != nil {
if device, err := gw.wgManager.GetDevice(); err == nil && len(device.Peers) > 0 {
if device, err := s.getWireGuardDevice(gw.wgManager); err == nil && len(device.Peers) > 0 {
wgPeer := device.Peers[0] // Each gateway interface has one peer

peer := WireGuardPeerStatus{
Expand Down Expand Up @@ -2843,7 +2863,11 @@ func (s *nodeStatusServer) getNodeStatus() *NodeStatusResponse {
}

// Collect BPF trie entries.
status.BpfEntries = s.collectBpfEntries()
if s.bpfCollector != nil {
status.BpfEntries = s.bpfCollector()
} else {
status.BpfEntries = s.collectBpfEntries()
}

return status
}
Expand All @@ -2866,26 +2890,32 @@ func linkStatsWarningsAsNodeErrors(warnings []string, peers []WireGuardPeerStatu
}

func suppressHealthyWireGuardRxErrors(warning string, peers []WireGuardPeerStatus, now time.Time) bool {
iface, deltas, ok := parseLinkStatsWarning(warning)
if !ok || len(deltas) != 1 || !strings.HasPrefix(deltas[0], "rx_errors +") {
return false
}
return suppressHealthyInterfaceRxErrors(warning, func(iface string) bool {
matched := false

matched := false
for _, peer := range peers {
if peer.Tunnel.Interface != iface {
continue
}

for _, peer := range peers {
if peer.Tunnel.Interface != iface {
continue
matched = true

if !peerStatusHealthy(peer, now) {
return false
}
}

matched = true
return matched
})
}

if !peerStatusHealthy(peer, now) {
return false
}
func suppressHealthyInterfaceRxErrors(warning string, healthy func(string) bool) bool {
iface, deltas, ok := parseLinkStatsWarning(warning)
if !ok || len(deltas) != 1 || !strings.HasPrefix(deltas[0], "rx_errors +") {
return false
}

return matched
return healthy(iface)
}

func parseLinkStatsWarning(warning string) (string, []string, bool) {
Expand Down
101 changes: 101 additions & 0 deletions cmd/unbounded-net-node/status_summary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) Microsoft Corporation.
// SPDX-License-Identifier: Apache-2.0

package main

import (
"encoding/json"
"net/http"
"time"

"k8s.io/klog/v2"

"github.com/Azure/unbounded/internal/net/routeplan"
netstatus "github.com/Azure/unbounded/internal/net/status"
statusv1alpha1 "github.com/Azure/unbounded/internal/net/status/v1alpha1"
)

type NodeStatusOverview = statusv1alpha1.NodeStatusOverview

// getNodeSummary collects overview facts directly. Only route-planning inputs
// survive peer visitation; full peer, route, and BPF snapshots are never built.
func (s *nodeStatusServer) getNodeSummary() *NodeStatusOverview {
summary := &NodeStatusOverview{}

var routePeers []routeplan.Peer

interfaceHealthy := make(map[string]bool)
now := time.Now()
facts := s.inspectNodePeers(func(peer WireGuardPeerStatus) {
summary.PeerCount++
if netstatus.PeerHealthyForOverview(&peer, now) {
summary.HealthyPeers++
}

previous, seen := interfaceHealthy[peer.Tunnel.Interface]
interfaceHealthy[peer.Tunnel.Interface] = (!seen || previous) && peerStatusHealthy(peer, now)
routePeers = append(routePeers, routeplan.Peer{
Name: peer.Name, PeerType: peer.PeerType, SiteName: peer.SiteName,
SkipPodCIDRRoutes: peer.SkipPodCIDRRoutes,
Interface: peer.Tunnel.Interface, Endpoint: peer.Tunnel.Endpoint,
PodCIDRGateways: peer.PodCIDRGateways, AllowedIPs: peer.Tunnel.AllowedIPs,
RouteDistances: peer.RouteDistances,
})
})
summary.Timestamp = facts.Timestamp
summary.NodeInfo = facts.NodeInfo
summary.NodeErrors = facts.NodeErrors
summary.HealthCheck = facts.HealthCheck
summary.RouteCount, summary.RouteMismatch = s.collectRouteSummary(routePeers, facts.NodeInfo.SiteName)

if s.state.linkStatsMonitor != nil {
for _, warning := range s.state.linkStatsMonitor.GetWarnings() {
if !suppressHealthyInterfaceRxErrors(warning, func(iface string) bool { return interfaceHealthy[iface] }) {
summary.NodeErrors = append(summary.NodeErrors, NodeError{Type: "link-stats", Message: warning})
}
}
}

return summary
}

func (h *nodeHealthState) getSummarySnapshot() *NodeStatusOverview {
h.mu.RLock()
srv := h.statusServer

summary := &NodeStatusOverview{
Timestamp: time.Now(),
NodeInfo: NodeInfo{
Name: h.nodeName, SiteName: h.siteName, IsGateway: h.isGateway,
PodCIDRs: append([]string(nil), h.podCIDRs...), BuildInfo: nodeAgentBuildInfo(),
},
}
if h.pubKey != "" {
summary.NodeInfo.WireGuard = &WireGuardStatusInfo{PublicKey: h.pubKey}
}

cniManaged, cniReady, cniReason := h.cniManaged, h.cniReady, h.cniReason
transientErrors := append([]NodeError(nil), h.transientErrors...)
h.mu.RUnlock()

if srv != nil {
summary = srv.getNodeSummary()
}

summary.NodeErrors = mergeNodeErrors(summary.NodeErrors, filterExpiredNodeErrors(transientErrors, time.Now(), time.Minute))

summary.NodeErrors = removeNodeErrorsByType(summary.NodeErrors, configPodCIDRGuard)
if cniManaged && !cniReady && cniReason != "" {
summary.NodeErrors = append(summary.NodeErrors, NodeError{Type: configPodCIDRGuard, Message: cniReason})
}

return summary
}

func (h *nodeHealthState) handleStatusSummary(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")

if err := json.NewEncoder(w).Encode(h.getSummarySnapshot()); err != nil {
klog.V(4).Infof("status summary json encode failed: %v", err)
}
}
7 changes: 3 additions & 4 deletions cmd/unbounded-net-node/status_summary_routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"golang.org/x/sys/unix"

"github.com/Azure/unbounded/internal/net/routeplan"
netstatus "github.com/Azure/unbounded/internal/net/status"
)

func summaryRoute(destination string, index, table, distance int) netlink.Route {
Expand Down Expand Up @@ -89,10 +90,8 @@ func TestRouteSummaryParity(t *testing.T) {
wantMismatch := false

for _, route := range full.RoutingTable.Routes {
for _, hop := range route.NextHops {
if (hop.Expected != nil && *hop.Expected) != (hop.Present != nil && *hop.Present) {
wantMismatch = true
}
if netstatus.RouteMismatchForOverview(route) {
wantMismatch = true
}
}

Expand Down
Loading