diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml index e7166b0aa73..e35d12ce106 100644 --- a/management/server/http/api/openapi.yml +++ b/management/server/http/api/openapi.yml @@ -842,6 +842,8 @@ components: $ref: '#/components/schemas/NBVersionCheck' os_version_check: $ref: '#/components/schemas/OSVersionCheck' + geo_location_check: + $ref: '#/components/schemas/GeoLocationCheck' NBVersionCheck: description: Posture check for the version of NetBird type: object @@ -884,6 +886,38 @@ components: example: "6.6.12" required: - min_kernel_version + GeoLocationCheck: + description: Posture check for geo location + type: object + properties: + locations: + description: List of geo locations to which the policy applies + type: array + items: + $ref: '#/components/schemas/Location' + action: + description: Action to take upon policy match + type: string + enum: [ "allow", "deny" ] + example: "allow" + required: + - locations + - action + Location: + description: Describe geographical location information + type: object + properties: + country_code: + description: 2-letter ISO 3166-1 alpha-2 code that represents the country + type: string + example: "DE" + city_name: + description: Commonly used English name of the city + type: string + example: "Berlin" + required: + - country_code + - city_name PostureCheckUpdate: type: object properties: @@ -2698,4 +2732,4 @@ paths: '403': "$ref": "#/components/responses/forbidden" '500': - "$ref": "#/components/responses/internal_error" \ No newline at end of file + "$ref": "#/components/responses/internal_error" diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go index aed6adaf9cd..34d4ef3ed9e 100644 --- a/management/server/http/api/types.gen.go +++ b/management/server/http/api/types.gen.go @@ -63,6 +63,12 @@ const ( EventActivityCodeUserUnblock EventActivityCode = "user.unblock" ) +// Defines values for GeoLocationCheckAction. +const ( + GeoLocationCheckActionAllow GeoLocationCheckAction = "allow" + GeoLocationCheckActionDeny GeoLocationCheckAction = "deny" +) + // Defines values for NameserverNsType. const ( NameserverNsTypeUdp NameserverNsType = "udp" @@ -178,6 +184,9 @@ type AccountSettings struct { // Checks List of objects that perform the actual checks type Checks struct { + // GeoLocationCheck Posture check for geo location + GeoLocationCheck *GeoLocationCheck `json:"geo_location_check,omitempty"` + // NbVersionCheck Posture check for the version of operating system NbVersionCheck *NBVersionCheck `json:"nb_version_check,omitempty"` @@ -224,6 +233,18 @@ type Event struct { // EventActivityCode The string code of the activity that occurred during the event type EventActivityCode string +// GeoLocationCheck Posture check for geo location +type GeoLocationCheck struct { + // Action Action to take upon policy match + Action GeoLocationCheckAction `json:"action"` + + // Locations List of geo locations to which the policy applies + Locations []Location `json:"locations"` +} + +// GeoLocationCheckAction Action to take upon policy match +type GeoLocationCheckAction string + // Group defines model for Group. type Group struct { // Id Group ID @@ -266,6 +287,15 @@ type GroupRequest struct { Peers *[]string `json:"peers,omitempty"` } +// Location Describe geographical location information +type Location struct { + // CityName Commonly used English name of the city + CityName string `json:"city_name"` + + // CountryCode 2-letter ISO 3166-1 alpha-2 code that represents the country + CountryCode string `json:"country_code"` +} + // MinKernelVersionCheck Posture check for the version of kernel type MinKernelVersionCheck struct { // MinKernelVersion Minimum acceptable version diff --git a/management/server/http/posture_checks_handler.go b/management/server/http/posture_checks_handler.go index 123b7e5bc91..d39fcbe7cbb 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" + "regexp" "github.com/gorilla/mux" "github.com/rs/xid" @@ -15,6 +16,10 @@ import ( "github.com/netbirdio/netbird/management/server/status" ) +var ( + countryCodeRegex = regexp.MustCompile("^[a-zA-Z]{2}$") +) + // PostureChecksHandler is a handler that returns posture checks of the account. type PostureChecksHandler struct { accountManager server.AccountManager @@ -195,6 +200,10 @@ func (p *PostureChecksHandler) savePostureChecks( }) } + if geoLocationCheck := req.Checks.GeoLocationCheck; geoLocationCheck != nil { + postureChecks.Checks = append(postureChecks.Checks, toPostureGeoLocationCheck(geoLocationCheck)) + } + if err := p.accountManager.SavePostureChecks(account.Id, user.Id, &postureChecks); err != nil { util.WriteError(err, w) return @@ -208,7 +217,8 @@ func validatePostureChecksUpdate(req api.PostureCheckUpdate) error { return status.Errorf(status.InvalidArgument, "posture checks name shouldn't be empty") } - if req.Checks == nil || (req.Checks.NbVersionCheck == nil && req.Checks.OsVersionCheck == nil) { + if req.Checks == nil || (req.Checks.NbVersionCheck == nil && req.Checks.OsVersionCheck == nil && + req.Checks.GeoLocationCheck == nil) { return status.Errorf(status.InvalidArgument, "posture checks shouldn't be empty") } @@ -230,6 +240,25 @@ func validatePostureChecksUpdate(req api.PostureCheckUpdate) error { } } + if geoLocationCheck := req.Checks.GeoLocationCheck; geoLocationCheck != nil { + if geoLocationCheck.Action == "" { + return status.Errorf(status.InvalidArgument, "action for geolocation check shouldn't be empty") + } + if len(geoLocationCheck.Locations) == 0 { + return status.Errorf(status.InvalidArgument, "locations for geolocation check shouldn't be empty") + } + + for _, loc := range geoLocationCheck.Locations { + if loc.CountryCode == "" { + return status.Errorf(status.InvalidArgument, "country code for geolocation check shouldn't be empty") + } + if !countryCodeRegex.MatchString(loc.CountryCode) { + return status.Errorf(status.InvalidArgument, "country code must be 2 letters (ISO 3166-1 alpha-2 format)") + } + } + + } + return nil } @@ -251,6 +280,9 @@ func toPostureChecksResponse(postureChecks *posture.Checks) *api.PostureCheck { Linux: (*api.MinKernelVersionCheck)(osCheck.Linux), Windows: (*api.MinKernelVersionCheck)(osCheck.Windows), } + case posture.GeoLocationCheckName: + geoLocationCheck := check.(*posture.GeoLocationCheck) + checks.GeoLocationCheck = toGeoLocationCheckResponse(geoLocationCheck) } } @@ -261,3 +293,33 @@ func toPostureChecksResponse(postureChecks *posture.Checks) *api.PostureCheck { Checks: checks, } } + +func toGeoLocationCheckResponse(geoLocationCheck *posture.GeoLocationCheck) *api.GeoLocationCheck { + locations := make([]api.Location, 0, len(geoLocationCheck.Locations)) + for _, loc := range geoLocationCheck.Locations { + locations = append(locations, api.Location{ + CityName: loc.CityName, + CountryCode: loc.CountryCode, + }) + } + + return &api.GeoLocationCheck{ + Action: api.GeoLocationCheckAction(geoLocationCheck.Action), + Locations: locations, + } +} + +func toPostureGeoLocationCheck(apiGeoLocationCheck *api.GeoLocationCheck) *posture.GeoLocationCheck { + locations := make([]posture.Location, 0, len(apiGeoLocationCheck.Locations)) + for _, loc := range apiGeoLocationCheck.Locations { + locations = append(locations, posture.Location{ + CountryCode: loc.CountryCode, + CityName: loc.CityName, + }) + } + + return &posture.GeoLocationCheck{ + Action: string(apiGeoLocationCheck.Action), + Locations: locations, + } +} diff --git a/management/server/http/posture_checks_handler_test.go b/management/server/http/posture_checks_handler_test.go index acba6ef5ca1..857950fe0ca 100644 --- a/management/server/http/posture_checks_handler_test.go +++ b/management/server/http/posture_checks_handler_test.go @@ -106,6 +106,22 @@ func TestGetPostureCheck(t *testing.T) { }, }, } + geoPostureCheck := &posture.Checks{ + ID: "geoPostureCheck", + Name: "geoLocation", + Checks: []posture.Check{ + &posture.GeoLocationCheck{ + Locations: []posture.Location{ + { + CountryCode: "DE", + CityName: "Berlin", + }, + }, + Action: posture.GeoLocationActionAllow, + }, + }, + } + tt := []struct { name string id string @@ -128,6 +144,13 @@ func TestGetPostureCheck(t *testing.T) { checkName: osPostureCheck.Name, expectedStatus: http.StatusOK, }, + { + name: "GetPostureCheck GeoLocation OK", + expectedBody: true, + id: geoPostureCheck.ID, + checkName: geoPostureCheck.Name, + expectedStatus: http.StatusOK, + }, { name: "GetPostureCheck Not Found", id: "not-exists", @@ -135,7 +158,7 @@ func TestGetPostureCheck(t *testing.T) { }, } - p := initPostureChecksTestData(postureCheck, osPostureCheck) + p := initPostureChecksTestData(postureCheck, osPostureCheck, geoPostureCheck) for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { @@ -256,6 +279,45 @@ func TestPostureCheckUpdate(t *testing.T) { }, }, }, + { + name: "Create Posture Checks Geo Location", + requestType: http.MethodPost, + requestPath: "/api/posture-checks", + requestBody: bytes.NewBuffer( + []byte(`{ + "name": "default", + "description": "default", + "checks": { + "geo_location_check": { + "locations": [ + { + "city_name": "Berlin", + "country_code": "DE" + } + ], + "action": "allow" + } + } + }`)), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedPostureCheck: &api.PostureCheck{ + Id: "postureCheck", + Name: "default", + Description: str("default"), + Checks: api.Checks{ + GeoLocationCheck: &api.GeoLocationCheck{ + Locations: []api.Location{ + { + CityName: "Berlin", + CountryCode: "DE", + }, + }, + Action: api.GeoLocationCheckActionAllow, + }, + }, + }, + }, { name: "Create Posture Checks Invalid Check", requestType: http.MethodPost, @@ -301,6 +363,20 @@ func TestPostureCheckUpdate(t *testing.T) { expectedStatus: http.StatusBadRequest, expectedBody: false, }, + { + name: "Create Posture Checks Invalid Geo Location", + requestType: http.MethodPost, + requestPath: "/api/posture-checks", + requestBody: bytes.NewBuffer( + []byte(`{ + "name": "default", + "checks": { + "geo_location_check": {} + } + }`)), + expectedStatus: http.StatusBadRequest, + expectedBody: false, + }, { name: "Update Posture Checks NB Version", requestType: http.MethodPut, @@ -357,6 +433,44 @@ func TestPostureCheckUpdate(t *testing.T) { }, }, }, + { + name: "Update Posture Checks Geo Location", + requestType: http.MethodPut, + requestPath: "/api/posture-checks/geoPostureCheck", + requestBody: bytes.NewBuffer( + []byte(`{ + "name": "default", + "checks": { + "geo_location_check": { + "locations": [ + { + "city_name": "Los Angeles", + "country_code": "US" + } + ], + "action": "allow" + } + } + }`)), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedPostureCheck: &api.PostureCheck{ + Id: "postureCheck", + Name: "default", + Description: str(""), + Checks: api.Checks{ + GeoLocationCheck: &api.GeoLocationCheck{ + Locations: []api.Location{ + { + CityName: "Los Angeles", + CountryCode: "US", + }, + }, + Action: api.GeoLocationCheckActionAllow, + }, + }, + }, + }, { name: "Update Posture Checks Invalid Check", requestType: http.MethodPut, @@ -424,6 +538,21 @@ func TestPostureCheckUpdate(t *testing.T) { }, }, }, + &posture.Checks{ + ID: "geoPostureCheck", + Name: "geoLocation", + Checks: []posture.Check{ + &posture.GeoLocationCheck{ + Locations: []posture.Location{ + { + CountryCode: "DE", + CityName: "Berlin", + }, + }, + Action: posture.GeoLocationActionDeny, + }, + }, + }, ) for _, tc := range tt { diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 56462a4a334..6ed2c793853 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -66,6 +66,12 @@ type PeerSystemMeta struct { WtVersion string UIVersion string KernelVersion string + // Location mock location for peer + // TODO: Add actual implementation based on peer IP + Location struct { + CountryCode string + CityName string + } `gorm:"embedded;embeddedPrefix:location_"` } func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool { diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go index 521546851ed..1613cf43f82 100644 --- a/management/server/posture/checks.go +++ b/management/server/posture/checks.go @@ -9,6 +9,7 @@ import ( const ( NBVersionCheckName = "NBVersionCheck" OSVersionCheckName = "OSVersionCheck" + GeoLocationCheckName = "GeoLocationCheck" ) // Check represents an interface for performing a check on a peer. @@ -117,6 +118,12 @@ func (pc *Checks) unmarshalChecks(rawChecks map[string]json.RawMessage) error { return err } pc.Checks = append(pc.Checks, check) + case GeoLocationCheckName: + check := &GeoLocationCheck{} + if err := json.Unmarshal(rawCheck, check); err != nil { + return err + } + pc.Checks = append(pc.Checks, check) } } return nil diff --git a/management/server/posture/geo_location.go b/management/server/posture/geo_location.go new file mode 100644 index 00000000000..303c1f1fd25 --- /dev/null +++ b/management/server/posture/geo_location.go @@ -0,0 +1,60 @@ +package posture + +import ( + "fmt" + + 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 + + // CityName Commonly used English name of the city + CityName string +} + +var _ Check = (*GeoLocationCheck)(nil) + +type GeoLocationCheck struct { + // Locations list of geolocations, to which the policy applies + Locations []Location + + // Action to take upon policy match + Action string +} + +func (g *GeoLocationCheck) Check(peer nbpeer.Peer) (bool, error) { + for _, loc := range g.Locations { + if loc.CountryCode == peer.Meta.Location.CountryCode { + if loc.CityName == "" || loc.CityName == peer.Meta.Location.CityName { + switch g.Action { + case GeoLocationActionDeny: + return false, nil + case GeoLocationActionAllow: + return true, nil + } + } + } + } + // 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 { + return true, nil + } + // For action allow and no location match, deny the peer + if g.Action == GeoLocationActionAllow { + return false, nil + } + + return false, fmt.Errorf("invalid geo location action: %s", g.Action) +} + +func (g *GeoLocationCheck) Name() string { + return GeoLocationCheckName +} diff --git a/management/server/posture/geo_location_test.go b/management/server/posture/geo_location_test.go new file mode 100644 index 00000000000..a2345f09ef0 --- /dev/null +++ b/management/server/posture/geo_location_test.go @@ -0,0 +1,224 @@ +package posture + +import ( + "testing" + + "github.com/netbirdio/netbird/management/server/peer" + + "github.com/stretchr/testify/assert" +) + +func TestGeoLocationCheck_Check(t *testing.T) { + tests := []struct { + name string + input peer.Peer + check GeoLocationCheck + wantErr bool + isValid bool + }{ + { + name: "Peer location matches the location in the allow sets", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + Location: Location{ + CountryCode: "DE", + CityName: "Berlin", + }, + }, + }, + check: GeoLocationCheck{ + Locations: []Location{ + { + CountryCode: "US", + CityName: "Los Angeles", + }, + { + CountryCode: "DE", + CityName: "Berlin", + }, + }, + Action: GeoLocationActionAllow, + }, + wantErr: false, + isValid: true, + }, + { + name: "Peer location matches the location in the allow country only", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + Location: Location{ + CountryCode: "DE", + CityName: "Berlin", + }, + }, + }, + check: GeoLocationCheck{ + Locations: []Location{ + { + CountryCode: "DE", + }, + }, + Action: GeoLocationActionAllow, + }, + wantErr: false, + isValid: true, + }, + { + name: "Peer location doesn't match the location in the allow sets", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + Location: Location{ + CountryCode: "DE", + CityName: "Frankfurt am Main", + }, + }, + }, + check: GeoLocationCheck{ + Locations: []Location{ + { + CountryCode: "DE", + CityName: "Berlin", + }, + { + CountryCode: "US", + CityName: "Los Angeles", + }, + }, + Action: GeoLocationActionAllow, + }, + wantErr: false, + isValid: false, + }, + { + name: "Peer location doesn't match the location in the allow country only", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + Location: Location{ + CountryCode: "DE", + CityName: "Frankfurt am Main", + }, + }, + }, + check: GeoLocationCheck{ + Locations: []Location{ + { + CountryCode: "US", + }, + }, + Action: GeoLocationActionAllow, + }, + wantErr: false, + isValid: false, + }, + { + name: "Peer location matches the location in the deny sets", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + Location: Location{ + CountryCode: "DE", + CityName: "Berlin", + }, + }, + }, + check: GeoLocationCheck{ + Locations: []Location{ + { + CountryCode: "DE", + CityName: "Berlin", + }, + { + CountryCode: "US", + CityName: "Los Angeles", + }, + }, + Action: GeoLocationActionDeny, + }, + wantErr: false, + isValid: false, + }, + { + name: "Peer location matches the location in the deny country only", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + Location: Location{ + CountryCode: "DE", + CityName: "Berlin", + }, + }, + }, + check: GeoLocationCheck{ + Locations: []Location{ + { + CountryCode: "DE", + }, + { + CountryCode: "US", + }, + }, + Action: GeoLocationActionDeny, + }, + wantErr: false, + isValid: false, + }, + { + name: "Peer location doesn't match the location in the deny sets", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + Location: Location{ + CountryCode: "DE", + CityName: "Frankfurt am Main", + }, + }, + }, + check: GeoLocationCheck{ + Locations: []Location{ + { + CountryCode: "DE", + CityName: "Berlin", + }, + { + CountryCode: "US", + CityName: "Los Angeles", + }, + }, + Action: GeoLocationActionDeny, + }, + wantErr: false, + isValid: true, + }, + { + name: "Peer location doesn't match the location in the deny country only", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + Location: Location{ + CountryCode: "DE", + CityName: "Frankfurt am Main", + }, + }, + }, + check: GeoLocationCheck{ + Locations: []Location{ + { + CountryCode: "US", + CityName: "Los Angeles", + }, + }, + Action: GeoLocationActionDeny, + }, + wantErr: false, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, err := tt.check.Check(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.isValid, isValid) + }) + } +}