From a0b9a8bd87d130d86d5f882b1b9deafa991a76fb Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Thu, 22 Feb 2024 19:22:43 +0300 Subject: [PATCH] Add private network posture check (#1606) * wip: Add PrivateNetworkCheck checks interface implementation * use generic CheckAction constant * Add private network check to posture checks * Fix copy function target in posture checks * Add network check functionality to posture package * regenerate the openapi specs * Update Posture Check actions in test file * Remove unused function * Refactor network address handling in PrivateNetworkCheck * Refactor Prefixes to Ranges in private network checks * Implement private network checks in posture checks handler tests * Add test for check copy * Add gorm serializer for network range --- management/server/http/api/openapi.yml | 20 +++ management/server/http/api/types.gen.go | 80 +++++++--- management/server/http/peers_handler.go | 41 ++--- .../server/http/posture_checks_handler.go | 56 ++++++- .../http/posture_checks_handler_test.go | 129 ++++++++++++++- management/server/posture/checks.go | 31 +++- management/server/posture/checks_test.go | 60 +++++++ management/server/posture/geo_location.go | 13 +- .../server/posture/geo_location_test.go | 20 +-- management/server/posture/network.go | 54 +++++++ management/server/posture/network_test.go | 149 ++++++++++++++++++ 11 files changed, 572 insertions(+), 81 deletions(-) create mode 100644 management/server/posture/network.go create mode 100644 management/server/posture/network_test.go diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml index c5fa463a53a..5c05c15ad9b 100644 --- a/management/server/http/api/openapi.yml +++ b/management/server/http/api/openapi.yml @@ -862,6 +862,8 @@ components: $ref: '#/components/schemas/OSVersionCheck' geo_location_check: $ref: '#/components/schemas/GeoLocationCheck' + private_network_check: + $ref: '#/components/schemas/PrivateNetworkCheck' NBVersionCheck: description: Posture check for the version of NetBird type: object @@ -932,6 +934,24 @@ components: required: - locations - action + PrivateNetworkCheck: + description: Posture check for allow or deny private network + type: object + properties: + ranges: + description: List of private network ranges in CIDR notation + type: array + items: + type: string + example: ["192.168.1.0/24", "10.0.0.0/8"] + action: + description: Action to take upon policy match + type: string + enum: [ "allow", "deny" ] + example: "allow" + required: + - ranges + - action Location: description: Describe geographical location information type: object diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go index 87e6b9be325..28fe63c9db8 100644 --- a/management/server/http/api/types.gen.go +++ b/management/server/http/api/types.gen.go @@ -116,6 +116,12 @@ const ( PolicyRuleUpdateProtocolUdp PolicyRuleUpdateProtocol = "udp" ) +// Defines values for PrivateNetworkCheckAction. +const ( + PrivateNetworkCheckActionAllow PrivateNetworkCheckAction = "allow" + PrivateNetworkCheckActionDeny PrivateNetworkCheckAction = "deny" +) + // Defines values for UserStatus. const ( UserStatusActive UserStatus = "active" @@ -186,10 +192,15 @@ type AccountSettings struct { type Checks struct { // GeoLocationCheck Posture check for geo location GeoLocationCheck *GeoLocationCheck `json:"geo_location_check,omitempty"` - NbVersionCheck *NBVersionCheck `json:"nb_version_check,omitempty"` + + // NbVersionCheck Posture check for the version of operating system + NbVersionCheck *NBVersionCheck `json:"nb_version_check,omitempty"` // OsVersionCheck Posture check for the version of operating system OsVersionCheck *OSVersionCheck `json:"os_version_check,omitempty"` + + // PrivateNetworkCheck Posture check for allow or deny private network + PrivateNetworkCheck *PrivateNetworkCheck `json:"private_network_check,omitempty"` } // City Describe city geographical location information @@ -324,13 +335,13 @@ type MinKernelVersionCheck struct { MinKernelVersion string `json:"min_kernel_version"` } -// MinVersionCheck defines model for MinVersionCheck. +// MinVersionCheck Posture check for the version of operating system type MinVersionCheck struct { // MinVersion Minimum acceptable version MinVersion string `json:"min_version"` } -// NBVersionCheck defines model for NBVersionCheck. +// NBVersionCheck Posture check for the version of operating system type NBVersionCheck = MinVersionCheck // Nameserver defines model for Nameserver. @@ -407,9 +418,14 @@ type NameserverGroupRequest struct { // OSVersionCheck Posture check for the version of operating system type OSVersionCheck struct { + // Android Posture check for the version of operating system Android *MinVersionCheck `json:"android,omitempty"` - Darwin *MinVersionCheck `json:"darwin,omitempty"` - Ios *MinVersionCheck `json:"ios,omitempty"` + + // Darwin Posture check for the version of operating system + Darwin *MinVersionCheck `json:"darwin,omitempty"` + + // Ios Posture check for the version of operating system + Ios *MinVersionCheck `json:"ios,omitempty"` // Linux Posture check with the kernel version Linux *MinKernelVersionCheck `json:"linux,omitempty"` @@ -427,22 +443,22 @@ type Peer struct { ApprovalRequired *bool `json:"approval_required,omitempty"` // CityName Commonly used English name of the city - CityName *CityName `json:"city_name,omitempty"` + CityName CityName `json:"city_name"` // Connected Peer to Management connection status Connected bool `json:"connected"` // ConnectionIp Peer's public connection IP address - ConnectionIp *string `json:"connection_ip,omitempty"` + ConnectionIp string `json:"connection_ip"` // CountryCode 2-letter ISO 3166-1 alpha-2 code that represents the country - CountryCode *CountryCode `json:"country_code,omitempty"` + CountryCode CountryCode `json:"country_code"` // DnsLabel Peer's DNS label 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 `json:"dns_label"` // GeonameId Unique identifier from the GeoNames database for a specific geographical location. - GeonameId *int `json:"geoname_id,omitempty"` + GeonameId int `json:"geoname_id"` // Groups Groups that the peer belongs to Groups []GroupMinimum `json:"groups"` @@ -457,7 +473,7 @@ type Peer struct { Ip string `json:"ip"` // KernelVersion Peer's operating system kernel version - KernelVersion *string `json:"kernel_version,omitempty"` + KernelVersion string `json:"kernel_version"` // LastLogin Last time this peer performed log in (authentication). E.g., user authenticated. LastLogin time.Time `json:"last_login"` @@ -481,10 +497,10 @@ type Peer struct { SshEnabled bool `json:"ssh_enabled"` // UiVersion Peer's desktop UI version - UiVersion *string `json:"ui_version,omitempty"` + UiVersion string `json:"ui_version"` // UserId User ID of the user that enrolled this peer - UserId *string `json:"user_id,omitempty"` + UserId string `json:"user_id"` // Version Peer's daemon or cli version Version string `json:"version"` @@ -496,22 +512,22 @@ type PeerBase struct { ApprovalRequired *bool `json:"approval_required,omitempty"` // CityName Commonly used English name of the city - CityName *CityName `json:"city_name,omitempty"` + CityName CityName `json:"city_name"` // Connected Peer to Management connection status Connected bool `json:"connected"` // ConnectionIp Peer's public connection IP address - ConnectionIp *string `json:"connection_ip,omitempty"` + ConnectionIp string `json:"connection_ip"` // CountryCode 2-letter ISO 3166-1 alpha-2 code that represents the country - CountryCode *CountryCode `json:"country_code,omitempty"` + CountryCode CountryCode `json:"country_code"` // DnsLabel Peer's DNS label 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 `json:"dns_label"` // GeonameId Unique identifier from the GeoNames database for a specific geographical location. - GeonameId *int `json:"geoname_id,omitempty"` + GeonameId int `json:"geoname_id"` // Groups Groups that the peer belongs to Groups []GroupMinimum `json:"groups"` @@ -526,7 +542,7 @@ type PeerBase struct { Ip string `json:"ip"` // KernelVersion Peer's operating system kernel version - KernelVersion *string `json:"kernel_version,omitempty"` + KernelVersion string `json:"kernel_version"` // LastLogin Last time this peer performed log in (authentication). E.g., user authenticated. LastLogin time.Time `json:"last_login"` @@ -550,10 +566,10 @@ type PeerBase struct { SshEnabled bool `json:"ssh_enabled"` // UiVersion Peer's desktop UI version - UiVersion *string `json:"ui_version,omitempty"` + UiVersion string `json:"ui_version"` // UserId User ID of the user that enrolled this peer - UserId *string `json:"user_id,omitempty"` + UserId string `json:"user_id"` // Version Peer's daemon or cli version Version string `json:"version"` @@ -568,22 +584,22 @@ type PeerBatch struct { ApprovalRequired *bool `json:"approval_required,omitempty"` // CityName Commonly used English name of the city - CityName *CityName `json:"city_name,omitempty"` + CityName CityName `json:"city_name"` // Connected Peer to Management connection status Connected bool `json:"connected"` // ConnectionIp Peer's public connection IP address - ConnectionIp *string `json:"connection_ip,omitempty"` + ConnectionIp string `json:"connection_ip"` // CountryCode 2-letter ISO 3166-1 alpha-2 code that represents the country - CountryCode *CountryCode `json:"country_code,omitempty"` + CountryCode CountryCode `json:"country_code"` // DnsLabel Peer's DNS label 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 `json:"dns_label"` // GeonameId Unique identifier from the GeoNames database for a specific geographical location. - GeonameId *int `json:"geoname_id,omitempty"` + GeonameId int `json:"geoname_id"` // Groups Groups that the peer belongs to Groups []GroupMinimum `json:"groups"` @@ -598,7 +614,7 @@ type PeerBatch struct { Ip string `json:"ip"` // KernelVersion Peer's operating system kernel version - KernelVersion *string `json:"kernel_version,omitempty"` + KernelVersion string `json:"kernel_version"` // LastLogin Last time this peer performed log in (authentication). E.g., user authenticated. LastLogin time.Time `json:"last_login"` @@ -622,10 +638,10 @@ type PeerBatch struct { SshEnabled bool `json:"ssh_enabled"` // UiVersion Peer's desktop UI version - UiVersion *string `json:"ui_version,omitempty"` + UiVersion string `json:"ui_version"` // UserId User ID of the user that enrolled this peer - UserId *string `json:"user_id,omitempty"` + UserId string `json:"user_id"` // Version Peer's daemon or cli version Version string `json:"version"` @@ -882,6 +898,18 @@ type PostureCheckUpdate struct { Name string `json:"name"` } +// PrivateNetworkCheck Posture check for allow or deny private network +type PrivateNetworkCheck struct { + // Action Action to take upon policy match + Action PrivateNetworkCheckAction `json:"action"` + + // Ranges List of private network ranges in CIDR notation + Ranges []string `json:"ranges"` +} + +// PrivateNetworkCheckAction Action to take upon policy match +type PrivateNetworkCheckAction string + // Route defines model for Route. type Route struct { // Description Route description diff --git a/management/server/http/peers_handler.go b/management/server/http/peers_handler.go index e44b164b777..d4d2558e88a 100644 --- a/management/server/http/peers_handler.go +++ b/management/server/http/peers_handler.go @@ -3,7 +3,6 @@ package http import ( "encoding/json" "fmt" - "net" "net/http" "github.com/gorilla/mux" @@ -231,44 +230,36 @@ func toGroupsInfo(groups map[string]*server.Group, peerID string) []api.GroupMin return groupsInfo } -func connectionIPoString(ip net.IP) *string { - publicIP := "" - if ip != nil { - publicIP = ip.String() - } - return &publicIP -} - func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsDomain string, accessiblePeer []api.AccessiblePeer) *api.Peer { osVersion := peer.Meta.OSVersion if osVersion == "" { osVersion = peer.Meta.Core } - geonameID := int(peer.Location.GeoNameID) + return &api.Peer{ Id: peer.ID, Name: peer.Name, Ip: peer.IP.String(), - ConnectionIp: connectionIPoString(peer.Location.ConnectionIP), + ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion), - KernelVersion: &peer.Meta.KernelVersion, - GeonameId: &geonameID, + KernelVersion: peer.Meta.KernelVersion, + GeonameId: int(peer.Location.GeoNameID), Version: peer.Meta.WtVersion, Groups: groupsInfo, SshEnabled: peer.SSHEnabled, Hostname: peer.Meta.Hostname, - UserId: &peer.UserID, - UiVersion: &peer.Meta.UIVersion, + UserId: peer.UserID, + UiVersion: peer.Meta.UIVersion, DnsLabel: fqdn(peer, dnsDomain), LoginExpirationEnabled: peer.LoginExpirationEnabled, LastLogin: peer.LastLogin, LoginExpired: peer.Status.LoginExpired, AccessiblePeers: accessiblePeer, ApprovalRequired: &peer.Status.RequiresApproval, - CountryCode: &peer.Location.CountryCode, - CityName: &peer.Location.CityName, + CountryCode: peer.Location.CountryCode, + CityName: peer.Location.CityName, } } @@ -277,31 +268,31 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn if osVersion == "" { osVersion = peer.Meta.Core } - geonameID := int(peer.Location.GeoNameID) + return &api.PeerBatch{ Id: peer.ID, Name: peer.Name, Ip: peer.IP.String(), - ConnectionIp: connectionIPoString(peer.Location.ConnectionIP), + ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion), - KernelVersion: &peer.Meta.KernelVersion, - GeonameId: &geonameID, + KernelVersion: peer.Meta.KernelVersion, + GeonameId: int(peer.Location.GeoNameID), Version: peer.Meta.WtVersion, Groups: groupsInfo, SshEnabled: peer.SSHEnabled, Hostname: peer.Meta.Hostname, - UserId: &peer.UserID, - UiVersion: &peer.Meta.UIVersion, + UserId: peer.UserID, + UiVersion: peer.Meta.UIVersion, DnsLabel: fqdn(peer, dnsDomain), LoginExpirationEnabled: peer.LoginExpirationEnabled, LastLogin: peer.LastLogin, LoginExpired: peer.Status.LoginExpired, AccessiblePeersCount: accessiblePeersCount, ApprovalRequired: &peer.Status.RequiresApproval, - CountryCode: &peer.Location.CountryCode, - CityName: &peer.Location.CityName, + CountryCode: peer.Location.CountryCode, + CityName: peer.Location.CityName, } } diff --git a/management/server/http/posture_checks_handler.go b/management/server/http/posture_checks_handler.go index 2f27e2579c0..581bba2b70e 100644 --- a/management/server/http/posture_checks_handler.go +++ b/management/server/http/posture_checks_handler.go @@ -3,6 +3,7 @@ package http import ( "encoding/json" "net/http" + "net/netip" "regexp" "slices" @@ -212,6 +213,14 @@ func (p *PostureChecksHandler) savePostureChecks( postureChecks.Checks.GeoLocationCheck = toPostureGeoLocationCheck(geoLocationCheck) } + if privateNetworkCheck := req.Checks.PrivateNetworkCheck; privateNetworkCheck != nil { + postureChecks.Checks.PrivateNetworkCheck, err = toPrivateNetworkCheck(privateNetworkCheck) + if err != nil { + util.WriteError(status.Errorf(status.InvalidArgument, "invalid network prefix"), w) + return + } + } + if err := p.accountManager.SavePostureChecks(account.Id, user.Id, &postureChecks); err != nil { util.WriteError(err, w) return @@ -226,7 +235,7 @@ func validatePostureChecksUpdate(req api.PostureCheckUpdate) error { } if req.Checks == nil || (req.Checks.NbVersionCheck == nil && req.Checks.OsVersionCheck == nil && - req.Checks.GeoLocationCheck == nil) { + req.Checks.GeoLocationCheck == nil && req.Checks.PrivateNetworkCheck == nil) { return status.Errorf(status.InvalidArgument, "posture checks shouldn't be empty") } @@ -267,7 +276,20 @@ func validatePostureChecksUpdate(req api.PostureCheckUpdate) error { return status.Errorf(status.InvalidArgument, "country code must be 2 letters (ISO 3166-1 alpha-2 format)") } } + } + if privateNetworkCheck := req.Checks.PrivateNetworkCheck; privateNetworkCheck != nil { + if privateNetworkCheck.Action == "" { + return status.Errorf(status.InvalidArgument, "action for private network check shouldn't be empty") + } + + allowedActions := []api.PrivateNetworkCheckAction{api.PrivateNetworkCheckActionAllow, api.PrivateNetworkCheckActionDeny} + if !slices.Contains(allowedActions, privateNetworkCheck.Action) { + return status.Errorf(status.InvalidArgument, "action for private network check is not valid value") + } + if len(privateNetworkCheck.Ranges) == 0 { + return status.Errorf(status.InvalidArgument, "network ranges for private network check shouldn't be empty") + } } return nil @@ -296,6 +318,10 @@ func toPostureChecksResponse(postureChecks *posture.Checks) *api.PostureCheck { checks.GeoLocationCheck = toGeoLocationCheckResponse(postureChecks.Checks.GeoLocationCheck) } + if postureChecks.Checks.PrivateNetworkCheck != nil { + checks.PrivateNetworkCheck = toPrivateNetworkCheckResponse(postureChecks.Checks.PrivateNetworkCheck) + } + return &api.PostureCheck{ Id: postureChecks.ID, Name: postureChecks.Name, @@ -342,3 +368,31 @@ func toPostureGeoLocationCheck(apiGeoLocationCheck *api.GeoLocationCheck) *postu Locations: locations, } } + +func toPrivateNetworkCheckResponse(check *posture.PrivateNetworkCheck) *api.PrivateNetworkCheck { + netPrefixes := make([]string, 0, len(check.Ranges)) + for _, netPrefix := range check.Ranges { + netPrefixes = append(netPrefixes, netPrefix.String()) + } + + return &api.PrivateNetworkCheck{ + Ranges: netPrefixes, + Action: api.PrivateNetworkCheckAction(check.Action), + } +} + +func toPrivateNetworkCheck(check *api.PrivateNetworkCheck) (*posture.PrivateNetworkCheck, error) { + prefixes := make([]netip.Prefix, 0) + for _, prefix := range check.Ranges { + parsedPrefix, err := netip.ParsePrefix(prefix) + if err != nil { + return nil, err + } + prefixes = append(prefixes, parsedPrefix) + } + + return &posture.PrivateNetworkCheck{ + Ranges: prefixes, + Action: string(check.Action), + }, nil +} diff --git a/management/server/http/posture_checks_handler_test.go b/management/server/http/posture_checks_handler_test.go index ac757252b6b..24a28f3ecb8 100644 --- a/management/server/http/posture_checks_handler_test.go +++ b/management/server/http/posture_checks_handler_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/netip" "strings" "testing" @@ -122,7 +123,19 @@ func TestGetPostureCheck(t *testing.T) { CityName: "Berlin", }, }, - Action: posture.GeoLocationActionAllow, + Action: posture.CheckActionAllow, + }, + }, + } + privateNetworkCheck := &posture.Checks{ + ID: "privateNetworkPostureCheck", + Name: "privateNetwork", + Checks: posture.ChecksDefinition{ + PrivateNetworkCheck: &posture.PrivateNetworkCheck{ + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/24"), + }, + Action: posture.CheckActionAllow, }, }, } @@ -156,6 +169,13 @@ func TestGetPostureCheck(t *testing.T) { checkName: geoPostureCheck.Name, expectedStatus: http.StatusOK, }, + { + name: "GetPostureCheck PrivateNetwork OK", + expectedBody: true, + id: privateNetworkCheck.ID, + checkName: privateNetworkCheck.Name, + expectedStatus: http.StatusOK, + }, { name: "GetPostureCheck Not Found", id: "not-exists", @@ -163,7 +183,7 @@ func TestGetPostureCheck(t *testing.T) { }, } - p := initPostureChecksTestData(postureCheck, osPostureCheck, geoPostureCheck) + p := initPostureChecksTestData(postureCheck, osPostureCheck, geoPostureCheck, privateNetworkCheck) for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { @@ -354,6 +374,39 @@ func TestPostureCheckUpdate(t *testing.T) { }, }, }, + { + name: "Create Posture Checks Private Network", + requestType: http.MethodPost, + requestPath: "/api/posture-checks", + requestBody: bytes.NewBuffer( + []byte(`{ + "name": "default", + "description": "default", + "checks": { + "private_network_check": { + "action": "allow", + "ranges": [ + "10.0.0.0/8" + ] + } + } + }`)), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedPostureCheck: &api.PostureCheck{ + Id: "postureCheck", + Name: "default", + Description: str("default"), + Checks: api.Checks{ + PrivateNetworkCheck: &api.PrivateNetworkCheck{ + Ranges: []string{ + "10.0.0.0/8", + }, + Action: api.PrivateNetworkCheckActionAllow, + }, + }, + }, + }, { name: "Create Posture Checks Geo Location with No geolocation DB", requestType: http.MethodPost, @@ -661,6 +714,38 @@ func TestPostureCheckUpdate(t *testing.T) { expectedStatus: http.StatusBadRequest, expectedBody: false, }, + { + name: "Update Posture Checks Private Network", + requestType: http.MethodPut, + requestPath: "/api/posture-checks/privateNetworkPostureCheck", + requestBody: bytes.NewBuffer( + []byte(`{ + "name": "default", + "checks": { + "private_network_check": { + "action": "deny", + "ranges": [ + "192.168.1.0/24" + ] + } + } + }`)), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedPostureCheck: &api.PostureCheck{ + Id: "postureCheck", + Name: "default", + Description: str(""), + Checks: api.Checks{ + PrivateNetworkCheck: &api.PrivateNetworkCheck{ + Ranges: []string{ + "192.168.1.0/24", + }, + Action: api.PrivateNetworkCheckActionDeny, + }, + }, + }, + }, } p := initPostureChecksTestData(&posture.Checks{ @@ -694,7 +779,19 @@ func TestPostureCheckUpdate(t *testing.T) { CityName: "Berlin", }, }, - Action: posture.GeoLocationActionDeny, + Action: posture.CheckActionDeny, + }, + }, + }, + &posture.Checks{ + ID: "privateNetworkPostureCheck", + Name: "privateNetwork", + Checks: posture.ChecksDefinition{ + PrivateNetworkCheck: &posture.PrivateNetworkCheck{ + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/24"), + }, + Action: posture.CheckActionAllow, }, }, }, @@ -793,4 +890,30 @@ func TestPostureCheck_validatePostureChecksUpdate(t *testing.T) { } err = validatePostureChecksUpdate(api.PostureCheckUpdate{Name: "Default", Checks: &api.Checks{OsVersionCheck: &osVersionCheck}}) assert.NoError(t, err) + + // valid private network check + privateNetworkCheck := api.PrivateNetworkCheck{ + Action: api.PrivateNetworkCheckActionAllow, + Ranges: []string{ + "192.168.1.0/24", "10.0.0.0/8", + }, + } + err = validatePostureChecksUpdate(api.PostureCheckUpdate{Name: "Default", Checks: &api.Checks{PrivateNetworkCheck: &privateNetworkCheck}}) + assert.NoError(t, err) + + // invalid private network check + privateNetworkCheck = api.PrivateNetworkCheck{ + Action: api.PrivateNetworkCheckActionDeny, + Ranges: []string{}, + } + err = validatePostureChecksUpdate(api.PostureCheckUpdate{Name: "Default", Checks: &api.Checks{PrivateNetworkCheck: &privateNetworkCheck}}) + assert.Error(t, err) + + // invalid private network check + privateNetworkCheck = api.PrivateNetworkCheck{ + Action: "unknownAction", + Ranges: []string{}, + } + err = validatePostureChecksUpdate(api.PostureCheckUpdate{Name: "Default", Checks: &api.Checks{PrivateNetworkCheck: &privateNetworkCheck}}) + assert.Error(t, err) } diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go index bdbeaa79637..ee85be405bf 100644 --- a/management/server/posture/checks.go +++ b/management/server/posture/checks.go @@ -2,6 +2,7 @@ package posture import ( "fmt" + "net/netip" "github.com/hashicorp/go-version" @@ -9,9 +10,13 @@ import ( ) const ( - NBVersionCheckName = "NBVersionCheck" - OSVersionCheckName = "OSVersionCheck" - GeoLocationCheckName = "GeoLocationCheck" + NBVersionCheckName = "NBVersionCheck" + OSVersionCheckName = "OSVersionCheck" + GeoLocationCheckName = "GeoLocationCheck" + PrivateNetworkCheckName = "PrivateNetworkCheck" + + CheckActionAllow string = "allow" + CheckActionDeny string = "deny" ) // Check represents an interface for performing a check on a peer. @@ -39,9 +44,10 @@ type Checks struct { // ChecksDefinition contains definition of actual check type ChecksDefinition struct { - NBVersionCheck *NBVersionCheck `json:",omitempty"` - OSVersionCheck *OSVersionCheck `json:",omitempty"` - GeoLocationCheck *GeoLocationCheck `json:",omitempty"` + NBVersionCheck *NBVersionCheck `json:",omitempty"` + OSVersionCheck *OSVersionCheck `json:",omitempty"` + GeoLocationCheck *GeoLocationCheck `json:",omitempty"` + PrivateNetworkCheck *PrivateNetworkCheck `json:",omitempty"` } // Copy returns a copy of a checks definition. @@ -54,7 +60,7 @@ func (cd ChecksDefinition) Copy() ChecksDefinition { } if cd.OSVersionCheck != nil { cdCopy.OSVersionCheck = &OSVersionCheck{} - osCheck := cdCopy.OSVersionCheck + osCheck := cd.OSVersionCheck if osCheck.Android != nil { cdCopy.OSVersionCheck.Android = &MinVersionCheck{MinVersion: osCheck.Android.MinVersion} } @@ -79,6 +85,14 @@ func (cd ChecksDefinition) Copy() ChecksDefinition { } copy(cdCopy.GeoLocationCheck.Locations, geoCheck.Locations) } + if cd.PrivateNetworkCheck != nil { + privateNetCheck := cd.PrivateNetworkCheck + cdCopy.PrivateNetworkCheck = &PrivateNetworkCheck{ + Action: privateNetCheck.Action, + Ranges: make([]netip.Prefix, len(privateNetCheck.Ranges)), + } + copy(cdCopy.PrivateNetworkCheck.Ranges, privateNetCheck.Ranges) + } return cdCopy } @@ -116,6 +130,9 @@ func (pc *Checks) GetChecks() []Check { if pc.Checks.GeoLocationCheck != nil { checks = append(checks, pc.Checks.GeoLocationCheck) } + if pc.Checks.PrivateNetworkCheck != nil { + checks = append(checks, pc.Checks.PrivateNetworkCheck) + } return checks } diff --git a/management/server/posture/checks_test.go b/management/server/posture/checks_test.go index ef6eefeecfd..fc36e7f12f9 100644 --- a/management/server/posture/checks_test.go +++ b/management/server/posture/checks_test.go @@ -2,6 +2,7 @@ package posture import ( "encoding/json" + "net/netip" "testing" "github.com/stretchr/testify/assert" @@ -216,3 +217,62 @@ func TestChecks_Validate(t *testing.T) { }) } } + +func TestChecks_Copy(t *testing.T) { + check := &Checks{ + ID: "1", + Name: "default", + Description: "description", + AccountID: "accountID", + Checks: ChecksDefinition{ + NBVersionCheck: &NBVersionCheck{ + MinVersion: "0.25.0", + }, + OSVersionCheck: &OSVersionCheck{ + Android: &MinVersionCheck{ + MinVersion: "13", + }, + Darwin: &MinVersionCheck{ + MinVersion: "14.2.0", + }, + Ios: &MinVersionCheck{ + MinVersion: "17.3.0", + }, + Linux: &MinKernelVersionCheck{ + MinKernelVersion: "6.5.11-linuxkit", + }, + Windows: &MinKernelVersionCheck{ + MinKernelVersion: "10.0.14393", + }, + }, + GeoLocationCheck: &GeoLocationCheck{ + Locations: []Location{ + { + CountryCode: "DE", + CityName: "Berlin", + }, + }, + Action: CheckActionAllow, + }, + PrivateNetworkCheck: &PrivateNetworkCheck{ + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/24"), + netip.MustParsePrefix("10.0.0.0/8"), + }, + Action: CheckActionDeny, + }, + }, + } + checkCopy := check.Copy() + + assert.Equal(t, check.ID, checkCopy.ID) + assert.Equal(t, check.Name, checkCopy.Name) + assert.Equal(t, check.Description, checkCopy.Description) + assert.Equal(t, check.AccountID, checkCopy.AccountID) + assert.Equal(t, check.Checks.Copy(), checkCopy.Checks.Copy()) + assert.ElementsMatch(t, check.GetChecks(), checkCopy.GetChecks()) + + // Updating the original check should not take effect on copy + check.Name = "name" + assert.NotSame(t, check, checkCopy) +} diff --git a/management/server/posture/geo_location.go b/management/server/posture/geo_location.go index d23c643bc3b..856913a7a48 100644 --- a/management/server/posture/geo_location.go +++ b/management/server/posture/geo_location.go @@ -6,11 +6,6 @@ import ( nbpeer "github.com/netbirdio/netbird/management/server/peer" ) -const ( - GeoLocationActionAllow string = "allow" - GeoLocationActionDeny string = "deny" -) - type Location struct { // CountryCode 2-letter ISO 3166-1 alpha-2 code that represents the country CountryCode string @@ -39,9 +34,9 @@ func (g *GeoLocationCheck) Check(peer nbpeer.Peer) (bool, error) { if loc.CountryCode == peer.Location.CountryCode { if loc.CityName == "" || loc.CityName == peer.Location.CityName { switch g.Action { - case GeoLocationActionDeny: + case CheckActionDeny: return false, nil - case GeoLocationActionAllow: + case CheckActionAllow: return true, nil default: return false, fmt.Errorf("invalid geo location action: %s", g.Action) @@ -51,11 +46,11 @@ func (g *GeoLocationCheck) Check(peer nbpeer.Peer) (bool, error) { } // At this point, no location in the list matches the peer's location // For action deny and no location match, allow the peer - if g.Action == GeoLocationActionDeny { + if g.Action == CheckActionDeny { return true, nil } // For action allow and no location match, deny the peer - if g.Action == GeoLocationActionAllow { + if g.Action == CheckActionAllow { return false, nil } diff --git a/management/server/posture/geo_location_test.go b/management/server/posture/geo_location_test.go index 7a886a2827e..267bbe0f2ed 100644 --- a/management/server/posture/geo_location_test.go +++ b/management/server/posture/geo_location_test.go @@ -35,7 +35,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CityName: "Berlin", }, }, - Action: GeoLocationActionAllow, + Action: CheckActionAllow, }, wantErr: false, isValid: true, @@ -54,7 +54,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CountryCode: "DE", }, }, - Action: GeoLocationActionAllow, + Action: CheckActionAllow, }, wantErr: false, isValid: true, @@ -78,7 +78,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CityName: "Los Angeles", }, }, - Action: GeoLocationActionAllow, + Action: CheckActionAllow, }, wantErr: false, isValid: false, @@ -97,7 +97,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CountryCode: "US", }, }, - Action: GeoLocationActionAllow, + Action: CheckActionAllow, }, wantErr: false, isValid: false, @@ -121,7 +121,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CityName: "Los Angeles", }, }, - Action: GeoLocationActionDeny, + Action: CheckActionDeny, }, wantErr: false, isValid: false, @@ -143,7 +143,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CountryCode: "US", }, }, - Action: GeoLocationActionDeny, + Action: CheckActionDeny, }, wantErr: false, isValid: false, @@ -167,7 +167,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CityName: "Los Angeles", }, }, - Action: GeoLocationActionDeny, + Action: CheckActionDeny, }, wantErr: false, isValid: true, @@ -187,7 +187,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CityName: "Los Angeles", }, }, - Action: GeoLocationActionDeny, + Action: CheckActionDeny, }, wantErr: false, isValid: true, @@ -202,7 +202,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CityName: "Berlin", }, }, - Action: GeoLocationActionAllow, + Action: CheckActionAllow, }, wantErr: true, isValid: false, @@ -217,7 +217,7 @@ func TestGeoLocationCheck_Check(t *testing.T) { CityName: "Berlin", }, }, - Action: GeoLocationActionDeny, + Action: CheckActionDeny, }, wantErr: true, isValid: false, diff --git a/management/server/posture/network.go b/management/server/posture/network.go new file mode 100644 index 00000000000..8607d07aa40 --- /dev/null +++ b/management/server/posture/network.go @@ -0,0 +1,54 @@ +package posture + +import ( + "fmt" + "net/netip" + "slices" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +type PrivateNetworkCheck struct { + Action string + Ranges []netip.Prefix `gorm:"serializer:json"` +} + +var _ Check = (*PrivateNetworkCheck)(nil) + +func (p *PrivateNetworkCheck) Check(peer nbpeer.Peer) (bool, error) { + if len(peer.Meta.NetworkAddresses) == 0 { + return false, fmt.Errorf("peer's does not contain private network addresses") + } + + maskedPrefixes := make([]netip.Prefix, 0, len(p.Ranges)) + for _, prefix := range p.Ranges { + maskedPrefixes = append(maskedPrefixes, prefix.Masked()) + } + + for _, peerNetAddr := range peer.Meta.NetworkAddresses { + peerMaskedPrefix := peerNetAddr.NetIP.Masked() + if slices.Contains(maskedPrefixes, peerMaskedPrefix) { + switch p.Action { + case CheckActionDeny: + return false, nil + case CheckActionAllow: + return true, nil + default: + return false, fmt.Errorf("invalid private network check action: %s", p.Action) + } + } + } + + if p.Action == CheckActionDeny { + return true, nil + } + if p.Action == CheckActionAllow { + return false, nil + } + + return false, fmt.Errorf("invalid private network check action: %s", p.Action) +} + +func (p *PrivateNetworkCheck) Name() string { + return PrivateNetworkCheckName +} diff --git a/management/server/posture/network_test.go b/management/server/posture/network_test.go new file mode 100644 index 00000000000..0180054603d --- /dev/null +++ b/management/server/posture/network_test.go @@ -0,0 +1,149 @@ +package posture + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +func TestPrivateNetworkCheck_Check(t *testing.T) { + tests := []struct { + name string + check PrivateNetworkCheck + peer nbpeer.Peer + wantErr bool + isValid bool + }{ + { + name: "Peer private networks matches the allowed range", + check: PrivateNetworkCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/24"), + netip.MustParsePrefix("10.0.0.0/8"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + { + NetIP: netip.MustParsePrefix("192.168.0.123/24"), + }, + { + NetIP: netip.MustParsePrefix("fe80::6089:eaff:fe0c:232f/64"), + }, + }, + }, + }, + wantErr: false, + isValid: true, + }, + { + name: "Peer private networks doesn't matches the allowed range", + check: PrivateNetworkCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/24"), + netip.MustParsePrefix("10.0.0.0/8"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + { + NetIP: netip.MustParsePrefix("198.19.249.3/24"), + }, + }, + }, + }, + wantErr: false, + isValid: false, + }, + { + name: "Peer with no privates network in the allow range", + check: PrivateNetworkCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("10.0.0.0/8"), + }, + }, + peer: nbpeer.Peer{}, + wantErr: true, + isValid: false, + }, + { + name: "Peer private networks matches the denied range", + check: PrivateNetworkCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/24"), + netip.MustParsePrefix("10.0.0.0/8"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + { + NetIP: netip.MustParsePrefix("192.168.0.123/24"), + }, + { + NetIP: netip.MustParsePrefix("fe80::6089:eaff:fe0c:232f/64"), + }, + }, + }, + }, + wantErr: false, + isValid: false, + }, + { + name: "Peer private networks doesn't matches the denied range", + check: PrivateNetworkCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/24"), + netip.MustParsePrefix("10.0.0.0/8"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + { + NetIP: netip.MustParsePrefix("198.19.249.3/24"), + }, + }, + }, + }, + wantErr: false, + isValid: true, + }, + { + name: "Peer with no private networks in the denied range", + check: PrivateNetworkCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("10.0.0.0/8"), + }, + }, + peer: nbpeer.Peer{}, + wantErr: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, err := tt.check.Check(tt.peer) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.isValid, isValid) + }) + } +}