package peer import ( "fmt" "net" "net/netip" "slices" "sort" "strings" "time" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" ) // Peer capability constants mirror the proto enum values. const ( PeerCapabilitySourcePrefixes int32 = 1 PeerCapabilityIPv6Overlay int32 = 2 ) // Peer represents a machine connected to the network. // The Peer is a WireGuard peer identified by a public key type Peer struct { // ID is an internal ID of the peer ID string `gorm:"primaryKey"` // AccountID is a reference to Account that this object belongs AccountID string `json:"-" gorm:"index"` // WireGuard public key Key string // uniqueness index (check migrations) // IP address of the Peer IP netip.Addr `gorm:"serializer:json"` // uniqueness index per accountID (check migrations) // IPv6 overlay address of the Peer, zero value if IPv6 is not enabled for the account. IPv6 netip.Addr `gorm:"serializer:json"` // Meta is a Peer system meta data Meta PeerSystemMeta `gorm:"embedded;embeddedPrefix:meta_"` // ProxyMeta is metadata related to proxy peers ProxyMeta ProxyMeta `gorm:"embedded;embeddedPrefix:proxy_meta_"` // Name is peer's name (machine name) Name string `gorm:"index"` // DNSLabel is the parsed peer name for domain resolution. It is used to form an FQDN by appending the account's // domain to the peer label. e.g. peer-dns-label.netbird.cloud DNSLabel string // uniqueness index per accountID (check migrations) // Status peer's management connection status Status *PeerStatus `gorm:"embedded;embeddedPrefix:peer_status_"` // The user ID that registered the peer UserID string // SSHKey is a public SSH key of the peer SSHKey string // SSHEnabled indicates whether SSH server is enabled on the peer SSHEnabled bool // LoginExpirationEnabled indicates whether peer's login expiration is enabled and once expired the peer has to re-login. // Works with LastLogin LoginExpirationEnabled bool InactivityExpirationEnabled bool // LastLogin the time when peer performed last login operation LastLogin *time.Time // CreatedAt records the time the peer was created CreatedAt time.Time // Indicate ephemeral peer attribute Ephemeral bool `gorm:"index"` // Geo location based on connection IP Location Location `gorm:"embedded;embeddedPrefix:location_"` // ExtraDNSLabels is a list of additional DNS labels that can be used to resolve the peer ExtraDNSLabels []string `gorm:"serializer:json"` // AllowExtraDNSLabels indicates whether the peer allows extra DNS labels to be used for resolving the peer AllowExtraDNSLabels bool } type ProxyMeta struct { Embedded bool `gorm:"index"` Cluster string `gorm:"index"` } type PeerStatus struct { //nolint:revive // LastSeen is the last time the peer status was updated (i.e. the last // time we observed the peer being alive on a sync stream). Written by // the database (CURRENT_TIMESTAMP) — callers do not supply it. LastSeen time.Time // SessionStartedAt records when the currently-active sync stream began, // stored as Unix nanoseconds. It acts as the optimistic-locking token // for status updates: a stream is only allowed to mutate the peer's // status when its own token strictly exceeds the stored token (when connecting) // or matches it exactly (for disconnects). Zero means "no // active session". Integer nanoseconds are used so equality is // precision-safe across drivers, and so the predicates compose to a // single bigint comparison. SessionStartedAt int64 `gorm:"not null;default:0"` // Connected indicates whether peer is connected to the management service or not Connected bool // LoginExpired LoginExpired bool // RequiresApproval indicates whether peer requires approval or not RequiresApproval bool } // Location is a geo location information of a Peer based on public connection IP type Location struct { ConnectionIP net.IP `gorm:"serializer:json"` // from grpc peer or reverse proxy headers depends on setup CountryCode string CityName string GeoNameID uint // city level geoname id } // NetworkAddress is the IP address with network and MAC address of a network interface type NetworkAddress struct { NetIP netip.Prefix `gorm:"serializer:json"` Mac string } // Environment is a system environment information type Environment struct { Cloud string Platform string } // File is a file on the system. type File struct { Path string Exist bool ProcessIsRunning bool } // Flags defines a set of options to control feature behavior type Flags struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed bool DisableClientRoutes bool DisableServerRoutes bool DisableDNS bool DisableFirewall bool BlockLANAccess bool BlockInbound bool DisableIPv6 bool LazyConnectionEnabled bool } // PeerSystemMeta is a metadata of a Peer machine system type PeerSystemMeta struct { //nolint:revive Hostname string GoOS string Kernel string Core string Platform string OS string OSVersion string WtVersion string UIVersion string KernelVersion string NetworkAddresses []NetworkAddress `gorm:"serializer:json"` SystemSerialNumber string SystemProductName string SystemManufacturer string Environment Environment `gorm:"serializer:json"` Flags Flags `gorm:"serializer:json"` Files []File `gorm:"serializer:json"` Capabilities []int32 `gorm:"serializer:json"` } func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool { return len(metaDiff(p, other)) == 0 } func (p PeerSystemMeta) isEmpty() bool { return p.Hostname == "" && p.GoOS == "" && p.Kernel == "" && p.Core == "" && p.Platform == "" && p.OS == "" && p.OSVersion == "" && p.WtVersion == "" && p.UIVersion == "" && p.KernelVersion == "" && len(p.NetworkAddresses) == 0 && p.SystemSerialNumber == "" && p.SystemProductName == "" && p.SystemManufacturer == "" && p.Environment.Cloud == "" && p.Environment.Platform == "" && len(p.Files) == 0 } // AddedWithSSOLogin indicates whether this peer has been added with an SSO login by a user. func (p *Peer) AddedWithSSOLogin() bool { return p.UserID != "" } // HasCapability reports whether the peer has the given capability. func (p *Peer) HasCapability(capability int32) bool { return slices.Contains(p.Meta.Capabilities, capability) } // SupportsIPv6 reports whether the peer supports IPv6 overlay. func (p *Peer) SupportsIPv6() bool { return !p.Meta.Flags.DisableIPv6 && p.HasCapability(PeerCapabilityIPv6Overlay) } // SupportsSourcePrefixes reports whether the peer reads SourcePrefixes. func (p *Peer) SupportsSourcePrefixes() bool { return p.HasCapability(PeerCapabilitySourcePrefixes) } func (a PeerSystemMeta) CapabilitiesEqual(b []int32) bool { if len(a.Capabilities) != len(b) { return false } set := make(map[int32]struct{}, len(a.Capabilities)) for _, c := range a.Capabilities { set[c] = struct{}{} } for _, c := range b { if _, ok := set[c]; !ok { return false } } return true } // Copy copies Peer object func (p *Peer) Copy() *Peer { peerStatus := p.Status if peerStatus != nil { peerStatus = p.Status.Copy() } return &Peer{ ID: p.ID, AccountID: p.AccountID, Key: p.Key, IP: p.IP, IPv6: p.IPv6, Meta: p.Meta, Name: p.Name, DNSLabel: p.DNSLabel, Status: peerStatus, UserID: p.UserID, SSHKey: p.SSHKey, SSHEnabled: p.SSHEnabled, LoginExpirationEnabled: p.LoginExpirationEnabled, LastLogin: p.LastLogin, CreatedAt: p.CreatedAt, Ephemeral: p.Ephemeral, ProxyMeta: p.ProxyMeta, Location: p.Location, InactivityExpirationEnabled: p.InactivityExpirationEnabled, ExtraDNSLabels: slices.Clone(p.ExtraDNSLabels), AllowExtraDNSLabels: p.AllowExtraDNSLabels, } } // UpdateMetaIfNew updates peer's system metadata if new information is provided // returns true if meta was updated, false otherwise func (p *Peer) UpdateMetaIfNew(meta PeerSystemMeta) (updated, versionChanged bool) { if meta.isEmpty() { return updated, versionChanged } versionChanged = p.Meta.WtVersion != meta.WtVersion // Avoid overwriting UIVersion if the update was triggered sole by the CLI client if meta.UIVersion == "" { meta.UIVersion = p.Meta.UIVersion } oldVersion := p.Meta.WtVersion diff := metaDiff(p.Meta, meta) if len(diff) != 0 { p.Meta = meta updated = true } versionInfo := "" if versionChanged { versionInfo = fmt.Sprintf("version changed: %s -> %s, ", oldVersion, meta.WtVersion) } if versionChanged || updated { log.WithFields(log.Fields{"peer": p.ID, "key": p.Key}). Infof("peer meta updated, %s%d field(s) changed: %s", versionInfo, len(diff), strings.Join(diff, ", ")) } return updated, versionChanged } // metaDiff returns a human-readable list of the fields that differ between the // old and new meta, each formatted as `field: -> `. It is the single // source of truth for meta comparison: isEqual reports equality as an empty // diff, so the log line can never disagree with the change decision. Slices are // cloned before sorting, so callers' meta is not mutated. func metaDiff(old, new PeerSystemMeta) []string { var diff []string add := func(field string, oldVal, newVal any) { diff = append(diff, fmt.Sprintf("%s: %v -> %v", field, oldVal, newVal)) } if old.Hostname != new.Hostname { add("hostname", old.Hostname, new.Hostname) } if old.GoOS != new.GoOS { add("goos", old.GoOS, new.GoOS) } if old.Kernel != new.Kernel { add("kernel", old.Kernel, new.Kernel) } if old.KernelVersion != new.KernelVersion { add("kernel_version", old.KernelVersion, new.KernelVersion) } if old.Core != new.Core { add("core", old.Core, new.Core) } if old.Platform != new.Platform { add("platform", old.Platform, new.Platform) } if old.OS != new.OS { add("os", old.OS, new.OS) } if old.OSVersion != new.OSVersion { add("os_version", old.OSVersion, new.OSVersion) } if old.WtVersion != new.WtVersion { add("wt_version", old.WtVersion, new.WtVersion) } if old.UIVersion != new.UIVersion { add("ui_version", old.UIVersion, new.UIVersion) } if old.SystemSerialNumber != new.SystemSerialNumber { add("system_serial_number", old.SystemSerialNumber, new.SystemSerialNumber) } if old.SystemProductName != new.SystemProductName { add("system_product_name", old.SystemProductName, new.SystemProductName) } if old.SystemManufacturer != new.SystemManufacturer { add("system_manufacturer", old.SystemManufacturer, new.SystemManufacturer) } if old.Environment.Cloud != new.Environment.Cloud { add("environment_cloud", old.Environment.Cloud, new.Environment.Cloud) } if old.Environment.Platform != new.Environment.Platform { add("environment_platform", old.Environment.Platform, new.Environment.Platform) } if !old.Flags.isEqual(new.Flags) { add("flags", fmt.Sprintf("%+v", old.Flags), fmt.Sprintf("%+v", new.Flags)) } if !old.CapabilitiesEqual(new.Capabilities) { add("capabilities", old.Capabilities, new.Capabilities) } oldAddrs := slices.Clone(old.NetworkAddresses) newAddrs := slices.Clone(new.NetworkAddresses) sort.Slice(oldAddrs, func(i, j int) bool { return oldAddrs[i].Mac < oldAddrs[j].Mac }) sort.Slice(newAddrs, func(i, j int) bool { return newAddrs[i].Mac < newAddrs[j].Mac }) if !slices.EqualFunc(oldAddrs, newAddrs, func(a, b NetworkAddress) bool { return a.Mac == b.Mac && a.NetIP == b.NetIP }) { add("network_addresses", fmt.Sprintf("%v", oldAddrs), fmt.Sprintf("%v", newAddrs)) } oldFiles := slices.Clone(old.Files) newFiles := slices.Clone(new.Files) sort.Slice(oldFiles, func(i, j int) bool { return oldFiles[i].Path < oldFiles[j].Path }) sort.Slice(newFiles, func(i, j int) bool { return newFiles[i].Path < newFiles[j].Path }) if !slices.EqualFunc(oldFiles, newFiles, func(a, b File) bool { return a.Path == b.Path && a.Exist == b.Exist && a.ProcessIsRunning == b.ProcessIsRunning }) { add("files", fmt.Sprintf("%v", oldFiles), fmt.Sprintf("%v", newFiles)) } return diff } // GetLastLogin returns the last login time of the peer. func (p *Peer) GetLastLogin() time.Time { if p.LastLogin != nil { return *p.LastLogin } return time.Time{} } // MarkLoginExpired marks peer's status expired or not func (p *Peer) MarkLoginExpired(expired bool) { newStatus := p.Status.Copy() newStatus.LoginExpired = expired if expired { newStatus.Connected = false } p.Status = newStatus } // SessionExpired indicates whether the peer's session has expired or not. // If Peer.LastLogin plus the expiresIn duration has happened already; then session has expired. // Return true if a session has expired, false otherwise, and time left to expiration (negative when expired). // Session expiration can be disabled/enabled on a Peer level via Peer.LoginExpirationEnabled property. // Session expiration can also be disabled/enabled globally on the Account level via Settings.PeerLoginExpirationEnabled. // Only peers added by interactive SSO login can be expired. func (p *Peer) SessionExpired(expiresIn time.Duration) (bool, time.Duration) { if !p.AddedWithSSOLogin() || !p.InactivityExpirationEnabled || p.Status.Connected { return false, 0 } expiresAt := p.Status.LastSeen.Add(expiresIn) now := time.Now() timeLeft := expiresAt.Sub(now) return timeLeft <= 0, timeLeft } // LoginExpired indicates whether the peer's login has expired or not. // If Peer.LastLogin plus the expiresIn duration has happened already; then login has expired. // Return true if a login has expired, false otherwise, and time left to expiration (negative when expired). // Login expiration can be disabled/enabled on a Peer level via Peer.LoginExpirationEnabled property. // Login expiration can also be disabled/enabled globally on the Account level via Settings.PeerLoginExpirationEnabled. // Only peers added by interactive SSO login can be expired. func (p *Peer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) { if !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled { return false, 0 } expiresAt := p.GetLastLogin().Add(expiresIn) now := time.Now() timeLeft := expiresAt.Sub(now) return timeLeft <= 0, timeLeft } // SessionExpiresAt returns the absolute UTC instant at which the peer's SSO // session expires, derived from LastLogin and the account-level // PeerLoginExpiration setting. Returns the zero value when login expiration // does not apply (peer not SSO-registered, peer-level toggle off, or account // expiry disabled). Callers should treat the zero value as "no deadline". func (p *Peer) SessionExpiresAt(accountExpirationEnabled bool, expiresIn time.Duration) time.Time { if !accountExpirationEnabled || !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled { return time.Time{} } last := p.GetLastLogin() if last.IsZero() { return time.Time{} } return last.Add(expiresIn).UTC() } // FQDN returns peers FQDN combined of the peer's DNS label and the system's DNS domain func (p *Peer) FQDN(dnsDomain string) string { if dnsDomain == "" { return "" } return p.DNSLabel + "." + dnsDomain } // EventMeta returns activity event meta related to the peer func (p *Peer) EventMeta(dnsDomain string) map[string]any { meta := map[string]any{"name": p.Name, "fqdn": p.FQDN(dnsDomain), "ip": p.IP, "created_at": p.CreatedAt, "location_city_name": p.Location.CityName, "location_country_code": p.Location.CountryCode, "location_geo_name_id": p.Location.GeoNameID, "location_connection_ip": p.Location.ConnectionIP} if p.IPv6.IsValid() { meta["ipv6"] = p.IPv6.String() } return meta } // Copy PeerStatus. SessionStartedAt must be propagated so clone-based // callers (Peer.Copy, MarkLoginExpired, UpdateLastLogin) don't silently // reset the fencing token to zero — that would let any subsequent // SavePeerStatus write reopen the optimistic-lock window. func (p *PeerStatus) Copy() *PeerStatus { return &PeerStatus{ LastSeen: p.LastSeen, SessionStartedAt: p.SessionStartedAt, Connected: p.Connected, LoginExpired: p.LoginExpired, RequiresApproval: p.RequiresApproval, } } // UpdateLastLogin and set login expired false func (p *Peer) UpdateLastLogin() *Peer { p.LastLogin = util.ToPtr(time.Now().UTC()) newStatus := p.Status.Copy() newStatus.LoginExpired = false p.Status = newStatus return p } func (p *Peer) FromAPITemporaryAccessRequest(a *api.PeerTemporaryAccessRequest) { p.Ephemeral = true p.Name = a.Name p.Key = a.WgPubKey p.Meta = PeerSystemMeta{ Hostname: a.Name, GoOS: "js", OS: "js", KernelVersion: "wasm", } } func (f Flags) isEqual(other Flags) bool { return f.RosenpassEnabled == other.RosenpassEnabled && f.RosenpassPermissive == other.RosenpassPermissive && f.ServerSSHAllowed == other.ServerSSHAllowed && f.DisableClientRoutes == other.DisableClientRoutes && f.DisableServerRoutes == other.DisableServerRoutes && f.DisableDNS == other.DisableDNS && f.DisableFirewall == other.DisableFirewall && f.BlockLANAccess == other.BlockLANAccess && f.BlockInbound == other.BlockInbound && f.LazyConnectionEnabled == other.LazyConnectionEnabled && f.DisableIPv6 == other.DisableIPv6 }