Files
netbird/client/internal/peer/status/recorder.go
T

1320 lines
36 KiB
Go

package status
import (
"context"
"errors"
"fmt"
"net/netip"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/internal/ingressgw"
"github.com/netbirdio/netbird/client/internal/relay"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
)
const eventQueueSize = 10
var errPeerNotExists = errors.New("peer doesn't exist")
type ResolvedDomainInfo struct {
Prefixes []netip.Prefix
ParentDomain domain.Domain
}
type WGIfaceStatus interface {
FullStats() (*configurer.Stats, error)
}
type EventListener interface {
OnEvent(event *proto.SystemEvent)
}
// RouterState status for router peers. This contains relevant fields for route manager
type RouterState struct {
Status ConnStatus
Relayed bool
Latency time.Duration
}
// LocalPeerState contains the latest state of the local peer
type LocalPeerState struct {
IP string
IPv6 string
PubKey string
KernelInterface bool
FQDN string
WgPort int
Routes map[string]struct{}
}
// Clone returns a copy of the LocalPeerState
func (l LocalPeerState) Clone() LocalPeerState {
l.Routes = maps.Clone(l.Routes)
return l
}
// SignalState contains the latest state of a signal connection
type SignalState struct {
URL string
Connected bool
Error error
}
// ManagementState contains the latest state of a management connection
type ManagementState struct {
URL string
Connected bool
Error error
}
// RosenpassState contains the latest state of the Rosenpass configuration
type RosenpassState struct {
Enabled bool
Permissive bool
}
// NSGroupState represents the status of a DNS server group, including associated domains,
// whether it's enabled, and the last error message encountered during probing.
type NSGroupState struct {
ID string
Servers []netip.AddrPort
Domains []string
Enabled bool
Error error
}
type StatusChangeSubscription struct {
peerID string
id string
eventsChan chan map[string]RouterState
ctx context.Context
}
func newStatusChangeSubscription(ctx context.Context, peerID string) *StatusChangeSubscription {
return &StatusChangeSubscription{
ctx: ctx,
peerID: peerID,
id: uuid.New().String(),
// it is a buffer for notifications to block less the status recorded
eventsChan: make(chan map[string]RouterState, 8),
}
}
func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
return s.eventsChan
}
// Recorder holds a state of peers, signal, management connections and relays.
// mux is an RWMutex so hot read paths (notably PeerStateByIP, called for
// every private-service request) don't contend against each other.
// Pure read methods take RLock; anything that mutates state takes Lock.
type Recorder struct {
mux sync.RWMutex
muxRelays sync.RWMutex
peers map[string]State
ipToKey map[string]string
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
signalState bool
signalError error
managementState bool
managementError error
relayStates []relay.ProbeResult
localPeer LocalPeerState
offlinePeers []State
mgmAddress string
signalAddress string
notifier *notifier
rosenpassEnabled bool
rosenpassPermissive bool
// sessionExpiresAt is the absolute UTC instant at which the peer's SSO
// session expires. Zero when the peer is not SSO-tracked or login
// expiration is disabled. Populated from management LoginResponse /
// SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
// so the UI can show remaining time without itself talking to mgm.
sessionExpiresAt time.Time
nsGroupStates []NSGroupState
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
lazyConnectionEnabled bool
// To reduce the number of notification invocation this bool will be true when need to call the notification
// Some Peer actions mostly used by in a batch when the network map has been synchronized. In these type of events
// set to true this variable and at the end of the processing we will reset it by the FinishPeerListModifications()
peerListChangedForNotification bool
relayMgr *relayClient.Manager
eventMux sync.RWMutex
eventStreams map[string]chan *proto.SystemEvent
eventQueue *EventQueue
// stateChangeStreams fan-out connection-state changes (connected /
// disconnected / connecting / address change / peers list change) to
// every active SubscribeStatus gRPC stream. Each subscriber gets a
// buffered chan; the notifier non-blockingly pings them so a slow
// consumer can never stall the daemon.
stateChangeMux sync.Mutex
stateChangeStreams map[string]chan struct{}
// networksRevision bumps whenever the routed-networks set or their
// selected state changes (driven by the route manager). Surfaced in the
// status snapshot so the UI can fingerprint on it and re-fetch
// ListNetworks only on a real change. Atomic so the snapshot builder can
// read it without taking mux.
networksRevision atomic.Uint64
ingressGwMgr *ingressgw.Manager
routeIDLookup routeIDLookup
wgIface WGIfaceStatus
}
// NewRecorder returns a new Recorder instance
func NewRecorder(mgmAddress string) *Recorder {
return &Recorder{
peers: make(map[string]State),
ipToKey: make(map[string]string),
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
eventStreams: make(map[string]chan *proto.SystemEvent),
eventQueue: NewEventQueue(eventQueueSize),
stateChangeStreams: make(map[string]chan struct{}),
offlinePeers: make([]State, 0),
notifier: newNotifier(),
mgmAddress: mgmAddress,
resolvedDomainsStates: map[domain.Domain]ResolvedDomainInfo{},
}
}
func (d *Recorder) SetRelayMgr(manager *relayClient.Manager) {
d.muxRelays.Lock()
defer d.muxRelays.Unlock()
d.relayMgr = manager
}
func (d *Recorder) SetIngressGwMgr(ingressGwMgr *ingressgw.Manager) {
d.mux.Lock()
defer d.mux.Unlock()
d.ingressGwMgr = ingressGwMgr
}
// ReplaceOfflinePeers replaces
func (d *Recorder) ReplaceOfflinePeers(replacement []State) {
d.mux.Lock()
defer d.mux.Unlock()
d.offlinePeers = make([]State, len(replacement))
copy(d.offlinePeers, replacement)
// todo we should set to true in case if the list changed only
d.peerListChangedForNotification = true
}
// AddPeer adds peer to Daemon status map
func (d *Recorder) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string) error {
d.mux.Lock()
defer d.mux.Unlock()
_, ok := d.peers[peerPubKey]
if ok {
return errors.New("peer already exist")
}
d.peers[peerPubKey] = State{
PubKey: peerPubKey,
IP: ip,
IPv6: ipv6,
ConnStatus: StatusIdle,
FQDN: fqdn,
Mux: new(sync.RWMutex),
}
d.peerListChangedForNotification = true
if ipv6 != "" {
d.ipToKey[ipv6] = peerPubKey
}
if ip != "" {
d.ipToKey[ip] = peerPubKey
}
return nil
}
// GetPeer adds peer to Daemon status map
func (d *Recorder) GetPeer(peerPubKey string) (State, error) {
d.mux.RLock()
defer d.mux.RUnlock()
state, ok := d.peers[peerPubKey]
if !ok {
return State{}, configurer.ErrPeerNotFound
}
return state, nil
}
func (d *Recorder) PeerByIP(ip string) (string, bool) {
d.mux.RLock()
defer d.mux.RUnlock()
for _, state := range d.peers {
if state.IP == ip {
return state.FQDN, true
}
}
return "", false
}
// PeerStateByIP returns the full peer State for the given tunnel IP.
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
// address so dual-stack peers are reachable on either family. Only
// active peers are matched; peers moved into the offline slice by
// ReplaceOfflinePeers are intentionally treated as unknown.
func (d *Recorder) PeerStateByIP(ip string) (State, bool) {
if ip == "" {
return State{}, false
}
d.mux.RLock()
defer d.mux.RUnlock()
key, ok := d.ipToKey[ip]
if !ok {
return State{}, false
}
state, ok := d.peers[key]
if ok {
return state, true
}
return State{}, false
}
// RemovePeer removes peer from Daemon status map
func (d *Recorder) RemovePeer(peerPubKey string) error {
d.mux.Lock()
defer d.mux.Unlock()
p, ok := d.peers[peerPubKey]
if !ok {
return errors.New("no peer with to remove")
}
delete(d.peers, peerPubKey)
if mappedKey, exists := d.ipToKey[p.IP]; exists && mappedKey == peerPubKey {
delete(d.ipToKey, p.IP)
}
if mappedKey, exists := d.ipToKey[p.IPv6]; exists && mappedKey == peerPubKey {
delete(d.ipToKey, p.IPv6)
}
d.peerListChangedForNotification = true
return nil
}
// UpdatePeerState updates peer status
func (d *Recorder) UpdatePeerState(receivedState State) error {
return d.updatePeer(receivedState.PubKey,
func(_, updated State) bool { return updated.ConnStatus == StatusIdle },
func(peerState *State) {
if receivedState.ConnStatus == peerState.ConnStatus {
return
}
peerState.ConnStatus = receivedState.ConnStatus
peerState.ConnStatusUpdate = receivedState.ConnStatusUpdate
peerState.Relayed = receivedState.Relayed
peerState.LocalIceCandidateType = receivedState.LocalIceCandidateType
peerState.RemoteIceCandidateType = receivedState.RemoteIceCandidateType
peerState.LocalIceCandidateEndpoint = receivedState.LocalIceCandidateEndpoint
peerState.RemoteIceCandidateEndpoint = receivedState.RemoteIceCandidateEndpoint
peerState.RelayServerAddress = receivedState.RelayServerAddress
peerState.RosenpassEnabled = receivedState.RosenpassEnabled
})
}
func (d *Recorder) AddPeerStateRoute(peer string, route string, resourceId route.ResID) error {
d.mux.Lock()
peerState, ok := d.peers[peer]
if !ok {
d.mux.Unlock()
return errPeerNotExists
}
peerState.AddRoute(route)
d.peers[peer] = peerState
pref, err := netip.ParsePrefix(route)
if err == nil {
d.routeIDLookup.AddRemoteRouteID(resourceId, pref)
}
numPeers := d.numOfPeers()
d.mux.Unlock()
// todo: consider to make sense of this notification or not
d.notifier.peerListChanged(numPeers)
d.notifyStateChange()
return nil
}
func (d *Recorder) RemovePeerStateRoute(peer string, route string) error {
d.mux.Lock()
peerState, ok := d.peers[peer]
if !ok {
d.mux.Unlock()
return errPeerNotExists
}
peerState.DeleteRoute(route)
d.peers[peer] = peerState
pref, err := netip.ParsePrefix(route)
if err == nil {
d.routeIDLookup.RemoveRemoteRouteID(pref)
}
numPeers := d.numOfPeers()
d.mux.Unlock()
// todo: consider to make sense of this notification or not
d.notifier.peerListChanged(numPeers)
d.notifyStateChange()
return nil
}
// CheckRoutes checks if the source and destination addresses are within the same route
// and returns the resource ID of the route that contains the addresses
func (d *Recorder) CheckRoutes(ip netip.Addr) ([]byte, bool) {
if d == nil {
return nil, false
}
resId, isExitNode := d.routeIDLookup.Lookup(ip)
return []byte(resId), isExitNode
}
func (d *Recorder) UpdatePeerICEState(receivedState State) error {
return d.updatePeer(receivedState.PubKey, hasStatusOrRelayedChange, func(peerState *State) {
peerState.ConnStatus = receivedState.ConnStatus
peerState.ConnStatusUpdate = receivedState.ConnStatusUpdate
peerState.Relayed = receivedState.Relayed
peerState.LocalIceCandidateType = receivedState.LocalIceCandidateType
peerState.RemoteIceCandidateType = receivedState.RemoteIceCandidateType
peerState.LocalIceCandidateEndpoint = receivedState.LocalIceCandidateEndpoint
peerState.RemoteIceCandidateEndpoint = receivedState.RemoteIceCandidateEndpoint
peerState.RosenpassEnabled = receivedState.RosenpassEnabled
})
}
func (d *Recorder) UpdatePeerRelayedState(receivedState State) error {
return d.updatePeer(receivedState.PubKey, hasStatusOrRelayedChange, func(peerState *State) {
peerState.ConnStatus = receivedState.ConnStatus
peerState.ConnStatusUpdate = receivedState.ConnStatusUpdate
peerState.Relayed = receivedState.Relayed
peerState.RelayServerAddress = receivedState.RelayServerAddress
peerState.RosenpassEnabled = receivedState.RosenpassEnabled
})
}
func (d *Recorder) UpdatePeerRelayedStateToDisconnected(receivedState State) error {
return d.updatePeer(receivedState.PubKey, hasStatusOrRelayedChange, func(peerState *State) {
peerState.ConnStatus = receivedState.ConnStatus
peerState.Relayed = receivedState.Relayed
peerState.ConnStatusUpdate = receivedState.ConnStatusUpdate
peerState.RelayServerAddress = ""
})
}
func (d *Recorder) UpdatePeerICEStateToDisconnected(receivedState State) error {
return d.updatePeer(receivedState.PubKey, hasStatusOrRelayedChange, func(peerState *State) {
peerState.ConnStatus = receivedState.ConnStatus
peerState.Relayed = receivedState.Relayed
peerState.ConnStatusUpdate = receivedState.ConnStatusUpdate
peerState.LocalIceCandidateType = receivedState.LocalIceCandidateType
peerState.RemoteIceCandidateType = receivedState.RemoteIceCandidateType
peerState.LocalIceCandidateEndpoint = receivedState.LocalIceCandidateEndpoint
peerState.RemoteIceCandidateEndpoint = receivedState.RemoteIceCandidateEndpoint
})
}
// UpdateWireGuardPeerState updates the WireGuard bits of the peer state
func (d *Recorder) UpdateWireGuardPeerState(pubKey string, wgStats configurer.WGStats) error {
d.mux.Lock()
defer d.mux.Unlock()
peerState, ok := d.peers[pubKey]
if !ok {
return errPeerNotExists
}
peerState.LastWireguardHandshake = wgStats.LastHandshake
peerState.BytesRx = wgStats.RxBytes
peerState.BytesTx = wgStats.TxBytes
d.peers[pubKey] = peerState
return nil
}
// updatePeer applies mutate to the stored peer state and runs the list, router
// and state-change notifications outside the lock
func (d *Recorder) updatePeer(pubKey string, notifyRouter func(old, updated State) bool, mutate func(*State)) error {
d.mux.Lock()
peerState, ok := d.peers[pubKey]
if !ok {
d.mux.Unlock()
return errPeerNotExists
}
oldState := peerState
mutate(&peerState)
d.peers[pubKey] = peerState
notifyList := oldState.ConnStatus != peerState.ConnStatus
router := notifyRouter(oldState, peerState)
routerSnapshot := d.snapshotRouterPeersLocked(pubKey, router)
numPeers := d.numOfPeers()
d.mux.Unlock()
if notifyList {
d.notifier.peerListChanged(numPeers)
}
if router {
d.dispatchRouterPeers(pubKey, routerSnapshot)
}
d.notifyStateChange()
return nil
}
// UpdatePeerFQDN update peer's state fqdn only
func (d *Recorder) UpdatePeerFQDN(peerPubKey, fqdn string) error {
d.mux.Lock()
defer d.mux.Unlock()
peerState, ok := d.peers[peerPubKey]
if !ok {
return errPeerNotExists
}
peerState.FQDN = fqdn
d.peers[peerPubKey] = peerState
return nil
}
// UpdatePeerSSHHostKey updates peer's SSH host key
func (d *Recorder) UpdatePeerSSHHostKey(peerPubKey string, sshHostKey []byte) error {
d.mux.Lock()
defer d.mux.Unlock()
peerState, ok := d.peers[peerPubKey]
if !ok {
return errPeerNotExists
}
peerState.SSHHostKey = sshHostKey
d.peers[peerPubKey] = peerState
return nil
}
// FinishPeerListModifications this event invoke the notification
func (d *Recorder) FinishPeerListModifications() {
d.mux.Lock()
if !d.peerListChangedForNotification {
d.mux.Unlock()
return
}
d.peerListChangedForNotification = false
numPeers := d.numOfPeers()
// snapshot per-peer router state to deliver after the lock is released
type routerDispatch struct {
peerID string
snapshot map[string]RouterState
}
dispatches := make([]routerDispatch, 0, len(d.peers))
for key := range d.peers {
snapshot := d.snapshotRouterPeersLocked(key, true)
if snapshot != nil {
dispatches = append(dispatches, routerDispatch{peerID: key, snapshot: snapshot})
}
}
d.mux.Unlock()
d.notifier.peerListChanged(numPeers)
for _, rd := range dispatches {
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
}
d.notifyStateChange()
}
func (d *Recorder) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
d.mux.Lock()
defer d.mux.Unlock()
sub := newStatusChangeSubscription(ctx, peerID)
if _, ok := d.changeNotify[peerID]; !ok {
d.changeNotify[peerID] = make(map[string]*StatusChangeSubscription)
}
d.changeNotify[peerID][sub.id] = sub
return sub
}
func (d *Recorder) UnsubscribePeerStateChanges(subscription *StatusChangeSubscription) {
d.mux.Lock()
defer d.mux.Unlock()
if subscription == nil {
return
}
channels, ok := d.changeNotify[subscription.peerID]
if !ok {
return
}
sub, exists := channels[subscription.id]
if !exists {
return
}
delete(channels, subscription.id)
if len(channels) == 0 {
delete(d.changeNotify, sub.peerID)
}
}
// GetLocalPeerState returns the local peer state
func (d *Recorder) GetLocalPeerState() LocalPeerState {
d.mux.RLock()
defer d.mux.RUnlock()
return d.localPeer.Clone()
}
// UpdateLocalPeerState updates local peer status
func (d *Recorder) UpdateLocalPeerState(localPeerState LocalPeerState) {
d.mux.Lock()
d.localPeer = localPeerState
fqdn := d.localPeer.FQDN
ip := d.localPeer.IP
if d.localPeer.IPv6 != "" {
ip = ip + "\n" + d.localPeer.IPv6
}
d.mux.Unlock()
d.notifier.localAddressChanged(fqdn, ip)
d.notifyStateChange()
}
// SetSessionExpiresAt records the absolute UTC instant at which the peer's
// SSO session is set to expire. Pass the zero value to clear (e.g. when the
// management server stops publishing a deadline because login expiration was
// disabled or the peer is not SSO-tracked). Same-value updates are no-ops;
// real changes fan out via notifyStateChange so SubscribeStatus consumers
// pick up the new deadline on their next read.
func (d *Recorder) SetSessionExpiresAt(deadline time.Time) {
d.mux.Lock()
if d.sessionExpiresAt.Equal(deadline) {
d.mux.Unlock()
return
}
d.sessionExpiresAt = deadline
d.mux.Unlock()
d.notifyStateChange()
}
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
// or the zero value when no deadline is tracked. A deadline in the past is
// returned as-is: it means the session has expired, and consumers (tray row,
// CLI status) render it as "expired" rather than hiding it — masking it as
// "none" would blank the UI at the exact moment it should say the session
// ended.
func (d *Recorder) GetSessionExpiresAt() time.Time {
d.mux.Lock()
defer d.mux.Unlock()
return d.sessionExpiresAt
}
// AddLocalPeerStateRoute adds a route to the local peer state
func (d *Recorder) AddLocalPeerStateRoute(route string, resourceId route.ResID) {
d.mux.Lock()
defer d.mux.Unlock()
pref, err := netip.ParsePrefix(route)
if err == nil {
d.routeIDLookup.AddLocalRouteID(resourceId, pref)
}
if d.localPeer.Routes == nil {
d.localPeer.Routes = map[string]struct{}{}
}
d.localPeer.Routes[route] = struct{}{}
}
// RemoveLocalPeerStateRoute removes a route from the local peer state
func (d *Recorder) RemoveLocalPeerStateRoute(route string) {
d.mux.Lock()
defer d.mux.Unlock()
pref, err := netip.ParsePrefix(route)
if err == nil {
d.routeIDLookup.RemoveLocalRouteID(pref)
}
delete(d.localPeer.Routes, route)
}
// AddResolvedIPLookupEntry adds a resolved IP lookup entry
func (d *Recorder) AddResolvedIPLookupEntry(prefix netip.Prefix, resourceId route.ResID) {
d.mux.Lock()
defer d.mux.Unlock()
d.routeIDLookup.AddResolvedIP(resourceId, prefix)
}
// RemoveResolvedIPLookupEntry removes a resolved IP lookup entry
func (d *Recorder) RemoveResolvedIPLookupEntry(route string) {
d.mux.Lock()
defer d.mux.Unlock()
pref, err := netip.ParsePrefix(route)
if err == nil {
d.routeIDLookup.RemoveResolvedIP(pref)
}
}
// CleanLocalPeerStateRoutes cleans all routes from the local peer state
func (d *Recorder) CleanLocalPeerStateRoutes() {
d.mux.Lock()
defer d.mux.Unlock()
d.localPeer.Routes = map[string]struct{}{}
}
// CleanLocalPeerState cleans local peer status
func (d *Recorder) CleanLocalPeerState() {
d.mux.Lock()
d.localPeer = LocalPeerState{}
fqdn := d.localPeer.FQDN
ip := d.localPeer.IP
d.mux.Unlock()
d.notifier.localAddressChanged(fqdn, ip)
d.notifyStateChange()
}
// MarkManagementDisconnected sets ManagementState to disconnected
func (d *Recorder) MarkManagementDisconnected(err error) {
d.mux.Lock()
// Health checks re-mark the same state on every probe; skip the fan-out
// when nothing actually changed so we don't flood SubscribeStatus
// consumers with identical snapshots.
if !d.managementState && errors.Is(d.managementError, err) {
d.mux.Unlock()
return
}
d.managementState = false
d.managementError = err
mgm := d.managementState
sig := d.signalState
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
d.notifyStateChange()
}
// MarkManagementConnected sets ManagementState to connected
func (d *Recorder) MarkManagementConnected() {
d.mux.Lock()
if d.managementState && d.managementError == nil {
d.mux.Unlock()
return
}
d.managementState = true
d.managementError = nil
mgm := d.managementState
sig := d.signalState
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
d.notifyStateChange()
}
// UpdateSignalAddress update the address of the signal server
func (d *Recorder) UpdateSignalAddress(signalURL string) {
d.mux.Lock()
defer d.mux.Unlock()
d.signalAddress = signalURL
}
// UpdateManagementAddress update the address of the management server
func (d *Recorder) UpdateManagementAddress(mgmAddress string) {
d.mux.Lock()
defer d.mux.Unlock()
d.mgmAddress = mgmAddress
}
// UpdateRosenpass update the Rosenpass configuration
func (d *Recorder) UpdateRosenpass(rosenpassEnabled, rosenpassPermissive bool) {
d.mux.Lock()
defer d.mux.Unlock()
d.rosenpassPermissive = rosenpassPermissive
d.rosenpassEnabled = rosenpassEnabled
}
func (d *Recorder) UpdateLazyConnection(enabled bool) {
d.mux.Lock()
defer d.mux.Unlock()
d.lazyConnectionEnabled = enabled
}
// MarkSignalDisconnected sets SignalState to disconnected
func (d *Recorder) MarkSignalDisconnected(err error) {
d.mux.Lock()
if !d.signalState && errors.Is(d.signalError, err) {
d.mux.Unlock()
return
}
d.signalState = false
d.signalError = err
mgm := d.managementState
sig := d.signalState
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
d.notifyStateChange()
}
// MarkSignalConnected sets SignalState to connected
func (d *Recorder) MarkSignalConnected() {
d.mux.Lock()
if d.signalState && d.signalError == nil {
d.mux.Unlock()
return
}
d.signalState = true
d.signalError = nil
mgm := d.managementState
sig := d.signalState
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
d.notifyStateChange()
}
func (d *Recorder) UpdateRelayStates(relayResults []relay.ProbeResult) {
d.muxRelays.Lock()
defer d.muxRelays.Unlock()
d.relayStates = relayResults
}
func (d *Recorder) UpdateDNSStates(dnsStates []NSGroupState) {
d.mux.Lock()
defer d.mux.Unlock()
d.nsGroupStates = dnsStates
}
func (d *Recorder) UpdateResolvedDomainsStates(originalDomain domain.Domain, resolvedDomain domain.Domain, prefixes []netip.Prefix, resourceId route.ResID) {
d.mux.Lock()
defer d.mux.Unlock()
// Store both the original domain pattern and resolved domain
d.resolvedDomainsStates[resolvedDomain] = ResolvedDomainInfo{
Prefixes: prefixes,
ParentDomain: originalDomain,
}
for _, prefix := range prefixes {
d.routeIDLookup.AddResolvedIP(resourceId, prefix)
}
}
func (d *Recorder) DeleteResolvedDomainsStates(domain domain.Domain) {
d.mux.Lock()
defer d.mux.Unlock()
// Remove all entries that have this domain as their parent
for k, v := range d.resolvedDomainsStates {
if v.ParentDomain == domain {
delete(d.resolvedDomainsStates, k)
for _, prefix := range v.Prefixes {
d.routeIDLookup.RemoveResolvedIP(prefix)
}
}
}
}
func (d *Recorder) GetRosenpassState() RosenpassState {
d.mux.RLock()
defer d.mux.RUnlock()
return RosenpassState{
d.rosenpassEnabled,
d.rosenpassPermissive,
}
}
func (d *Recorder) GetLazyConnection() bool {
d.mux.RLock()
defer d.mux.RUnlock()
return d.lazyConnectionEnabled
}
func (d *Recorder) GetManagementState() ManagementState {
d.mux.RLock()
defer d.mux.RUnlock()
return ManagementState{
d.mgmAddress,
d.managementState,
d.managementError,
}
}
func (d *Recorder) UpdateLatency(pubKey string, latency time.Duration) error {
if latency <= 0 {
return nil
}
d.mux.Lock()
defer d.mux.Unlock()
peerState, ok := d.peers[pubKey]
if !ok {
return errPeerNotExists
}
peerState.Latency = latency
d.peers[pubKey] = peerState
return nil
}
// IsLoginRequired determines if a peer's login has expired.
func (d *Recorder) IsLoginRequired() bool {
d.mux.RLock()
defer d.mux.RUnlock()
// if peer is connected to the management then login is not expired
if d.managementState {
return false
}
s, ok := gstatus.FromError(d.managementError)
if ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) {
return true
}
return false
}
func (d *Recorder) GetSignalState() SignalState {
d.mux.RLock()
defer d.mux.RUnlock()
return SignalState{
d.signalAddress,
d.signalState,
d.signalError,
}
}
// GetRelayStates returns the stun/turn/permanent relay states
func (d *Recorder) GetRelayStates() []relay.ProbeResult {
d.muxRelays.RLock()
if d.relayMgr == nil {
defer d.muxRelays.RUnlock()
return slices.Clone(d.relayStates)
}
relayMgr := d.relayMgr
// extend the list of stun, turn servers with the relay server connections
relayStates := slices.Clone(d.relayStates)
d.muxRelays.RUnlock()
states := relayMgr.RelayStates()
if len(states) == 0 {
// no relay connection tracked yet; surface configured servers as
// unavailable with the real reconnect error when known
err := relayClient.ErrRelayClientNotConnected
if connErr := relayMgr.RelayConnectError(); connErr != nil {
err = connErr
}
for _, r := range relayMgr.ServerURLs() {
relayStates = append(relayStates, relay.ProbeResult{
URI: r,
Err: err,
})
}
return relayStates
}
for _, rs := range states {
relayStates = append(relayStates, relay.ProbeResult{
URI: rs.URL,
Err: rs.Err,
Transport: rs.Transport,
})
}
return relayStates
}
func (d *Recorder) ForwardingRules() []firewall.ForwardRule {
d.mux.RLock()
defer d.mux.RUnlock()
if d.ingressGwMgr == nil {
return nil
}
return d.ingressGwMgr.Rules()
}
func (d *Recorder) GetDNSStates() []NSGroupState {
d.mux.RLock()
defer d.mux.RUnlock()
// shallow copy is good enough, as slices fields are currently not updated
return slices.Clone(d.nsGroupStates)
}
func (d *Recorder) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
d.mux.RLock()
defer d.mux.RUnlock()
return maps.Clone(d.resolvedDomainsStates)
}
// GetPeerStates returns a snapshot of all known peer states, including offline peers.
func (d *Recorder) GetPeerStates() []State {
d.mux.RLock()
defer d.mux.RUnlock()
states := make([]State, 0, d.numOfPeers())
for _, state := range d.peers {
states = append(states, state)
}
return append(states, d.offlinePeers...)
}
// GetFullStatus gets full status
func (d *Recorder) GetFullStatus() FullStatus {
fullStatus := FullStatus{
ManagementState: d.GetManagementState(),
SignalState: d.GetSignalState(),
Relays: d.GetRelayStates(),
RosenpassState: d.GetRosenpassState(),
NSGroupStates: d.GetDNSStates(),
NumOfForwardingRules: len(d.ForwardingRules()),
LazyConnectionEnabled: d.GetLazyConnection(),
}
d.mux.RLock()
defer d.mux.RUnlock()
fullStatus.LocalPeerState = d.localPeer
for _, status := range d.peers {
fullStatus.Peers = append(fullStatus.Peers, status)
}
fullStatus.Peers = append(fullStatus.Peers, d.offlinePeers...)
fullStatus.Events = d.GetEventHistory()
return fullStatus
}
// ClientStart will notify all listeners about the new service state
func (d *Recorder) ClientStart() {
d.notifier.clientStart()
d.notifyStateChange()
}
// ClientStop will notify all listeners about the new service state
func (d *Recorder) ClientStop() {
d.notifier.clientStop()
d.notifyStateChange()
}
// ClientTeardown will notify all listeners about the service is under teardown
func (d *Recorder) ClientTeardown() {
d.notifier.clientTearDown()
d.notifyStateChange()
}
// SetNetworkAvailable records the OS-reported network availability; while
// unavailable, listeners see NoNetwork instead of Connecting.
func (d *Recorder) SetNetworkAvailable(available bool) {
d.notifier.setNetworkAvailable(available)
}
// SetConnectionListener set a listener to the notifier
func (d *Recorder) SetConnectionListener(listener Listener) {
d.notifier.setListener(listener)
}
// RemoveConnectionListener remove the listener from the notifier
func (d *Recorder) RemoveConnectionListener() {
d.notifier.removeListener()
}
// snapshotRouterPeersLocked builds the RouterState map for a peer's subscribers.
// Caller MUST hold d.mux. Returns nil when there are no subscribers for peerID
// or when notify is false. The snapshot is consumed later by dispatchRouterPeers
// outside the lock so the channel send cannot stall any d.mux holder.
func (d *Recorder) snapshotRouterPeersLocked(peerID string, notify bool) map[string]RouterState {
if !notify {
return nil
}
if _, ok := d.changeNotify[peerID]; !ok {
return nil
}
routerPeers := make(map[string]RouterState, len(d.changeNotify))
for pid := range d.changeNotify {
s, ok := d.peers[pid]
if !ok {
log.Warnf("router peer not found in peers list: %s", pid)
continue
}
routerPeers[pid] = RouterState{
Status: s.ConnStatus,
Relayed: s.Relayed,
Latency: s.Latency,
}
}
return routerPeers
}
// dispatchRouterPeers delivers a previously snapshotted router-state map to
// the peer's subscribers. Caller MUST NOT hold d.mux. The method takes a
// fresh, short read of d.changeNotify under the lock to grab subscriber
// channels, then sends outside the lock so a slow consumer cannot block other
// d.mux holders. The send itself stays blocking (only short-circuited by the
// subscriber's context) so peer state transitions are not silently dropped.
func (d *Recorder) dispatchRouterPeers(peerID string, routerPeers map[string]RouterState) {
if routerPeers == nil {
return
}
d.mux.Lock()
subsMap, ok := d.changeNotify[peerID]
subs := make([]*StatusChangeSubscription, 0, len(subsMap))
if ok {
for _, sub := range subsMap {
subs = append(subs, sub)
}
}
d.mux.Unlock()
for _, sub := range subs {
select {
case sub.eventsChan <- routerPeers:
case <-sub.ctx.Done():
}
}
}
func (d *Recorder) numOfPeers() int {
return len(d.peers) + len(d.offlinePeers)
}
// PublishEvent adds an event to the queue and distributes it to all subscribers
func (d *Recorder) PublishEvent(
severity proto.SystemEvent_Severity,
category proto.SystemEvent_Category,
msg string,
userMsg string,
metadata map[string]string,
) {
event := &proto.SystemEvent{
Id: uuid.New().String(),
Severity: severity,
Category: category,
Message: msg,
UserMessage: userMsg,
Metadata: metadata,
Timestamp: timestamppb.Now(),
}
d.eventMux.Lock()
defer d.eventMux.Unlock()
d.eventQueue.Add(event)
for _, stream := range d.eventStreams {
select {
case stream <- event:
default:
log.Debugf("event stream buffer full, skipping event: %v", event)
}
}
log.Debugf("event published: %v", event)
}
// SubscribeToEvents returns a new event subscription
func (d *Recorder) SubscribeToEvents() *EventSubscription {
d.eventMux.Lock()
defer d.eventMux.Unlock()
id := uuid.New().String()
stream := make(chan *proto.SystemEvent, 10)
d.eventStreams[id] = stream
return &EventSubscription{
id: id,
events: stream,
}
}
// UnsubscribeFromEvents removes an event subscription
func (d *Recorder) UnsubscribeFromEvents(sub *EventSubscription) {
if sub == nil {
return
}
d.eventMux.Lock()
defer d.eventMux.Unlock()
if stream, exists := d.eventStreams[sub.id]; exists {
close(stream)
delete(d.eventStreams, sub.id)
}
}
// GetEventHistory returns all events in the queue
func (d *Recorder) GetEventHistory() []*proto.SystemEvent {
return d.eventQueue.GetAll()
}
// SubscribeToStateChanges hands back a channel that receives a tick on
// every connection-state change (connected / disconnected / connecting /
// address change / peers-list change). The channel is buffered to one
// pending tick so a coalesced burst still wakes the consumer exactly
// once. Pass the returned id to UnsubscribeFromStateChanges to detach.
func (d *Recorder) SubscribeToStateChanges() (string, <-chan struct{}) {
d.stateChangeMux.Lock()
defer d.stateChangeMux.Unlock()
id := uuid.New().String()
ch := make(chan struct{}, 1)
d.stateChangeStreams[id] = ch
return id, ch
}
// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel
// and closes it so any consumer goroutine selecting on the channel
// unblocks cleanly.
func (d *Recorder) UnsubscribeFromStateChanges(id string) {
d.stateChangeMux.Lock()
defer d.stateChangeMux.Unlock()
if ch, ok := d.stateChangeStreams[id]; ok {
close(ch)
delete(d.stateChangeStreams, id)
}
}
// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops
// the tick if a subscriber's buffer is full — by definition the consumer
// is already going to fetch the latest snapshot, so multiple pending ticks
// would be redundant.
func (d *Recorder) notifyStateChange() {
d.stateChangeMux.Lock()
defer d.stateChangeMux.Unlock()
for _, ch := range d.stateChangeStreams {
select {
case ch <- struct{}{}:
default:
}
}
}
// NotifyStateChange is the public wake-the-subscribers entry point used by
// callers that mutate state outside the peer recorder — most importantly
// the connect-state machine, which writes StatusNeedsLogin into the
// shared contextState (client/internal/state.go) without touching any
// recorder field. Without this push the SubscribeStatus stream stays on
// the previous snapshot until an unrelated peer/management/signal
// change happens to fire notifyStateChange, leaving the UI's status
// out of sync with the daemon.
func (d *Recorder) NotifyStateChange() {
d.notifyStateChange()
}
// BumpNetworksRevision increments the routed-networks revision and wakes every
// SubscribeStatus subscriber. The route manager calls it when a network map
// changes the available routes or when a selection is applied — the peer
// status itself only records actively-routed (chosen) networks, so without
// this bump a candidate route appearing/disappearing would never reach the UI.
func (d *Recorder) BumpNetworksRevision() {
d.networksRevision.Add(1)
d.notifyStateChange()
}
// GetNetworksRevision returns the current routed-networks revision, surfaced in
// the status snapshot so the UI can detect route/selection changes (see
// BumpNetworksRevision).
func (d *Recorder) GetNetworksRevision() uint64 {
return d.networksRevision.Load()
}
func (d *Recorder) SetWgIface(wgInterface WGIfaceStatus) {
d.mux.Lock()
defer d.mux.Unlock()
d.wgIface = wgInterface
}
func (d *Recorder) PeersStatus() (*configurer.Stats, error) {
d.mux.RLock()
defer d.mux.RUnlock()
if d.wgIface == nil {
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
}
return d.wgIface.FullStats()
}
// RefreshWireGuardStats fetches fresh WireGuard statistics from the interface
// and updates the cached peer states. This ensures accurate handshake times and
// transfer statistics in status reports without running full health probes.
func (d *Recorder) RefreshWireGuardStats() error {
d.mux.Lock()
defer d.mux.Unlock()
if d.wgIface == nil {
return nil // silently skip if interface not set
}
stats, err := d.wgIface.FullStats()
if err != nil {
return fmt.Errorf("get wireguard stats: %w", err)
}
// Update each peer's WireGuard statistics
for _, peerStats := range stats.Peers {
peerState, ok := d.peers[peerStats.PublicKey]
if !ok {
continue
}
peerState.LastWireguardHandshake = peerStats.LastHandshake
peerState.BytesRx = peerStats.RxBytes
peerState.BytesTx = peerStats.TxBytes
d.peers[peerStats.PublicKey] = peerState
}
return nil
}
func hasStatusOrRelayedChange(old, updated State) bool {
return old.Relayed != updated.Relayed || old.ConnStatus != updated.ConnStatus
}