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 00000000000..bfbaa094590 Binary files /dev/null and b/management/server/testdata/GeoLite2-City-Test.mmdb differ diff --git a/management/server/testdata/geonames-test.db b/management/server/testdata/geonames-test.db new file mode 100644 index 00000000000..3e01c096543 Binary files /dev/null and b/management/server/testdata/geonames-test.db differ