From 9bc7b9e897b1deb65671bf5e45bc7e150d815e5c Mon Sep 17 00:00:00 2001 From: Yury Gargay Date: Tue, 20 Feb 2024 09:59:56 +0100 Subject: [PATCH] Add initial support of device posture checks (#1540) This PR implements the following posture checks: * Agent minimum version allowed * OS minimum version allowed * Geo-location based on connection IP For the geo-based location, we rely on GeoLite2 databases which are free IP geolocation databases. MaxMind was tested and we provide a script that easily allows to download of all necessary files, see infrastructure_files/download-geolite2.sh. The OpenAPI spec should extensively cover the life cycle of current version posture checks. --- .gitignore | 2 +- client/cmd/testutil.go | 3 +- client/internal/engine_test.go | 3 +- client/system/info.go | 2 +- client/system/info_android.go | 7 +- client/system/info_darwin.go | 2 +- client/system/info_freebsd.go | 2 +- client/system/info_ios.go | 2 +- client/system/info_linux.go | 2 +- client/system/info_windows.go | 2 +- go.mod | 5 +- go.sum | 6 +- infrastructure_files/download-geolite2.sh | 118 +++ management/client/client_test.go | 7 +- management/client/grpc.go | 2 + management/cmd/management.go | 33 +- management/proto/management.pb.go | 514 +++++------ management/proto/management.proto | 2 + management/server/account.go | 20 +- management/server/account_test.go | 23 +- management/server/activity/codes.go | 9 + management/server/dns_test.go | 2 +- management/server/file_store.go | 21 + management/server/file_store_test.go | 49 ++ management/server/geolocation/geolocation.go | 255 ++++++ .../server/geolocation/geolocation_test.go | 55 ++ management/server/geolocation/store.go | 222 +++++ management/server/grpcserver.go | 39 +- management/server/http/api/openapi.yml | 390 ++++++++- management/server/http/api/types.gen.go | 173 ++++ .../server/http/geolocation_handler_test.go | 236 ++++++ .../server/http/geolocations_handler.go | 119 +++ management/server/http/handler.go | 34 +- management/server/http/peers_handler.go | 33 +- management/server/http/policies_handler.go | 27 +- .../server/http/posture_checks_handler.go | 344 ++++++++ .../http/posture_checks_handler_test.go | 796 ++++++++++++++++++ management/server/management_proto_test.go | 2 +- management/server/management_test.go | 2 +- management/server/mock_server/account_mock.go | 46 +- management/server/nameserver_test.go | 2 +- management/server/peer.go | 20 +- management/server/peer/peer.go | 31 +- management/server/policy.go | 70 +- management/server/policy_test.go | 318 +++++++ management/server/posture/checks.go | 177 ++++ management/server/posture/checks_test.go | 218 +++++ management/server/posture/geo_location.go | 67 ++ .../server/posture/geo_location_test.go | 238 ++++++ management/server/posture/nb_version.go | 39 + management/server/posture/nb_version_test.go | 110 +++ management/server/posture/os_version.go | 99 +++ management/server/posture/os_version_test.go | 152 ++++ management/server/posture_checks.go | 178 ++++ management/server/posture_checks_test.go | 118 +++ management/server/route_test.go | 2 +- management/server/sqlite_store.go | 16 +- management/server/sqlite_store_test.go | 43 + management/server/store.go | 1 + .../server/testdata/GeoLite2-City-Test.mmdb | Bin 0 -> 21117 bytes management/server/testdata/geonames-test.db | Bin 0 -> 16384 bytes 61 files changed, 5162 insertions(+), 348 deletions(-) create mode 100755 infrastructure_files/download-geolite2.sh create mode 100644 management/server/geolocation/geolocation.go create mode 100644 management/server/geolocation/geolocation_test.go create mode 100644 management/server/geolocation/store.go create mode 100644 management/server/http/geolocation_handler_test.go create mode 100644 management/server/http/geolocations_handler.go create mode 100644 management/server/http/posture_checks_handler.go create mode 100644 management/server/http/posture_checks_handler_test.go create mode 100644 management/server/posture/checks.go create mode 100644 management/server/posture/checks_test.go create mode 100644 management/server/posture/geo_location.go create mode 100644 management/server/posture/geo_location_test.go create mode 100644 management/server/posture/nb_version.go create mode 100644 management/server/posture/nb_version_test.go create mode 100644 management/server/posture/os_version.go create mode 100644 management/server/posture/os_version_test.go create mode 100644 management/server/posture_checks.go create mode 100644 management/server/posture_checks_test.go create mode 100644 management/server/testdata/GeoLite2-City-Test.mmdb create mode 100644 management/server/testdata/geonames-test.db diff --git a/.gitignore b/.gitignore index c4f90b84723..cdce4697529 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,4 @@ infrastructure_files/setup.env infrastructure_files/setup-*.env .vscode .DS_Store -*.db \ No newline at end of file +GeoLite2-City* \ No newline at end of file diff --git a/client/cmd/testutil.go b/client/cmd/testutil.go index 28423776fde..cba47326f84 100644 --- a/client/cmd/testutil.go +++ b/client/cmd/testutil.go @@ -78,8 +78,7 @@ func startManagement(t *testing.T, config *mgmt.Config) (*grpc.Server, net.Liste if err != nil { return nil, nil } - accountManager, err := mgmt.BuildManager(store, peersUpdateManager, nil, "", "", - eventStore, false) + accountManager, err := mgmt.BuildManager(store, peersUpdateManager, nil, "", "", eventStore, nil, false) if err != nil { t.Fatal(err) } diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 5dfc171a632..875dc60036c 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -1049,8 +1049,7 @@ func startManagement(dataDir string) (*grpc.Server, string, error) { if err != nil { return nil, "", err } - accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "", - eventStore, false) + accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "", eventStore, nil, false) if err != nil { return nil, "", err } diff --git a/client/system/info.go b/client/system/info.go index 2d5b7192ece..7a5ae9c609e 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -23,7 +23,6 @@ const OsNameCtxKey = "OsName" type Info struct { GoOS string Kernel string - Core string Platform string OS string OSVersion string @@ -31,6 +30,7 @@ type Info struct { CPUs int WiretrusteeVersion string UIVersion string + KernelVersion string } // extractUserAgent extracts Netbird's agent (client) name and version from the outgoing context diff --git a/client/system/info_android.go b/client/system/info_android.go index 9a1a7befb30..be62352f709 100644 --- a/client/system/info_android.go +++ b/client/system/info_android.go @@ -23,7 +23,12 @@ func GetInfo(ctx context.Context) *Info { kernel = osInfo[1] } - gio := &Info{Kernel: kernel, Core: osVersion(), Platform: "unknown", OS: "android", OSVersion: osVersion(), GoOS: runtime.GOOS, CPUs: runtime.NumCPU()} + var kernelVersion string + if len(osInfo) > 2 { + kernelVersion = osInfo[2] + } + + gio := &Info{Kernel: kernel, Platform: "unknown", OS: "android", OSVersion: osVersion(), GoOS: runtime.GOOS, CPUs: runtime.NumCPU(), KernelVersion: kernelVersion} gio.Hostname = extractDeviceName(ctx, "android") gio.WiretrusteeVersion = version.NetbirdVersion() gio.UIVersion = extractUserAgent(ctx) diff --git a/client/system/info_darwin.go b/client/system/info_darwin.go index 5ae2b4fc676..b35b3a3af85 100644 --- a/client/system/info_darwin.go +++ b/client/system/info_darwin.go @@ -33,7 +33,7 @@ func GetInfo(ctx context.Context) *Info { log.Warnf("got an error while retrieving macOS version with sw_vers, error: %s. Using darwin version instead.\n", err) swVersion = []byte(release) } - gio := &Info{Kernel: sysName, OSVersion: strings.TrimSpace(string(swVersion)), Core: release, Platform: machine, OS: sysName, GoOS: runtime.GOOS, CPUs: runtime.NumCPU()} + gio := &Info{Kernel: sysName, OSVersion: strings.TrimSpace(string(swVersion)), Platform: machine, OS: sysName, GoOS: runtime.GOOS, CPUs: runtime.NumCPU(), KernelVersion: release} systemHostname, _ := os.Hostname() gio.Hostname = extractDeviceName(ctx, systemHostname) gio.WiretrusteeVersion = version.NetbirdVersion() diff --git a/client/system/info_freebsd.go b/client/system/info_freebsd.go index 6c2d8a70165..74b132f4a98 100644 --- a/client/system/info_freebsd.go +++ b/client/system/info_freebsd.go @@ -23,7 +23,7 @@ func GetInfo(ctx context.Context) *Info { osStr := strings.Replace(out, "\n", "", -1) osStr = strings.Replace(osStr, "\r\n", "", -1) osInfo := strings.Split(osStr, " ") - gio := &Info{Kernel: osInfo[0], Core: osInfo[1], Platform: runtime.GOARCH, OS: osInfo[2], GoOS: runtime.GOOS, CPUs: runtime.NumCPU()} + gio := &Info{Kernel: osInfo[0], Platform: runtime.GOARCH, OS: osInfo[2], GoOS: runtime.GOOS, CPUs: runtime.NumCPU(), KernelVersion: osInfo[1]} systemHostname, _ := os.Hostname() gio.Hostname = extractDeviceName(ctx, systemHostname) gio.WiretrusteeVersion = version.NetbirdVersion() diff --git a/client/system/info_ios.go b/client/system/info_ios.go index c0e51ec6001..e1c291ef591 100644 --- a/client/system/info_ios.go +++ b/client/system/info_ios.go @@ -17,7 +17,7 @@ func GetInfo(ctx context.Context) *Info { sysName := extractOsName(ctx, "sysName") swVersion := extractOsVersion(ctx, "swVersion") - gio := &Info{Kernel: sysName, OSVersion: swVersion, Core: swVersion, Platform: "unknown", OS: sysName, GoOS: runtime.GOOS, CPUs: runtime.NumCPU()} + gio := &Info{Kernel: sysName, OSVersion: swVersion, Platform: "unknown", OS: sysName, GoOS: runtime.GOOS, CPUs: runtime.NumCPU(), KernelVersion: swVersion} gio.Hostname = extractDeviceName(ctx, "hostname") gio.WiretrusteeVersion = version.NetbirdVersion() gio.UIVersion = extractUserAgent(ctx) diff --git a/client/system/info_linux.go b/client/system/info_linux.go index 21a4d482a64..e2b60b0562e 100644 --- a/client/system/info_linux.go +++ b/client/system/info_linux.go @@ -50,7 +50,7 @@ func GetInfo(ctx context.Context) *Info { if osName == "" { osName = osInfo[3] } - gio := &Info{Kernel: osInfo[0], Core: osInfo[1], Platform: osInfo[2], OS: osName, OSVersion: osVer, GoOS: runtime.GOOS, CPUs: runtime.NumCPU()} + gio := &Info{Kernel: osInfo[0], Platform: osInfo[2], OS: osName, OSVersion: osVer, GoOS: runtime.GOOS, CPUs: runtime.NumCPU(), KernelVersion: osInfo[1]} systemHostname, _ := os.Hostname() gio.Hostname = extractDeviceName(ctx, systemHostname) gio.WiretrusteeVersion = version.NetbirdVersion() diff --git a/client/system/info_windows.go b/client/system/info_windows.go index 76b13bbc304..d343063ffaf 100644 --- a/client/system/info_windows.go +++ b/client/system/info_windows.go @@ -22,7 +22,7 @@ type Win32_OperatingSystem struct { func GetInfo(ctx context.Context) *Info { osName, osVersion := getOSNameAndVersion() buildVersion := getBuildVersion() - gio := &Info{Kernel: "windows", OSVersion: osVersion, Core: buildVersion, Platform: "unknown", OS: osName, GoOS: runtime.GOOS, CPUs: runtime.NumCPU()} + gio := &Info{Kernel: "windows", OSVersion: osVersion, Platform: "unknown", OS: osName, GoOS: runtime.GOOS, CPUs: runtime.NumCPU(), KernelVersion: buildVersion} systemHostname, _ := os.Hostname() gio.Hostname = extractDeviceName(ctx, systemHostname) gio.WiretrusteeVersion = version.NetbirdVersion() diff --git a/go.mod b/go.mod index 38590fa13be..64444616159 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( github.com/google/go-cmp v0.5.9 github.com/google/gopacket v1.1.19 github.com/google/nftables v0.0.0-20220808154552-2eca00135732 - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240202184442-37827591b26c + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.6.0 github.com/libp2p/go-netroute v0.2.0 @@ -60,6 +60,7 @@ require ( github.com/netbirdio/management-integrations/additions v0.0.0-20240118163419-8a7c87accb22 github.com/netbirdio/management-integrations/integrations v0.0.0-20240118163419-8a7c87accb22 github.com/okta/okta-sdk-golang/v2 v2.18.0 + github.com/oschwald/maxminddb-golang v1.12.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pion/logging v0.2.2 github.com/pion/stun/v2 v2.0.0 @@ -171,5 +172,3 @@ replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-202 replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20240105182236-6c340dd55aed replace github.com/cloudflare/circl => github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 - -replace github.com/grpc-ecosystem/go-grpc-middleware/v2 => github.com/surik/go-grpc-middleware/v2 v2.0.0-20240206110057-98a38fc1f86f diff --git a/go.sum b/go.sum index ac404d216cd..664e8a6f226 100644 --- a/go.sum +++ b/go.sum @@ -286,6 +286,8 @@ github.com/gopherjs/gopherjs v0.0.0-20220410123724-9e86199038b0 h1:fWY+zXdWhvWnd github.com/gopherjs/gopherjs v0.0.0-20220410123724-9e86199038b0/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357 h1:Fkzd8ktnpOR9h47SXHe2AYPwelXLH2GjGsjlAloiWfo= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357/go.mod h1:w9Y7gY31krpLmrVU5ZPG9H7l9fZuRu5/3R3S3FMtVQ4= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= @@ -407,6 +409,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= +github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -517,8 +521,6 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/surik/go-grpc-middleware/v2 v2.0.0-20240206110057-98a38fc1f86f h1:J+egXEDkpg/vOYYzPO5IwF8OufGb7g+KcwEF1AWIzhQ= -github.com/surik/go-grpc-middleware/v2 v2.0.0-20240206110057-98a38fc1f86f/go.mod h1:w9Y7gY31krpLmrVU5ZPG9H7l9fZuRu5/3R3S3FMtVQ4= github.com/things-go/go-socks5 v0.0.4 h1:jMQjIc+qhD4z9cITOMnBiwo9dDmpGuXmBlkRFrl/qD0= github.com/things-go/go-socks5 v0.0.4/go.mod h1:sh4K6WHrmHZpjxLTCHyYtXYH8OUuD+yZun41NomR1IQ= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= diff --git a/infrastructure_files/download-geolite2.sh b/infrastructure_files/download-geolite2.sh new file mode 100755 index 00000000000..22ccb6ecb6c --- /dev/null +++ b/infrastructure_files/download-geolite2.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +# set $MM_ACCOUNT_ID and $MM_LICENSE_KEY when calling this script +# see https://dev.maxmind.com/geoip/updating-databases#directly-downloading-databases + +# Check if MM_ACCOUNT_ID is set +if [ -z "$MM_ACCOUNT_ID" ]; then + echo "MM_ACCOUNT_ID is not set. Please set the environment variable." + exit 1 +fi + +# Check if MM_LICENSE_KEY is set +if [ -z "$MM_LICENSE_KEY" ]; then + echo "MM_LICENSE_KEY is not set. Please set the environment variable." + exit 1 +fi + +# to install sha256sum on mac: brew install coreutils +if ! command -v sha256sum &> /dev/null +then + echo "sha256sum is not installed or not in PATH, please install with your package manager. e.g. sudo apt install sha256sum" > /dev/stderr + exit 1 +fi + +if ! command -v sqlite3 &> /dev/null +then + echo "sqlite3 is not installed or not in PATH, please install with your package manager. e.g. sudo apt install sqlite3" > /dev/stderr + exit 1 +fi + +download_geolite_mmdb() { + DATABASE_URL="https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz" + SIGNATURE_URL="https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz.sha256" + + # Download the database and signature files + echo "Downloading mmdb database file..." + DATABASE_FILE=$(curl -s -u "$MM_ACCOUNT_ID":"$MM_LICENSE_KEY" -L -O -J "$DATABASE_URL" -w "%{filename_effective}") + echo "Downloading mmdb signature file..." + SIGNATURE_FILE=$(curl -s -u "$MM_ACCOUNT_ID":"$MM_LICENSE_KEY" -L -O -J "$SIGNATURE_URL" -w "%{filename_effective}") + + # Verify the signature + echo "Verifying signature..." + if sha256sum -c --status "$SIGNATURE_FILE"; then + echo "Signature is valid." + else + echo "Signature is invalid. Aborting." + exit 1 + fi + + # Unpack the database file + EXTRACTION_DIR=$(basename "$DATABASE_FILE" .tar.gz) + echo "Unpacking $DATABASE_FILE..." + mkdir -p "$EXTRACTION_DIR" + tar -xzvf "$DATABASE_FILE" > /dev/null 2>&1 + + # Create a SHA256 signature file + MMDB_FILE="GeoLite2-City.mmdb" + cd "$EXTRACTION_DIR" + sha256sum "$MMDB_FILE" > "$MMDB_FILE.sha256" + echo "SHA256 signature created for $MMDB_FILE." + cd - > /dev/null 2>&1 + + # Remove downloaded files + rm "$DATABASE_FILE" "$SIGNATURE_FILE" + + # Done. Print next steps + echo "Process completed successfully." + echo "Now you can place $EXTRACTION_DIR/$MMDB_FILE to 'datadir' of management service." + echo -e "Example:\n\tdocker compose cp $EXTRACTION_DIR/$MMDB_FILE management:/var/lib/netbird/" +} + + +download_geolite_csv_and_create_sqlite_db() { + DATABASE_URL="https://download.maxmind.com/geoip/databases/GeoLite2-City-CSV/download?suffix=zip" + SIGNATURE_URL="https://download.maxmind.com/geoip/databases/GeoLite2-City-CSV/download?suffix=zip.sha256" + + + # Download the database file + echo "Downloading csv database file..." + DATABASE_FILE=$(curl -s -u "$MM_ACCOUNT_ID":"$MM_LICENSE_KEY" -L -O -J "$DATABASE_URL" -w "%{filename_effective}") + echo "Downloading csv signature file..." + SIGNATURE_FILE=$(curl -s -u "$MM_ACCOUNT_ID":"$MM_LICENSE_KEY" -L -O -J "$SIGNATURE_URL" -w "%{filename_effective}") + + # Verify the signature + echo "Verifying signature..." + if sha256sum -c --status "$SIGNATURE_FILE"; then + echo "Signature is valid." + else + echo "Signature is invalid. Aborting." + exit 1 + fi + + # Unpack the database file + EXTRACTION_DIR=$(basename "$DATABASE_FILE" .zip) + DB_NAME="geonames.db" + + echo "Unpacking $DATABASE_FILE..." + unzip "$DATABASE_FILE" > /dev/null 2>&1 + +# Create SQLite database and import data from CSV +sqlite3 "$DB_NAME" <= " + n.MinVersion) + if err != nil { + return false, err + } + + if constraints.Check(peerNBVersion) { + return true, nil + } + + log.Debugf("peer %s NB version %s is older than minimum allowed version %s", + peer.ID, peer.Meta.WtVersion, n.MinVersion) + + return false, nil +} + +func (n *NBVersionCheck) Name() string { + return NBVersionCheckName +} diff --git a/management/server/posture/nb_version_test.go b/management/server/posture/nb_version_test.go new file mode 100644 index 00000000000..de51c2283b1 --- /dev/null +++ b/management/server/posture/nb_version_test.go @@ -0,0 +1,110 @@ +package posture + +import ( + "testing" + + "github.com/netbirdio/netbird/management/server/peer" + + "github.com/stretchr/testify/assert" +) + +func TestNBVersionCheck_Check(t *testing.T) { + tests := []struct { + name string + input peer.Peer + check NBVersionCheck + wantErr bool + isValid bool + }{ + { + name: "Valid Peer NB version", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + WtVersion: "1.0.1", + }, + }, + check: NBVersionCheck{ + MinVersion: "1.0.0", + }, + wantErr: false, + isValid: true, + }, + { + name: "Valid Peer NB version With No Patch Version 1", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + WtVersion: "2.0.9", + }, + }, + check: NBVersionCheck{ + MinVersion: "2.0", + }, + wantErr: false, + isValid: true, + }, + { + name: "Valid Peer NB version With No Patch Version 2", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + WtVersion: "2.0.0", + }, + }, + check: NBVersionCheck{ + MinVersion: "2.0", + }, + wantErr: false, + isValid: true, + }, + { + name: "Older Peer NB version", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + WtVersion: "0.9.9", + }, + }, + check: NBVersionCheck{ + MinVersion: "1.0.0", + }, + wantErr: false, + isValid: false, + }, + { + name: "Older Peer NB version With Patch Version", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + WtVersion: "0.1.0", + }, + }, + check: NBVersionCheck{ + MinVersion: "0.2", + }, + wantErr: false, + isValid: false, + }, + { + name: "Invalid Peer NB version", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + WtVersion: "x.y.z", + }, + }, + check: NBVersionCheck{ + MinVersion: "1.0.0", + }, + wantErr: true, + isValid: false, + }, + } + + 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) + }) + } +} diff --git a/management/server/posture/os_version.go b/management/server/posture/os_version.go new file mode 100644 index 00000000000..4c311f01b94 --- /dev/null +++ b/management/server/posture/os_version.go @@ -0,0 +1,99 @@ +package posture + +import ( + "strings" + + "github.com/hashicorp/go-version" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + log "github.com/sirupsen/logrus" +) + +type MinVersionCheck struct { + MinVersion string +} + +type MinKernelVersionCheck struct { + MinKernelVersion string +} + +type OSVersionCheck struct { + Android *MinVersionCheck + Darwin *MinVersionCheck + Ios *MinVersionCheck + Linux *MinKernelVersionCheck + Windows *MinKernelVersionCheck +} + +var _ Check = (*OSVersionCheck)(nil) + +func (c *OSVersionCheck) Check(peer nbpeer.Peer) (bool, error) { + peerGoOS := peer.Meta.GoOS + switch peerGoOS { + case "android": + return checkMinVersion(peerGoOS, peer.Meta.OSVersion, c.Android) + case "darwin": + return checkMinVersion(peerGoOS, peer.Meta.OSVersion, c.Darwin) + case "ios": + return checkMinVersion(peerGoOS, peer.Meta.OSVersion, c.Ios) + case "linux": + kernelVersion := strings.Split(peer.Meta.KernelVersion, "-")[0] + return checkMinKernelVersion(peerGoOS, kernelVersion, c.Linux) + case "windows": + return checkMinKernelVersion(peerGoOS, peer.Meta.KernelVersion, c.Windows) + } + return true, nil +} + +func (c *OSVersionCheck) Name() string { + return OSVersionCheckName +} + +func checkMinVersion(peerGoOS, peerVersion string, check *MinVersionCheck) (bool, error) { + if check == nil { + log.Debugf("peer %s OS is not allowed in the check", peerGoOS) + return false, nil + } + + peerNBVersion, err := version.NewVersion(peerVersion) + if err != nil { + return false, err + } + + constraints, err := version.NewConstraint(">= " + check.MinVersion) + if err != nil { + return false, err + } + + if constraints.Check(peerNBVersion) { + return true, nil + } + + log.Debugf("peer %s OS version %s is older than minimum allowed version %s", peerGoOS, peerVersion, check.MinVersion) + + return false, nil +} + +func checkMinKernelVersion(peerGoOS, peerVersion string, check *MinKernelVersionCheck) (bool, error) { + if check == nil { + log.Debugf("peer %s OS is not allowed in the check", peerGoOS) + return false, nil + } + + peerNBVersion, err := version.NewVersion(peerVersion) + if err != nil { + return false, err + } + + constraints, err := version.NewConstraint(">= " + check.MinKernelVersion) + if err != nil { + return false, err + } + + if constraints.Check(peerNBVersion) { + return true, nil + } + + log.Debugf("peer %s kernel version %s is older than minimum allowed version %s", peerGoOS, peerVersion, check.MinKernelVersion) + + return false, nil +} diff --git a/management/server/posture/os_version_test.go b/management/server/posture/os_version_test.go new file mode 100644 index 00000000000..32bf5266091 --- /dev/null +++ b/management/server/posture/os_version_test.go @@ -0,0 +1,152 @@ +package posture + +import ( + "testing" + + "github.com/netbirdio/netbird/management/server/peer" + + "github.com/stretchr/testify/assert" +) + +func TestOSVersionCheck_Check(t *testing.T) { + tests := []struct { + name string + input peer.Peer + check OSVersionCheck + wantErr bool + isValid bool + }{ + { + name: "Valid Peer Windows Kernel version", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + GoOS: "linux", + KernelVersion: "10.0.20348.2227", + }, + }, + check: OSVersionCheck{ + Linux: &MinKernelVersionCheck{ + MinKernelVersion: "10.0.20340.2200", + }, + }, + wantErr: false, + isValid: true, + }, + { + name: "Valid Peer Linux Kernel version", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + GoOS: "linux", + KernelVersion: "6.1.1", + }, + }, + check: OSVersionCheck{ + Linux: &MinKernelVersionCheck{ + MinKernelVersion: "6.0.0", + }, + }, + wantErr: false, + isValid: true, + }, + { + name: "Valid Peer Linux Kernel version with suffix", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + GoOS: "linux", + KernelVersion: "6.5.11-linuxkit", + }, + }, + check: OSVersionCheck{ + Linux: &MinKernelVersionCheck{ + MinKernelVersion: "6.0.0", + }, + }, + wantErr: false, + isValid: true, + }, + { + name: "Not valid Peer macOS version", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + GoOS: "darwin", + OSVersion: "14.2.1", + }, + }, + check: OSVersionCheck{ + Darwin: &MinVersionCheck{ + MinVersion: "15", + }, + }, + wantErr: false, + isValid: false, + }, + { + name: "Valid Peer ios version allowed by any rule", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + GoOS: "ios", + OSVersion: "17.0.1", + }, + }, + check: OSVersionCheck{ + Ios: &MinVersionCheck{ + MinVersion: "0", + }, + }, + wantErr: false, + isValid: true, + }, + { + name: "Valid Peer android version not allowed by rule", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + GoOS: "android", + OSVersion: "14", + }, + }, + check: OSVersionCheck{}, + wantErr: false, + isValid: false, + }, + { + name: "Valid Peer Linux Kernel version not allowed by rule", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + GoOS: "linux", + KernelVersion: "6.1.1", + }, + }, + check: OSVersionCheck{}, + wantErr: false, + isValid: false, + }, + { + name: "Invalid Peer Linux kernel version", + input: peer.Peer{ + Meta: peer.PeerSystemMeta{ + GoOS: "linux", + KernelVersion: "x.y.1", + }, + }, + check: OSVersionCheck{ + Linux: &MinKernelVersionCheck{ + MinKernelVersion: "6.0.0", + }, + }, + wantErr: true, + isValid: false, + }, + } + + 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) + }) + } +} diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go new file mode 100644 index 00000000000..7e654b5fb7c --- /dev/null +++ b/management/server/posture_checks.go @@ -0,0 +1,178 @@ +package server + +import ( + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/status" +) + +func (am *DefaultAccountManager) GetPostureChecks(accountID, postureChecksID, userID string) (*posture.Checks, error) { + unlock := am.Store.AcquireAccountLock(accountID) + defer unlock() + + account, err := am.Store.GetAccount(accountID) + if err != nil { + return nil, err + } + + user, err := account.FindUser(userID) + if err != nil { + return nil, err + } + + if !user.HasAdminPower() { + return nil, status.Errorf(status.PermissionDenied, "only users with admin power are allowed to view posture checks") + } + + for _, postureChecks := range account.PostureChecks { + if postureChecks.ID == postureChecksID { + return postureChecks, nil + } + } + + return nil, status.Errorf(status.NotFound, "posture checks with ID %s not found", postureChecksID) +} + +func (am *DefaultAccountManager) SavePostureChecks(accountID, userID string, postureChecks *posture.Checks) error { + unlock := am.Store.AcquireAccountLock(accountID) + defer unlock() + + account, err := am.Store.GetAccount(accountID) + if err != nil { + return err + } + + user, err := account.FindUser(userID) + if err != nil { + return err + } + + if !user.HasAdminPower() { + return status.Errorf(status.PermissionDenied, "only users with admin power are allowed to view posture checks") + } + + if err := postureChecks.Validate(); err != nil { + return status.Errorf(status.BadRequest, err.Error()) + } + + exists, uniqName := am.savePostureChecks(account, postureChecks) + + // we do not allow create new posture checks with non uniq name + if !exists && !uniqName { + return status.Errorf(status.PreconditionFailed, "Posture check name should be unique") + } + + action := activity.PostureCheckCreated + if exists { + action = activity.PostureCheckUpdated + account.Network.IncSerial() + } + + if err = am.Store.SaveAccount(account); err != nil { + return err + } + + am.StoreEvent(userID, postureChecks.ID, accountID, action, postureChecks.EventMeta()) + if exists { + am.updateAccountPeers(account) + } + + return nil +} + +func (am *DefaultAccountManager) DeletePostureChecks(accountID, postureChecksID, userID string) error { + unlock := am.Store.AcquireAccountLock(accountID) + defer unlock() + + account, err := am.Store.GetAccount(accountID) + if err != nil { + return err + } + + user, err := account.FindUser(userID) + if err != nil { + return err + } + + if !user.HasAdminPower() { + return status.Errorf(status.PermissionDenied, "only users with admin power are allowed to view posture checks") + } + + postureChecks, err := am.deletePostureChecks(account, postureChecksID) + if err != nil { + return err + } + + if err = am.Store.SaveAccount(account); err != nil { + return err + } + + am.StoreEvent(userID, postureChecks.ID, accountID, activity.PostureCheckDeleted, postureChecks.EventMeta()) + + return nil +} + +func (am *DefaultAccountManager) ListPostureChecks(accountID, userID string) ([]*posture.Checks, error) { + unlock := am.Store.AcquireAccountLock(accountID) + defer unlock() + + account, err := am.Store.GetAccount(accountID) + if err != nil { + return nil, err + } + + user, err := account.FindUser(userID) + if err != nil { + return nil, err + } + + if !user.HasAdminPower() { + return nil, status.Errorf(status.PermissionDenied, "only users with admin power are allowed to view posture checks") + } + + return account.PostureChecks, nil +} + +func (am *DefaultAccountManager) savePostureChecks(account *Account, postureChecks *posture.Checks) (exists, uniqName bool) { + uniqName = true + for i, p := range account.PostureChecks { + if !exists && p.ID == postureChecks.ID { + account.PostureChecks[i] = postureChecks + exists = true + } + if p.Name == postureChecks.Name { + uniqName = false + } + } + if !exists { + account.PostureChecks = append(account.PostureChecks, postureChecks) + } + return +} + +func (am *DefaultAccountManager) deletePostureChecks(account *Account, postureChecksID string) (*posture.Checks, error) { + postureChecksIdx := -1 + for i, postureChecks := range account.PostureChecks { + if postureChecks.ID == postureChecksID { + postureChecksIdx = i + break + } + } + if postureChecksIdx < 0 { + return nil, status.Errorf(status.NotFound, "posture checks with ID %s doesn't exist", postureChecksID) + } + + // check policy links + for _, policy := range account.Policies { + for _, id := range policy.SourcePostureChecks { + if id == postureChecksID { + return nil, status.Errorf(status.PreconditionFailed, "posture checks have been linked to policy: %s", policy.Name) + } + } + } + + postureChecks := account.PostureChecks[postureChecksIdx] + account.PostureChecks = append(account.PostureChecks[:postureChecksIdx], account.PostureChecks[postureChecksIdx+1:]...) + + return postureChecks, nil +} diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go new file mode 100644 index 00000000000..a65cb8c53e6 --- /dev/null +++ b/management/server/posture_checks_test.go @@ -0,0 +1,118 @@ +package server + +import ( + "testing" + + "github.com/netbirdio/netbird/management/server/posture" + "github.com/stretchr/testify/assert" +) + +const ( + adminUserID = "adminUserID" + regularUserID = "regularUserID" + postureCheckID = "existing-id" + postureCheckName = "Existing check" +) + +func TestDefaultAccountManager_PostureCheck(t *testing.T) { + am, err := createManager(t) + if err != nil { + t.Error("failed to create account manager") + } + + account, err := initTestPostureChecksAccount(am) + if err != nil { + t.Error("failed to init testing account") + } + + t.Run("Generic posture check flow", func(t *testing.T) { + // regular users can not create checks + err := am.SavePostureChecks(account.Id, regularUserID, &posture.Checks{}) + assert.Error(t, err) + + // regular users cannot list check + _, err = am.ListPostureChecks(account.Id, regularUserID) + assert.Error(t, err) + + // should be possible to create posture check with uniq name + err = am.SavePostureChecks(account.Id, adminUserID, &posture.Checks{ + ID: postureCheckID, + Name: postureCheckName, + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.26.0", + }, + }, + }) + assert.NoError(t, err) + + // admin users can list check + checks, err := am.ListPostureChecks(account.Id, adminUserID) + assert.NoError(t, err) + assert.Len(t, checks, 1) + + // should not be possible to create posture check with non uniq name + err = am.SavePostureChecks(account.Id, adminUserID, &posture.Checks{ + ID: "new-id", + Name: postureCheckName, + Checks: posture.ChecksDefinition{ + GeoLocationCheck: &posture.GeoLocationCheck{ + Locations: []posture.Location{ + { + CountryCode: "DE", + }, + }, + }, + }, + }) + assert.Error(t, err) + + // admins can update posture checks + err = am.SavePostureChecks(account.Id, adminUserID, &posture.Checks{ + ID: postureCheckID, + Name: postureCheckName, + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.27.0", + }, + }, + }) + assert.NoError(t, err) + + // users should not be able to delete posture checks + err = am.DeletePostureChecks(account.Id, postureCheckID, regularUserID) + assert.Error(t, err) + + // admin should be able to delete posture checks + err = am.DeletePostureChecks(account.Id, postureCheckID, adminUserID) + assert.NoError(t, err) + checks, err = am.ListPostureChecks(account.Id, adminUserID) + assert.NoError(t, err) + assert.Len(t, checks, 0) + }) +} + +func initTestPostureChecksAccount(am *DefaultAccountManager) (*Account, error) { + accountID := "testingAccount" + domain := "example.com" + + admin := &User{ + Id: adminUserID, + Role: UserRoleAdmin, + } + user := &User{ + Id: regularUserID, + Role: UserRoleUser, + } + + account := newAccountWithId(accountID, groupAdminUserID, domain) + account.Users[admin.Id] = admin + account.Users[user.Id] = user + + err := am.Store.SaveAccount(account) + if err != nil { + return nil, err + } + + return am.Store.GetAccount(account.Id) +} diff --git a/management/server/route_test.go b/management/server/route_test.go index bbf0ea3dd13..a5db2ca07b4 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -1014,7 +1014,7 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, error) { return nil, err } eventStore := &activity.InMemoryEventStore{} - return BuildManager(store, NewPeersUpdateManager(nil), nil, "", "", eventStore, false) + return BuildManager(store, NewPeersUpdateManager(nil), nil, "", "", eventStore, nil, false) } func createRouterStore(t *testing.T) (Store, error) { diff --git a/management/server/sqlite_store.go b/management/server/sqlite_store.go index c8d31a0efca..3338e10685f 100644 --- a/management/server/sqlite_store.go +++ b/management/server/sqlite_store.go @@ -16,6 +16,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/account" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/status" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/route" @@ -63,7 +64,7 @@ func NewSqliteStore(dataDir string, metrics telemetry.AppMetrics) (*SqliteStore, err = db.AutoMigrate( &SetupKey{}, &nbpeer.Peer{}, &User{}, &PersonalAccessToken{}, &Group{}, &Rule{}, &Account{}, &Policy{}, &PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{}, - &installation{}, &account.ExtraSettings{}, + &installation{}, &account.ExtraSettings{}, &posture.Checks{}, ) if err != nil { return nil, err @@ -261,6 +262,18 @@ func (s *SqliteStore) SavePeerStatus(accountID, peerID string, peerStatus nbpeer return s.db.Save(peer).Error } +func (s *SqliteStore) SavePeerLocation(accountID string, peerWithLocation *nbpeer.Peer) error { + var peer nbpeer.Peer + result := s.db.First(&peer, "account_id = ? and id = ?", accountID, peerWithLocation.ID) + if result.Error != nil { + return status.Errorf(status.NotFound, "peer %s not found", peer.ID) + } + + peer.Location = peerWithLocation.Location + + return s.db.Save(peer).Error +} + // DeleteHashedPAT2TokenIDIndex is noop in Sqlite func (s *SqliteStore) DeleteHashedPAT2TokenIDIndex(hashedToken string) error { return nil @@ -356,6 +369,7 @@ func (s *SqliteStore) GetAccount(accountID string) (*Account, error) { Preload(clause.Associations). First(&account, "id = ?", accountID) if result.Error != nil { + log.Errorf("when getting account from the store: %s", result.Error) return nil, status.Errorf(status.NotFound, "account not found") } diff --git a/management/server/sqlite_store_test.go b/management/server/sqlite_store_test.go index e493368fafe..29b49d7f3b1 100644 --- a/management/server/sqlite_store_test.go +++ b/management/server/sqlite_store_test.go @@ -212,6 +212,49 @@ func TestSqlite_SavePeerStatus(t *testing.T) { actual := account.Peers["testpeer"].Status assert.Equal(t, newStatus, *actual) } +func TestSqlite_SavePeerLocation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + store := newSqliteStoreFromFile(t, "testdata/store.json") + + account, err := store.GetAccount("bf1c8084-ba50-4ce7-9439-34653001fc3b") + require.NoError(t, err) + + peer := &nbpeer.Peer{ + AccountID: account.Id, + ID: "testpeer", + Location: nbpeer.Location{ + ConnectionIP: net.ParseIP("0.0.0.0"), + CountryCode: "YY", + CityName: "City", + GeoNameID: 1, + }, + Meta: nbpeer.PeerSystemMeta{}, + } + // error is expected as peer is not in store yet + err = store.SavePeerLocation(account.Id, peer) + assert.Error(t, err) + + account.Peers[peer.ID] = peer + err = store.SaveAccount(account) + require.NoError(t, err) + + peer.Location.ConnectionIP = net.ParseIP("35.1.1.1") + peer.Location.CountryCode = "DE" + peer.Location.CityName = "Berlin" + peer.Location.GeoNameID = 2950159 + + err = store.SavePeerLocation(account.Id, account.Peers[peer.ID]) + assert.NoError(t, err) + + account, err = store.GetAccount(account.Id) + require.NoError(t, err) + + actual := account.Peers[peer.ID].Location + assert.Equal(t, peer.Location, actual) +} func TestSqlite_TestGetAccountByPrivateDomain(t *testing.T) { if runtime.GOOS == "windows" { diff --git a/management/server/store.go b/management/server/store.go index a482ca9470c..3a96c32401b 100644 --- a/management/server/store.go +++ b/management/server/store.go @@ -33,6 +33,7 @@ type Store interface { // AcquireGlobalLock should attempt to acquire a global lock and return a function that releases the lock AcquireGlobalLock() func() SavePeerStatus(accountID, peerID string, status nbpeer.PeerStatus) error + SavePeerLocation(accountID string, peer *nbpeer.Peer) error SaveUserLastLogin(accountID, userID string, lastLogin time.Time) error // Close should close the store persisting all unsaved data. Close() error diff --git a/management/server/testdata/GeoLite2-City-Test.mmdb b/management/server/testdata/GeoLite2-City-Test.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..bfbaa094590ec9df1bab022701b930aa7f212f56 GIT binary patch literal 21117 zcmZ{q349bq_Qz|wdjLTZ0YT6}2LlWUsNjVsIVY2Z5OWZeU>uSm8AxX096}qDJ z4!INVLnPdXTsrD{J8`{Nch!&tbXQ$(*X#eQ_qsC^|NnpZFyF3MRo$&x04Ib{g|LK$&^TpfbYOI3bV89Qg}8)Mof%yimohG6bY)!5=*GB$(VfwQ(UWl{ zqZgw$<0?iUMqkF&jB6M%jDC#%44o0n7{Iue5yu$F7{nONurY=(hBB^W3}Xyuj9^^P zxPfsaV!Adl+LF@r(pUA|r{B%t&F_8L12h zBaM;H$Y6|RWHQDvvKaSb_!kI~&8ZwlE+daIo{`U(z_^cbKVu@JfH8^j0HcucAj2ty zcvpzYz$_t(SXj((LHMW;Q#e(ER4G}pv}896rxK(Lev$oG7hF`Y4kQAwZ&RdMx4gqRr-{)L3rmxXvNBCKX1w5x%FuL@BMtPx^16%lhH zCFk-z=Ls>N^DqW@7(H*H5Q`$I#X>BJq?U4#WsK!Qtl;X8M~Xbbc`F&Kgjh{^oi=dE zC!x||A=YwgU8LT6A&ep$Id2nVGoy}CPar+F2(gug+akj4LhNASPR6cC-fkiGL{fWM z!KZ}S2bB&A@w5<~dke8&h%OrEH82hkx?r{*qUy4g)Zhr?Xyl&9xWaKEkZtzoNg@vhaN&K4_~aJ{02b$ZHnjqloZhF8L3}KN+7e zK4pByXkwfv$OpCHW?u-=N;eZ17#A6z6J#k<-G2%3Z_fLY@l_=6Ya#x_!fzPgGQNux z`JSKtgAhM*-cO95BSrqpMSdY%)W}(w$RKPCmUS<4Q&^LR6p0B7NF~ zmcG<5%hkei4OfU^^kejol+?LotgsB=ylWY8ks<@Bh$Y)Go+{fVfXPHPT*%OumzUL&y1Hw|sB_Cus+e*qO6rln~CrdF` za0$y4E?Lr6(o!lcZWd0B2s!>)IR076g~h`~yo?G$S4=GVARoie2oOl!ps+j~Nlh1) z8C;~2QN?(KF_ZBZ0#$!hSRRX{s)c117paK|Yq?>wg=G%s&5h*E!-Ul}hkUS_<)c$M)Q<8{Uxj5i6=dZdDl zjJL35fi3-wQ*SfgVZ6&g{F4>lXMDi;knwlMN4N_w%q$-Z%RgB7PsS&VPZ^(~q)%9y zfJwq~9@FsO!qUunEsR#i1;$0j=Zr5H|02MgNV6{)U*Vqc&|d?Ju>1$N{y}C7%QuW~ zh2=Xg`8}a~0;drFEWZfLPn`Oh@n0@t^yjZA@`tee#(BR-=P80xiiOaFjaE^iM0cTR zKqsMCxkv{_M}qNm8A- zP_E=6z1kJIiiLgJ39m+lenPp1^I|xU8?E$bq28{-091$<%C*2qp~L~#31uK)6Urd2 zFu2`ahOlrb!R#KyKjnI%4ClNN?JC^B!W&u0W2B5ih1-R46L70gZssDlv@3EO3rDvT z-hm2t3*}DEyNmOT_T9t6G3|tSo{}k)L?BHlNq}7_$y_ppOByPrvd|GNl;)#y z5#bCLRx+xD@(ATAGuw*DJMpwv9%~aS)k2xYC2JV9jMc|9RHL> zLRpLxoNYo`f}Y>Vx^VncIQ}Ud|CAL%c^pMJ#wkw-WhFmh72(Q7oLYk->xA+or`8gB z!JwgmvYxSlg<(!@!qW!|Wi#siLnw8?S)tSe4MN!h>=w#aV24n)afR)K-j{G{Cu0{2 z4|8e{D(n-=UQRv5c^`4=X~urSRh67NfC|Tjg7~K#5y~MJ^3D2meTrLU|5&K`0^4d!EqO=%p7~_*cT!#(a1g z6wJX=VoM5P=Uctid1oLJ*e(IGZ)U&uqZ``GyP_F{67HS_B_H9@1 z8WzS7jCrK?M}+}G)j2PgU<|m*2ByZ5(A0VmD!7C?7)TMS4Y*aPLx3BEIusZ#)a&?W z!w9imI5mQCJ;9iN>W!#ylTb%;9=i)O?`9U>LNF$tdK)U-Bh=BrokG2xi`+plY)QR~ zh3u1K@8r}NR7ezRJf{)}v215*5(|^t3GJvbUZ{wF>Nue~fDECgamjRoIUHkIm`N}P z4x^&x2=!jh%O(tfkA&^2xr{u5+1h+mctEHVfQdrAkBi(-FdtOF!bt?PTMJQPvQQu7 zJSV}tXAujF+X<(j!W^NN01pcl@lW*%)eV#hbt+ew)~-T13q1t0&ni&CFVu%P&)2R( zfQ3PV;ce9EsPL#zX8?}~wUUce5w10QX(kK*!u57?>M>NP5o$H3W)aMhu4UnDg4yV~ zsIW??^MIv7oewM$>H;pguw6;SKRFyr2u7?@m!ZPrLS4>zE810ff`uyym=#ogH7aZr zD#lh_C)6idxRzkfBlhg-1`?V*DC1wfP&adhI)Z6ETUfZ2VBQ7sPunfj9RTJSx<~z7 zsJno_3UxPdT&R11147*kJS|j?f9gKEi#dJw^Q{{QamIrVqQViOa{N;dQ{F(BhU}iB zjAI1TYn(uZb3#1{oE9p_KlKz9G26iLPd!UQBbchsqQdh+eU9%LYFFU}7QRR@HG2sa z{w~y)fwzVF3eYIjSAjQ#`WjbwonTI`H(3}a4E&8#Z!tLjnbYYV+~s|tzRP*<5zM>~ zSok3ezvk3OsL&+TkAY8w`VSWVlVHSY^-~spMlgNWc~oc>YBT4x5c(MRyuiYX1apkO zzz}y9>c3F$7oq+e_)e%_0{;=}S6qRsn-k$17Jf@G+weUq{3O&LIPXV-IZ1wI;eQEc zU;m27szODdX{h=;7x{x=Xr@^xr746#+!PI^wT?owa$X05(RQs93ojuUPuIGjLO-Eh z3iKA*Wk64%bp@^v+T~oK8^L^RcNX?&cb6+!*sER1t8kaAh1Q4j`Vt1u=hQWf7=o!w ze^eMGG#$8BXt7*m0Ks-Yr{Wj`2}bW~gHd6q&}^JHgkVfLPfg@J{I~3rY=ELm?5-> zId3|l|2$4rGO7qeIZvC33bjJ}3s5bzN4dyj1amsgVqp!zG`HEPFi&W6IBzb&G*iR$ z7m(0!huR`kSSPf_z!O4S0xTEWQm(#?U_@<=E5}GdPS={9%p*;t@AT*AD+Vkz|y+|qTuLN@#Uq*%3gogNskgUB* zFs=1<7QR6+RSKiRKZVu^yf3u3fOmxUH!k@$!MydmEPRh(tTnU`P~jt?eaLx#Cz#Ls zn1%l!nB(^eDzpmiQ{cSNKI0-y1k-k#S=d4_pMC)qz7X0)&ikBT&f9;n@ZVgaBd5Ls zx(n@V)cZqd{{emy+Bd-WLi?7Bd`Gx0i&H-^ek7Q;{uvd1721C}?-zop=Wi_honUPB ztOBVn!fFBV_E@1jt4c7sz=|@~PQu!O^H^QudDcr<*tuPWOOd)ONG5>n~H znhc~0YYG>!6O8#_b+9mv(C-~iWdL~9H8@S4mI)Cg+;ctlu(0FF8z1~72b38ocP zvapIU+%Tex6YBfLDF1sEBnn!8AUOf7Z1mH1%AM)Fxrw zz!f$U%>LZW!a9OkVGB~bg>@^iLs++Qk?jPto?&OZNI3j0F0u#MC#-up^%TME3B*4+ z4*LlsjD9(R)B!0M)`N^gjKhp0jHAMOjH}C%$61KW_J6HOMeac5XK5uaug6*LDs&fz zY_-YB^4kAtg3Du1@Xnax^%XmcyqI8d#b5+UsG*uQ|*f6ioo!MY~=fb8R1pogN@bU zGvTx0hUBW!;fa|kEl=%iIegGw=&x6&CbcMV5ccV;xz zY-_4{x~X^y$(RTrbba zd5fF@x7TxioU^DX=yMiT7W$mU?x4TEIJFEpfnc$#bd2K@>TgGx*Hglxv)}OuI0A0; zca_)U8cUt$x<13}DfW6&D!l%Hv+PrSRN}Y^H)i`>C2oHJZ7oLgsYlMEM@Dqsk?Zys zx;=$1+3_x?r!eS&GM0||u7s3q>Y=n0pVKqV=#&(hD01ofcDiso^@7+7x-+k-c2!gD z?xxy9XnymY1I>>wM%(lNl?v^2>N7LL+t34OedAoC{Vj)9p-hH9IJwyUu-lKP`V~zM zQ{k>x^<9a{nRdO`c|FFS>?tX8dW!Y@G>5!G8+>?bT09ainQ-ZW0mZW3$d8<;m!FWX z_iDoM<-7a=J;CLh<}Y=l(9B6ncnuVQfrJl3jYZ)TdU!?n2(&ucI3GGy>x(lkY(IPc z)c)o-e%NL#lm!ZmGMxqBIy=CoSO8>+@ny(r{;wBYhm%!Sc{H4>i-C?JakD zWJ^;ab$UYk(DSL;UU@AWjTyO@X5^-(+C|iKWR{2a$SN4|y2je@DRi`9@foeB*IeBF zRCB{AbVf?R=X3}BpJv1SoF1>IvfLZ=7gqSZGb$||TXf53(9M?LU+>kbC#A4O>oIYY zV0bd&!(=Upn=0p>^ah8TY8T*F4?r|CBfK8-?KB1%zq(Bysjt=Rsb}hoWyd(4ZOpvm z^;^&}XOF!z<<0nSdc;=6&W^1k({p`_k;%)Uo=D8giPvMs8g3vb0Mqm9`I*U>R>fXF z`vItt9$J7bzw`rsITK(9we{o(_BJn{f1!RZja%I3`plGYeRx-RIfkqC%u+}PjJ9Y4 zQqkDBd)w=&#^%NCjxC8h%$mi828;F-%^Z!#$cHWSFQ}tfWdQw`3+cPb6v;tmC zj~Td5FTj821gW2#tjC&acG3v0q-2A>*wM0S&BgV5nok^JourZ2HvVoHNlr3#O=?b2 z>2z1IoC+y9e374$6Lb|};0tgO+Eonmm;h(cRI|f;#5_F0G-Er;?3Sf5T)V;tF%3<( z(7Iw%^PJ;6UKw({#@;->?7RN4(vYve<6PfcKKmlJD$8(nIRS6cv{G+bIUDn^$8!xm zlUUF6Bv&xtFDfMm5@WZ!e6ZX~m~(uYi!Hmr5nm>+OFX17>GJ9_d4J8kQ|l5++EM+zC1U!DxKU*s?#^w?TM`$ zTrcfgc1QmnJ@MR;BdI^q)7)ilkDgQNfS3zDVd&CiqaJ@ zUTJDkDNIC1ip)YAFb8ddM{iYA%~mpPY0gojXA^#XW_EZRg0T$9XYud2i~%tB3(F2R zpIOxW^cikkr8K#rJ8%Ew3TW1p~IX+hfToP6_ z?ZRu^i~6p(*YsX-jY)yZ3Rh}56b?9jm96aFvXevP-W)k_|4D9FiC2%YS?<*H zjI?rBz{l&pE$ZmlsvM&auvGFFVjd)_ZFUzlO~=!G~HR| z#yXb6V_Kq_LT}oX@3AUrld$_kbcCOURbpv`YZ)V(k8RQe=~OOs2seU5$;B*dkcQbf zJA8(EkXMM!3+qtYu$Bo+<7ob*jCGdI_zO$Y|%{jOHavTi36@Sbr8}&PP1Q zHLmCJ*t9Had~%wzLawT8+6pYIkO&=u&y-T=;Y?{+v%O`*Q+j}Mz_ydO?Mt{bTD zZQA}QXBYq8iBBH}HHKW>;P%Y%2Y*XPUd}*{P$K?%{p-t9H zqT19aa0+e8&8N;y$#uG?Q}0^8#EML&Xw{0V(0-ZWOle-$&~oAoYzrk|s#|0TfrF46 z0E8x6hi*}-;9y7oNmU*g!Z0&2K!|n z%APLLy;F3KZut|U`Zdd25iE21jI4se&*{#=ujm08=LWyW;jIZigcgbM)vO0py5CT# z&ep*lQ6hBp^V#T-RBSfj!%|bRFPnz{2+{Hqql=$>TgT3DT->p?<@kQ|2U6&d zC&<*~#t?>${^+Pj^~Wv76Y=nZg?o-~e`8{7RVK$)+_q5O8(q@jOLzvGjyzvGZzND+ODQ}}`a;Yri_4Pm3 zp=p`CA4r9dpYAH5rJlTWV);H zV?>BRtYY`pvUpGPu31s-9!Gsnb`WP=R;3#&y>b3BInO%295n>mqhCW$M+V!Z!|!v# zyT#Z#)u89`Q(%{((B3kNqu8FGkXuv)(b~NS#ne7)A9BiJy zCQ7q=s!1Pdq45v$r7~^#0nD1b$ZC?#Mnbob?sE1TE$l)wkpGDRhHA|_Q%%6 z9*XLH+wvN;Dv=jJc3Nn=tEAhQ?haJBe7qM<&I!7!c&+XB`_cR3Xss<9$xB!4y|KjJ zM|4%%=8?&P#wKOQ@b^<$eVrDJd|EdQiJjxl^`?0b#p zq7j|@M)h~Um+psh*?ByXvx9y=?-o+zCBk!lGA_UZ7ND;ka?>fdPT1?)vqLAT7$Qg= zOng@3Ebd>&`LnaJ;cZ!sK+@(`GD#s)?mpuZ+8i^q;r)1)v4zQEyH3vXmeJ$`=@`V0 z3$I)^6wsBqaoI>6MMp5T3!;|3sVE*gOzVt{@Mbt1xnh_vmt-j|hqg4&X^85(w1|2) z8JBGqkni;Qozu~IU6KuT2h~JXb)2ILvfBs#xhf7}u;h=e8&nh3&>Q?sMnfHZ5Mp=W zxQdP)lI0cLObSV4&vO>-u3_Rh55X}TbdQ`fIdE)JhCab4JW2Z%xthW$o1ER-W)OE<7J-0V58HBxyF(Yw);X)%X9TBB!=kCJ|RZ|>nB`I!?hpohp zOCA^ON9an9Osa7WW0^?nKkQ1duKC}ilO_v>ws?xA?z}-cjS*UguyELzrxzAJ)qa%l zenI}lme@MakF6SKD4yyn^JAwkgXN_k7|Kg$e#xdWa_-$nijU2AxkH=bu?!oxC%6z1 zgV;@_C%9c@ULUTd{uv4G&~7B<3_u%jT(Fk3r_-LAV^KXnmg)^}n*~|&Obu&U_!%5e z&62@Bqjl}+sL6mMDvq&f)kZr?$J|L*QI2t}+vA^xrd+lc-JY@QW*i!J zz^?j8<9y6&Y-8lUrVhzj;j`FeA21GgF_p%(EJ3F%YU`IMV@v(SXr-;Q67i>5K4(c# z8d_H;8fb3NEx%*OnBd@r^PL-8o640}r;$XXq50!enh-TVwP@M3vgb{cVZ(*E(G5dt&nrR=X{<6S&zf-1w4Wk84saH>Jy;K4e+?Jj{v6u!9Zm`Wur&g1OP_WxjsMIanP^sN9V!E^EvJZZ{6;9-wb=mI? zft@}+tkdhGEVlD6uNoGc%JC&7&r{;`A@DXk?Z(Xpqy|=TswWiCP5qFUgEOu7lgdFX=qQ_@)?H4sd zsS_T0a8=o5(m@We|1shAoD-2H%XnvTuqvvFgT@^&nwZSTruH0cyx5Q93UNK(p6su1 zh7Mq;=!zm1w(_vEb!2>q?v&w?r)yY{$@wMC$#CH~ly6w{}YbMfx#RNIoh2WGWgYr&H@E;bkJj+Xr^(YTg1N841y8Y>b@&4FkONq8}bwP_g^ zxV9eBWK1`wExQiWI+xv?JvVg9H_he7Npfxw7jj16;vpzX)k$=2ttm@mybKCo}^=DhWh`QX5*zGpJ+dwULv?plAn z(Y5isOiAbCTH}3~JwE6UNQ20?_52AP8y`eU1|!7%8aZ*v%%S+77A&cuSw=u35~*`FM=9M_N(OcMQ#C;P3MNLiBPxuOe}LJ=vI* zcD|G91aPt?RSt=FW8@&u1^LbmB#v7N$u|tB(?F&lR*z4{nQyB8& z3x+9Va;K(ey73N#c4UZ3>6xxTDW|ZWf-9HHLOI#8oR$9J2{@*f$GNZxjQ!dCgM!V(u7$N_pJ@=+7ETGIcBk|?( ztpg5!Wh*|3?&pbedzo$=~<@Wm)zvl)~#a z>8Qu%ID^IR;qgA_WO88{l-4N?tIaenNE$!~kK>ZM-|0m?oJY&KcZ4*T?_;UMpTLFL z;V2)fpNu(c-eKk>EsWE2v`W4+#9TDjddq34aFFBBxR!%!(Mni*%c2E%OWbDX(nX`8 zDb?*7n(eN17Rw#;Rd*ORBqwFzFBxcPwizj1cvV81yBv8Xr(~adZ|flCCzvloF-o&a5SHoii2ENx$I!`lL(x%aRK;jqog!=vl6W?q$0gw2=sB}w>omOJqkf_o~? zf2H@T#Iq-We>%Yp&4SIl*uho8jwfB#y6w#QQ|rzjf2Q>eRuw04{!8mYX%9`cSgx~m zucq^?3DQBrI*hxd8PCMC+MFTni6^$1u#D#WSISQl8djV?wI=$2*w*zf*}pfM{hKNK zrwAVp_}mrVGP%p_`=epk^4MbdZA%Q_=-?Gdii1z8V(bnd_L%bfjl8H`1P=eXfb4p{ zqQwXrB;UWnV2r&otllh$LqxgHFkGUsIBcDNKn9;EzbJPVSd{Cg=4RMQl4rxxIIjLR z%G^7BUxv|(=l$f^ct2*I(_Jh_48bQY9tUnnPemd>-AGi@QFjKXLi>wlCT*jjIzCRK z_$faII^I+(cl}W-ck>QYL-LKoc=|eoXR$mSKa7KW+Cyc?fLgz)WzmM#)pMiFK4}a! zNe+ws9A8#B=vX+xISrq4*shUl!nQ@0=Qr6f9*u&icWmS>$uN`45s5LKV{(me9AuVm zND;&G`4TaSO)B!uLvCmR)%BBCM(pDEbJ&C#26=JEYP_0;L7AtKd@uRk#GmdpbJEq} zP53-&Ap#1HJP_^VmPUS`a|ACDG2RWxlpYzcIrZ>9yj8@fI&juCGA2%FGvy1jR^n@m zi}-S9)!BA-+(Etw9FtD{h4{#?ZD0#dX~Qvc*=_civUjfRv9aVUQsaH)E_Qr&zLeg% z-$!zd=fitaWJRr*@V&?XS}~!N`5}yK2Hu96JDLp4IQaz#eF)Pwz_#ulEcg3Zu+^eL zy$>I2Jxt!L-?^xnX6ta9G1HRx-Gd_upX=ZY1|JSS>`5*UUFAEH@MavBMbaTBVZB9S zc}Oom!)U6-7du#+?l*m~t^W$?C00hcU3^b;@np2y%`p0~#MUV@w(4Hv+$j;CjbY=4 z7X;|f{!+JRp7v&5U-!hoq@t~=Txt+@L`uvemPgK{5cn2Dz@d;rB4pJ z%ZdwK72cv!O>u4LRXn!X8E{T^`dx+c%Y?CZmp21}edO>&e8rhw?D7}E&C9P6<+q!Y zhMC#A%+_<|hjmFtecd+2InAY`gd=ODF7G-_hlaXqhO-=R<@Ac64<91JLwG%9l|$0p s6>Tbaa3Cy{1YtRTi>6Oc^Wbw({xxp!qs|c%tLchDpRe#O@07IwwjQ{`u literal 0 HcmV?d00001 diff --git a/management/server/testdata/geonames-test.db b/management/server/testdata/geonames-test.db new file mode 100644 index 0000000000000000000000000000000000000000..3e01c096543fa3b0c9f94a8ef33b9b40824a89e9 GIT binary patch literal 16384 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 T4_)9*I1BB7UT&A%<@i4Vv$T0( literal 0 HcmV?d00001