Merge branch 'main' into profile-ownership

This commit is contained in:
Theodor S. Midtlien
2026-10-20 21:52:17 +02:00
121 changed files with 4002 additions and 2810 deletions
@@ -34,7 +34,7 @@ jobs:
while IFS= read -r dir; do while IFS= read -r dir; do
echo "=== Checking $dir ===" echo "=== Checking $dir ==="
# Search for problematic imports, excluding test files # Search for problematic imports, excluding test files
RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" | grep -v "tools/mysql-migrate/" || true) RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" || true)
if [ -n "$RESULTS" ]; then if [ -n "$RESULTS" ]; then
echo "❌ Found problematic dependencies:" echo "❌ Found problematic dependencies:"
echo "$RESULTS" echo "$RESULTS"
@@ -93,7 +93,7 @@ jobs:
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath") IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
# Check if any importer is NOT in management/signal/relay # Check if any importer is NOT in management/signal/relay
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\|tools/mysql-migrate\)" | head -1) BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
if [ -n "$BSD_IMPORTER" ]; then if [ -n "$BSD_IMPORTER" ]; then
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER" echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
+82
View File
@@ -3,9 +3,16 @@ name: Post release topic on Discourse
on: on:
release: release:
types: [published] types: [published]
workflow_run:
workflows: [Release]
types: [completed]
permissions:
contents: read
jobs: jobs:
post: post:
if: github.event_name == 'release'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0 - uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
@@ -15,3 +22,78 @@ jobs:
discourse-author-username: NetBird discourse-author-username: NetBird
discourse-category: 17 discourse-category: 17
discourse-tags: releases discourse-tags: releases
# goreleaser publishes the release of a pushed tag with GITHUB_TOKEN, and events
# raised by that token start no workflow, so the release event above never fires.
post-tag-release:
if: >-
github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.conclusion == 'success' &&
startsWith(github.event.workflow_run.head_branch, 'v') &&
!contains(github.event.workflow_run.head_branch, '-')
runs-on: ubuntu-latest
steps:
- name: Post the release topic
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.workflow_run.head_branch }}
DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
DISCOURSE_URL: https://forum.netbird.io
DISCOURSE_USER: NetBird
DISCOURSE_CATEGORY: "17"
run: |
set -euo pipefail
if ! release=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>gh.err); then
if grep -q 'HTTP 404' gh.err; then
echo "no release for ${TAG}, nothing to announce"
exit 0
fi
cat gh.err >&2
exit 1
fi
# A release a person published already fired the release event above.
author=$(jq -r '.author.login' <<<"$release")
if [ "$author" != "github-actions[bot]" ] || [ "$(jq -r '.draft' <<<"$release")" = "true" ]; then
echo "${TAG} published by ${author}, announced by the release event"
exit 0
fi
# Same title and body as the action above, so both paths read alike.
title="${GITHUB_REPOSITORY#*/} ${TAG} released"
# A re-run of a successful Release run fires this event again. A failed lookup
# stops the step rather than risk a duplicate topic.
search=$(curl -fsS --retry 3 --proto '=https' --proto-redir '=https' -G \
-H "Api-Key: ${DISCOURSE_API_KEY}" \
-H "Api-Username: ${DISCOURSE_USER}" \
"${DISCOURSE_URL}/search.json" \
--data-urlencode "q=\"${title}\" in:title")
existing=$(jq -r --arg t "$title" --argjson c "$DISCOURSE_CATEGORY" \
'[(.topics // [])[] | select(.category_id == $c and (.title | ascii_downcase) == ($t | ascii_downcase))] | length' \
<<<"$search")
if [ "$existing" != "0" ]; then
echo "topic '${title}' already exists"
exit 0
fi
# goreleaser leaves the body empty; the notes are written on the release page afterwards.
url=$(jq -r '.html_url' <<<"$release")
body=$(jq -r '.body // ""' <<<"$release")
if [ -z "${body//[[:space:]]/}" ]; then
body="Release notes and downloads: ${url}"
fi
jq -n \
--arg title "$title" \
--arg raw "$(printf '[%s](%s)\n\n%s' "$title" "$url" "$body")" \
--argjson category "$DISCOURSE_CATEGORY" \
'{title: $title, raw: $raw, category: $category, tags: ["releases"]}' \
| curl -fsS -X POST "${DISCOURSE_URL}/posts.json" \
-H "Api-Key: ${DISCOURSE_API_KEY}" \
-H "Api-Username: ${DISCOURSE_USER}" \
-H "Content-Type: application/json" \
--data @- \
| jq -r '"posted \(env.DISCOURSE_URL)/t/\(.topic_slug)/\(.topic_id)"'
+2 -6
View File
@@ -307,7 +307,7 @@ jobs:
sh -c ' \ sh -c ' \
apk update; apk add --no-cache \ apk update; apk add --no-cache \
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged -e tools/mysql-migrate) go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
' '
test_relay: test_relay:
@@ -498,7 +498,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
arch: ["amd64"] arch: ["amd64"]
store: ["sqlite", "postgres", "mysql"] store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout code - name: Checkout code
@@ -547,10 +547,6 @@ jobs:
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
- name: download mysql image
if: matrix.store == 'mysql'
run: docker pull mlsmaycon/warmed-mysql:8
# The -json stream goes through tools/gotestsummary so the log shows one # The -json stream goes through tools/gotestsummary so the log shows one
# line per test, the output of failed tests, the head of a timeout panic # line per test, the output of failed tests, the head of a timeout panic
# with the still-running tests, and the slowest tests per package. # with the still-running tests, and the slowest tests per package.
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
# resolve; the Where-Object pipeline then drops the broken package by # resolve; the Where-Object pipeline then drops the broken package by
# path. Without -e, go list aborts with empty stdout. # path. Without -e, go list aborts with empty stdout.
run: | run: |
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' } | Where-Object { $_ -notmatch '/tools/mysql-migrate' } $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' }
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
store: ["sqlite", "postgres", "mysql"] store: ["sqlite", "postgres"]
services: services:
postgres: postgres:
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }} image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
@@ -35,19 +35,6 @@ jobs:
--health-timeout 5s --health-timeout 5s
ports: ports:
- 5432:5432 - 5432:5432
mysql:
image: ${{ (matrix.store == 'mysql') && 'mysql' || '' }}
env:
MYSQL_USER: netbird
MYSQL_PASSWORD: mysql
MYSQL_ROOT_PASSWORD: mysqlroot
MYSQL_DATABASE: netbird
options: >-
--health-cmd "mysqladmin ping --silent"
--health-interval 10s
--health-timeout 5s
ports:
- 3306:3306
steps: steps:
- name: Set Database Connection String - name: Set Database Connection String
run: | run: |
@@ -56,11 +43,6 @@ jobs:
else else
echo "NETBIRD_STORE_ENGINE_POSTGRES_DSN==" >> $GITHUB_ENV echo "NETBIRD_STORE_ENGINE_POSTGRES_DSN==" >> $GITHUB_ENV
fi fi
if [ "${{ matrix.store }}" == "mysql" ]; then
echo "NETBIRD_STORE_ENGINE_MYSQL_DSN=netbird:mysql@tcp($(hostname -I | awk '{print $1}'):3306)/netbird" >> $GITHUB_ENV
else
echo "NETBIRD_STORE_ENGINE_MYSQL_DSN==" >> $GITHUB_ENV
fi
- name: Install jq - name: Install jq
run: sudo apt-get install -y jq run: sudo apt-get install -y jq
@@ -86,15 +68,6 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-go- ${{ runner.os }}-go-
- name: Setup MySQL privileges
if: matrix.store == 'mysql'
run: |
sleep 10
mysql -h 127.0.0.1 -u root -pmysqlroot -e "
GRANT SYSTEM_VARIABLES_ADMIN ON *.* TO 'netbird'@'%';
FLUSH PRIVILEGES;
"
- name: cp setup.env - name: cp setup.env
run: cp infrastructure_files/tests/setup.env infrastructure_files/ run: cp infrastructure_files/tests/setup.env infrastructure_files/
@@ -114,7 +87,6 @@ jobs:
CI_NETBIRD_AUTH_SUPPORTED_SCOPES: "openid profile email offline_access api email_verified" CI_NETBIRD_AUTH_SUPPORTED_SCOPES: "openid profile email offline_access api email_verified"
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }} CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
NETBIRD_STORE_ENGINE_POSTGRES_DSN: ${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }} NETBIRD_STORE_ENGINE_POSTGRES_DSN: ${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}
NETBIRD_STORE_ENGINE_MYSQL_DSN: ${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
- name: check values - name: check values
@@ -143,7 +115,6 @@ jobs:
CI_NETBIRD_SIGNAL_PORT: 12345 CI_NETBIRD_SIGNAL_PORT: 12345
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }} CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$" NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4" CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
@@ -182,7 +153,6 @@ jobs:
grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_SUPPORTED_SCOPES" grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_SUPPORTED_SCOPES"
grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep -A 3 RedirectURLs | grep "http://localhost:53000" grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep -A 3 RedirectURLs | grep "http://localhost:53000"
grep "external-ip" turnserver.conf | grep $CI_NETBIRD_TURN_EXTERNAL_IP grep "external-ip" turnserver.conf | grep $CI_NETBIRD_TURN_EXTERNAL_IP
grep "NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN" docker-compose.yml
grep NETBIRD_STORE_ENGINE_POSTGRES_DSN docker-compose.yml | egrep "$NETBIRD_STORE_ENGINE_POSTGRES_DSN" grep NETBIRD_STORE_ENGINE_POSTGRES_DSN docker-compose.yml | egrep "$NETBIRD_STORE_ENGINE_POSTGRES_DSN"
# check relay values # check relay values
grep "NB_EXPOSED_ADDRESS=rels://$CI_NETBIRD_DOMAIN:33445" docker-compose.yml grep "NB_EXPOSED_ADDRESS=rels://$CI_NETBIRD_DOMAIN:33445" docker-compose.yml
-26
View File
@@ -280,28 +280,6 @@ builds:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}" mod_timestamp: "{{ .CommitTimestamp }}"
- id: netbird-mysql-migrate
dir: tools/mysql-migrate
env:
- CGO_ENABLED=1
- >-
{{- if eq .Runtime.Goos "linux" }}
{{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }}
{{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }}
{{- end }}
binary: netbird-mysql-migrate
goos:
- linux
goarch:
- amd64
- arm64
- arm
goarm:
- 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
universal_binaries: universal_binaries:
- id: netbird - id: netbird
@@ -318,10 +296,6 @@ archives:
builds: builds:
- netbird-idp-migrate - netbird-idp-migrate
name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}" name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
- id: netbird-mysql-migrate
builds:
- netbird-mysql-migrate
name_template: "netbird-mysql-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
- id: nblink - id: nblink
builds: builds:
- nblink - nblink
+8 -14
View File
@@ -58,23 +58,17 @@ func (p *Preferences) SetManagementURL(url string) {
p.configInput.ManagementURL = url p.configInput.ManagementURL = url
} }
// GetAdminURL reads URL from config file // GetAdminURL returns the default admin panel URL.
//
// Deprecated: the admin URL is no longer stored or used by the client.
func (p *Preferences) GetAdminURL() (string, error) { func (p *Preferences) GetAdminURL() (string, error) {
if p.configInput.AdminURL != "" { return profilemanager.DefaultAdminURL, nil
return p.configInput.AdminURL, nil
}
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return "", err
}
return cfg.AdminURL.String(), err
} }
// SetAdminURL stores the given URL and waits for commit // SetAdminURL is a no-op kept for API compatibility.
func (p *Preferences) SetAdminURL(url string) { //
p.configInput.AdminURL = url // Deprecated: the admin URL is no longer stored or used by the client.
} func (p *Preferences) SetAdminURL(_ string) {}
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or // HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
// enforced by MDM; the key itself is never handed to the native layer. // enforced by MDM; the key itself is never handed to the native layer.
+3 -2
View File
@@ -43,13 +43,14 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
cfgFile := filepath.Join(t.TempDir(), "netbird.json") cfgFile := filepath.Join(t.TempDir(), "netbird.json")
p := NewPreferences(cfgFile) p := NewPreferences(cfgFile)
// The admin URL is deprecated: setting it is a no-op.
p.SetAdminURL(exampleString) p.SetAdminURL(exampleString)
resp, err := p.GetAdminURL() resp, err := p.GetAdminURL()
if err != nil { if err != nil {
t.Fatalf("failed to read admin url: %s", err) t.Fatalf("failed to read admin url: %s", err)
} }
if resp != exampleString { if resp != profilemanager.DefaultAdminURL {
t.Errorf("unexpected admin url: %s", resp) t.Errorf("unexpected admin url: %s", resp)
} }
@@ -95,7 +96,7 @@ func TestPreferences_Commit(t *testing.T) {
t.Fatalf("failed to read admin url: %s", err) t.Fatalf("failed to read admin url: %s", err)
} }
if resp != exampleURL { if resp != profilemanager.DefaultAdminURL {
t.Errorf("unexpected admin url: %s", resp) t.Errorf("unexpected admin url: %s", resp)
} }
+2 -1
View File
@@ -164,7 +164,8 @@ func init() {
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|npipe]://[path|name]. tcp://host:port is deprecated: it carries no caller identity, so the daemon refuses every request on it") rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|npipe]://[path|name]. tcp://host:port is deprecated: it carries no caller identity, so the daemon refuses every request on it")
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL)) rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL)) rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", "(DEPRECATED) Admin Panel URL - This flag is no longer functional")
_ = rootCmd.PersistentFlags().MarkDeprecated("admin-url", "the admin-url flag is no longer functional and will be removed in a future version")
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level") rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
rootCmd.PersistentFlags().StringSliceVar(&logFiles, "log-file", []string{defaultLogFile}, "sets NetBird log paths written to simultaneously. If `console` is specified the log will be output to stdout. If `syslog` is specified the log will be sent to syslog daemon. You can pass the flag multiple times or separate entries by `,` character") rootCmd.PersistentFlags().StringSliceVar(&logFiles, "log-file", []string{defaultLogFile}, "sets NetBird log paths written to simultaneously. If `console` is specified the log will be output to stdout. If `syslog` is specified the log will be sent to syslog daemon. You can pass the flag multiple times or separate entries by `,` character")
rootCmd.PersistentFlags().StringVarP(&setupKey, "setup-key", "k", "", "Setup key obtained from the Management Service Dashboard (used to register peer)") rootCmd.PersistentFlags().StringVarP(&setupKey, "setup-key", "k", "", "Setup key obtained from the Management Service Dashboard (used to register peer)")
-1
View File
@@ -503,7 +503,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
req.Username = username req.Username = username
req.ManagementUrl = managementURL req.ManagementUrl = managementURL
req.AdminURL = adminURL
req.NatExternalIPs = natExternalIPs req.NatExternalIPs = natExternalIPs
req.CustomDNSAddress = customDNSAddressConverted req.CustomDNSAddress = customDNSAddressConverted
req.ExtraIFaceBlacklist = extraIFaceBlackList req.ExtraIFaceBlacklist = extraIFaceBlackList
+16 -8
View File
@@ -12,6 +12,7 @@ import (
"golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/device"
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/udpmux"
"github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/iface/wgproxy" "github.com/netbirdio/netbird/client/iface/wgproxy"
@@ -34,14 +35,14 @@ func (f *fakeTunDevice) Create() (device.WGConfigurer, error) {
func (f *fakeTunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { func (f *fakeTunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (f *fakeTunDevice) UpdateAddr(wgaddr.Address) error { return nil } func (f *fakeTunDevice) UpdateAddr(wgaddr.Address) error { return nil }
func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr.Address{} } func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr.Address{} }
func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU } func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU }
func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" } func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" }
func (f *fakeTunDevice) FilteredDevice() *device.FilteredDevice { return nil } func (f *fakeTunDevice) FilteredDevice() *device.FilteredDevice { return nil }
func (f *fakeTunDevice) Device() *wgdevice.Device { return nil } func (f *fakeTunDevice) Device() *wgdevice.Device { return nil }
func (f *fakeTunDevice) GetNet() *netstack.Net { return nil } func (f *fakeTunDevice) GetNet() *netstack.Net { return nil }
func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil } func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil }
func (f *fakeTunDevice) Close() error { func (f *fakeTunDevice) Close() error {
close(f.closeStarted) close(f.closeStarted)
@@ -62,6 +63,10 @@ func (fakeProxyFactory) Free() error { return nil }
// WGIface.GetDevice() which needs the same mutex. The fix is to drop // WGIface.GetDevice() which needs the same mutex. The fix is to drop
// the lock before tun.Close() returns control. // the lock before tun.Close() returns control.
func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) { func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) {
// Netstack mode skips the platform interface cleanup in Close, so only
// the mutex and tun close behavior is exercised.
t.Setenv(nbnetstack.EnvUseNetstackMode, "true")
tun := &fakeTunDevice{ tun := &fakeTunDevice{
closeStarted: make(chan struct{}), closeStarted: make(chan struct{}),
unblockClose: make(chan struct{}), unblockClose: make(chan struct{}),
@@ -105,7 +110,10 @@ func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) {
close(tun.unblockClose) close(tun.unblockClose)
select { select {
case <-closeDone: case err := <-closeDone:
if err != nil {
t.Errorf("WGIface.Close() returned an error: %v", err)
}
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal("WGIface.Close() never returned after the tun was unblocked") t.Fatal("WGIface.Close() never returned after the tun was unblocked")
} }
-7
View File
@@ -179,7 +179,6 @@ The profiles.txt file lists every profile JSON on disk, including profiles the r
Configuration Configuration
The config.txt file contains anonymized configuration information of the NetBird client. Sensitive information such as private keys and SSH keys are excluded. The following fields are anonymized: The config.txt file contains anonymized configuration information of the NetBird client. Sensitive information such as private keys and SSH keys are excluded. The following fields are anonymized:
- ManagementURL - ManagementURL
- AdminURL
- NATExternalIPs - NATExternalIPs
- CustomDNSAddress - CustomDNSAddress
@@ -611,9 +610,6 @@ func (g *BundleGenerator) addConfig() error {
if g.internalConfig.ManagementURL != nil { if g.internalConfig.ManagementURL != nil {
configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()))) configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String())))
} }
if g.internalConfig.AdminURL != nil {
configContent.WriteString(fmt.Sprintf("AdminURL: %s\n", g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.String())))
}
configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", anonymizeNATExternalIPs(g.internalConfig.NATExternalIPs, g.anonymizer))) configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", anonymizeNATExternalIPs(g.internalConfig.NATExternalIPs, g.anonymizer)))
if g.internalConfig.CustomDNSAddress != "" { if g.internalConfig.CustomDNSAddress != "" {
configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress))) configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress)))
@@ -622,9 +618,6 @@ func (g *BundleGenerator) addConfig() error {
if g.internalConfig.ManagementURL != nil { if g.internalConfig.ManagementURL != nil {
configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.internalConfig.ManagementURL.String())) configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.internalConfig.ManagementURL.String()))
} }
if g.internalConfig.AdminURL != nil {
configContent.WriteString(fmt.Sprintf("AdminURL: %s\n", g.internalConfig.AdminURL.String()))
}
configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", g.internalConfig.NATExternalIPs)) configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", g.internalConfig.NATExternalIPs))
if g.internalConfig.CustomDNSAddress != "" { if g.internalConfig.CustomDNSAddress != "" {
configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.internalConfig.CustomDNSAddress)) configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.internalConfig.CustomDNSAddress))
+1 -9
View File
@@ -858,14 +858,12 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
} }
mURL, _ := url.Parse("https://api.example.com:443") mURL, _ := url.Parse("https://api.example.com:443")
aURL, _ := url.Parse("https://admin.example.com:443")
bTrue := true bTrue := true
iVal := 42 iVal := 42
cfg := &profilemanager.Config{ cfg := &profilemanager.Config{
PrivateKey: "priv", PrivateKey: "priv",
PreSharedKey: "psk", PreSharedKey: "psk",
ManagementURL: mURL, ManagementURL: mURL,
AdminURL: aURL,
WgIface: "wt0", WgIface: "wt0",
WgPort: 51820, WgPort: 51820,
NetworkMonitor: &bTrue, NetworkMonitor: &bTrue,
@@ -949,7 +947,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
} }
// renderAddConfigSpecific renders the fields handled by the anonymize/non-anonymize // renderAddConfigSpecific renders the fields handled by the anonymize/non-anonymize
// branches in addConfig (ManagementURL, AdminURL, NATExternalIPs, CustomDNSAddress). // branches in addConfig (ManagementURL, NATExternalIPs, CustomDNSAddress).
// addCommonConfigFields covers the rest. Keeping this in the test mirrors the // addCommonConfigFields covers the rest. Keeping this in the test mirrors the
// production shape without needing to write an actual zip. // production shape without needing to write an actual zip.
func renderAddConfigSpecific(g *BundleGenerator) string { func renderAddConfigSpecific(g *BundleGenerator) string {
@@ -958,9 +956,6 @@ func renderAddConfigSpecific(g *BundleGenerator) string {
if g.internalConfig.ManagementURL != nil { if g.internalConfig.ManagementURL != nil {
sb.WriteString("ManagementURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()) + "\n") sb.WriteString("ManagementURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()) + "\n")
} }
if g.internalConfig.AdminURL != nil {
sb.WriteString("AdminURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.String()) + "\n")
}
sb.WriteString("NATExternalIPs: x\n") sb.WriteString("NATExternalIPs: x\n")
if g.internalConfig.CustomDNSAddress != "" { if g.internalConfig.CustomDNSAddress != "" {
sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n") sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n")
@@ -969,9 +964,6 @@ func renderAddConfigSpecific(g *BundleGenerator) string {
if g.internalConfig.ManagementURL != nil { if g.internalConfig.ManagementURL != nil {
sb.WriteString("ManagementURL: " + g.internalConfig.ManagementURL.String() + "\n") sb.WriteString("ManagementURL: " + g.internalConfig.ManagementURL.String() + "\n")
} }
if g.internalConfig.AdminURL != nil {
sb.WriteString("AdminURL: " + g.internalConfig.AdminURL.String() + "\n")
}
sb.WriteString("NATExternalIPs: x\n") sb.WriteString("NATExternalIPs: x\n")
if g.internalConfig.CustomDNSAddress != "" { if g.internalConfig.CustomDNSAddress != "" {
sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n") sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n")
+64 -50
View File
@@ -35,11 +35,7 @@ func parseBackupResolvConf() (*resolvConf, error) {
} }
func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) { func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) {
rconf := &resolvConf{ rconf := newResolvConf()
searchDomains: make([]string, 0),
nameServers: make([]netip.Addr, 0),
others: make([]string, 0),
}
file, err := os.Open(resolvConfFile) file, err := os.Open(resolvConfFile)
if err != nil { if err != nil {
@@ -56,56 +52,74 @@ func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) {
return rconf, fmt.Errorf("failed to read %s file: %w", resolvConfFile, err) return rconf, fmt.Errorf("failed to read %s file: %w", resolvConfFile, err)
} }
return parseResolvConf(cur)
}
// parseResolvConf parses resolv.conf content. The returned config is never nil, so callers can
// fall back to its empty lists on error.
func parseResolvConf(cur []byte) (*resolvConf, error) {
rconf := newResolvConf()
if len(cur) == 0 { if len(cur) == 0 {
return rconf, fmt.Errorf("file is empty") return rconf, fmt.Errorf("file is empty")
} }
for _, line := range strings.Split(string(cur), "\n") { for _, line := range strings.Split(string(cur), "\n") {
line = strings.TrimSpace(line) parseResolvConfLine(rconf, strings.TrimSpace(line))
if strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "domain") {
continue
}
if strings.HasPrefix(line, "options") && strings.Contains(line, "rotate") {
line = strings.ReplaceAll(line, "rotate", "")
splitLines := strings.Fields(line)
if len(splitLines) == 1 {
continue
}
line = strings.Join(splitLines, " ")
}
if strings.HasPrefix(line, "search") {
splitLines := strings.Fields(line)
if len(splitLines) < 2 {
continue
}
rconf.searchDomains = splitLines[1:]
continue
}
if strings.HasPrefix(line, "nameserver") {
splitLines := strings.Fields(line)
if len(splitLines) != 2 {
continue
}
if addr, err := netip.ParseAddr(splitLines[1]); err == nil {
rconf.nameServers = append(rconf.nameServers, addr.Unmap())
} else {
log.Warnf("invalid nameserver address in resolv.conf: %s, skipping", splitLines[1])
}
continue
}
if line != "" {
rconf.others = append(rconf.others, line)
}
} }
return rconf, nil return rconf, nil
} }
func parseResolvConfLine(rconf *resolvConf, line string) {
if strings.HasPrefix(line, "#") {
return
}
if strings.HasPrefix(line, "domain") {
return
}
if strings.HasPrefix(line, "options") && strings.Contains(line, "rotate") {
line = strings.ReplaceAll(line, "rotate", "")
splitLines := strings.Fields(line)
if len(splitLines) == 1 {
return
}
line = strings.Join(splitLines, " ")
}
if strings.HasPrefix(line, "search") {
splitLines := strings.Fields(line)
if len(splitLines) < 2 {
return
}
rconf.searchDomains = splitLines[1:]
return
}
if strings.HasPrefix(line, "nameserver") {
splitLines := strings.Fields(line)
if len(splitLines) != 2 {
return
}
if addr, err := netip.ParseAddr(splitLines[1]); err == nil {
rconf.nameServers = append(rconf.nameServers, addr.Unmap())
} else {
log.Warnf("invalid nameserver address in resolv.conf: %s, skipping", splitLines[1])
}
return
}
if line != "" {
rconf.others = append(rconf.others, line)
}
}
func newResolvConf() *resolvConf {
return &resolvConf{
searchDomains: make([]string, 0),
nameServers: make([]netip.Addr, 0),
others: make([]string, 0),
}
}
+69 -38
View File
@@ -3,7 +3,9 @@
package dns package dns
import ( import (
"bytes"
"net/netip" "net/netip"
"os"
"path" "path"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -23,7 +25,8 @@ var (
} }
) )
type repairConfFn func([]string, netip.Addr, *resolvConf, *statemanager.Manager) error // repairConfFn writes the NetBird-managed resolv.conf and returns the exact content it wrote.
type repairConfFn func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error)
type repair struct { type repair struct {
operationFile string operationFile string
@@ -43,7 +46,9 @@ func newRepair(operationFile string, updateFn repairConfFn) *repair {
} }
} }
func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP netip.Addr, stateManager *statemanager.Manager) { // watchFileChanges keeps resolv.conf pointing at NetBird. written is the content NetBird last
// wrote to the file, used to recognize the echo of its own writes.
func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP netip.Addr, written []byte, stateManager *statemanager.Manager) {
if f.inotify != nil { if f.inotify != nil {
return return
} }
@@ -54,54 +59,80 @@ func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP netip
log.Errorf("failed to start inotify watcher for resolv.conf: %s", err) log.Errorf("failed to start inotify watcher for resolv.conf: %s", err)
return return
} }
if err := inotify.Add(f.watchDir); err != nil {
log.Errorf("failed to add inotify watch for resolv.conf: %s", err)
if err := inotify.Close(); err != nil {
log.Debugf("failed to close resolv.conf inotify: %v", err)
}
return
}
f.inotify = inotify f.inotify = inotify
f.inotifyWg.Add(1) f.inotifyWg.Add(1)
go func() { go func() {
defer f.inotifyWg.Done() defer f.inotifyWg.Done()
for event := range f.inotify.Events {
if !f.isEventRelevant(event) {
continue
}
log.Tracef("%s changed, check if it is broken", f.operationFile) // A change that landed before the watch was armed produced no event.
lastWritten := f.checkAndRepair(nbSearchDomains, nbNameserverIP, written, stateManager)
rConf, err := parseResolvConfFile(f.operationFile) for {
if err != nil { select {
log.Warnf("failed to parse resolv conf: %s", err) case event, ok := <-inotify.Events:
continue if !ok {
} return
}
log.Debugf("check resolv.conf parameters: %s", rConf) if !f.isEventRelevant(event) {
if !isNbParamsMissing(nbSearchDomains, nbNameserverIP, rConf) { continue
log.Tracef("resolv.conf still correct, skip the update") }
continue case err, ok := <-inotify.Errors:
} if !ok {
log.Info("broken params in resolv.conf, repairing it...") return
}
err = f.inotify.Remove(f.watchDir) // fsnotify stops delivering events until an error is received, and an
if err != nil { // error such as a queue overflow means events were dropped.
log.Errorf("failed to rm inotify watch for resolv.conf: %s", err) log.Warnf("resolv.conf inotify error, checking the file: %v", err)
}
err = f.updateFn(nbSearchDomains, nbNameserverIP, rConf, stateManager)
if err != nil {
log.Errorf("failed to repair resolv.conf: %v", err)
}
err = f.inotify.Add(f.watchDir)
if err != nil {
log.Errorf("failed to re-add inotify watch for resolv.conf: %s", err)
return
} }
lastWritten = f.checkAndRepair(nbSearchDomains, nbNameserverIP, lastWritten, stateManager)
} }
}() }()
}
err = f.inotify.Add(f.watchDir) // checkAndRepair rewrites resolv.conf when NetBird's parameters are missing from it and returns
// the content NetBird last wrote. The watch stays armed while repairing, so a third-party write
// racing the repair still raises an event; the echo of NetBird's own write is skipped by content.
func (f *repair) checkAndRepair(nbSearchDomains []string, nbNameserverIP netip.Addr, lastWritten []byte, stateManager *statemanager.Manager) []byte {
content, err := os.ReadFile(f.operationFile)
if err != nil { if err != nil {
log.Errorf("failed to add inotify watch for resolv.conf: %s", err) log.Warnf("failed to read resolv.conf: %s", err)
return return lastWritten
} }
if bytes.Equal(content, lastWritten) {
log.Tracef("%s still holds the NetBird managed content, skip the check", f.operationFile)
return lastWritten
}
log.Tracef("%s changed, check if it is broken", f.operationFile)
rConf, err := parseResolvConf(content)
if err != nil {
log.Warnf("failed to parse resolv conf: %s", err)
return lastWritten
}
log.Debugf("check resolv.conf parameters: %s", rConf)
if !isNbParamsMissing(nbSearchDomains, nbNameserverIP, rConf) {
log.Tracef("resolv.conf still correct, skip the update")
return lastWritten
}
log.Info("broken params in resolv.conf, repairing it...")
written, err := f.updateFn(nbSearchDomains, nbNameserverIP, rConf, stateManager)
if err != nil {
log.Errorf("failed to repair resolv.conf: %v", err)
return lastWritten
}
return written
} }
func (f *repair) stopWatchFileChanges() { func (f *repair) stopWatchFileChanges() {
+159 -6
View File
@@ -4,12 +4,19 @@ package dns
import ( import (
"context" "context"
"fmt"
"net/netip" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync/atomic"
"testing" "testing"
"time" "time"
"github.com/fsnotify/fsnotify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util"
) )
@@ -106,14 +113,14 @@ nameserver 8.8.8.8`,
var changed bool var changed bool
ctx, cancel := context.WithTimeout(context.Background(), time.Second) ctx, cancel := context.WithTimeout(context.Background(), time.Second)
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) error { updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
changed = true changed = true
cancel() cancel()
return nil return nil, nil
} }
r := newRepair(operationFile, updateFn) r := newRepair(operationFile, updateFn)
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil) r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil, nil)
err = os.WriteFile(operationFile, []byte(tt.touchedConfContent), 0755) err = os.WriteFile(operationFile, []byte(tt.touchedConfContent), 0755)
if err != nil { if err != nil {
@@ -153,14 +160,14 @@ searchdomain netbird.cloud something`
var changed bool var changed bool
ctx, cancel := context.WithTimeout(context.Background(), time.Second) ctx, cancel := context.WithTimeout(context.Background(), time.Second)
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) error { updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
changed = true changed = true
cancel() cancel()
return nil return nil, nil
} }
r := newRepair(tmpLink, updateFn) r := newRepair(tmpLink, updateFn)
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil) r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil, nil)
err = os.WriteFile(tmpLink, []byte(modifyContent), 0755) err = os.WriteFile(tmpLink, []byte(modifyContent), 0755)
if err != nil { if err != nil {
@@ -175,3 +182,149 @@ searchdomain netbird.cloud something`
t.Errorf("unexpected result: want: %v, got: %v", true, false) t.Errorf("unexpected result: want: %v, got: %v", true, false)
} }
} }
// systemd-resolved can rewrite resolv.conf twice in a row, e.g. on restart. When the second write
// lands right after NetBird's repair, the watcher must still see it and repair again; otherwise the
// host silently stays on the upstream resolver until the next unrelated change of the file.
func Test_repairCatchesWriteRacingRepair(t *testing.T) {
good := "nameserver 10.0.0.1\nsearch netbird.cloud\n"
upstream := "nameserver 8.8.8.8\nsearch .\n"
operationFile := filepath.Join(t.TempDir(), "resolv.conf")
require.NoError(t, os.WriteFile(operationFile, []byte(good), 0644))
var calls atomic.Int32
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
n := calls.Add(1)
if err := os.WriteFile(operationFile, []byte(good), 0644); err != nil {
return nil, err
}
if n == 1 {
// the third party's second write lands right after the repair
if err := writeFileAtomic(operationFile, upstream); err != nil {
return nil, err
}
}
return []byte(good), nil
}
r := newRepair(operationFile, updateFn)
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), []byte(good), nil)
t.Cleanup(r.stopWatchFileChanges)
require.NoError(t, writeFileAtomic(operationFile, upstream))
assert.Eventually(t, func() bool {
content, err := os.ReadFile(operationFile)
return err == nil && string(content) == good
}, 2*time.Second, 10*time.Millisecond, "resolv.conf should be repaired after the racing write")
assert.GreaterOrEqual(t, calls.Load(), int32(2), "the racing write should trigger a second repair")
}
// A change made between NetBird's write and the arming of the watch produces no event, so the
// watcher must check the file as soon as it starts.
func Test_repairChecksFileWhenArmed(t *testing.T) {
good := "nameserver 10.0.0.1\nsearch netbird.cloud\n"
operationFile := filepath.Join(t.TempDir(), "resolv.conf")
require.NoError(t, os.WriteFile(operationFile, []byte("nameserver 8.8.8.8\n"), 0644))
repaired := make(chan struct{}, 1)
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
select {
case repaired <- struct{}{}:
default:
}
return []byte(good), os.WriteFile(operationFile, []byte(good), 0644)
}
r := newRepair(operationFile, updateFn)
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), []byte(good), nil)
t.Cleanup(r.stopWatchFileChanges)
select {
case <-repaired:
case <-time.After(2 * time.Second):
t.Fatal("watcher should repair a file changed before it was armed")
}
}
// When NetBird has more search domains than resolv.conf can hold, its own write lacks some of them
// and fails the parameter check. The watcher must recognize that write as its own and not repair
// it again in a loop.
func Test_repairNoLoopOnTruncatedSearchDomains(t *testing.T) {
nbDomains := make([]string, 0, fileMaxNumberOfSearchDomains+2)
for i := range fileMaxNumberOfSearchDomains + 2 {
nbDomains = append(nbDomains, fmt.Sprintf("d%d.netbird.cloud", i))
}
truncated := fmt.Sprintf("nameserver 10.0.0.1\nsearch %s\n", strings.Join(nbDomains[:fileMaxNumberOfSearchDomains], " "))
operationFile := filepath.Join(t.TempDir(), "resolv.conf")
require.NoError(t, os.WriteFile(operationFile, []byte(truncated), 0644))
var calls atomic.Int32
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
calls.Add(1)
return []byte(truncated), os.WriteFile(operationFile, []byte(truncated), 0644)
}
r := newRepair(operationFile, updateFn)
r.watchFileChanges(nbDomains, netip.MustParseAddr("10.0.0.1"), []byte(truncated), nil)
t.Cleanup(r.stopWatchFileChanges)
require.NoError(t, writeFileAtomic(operationFile, "nameserver 8.8.8.8\n"))
require.Eventually(t, func() bool { return calls.Load() >= 1 }, 2*time.Second, 10*time.Millisecond,
"the third-party write should be repaired")
time.Sleep(500 * time.Millisecond)
assert.Equal(t, int32(1), calls.Load(), "NetBird's own truncated write should not trigger another repair")
}
// fsnotify blocks its reader until an error is received, and an error such as a queue overflow
// means events were dropped. The watcher must consume errors and check the file, so it keeps working
// and catches a change whose event was lost.
func Test_repairChecksFileOnWatcherError(t *testing.T) {
good := "nameserver 10.0.0.1\nsearch netbird.cloud\n"
operationFile := filepath.Join(t.TempDir(), "resolv.conf")
require.NoError(t, os.WriteFile(operationFile, []byte(good), 0644))
// A hard link in another directory changes the file without an event in the watched one,
// standing in for an event the kernel dropped.
alias := filepath.Join(t.TempDir(), "alias")
require.NoError(t, os.Link(operationFile, alias))
repaired := make(chan struct{}, 1)
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
select {
case repaired <- struct{}{}:
default:
}
return []byte(good), os.WriteFile(operationFile, []byte(good), 0644)
}
r := newRepair(operationFile, updateFn)
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), []byte(good), nil)
t.Cleanup(r.stopWatchFileChanges)
require.NoError(t, os.WriteFile(alias, []byte("nameserver 8.8.8.8\n"), 0644))
select {
case r.inotify.Errors <- fsnotify.ErrEventOverflow:
case <-time.After(2 * time.Second):
t.Fatal("watcher should consume inotify errors")
}
select {
case <-repaired:
case <-time.After(2 * time.Second):
t.Fatal("watcher should check the file after an inotify error")
}
}
// writeFileAtomic replaces the file the way systemd-resolved does: write a temporary file in the same
// directory, then rename it over the target.
func writeFileAtomic(target, content string) error {
tmp := target + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0644); err != nil {
return err
}
return os.Rename(tmp, target)
}
+5 -5
View File
@@ -61,11 +61,11 @@ func (f *fileConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *st
f.repair.stopWatchFileChanges() f.repair.stopWatchFileChanges()
err = f.updateConfig(nbSearchDomains, f.nbNameserverIP, resolvConf, stateManager) written, err := f.updateConfig(nbSearchDomains, f.nbNameserverIP, resolvConf, stateManager)
if err != nil { if err != nil {
return err return err
} }
f.repair.watchFileChanges(nbSearchDomains, f.nbNameserverIP, stateManager) f.repair.watchFileChanges(nbSearchDomains, f.nbNameserverIP, written, stateManager)
return nil return nil
} }
@@ -74,7 +74,7 @@ func (f *fileConfigurator) getOriginalNameservers() []netip.Addr {
return f.originalNameservers return f.originalNameservers
} }
func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP netip.Addr, cfg *resolvConf, stateManager *statemanager.Manager) error { func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP netip.Addr, cfg *resolvConf, stateManager *statemanager.Manager) ([]byte, error) {
searchDomainList := mergeSearchDomains(nbSearchDomains, cfg.searchDomains) searchDomainList := mergeSearchDomains(nbSearchDomains, cfg.searchDomains)
buf := prepareResolvConfContent( buf := prepareResolvConfContent(
@@ -90,7 +90,7 @@ func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP
if restoreErr != nil { if restoreErr != nil {
log.Errorf("attempt to restore default file failed with error: %s", err) log.Errorf("attempt to restore default file failed with error: %s", err)
} }
return fmt.Errorf("creating resolver file %s. Error: %w", defaultResolvConfPath, err) return nil, fmt.Errorf("creating resolver file %s. Error: %w", defaultResolvConfPath, err)
} }
log.Infof("created a NetBird managed %s file with the DNS settings. Added %d search domains. Search list: %s", defaultResolvConfPath, len(searchDomainList), searchDomainList) log.Infof("created a NetBird managed %s file with the DNS settings. Added %d search domains. Search list: %s", defaultResolvConfPath, len(searchDomainList), searchDomainList)
@@ -100,7 +100,7 @@ func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP
log.Errorf("failed to create unclean shutdown resolv.conf backup: %s", err) log.Errorf("failed to create unclean shutdown resolv.conf backup: %s", err)
} }
return nil return buf.Bytes(), nil
} }
func (f *fileConfigurator) restoreHostDNS() error { func (f *fileConfigurator) restoreHostDNS() error {
+3 -47
View File
@@ -37,7 +37,8 @@ const (
DefaultManagementURL = "https://api.netbird.io:443" DefaultManagementURL = "https://api.netbird.io:443"
// oldDefaultManagementURL points to the NetBird's old cloud management endpoint // oldDefaultManagementURL points to the NetBird's old cloud management endpoint
oldDefaultManagementURL = "https://api.wiretrustee.com:443" oldDefaultManagementURL = "https://api.wiretrustee.com:443"
// DefaultAdminURL points to NetBird's cloud management console // DefaultAdminURL points to NetBird's cloud management console. It is only
// reported by the deprecated mobile admin URL getters.
DefaultAdminURL = "https://app.netbird.io:443" DefaultAdminURL = "https://app.netbird.io:443"
) )
@@ -61,7 +62,6 @@ var DefaultInterfaceBlacklist = []string{
// ConfigInput carries configuration changes to the client // ConfigInput carries configuration changes to the client
type ConfigInput struct { type ConfigInput struct {
ManagementURL string ManagementURL string
AdminURL string
ConfigPath string ConfigPath string
StateFilePath string StateFilePath string
PreSharedKey *string PreSharedKey *string
@@ -120,7 +120,6 @@ type Config struct {
PrivateKey string PrivateKey string
PreSharedKey string PreSharedKey string
ManagementURL *url.URL ManagementURL *url.URL
AdminURL *url.URL
WgIface string WgIface string
WgPort int WgPort int
NetworkMonitor *bool NetworkMonitor *bool
@@ -520,29 +519,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
} }
} }
if config.AdminURL == nil {
log.Infof("using default Admin URL %s", DefaultAdminURL)
config.AdminURL, err = parseURL("Admin URL", DefaultAdminURL)
if err != nil {
return false, err
}
}
// The admin panel is opened, not dialed, so unlike the Management URL its
// path is part of what identifies it: a panel served under /netbird is not
// the one served at the root.
if input.AdminURL != "" {
newURL, err := parseURL("Admin Panel URL", input.AdminURL)
if err != nil {
return updated, err
}
if !SameServiceURLIncludingPath(newURL, config.AdminURL) {
log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)",
newURL.String(), config.AdminURL.String())
config.AdminURL = newURL
updated = true
}
}
if input.WireguardPort != nil && *input.WireguardPort != config.WgPort { if input.WireguardPort != nil && *input.WireguardPort != config.WgPort {
log.Infof("updating Wireguard port %d (old value %d)", log.Infof("updating Wireguard port %d (old value %d)",
*input.WireguardPort, config.WgPort) *input.WireguardPort, config.WgPort)
@@ -1046,8 +1022,7 @@ func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
// matches only another nil one. // matches only another nil one.
// //
// The path plays no part: a management URL is dialed, and only its host and // The path plays no part: a management URL is dialed, and only its host and
// port are. util.SameServiceURL is this comparison plus the path, which is // port are. util.SameServiceURL is this comparison plus the path.
// what SameServiceURLIncludingPath needs and delegates to.
func SameServiceURL(a, b *url.URL) bool { func SameServiceURL(a, b *url.URL) bool {
if a == nil || b == nil { if a == nil || b == nil {
return a == b return a == b
@@ -1058,25 +1033,6 @@ func SameServiceURL(a, b *url.URL) bool {
util.ServiceURLPort(a) == util.ServiceURLPort(b) util.ServiceURLPort(a) == util.ServiceURLPort(b)
} }
// SameServiceURLIncludingPath is SameServiceURL plus everything a URL carries
// past its endpoint: path, query, fragment and userinfo.
//
// Use it for a URL that gets opened rather than dialed. The admin panel can
// live under a path, so two URLs with the same endpoint and different paths are
// two different panels — where for a URL the client dials over gRPC only the
// endpoint is ever used. Equivalent spellings still compare equal: a missing
// path and "/" are the same root, and so is a trailing slash on any path.
func SameServiceURLIncludingPath(a, b *url.URL) bool {
if a == nil || b == nil {
return a == b
}
return util.SameServiceURL(a, b) &&
a.RawQuery == b.RawQuery &&
a.Fragment == b.Fragment &&
a.User.String() == b.User.String()
}
func parseURL(serviceName, serviceURL string) (*url.URL, error) { func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL) parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
if err != nil { if err != nil {
@@ -36,17 +36,14 @@ func TestGetConfig(t *testing.T) {
} }
assert.Equal(t, config.ManagementURL.String(), DefaultManagementURL) assert.Equal(t, config.ManagementURL.String(), DefaultManagementURL)
assert.Equal(t, config.AdminURL.String(), DefaultAdminURL)
managementURL := "https://test.management.url:33071" managementURL := "https://test.management.url:33071"
adminURL := "https://app.admin.url:443"
path := filepath.Join(t.TempDir(), "config.json") path := filepath.Join(t.TempDir(), "config.json")
preSharedKey := "preSharedKey" preSharedKey := "preSharedKey"
// case 2: new config has to be generated // case 2: new config has to be generated
config, err = UpdateOrCreateConfig(ConfigInput{ config, err = UpdateOrCreateConfig(ConfigInput{
ManagementURL: managementURL, ManagementURL: managementURL,
AdminURL: adminURL,
ConfigPath: path, ConfigPath: path,
PreSharedKey: &preSharedKey, PreSharedKey: &preSharedKey,
}) })
@@ -64,7 +61,6 @@ func TestGetConfig(t *testing.T) {
// case 3: existing config -> fetch it // case 3: existing config -> fetch it
config, err = UpdateOrCreateConfig(ConfigInput{ config, err = UpdateOrCreateConfig(ConfigInput{
ManagementURL: managementURL, ManagementURL: managementURL,
AdminURL: adminURL,
ConfigPath: path, ConfigPath: path,
PreSharedKey: &preSharedKey, PreSharedKey: &preSharedKey,
}) })
@@ -79,7 +75,6 @@ func TestGetConfig(t *testing.T) {
newManagementURL := "https://test.newManagement.url:33071" newManagementURL := "https://test.newManagement.url:33071"
config, err = UpdateOrCreateConfig(ConfigInput{ config, err = UpdateOrCreateConfig(ConfigInput{
ManagementURL: newManagementURL, ManagementURL: newManagementURL,
AdminURL: adminURL,
ConfigPath: path, ConfigPath: path,
PreSharedKey: &preSharedKey, PreSharedKey: &preSharedKey,
}) })
@@ -164,7 +159,6 @@ func TestNewProfileDefaults(t *testing.T) {
require.NoError(t, err, "should create new config") require.NoError(t, err, "should create new config")
assert.Equal(t, DefaultManagementURL, config.ManagementURL.String(), "ManagementURL should have default") assert.Equal(t, DefaultManagementURL, config.ManagementURL.String(), "ManagementURL should have default")
assert.Equal(t, DefaultAdminURL, config.AdminURL.String(), "AdminURL should have default")
assert.NotEmpty(t, config.PrivateKey, "PrivateKey should be generated") assert.NotEmpty(t, config.PrivateKey, "PrivateKey should be generated")
assert.NotEmpty(t, config.SSHKey, "SSHKey should be generated") assert.NotEmpty(t, config.SSHKey, "SSHKey should be generated")
assert.Equal(t, iface.WgInterfaceDefault, config.WgIface, "WgIface should have default") assert.Equal(t, iface.WgInterfaceDefault, config.WgIface, "WgIface should have default")
@@ -334,40 +334,34 @@ func TestCreateInMemoryConfigCarriesAnIdentity(t *testing.T) {
require.NotEmpty(t, cfg.SSHKey) require.NotEmpty(t, cfg.SSHKey)
} }
// The admin panel is opened, not dialed, so its path identifies it. Comparing // Profiles written before the admin URL was dropped still carry an AdminURL key.
// it as a bare endpoint left a custom panel URL unable to change. // It must not break loading, and the next write must drop it.
func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) { func TestLegacyAdminURLIsIgnored(t *testing.T) {
path := filepath.Join(t.TempDir(), "panel.json") path := filepath.Join(t.TempDir(), "legacy.json")
_, err := UpdateOrCreateConfig(ConfigInput{ _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path})
ConfigPath: path,
AdminURL: "https://app.example.com/netbird",
})
require.NoError(t, err) require.NoError(t, err)
cfg, err := GetExistingConfig(path) raw, err := os.ReadFile(path)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String()) var stored map[string]json.RawMessage
require.NoError(t, json.Unmarshal(raw, &stored))
// Equivalent spellings of the same panel are still not a change. stored["AdminURL"] = json.RawMessage(`{"Scheme":"https","Host":"panel.example.com:443","Path":"/netbird"}`)
for _, same := range []string{ rewritten, err := json.Marshal(stored)
"https://app.example.com/netbird",
"https://app.example.com:443/netbird",
"https://app.example.com/netbird/",
"https://APP.example.com/netbird",
} {
changed, err := cfg.WouldChange(ConfigInput{AdminURL: same})
require.NoError(t, err)
require.False(t, changed, "%q is the stored panel written differently", same)
}
// A different path is a different panel, and it must be persisted.
changed, err := cfg.WouldChange(ConfigInput{AdminURL: "https://app.example.com/other"})
require.NoError(t, err) require.NoError(t, err)
require.True(t, changed, "a different panel path is a change") require.NoError(t, os.WriteFile(path, rewritten, 0600))
updated, err := UpdateConfig(ConfigInput{ConfigPath: path, AdminURL: "https://app.example.com/other"}) _, err = GetExistingConfig(path)
require.NoError(t, err, "a legacy AdminURL key must not prevent loading the profile")
port := 51999
_, err = UpdateConfig(ConfigInput{ConfigPath: path, WireguardPort: &port})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "https://app.example.com:443/other", updated.AdminURL.String(), "the new panel path was not persisted")
raw, err = os.ReadFile(path)
require.NoError(t, err)
stored = nil
require.NoError(t, json.Unmarshal(raw, &stored))
require.NotContains(t, stored, "AdminURL", "the legacy AdminURL key should be dropped on write")
} }
// unsetOnDisk rewrites the stored config so the named fields carry a JSON null, // unsetOnDisk rewrites the stored config so the named fields carry a JSON null,
+8 -14
View File
@@ -61,23 +61,17 @@ func (p *Preferences) SetManagementURL(url string) {
p.configInput.ManagementURL = url p.configInput.ManagementURL = url
} }
// GetAdminURL read url from config file // GetAdminURL returns the default admin panel URL.
//
// Deprecated: the admin URL is no longer stored or used by the client.
func (p *Preferences) GetAdminURL() (string, error) { func (p *Preferences) GetAdminURL() (string, error) {
if p.configInput.AdminURL != "" { return profilemanager.DefaultAdminURL, nil
return p.configInput.AdminURL, nil
}
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return "", err
}
return cfg.AdminURL.String(), err
} }
// SetAdminURL store the given url and wait for commit // SetAdminURL is a no-op kept for API compatibility.
func (p *Preferences) SetAdminURL(url string) { //
p.configInput.AdminURL = url // Deprecated: the admin URL is no longer stored or used by the client.
} func (p *Preferences) SetAdminURL(_ string) {}
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or // HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
// enforced by MDM; the key itself is never handed to the native layer. // enforced by MDM; the key itself is never handed to the native layer.
+3 -2
View File
@@ -47,13 +47,14 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
stateFile := filepath.Join(t.TempDir(), "state.json") stateFile := filepath.Join(t.TempDir(), "state.json")
p := NewPreferences(cfgFile, stateFile) p := NewPreferences(cfgFile, stateFile)
// The admin URL is deprecated: setting it is a no-op.
p.SetAdminURL(exampleString) p.SetAdminURL(exampleString)
resp, err := p.GetAdminURL() resp, err := p.GetAdminURL()
if err != nil { if err != nil {
t.Fatalf("failed to read admin url: %s", err) t.Fatalf("failed to read admin url: %s", err)
} }
if resp != exampleString { if resp != profilemanager.DefaultAdminURL {
t.Errorf("unexpected admin url: %s", resp) t.Errorf("unexpected admin url: %s", resp)
} }
@@ -100,7 +101,7 @@ func TestPreferences_Commit(t *testing.T) {
t.Fatalf("failed to read admin url: %s", err) t.Fatalf("failed to read admin url: %s", err)
} }
if resp != exampleURL { if resp != profilemanager.DefaultAdminURL {
t.Errorf("unexpected admin url: %s", resp) t.Errorf("unexpected admin url: %s", resp)
} }
-5
View File
@@ -613,9 +613,6 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest, resolved
if msg.ManagementUrl != "" { if msg.ManagementUrl != "" {
config.ManagementURL = msg.ManagementUrl config.ManagementURL = msg.ManagementUrl
} }
if msg.AdminURL != "" {
config.AdminURL = msg.AdminURL
}
if msg.InterfaceName != nil { if msg.InterfaceName != nil {
config.InterfaceName = msg.InterfaceName config.InterfaceName = msg.InterfaceName
} }
@@ -2461,7 +2458,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
// overlay (Loader lives outside profilemanager). // overlay (Loader lives outside profilemanager).
cfg.ApplyMDMPolicy(s.mdmLoader.Load()) cfg.ApplyMDMPolicy(s.mdmLoader.Load())
managementURL := cfg.ManagementURL managementURL := cfg.ManagementURL
adminURL := cfg.AdminURL
var preSharedKey = cfg.PreSharedKey var preSharedKey = cfg.PreSharedKey
if preSharedKey != "" { if preSharedKey != "" {
@@ -2517,7 +2513,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
return &proto.GetConfigResponse{ return &proto.GetConfigResponse{
ManagementUrl: managementURL.String(), ManagementUrl: managementURL.String(),
PreSharedKey: preSharedKey, PreSharedKey: preSharedKey,
AdminURL: adminURL.String(),
InterfaceName: cfg.WgIface, InterfaceName: cfg.WgIface,
WireguardPort: int64(cfg.WgPort), WireguardPort: int64(cfg.WgPort),
Mtu: int64(cfg.MTU), Mtu: int64(cfg.MTU),
+7 -3
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"os"
"os/user" "os/user"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -133,7 +134,10 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "https://new-api.netbird.io:443", cfg.ManagementURL.String()) require.Equal(t, "https://new-api.netbird.io:443", cfg.ManagementURL.String())
require.Equal(t, "https://new-admin.netbird.io:443", cfg.AdminURL.String()) // Older clients still send the deprecated admin URL; it must be ignored.
raw, err := os.ReadFile(cfgPath)
require.NoError(t, err)
require.NotContains(t, string(raw), "new-admin.netbird.io", "deprecated AdminURL must not be persisted")
require.Equal(t, rosenpassEnabled, cfg.RosenpassEnabled) require.Equal(t, rosenpassEnabled, cfg.RosenpassEnabled)
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive) require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
require.NotNil(t, cfg.ServerSSHAllowed) require.NotNil(t, cfg.ServerSSHAllowed)
@@ -186,11 +190,11 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"CleanNATExternalIPs": true, // control flag for clearing "CleanNATExternalIPs": true, // control flag for clearing
"CleanDNSLabels": true, // control flag for clearing "CleanDNSLabels": true, // control flag for clearing
"LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied "LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied
"AdminURL": true, // deprecated: proto field retained for compat, no longer applied
} }
expectedFields := map[string]bool{ expectedFields := map[string]bool{
"ManagementUrl": true, "ManagementUrl": true,
"AdminURL": true,
"RosenpassEnabled": true, "RosenpassEnabled": true,
"RosenpassPermissive": true, "RosenpassPermissive": true,
"ServerSSHAllowed": true, "ServerSSHAllowed": true,
@@ -253,7 +257,6 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
// This map must be updated when adding new config-related CLI flags. // This map must be updated when adding new config-related CLI flags.
flagToField := map[string]string{ flagToField := map[string]string{
"management-url": "ManagementUrl", "management-url": "ManagementUrl",
"admin-url": "AdminURL",
"enable-rosenpass": "RosenpassEnabled", "enable-rosenpass": "RosenpassEnabled",
"rosenpass-permissive": "RosenpassPermissive", "rosenpass-permissive": "RosenpassPermissive",
"allow-server-ssh": "ServerSSHAllowed", "allow-server-ssh": "ServerSSHAllowed",
@@ -290,6 +293,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
fieldsWithoutCLIFlags := map[string]bool{ fieldsWithoutCLIFlags := map[string]bool{
"DisableNotifications": true, // Only settable via UI "DisableNotifications": true, // Only settable via UI
"LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN) "LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN)
"AdminURL": true, // deprecated: the admin-url flag is no longer functional
} }
// Get all SetConfigRequest fields to verify our map is complete. // Get all SetConfigRequest fields to verify our map is complete.
+4 -2
View File
@@ -166,7 +166,8 @@ func (c *Client) ExecuteCommandWithIO(ctx context.Context, command string) error
case <-ctx.Done(): case <-ctx.Done():
_ = session.Signal(ssh.SIGTERM) _ = session.Signal(ssh.SIGTERM)
select { select {
case <-done: case err := <-done:
log.Debugf("session ended after SIGTERM: %v", err)
return ctx.Err() return ctx.Err()
case <-time.After(100 * time.Millisecond): case <-time.After(100 * time.Millisecond):
return ctx.Err() return ctx.Err()
@@ -205,7 +206,8 @@ func (c *Client) ExecuteCommandWithPTY(ctx context.Context, command string) erro
case <-ctx.Done(): case <-ctx.Done():
_ = session.Signal(ssh.SIGTERM) _ = session.Signal(ssh.SIGTERM)
select { select {
case <-done: case err := <-done:
log.Debugf("session ended after SIGTERM: %v", err)
return ctx.Err() return ctx.Err()
case <-time.After(100 * time.Millisecond): case <-time.After(100 * time.Millisecond):
return ctx.Err() return ctx.Err()
+3 -3
View File
@@ -110,7 +110,7 @@ Settings.SetConfig(p: SetConfigParams): Promise<void> // partial update
Settings.GetFeatures(): Promise<Features> // operator-disabled UI sections Settings.GetFeatures(): Promise<Features> // operator-disabled UI sections
``` ```
`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons. `SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` is always-string for historical reasons.
**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`. **PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`.
@@ -242,7 +242,7 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front
`Config` (read-only mirror, all required): `Config` (read-only mirror, all required):
```ts ```ts
{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string; { managementUrl, configFile, logFile, preSharedKey, interfaceName: string;
wireguardPort, mtu, sshJwtCacheTtl: number; wireguardPort, mtu, sshJwtCacheTtl: number;
disableAutoConnect, serverSshAllowed, disableAutoConnect, serverSshAllowed,
rosenpassEnabled, rosenpassPermissive, rosenpassEnabled, rosenpassPermissive,
@@ -254,7 +254,7 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front
disableSshAuth: boolean; } disableSshAuth: boolean; }
``` ```
`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings. `SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` as required strings.
`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`. `Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`.
+2 -1
View File
@@ -11,6 +11,7 @@
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, {"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"},
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"}, {"code": "ja", "displayName": "日本語", "englishName": "Japanese"},
{"code": "gl", "displayName": "Galego", "englishName": "Galician"} {"code": "gl", "displayName": "Galego", "englishName": "Galician"},
{"code": "he", "displayName": "עברית", "englishName": "Hebrew"}
] ]
} }
+2 -4
View File
@@ -552,12 +552,10 @@
"message": "Ändern des Autostarts fehlgeschlagen" "message": "Ändern des Autostarts fehlgeschlagen"
}, },
"settings.general.keepConnectedOnQuit.label": { "settings.general.keepConnectedOnQuit.label": {
"message": "Nach dem Beenden verbunden bleiben", "message": "Nach dem Beenden verbunden bleiben"
"description": "Toggle label: keep the VPN connection up after quitting the UI."
}, },
"settings.general.keepConnectedOnQuit.help": { "settings.general.keepConnectedOnQuit.help": {
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.", "message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen."
"description": "Helper text for the stay-connected-after-quitting toggle."
}, },
"settings.general.language.label": { "settings.general.language.label": {
"message": "Anzeigesprache" "message": "Anzeigesprache"
+2 -4
View File
@@ -552,12 +552,10 @@
"message": "Error al cambiar el inicio automático" "message": "Error al cambiar el inicio automático"
}, },
"settings.general.keepConnectedOnQuit.label": { "settings.general.keepConnectedOnQuit.label": {
"message": "Permanecer conectado al salir", "message": "Permanecer conectado al salir"
"description": "Toggle label: keep the VPN connection up after quitting the UI."
}, },
"settings.general.keepConnectedOnQuit.help": { "settings.general.keepConnectedOnQuit.help": {
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.", "message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú."
"description": "Helper text for the stay-connected-after-quitting toggle."
}, },
"settings.general.language.label": { "settings.general.language.label": {
"message": "Idioma de la interfaz" "message": "Idioma de la interfaz"
+2 -4
View File
@@ -552,12 +552,10 @@
"message": "Échec de la modification du démarrage automatique" "message": "Échec de la modification du démarrage automatique"
}, },
"settings.general.keepConnectedOnQuit.label": { "settings.general.keepConnectedOnQuit.label": {
"message": "Rester connecté après la fermeture", "message": "Rester connecté après la fermeture"
"description": "Toggle label: keep the VPN connection up after quitting the UI."
}, },
"settings.general.keepConnectedOnQuit.help": { "settings.general.keepConnectedOnQuit.help": {
"message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.", "message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même."
"description": "Helper text for the stay-connected-after-quitting toggle."
}, },
"settings.general.language.label": { "settings.general.language.label": {
"message": "Langue d’affichage" "message": "Langue d’affichage"
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -552,12 +552,10 @@
"message": "Az automatikus indítás módosítása sikertelen" "message": "Az automatikus indítás módosítása sikertelen"
}, },
"settings.general.keepConnectedOnQuit.label": { "settings.general.keepConnectedOnQuit.label": {
"message": "Kapcsolat megtartása kilépéskor", "message": "Kapcsolat megtartása kilépéskor"
"description": "Toggle label: keep the VPN connection up after quitting the UI."
}, },
"settings.general.keepConnectedOnQuit.help": { "settings.general.keepConnectedOnQuit.help": {
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.", "message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod."
"description": "Helper text for the stay-connected-after-quitting toggle."
}, },
"settings.general.language.label": { "settings.general.language.label": {
"message": "Megjelenítési nyelv" "message": "Megjelenítési nyelv"
+2 -4
View File
@@ -552,12 +552,10 @@
"message": "Modifica avvio automatico non riuscita" "message": "Modifica avvio automatico non riuscita"
}, },
"settings.general.keepConnectedOnQuit.label": { "settings.general.keepConnectedOnQuit.label": {
"message": "Resta connesso dopo la chiusura", "message": "Resta connesso dopo la chiusura"
"description": "Toggle label: keep the VPN connection up after quitting the UI."
}, },
"settings.general.keepConnectedOnQuit.help": { "settings.general.keepConnectedOnQuit.help": {
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.", "message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu."
"description": "Helper text for the stay-connected-after-quitting toggle."
}, },
"settings.general.language.label": { "settings.general.language.label": {
"message": "Lingua dell'interfaccia" "message": "Lingua dell'interfaccia"
+2 -4
View File
@@ -552,12 +552,10 @@
"message": "自動起動の変更に失敗しました" "message": "自動起動の変更に失敗しました"
}, },
"settings.general.keepConnectedOnQuit.label": { "settings.general.keepConnectedOnQuit.label": {
"message": "終了後も接続を維持", "message": "終了後も接続を維持"
"description": "Toggle label: keep the VPN connection up after quitting the UI."
}, },
"settings.general.keepConnectedOnQuit.help": { "settings.general.keepConnectedOnQuit.help": {
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。", "message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。"
"description": "Helper text for the stay-connected-after-quitting toggle."
}, },
"settings.general.language.label": { "settings.general.language.label": {
"message": "表示言語" "message": "表示言語"
+2 -4
View File
@@ -552,12 +552,10 @@
"message": "Falha ao alterar o início automático" "message": "Falha ao alterar o início automático"
}, },
"settings.general.keepConnectedOnQuit.label": { "settings.general.keepConnectedOnQuit.label": {
"message": "Permanecer conectado ao sair", "message": "Permanecer conectado ao sair"
"description": "Toggle label: keep the VPN connection up after quitting the UI."
}, },
"settings.general.keepConnectedOnQuit.help": { "settings.general.keepConnectedOnQuit.help": {
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.", "message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta."
"description": "Helper text for the stay-connected-after-quitting toggle."
}, },
"settings.general.language.label": { "settings.general.language.label": {
"message": "Idioma de exibição" "message": "Idioma de exibição"
+2 -4
View File
@@ -552,12 +552,10 @@
"message": "Не удалось изменить автозапуск" "message": "Не удалось изменить автозапуск"
}, },
"settings.general.keepConnectedOnQuit.label": { "settings.general.keepConnectedOnQuit.label": {
"message": "Оставаться подключённым после выхода", "message": "Оставаться подключённым после выхода"
"description": "Toggle label: keep the VPN connection up after quitting the UI."
}, },
"settings.general.keepConnectedOnQuit.help": { "settings.general.keepConnectedOnQuit.help": {
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.", "message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами."
"description": "Helper text for the stay-connected-after-quitting toggle."
}, },
"settings.general.language.label": { "settings.general.language.label": {
"message": "Язык интерфейса" "message": "Язык интерфейса"
+2 -4
View File
@@ -552,12 +552,10 @@
"message": "更改自启动设置失败" "message": "更改自启动设置失败"
}, },
"settings.general.keepConnectedOnQuit.label": { "settings.general.keepConnectedOnQuit.label": {
"message": "退出后保持连接", "message": "退出后保持连接"
"description": "Toggle label: keep the VPN connection up after quitting the UI."
}, },
"settings.general.keepConnectedOnQuit.help": { "settings.general.keepConnectedOnQuit.help": {
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。", "message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。"
"description": "Helper text for the stay-connected-after-quitting toggle."
}, },
"settings.general.language.label": { "settings.general.language.label": {
"message": "显示语言" "message": "显示语言"
-4
View File
@@ -51,7 +51,6 @@ type ConfigParams struct {
type Config struct { type Config struct {
ManagementURL string `json:"managementUrl"` ManagementURL string `json:"managementUrl"`
AdminURL string `json:"adminUrl"`
ConfigFile string `json:"configFile"` ConfigFile string `json:"configFile"`
LogFile string `json:"logFile"` LogFile string `json:"logFile"`
PreSharedKeySet bool `json:"preSharedKeySet"` PreSharedKeySet bool `json:"preSharedKeySet"`
@@ -84,7 +83,6 @@ type SetConfigParams struct {
ProfileName string `json:"profileName"` ProfileName string `json:"profileName"`
Username string `json:"username"` Username string `json:"username"`
ManagementURL string `json:"managementUrl"` ManagementURL string `json:"managementUrl"`
AdminURL string `json:"adminUrl"`
InterfaceName *string `json:"interfaceName,omitempty"` InterfaceName *string `json:"interfaceName,omitempty"`
WireguardPort *int64 `json:"wireguardPort,omitempty"` WireguardPort *int64 `json:"wireguardPort,omitempty"`
MTU *int64 `json:"mtu,omitempty"` MTU *int64 `json:"mtu,omitempty"`
@@ -146,7 +144,6 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
} }
return Config{ return Config{
ManagementURL: resp.GetManagementUrl(), ManagementURL: resp.GetManagementUrl(),
AdminURL: resp.GetAdminURL(),
ConfigFile: resp.GetConfigFile(), ConfigFile: resp.GetConfigFile(),
LogFile: resp.GetLogFile(), LogFile: resp.GetLogFile(),
PreSharedKeySet: resp.GetPreSharedKey() != "", PreSharedKeySet: resp.GetPreSharedKey() != "",
@@ -183,7 +180,6 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom
ProfileName: p.ProfileName, ProfileName: p.ProfileName,
Username: p.Username, Username: p.Username,
ManagementUrl: p.ManagementURL, ManagementUrl: p.ManagementURL,
AdminURL: p.AdminURL,
InterfaceName: p.InterfaceName, InterfaceName: p.InterfaceName,
WireguardPort: p.WireguardPort, WireguardPort: p.WireguardPort,
Mtu: p.MTU, Mtu: p.MTU,
-4
View File
@@ -35,13 +35,9 @@ func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T
func TestApplyServerStoreEnv(t *testing.T) { func TestApplyServerStoreEnv(t *testing.T) {
t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "") t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "")
t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "")
t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "") t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "")
applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"}) applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"})
require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN")) require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN"))
require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE")) require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE"))
applyServerStoreEnv(StoreConfig{Engine: "mysql", DSN: "mysql-dsn"})
require.Equal(t, "mysql-dsn", os.Getenv("NB_STORE_ENGINE_MYSQL_DSN"))
} }
+1 -1
View File
@@ -188,7 +188,7 @@ type RelaysConfig struct {
type StoreConfig struct { type StoreConfig struct {
Engine string `yaml:"engine"` Engine string `yaml:"engine"`
EncryptionKey string `yaml:"encryptionKey"` EncryptionKey string `yaml:"encryptionKey"`
DSN string `yaml:"dsn"` // Connection string for postgres or mysql engines DSN string `yaml:"dsn"` // Connection string for the postgres engine
File string `yaml:"file"` // SQLite database file path (optional, defaults to dataDir) File string `yaml:"file"` // SQLite database file path (optional, defaults to dataDir)
} }
+6 -11
View File
@@ -127,13 +127,8 @@ func execute(cmd *cobra.Command, _ []string) error {
// initializeConfig loads and validates the configuration, then initializes logging. // initializeConfig loads and validates the configuration, then initializes logging.
func applyServerStoreEnv(storeConfig StoreConfig) { func applyServerStoreEnv(storeConfig StoreConfig) {
if dsn := storeConfig.DSN; dsn != "" { if dsn := storeConfig.DSN; dsn != "" && strings.EqualFold(storeConfig.Engine, "postgres") {
switch strings.ToLower(storeConfig.Engine) { os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
case "postgres":
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
case "mysql":
os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn)
}
} }
if file := storeConfig.File; file != "" { if file := storeConfig.File; file != "" {
os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file)
@@ -758,7 +753,7 @@ func logAgentNetworkConfig(cfg *CombinedConfig) {
// logEnvVars logs all NB_ environment variables that are currently set // logEnvVars logs all NB_ environment variables that are currently set
func logEnvVars() { func logEnvVars() {
log.Info("=== Environment Variables ===") log.Debug("=== Environment Variables ===")
found := false found := false
for _, env := range os.Environ() { for _, env := range os.Environ() {
if strings.HasPrefix(env, "NB_") { if strings.HasPrefix(env, "NB_") {
@@ -770,14 +765,14 @@ func logEnvVars() {
} else if strings.Contains(keyLower, "dsn") { } else if strings.Contains(keyLower, "dsn") {
value = maskDSNPassword(value) value = maskDSNPassword(value)
} }
log.Infof(" %s=%s", key, value) log.Debugf(" %s=%s", key, value)
found = true found = true
} }
} }
if !found { if !found {
log.Info(" (none set)") log.Debug(" (none set)")
} }
log.Info("=== End Environment Variables ===") log.Debug("=== End Environment Variables ===")
} }
// maskDSNPassword masks the password in a DSN string. // maskDSNPassword masks the password in a DSN string.
+2 -2
View File
@@ -110,8 +110,8 @@ server:
# Store configuration # Store configuration
store: store:
engine: "sqlite" # sqlite, postgres, or mysql engine: "sqlite" # sqlite or postgres
dsn: "" # Connection string for postgres or mysql dsn: "" # Connection string for postgres
encryptionKey: "" encryptionKey: ""
# file: "" # Custom SQLite file path (optional, defaults to {dataDir}/store.db) # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/store.db)
@@ -64,17 +64,17 @@ Reads decrypt provider secrets in-place; writes do `provider.Copy().EncryptSensi
### Correctness ### Correctness
- `SaveAgentNetworkProvider` saves the copy (sql_store_agentnetwork.go:95). The caller's in-memory pointer therefore keeps plaintext `api_key` and any `CreatedAt`/`UpdatedAt` gorm autofills land on the copy, not the original. Callers that need synced timestamps must re-fetch. - `SaveAgentNetworkProvider` saves the copy (sql_store_agentnetwork.go:95). The caller's in-memory pointer therefore keeps plaintext `api_key` and any `CreatedAt`/`UpdatedAt` gorm autofills land on the copy, not the original. Callers that need synced timestamps must re-fetch.
- `IncrementAgentNetworkConsumption`'s `Create` provides initial counter values (`TokensInput: tokensIn`, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres, mysql) silently rejects the `OnConflict` clause — GORM emits engine-specific SQL but `ON DUPLICATE KEY UPDATE` (mysql) vs `ON CONFLICT (...)` (sqlite/postgres) need their unique constraint to match the composite PK on `agent_network_consumption`; it does, by construction. - `IncrementAgentNetworkConsumption`'s `Create` provides initial counter values (`TokensInput: tokensIn`, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres) silently rejects the `OnConflict` clause — GORM emits `ON CONFLICT (...)`, which needs its unique constraint to match the composite PK on `agent_network_consumption`; it does, by construction.
- `IncrementAgentNetworkConsumption` writes `updated_at: time.Now().UTC()` literally inside the assignments map (sql_store_agentnetwork.go:333) — fine, but it's a Go-side timestamp captured at call time, not a DB-side `now()`. Acceptable for an audit field. - `IncrementAgentNetworkConsumption` writes `updated_at: time.Now().UTC()` literally inside the assignments map (sql_store_agentnetwork.go:333) — fine, but it's a Go-side timestamp captured at call time, not a DB-side `now()`. Acceptable for an audit field.
- `GetAgentNetworkConsumption` returns a zero-valued non-nil row on `ErrRecordNotFound` (sql_store_agentnetwork.go:364-371). Document or rename — a typed sentinel error would be more orthodox; callers must know not to error-check. - `GetAgentNetworkConsumption` returns a zero-valued non-nil row on `ErrRecordNotFound` (sql_store_agentnetwork.go:364-371). Document or rename — a typed sentinel error would be more orthodox; callers must know not to error-check.
### Concurrency / transactions ### Concurrency / transactions
- Hot-path `IncrementAgentNetworkConsumption` runs outside any explicit transaction; concurrency safety relies entirely on the DB serialising the `ON CONFLICT` upsert against the composite PK. This is correct for postgres and mysql; for sqlite it serialises behind the single writer. - Hot-path `IncrementAgentNetworkConsumption` runs outside any explicit transaction; concurrency safety relies entirely on the DB serialising the `ON CONFLICT` upsert against the composite PK. This is correct for postgres; for sqlite it serialises behind the single writer.
- `SaveAgentNetworkSettings` is a blind upsert with no version/etag — concurrent writes from two operators last-write-wins on the collection-toggle flags (settings.go:23-25). Acceptable for admin-curated state but worth flagging. - `SaveAgentNetworkSettings` is a blind upsert with no version/etag — concurrent writes from two operators last-write-wins on the collection-toggle flags (settings.go:23-25). Acceptable for admin-curated state but worth flagging.
- `Save*Provider` uses `db.Save` on a struct with a PK already set — GORM emits UPDATE or INSERT based on row existence. No upsert clause is attached, so a race between two creates with the same generated `xid` (vanishingly unlikely) would surface as a PK violation. - `Save*Provider` uses `db.Save` on a struct with a PK already set — GORM emits UPDATE or INSERT based on row existence. No upsert clause is attached, so a race between two creates with the same generated `xid` (vanishingly unlikely) would surface as a PK violation.
### Migration safety ### Migration safety
- All six tables ride `AutoMigrate` (sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Three `bool` columns on `agent_network_settings` (`EnableLogCollection`, `EnablePromptCollection`, `RedactPii`) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres/mysql produce the same default. - All six tables ride `AutoMigrate` (sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Three `bool` columns on `agent_network_settings` (`EnableLogCollection`, `EnablePromptCollection`, `RedactPii`) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres produces the same default.
- The named index `idx_agent_network_settings_cluster_subdomain` on settings.go:15 is declared on only `subdomain`. Either the cluster column also needs `gorm:"index:idx_agent_network_settings_cluster_subdomain"` to make it composite, or the name is misleading. - The named index `idx_agent_network_settings_cluster_subdomain` on settings.go:15 is declared on only `subdomain`. Either the cluster column also needs `gorm:"index:idx_agent_network_settings_cluster_subdomain"` to make it composite, or the name is misleading.
- The named index `idx_agent_network_provider` on `Provider.ProviderID` (provider.go:30) is *not* unique and not scoped to account — two providers in the same account with the same `provider_id` are permitted at the DB layer; uniqueness, if any, must live above the store. - The named index `idx_agent_network_provider` on `Provider.ProviderID` (provider.go:30) is *not* unique and not scoped to account — two providers in the same account with the same `provider_id` are permitted at the DB layer; uniqueness, if any, must live above the store.
@@ -101,7 +101,7 @@ Gap: there is no store-level test for providers (encryption round-trip), policie
- No retention / GC for `agent_network_consumption`. - No retention / GC for `agent_network_consumption`.
- No `Delete` for `Settings` (one row per account, cleared with the account). - No `Delete` for `Settings` (one row per account, cleared with the account).
- No DB-engine-specific tuning — the same struct tags drive sqlite, mysql, postgres. - No DB-engine-specific tuning — the same struct tags drive sqlite and postgres.
- Provider `extra_values` and `models` are JSON blobs; querying inside them is not supported by design. - Provider `extra_values` and `models` are JSON blobs; querying inside them is not supported by design.
- `GetAgentNetworkConsumption` "not-found = zero row" contract is convenient but unconventional. - `GetAgentNetworkConsumption` "not-found = zero row" contract is convenient but unconventional.
+29 -5
View File
@@ -65,11 +65,7 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie
opt(&o) opt(&o)
} }
root, err := repoRoot(ctx) clientImage, err := ClientImage(ctx)
if err != nil {
return nil, err
}
clientImage, err := resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -105,6 +101,34 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie
return &Client{container: ctr, name: o.name}, nil return &Client{container: ctr, name: o.name}, nil
} }
// ClientImage returns the client image StartClient runs, building it from this
// module's source unless NB_E2E_CLIENT_IMAGE names a published one. A suite that
// runs the agent outside a harness container, such as in a Kubernetes cluster,
// uses it to get the same build.
func ClientImage(ctx context.Context) (string, error) {
root, err := repoRoot(ctx)
if err != nil {
return "", err
}
return resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile)
}
// Exec runs a command inside the client container and returns its exit code and
// combined output. It reaches whatever the agent routes, so a suite can check
// access the request helpers here do not model, such as plain HTTP to a routed
// address.
func (cl *Client) Exec(ctx context.Context, cmd ...string) (int, string, error) {
code, reader, err := cl.container.Exec(ctx, cmd, tcexec.Multiplexed())
if err != nil {
return 0, "", fmt.Errorf("exec %v: %w", cmd, err)
}
out, err := io.ReadAll(reader)
if err != nil {
return code, string(out), fmt.Errorf("read output of %v: %w", cmd, err)
}
return code, string(out), nil
}
// Hostname returns the container hostname the agent reports to management — // Hostname returns the container hostname the agent reports to management —
// the name the registered peer appears under in the peers API. // the name the registered peer appears under in the peers API.
func (cl *Client) Hostname() string { func (cl *Client) Hostname() string {
+8
View File
@@ -332,6 +332,14 @@ func (c *Combined) Restart(ctx context.Context) error {
return nil return nil
} }
// NetworkName returns the Docker network the deployment runs on. Peers reach
// management, signal and relay at the combined server's alias on this network
// only, so a suite running agents outside the harness, such as in a Kind
// cluster, attaches them here.
func (c *Combined) NetworkName() string {
return c.network.Name
}
// Logs returns the combined server container logs, for diagnostics. // Logs returns the combined server container logs, for diagnostics.
func (c *Combined) Logs(ctx context.Context) string { func (c *Combined) Logs(ctx context.Context) string {
return containerLogs(ctx, c.container) return containerLogs(ctx, c.container)
+2 -4
View File
@@ -105,7 +105,7 @@ require (
github.com/pkg/sftp v1.13.9 github.com/pkg/sftp v1.13.9
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 github.com/prometheus/client_model v0.6.2
github.com/quic-go/quic-go v0.62.0 github.com/quic-go/quic-go v0.64.0
github.com/redis/go-redis/v9 v9.7.3 github.com/redis/go-redis/v9 v9.7.3
github.com/rs/xid v1.3.0 github.com/rs/xid v1.3.0
github.com/shirou/gopsutil/v4 v4.25.8 github.com/shirou/gopsutil/v4 v4.25.8
@@ -113,7 +113,6 @@ require (
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.12.1
github.com/testcontainers/testcontainers-go v0.37.0 github.com/testcontainers/testcontainers-go v0.37.0
github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0
github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 github.com/testcontainers/testcontainers-go/modules/redis v0.37.0
github.com/things-go/go-socks5 v0.0.4 github.com/things-go/go-socks5 v0.0.4
@@ -142,7 +141,6 @@ require (
google.golang.org/api v0.276.0 google.golang.org/api v0.276.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.7 gorm.io/driver/postgres v1.5.7
gorm.io/driver/sqlite v1.5.7 gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
@@ -369,7 +367,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024
replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949 replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949
replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9 replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0
replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6
+4 -10
View File
@@ -252,7 +252,6 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
@@ -541,8 +540,8 @@ github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d h1:DV0FHFhNAaU69JHXKclYQWr/ZJoc2BA/ZpqlnX8l22U= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d h1:DV0FHFhNAaU69JHXKclYQWr/ZJoc2BA/ZpqlnX8l22U=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU=
github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9 h1:Mjt3tIBGa1/mJh1Xn8uZP30cd2VYd0NHANqTn+HBsaU= github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0 h1:nCK1YzDybjbAfrx+C2QPmcQ+7jYj4Rcb3FskqTNZEXo=
github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
@@ -647,8 +646,8 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= github.com/quic-go/quic-go v0.64.0 h1:dlUpiyp9+7UrbbuhLvV7CJ9F8uoZxXf/vi2h8tkuuoA=
github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/quic-go/quic-go v0.64.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
@@ -721,8 +720,6 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI=
github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc=
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc= github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc=
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc=
github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 h1:9HIY28I9ME/Zmb+zey1p/I1mto5+5ch0wLX+nJdOsQ4= github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 h1:9HIY28I9ME/Zmb+zey1p/I1mto5+5ch0wLX+nJdOsQ4=
@@ -1025,13 +1022,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
-12
View File
@@ -53,18 +53,6 @@ if [[ "$NETBIRD_STORE_CONFIG_ENGINE" == "postgres" ]]; then
export NETBIRD_STORE_ENGINE_POSTGRES_DSN export NETBIRD_STORE_ENGINE_POSTGRES_DSN
fi fi
# Check if MySQL is set as the store engine
if [[ "$NETBIRD_STORE_CONFIG_ENGINE" == "mysql" ]]; then
# Exit if 'NETBIRD_STORE_ENGINE_MYSQL_DSN' is not set
if [[ -z "$NETBIRD_STORE_ENGINE_MYSQL_DSN" ]]; then
echo "Warning: NETBIRD_STORE_CONFIG_ENGINE=mysql but NETBIRD_STORE_ENGINE_MYSQL_DSN is not set."
echo "Please add the following line to your setup.env file:"
echo 'NETBIRD_STORE_ENGINE_MYSQL_DSN="<username>:<password>@tcp(127.0.0.1:3306)/<database>"'
exit 1
fi
export NETBIRD_STORE_ENGINE_MYSQL_DSN
fi
# local development or tests # local development or tests
if [[ $NETBIRD_DOMAIN == "localhost" || $NETBIRD_DOMAIN == "127.0.0.1" ]]; then if [[ $NETBIRD_DOMAIN == "localhost" || $NETBIRD_DOMAIN == "127.0.0.1" ]]; then
export NETBIRD_MGMT_SINGLE_ACCOUNT_MODE_DOMAIN="netbird.selfhosted" export NETBIRD_MGMT_SINGLE_ACCOUNT_MODE_DOMAIN="netbird.selfhosted"
@@ -94,7 +94,6 @@ services:
] ]
environment: environment:
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN - NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN
- NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN
# Coturn # Coturn
coturn: coturn:
@@ -100,7 +100,6 @@ services:
- traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c - traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c
environment: environment:
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN - NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN
- NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN
# Coturn # Coturn
coturn: coturn:
@@ -855,7 +855,7 @@ configure_sqlite_store() {
POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable" POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
} }
# mysql, or something this script has never seen. Swapping the images is still # An engine this script has never seen. Swapping the images is still
# valid; touching the store is not. # valid; touching the store is not.
configure_unsupported_store() { configure_unsupported_store() {
MIGRATE_POSTGRES="no" MIGRATE_POSTGRES="no"
-12
View File
@@ -472,12 +472,6 @@ detect_store_config() {
if [[ -n "$pg_dsn" ]]; then if [[ -n "$pg_dsn" ]]; then
STORE_DSN="$pg_dsn" STORE_DSN="$pg_dsn"
fi fi
local mysql_dsn
mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "")
if [[ -n "$mysql_dsn" ]]; then
STORE_DSN="$mysql_dsn"
fi
fi fi
# Also check base.setup.env # Also check base.setup.env
@@ -487,12 +481,6 @@ detect_store_config() {
if [[ -n "$pg_dsn" ]]; then if [[ -n "$pg_dsn" ]]; then
STORE_DSN="$pg_dsn" STORE_DSN="$pg_dsn"
fi fi
local mysql_dsn
mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "")
if [[ -n "$mysql_dsn" ]]; then
STORE_DSN="$mysql_dsn"
fi
fi fi
log_success "Store engine: $STORE_ENGINE" log_success "Store engine: $STORE_ENGINE"
@@ -341,7 +341,7 @@ func TestGetAgentConfigForUser_RealStore(t *testing.T) {
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
// users.account_id is a foreign key into accounts, enforced on // users.account_id is a foreign key into accounts, enforced on
// MySQL/Postgres, so the account row must exist before its users. // Postgres, so the account row must exist before its users.
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID})) require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"}, Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
@@ -1277,7 +1277,6 @@ func isUniqueConstraintError(err error) bool {
} }
msg := err.Error() msg := err.Error()
return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres
strings.Contains(msg, "Error 1062 (23000)") || // mysql
strings.Contains(msg, "UNIQUE constraint failed") // sqlite strings.Contains(msg, "UNIQUE constraint failed") // sqlite
} }
@@ -98,9 +98,8 @@ func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) {
// keeps the account-wide view with full config. // keeps the account-wide view with full config.
// newSelfScopeStore seeds the account and its users only, so each test // newSelfScopeStore seeds the account and its users only, so each test
// declares exactly the providers and policies it asserts on — the store // declares exactly the providers and policies it asserts on, so tests never
// rejects re-saving a policy id on MySQL, so tests never overwrite each // overwrite each other's rows.
// other's rows.
func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) { func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) {
t.Helper() t.Helper()
mgr, s := newAgentConfigTestMgr(t) mgr, s := newAgentConfigTestMgr(t)
@@ -21,8 +21,7 @@ type Settings struct {
AccountID string `gorm:"primaryKey"` AccountID string `gorm:"primaryKey"`
// Domain is the gateway endpoint hostname agents call. Globally unique // Domain is the gateway endpoint hostname agents call. Globally unique
// across accounts. Sized explicitly because MySQL cannot index an // across accounts. 255 covers the RFC 1035 253-octet bound.
// unbounded TEXT column; 255 covers the RFC 1035 253-octet bound.
Domain string `gorm:"type:varchar(255);uniqueIndex:idx_agent_network_settings_domain"` Domain string `gorm:"type:varchar(255);uniqueIndex:idx_agent_network_settings_domain"`
// ProxyAddress is the declared cluster address of the proxy serving this // ProxyAddress is the declared cluster address of the proxy serving this
@@ -95,7 +95,7 @@ func bucketStart(t time.Time, g UsageGranularity) time.Time {
// AggregateUsageByGranularity buckets the usage rows by the requested // AggregateUsageByGranularity buckets the usage rows by the requested
// granularity and returns the buckets ordered oldest-first. Aggregation is done // granularity and returns the buckets ordered oldest-first. Aggregation is done
// in Go (rather than per-engine SQL date_trunc) so granularities stay portable // in Go (rather than per-engine SQL date_trunc) so granularities stay portable
// across SQLite/Postgres/MySQL and easy to extend. // across SQLite and Postgres and easy to extend.
func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) []*AgentNetworkUsageBucket { func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) []*AgentNetworkUsageBucket {
byPeriod := make(map[string]*AgentNetworkUsageBucket) byPeriod := make(map[string]*AgentNetworkUsageBucket)
for _, r := range rows { for _, r := range rows {
+31 -16
View File
@@ -28,8 +28,8 @@ import (
type Manager interface { type Manager interface {
GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error)
GetPeerAccountID(ctx context.Context, peerID string) (string, error) GetPeerAccountID(ctx context.Context, peerID string) (string, error)
GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error)
GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error)
GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error)
DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error
SetNetworkMapController(networkMapController network_map.Controller) SetNetworkMapController(networkMapController network_map.Controller)
SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator) SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator)
@@ -51,8 +51,7 @@ type managerImpl struct {
permissionsManager permissions.Manager permissionsManager permissions.Manager
integratedPeerValidator integrated_validator.IntegratedValidator integratedPeerValidator integrated_validator.IntegratedValidator
accountManager account.Manager accountManager account.Manager
networkMapController network_map.Controller
networkMapController network_map.Controller
} }
func NewManager(store store.Store, permissionsManager permissions.Manager) Manager { func NewManager(store store.Store, permissionsManager permissions.Manager) Manager {
@@ -87,19 +86,6 @@ func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID str
return m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) return m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
} }
func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) {
allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
if !allowed {
return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
}
return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
}
func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) { func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) {
return m.store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID) return m.store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID)
} }
@@ -108,6 +94,35 @@ func (m *managerImpl) GetPeersByGroupIDs(ctx context.Context, accountID string,
return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs) return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs)
} }
func (m *managerImpl) GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error) {
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
return nil, 0, err
}
allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, 0, status.NewPermissionValidationError(err)
}
if allowed {
return m.store.GetAccountPeersPaginated(ctx, store.LockingStrengthNone, accountID, pagination, filters, sorting)
}
settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, 0, fmt.Errorf("failed to get account settings: %w", err)
}
if user.IsRestrictable() && settings.RegularUsersViewBlocked {
return []*peer.Peer{}, 0, nil
}
peers, err := m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
return peers, len(peers), err
}
// GetPeerByTunnelIP delegates to the store's indexed lookup. // GetPeerByTunnelIP delegates to the store's indexed lookup.
func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) { func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) {
return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip) return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip)
@@ -18,6 +18,7 @@ import (
account "github.com/netbirdio/netbird/management/server/account" account "github.com/netbirdio/netbird/management/server/account"
integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
peer "github.com/netbirdio/netbird/management/server/peer" peer "github.com/netbirdio/netbird/management/server/peer"
store "github.com/netbirdio/netbird/management/server/store"
types "github.com/netbirdio/netbird/management/server/types" types "github.com/netbirdio/netbird/management/server/types"
gomock "go.uber.org/mock/gomock" gomock "go.uber.org/mock/gomock"
) )
@@ -74,21 +75,6 @@ func (mr *MockManagerMockRecorder) DeletePeers(ctx, accountID, peerIDs, userID,
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeers", reflect.TypeOf((*MockManager)(nil).DeletePeers), ctx, accountID, peerIDs, userID, checkConnected) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeers", reflect.TypeOf((*MockManager)(nil).DeletePeers), ctx, accountID, peerIDs, userID, checkConnected)
} }
// GetAllPeers mocks base method.
func (m *MockManager) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAllPeers", ctx, accountID, userID)
ret0, _ := ret[0].([]*peer.Peer)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAllPeers indicates an expected call of GetAllPeers.
func (mr *MockManagerMockRecorder) GetAllPeers(ctx, accountID, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeers", reflect.TypeOf((*MockManager)(nil).GetAllPeers), ctx, accountID, userID)
}
// GetPeer mocks base method. // GetPeer mocks base method.
func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) { func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -180,6 +166,22 @@ func (mr *MockManagerMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupsIDs
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockManager)(nil).GetPeersByGroupIDs), ctx, accountID, groupsIDs) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockManager)(nil).GetPeersByGroupIDs), ctx, accountID, groupsIDs)
} }
// GetPeersPaginated mocks base method.
func (m *MockManager) GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPeersPaginated", ctx, accountID, userID, pagination, filters, sorting)
ret0, _ := ret[0].([]*peer.Peer)
ret1, _ := ret[1].(int)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// GetPeersPaginated indicates an expected call of GetPeersPaginated.
func (mr *MockManagerMockRecorder) GetPeersPaginated(ctx, accountID, userID, pagination, filters, sorting any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersPaginated", reflect.TypeOf((*MockManager)(nil).GetPeersPaginated), ctx, accountID, userID, pagination, filters, sorting)
}
// SetAccountManager mocks base method. // SetAccountManager mocks base method.
func (m *MockManager) SetAccountManager(accountManager account.Manager) { func (m *MockManager) SetAccountManager(accountManager account.Manager) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -0,0 +1,197 @@
package peers
import (
"context"
"fmt"
"testing"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
func TestManager_GetPeersPaginated_HappyPath(t *testing.T) {
ctrl := gomock.NewController(t)
// GetUserByUserID
// GetAccountPeersPaginated
// GetAccountSettings
// GetUserPeers
mockStore := store.NewMockStore(ctrl)
// ValidateUserPermissions
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(true, context.Background(), nil)
mockStore.EXPECT().GetAccountPeersPaginated(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"),
gomock.Eq(store.PaginationState{Page: 11, PageSize: 111}), gomock.Eq(store.PeerFilters{UserId: "test-user"}),
gomock.Eq(store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})).Times(1).
Return([]*peer.Peer{{ID: "test-peer"}}, 10, nil)
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers)
assert.Equal(t, 10, total)
assert.NoError(t, err)
}
func TestManager_GetPeersPaginated_GetUserWithError(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(nil, fmt.Errorf("boom!"))
_, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, 0, total)
assert.ErrorContains(t, err, "boom!")
}
func TestManager_GetPeersPaginated_NoPermission(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(&types.Settings{RegularUsersViewBlocked: false}, nil)
mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1).
Return([]*peer.Peer{{ID: "test-peer"}}, nil)
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers)
assert.Equal(t, 1, total)
assert.NoError(t, err)
}
func TestManager_GetPeersPaginated_NoPermission_GetUserPeers_WithError(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(&types.Settings{RegularUsersViewBlocked: false}, nil)
mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1).
Return(nil, fmt.Errorf("boom!"))
_, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, 0, total)
assert.ErrorContains(t, err, "boom!")
}
func TestManager_GetPeersPaginated_GetAccountSettings_WithError(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(nil, fmt.Errorf("boom!"))
_, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, 0, total)
assert.ErrorContains(t, err, "boom!")
}
func TestManager_GetPeersPaginated_NoPermission_User_IsRestrictable_ViewUnblocked(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
Role: types.UserRoleUser,
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(&types.Settings{RegularUsersViewBlocked: false}, nil)
mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1).
Return([]*peer.Peer{{ID: "test-peer"}}, nil)
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers)
assert.Equal(t, 1, total)
assert.NoError(t, err)
}
func TestManager_GetPeersPaginated_NoPermission_User_IsRestrictable_ViewBlocked(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
Role: types.UserRoleUser,
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(&types.Settings{RegularUsersViewBlocked: true}, nil)
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, make([]*peer.Peer, 0), peers)
assert.Equal(t, 0, total)
assert.NoError(t, err)
}
@@ -8,6 +8,6 @@ type Manager interface {
GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error) GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error)
CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error)
DeleteDomain(ctx context.Context, accountID, userID, domainID string) error DeleteDomain(ctx context.Context, accountID, userID, domainID string) error
ValidateDomain(ctx context.Context, accountID, userID, domainID string) ValidateDomain(ctx context.Context, accountID, userID, domainID string) error
GetClusterDomains() []string GetClusterDomains() []string
} }
@@ -134,7 +134,10 @@ func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.R
return return
} }
go h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID) if err := h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID); err != nil {
util.WriteError(r.Context(), err, w)
return
}
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusNoContent)
} }
@@ -29,7 +29,7 @@ func TestValidateDomain_ExpiredRegistration(t *testing.T) {
Update("validation_expires_at", expiresAt).Error) Update("validation_expires_at", expiresAt).Error)
env.resolver.set("validation.expired.example.com", testCluster) env.resolver.set("validation.expired.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "an expired registration must be refused")
stored := storedDomain(t, env.store, accountA, d.Domain) stored := storedDomain(t, env.store, accountA, d.Domain)
require.NotNil(t, stored) require.NotNil(t, stored)
@@ -47,7 +47,7 @@ func TestCreateDomain_ValidationDeadline(t *testing.T) {
assert.Equal(t, createdAt.Add(48*time.Hour), *d.ValidationExpiresAt, "new registrations get 48 hours") assert.Equal(t, createdAt.Add(48*time.Hour), *d.ValidationExpiresAt, "new registrations get 48 hours")
time.Sleep(time.Hour) time.Sleep(time.Hour)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "a missing CNAME must fail validation")
stored := storedDomain(t, env.store, accountA, d.Domain) stored := storedDomain(t, env.store, accountA, d.Domain)
require.NotNil(t, stored) require.NotNil(t, stored)
require.NotNil(t, stored.ValidationExpiresAt) require.NotNil(t, stored.ValidationExpiresAt)
@@ -198,9 +198,10 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})} resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
env.manager.validator.Resolver = resolver env.manager.validator.Resolver = resolver
done := make(chan struct{}) done := make(chan struct{})
var validateErr error
go func() { go func() {
defer close(done) defer close(done)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) validateErr = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
}() }()
<-resolver.started <-resolver.started
time.Sleep(48 * time.Hour) time.Sleep(48 * time.Hour)
@@ -211,6 +212,7 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
} }
close(resolver.release) close(resolver.release)
<-done <-done
assert.Error(t, validateErr, "a validation finishing after the deadline must be refused")
owner := accountA owner := accountA
if cleanup { if cleanup {
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "late validation must not restore the old claim") assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "late validation must not restore the old claim")
@@ -227,22 +227,19 @@ func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID s
return nil return nil
} }
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) { // validationTimeout bounds the DNS check so it ends, and reports why, before a proxy in
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) // front of management times the request out.
const validationTimeout = 10 * time.Second
// ValidateDomain checks the domain's validation CNAME and marks it validated. A failure
// the user can act on is returned as PreconditionFailed carrying the reason.
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) error {
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil { if err != nil {
log.WithFields(log.Fields{ return status.NewPermissionValidationError(err)
"accountID": accountID,
"domainID": domainID,
}).WithError(err).Error("validate domain")
return
} }
if !ok { if !ok {
log.WithFields(log.Fields{ return status.NewPermissionDeniedError()
"accountID": accountID,
"domainID": domainID,
"userID": userID,
}).Error("validate domain: permission denied")
return
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@@ -250,32 +247,23 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
"domainID": domainID, "domainID": domainID,
}).Info("starting domain validation") }).Info("starting domain validation")
d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID) d, err := m.store.GetCustomDomain(ctx, accountID, domainID)
if err != nil { if err != nil {
log.WithFields(log.Fields{ return fmt.Errorf("get domain from store: %w", err)
"accountID": accountID,
"domainID": domainID,
}).WithError(err).Error("get custom domain from store")
return
} }
if d.Validated { if d.Validated {
return return nil
} }
if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) { if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) {
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}). return validationFailed(domain.NewValidationError(domain.ValidationReasonExpired,
Debug("custom domain validation window has expired") "custom domain %s validation window has expired; delete and add the domain again", d.Domain))
return
} }
// Validate only against the domain's target cluster // Validate only against the domain's target cluster
targetCluster := d.TargetCluster targetCluster := d.TargetCluster
if targetCluster == "" { if targetCluster == "" {
log.WithFields(log.Fields{ return validationFailed(domain.NewValidationError(domain.ValidationReasonNoTargetCluster,
"accountID": accountID, "custom domain %s has no target cluster", d.Domain))
"domainID": domainID,
"domain": d.Domain,
}).Warn("domain has no target cluster set, skipping validation")
return
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@@ -285,32 +273,35 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
"targetCluster": targetCluster, "targetCluster": targetCluster,
}).Info("validating domain against target cluster") }).Info("validating domain against target cluster")
if m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) { lookupCtx, cancel := context.WithTimeout(ctx, validationTimeout)
_, err = m.validator.Validate(lookupCtx, d.Domain, []string{targetCluster})
cancel()
if err == nil {
d.Validated = true d.Validated = true
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil { if _, err := m.store.UpdateCustomDomain(ctx, accountID, d); err != nil {
entry := log.WithFields(log.Fields{ // A concurrent request may have validated the domain first.
"accountID": accountID, if current, getErr := m.store.GetCustomDomain(ctx, accountID, domainID); getErr == nil && current.Validated {
"domainID": domainID, return nil
}).WithError(err)
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.PreconditionFailed {
entry.Debug("custom domain registration is no longer pending validation")
return
} }
entry.Error("update custom domain in store") return fmt.Errorf("update domain in store: %w", err)
return
} }
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}). log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
Info("custom domain validated successfully") Info("custom domain validated successfully")
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta()) m.accountManager.StoreEvent(ctx, userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
} else { return nil
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
"targetCluster": targetCluster,
}).Warn("domain validation failed - CNAME does not match target cluster")
} }
return validationFailed(err)
}
// validationFailed shows a classified failure to the user and keeps anything else internal.
func validationFailed(err error) error {
var vErr *domain.ValidationError
if errors.As(err, &vErr) {
return status.Errorf(status.PreconditionFailed, "%s", vErr.Message)
}
return fmt.Errorf("validate domain: %w", err)
} }
// GetClusterDomains returns a list of proxy cluster domains. // GetClusterDomains returns a list of proxy cluster domains.
@@ -229,7 +229,7 @@ func TestValidateDomain_UnlocksClusterDerivation(t *testing.T) {
require.Error(t, err) require.Error(t, err)
env.resolver.set("validation.later.example.com", testCluster) env.resolver.set("validation.later.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID) require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID))
require.True(t, storedDomain(t, env.store, accountA, "later.example.com").Validated) require.True(t, storedDomain(t, env.store, accountA, "later.example.com").Validated)
@@ -286,7 +286,7 @@ func TestValidateDomain_PermissionDeniedDoesNotValidate(t *testing.T) {
// a validated domain is the permission check. // a validated domain is the permission check.
env.resolver.set("validation.guarded.example.com", testCluster) env.resolver.set("validation.guarded.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID) assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID), "a caller without permission must be refused")
stored := storedDomain(t, env.store, accountA, "guarded.example.com") stored := storedDomain(t, env.store, accountA, "guarded.example.com")
require.NotNil(t, stored) require.NotNil(t, stored)
@@ -52,7 +52,7 @@ func TestCreateDomain_NormalizedNameCanValidateLater(t *testing.T) {
require.False(t, created.Validated, "a missing CNAME must leave the normalized registration pending") require.False(t, created.Validated, "a missing CNAME must leave the normalized registration pending")
env.resolver.set("validation.apps.example.com", testCluster) env.resolver.set("validation.apps.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID) require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID))
stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID) stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "apps.example.com", stored.Domain, "retrying validation must retain the normalized name") assert.Equal(t, "apps.example.com", stored.Domain, "retrying validation must retain the normalized name")
@@ -0,0 +1,151 @@
package manager
import (
"context"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
nbstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/status"
)
// The validator tests cover each DNS outcome. These cover what the manager adds on top:
// the status type the API turns into a response code, and the checks before the lookup.
func TestValidateDomain_ReturnsReason(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
setup func(t *testing.T, env *domainTestEnv, d *domain.Domain)
user string
want status.Type
message string
}{
{
name: "cname mismatch",
setup: func(_ *testing.T, env *domainTestEnv, d *domain.Domain) {
env.resolver.set("validation."+d.Domain, "other.example.net")
},
want: status.PreconditionFailed,
message: "CNAME record validation.test.example.com points to other.example.net; point it to " + testCluster,
},
{
name: "expired window",
setup: func(t *testing.T, env *domainTestEnv, d *domain.Domain) {
setDomainColumn(t, env, d, "validation_expires_at", time.Now().Add(-time.Second))
},
want: status.PreconditionFailed,
message: "custom domain test.example.com validation window has expired; delete and add the domain again",
},
{
name: "no target cluster",
setup: func(t *testing.T, env *domainTestEnv, d *domain.Domain) {
setDomainColumn(t, env, d, "target_cluster", "")
},
want: status.PreconditionFailed,
message: "custom domain test.example.com has no target cluster",
},
{
name: "permission denied",
setup: func(_ *testing.T, env *domainTestEnv, d *domain.Domain) {
env.resolver.set("validation."+d.Domain, testCluster)
},
user: accountAMember,
want: status.PermissionDenied,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := setupDomainTest(t)
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "test.example.com", testCluster)
require.NoError(t, err)
tt.setup(t, env, d)
user := accountAUser
if tt.user != "" {
user = tt.user
}
err = env.manager.ValidateDomain(ctx, accountA, user, d.ID)
sErr, ok := status.FromError(err)
require.True(t, ok, "expected a status error, got %v", err)
assert.Equal(t, tt.want, sErr.Type(), "status type")
if tt.message != "" {
assert.Equal(t, tt.message, sErr.Message, "user-facing message")
}
assert.False(t, storedDomain(t, env.store, accountA, d.Domain).Validated, "a failed check must not validate the domain")
})
}
}
func TestValidateDomain_SuccessIsIdempotent(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "ok.example.com", testCluster)
require.NoError(t, err)
env.resolver.set("validation.ok.example.com", testCluster)
require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID))
assert.True(t, storedDomain(t, env.store, accountA, d.Domain).Validated, "domain should be validated")
assert.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "an already validated domain is a success")
}
func setDomainColumn(t *testing.T, env *domainTestEnv, d *domain.Domain, column string, value any) {
t.Helper()
db := env.store.(*nbstore.SqlStore).GetDB()
require.NoError(t, db.Model(&domain.Domain{}).Where("id = ?", d.ID).Update(column, value).Error)
}
// Two verify requests can both read the domain as pending. The one that loses the store
// update must still report success when the other one validated the domain.
func TestValidateDomain_ConcurrentValidationSucceeds(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "twice.example.com", testCluster)
require.NoError(t, err)
resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
env.manager.validator.Resolver = resolver
done := make(chan error, 1)
go func() {
done <- env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
}()
<-resolver.started
// The other request completes while this one is still looking up the CNAME.
setDomainColumn(t, env, d, "validated", true)
close(resolver.release)
assert.NoError(t, <-done, "a domain validated by a concurrent request is a success")
}
// hangingResolver never answers, so only a deadline on the context ends the lookup.
type hangingResolver struct{}
func (hangingResolver) LookupCNAME(ctx context.Context, _ string) (string, error) {
<-ctx.Done()
return "", ctx.Err()
}
// A lookup that hangs must end on the validation timeout and still report the reason,
// rather than leaving the request to whatever timeout sits in front of management.
func TestValidateDomain_LookupTimeout(t *testing.T) {
env := setupClockDomainTest(t)
synctest.Test(t, func(t *testing.T) {
ctx := context.Background()
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "slow.example.com", testCluster)
require.NoError(t, err)
env.manager.validator.Resolver = hangingResolver{}
start := time.Now()
err = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
sErr, ok := status.FromError(err)
require.True(t, ok, "expected a status error, got %v", err)
assert.Equal(t, status.PreconditionFailed, sErr.Type(), "status type")
assert.Equal(t, "DNS lookup for validation.slow.example.com failed; retry the validation", sErr.Message, "user-facing message")
assert.Equal(t, validationTimeout, time.Since(start), "the lookup must end on the validation timeout")
})
}
@@ -2,6 +2,8 @@ package domain
import ( import (
"context" "context"
"errors"
"fmt"
"net" "net"
"strings" "strings"
@@ -16,6 +18,33 @@ type Validator struct {
Resolver resolver Resolver resolver
} }
// ValidationReason classifies why a custom domain failed validation.
type ValidationReason string
const (
ValidationReasonCNAMENotFound ValidationReason = "cname_not_found"
ValidationReasonCNAMEMismatch ValidationReason = "cname_mismatch"
ValidationReasonLookupFailed ValidationReason = "lookup_failed"
ValidationReasonExpired ValidationReason = "validation_expired"
ValidationReasonNoTargetCluster ValidationReason = "no_target_cluster"
)
// ValidationError is a validation failure whose message is safe to show to the user.
type ValidationError struct {
Reason ValidationReason
Message string
}
// Error returns the user-facing message.
func (e *ValidationError) Error() string {
return e.Message
}
// NewValidationError returns a ValidationError with a formatted message.
func NewValidationError(reason ValidationReason, format string, args ...any) *ValidationError {
return &ValidationError{Reason: reason, Message: fmt.Sprintf(format, args...)}
}
// NewValidator initializes a validator with a specific DNS Resolver. // NewValidator initializes a validator with a specific DNS Resolver.
// If a Validator is used without specifying a Resolver, then it will // If a Validator is used without specifying a Resolver, then it will
// use the net.DefaultResolver. // use the net.DefaultResolver.
@@ -39,6 +68,13 @@ func (v *Validator) IsValid(ctx context.Context, domain string, accept []string)
// ValidateWithCluster validates a custom domain and returns the matched cluster address. // ValidateWithCluster validates a custom domain and returns the matched cluster address.
// Returns the cluster address and true if valid, or empty string and false if invalid. // Returns the cluster address and true if valid, or empty string and false if invalid.
func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) { func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) {
cluster, err := v.Validate(ctx, domain, accept)
return cluster, err == nil
}
// Validate validates a custom domain and returns the matched cluster address.
// On failure it returns a *ValidationError whose message is safe to show to the user.
func (v *Validator) Validate(ctx context.Context, domain string, accept []string) (string, error) {
if v.Resolver == nil { if v.Resolver == nil {
v.Resolver = net.DefaultResolver v.Resolver = net.DefaultResolver
} }
@@ -56,10 +92,19 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
"domain": domain, "domain": domain,
"lookupDomain": lookupDomain, "lookupDomain": lookupDomain,
}).WithError(err).Warn("CNAME lookup failed for domain validation") }).WithError(err).Warn("CNAME lookup failed for domain validation")
return "", false // Never forward the resolver error: its text includes the resolver address.
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return "", cnameNotFound(lookupDomain, accept)
}
return "", NewValidationError(ValidationReasonLookupFailed, "DNS lookup for %s failed; retry the validation", lookupDomain)
} }
nakedCNAME := strings.TrimSuffix(cname, ".") nakedCNAME := strings.TrimSuffix(cname, ".")
// A name without a CNAME record resolves to itself.
if strings.EqualFold(nakedCNAME, lookupDomain) {
return "", cnameNotFound(lookupDomain, accept)
}
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"domain": domain, "domain": domain,
"cname": cname, "cname": cname,
@@ -69,13 +114,13 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
for _, acceptDomain := range accept { for _, acceptDomain := range accept {
normalizedAccept := strings.TrimSuffix(acceptDomain, ".") normalizedAccept := strings.TrimSuffix(acceptDomain, ".")
if nakedCNAME == normalizedAccept { if strings.EqualFold(nakedCNAME, normalizedAccept) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"domain": domain, "domain": domain,
"cname": nakedCNAME, "cname": nakedCNAME,
"cluster": acceptDomain, "cluster": acceptDomain,
}).Info("domain CNAME matched cluster") }).Info("domain CNAME matched cluster")
return acceptDomain, true return acceptDomain, nil
} }
} }
@@ -84,5 +129,11 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
"cname": nakedCNAME, "cname": nakedCNAME,
"acceptList": accept, "acceptList": accept,
}).Warn("domain CNAME does not match any accepted cluster") }).Warn("domain CNAME does not match any accepted cluster")
return "", false return "", NewValidationError(ValidationReasonCNAMEMismatch, "CNAME record %s points to %s; point it to %s",
lookupDomain, nakedCNAME, strings.Join(accept, ", "))
}
func cnameNotFound(lookupDomain string, accept []string) *ValidationError {
return NewValidationError(ValidationReasonCNAMENotFound, "no CNAME record found for %s; point it to %s",
lookupDomain, strings.Join(accept, ", "))
} }
@@ -2,6 +2,8 @@ package domain_test
import ( import (
"context" "context"
"errors"
"net"
"testing" "testing"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
@@ -36,6 +38,12 @@ func TestIsValid(t *testing.T) {
accept: []string{"bar.example.com"}, accept: []string{"bar.example.com"},
expect: false, expect: false,
}, },
"match in other case": {
resolver: resolver{"Bar.Example.COM."},
domain: "foo.example.com",
accept: []string{"bar.example.com"},
expect: true,
},
"accept trailing dot": { "accept trailing dot": {
resolver: resolver{"bar.example.com."}, resolver: resolver{"bar.example.com."},
domain: "foo.example.com", domain: "foo.example.com",
@@ -54,3 +62,61 @@ func TestIsValid(t *testing.T) {
}) })
} }
} }
type errResolver struct {
err error
}
func (r errResolver) LookupCNAME(context.Context, string) (string, error) {
return "", r.err
}
func TestValidate_Reason(t *testing.T) {
const notFound = "no CNAME record found for validation.foo.example.com; point it to eu.proxy.example.com"
tests := map[string]struct {
resolver interface {
LookupCNAME(context.Context, string) (string, error)
}
reason domain.ValidationReason
message string
}{
"mismatch": {
resolver: resolver{"other.example.net."},
reason: domain.ValidationReasonCNAMEMismatch,
message: "CNAME record validation.foo.example.com points to other.example.net; point it to eu.proxy.example.com",
},
"no cname resolves to itself": {
resolver: resolver{"validation.foo.example.com."},
reason: domain.ValidationReasonCNAMENotFound,
message: notFound,
},
"no cname resolves to itself in other case": {
resolver: resolver{"Validation.Foo.example.com."},
reason: domain.ValidationReasonCNAMENotFound,
message: notFound,
},
"not found": {
resolver: errResolver{&net.DNSError{Err: "no such host", Name: "validation.foo.example.com", Server: "10.0.0.2:53", IsNotFound: true}},
reason: domain.ValidationReasonCNAMENotFound,
message: notFound,
},
"other error is not forwarded": {
resolver: errResolver{errors.New("dial udp 10.0.0.2:53: connect: network is unreachable")},
reason: domain.ValidationReasonLookupFailed,
message: "DNS lookup for validation.foo.example.com failed; retry the validation",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
_, err := domain.NewValidator(test.resolver).Validate(t.Context(), "foo.example.com", []string{"eu.proxy.example.com"})
var vErr *domain.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected a *domain.ValidationError, got %v", err)
}
if vErr.Reason != test.reason || vErr.Message != test.message {
t.Errorf("expect %q %q, actual %q %q", test.reason, test.message, vErr.Reason, vErr.Message)
}
})
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ func (s *BaseServer) EventStore() activity.Store {
func (s *BaseServer) APIHandler() http.Handler { func (s *BaseServer) APIHandler() http.Handler {
_ = CreateNamed(s, "http_v1api", func() http.Handler { _ = CreateNamed(s, "http_v1api", func() http.Handler {
apiv1Router := s.ApiV1Router() apiv1Router := s.ApiV1Router()
_, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.NetworkMapController(), s.PermissionsManager()) _, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.PeersManager(), s.NetworkMapController(), s.PermissionsManager())
if err != nil { if err != nil {
log.Fatalf("failed to create API handler: %v", err) log.Fatalf("failed to create API handler: %v", err)
} }
+5 -1
View File
@@ -335,7 +335,11 @@ func (s *BaseServer) Stop() error {
} }
select { select {
case <-s.Errors(): case err := <-s.Errors():
if err != nil {
log.WithContext(ctx).Infof("stopped Management Service: %v", err)
return nil
}
log.WithContext(ctx).Infof("stopped Management Service") log.WithContext(ctx).Infof("stopped Management Service")
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
-1
View File
@@ -6,5 +6,4 @@ type Engine string
const ( const (
SqliteStoreEngine Engine = "sqlite" SqliteStoreEngine Engine = "sqlite"
PostgresStoreEngine Engine = "postgres" PostgresStoreEngine Engine = "postgres"
MysqlStoreEngine Engine = "mysql"
) )
-25
View File
@@ -11,7 +11,6 @@ import (
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@@ -121,30 +120,6 @@ func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, erro
return conn, nil return conn, nil
} }
// MysqlDSN adds the connection parameters every MySQL handle needs, keeping
// the options already present in dsn.
func MysqlDSN(dsn string) string {
separator := "?"
if strings.Contains(dsn, "?") {
separator = "&"
}
return dsn + separator + "charset=utf8&parseTime=True&loc=Local"
}
// OpenMysql opens a MySQL database through gorm.
func OpenMysql(ctx context.Context, dsn string) (*Conn, error) {
gormDB, err := gorm.Open(mysql.Open(MysqlDSN(dsn)), GormConfig())
if err != nil {
return nil, err
}
conn, err := NewConn(ctx, gormDB, MysqlStoreEngine, nil)
if err != nil {
closeGorm(gormDB)
return nil, err
}
return conn, nil
}
func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) { func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(dsn) config, err := pgxpool.ParseConfig(dsn)
if err != nil { if err != nil {
@@ -1,12 +0,0 @@
package db
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMysqlDSN(t *testing.T) {
assert.Equal(t, "user:pw@tcp(host:3306)/db?charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db"))
assert.Equal(t, "user:pw@tcp(host:3306)/db?tls=true&charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db?tls=true"))
}
+1 -29
View File
@@ -39,10 +39,7 @@ func (c *Conn) RunInTx(ctx context.Context, fn func(tx *Tx) error) error {
return err return err
} }
err := c.withForeignKeyChecksDisabled(tx, func() error { if err := fn(&Tx{db: tx}); err != nil {
return fn(&Tx{db: tx})
})
if err != nil {
tx.Rollback() tx.Rollback()
c.logIfTimedOut(ctx, timeoutCtx, err, "transaction", startTime) c.logIfTimedOut(ctx, timeoutCtx, err, "transaction", startTime)
return err return err
@@ -73,31 +70,6 @@ func (c *Conn) applyStatementTimeouts(tx *gorm.DB) error {
return nil return nil
} }
// withForeignKeyChecksDisabled runs fn with MySQL's FK checks off, which avoids
// deadlocks on MySQL and Aurora without needing SUPER privilege. The setting is
// session-scoped and survives a rollback, so it is turned back on whenever fn
// returns or panics; otherwise the pooled connection would keep it disabled.
func (c *Conn) withForeignKeyChecksDisabled(tx *gorm.DB, fn func() error) (err error) {
if c.engine != MysqlStoreEngine {
return fn()
}
if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil {
return fmt.Errorf("failed to disable FK checks: %w", err)
}
defer func() {
restoreErr := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error
if restoreErr == nil {
return
}
if err == nil {
err = fmt.Errorf("failed to re-enable FK checks: %w", restoreErr)
return
}
log.WithContext(tx.Statement.Context).Warnf("failed to re-enable FK checks after failed transaction: %v", restoreErr)
}()
return fn()
}
func (c *Conn) logIfTimedOut(ctx, timeoutCtx context.Context, err error, phase string, startTime time.Time) { func (c *Conn) logIfTimedOut(ctx, timeoutCtx context.Context, err error, phase string, startTime time.Time) {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
log.WithContext(ctx).Warnf("%s exceeded %s timeout after %v, stack: %s", phase, c.txTimeout, time.Since(startTime), debug.Stack()) log.WithContext(ctx).Warnf("%s exceeded %s timeout after %v, stack: %s", phase, c.txTimeout, time.Since(startTime), debug.Stack())
-1
View File
@@ -152,7 +152,6 @@ func (am *DefaultAccountManager) runAccountDeletionHooks(ctx context.Context, ac
func isUniqueConstraintError(err error) bool { func isUniqueConstraintError(err error) bool {
switch { switch {
case strings.Contains(err.Error(), "(SQLSTATE 23505)"), case strings.Contains(err.Error(), "(SQLSTATE 23505)"),
strings.Contains(err.Error(), "Error 1062 (23000)"),
strings.Contains(err.Error(), "UNIQUE constraint failed"): strings.Contains(err.Error(), "UNIQUE constraint failed"):
return true return true
+1 -1
View File
@@ -3759,7 +3759,7 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel.
// peerUpdateTimeout bounds how long peerShouldReceiveUpdate and its outer // peerUpdateTimeout bounds how long peerShouldReceiveUpdate and its outer
// wrappers wait for an expected update message. Sized for slow CI runners // wrappers wait for an expected update message. Sized for slow CI runners
// (MySQL, FreeBSD, loaded sqlite) where the channel publish can take // (FreeBSD, loaded sqlite) where the channel publish can take
// seconds. Only runs down on failure; passing tests return immediately // seconds. Only runs down on failure; passing tests return immediately
// when the channel delivers. // when the channel delivers.
const peerUpdateTimeout = 5 * time.Second const peerUpdateTimeout = 5 * time.Second
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions"
peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/server/api/v1alpha1/peers" "github.com/netbirdio/netbird/management/server/api/v1alpha1/peers"
"github.com/netbirdio/netbird/management/server/api/v1alpha1/users" "github.com/netbirdio/netbird/management/server/api/v1alpha1/users"
) )
@@ -20,6 +21,7 @@ func NewAPIV1Handler(
ctx context.Context, ctx context.Context,
router *mux.Router, router *mux.Router,
accountManager account.Manager, accountManager account.Manager,
peerManager peersmodule.Manager,
networkMapController network_map.Controller, networkMapController network_map.Controller,
permissionsManager permissions.Manager) (http.Handler, error) { permissionsManager permissions.Manager) (http.Handler, error) {
@@ -30,7 +32,7 @@ func NewAPIV1Handler(
router.Use(v1validatorMiddleware.Handler) router.Use(v1validatorMiddleware.Handler)
peersHandler := peers.NewHandler(accountManager, networkMapController, permissionsManager) peersHandler := peers.NewHandler(accountManager, peerManager, networkMapController, permissionsManager)
_ = peersHandler.WithEndpointsForRouter(router) _ = peersHandler.WithEndpointsForRouter(router)
usersHandler := users.NewHandler(accountManager) usersHandler := users.NewHandler(accountManager)
@@ -6,17 +6,22 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/netip" "net/netip"
"net/url"
"strconv"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/api/v1alpha1/groups" "github.com/netbirdio/netbird/management/server/api/v1alpha1/groups"
nbcontext "github.com/netbirdio/netbird/management/server/context" nbcontext "github.com/netbirdio/netbird/management/server/context"
nbpeer "github.com/netbirdio/netbird/management/server/peer" nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
"github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/shared/management/status"
@@ -26,12 +31,14 @@ type Handler struct {
accountManager account.Manager accountManager account.Manager
permissionsManager permissions.Manager permissionsManager permissions.Manager
networkMapController network_map.Controller networkMapController network_map.Controller
peersManager peers.Manager
} }
// NewHandler creates a new peers Handler // NewHandler creates a new peers Handler
func NewHandler(accountManager account.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler { func NewHandler(accountManager account.Manager, peerManager peers.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler {
return &Handler{ return &Handler{
accountManager: accountManager, accountManager: accountManager,
peersManager: peerManager,
networkMapController: networkMapController, networkMapController: networkMapController,
permissionsManager: permissionsManager, permissionsManager: permissionsManager,
} }
@@ -210,14 +217,32 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
return return
} }
nameFilter := r.URL.Query().Get("name") pagination, err := paginationFromQuery(r.URL.Query())
ipFilter := r.URL.Query().Get("ip") if err != nil {
macFilter := r.URL.Query().Get("mac") log.WithContext(r.Context()).Errorf("error parsing query: %v", err)
util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w)
return
}
filters, err := filtersFromQuery(r.URL.Query())
if err != nil {
log.WithContext(r.Context()).Errorf("error parsing query: %v", err)
util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w)
return
}
sorting, err := sortingOrderFromQuery(r.URL.Query())
if err != nil {
log.WithContext(r.Context()).Errorf("error parsing query: %v", err)
util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w)
return
}
accountID, userID := userAuth.AccountId, userAuth.UserId accountID, userID := userAuth.AccountId, userAuth.UserId
peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter) peers, peerCount, err := h.peersManager.GetPeersPaginated(r.Context(), accountID, userID, pagination, filters, sorting)
if err != nil { if err != nil {
log.WithContext(r.Context()).Errorf("error getting peers: %v", err)
util.WriteError(r.Context(), err, w) util.WriteError(r.Context(), err, w)
return return
} }
@@ -232,35 +257,135 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
grps, _ := h.accountManager.GetAllGroups(r.Context(), accountID, userID) grps, _ := h.accountManager.GetAllGroups(r.Context(), accountID, userID)
grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers)) grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers))
respBody := make([]*apiv1alpha1.PeerBatch, 0, len(peers)) validPeers, invalidPeers, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
for _, peer := range peers {
if peer.ProxyMeta.Embedded {
continue
}
respBody = append(respBody, toPeerListItemResponse(peer, grpsInfoMap[peer.ID], dnsDomain, 0))
}
validPeersMap, invalidPeersMap, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
if err != nil { if err != nil {
log.WithContext(r.Context()).Errorf("failed to get validated peers: %v", err) log.WithContext(r.Context()).Errorf("failed to get validated peers: %v", err)
util.WriteError(r.Context(), fmt.Errorf("internal error"), w) util.WriteError(r.Context(), fmt.Errorf("internal error"), w)
return return
} }
h.setApprovalRequiredFlag(respBody, validPeersMap, invalidPeersMap)
respBody := apiv1alpha1.PeerBatch{Peers: make([]apiv1alpha1.Peer, 0, len(peers))}
for _, peer := range peers {
_, valid := validPeers[peer.ID]
reason := invalidPeers[peer.ID]
respBody.Peers = append(respBody.Peers, *toSinglePeerResponse(peer, grpsInfoMap[peer.ID], dnsDomain, valid, reason))
}
respBody.Page = pagination.Page
respBody.PageSize = pagination.PageSize
respBody.PeerCount = peerCount
util.WriteJSONObject(r.Context(), w, respBody) util.WriteJSONObject(r.Context(), w, respBody)
} }
func (h *Handler) setApprovalRequiredFlag(respBody []*apiv1alpha1.PeerBatch, validPeersMap map[string]struct{}, invalidPeersMap map[string]string) { func filtersFromQuery(v url.Values) (store.PeerFilters, error) {
for _, peer := range respBody { connected, err := parseBool(v.Get("connected"))
_, ok := validPeersMap[peer.Id] if err != nil {
if !ok { return store.PeerFilters{}, err
peer.ApprovalRequired = true }
reason := invalidPeersMap[peer.Id] approvalRequired, err := parseBool(v.Get("approval_required"))
peer.DisapprovalReason = &reason if err != nil {
return store.PeerFilters{}, err
}
var isServer *bool
if v.Get("kind") != "" {
isServer = new(v.Get("kind") == "server")
if *isServer && v.Get("user_id") != "" {
return store.PeerFilters{}, fmt.Errorf("incompatible filters: kind == server and a non-empty user_id")
} }
} }
return store.PeerFilters{
UserId: v.Get("user_id"),
GroupIds: v["group_ids"],
Connected: connected,
ApprovalRequired: approvalRequired,
Os: v["os"],
IP: v.Get("ip"),
MAC: v.Get("mac"),
Name: v.Get("name"),
Hostname: v.Get("hostname"),
IsServer: isServer,
PeersIds: v["peer_ids"],
}, nil
}
func parseBool(maybeBool string) (*bool, error) {
if maybeBool == "" {
return nil, nil //nolint:nilnil
}
parsed, err := strconv.ParseBool(maybeBool)
if err != nil {
return nil, err
}
return new(parsed), nil
}
func paginationFromQuery(v url.Values) (store.PaginationState, error) {
page, err := strconv.Atoi(v.Get("page"))
if err != nil {
return store.PaginationState{}, err
}
pageSize, err := strconv.Atoi(v.Get("page_size"))
if err != nil {
return store.PaginationState{}, err
}
return store.PaginationState{
Page: page,
PageSize: pageSize,
}, nil
}
func sortingOrderFromQuery(v url.Values) (store.PeerSorting, error) {
toret := store.PeerSorting{}
sortby, ok := v["sort_by"]
if !ok {
return toret, nil
}
for i := 0; i < len(sortby); {
// ascending sorting order by default
sortkey := store.PeerSortingKey{Order: store.AscOrderBy}
field, order := parseFieldAndOrder(sortby[i])
switch field {
case "name":
sortkey.Field = store.PeerSortByName
case "last_seen":
sortkey.Field = store.PeerSortByLastSeen
case "version":
sortkey.Field = store.PeerSortByVersion
default:
return toret, fmt.Errorf("unable to parse sorting order '%s'", sortby)
}
switch order {
case "asc", "":
// nop, already set
case "desc":
sortkey.Order = store.DescOrderBy
default:
return store.PeerSorting{}, fmt.Errorf("unable to parse sorting order '%s'", sortby)
}
toret.Fields = append(toret.Fields, sortkey)
i++
}
return toret, nil
}
func parseFieldAndOrder(fieldOrder string) (string, string) {
maybeFieldAndOrder := strings.SplitN(fieldOrder, ",", 2)
if len(maybeFieldAndOrder) == 2 {
return strings.TrimSpace(maybeFieldAndOrder[0]), strings.TrimSpace(maybeFieldAndOrder[1])
}
return strings.TrimSpace(maybeFieldAndOrder[0]), ""
} }
func parseIPv6(s *string) (netip.Addr, error) { func parseIPv6(s *string) (netip.Addr, error) {
@@ -337,61 +462,6 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinim
return apiPeer return apiPeer
} }
func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinimum, dnsDomain string, accessiblePeersCount int) *apiv1alpha1.PeerBatch {
osVersion := peer.Meta.OSVersion
if osVersion == "" {
osVersion = peer.Meta.Core
}
return &apiv1alpha1.PeerBatch{
CreatedAt: peer.CreatedAt,
AccessiblePeersCount: accessiblePeersCount,
Peer: apiv1alpha1.Peer{
PeerMinimum: apiv1alpha1.PeerMinimum{
Id: peer.ID,
Name: peer.Name,
},
Ip: peer.IP.String(),
Ipv6: peerIPv6String(peer),
ConnectionIp: peer.Location.ConnectionIP.String(),
Connected: peer.Status.Connected,
LastSeen: peer.Status.LastSeen,
Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion),
KernelVersion: peer.Meta.KernelVersion,
GeonameId: int(peer.Location.GeoNameID),
Version: peer.Meta.WtVersion,
Groups: groupsInfo,
SshEnabled: peer.SSHEnabled,
Hostname: peer.Meta.Hostname,
UserId: peer.UserID,
UiVersion: peer.Meta.UIVersion,
DnsLabel: fqdn(peer, dnsDomain),
ExtraDnsLabels: fqdnList(peer.ExtraDNSLabels, dnsDomain),
LoginExpirationEnabled: peer.LoginExpirationEnabled,
LastLogin: peer.GetLastLogin(),
LoginExpired: peer.Status.LoginExpired,
CountryCode: apiv1alpha1.CountryCode(peer.Location.CountryCode),
CityName: apiv1alpha1.CityName(peer.Location.CityName),
SerialNumber: peer.Meta.SystemSerialNumber,
InactivityExpirationEnabled: peer.InactivityExpirationEnabled,
Ephemeral: peer.Ephemeral,
LocalFlags: &apiv1alpha1.PeerLocalFlags{
BlockInbound: &peer.Meta.Flags.BlockInbound,
BlockLanAccess: &peer.Meta.Flags.BlockLANAccess,
DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes,
DisableDns: &peer.Meta.Flags.DisableDNS,
DisableFirewall: &peer.Meta.Flags.DisableFirewall,
DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes,
LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled,
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
},
},
}
}
func fqdn(peer *nbpeer.Peer, dnsDomain string) string { func fqdn(peer *nbpeer.Peer, dnsDomain string) string {
fqdn := peer.FQDN(dnsDomain) fqdn := peer.FQDN(dnsDomain)
if fqdn == "" { if fqdn == "" {
@@ -0,0 +1,191 @@
package peers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers"
nbcontext "github.com/netbirdio/netbird/management/server/context"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/auth"
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
func TestGetPeersPagination(t *testing.T) {
peer := &nbpeer.Peer{
ID: testPeerID,
Status: &nbpeer.PeerStatus{Connected: true},
Meta: nbpeer.PeerSystemMeta{},
}
tt := []struct {
name string
expectedStatus int
requestPath string
setExpectations func(p *Handler)
}{
{
name: "GetPeers default paging, filtering, and sorting",
requestPath: "/peers",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}), gomock.Any(), gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers paging",
requestPath: "/peers?page=10&page_size=250",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 10, PageSize: 250}),
gomock.Any(), gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers paging with invalid page",
requestPath: "/peers?page=aaa&page_size=250",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers paging with invalid page size",
requestPath: "/peers?page=10&page_size=aaa",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering all valid params",
requestPath: "/peers?user_id=test-user&group_ids=1&group_ids=2&group_ids=3&group_ids=4&peer_ids=peer-1&peer_ids=peer-2&connected=true&approval_required=false&os=linux&os=windows&os=darwin&os=android&os=ios&os=js&ip=10.10.10.10&mac=00:11:22:33:44:55&name=test&hostname=test-host&kind=user_device",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}),
gomock.Eq(store.PeerFilters{
UserId: "test-user",
GroupIds: []string{"1", "2", "3", "4"},
Connected: new(true),
ApprovalRequired: new(false),
Os: []string{"linux", "windows", "darwin", "android", "ios", "js"},
IP: "10.10.10.10",
MAC: "00:11:22:33:44:55",
Name: "test",
Hostname: "test-host",
IsServer: new(false),
PeersIds: []string{"peer-1", "peer-2"},
}),
gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers filtering with kind server",
requestPath: "/peers?kind=server",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}),
gomock.Eq(store.PeerFilters{IsServer: new(true)}),
gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers filtering with invalid connected value",
requestPath: "/peers?connected=aaa",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering with invalid approval_required value",
requestPath: "/peers?approval_required=aaa",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering with invalid os value",
requestPath: "/peers?os=nosuchos",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering with invalid kind value",
requestPath: "/peers?kind=nosuchkind",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering with conflicting user_id and kind",
requestPath: "/peers?user_id=test-user&kind=server",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers sorting preserves field ordering",
requestPath: "/peers?sort_by=last_seen&sort_by=name&sort_by=version",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}),
gomock.Any(),
gomock.Eq(store.PeerSorting{
Fields: []store.PeerSortingKey{
{Field: store.PeerSortByLastSeen, Order: store.AscOrderBy},
{Field: store.PeerSortByName, Order: store.AscOrderBy},
{Field: store.PeerSortByVersion, Order: store.AscOrderBy},
},
})).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers sorting with invalid field",
requestPath: "/peers?sort_by=nosuchfield,asc",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers sorting with invalid field",
requestPath: "/peers?sort_by=nosuchfield,asc",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers sorting with invalid sorting order",
requestPath: "/peers?sort_by=name,nosuchorder&sort_by=version",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
p := initTestMetaData(t, peer)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.requestPath, nil)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: "admin_user",
Domain: "hotmail.com",
AccountId: "test_id",
})
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
assert.NoError(t, err)
router := mux.NewRouter()
router.Use(v1validator.Handler)
router = p.WithEndpointsForRouter(router)
tc.setExpectations(p)
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
assert.Equal(t, tc.expectedStatus, recorder.Code)
})
}
}
@@ -18,6 +18,7 @@ import (
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
"github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/controllers/network_map"
peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers"
nbcontext "github.com/netbirdio/netbird/management/server/context" nbcontext "github.com/netbirdio/netbird/management/server/context"
nbpeer "github.com/netbirdio/netbird/management/server/peer" nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions"
@@ -127,7 +128,10 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
}). }).
AnyTimes() AnyTimes()
peerManager := peersmodule.NewMockManager(ctrl2)
return &Handler{ return &Handler{
peersManager: peerManager,
accountManager: &mock_server.MockAccountManager{ accountManager: &mock_server.MockAccountManager{
UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) { UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
var p *nbpeer.Peer var p *nbpeer.Peer
@@ -173,8 +177,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
return nil, fmt.Errorf("user not found") return nil, fmt.Errorf("user not found")
} }
}, },
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { GetPeersFunc: func(_ context.Context, accountID, userID string, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
return peers, nil return peers, len(peers), nil
}, },
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
peersID := make([]string, len(peers)) peersID := make([]string, len(peers))
@@ -220,9 +224,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
} }
} }
// Tests the GetAllPeers endpoint reachable in the route /api/peers func TestGetPeer(t *testing.T) {
// Use the metadata generated by initTestMetaData() to check for values
func TestGetPeers(t *testing.T) {
peer := &nbpeer.Peer{ peer := &nbpeer.Peer{
ID: testPeerID, ID: testPeerID,
@@ -262,23 +264,13 @@ func TestGetPeers(t *testing.T) {
requestPath string requestPath string
contentType string contentType string
requestBody io.Reader requestBody io.Reader
expectedArray bool
expectedPeer *nbpeer.Peer expectedPeer *nbpeer.Peer
}{ }{
{
name: "GetPeersMetaData",
requestType: http.MethodGet,
requestPath: "/peers",
expectedStatus: http.StatusOK,
expectedArray: true,
expectedPeer: peer,
},
{ {
name: "GetPeer with update channel", name: "GetPeer with update channel",
requestType: http.MethodGet, requestType: http.MethodGet,
requestPath: "/peers/" + testPeerID, requestPath: "/peers/" + testPeerID,
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
expectedArray: false,
expectedPeer: peer, expectedPeer: peer,
}, },
{ {
@@ -287,7 +279,6 @@ func TestGetPeers(t *testing.T) {
requestPath: "/peers/" + testPeerID, requestPath: "/peers/" + testPeerID,
contentType: "application/json", contentType: "application/json",
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
expectedArray: false,
requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true, \"inactivity_expiration_enabled\":true}"), requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true, \"inactivity_expiration_enabled\":true}"),
expectedPeer: expectedUpdatedPeer, expectedPeer: expectedUpdatedPeer,
}, },
@@ -330,32 +321,123 @@ func TestGetPeers(t *testing.T) {
t.Fatalf("I don't know what I expected; %v", err) t.Fatalf("I don't know what I expected; %v", err)
} }
var got *apiv1alpha1.Peer var got apiv1alpha1.Peer
if tc.expectedArray { err = json.Unmarshal(content, &got)
respBody := []*apiv1alpha1.Peer{} if err != nil {
err = json.Unmarshal(content, &respBody) t.Fatalf("Sent content is not in correct json format; %v", err)
if err != nil {
t.Fatalf("Sent content is not in correct json format; %v", err)
}
// hardcode this check for now as we only have two peers in this suite
assert.Equal(t, len(respBody), 2)
for _, peer := range respBody {
if peer.Id == testPeerID {
got = peer
}
}
} else {
got = &apiv1alpha1.Peer{}
err = json.Unmarshal(content, got)
if err != nil {
t.Fatalf("Sent content is not in correct json format; %v", err)
}
} }
t.Log(got) assert.Equal(t, tc.expectedPeer.Name, got.Name)
assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version)
assert.Equal(t, tc.expectedPeer.IP.String(), got.Ip)
assert.Equal(t, "OS core", got.Os)
assert.Equal(t, tc.expectedPeer.LoginExpirationEnabled, got.LoginExpirationEnabled)
assert.Equal(t, tc.expectedPeer.SSHEnabled, got.SshEnabled)
assert.Equal(t, tc.expectedPeer.Status.Connected, got.Connected)
assert.Equal(t, tc.expectedPeer.Meta.SystemSerialNumber, got.SerialNumber)
})
}
}
func TestGetPeers(t *testing.T) {
peer := &nbpeer.Peer{
ID: testPeerID,
Key: "key",
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
Status: &nbpeer.PeerStatus{Connected: true},
Name: "PeerName",
LoginExpirationEnabled: false,
Meta: nbpeer.PeerSystemMeta{
Hostname: "hostname",
GoOS: "GoOS",
Kernel: "kernel",
Core: "core",
Platform: "platform",
OS: "OS",
WtVersion: "development",
SystemSerialNumber: "C02XJ0J0JGH7",
},
}
peer1 := peer.Copy()
peer1.ID = noUpdateChannelTestPeerID
tt := []struct {
name string
expectedStatus int
requestType string
requestPath string
contentType string
expectedPeer *nbpeer.Peer
}{
{
name: "GetPeersMetaData",
requestType: http.MethodGet,
requestPath: "/peers",
expectedStatus: http.StatusOK,
expectedPeer: peer,
},
}
p := initTestMetaData(t, peer, peer1)
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(tc.requestType, tc.requestPath, nil)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: "admin_user",
Domain: "hotmail.com",
AccountId: "test_id",
})
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
assert.NoError(t, err)
router := mux.NewRouter()
router.Use(v1validator.Handler)
router = p.WithEndpointsForRouter(router)
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return([]*nbpeer.Peer{peer, peer1}, 2, nil)
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
if status := recorder.Code; status != tc.expectedStatus {
t.Fatalf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
content, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("I don't know what I expected; %v", err)
}
var got *apiv1alpha1.Peer
respBody := apiv1alpha1.PeerBatch{}
err = json.Unmarshal(content, &respBody)
if err != nil {
t.Fatalf("Sent content is not in correct json format; %v", err)
}
assert.Equal(t, respBody.Page, 1)
assert.Equal(t, respBody.PageSize, 100)
assert.Equal(t, respBody.PeerCount, 2)
assert.Equal(t, len(respBody.Peers), 2)
for _, peer := range respBody.Peers {
if peer.Id == testPeerID {
got = &peer
}
}
assert.Equal(t, tc.expectedPeer.Name, got.Name) assert.Equal(t, tc.expectedPeer.Name, got.Name)
assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version) assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version)
+1 -1
View File
@@ -39,7 +39,7 @@ func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (Store
return nil, err return nil, err
} }
log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr) log.WithContext(subCtx).Infof("using redis cache at %s", options.Addr)
return &redisStore{ return &redisStore{
StoreInterface: redisstore.NewRedis(redisClient), StoreInterface: redisstore.NewRedis(redisClient),
@@ -78,8 +78,8 @@ func initGroupTestData(initGroups ...*types.Group) *handler {
return nil, status.Errorf(status.NotFound, "unknown group name") return nil, status.Errorf(status.NotFound, "unknown group name")
}, },
GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
return maps.Values(TestPeers), nil return maps.Values(TestPeers), len(maps.Values(TestPeers)), nil
}, },
DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error { DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error {
if groupID == "linked-grp" { if groupID == "linked-grp" {
@@ -173,8 +173,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
return nil, fmt.Errorf("user not found") return nil, fmt.Errorf("user not found")
} }
}, },
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
return peers, nil return peers, len(peers), nil
}, },
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
peersID := make([]string, len(peers)) peersID := make([]string, len(peers))
@@ -429,10 +429,6 @@ func Test_SyncStatusRace(t *testing.T) {
if os.Getenv("NETBIRD_STORE_ENGINE") == "postgres" { if os.Getenv("NETBIRD_STORE_ENGINE") == "postgres" {
t.Skip("Skipping on CI and Postgres store") t.Skip("Skipping on CI and Postgres store")
} }
if os.Getenv("NETBIRD_STORE_ENGINE") == "mysql" {
t.Skip("Skipping on CI and MySQL store")
}
} }
for i := 0; i < 500; i++ { for i := 0; i < 500; i++ {
t.Run(fmt.Sprintf("TestRun-%d", i), func(t *testing.T) { t.Run(fmt.Sprintf("TestRun-%d", i), func(t *testing.T) {
+8 -46
View File
@@ -19,19 +19,11 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
func GetColumnName(db *gorm.DB, column string) string {
if db.Name() == "mysql" {
return fmt.Sprintf("`%s`", column)
}
return column
}
// MigrateFieldFromGobToJSON migrates a column from Gob encoding to JSON encoding. // MigrateFieldFromGobToJSON migrates a column from Gob encoding to JSON encoding.
// T is the type of the model that contains the field to be migrated. // T is the type of the model that contains the field to be migrated.
// S is the type of the field to be migrated. // S is the type of the field to be migrated.
func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, fieldName string) error { func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, fieldName string) error {
orgColumnName := fieldName oldColumnName := fieldName
oldColumnName := GetColumnName(db, orgColumnName)
newColumnName := fieldName + "_tmp" newColumnName := fieldName + "_tmp"
var model T var model T
@@ -86,7 +78,7 @@ func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, f
for _, row := range rows { for _, row := range rows {
var field S var field S
str, ok := row[orgColumnName].(string) str, ok := row[oldColumnName].(string)
if !ok { if !ok {
return fmt.Errorf("type assertion failed") return fmt.Errorf("type assertion failed")
} }
@@ -125,8 +117,7 @@ func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, f
// MigrateNetIPFieldFromBlobToJSON migrates a Net IP column from Blob encoding to JSON encoding. // MigrateNetIPFieldFromBlobToJSON migrates a Net IP column from Blob encoding to JSON encoding.
// T is the type of the model that contains the field to be migrated. // T is the type of the model that contains the field to be migrated.
func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fieldName string, indexName string) error { func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fieldName string, indexName string) error {
orgColumnName := fieldName oldColumnName := fieldName
oldColumnName := GetColumnName(db, orgColumnName)
newColumnName := fieldName + "_tmp" newColumnName := fieldName + "_tmp"
var model T var model T
@@ -178,7 +169,7 @@ func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fi
for _, row := range rows { for _, row := range rows {
var blobValue string var blobValue string
if columnValue := row[orgColumnName]; columnValue != nil { if columnValue := row[oldColumnName]; columnValue != nil {
value, ok := columnValue.(string) value, ok := columnValue.(string)
if !ok { if !ok {
return fmt.Errorf("type assertion failed") return fmt.Errorf("type assertion failed")
@@ -225,8 +216,7 @@ func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fi
} }
func MigrateSetupKeyToHashedSetupKey[T any](ctx context.Context, db *gorm.DB) error { func MigrateSetupKeyToHashedSetupKey[T any](ctx context.Context, db *gorm.DB) error {
orgColumnName := "key" oldColumnName := "key"
oldColumnName := GetColumnName(db, orgColumnName)
newColumnName := "key_secret" newColumnName := "key_secret"
var model T var model T
@@ -268,7 +258,7 @@ func MigrateSetupKeyToHashedSetupKey[T any](ctx context.Context, db *gorm.DB) er
for _, row := range rows { for _, row := range rows {
var plainKey string var plainKey string
if columnValue := row[orgColumnName]; columnValue != nil { if columnValue := row[oldColumnName]; columnValue != nil {
value, ok := columnValue.(string) value, ok := columnValue.(string)
if !ok { if !ok {
return fmt.Errorf("type assertion failed") return fmt.Errorf("type assertion failed")
@@ -394,33 +384,13 @@ func CreateIndexIfNotExists[T any](ctx context.Context, db *gorm.DB, indexName s
return fmt.Errorf("failed to parse model schema: %w", err) return fmt.Errorf("failed to parse model schema: %w", err)
} }
tableName := stmt.Schema.Table tableName := stmt.Schema.Table
dialect := db.Name()
if db.Migrator().HasIndex(&model, indexName) { if db.Migrator().HasIndex(&model, indexName) {
log.WithContext(ctx).Infof("index %s already exists on table %s", indexName, tableName) log.WithContext(ctx).Infof("index %s already exists on table %s", indexName, tableName)
return nil return nil
} }
var columnClause string createStmt := fmt.Sprintf("CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (%s)", indexName, tableName, strings.Join(columns, ", "))
if dialect == "mysql" {
var withLength []string
for _, col := range columns {
quotedCol := fmt.Sprintf("`%s`", col)
if col == "ip" || col == "dns_label" || col == "key" {
withLength = append(withLength, fmt.Sprintf("%s(64)", quotedCol))
} else {
withLength = append(withLength, quotedCol)
}
}
columnClause = strings.Join(withLength, ", ")
} else {
columnClause = strings.Join(columns, ", ")
}
createStmt := fmt.Sprintf("CREATE UNIQUE INDEX %s ON %s (%s)", indexName, tableName, columnClause)
if dialect == "postgres" || dialect == "sqlite" {
createStmt = strings.Replace(createStmt, "CREATE UNIQUE INDEX", "CREATE UNIQUE INDEX IF NOT EXISTS", 1)
}
log.WithContext(ctx).Infof("executing index creation: %s", createStmt) log.WithContext(ctx).Infof("executing index creation: %s", createStmt)
if err := db.Exec(createStmt).Error; err != nil { if err := db.Exec(createStmt).Error; err != nil {
@@ -505,14 +475,6 @@ func hasForeignKey(db *gorm.DB, table, column string) bool {
AND kcu.table_name = ? AND kcu.table_name = ?
AND kcu.column_name = ? AND kcu.column_name = ?
`, table, column).Scan(&count) `, table, column).Scan(&count)
case "mysql":
db.Raw(`
SELECT COUNT(*) FROM information_schema.key_column_usage
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?
AND referenced_table_name IS NOT NULL
`, table, column).Scan(&count)
default: // sqlite default: // sqlite
type fkInfo struct { type fkInfo struct {
From string From string
@@ -592,7 +554,7 @@ func RemoveDuplicatePeerKeys(ctx context.Context, db *gorm.DB) error {
return nil return nil
} }
keyColumn := GetColumnName(db, "key") keyColumn := "key"
var duplicates []struct { var duplicates []struct {
Key string Key string
@@ -33,15 +33,6 @@ func (agentNetworkSettingsMigration) TableName() string { return "agent_network_
// bootstrap always wrote both, so such a row indicates corruption and the // bootstrap always wrote both, so such a row indicates corruption and the
// migration fails loudly rather than leaving an empty domain to collide with // migration fails loudly rather than leaving an empty domain to collide with
// the unique index confusingly. // the unique index confusingly.
//
// The transaction is real only on sqlite and postgres, where DDL is
// transactional. MySQL implicitly commits around every ALTER TABLE, so there
// each step stands alone; what makes an interrupted run resumable on MySQL is
// that every step is guarded by the schema state it changes — the entry check
// fires while either legacy column remains, the adds skip existing columns,
// the backfill and its loud-failure check run only while the legacy cluster
// column exists (they provably completed before any drop), and each drop
// skips what is already gone.
func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error { func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error {
model := &agentNetworkSettingsMigration{} model := &agentNetworkSettingsMigration{}
migrator := db.Migrator() migrator := db.Migrator()
@@ -66,14 +57,9 @@ func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error
} }
if hasCluster { if hasCluster {
concat := "subdomain || '.' || cluster" res := tx.Exec(
if tx.Name() == "mysql" { "UPDATE agent_network_settings SET domain = subdomain || '.' || cluster, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
concat = "CONCAT(subdomain, '.', cluster)" )
}
res := tx.Exec(fmt.Sprintf(
"UPDATE agent_network_settings SET domain = %s, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
concat,
))
if res.Error != nil { if res.Error != nil {
return fmt.Errorf("backfill agent_network_settings domain: %w", res.Error) return fmt.Errorf("backfill agent_network_settings domain: %w", res.Error)
} }
+13 -41
View File
@@ -11,7 +11,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@@ -32,17 +31,6 @@ func setupDatabase(t *testing.T) *gorm.DB {
var dsn string var dsn string
var cleanup func() var cleanup func()
switch os.Getenv("NETBIRD_STORE_ENGINE") { switch os.Getenv("NETBIRD_STORE_ENGINE") {
case "mysql":
cleanup, dsn, err = testutil.CreateMysqlTestContainer()
if err != nil {
t.Fatalf("Failed to create MySQL test container: %v", err)
}
if dsn == "" {
t.Fatal("MySQL connection string is empty, ensure the test container is running")
}
db, err = gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{})
case "postgres": case "postgres":
cleanup, dsn, err = testutil.CreatePostgresTestContainer() cleanup, dsn, err = testutil.CreatePostgresTestContainer()
if err != nil { if err != nil {
@@ -597,9 +585,8 @@ func TestCleanupOrphanedResources_Idempotent(t *testing.T) {
} }
func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) { func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
engine := os.Getenv("NETBIRD_STORE_ENGINE") if os.Getenv("NETBIRD_STORE_ENGINE") != "postgres" {
if engine != "postgres" && engine != "mysql" { t.Skip("FK constraint early-exit test requires postgres")
t.Skip("FK constraint early-exit test requires postgres or mysql")
} }
db := setupDatabase(t) db := setupDatabase(t)
@@ -614,24 +601,12 @@ func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
require.NoError(t, db.Create(&testChildWithFK{ID: "c1", ParentID: "p1"}).Error) require.NoError(t, db.Create(&testChildWithFK{ID: "c1", ParentID: "p1"}).Error)
require.NoError(t, db.Create(&testChildWithFK{ID: "c2", ParentID: "p2"}).Error) require.NoError(t, db.Create(&testChildWithFK{ID: "c2", ParentID: "p2"}).Error)
switch engine { require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent").Error)
case "postgres": require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error)
require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent").Error) require.NoError(t, db.Exec(
require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error) "ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+
require.NoError(t, db.Exec( "FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID",
"ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+ ).Error)
"FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID",
).Error)
case "mysql":
require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error)
require.NoError(t, db.Exec("ALTER TABLE test_children DROP FOREIGN KEY fk_test_children_parent").Error)
require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error)
require.NoError(t, db.Exec(
"ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+
"FOREIGN KEY (parent_id) REFERENCES test_parents(id)",
).Error)
require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error)
}
err = migration.CleanupOrphanedResources[testChildWithFK, testParent](context.Background(), db, "parent_id") err = migration.CleanupOrphanedResources[testChildWithFK, testParent](context.Background(), db, "parent_id")
require.NoError(t, err) require.NoError(t, err)
@@ -717,8 +692,6 @@ func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) {
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{})) require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{})) require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}))
// Timestamp must be set explicitly: a zero time.Time serialises as
// '0000-00-00 00:00:00', which MySQL rejects under strict mode.
require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{ require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6",
Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC), Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC),
@@ -819,10 +792,9 @@ func TestMigrateAgentNetworkSettingsToDomain_FailsOnUnmigratableRow(t *testing.T
assert.Contains(t, err.Error(), "resolve them manually", "the error must tell the operator what to do") assert.Contains(t, err.Error(), "resolve them manually", "the error must tell the operator what to do")
} }
// partialAgentNetworkSettings models the one non-atomic state a MySQL run can // partialAgentNetworkSettings models a schema left between the two legacy
// be interrupted in: DDL auto-commits there, so a crash between the two legacy // column drops: subdomain is still present while cluster is gone and the
// column drops leaves subdomain behind while cluster (and the completed // backfill has already completed.
// backfill) are already committed.
type partialAgentNetworkSettings struct { type partialAgentNetworkSettings struct {
AccountID string `gorm:"primaryKey"` AccountID string `gorm:"primaryKey"`
Subdomain string Subdomain string
@@ -832,8 +804,8 @@ type partialAgentNetworkSettings struct {
func (partialAgentNetworkSettings) TableName() string { return "agent_network_settings" } func (partialAgentNetworkSettings) TableName() string { return "agent_network_settings" }
// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins MySQL // TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins
// resumability: a rerun over the interrupted state must remove the leftover // resumability: a rerun over the partial state must remove the leftover
// subdomain column without re-running the backfill (the cluster column that // subdomain column without re-running the backfill (the cluster column that
// feeds it is gone) and without touching the migrated values. // feeds it is gone) and without touching the migrated values.
func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.T) { func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.T) {
@@ -39,7 +39,7 @@ type MockAccountManager struct {
GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error) GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error)
GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error) ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error)
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error)
MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
@@ -820,7 +820,8 @@ func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, user
// GetPeers mocks GetPeers of the AccountManager interface // GetPeers mocks GetPeers of the AccountManager interface
func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
if am.GetPeersFunc != nil { if am.GetPeersFunc != nil {
return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter) peers, _, err := am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter)
return peers, err
} }
return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented") return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented")
} }
+3 -1
View File
@@ -73,7 +73,9 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
return []*nbpeer.Peer{}, nil return []*nbpeer.Peer{}, nil
} }
return am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) peers, err := am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
return peers, err
} }
// MarkPeerConnected marks a peer as connected with optimistic-locked // MarkPeerConnected marks a peer as connected with optimistic-locked
+1 -7
View File
@@ -730,7 +730,6 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) {
return return
} }
assert.NotNil(t, peers) assert.NotNil(t, peers)
assert.Len(t, peers, testCase.expectedPeerCount) assert.Len(t, peers, testCase.expectedPeerCount)
}) })
@@ -1567,8 +1566,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
func Test_RegisterPeerRollbackOnFailure(t *testing.T) { func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
engine := os.Getenv("NETBIRD_STORE_ENGINE") engine := os.Getenv("NETBIRD_STORE_ENGINE")
if engine == "sqlite" || engine == "mysql" || engine == "" { if engine == "sqlite" || engine == "" {
// we intentionally disabled foreign keys in mysql
t.Skip("Skipping test because store is not respecting foreign keys") t.Skip("Skipping test because store is not respecting foreign keys")
} }
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
@@ -2220,10 +2218,6 @@ func Test_IsUniqueConstraintError(t *testing.T) {
name: "PostgreSQL uniqueness error", name: "PostgreSQL uniqueness error",
engine: types.PostgresStoreEngine, engine: types.PostgresStoreEngine,
}, },
{
name: "MySQL uniqueness error",
engine: types.MysqlStoreEngine,
},
{ {
name: "SQLite uniqueness error", name: "SQLite uniqueness error",
engine: types.SqliteStoreEngine, engine: types.SqliteStoreEngine,
-36
View File
@@ -33,7 +33,6 @@ import (
const ( const (
idQueryCondition = "id = ?" idQueryCondition = "id = ?"
keyQueryCondition = "key = ?" keyQueryCondition = "key = ?"
mysqlKeyQueryCondition = "`key` = ?"
accountAndIDQueryCondition = "account_id = ? and id = ?" accountAndIDQueryCondition = "account_id = ? and id = ?"
accountAndAnyIDQueryCondition = "account_id = ? and (id = ? or public_id = ?)" accountAndAnyIDQueryCondition = "account_id = ? and (id = ? or public_id = ?)"
accountAndPeerIDQueryCondition = "account_id = ? and peer_id = ?" accountAndPeerIDQueryCondition = "account_id = ? and peer_id = ?"
@@ -119,13 +118,6 @@ func (s *SqlStore) pgxPool() *pgxpool.Pool {
return s.conn.Pool(s.tx) return s.conn.Pool(s.tx)
} }
func GetKeyQueryCondition(s *SqlStore) string {
if s.conn.Engine() == db.MysqlStoreEngine {
return mysqlKeyQueryCondition
}
return keyQueryCondition
}
// AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock // AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock
func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) { func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) {
log.WithContext(ctx).Tracef("acquiring global lock") log.WithContext(ctx).Tracef("acquiring global lock")
@@ -174,15 +166,6 @@ func NewPostgresqlStore(ctx context.Context, dsn string, metrics telemetry.AppMe
return newStore(ctx, conn, metrics, skipMigration) return newStore(ctx, conn, metrics, skipMigration)
} }
// NewMysqlStore creates a new MySQL store.
func NewMysqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
conn, err := db.OpenMysql(ctx, dsn)
if err != nil {
return nil, err
}
return newStore(ctx, conn, metrics, skipMigration)
}
// NewSqliteStoreFromFileStore restores a store from FileStore and stores SQLite DB in the file located in datadir. // NewSqliteStoreFromFileStore restores a store from FileStore and stores SQLite DB in the file located in datadir.
func NewSqliteStoreFromFileStore(ctx context.Context, fileStore *FileStore, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { func NewSqliteStoreFromFileStore(ctx context.Context, fileStore *FileStore, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
store, err := NewSqliteStore(ctx, dataDir, metrics, skipMigration) store, err := NewSqliteStore(ctx, dataDir, metrics, skipMigration)
@@ -240,11 +223,6 @@ func NewPostgresqlStoreForTests(ctx context.Context, dsn string, metrics telemet
return newStore(ctx, conn, metrics, skipMigration) return newStore(ctx, conn, metrics, skipMigration)
} }
// NewMysqlStoreFromSqlStore restores a store from SqlStore and stores MySQL DB.
func NewMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics) (*SqlStore, error) {
return newMysqlStoreFromSqlStore(ctx, sqliteStore, dsn, metrics, false)
}
// seedFromSqliteStore copies the installation ID and the accounts of the // seedFromSqliteStore copies the installation ID and the accounts of the
// sqlite seed store into a freshly created engine store. // sqlite seed store into a freshly created engine store.
func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) error { func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) error {
@@ -259,20 +237,6 @@ func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) erro
return nil return nil
} }
func newMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
store, err := NewMysqlStore(ctx, dsn, metrics, skipMigration)
if err != nil {
return nil, err
}
if err := seedFromSqliteStore(ctx, store, sqliteStore); err != nil {
_ = store.Close(ctx)
return nil, err
}
return store, nil
}
// ExecuteInTransaction runs operation in a transaction. A store that is already // ExecuteInTransaction runs operation in a transaction. A store that is already
// bound to one joins it instead of opening a second, independent transaction. // bound to one joins it instead of opening a second, independent transaction.
func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error { func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error {
+2 -4
View File
@@ -1084,10 +1084,8 @@ func (s *SqlStore) SaveAccountSettings(ctx context.Context, accountID string, se
return status.Errorf(status.Internal, "failed to save account settings to store") return status.Errorf(status.Internal, "failed to save account settings to store")
} }
// MySQL reports RowsAffected=0 for no-op updates where values don't change, // No RowsAffected check: the caller (UpdateAccountSettings) already verified
// unlike SQLite/Postgres which report matched rows. Skip the check since the // the account exists via GetAccountSettings with LockingStrengthUpdate.
// caller (UpdateAccountSettings) already verified the account exists via
// GetAccountSettings with LockingStrengthUpdate.
return nil return nil
} }
@@ -3,7 +3,6 @@ package store
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -69,10 +68,6 @@ func TestSqlStore_GetGroupsByIDs(t *testing.T) {
} }
func TestSqlStore_CreateGroup(t *testing.T) { func TestSqlStore_CreateGroup(t *testing.T) {
if os.Getenv("CI") == "true" {
t.Log("Skipping MySQL test on CI")
}
t.Setenv("NETBIRD_STORE_ENGINE", string(types.MysqlStoreEngine))
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
t.Cleanup(cleanup) t.Cleanup(cleanup)
require.NoError(t, err) require.NoError(t, err)
@@ -55,7 +55,6 @@ func (s *SqlStore) ListUsers(ctx context.Context) ([]*types.User, error) {
} }
// txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction. // txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction.
// MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0).
func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error { func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error {
if s.conn.Engine() == types.SqliteStoreEngine { if s.conn.Engine() == types.SqliteStoreEngine {
return tx.Exec("PRAGMA defer_foreign_keys = ON").Error return tx.Exec("PRAGMA defer_foreign_keys = ON").Error
+162 -7
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/netip" "net/netip"
"strings"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -374,7 +375,7 @@ func (s *SqlStore) GetAccountByPeerID(ctx context.Context, peerID string) (*type
func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types.Account, error) { func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types.Account, error) {
var peer nbpeer.Peer var peer nbpeer.Peer
result := s.db.Select("account_id").Take(&peer, GetKeyQueryCondition(s), peerKey) result := s.db.Select("account_id").Take(&peer, keyQueryCondition, peerKey)
if result.Error != nil { if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
@@ -393,7 +394,7 @@ func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (
func (s *SqlStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string) (string, error) { func (s *SqlStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string) (string, error) {
var peer nbpeer.Peer var peer nbpeer.Peer
var accountID string var accountID string
result := s.db.Model(&peer).Select("account_id").Where(GetKeyQueryCondition(s), peerKey).Take(&accountID) result := s.db.Model(&peer).Select("account_id").Where(keyQueryCondition, peerKey).Take(&accountID)
if result.Error != nil { if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return "", status.Errorf(status.NotFound, "account not found: index lookup failed") return "", status.Errorf(status.NotFound, "account not found: index lookup failed")
@@ -482,7 +483,7 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking
} }
var peer nbpeer.Peer var peer nbpeer.Peer
result := tx.Take(&peer, GetKeyQueryCondition(s), peerKey) result := tx.Take(&peer, keyQueryCondition, peerKey)
if result.Error != nil { if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
@@ -494,7 +495,6 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking
return &peer, nil return &peer, nil
} }
// GetAccountPeers retrieves peers for an account.
func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
var peers []*nbpeer.Peer var peers []*nbpeer.Peer
tx := s.db tx := s.db
@@ -514,7 +514,6 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
if macFilter != "" { if macFilter != "" {
query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%") query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%")
} }
if err := query.Find(&peers).Error; err != nil { if err := query.Find(&peers).Error; err != nil {
log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err) log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err)
return nil, status.Errorf(status.Internal, "failed to get peers from store") return nil, status.Errorf(status.Internal, "failed to get peers from store")
@@ -523,6 +522,162 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
return peers, nil return peers, nil
} }
// GetAccountPeers retrieves peers for an account.
func (s *SqlStore) GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*nbpeer.Peer, int, error) {
// main query
var peers []*nbpeer.Peer
{
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
query := tx.Where("peers.account_id=?", accountID)
query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)")
query = filters.AsConditions(query)
query = pagination.AsScope(query)
query = sorting.AsOrderBy(ctx, query)
if err := query.Distinct().Find(&peers).Error; err != nil {
log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err)
return nil, 0, status.Errorf(status.Internal, "failed to get peers from store")
}
}
// total number of records
var peerCount int64
{
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
query := tx.Where("peers.account_id=?", accountID)
query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)")
query = filters.AsConditions(query)
if err := query.Table("peers").Select("count(distinct(peers.id))").Count(&peerCount).Error; err != nil {
log.WithContext(ctx).Errorf("failed to get total number of peers from the store: %s", err)
return nil, 0, status.Errorf(status.Internal, "failed to get total number of peers from store")
}
}
return peers, int(peerCount), nil
}
type PaginationState struct {
Page int
PageSize int
}
func (p *PaginationState) AsScope(db *gorm.DB) *gorm.DB {
// empty pagination state
if p.PageSize == 0 {
return db
}
offset := max(p.Page-1, 0) * p.PageSize
return db.Offset(offset).Limit(p.PageSize)
}
type PeerFilters struct {
UserId string
GroupIds []string
Connected *bool
ApprovalRequired *bool
Os []string
IP string
MAC string
Hostname string
Name string
IsServer *bool
PeersIds []string
}
func (f *PeerFilters) AsConditions(db *gorm.DB) *gorm.DB {
if len(f.GroupIds) > 0 {
db = db.Joins("join group_peers on peers.id=group_peers.peer_id and peers.account_id=group_peers.account_id").Where("group_peers.group_id in (?)", f.GroupIds)
}
if f.UserId != "" {
db = db.Where("user_id=?", f.UserId)
}
if f.Connected != nil {
db = db.Where("peer_status_connected=?", *f.Connected)
}
if f.ApprovalRequired != nil {
db = db.Where("peer_status_requires_approval=?", *f.ApprovalRequired)
}
if len(f.Os) > 0 {
db = db.Where("meta_go_os in (?)", f.Os)
}
if f.IP != "" {
db = db.Where("ip like ? or lower(ipv6) like ?", fmt.Sprintf("%%%s%%", f.IP), fmt.Sprintf("%%%s%%", strings.ToLower(f.IP)))
}
if f.MAC != "" {
db = db.Where("lower(meta_network_addresses) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.MAC)))
}
if f.Hostname != "" {
db = db.Where("lower(meta_hostname) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Hostname)))
}
if f.Name != "" {
db = db.Where("lower(name) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Name)))
}
if f.IsServer != nil {
if *f.IsServer {
db = db.Where("(user_id='' or user_id is null)")
} else {
db = db.Where("user_id<>'' and user_id is not null")
}
}
if len(f.PeersIds) > 0 {
db = db.Where("id in (?)", f.PeersIds)
}
return db
}
type PeerSortByField int
const (
PeerSortByName PeerSortByField = iota
PeerSortByLastSeen
PeerSortByVersion
)
type OrderBy int
const (
AscOrderBy OrderBy = iota
DescOrderBy
)
type PeerSortingKey struct {
Field PeerSortByField
Order OrderBy
}
type PeerSorting struct {
Fields []PeerSortingKey
}
func (s *PeerSorting) AsOrderBy(ctx context.Context, db *gorm.DB) *gorm.DB {
order := make([]clause.OrderByColumn, 0)
for _, key := range s.Fields {
switch key.Field {
case PeerSortByName:
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.name"}, Desc: key.Order == DescOrderBy})
case PeerSortByLastSeen:
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.peer_status_last_seen"}, Desc: key.Order == DescOrderBy})
case PeerSortByVersion:
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "meta_wt_version"}, Desc: key.Order == DescOrderBy})
default:
log.WithContext(ctx).Warnf("invalid order by column '%v', skipping", key.Field)
}
}
// always order/break ties by id asc order
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.id"}, Desc: false})
return db.Order(clause.OrderBy{Columns: order})
}
// GetUserPeers retrieves peers for a user. // GetUserPeers retrieves peers for a user.
func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) { func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) {
tx := s.db tx := s.db
@@ -759,7 +914,7 @@ func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingS
var userID string var userID string
result := tx.Model(&nbpeer.Peer{}). result := tx.Model(&nbpeer.Peer{}).
Select("user_id"). Select("user_id").
Take(&userID, GetKeyQueryCondition(s), peerKey) Take(&userID, keyQueryCondition, peerKey)
if result.Error != nil { if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
@@ -780,7 +935,7 @@ func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStren
var peerID string var peerID string
result := tx.Model(&nbpeer.Peer{}). result := tx.Model(&nbpeer.Peer{}).
Select("id"). Select("id").
Where(GetKeyQueryCondition(s), key). Where(keyQueryCondition, key).
Limit(1). Limit(1).
Scan(&peerID) Scan(&peerID)
if result.Error != nil { if result.Error != nil {
@@ -512,7 +512,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter, "") peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, tt.accountID, PaginationState{}, PeerFilters{Hostname: tt.nameFilter, IP: tt.ipFilter}, PeerSorting{})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, peers, tt.expectedCount) require.Len(t, peers, tt.expectedCount)
}) })
@@ -555,7 +555,7 @@ func TestSqlStore_GetAccountPeers_FilterByMac(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
peers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", tt.macFilter) peers, _, err := store.GetAccountPeersPaginated(ctx, LockingStrengthNone, accountID, PaginationState{}, PeerFilters{MAC: tt.macFilter}, PeerSorting{})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, peers, tt.expectedCount) require.Len(t, peers, tt.expectedCount)
}) })
@@ -0,0 +1,250 @@
package store
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestSqlStore_FilterPeers(t *testing.T) {
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
var tests = []struct {
description string
filters PeerFilters
expectedLength int
expectedPeers []string
}{
{
description: "filter by user_id",
filters: PeerFilters{UserId: "user-id-1"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by connected status",
filters: PeerFilters{Connected: new(true)},
expectedLength: 2,
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
},
{
description: "filter by approval required status",
filters: PeerFilters{ApprovalRequired: new(true)},
expectedLength: 2,
expectedPeers: []string{"peer-2-name", "peer-3-name"},
},
{
description: "filter by ipv4",
filters: PeerFilters{IP: "10.10.10"},
expectedLength: 2,
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
},
{
description: "filter by ipv6",
filters: PeerFilters{IP: "ba80:6aa5:89f1:44d7:8701:869"},
expectedLength: 2,
expectedPeers: []string{"peer-2-name", "peer-3-name"},
},
{
description: "filter by ipv6 (uppercase)",
filters: PeerFilters{IP: "BA80:6AA5:89f1:44d7:8701:869"},
expectedLength: 2,
expectedPeers: []string{"peer-2-name", "peer-3-name"},
},
{
description: "filter by mac",
filters: PeerFilters{MAC: ":15:5d:24:0c"},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
},
{
description: "filter by mac (uppercase)",
filters: PeerFilters{MAC: ":15:5D:24:0C"},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
},
{
description: "filter by kind (server)",
filters: PeerFilters{IsServer: new(true), Os: []string{"linux"}},
expectedLength: 3,
expectedPeers: []string{"peer-4-name", "peer-5-name", "peer-6-name"},
},
{
description: "filter by kind (user_device)",
filters: PeerFilters{IsServer: new(false)},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
},
{
description: "filter by group_ids",
filters: PeerFilters{GroupIds: []string{"group-one-resource-id", "group-two-resources-id"}},
expectedLength: 2,
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
},
{
description: "filter by os",
filters: PeerFilters{Os: []string{"linux", "ios"}},
expectedLength: 5,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-5-name", "peer-6-name"},
},
{
description: "filter by peer id",
filters: PeerFilters{PeersIds: []string{"peer-id-one", "peer-id-five", "peer-id-three"}},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-3-name", "peer-5-name"},
},
{
description: "filter by name",
filters: PeerFilters{Name: "peer-1"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by name (upper-case)",
filters: PeerFilters{Name: "PEER-1"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by hostname",
filters: PeerFilters{Hostname: "one-name.local"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by hostname (upper-case)",
filters: PeerFilters{Hostname: "ONE-NAME.LOCAL"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
}
for _, tt := range tests {
t.Run(tt.description+" "+engine, func(t *testing.T) {
t.Helper()
peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
PaginationState{}, tt.filters, PeerSorting{})
require.NoError(t, err)
require.Len(t, peers, tt.expectedLength)
require.Equal(t, tt.expectedLength, count)
actualPeerNames := make([]string, 0, len(peers))
for _, p := range peers {
actualPeerNames = append(actualPeerNames, p.Name)
}
require.ElementsMatch(t, actualPeerNames, tt.expectedPeers)
})
}
})
}
func TestSqlStore_PeersPaging(t *testing.T) {
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
var tests = []struct {
description string
page int
pageSize int
expectedLength int
expectedCount int
expectedPeers []string
}{
{
description: "page 1, size 4",
page: 1,
pageSize: 4,
expectedLength: 4,
expectedCount: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name"},
},
{
description: "page 2, size 4",
page: 2,
pageSize: 4,
expectedLength: 2,
expectedCount: 6,
expectedPeers: []string{"peer-5-name", "peer-6-name"},
},
}
for _, tt := range tests {
t.Run(tt.description+" "+engine, func(t *testing.T) {
t.Helper()
peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
PaginationState{Page: tt.page, PageSize: tt.pageSize}, PeerFilters{}, PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}})
require.NoError(t, err)
require.Len(t, peers, tt.expectedLength)
require.Equal(t, tt.expectedCount, count)
actualPeerNames := make([]string, 0, len(peers))
for _, p := range peers {
actualPeerNames = append(actualPeerNames, p.Name)
}
require.ElementsMatch(t, actualPeerNames, tt.expectedPeers)
})
}
})
}
func TestSqlStore_PeersSorting(t *testing.T) {
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
var tests = []struct {
description string
sorting PeerSorting
expectedLength int
expectedPeers []string
}{
{
description: "default order (by id ascending)",
sorting: PeerSorting{Fields: []PeerSortingKey{}},
expectedLength: 6,
expectedPeers: []string{"peer-5-name", "peer-4-name", "Peer-1-name", "peer-6-name", "peer-3-name", "peer-2-name"},
},
{
description: "by name ascending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}},
expectedLength: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name", "peer-5-name", "peer-6-name"},
},
{
description: "by name descending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName, Order: DescOrderBy}}},
expectedLength: 6,
expectedPeers: []string{"peer-6-name", "peer-5-name", "peer-4-name", "peer-3-name", "peer-2-name", "Peer-1-name"},
},
{
description: "by last_seen ascending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen}}},
expectedLength: 6,
expectedPeers: []string{"peer-6-name", "peer-4-name", "peer-5-name", "peer-3-name", "peer-2-name", "Peer-1-name"},
},
{
description: "by last_seen descending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen, Order: DescOrderBy}}},
expectedLength: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-5-name", "peer-4-name", "peer-6-name"},
},
{
description: "by version ascending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion}}},
expectedLength: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-3-name", "peer-6-name", "peer-5-name"},
},
{
description: "by version descending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion, Order: DescOrderBy}}},
expectedLength: 6,
expectedPeers: []string{"peer-5-name", "peer-6-name", "peer-3-name", "peer-4-name", "peer-2-name", "Peer-1-name"},
},
}
for _, tt := range tests {
t.Run(tt.description+" "+engine, func(t *testing.T) {
t.Helper()
peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
PaginationState{}, PeerFilters{}, tt.sorting)
require.NoError(t, err)
require.Len(t, peers, tt.expectedLength)
actualPeerNames := make([]string, 0, len(peers))
for _, p := range peers {
actualPeerNames = append(actualPeerNames, p.Name)
}
require.Equal(t, tt.expectedPeers, actualPeerNames)
})
}
})
}
@@ -13,7 +13,7 @@ import (
) )
// TestPgxServiceColumnsMatchGorm guards the Postgres pgx read path against // TestPgxServiceColumnsMatchGorm guards the Postgres pgx read path against
// drifting from the gorm model. The SQLite/MySQL gorm path loads rows by struct, // drifting from the gorm model. The SQLite gorm path loads rows by struct,
// so a new column on a model is picked up automatically, but the hand-written // so a new column on a model is picked up automatically, but the hand-written
// pgx SELECT in sql_store.go must be updated by hand. This test fails when a // pgx SELECT in sql_store.go must be updated by hand. This test fails when a
// gorm column is missing from the pgx column list, which otherwise silently // gorm column is missing from the pgx column list, which otherwise silently
+3 -3
View File
@@ -21,8 +21,8 @@ func (s *SqlStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) {
var m ProxyMetrics var m ProxyMetrics
activeCutoff := time.Now().Add(-proxyActiveThreshold) activeCutoff := time.Now().Add(-proxyActiveThreshold)
// COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite/postgres // COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite and postgres
// (MySQL too) and keeps the round-trip to one. proxy.StatusConnected // and keeps the round-trip to one. proxy.StatusConnected
// is the same string the cluster-capability queries use; the active // is the same string the cluster-capability queries use; the active
// window matches the cluster-capability semantics (only proxies // window matches the cluster-capability semantics (only proxies
// heartbeating within ~2 * heartbeat interval count as connected). // heartbeating within ~2 * heartbeat interval count as connected).
@@ -292,7 +292,7 @@ func (s *SqlStore) GetProxyClusters(ctx context.Context, accountID string) ([]pr
"cluster_address AS address, "+ "cluster_address AS address, "+
// COUNT(CASE WHEN ... THEN 1 END) counts only non-NULL — i.e. only // COUNT(CASE WHEN ... THEN 1 END) counts only non-NULL — i.e. only
// rows that satisfy the predicate — so it works portably across // rows that satisfy the predicate — so it works portably across
// sqlite/postgres/mysql without dialect-specific FILTER syntax. // sqlite and postgres without dialect-specific FILTER syntax.
"COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS connected_proxies, "+ "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS connected_proxies, "+
// MAX(CASE …) > 0 expresses BOOL_OR in a way Postgres tolerates // MAX(CASE …) > 0 expresses BOOL_OR in a way Postgres tolerates
// (Postgres can't MAX a boolean column). // (Postgres can't MAX a boolean column).

Some files were not shown because too many files have changed in this diff Show More