mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-21 12:59:08 +02:00
Merge branch 'main' into profile-ownership
This commit is contained in:
@@ -34,7 +34,7 @@ jobs:
|
||||
while IFS= read -r dir; do
|
||||
echo "=== Checking $dir ==="
|
||||
# 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
|
||||
echo "❌ Found problematic dependencies:"
|
||||
echo "$RESULTS"
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
||||
|
||||
# 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
|
||||
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||
|
||||
@@ -3,9 +3,16 @@ name: Post release topic on Discourse
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_run:
|
||||
workflows: [Release]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
post:
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
|
||||
@@ -15,3 +22,78 @@ jobs:
|
||||
discourse-author-username: NetBird
|
||||
discourse-category: 17
|
||||
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)"'
|
||||
|
||||
@@ -307,7 +307,7 @@ jobs:
|
||||
sh -c ' \
|
||||
apk update; apk add --no-cache \
|
||||
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:
|
||||
@@ -498,7 +498,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ["amd64"]
|
||||
store: ["sqlite", "postgres", "mysql"]
|
||||
store: ["sqlite", "postgres"]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -547,10 +547,6 @@ jobs:
|
||||
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||
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
|
||||
# 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.
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
# resolve; the Where-Object pipeline then drops the broken package by
|
||||
# path. Without -e, go list aborts with empty stdout.
|
||||
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"
|
||||
$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
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
store: ["sqlite", "postgres", "mysql"]
|
||||
store: ["sqlite", "postgres"]
|
||||
services:
|
||||
postgres:
|
||||
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
||||
@@ -35,19 +35,6 @@ jobs:
|
||||
--health-timeout 5s
|
||||
ports:
|
||||
- 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:
|
||||
- name: Set Database Connection String
|
||||
run: |
|
||||
@@ -56,11 +43,6 @@ jobs:
|
||||
else
|
||||
echo "NETBIRD_STORE_ENGINE_POSTGRES_DSN==" >> $GITHUB_ENV
|
||||
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
|
||||
run: sudo apt-get install -y jq
|
||||
@@ -86,15 +68,6 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ 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
|
||||
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_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||
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
|
||||
|
||||
- name: check values
|
||||
@@ -143,7 +115,6 @@ jobs:
|
||||
CI_NETBIRD_SIGNAL_PORT: 12345
|
||||
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||
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_TURN_EXTERNAL_IP: "1.2.3.4"
|
||||
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 -A 3 RedirectURLs | grep "http://localhost:53000"
|
||||
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"
|
||||
# check relay values
|
||||
grep "NB_EXPOSED_ADDRESS=rels://$CI_NETBIRD_DOMAIN:33445" docker-compose.yml
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
- id: netbird
|
||||
|
||||
@@ -318,10 +296,6 @@ archives:
|
||||
builds:
|
||||
- netbird-idp-migrate
|
||||
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
|
||||
builds:
|
||||
- nblink
|
||||
|
||||
@@ -58,23 +58,17 @@ func (p *Preferences) SetManagementURL(url string) {
|
||||
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) {
|
||||
if p.configInput.AdminURL != "" {
|
||||
return p.configInput.AdminURL, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.AdminURL.String(), err
|
||||
return profilemanager.DefaultAdminURL, nil
|
||||
}
|
||||
|
||||
// SetAdminURL stores the given URL and waits for commit
|
||||
func (p *Preferences) SetAdminURL(url string) {
|
||||
p.configInput.AdminURL = url
|
||||
}
|
||||
// SetAdminURL is a no-op kept for API compatibility.
|
||||
//
|
||||
// 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
|
||||
// enforced by MDM; the key itself is never handed to the native layer.
|
||||
|
||||
@@ -43,13 +43,14 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
||||
cfgFile := filepath.Join(t.TempDir(), "netbird.json")
|
||||
p := NewPreferences(cfgFile)
|
||||
|
||||
// The admin URL is deprecated: setting it is a no-op.
|
||||
p.SetAdminURL(exampleString)
|
||||
resp, err := p.GetAdminURL()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read admin url: %s", err)
|
||||
}
|
||||
|
||||
if resp != exampleString {
|
||||
if resp != profilemanager.DefaultAdminURL {
|
||||
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)
|
||||
}
|
||||
|
||||
if resp != exampleURL {
|
||||
if resp != profilemanager.DefaultAdminURL {
|
||||
t.Errorf("unexpected admin url: %s", resp)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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().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().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)")
|
||||
|
||||
@@ -503,7 +503,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
req.Username = username
|
||||
|
||||
req.ManagementUrl = managementURL
|
||||
req.AdminURL = adminURL
|
||||
req.NatExternalIPs = natExternalIPs
|
||||
req.CustomDNSAddress = customDNSAddressConverted
|
||||
req.ExtraIFaceBlacklist = extraIFaceBlackList
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"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/wgaddr"
|
||||
"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) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeTunDevice) UpdateAddr(wgaddr.Address) error { return nil }
|
||||
func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU }
|
||||
func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" }
|
||||
func (f *fakeTunDevice) UpdateAddr(wgaddr.Address) error { return nil }
|
||||
func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU }
|
||||
func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" }
|
||||
func (f *fakeTunDevice) FilteredDevice() *device.FilteredDevice { return nil }
|
||||
func (f *fakeTunDevice) Device() *wgdevice.Device { return nil }
|
||||
func (f *fakeTunDevice) GetNet() *netstack.Net { return nil }
|
||||
func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil }
|
||||
func (f *fakeTunDevice) Device() *wgdevice.Device { return nil }
|
||||
func (f *fakeTunDevice) GetNet() *netstack.Net { return nil }
|
||||
func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil }
|
||||
|
||||
func (f *fakeTunDevice) Close() error {
|
||||
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
|
||||
// the lock before tun.Close() returns control.
|
||||
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{
|
||||
closeStarted: make(chan struct{}),
|
||||
unblockClose: make(chan struct{}),
|
||||
@@ -105,7 +110,10 @@ func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) {
|
||||
|
||||
close(tun.unblockClose)
|
||||
select {
|
||||
case <-closeDone:
|
||||
case err := <-closeDone:
|
||||
if err != nil {
|
||||
t.Errorf("WGIface.Close() returned an error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("WGIface.Close() never returned after the tun was unblocked")
|
||||
}
|
||||
|
||||
@@ -179,7 +179,6 @@ The profiles.txt file lists every profile JSON on disk, including profiles the r
|
||||
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:
|
||||
- ManagementURL
|
||||
- AdminURL
|
||||
- NATExternalIPs
|
||||
- CustomDNSAddress
|
||||
|
||||
@@ -611,9 +610,6 @@ func (g *BundleGenerator) addConfig() error {
|
||||
if g.internalConfig.ManagementURL != nil {
|
||||
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)))
|
||||
if 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 {
|
||||
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))
|
||||
if g.internalConfig.CustomDNSAddress != "" {
|
||||
configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.internalConfig.CustomDNSAddress))
|
||||
|
||||
@@ -858,14 +858,12 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
}
|
||||
|
||||
mURL, _ := url.Parse("https://api.example.com:443")
|
||||
aURL, _ := url.Parse("https://admin.example.com:443")
|
||||
bTrue := true
|
||||
iVal := 42
|
||||
cfg := &profilemanager.Config{
|
||||
PrivateKey: "priv",
|
||||
PreSharedKey: "psk",
|
||||
ManagementURL: mURL,
|
||||
AdminURL: aURL,
|
||||
WgIface: "wt0",
|
||||
WgPort: 51820,
|
||||
NetworkMonitor: &bTrue,
|
||||
@@ -949,7 +947,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// production shape without needing to write an actual zip.
|
||||
func renderAddConfigSpecific(g *BundleGenerator) string {
|
||||
@@ -958,9 +956,6 @@ func renderAddConfigSpecific(g *BundleGenerator) string {
|
||||
if g.internalConfig.ManagementURL != nil {
|
||||
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")
|
||||
if g.internalConfig.CustomDNSAddress != "" {
|
||||
sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n")
|
||||
@@ -969,9 +964,6 @@ func renderAddConfigSpecific(g *BundleGenerator) string {
|
||||
if g.internalConfig.ManagementURL != nil {
|
||||
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")
|
||||
if g.internalConfig.CustomDNSAddress != "" {
|
||||
sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n")
|
||||
|
||||
@@ -35,11 +35,7 @@ func parseBackupResolvConf() (*resolvConf, error) {
|
||||
}
|
||||
|
||||
func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) {
|
||||
rconf := &resolvConf{
|
||||
searchDomains: make([]string, 0),
|
||||
nameServers: make([]netip.Addr, 0),
|
||||
others: make([]string, 0),
|
||||
}
|
||||
rconf := newResolvConf()
|
||||
|
||||
file, err := os.Open(resolvConfFile)
|
||||
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 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 {
|
||||
return rconf, fmt.Errorf("file is empty")
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(cur), "\n") {
|
||||
line = 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)
|
||||
}
|
||||
parseResolvConfLine(rconf, strings.TrimSpace(line))
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"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 {
|
||||
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 {
|
||||
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)
|
||||
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.inotifyWg.Add(1)
|
||||
go func() {
|
||||
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)
|
||||
|
||||
rConf, err := parseResolvConfFile(f.operationFile)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse resolv conf: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debugf("check resolv.conf parameters: %s", rConf)
|
||||
if !isNbParamsMissing(nbSearchDomains, nbNameserverIP, rConf) {
|
||||
log.Tracef("resolv.conf still correct, skip the update")
|
||||
continue
|
||||
}
|
||||
log.Info("broken params in resolv.conf, repairing it...")
|
||||
|
||||
err = f.inotify.Remove(f.watchDir)
|
||||
if err != nil {
|
||||
log.Errorf("failed to rm inotify watch for resolv.conf: %s", 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
|
||||
// A change that landed before the watch was armed produced no event.
|
||||
lastWritten := f.checkAndRepair(nbSearchDomains, nbNameserverIP, written, stateManager)
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-inotify.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !f.isEventRelevant(event) {
|
||||
continue
|
||||
}
|
||||
case err, ok := <-inotify.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// fsnotify stops delivering events until an error is received, and an
|
||||
// error such as a queue overflow means events were dropped.
|
||||
log.Warnf("resolv.conf inotify error, checking the file: %v", err)
|
||||
}
|
||||
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 {
|
||||
log.Errorf("failed to add inotify watch for resolv.conf: %s", err)
|
||||
return
|
||||
log.Warnf("failed to read resolv.conf: %s", err)
|
||||
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() {
|
||||
|
||||
@@ -4,12 +4,19 @@ package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"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/util"
|
||||
)
|
||||
@@ -106,14 +113,14 @@ nameserver 8.8.8.8`,
|
||||
|
||||
var changed bool
|
||||
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
|
||||
cancel()
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -153,14 +160,14 @@ searchdomain netbird.cloud something`
|
||||
|
||||
var changed bool
|
||||
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
|
||||
cancel()
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -175,3 +182,149 @@ searchdomain netbird.cloud something`
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -61,11 +61,11 @@ func (f *fileConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *st
|
||||
|
||||
f.repair.stopWatchFileChanges()
|
||||
|
||||
err = f.updateConfig(nbSearchDomains, f.nbNameserverIP, resolvConf, stateManager)
|
||||
written, err := f.updateConfig(nbSearchDomains, f.nbNameserverIP, resolvConf, stateManager)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.repair.watchFileChanges(nbSearchDomains, f.nbNameserverIP, stateManager)
|
||||
f.repair.watchFileChanges(nbSearchDomains, f.nbNameserverIP, written, stateManager)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func (f *fileConfigurator) getOriginalNameservers() []netip.Addr {
|
||||
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)
|
||||
|
||||
buf := prepareResolvConfContent(
|
||||
@@ -90,7 +90,7 @@ func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP
|
||||
if restoreErr != nil {
|
||||
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)
|
||||
@@ -100,7 +100,7 @@ func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP
|
||||
log.Errorf("failed to create unclean shutdown resolv.conf backup: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (f *fileConfigurator) restoreHostDNS() error {
|
||||
|
||||
@@ -37,7 +37,8 @@ const (
|
||||
DefaultManagementURL = "https://api.netbird.io:443"
|
||||
// oldDefaultManagementURL points to the NetBird's old cloud management endpoint
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -61,7 +62,6 @@ var DefaultInterfaceBlacklist = []string{
|
||||
// ConfigInput carries configuration changes to the client
|
||||
type ConfigInput struct {
|
||||
ManagementURL string
|
||||
AdminURL string
|
||||
ConfigPath string
|
||||
StateFilePath string
|
||||
PreSharedKey *string
|
||||
@@ -120,7 +120,6 @@ type Config struct {
|
||||
PrivateKey string
|
||||
PreSharedKey string
|
||||
ManagementURL *url.URL
|
||||
AdminURL *url.URL
|
||||
WgIface string
|
||||
WgPort int
|
||||
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 {
|
||||
log.Infof("updating Wireguard port %d (old value %d)",
|
||||
*input.WireguardPort, config.WgPort)
|
||||
@@ -1046,8 +1022,7 @@ func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||
// matches only another nil one.
|
||||
//
|
||||
// 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
|
||||
// what SameServiceURLIncludingPath needs and delegates to.
|
||||
// port are. util.SameServiceURL is this comparison plus the path.
|
||||
func SameServiceURL(a, b *url.URL) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
@@ -1058,25 +1033,6 @@ func SameServiceURL(a, b *url.URL) bool {
|
||||
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) {
|
||||
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -36,17 +36,14 @@ func TestGetConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.Equal(t, config.ManagementURL.String(), DefaultManagementURL)
|
||||
assert.Equal(t, config.AdminURL.String(), DefaultAdminURL)
|
||||
|
||||
managementURL := "https://test.management.url:33071"
|
||||
adminURL := "https://app.admin.url:443"
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
preSharedKey := "preSharedKey"
|
||||
|
||||
// case 2: new config has to be generated
|
||||
config, err = UpdateOrCreateConfig(ConfigInput{
|
||||
ManagementURL: managementURL,
|
||||
AdminURL: adminURL,
|
||||
ConfigPath: path,
|
||||
PreSharedKey: &preSharedKey,
|
||||
})
|
||||
@@ -64,7 +61,6 @@ func TestGetConfig(t *testing.T) {
|
||||
// case 3: existing config -> fetch it
|
||||
config, err = UpdateOrCreateConfig(ConfigInput{
|
||||
ManagementURL: managementURL,
|
||||
AdminURL: adminURL,
|
||||
ConfigPath: path,
|
||||
PreSharedKey: &preSharedKey,
|
||||
})
|
||||
@@ -79,7 +75,6 @@ func TestGetConfig(t *testing.T) {
|
||||
newManagementURL := "https://test.newManagement.url:33071"
|
||||
config, err = UpdateOrCreateConfig(ConfigInput{
|
||||
ManagementURL: newManagementURL,
|
||||
AdminURL: adminURL,
|
||||
ConfigPath: path,
|
||||
PreSharedKey: &preSharedKey,
|
||||
})
|
||||
@@ -164,7 +159,6 @@ func TestNewProfileDefaults(t *testing.T) {
|
||||
require.NoError(t, err, "should create new config")
|
||||
|
||||
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.SSHKey, "SSHKey should be generated")
|
||||
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)
|
||||
}
|
||||
|
||||
// The admin panel is opened, not dialed, so its path identifies it. Comparing
|
||||
// it as a bare endpoint left a custom panel URL unable to change.
|
||||
func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "panel.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: path,
|
||||
AdminURL: "https://app.example.com/netbird",
|
||||
})
|
||||
// Profiles written before the admin URL was dropped still carry an AdminURL key.
|
||||
// It must not break loading, and the next write must drop it.
|
||||
func TestLegacyAdminURLIsIgnored(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "legacy.json")
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := GetExistingConfig(path)
|
||||
raw, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String())
|
||||
|
||||
// Equivalent spellings of the same panel are still not a change.
|
||||
for _, same := range []string{
|
||||
"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"})
|
||||
var stored map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(raw, &stored))
|
||||
stored["AdminURL"] = json.RawMessage(`{"Scheme":"https","Host":"panel.example.com:443","Path":"/netbird"}`)
|
||||
rewritten, err := json.Marshal(stored)
|
||||
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.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,
|
||||
|
||||
@@ -61,23 +61,17 @@ func (p *Preferences) SetManagementURL(url string) {
|
||||
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) {
|
||||
if p.configInput.AdminURL != "" {
|
||||
return p.configInput.AdminURL, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.AdminURL.String(), err
|
||||
return profilemanager.DefaultAdminURL, nil
|
||||
}
|
||||
|
||||
// SetAdminURL store the given url and wait for commit
|
||||
func (p *Preferences) SetAdminURL(url string) {
|
||||
p.configInput.AdminURL = url
|
||||
}
|
||||
// SetAdminURL is a no-op kept for API compatibility.
|
||||
//
|
||||
// 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
|
||||
// enforced by MDM; the key itself is never handed to the native layer.
|
||||
|
||||
@@ -47,13 +47,14 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
||||
stateFile := filepath.Join(t.TempDir(), "state.json")
|
||||
p := NewPreferences(cfgFile, stateFile)
|
||||
|
||||
// The admin URL is deprecated: setting it is a no-op.
|
||||
p.SetAdminURL(exampleString)
|
||||
resp, err := p.GetAdminURL()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read admin url: %s", err)
|
||||
}
|
||||
|
||||
if resp != exampleString {
|
||||
if resp != profilemanager.DefaultAdminURL {
|
||||
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)
|
||||
}
|
||||
|
||||
if resp != exampleURL {
|
||||
if resp != profilemanager.DefaultAdminURL {
|
||||
t.Errorf("unexpected admin url: %s", resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -613,9 +613,6 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest, resolved
|
||||
if msg.ManagementUrl != "" {
|
||||
config.ManagementURL = msg.ManagementUrl
|
||||
}
|
||||
if msg.AdminURL != "" {
|
||||
config.AdminURL = msg.AdminURL
|
||||
}
|
||||
if msg.InterfaceName != nil {
|
||||
config.InterfaceName = msg.InterfaceName
|
||||
}
|
||||
@@ -2461,7 +2458,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
||||
// overlay (Loader lives outside profilemanager).
|
||||
cfg.ApplyMDMPolicy(s.mdmLoader.Load())
|
||||
managementURL := cfg.ManagementURL
|
||||
adminURL := cfg.AdminURL
|
||||
|
||||
var preSharedKey = cfg.PreSharedKey
|
||||
if preSharedKey != "" {
|
||||
@@ -2517,7 +2513,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
||||
return &proto.GetConfigResponse{
|
||||
ManagementUrl: managementURL.String(),
|
||||
PreSharedKey: preSharedKey,
|
||||
AdminURL: adminURL.String(),
|
||||
InterfaceName: cfg.WgIface,
|
||||
WireguardPort: int64(cfg.WgPort),
|
||||
Mtu: int64(cfg.MTU),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -133,7 +134,10 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
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, rosenpassPermissive, cfg.RosenpassPermissive)
|
||||
require.NotNil(t, cfg.ServerSSHAllowed)
|
||||
@@ -186,11 +190,11 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
||||
"CleanNATExternalIPs": true, // control flag for clearing
|
||||
"CleanDNSLabels": true, // control flag for clearing
|
||||
"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{
|
||||
"ManagementUrl": true,
|
||||
"AdminURL": true,
|
||||
"RosenpassEnabled": true,
|
||||
"RosenpassPermissive": 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.
|
||||
flagToField := map[string]string{
|
||||
"management-url": "ManagementUrl",
|
||||
"admin-url": "AdminURL",
|
||||
"enable-rosenpass": "RosenpassEnabled",
|
||||
"rosenpass-permissive": "RosenpassPermissive",
|
||||
"allow-server-ssh": "ServerSSHAllowed",
|
||||
@@ -290,6 +293,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
||||
fieldsWithoutCLIFlags := map[string]bool{
|
||||
"DisableNotifications": true, // Only settable via UI
|
||||
"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.
|
||||
|
||||
@@ -166,7 +166,8 @@ func (c *Client) ExecuteCommandWithIO(ctx context.Context, command string) error
|
||||
case <-ctx.Done():
|
||||
_ = session.Signal(ssh.SIGTERM)
|
||||
select {
|
||||
case <-done:
|
||||
case err := <-done:
|
||||
log.Debugf("session ended after SIGTERM: %v", err)
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
return ctx.Err()
|
||||
@@ -205,7 +206,8 @@ func (c *Client) ExecuteCommandWithPTY(ctx context.Context, command string) erro
|
||||
case <-ctx.Done():
|
||||
_ = session.Signal(ssh.SIGTERM)
|
||||
select {
|
||||
case <-done:
|
||||
case err := <-done:
|
||||
log.Debugf("session ended after SIGTERM: %v", err)
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
return ctx.Err()
|
||||
|
||||
@@ -110,7 +110,7 @@ Settings.SetConfig(p: SetConfigParams): Promise<void> // partial update
|
||||
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`.
|
||||
|
||||
@@ -242,7 +242,7 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front
|
||||
|
||||
`Config` (read-only mirror, all required):
|
||||
```ts
|
||||
{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string;
|
||||
{ managementUrl, configFile, logFile, preSharedKey, interfaceName: string;
|
||||
wireguardPort, mtu, sshJwtCacheTtl: number;
|
||||
disableAutoConnect, serverSshAllowed,
|
||||
rosenpassEnabled, rosenpassPermissive,
|
||||
@@ -254,7 +254,7 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front
|
||||
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 }`.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
|
||||
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"},
|
||||
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"},
|
||||
{"code": "gl", "displayName": "Galego", "englishName": "Galician"}
|
||||
{"code": "gl", "displayName": "Galego", "englishName": "Galician"},
|
||||
{"code": "he", "displayName": "עברית", "englishName": "Hebrew"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -552,12 +552,10 @@
|
||||
"message": "Ändern des Autostarts fehlgeschlagen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Nach dem Beenden verbunden bleiben",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
"message": "Nach dem Beenden verbunden bleiben"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"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."
|
||||
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Anzeigesprache"
|
||||
|
||||
@@ -552,12 +552,10 @@
|
||||
"message": "Error al cambiar el inicio automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado al salir",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
"message": "Permanecer conectado al salir"
|
||||
},
|
||||
"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ú.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de la interfaz"
|
||||
|
||||
@@ -552,12 +552,10 @@
|
||||
"message": "Échec de la modification du démarrage automatique"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Rester connecté après la fermeture",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
"message": "Rester connecté après la fermeture"
|
||||
},
|
||||
"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.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
"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."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Langue d’affichage"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -552,12 +552,10 @@
|
||||
"message": "Az automatikus indítás módosítása sikertelen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Kapcsolat megtartása kilépéskor",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
"message": "Kapcsolat megtartása kilépéskor"
|
||||
},
|
||||
"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.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Megjelenítési nyelv"
|
||||
|
||||
@@ -552,12 +552,10 @@
|
||||
"message": "Modifica avvio automatico non riuscita"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Resta connesso dopo la chiusura",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
"message": "Resta connesso dopo la chiusura"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"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."
|
||||
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Lingua dell'interfaccia"
|
||||
|
||||
@@ -552,12 +552,10 @@
|
||||
"message": "自動起動の変更に失敗しました"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "終了後も接続を維持",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
"message": "終了後も接続を維持"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。"
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "表示言語"
|
||||
|
||||
@@ -552,12 +552,10 @@
|
||||
"message": "Falha ao alterar o início automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado ao sair",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
"message": "Permanecer conectado ao sair"
|
||||
},
|
||||
"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.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de exibição"
|
||||
|
||||
@@ -552,12 +552,10 @@
|
||||
"message": "Не удалось изменить автозапуск"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Оставаться подключённым после выхода",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
"message": "Оставаться подключённым после выхода"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Язык интерфейса"
|
||||
|
||||
@@ -552,12 +552,10 @@
|
||||
"message": "更改自启动设置失败"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "退出后保持连接",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
"message": "退出后保持连接"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。"
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "显示语言"
|
||||
|
||||
@@ -51,7 +51,6 @@ type ConfigParams struct {
|
||||
|
||||
type Config struct {
|
||||
ManagementURL string `json:"managementUrl"`
|
||||
AdminURL string `json:"adminUrl"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
LogFile string `json:"logFile"`
|
||||
PreSharedKeySet bool `json:"preSharedKeySet"`
|
||||
@@ -84,7 +83,6 @@ type SetConfigParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
ManagementURL string `json:"managementUrl"`
|
||||
AdminURL string `json:"adminUrl"`
|
||||
InterfaceName *string `json:"interfaceName,omitempty"`
|
||||
WireguardPort *int64 `json:"wireguardPort,omitempty"`
|
||||
MTU *int64 `json:"mtu,omitempty"`
|
||||
@@ -146,7 +144,6 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
||||
}
|
||||
return Config{
|
||||
ManagementURL: resp.GetManagementUrl(),
|
||||
AdminURL: resp.GetAdminURL(),
|
||||
ConfigFile: resp.GetConfigFile(),
|
||||
LogFile: resp.GetLogFile(),
|
||||
PreSharedKeySet: resp.GetPreSharedKey() != "",
|
||||
@@ -183,7 +180,6 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
ManagementUrl: p.ManagementURL,
|
||||
AdminURL: p.AdminURL,
|
||||
InterfaceName: p.InterfaceName,
|
||||
WireguardPort: p.WireguardPort,
|
||||
Mtu: p.MTU,
|
||||
|
||||
@@ -35,13 +35,9 @@ func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T
|
||||
|
||||
func TestApplyServerStoreEnv(t *testing.T) {
|
||||
t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "")
|
||||
t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "")
|
||||
t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "")
|
||||
|
||||
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, "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"))
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ type RelaysConfig struct {
|
||||
type StoreConfig struct {
|
||||
Engine string `yaml:"engine"`
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+6
-11
@@ -127,13 +127,8 @@ func execute(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
// initializeConfig loads and validates the configuration, then initializes logging.
|
||||
func applyServerStoreEnv(storeConfig StoreConfig) {
|
||||
if dsn := storeConfig.DSN; dsn != "" {
|
||||
switch strings.ToLower(storeConfig.Engine) {
|
||||
case "postgres":
|
||||
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
|
||||
case "mysql":
|
||||
os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn)
|
||||
}
|
||||
if dsn := storeConfig.DSN; dsn != "" && strings.EqualFold(storeConfig.Engine, "postgres") {
|
||||
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
|
||||
}
|
||||
if file := storeConfig.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
|
||||
func logEnvVars() {
|
||||
log.Info("=== Environment Variables ===")
|
||||
log.Debug("=== Environment Variables ===")
|
||||
found := false
|
||||
for _, env := range os.Environ() {
|
||||
if strings.HasPrefix(env, "NB_") {
|
||||
@@ -770,14 +765,14 @@ func logEnvVars() {
|
||||
} else if strings.Contains(keyLower, "dsn") {
|
||||
value = maskDSNPassword(value)
|
||||
}
|
||||
log.Infof(" %s=%s", key, value)
|
||||
log.Debugf(" %s=%s", key, value)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -110,8 +110,8 @@ server:
|
||||
|
||||
# Store configuration
|
||||
store:
|
||||
engine: "sqlite" # sqlite, postgres, or mysql
|
||||
dsn: "" # Connection string for postgres or mysql
|
||||
engine: "sqlite" # sqlite or postgres
|
||||
dsn: "" # Connection string for postgres
|
||||
encryptionKey: ""
|
||||
# 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
|
||||
- `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.
|
||||
- `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
|
||||
- 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.
|
||||
- `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
|
||||
- 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_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 `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.
|
||||
- `GetAgentNetworkConsumption` "not-found = zero row" contract is convenient but unconventional.
|
||||
|
||||
|
||||
+29
-5
@@ -65,11 +65,7 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie
|
||||
opt(&o)
|
||||
}
|
||||
|
||||
root, err := repoRoot(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientImage, err := resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile)
|
||||
clientImage, err := ClientImage(ctx)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 —
|
||||
// the name the registered peer appears under in the peers API.
|
||||
func (cl *Client) Hostname() string {
|
||||
|
||||
@@ -332,6 +332,14 @@ func (c *Combined) Restart(ctx context.Context) error {
|
||||
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.
|
||||
func (c *Combined) Logs(ctx context.Context) string {
|
||||
return containerLogs(ctx, c.container)
|
||||
|
||||
@@ -105,7 +105,7 @@ require (
|
||||
github.com/pkg/sftp v1.13.9
|
||||
github.com/prometheus/client_golang v1.23.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/rs/xid v1.3.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.8
|
||||
@@ -113,7 +113,6 @@ require (
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||
github.com/stretchr/testify v1.12.1
|
||||
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/redis v0.37.0
|
||||
github.com/things-go/go-socks5 v0.0.4
|
||||
@@ -142,7 +141,6 @@ require (
|
||||
google.golang.org/api v0.276.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.7
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
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 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
|
||||
|
||||
|
||||
@@ -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-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-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/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
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/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/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9 h1:Mjt3tIBGa1/mJh1Xn8uZP30cd2VYd0NHANqTn+HBsaU=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0 h1:nCK1YzDybjbAfrx+C2QPmcQ+7jYj4Rcb3FskqTNZEXo=
|
||||
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.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
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/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/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8=
|
||||
github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
|
||||
github.com/quic-go/quic-go v0.64.0 h1:dlUpiyp9+7UrbbuhLvV7CJ9F8uoZxXf/vi2h8tkuuoA=
|
||||
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/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||
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/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/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/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=
|
||||
@@ -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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
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/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
|
||||
@@ -53,18 +53,6 @@ if [[ "$NETBIRD_STORE_CONFIG_ENGINE" == "postgres" ]]; then
|
||||
export NETBIRD_STORE_ENGINE_POSTGRES_DSN
|
||||
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
|
||||
if [[ $NETBIRD_DOMAIN == "localhost" || $NETBIRD_DOMAIN == "127.0.0.1" ]]; then
|
||||
export NETBIRD_MGMT_SINGLE_ACCOUNT_MODE_DOMAIN="netbird.selfhosted"
|
||||
|
||||
@@ -94,7 +94,6 @@ services:
|
||||
]
|
||||
environment:
|
||||
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN
|
||||
- NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN
|
||||
|
||||
# Coturn
|
||||
coturn:
|
||||
|
||||
@@ -100,7 +100,6 @@ services:
|
||||
- traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c
|
||||
environment:
|
||||
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN
|
||||
- NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN
|
||||
|
||||
# Coturn
|
||||
coturn:
|
||||
|
||||
@@ -855,7 +855,7 @@ configure_sqlite_store() {
|
||||
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.
|
||||
configure_unsupported_store() {
|
||||
MIGRATE_POSTGRES="no"
|
||||
|
||||
@@ -472,12 +472,6 @@ detect_store_config() {
|
||||
if [[ -n "$pg_dsn" ]]; then
|
||||
STORE_DSN="$pg_dsn"
|
||||
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
|
||||
|
||||
# Also check base.setup.env
|
||||
@@ -487,12 +481,6 @@ detect_store_config() {
|
||||
if [[ -n "$pg_dsn" ]]; then
|
||||
STORE_DSN="$pg_dsn"
|
||||
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
|
||||
|
||||
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", "")))
|
||||
|
||||
// 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.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
|
||||
|
||||
@@ -1277,7 +1277,6 @@ func isUniqueConstraintError(err error) bool {
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres
|
||||
strings.Contains(msg, "Error 1062 (23000)") || // mysql
|
||||
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.
|
||||
|
||||
// newSelfScopeStore seeds the account and its users only, so each test
|
||||
// declares exactly the providers and policies it asserts on — the store
|
||||
// rejects re-saving a policy id on MySQL, so tests never overwrite each
|
||||
// other's rows.
|
||||
// declares exactly the providers and policies it asserts on, so tests never
|
||||
// overwrite each other's rows.
|
||||
func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) {
|
||||
t.Helper()
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
|
||||
@@ -21,8 +21,7 @@ type Settings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
|
||||
// Domain is the gateway endpoint hostname agents call. Globally unique
|
||||
// across accounts. Sized explicitly because MySQL cannot index an
|
||||
// unbounded TEXT column; 255 covers the RFC 1035 253-octet bound.
|
||||
// across accounts. 255 covers the RFC 1035 253-octet bound.
|
||||
Domain string `gorm:"type:varchar(255);uniqueIndex:idx_agent_network_settings_domain"`
|
||||
|
||||
// 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
|
||||
// granularity and returns the buckets ordered oldest-first. Aggregation is done
|
||||
// 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 {
|
||||
byPeriod := make(map[string]*AgentNetworkUsageBucket)
|
||||
for _, r := range rows {
|
||||
|
||||
@@ -28,8 +28,8 @@ import (
|
||||
type Manager interface {
|
||||
GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, 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)
|
||||
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
|
||||
SetNetworkMapController(networkMapController network_map.Controller)
|
||||
SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator)
|
||||
@@ -51,8 +51,7 @@ type managerImpl struct {
|
||||
permissionsManager permissions.Manager
|
||||
integratedPeerValidator integrated_validator.IntegratedValidator
|
||||
accountManager account.Manager
|
||||
|
||||
networkMapController network_map.Controller
|
||||
networkMapController network_map.Controller
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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.
|
||||
func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) {
|
||||
return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
account "github.com/netbirdio/netbird/management/server/account"
|
||||
integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
|
||||
peer "github.com/netbirdio/netbird/management/server/peer"
|
||||
store "github.com/netbirdio/netbird/management/server/store"
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *MockManager) SetAccountManager(accountManager account.Manager) {
|
||||
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)
|
||||
CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, 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
|
||||
}
|
||||
|
||||
@@ -134,7 +134,10 @@ func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.R
|
||||
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)
|
||||
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)
|
||||
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")
|
||||
|
||||
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)
|
||||
require.NotNil(t, stored)
|
||||
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{})}
|
||||
env.manager.validator.Resolver = resolver
|
||||
done := make(chan struct{})
|
||||
var validateErr error
|
||||
go func() {
|
||||
defer close(done)
|
||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
||||
validateErr = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
||||
}()
|
||||
<-resolver.started
|
||||
time.Sleep(48 * time.Hour)
|
||||
@@ -211,6 +212,7 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
|
||||
}
|
||||
close(resolver.release)
|
||||
<-done
|
||||
assert.Error(t, validateErr, "a validation finishing after the deadline must be refused")
|
||||
owner := accountA
|
||||
if cleanup {
|
||||
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
|
||||
}
|
||||
|
||||
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) {
|
||||
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
|
||||
// validationTimeout bounds the DNS check so it ends, and reports why, before a proxy in
|
||||
// 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 {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
}).WithError(err).Error("validate domain")
|
||||
return
|
||||
return status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"userID": userID,
|
||||
}).Error("validate domain: permission denied")
|
||||
return
|
||||
return status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
@@ -250,32 +247,23 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
||||
"domainID": domainID,
|
||||
}).Info("starting domain validation")
|
||||
|
||||
d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID)
|
||||
d, err := m.store.GetCustomDomain(ctx, accountID, domainID)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
}).WithError(err).Error("get custom domain from store")
|
||||
return
|
||||
return fmt.Errorf("get domain from store: %w", err)
|
||||
}
|
||||
if d.Validated {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) {
|
||||
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
|
||||
Debug("custom domain validation window has expired")
|
||||
return
|
||||
return validationFailed(domain.NewValidationError(domain.ValidationReasonExpired,
|
||||
"custom domain %s validation window has expired; delete and add the domain again", d.Domain))
|
||||
}
|
||||
|
||||
// Validate only against the domain's target cluster
|
||||
targetCluster := d.TargetCluster
|
||||
if targetCluster == "" {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).Warn("domain has no target cluster set, skipping validation")
|
||||
return
|
||||
return validationFailed(domain.NewValidationError(domain.ValidationReasonNoTargetCluster,
|
||||
"custom domain %s has no target cluster", d.Domain))
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
@@ -285,32 +273,35 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
||||
"targetCluster": targetCluster,
|
||||
}).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
|
||||
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
|
||||
entry := log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
}).WithError(err)
|
||||
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.PreconditionFailed {
|
||||
entry.Debug("custom domain registration is no longer pending validation")
|
||||
return
|
||||
if _, err := m.store.UpdateCustomDomain(ctx, accountID, d); err != nil {
|
||||
// A concurrent request may have validated the domain first.
|
||||
if current, getErr := m.store.GetCustomDomain(ctx, accountID, domainID); getErr == nil && current.Validated {
|
||||
return nil
|
||||
}
|
||||
entry.Error("update custom domain in store")
|
||||
return
|
||||
return fmt.Errorf("update domain in store: %w", err)
|
||||
}
|
||||
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
|
||||
Info("custom domain validated successfully")
|
||||
|
||||
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
|
||||
} else {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
"targetCluster": targetCluster,
|
||||
}).Warn("domain validation failed - CNAME does not match target cluster")
|
||||
m.accountManager.StoreEvent(ctx, userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
|
||||
return nil
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -229,7 +229,7 @@ func TestValidateDomain_UnlocksClusterDerivation(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
|
||||
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)
|
||||
|
||||
@@ -286,7 +286,7 @@ func TestValidateDomain_PermissionDeniedDoesNotValidate(t *testing.T) {
|
||||
// a validated domain is the permission check.
|
||||
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")
|
||||
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")
|
||||
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
@@ -16,6 +18,33 @@ type Validator struct {
|
||||
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.
|
||||
// If a Validator is used without specifying a Resolver, then it will
|
||||
// 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.
|
||||
// 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) {
|
||||
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 {
|
||||
v.Resolver = net.DefaultResolver
|
||||
}
|
||||
@@ -56,10 +92,19 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
|
||||
"domain": domain,
|
||||
"lookupDomain": lookupDomain,
|
||||
}).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, ".")
|
||||
// A name without a CNAME record resolves to itself.
|
||||
if strings.EqualFold(nakedCNAME, lookupDomain) {
|
||||
return "", cnameNotFound(lookupDomain, accept)
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"cname": cname,
|
||||
@@ -69,13 +114,13 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
|
||||
|
||||
for _, acceptDomain := range accept {
|
||||
normalizedAccept := strings.TrimSuffix(acceptDomain, ".")
|
||||
if nakedCNAME == normalizedAccept {
|
||||
if strings.EqualFold(nakedCNAME, normalizedAccept) {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"cname": nakedCNAME,
|
||||
"cluster": acceptDomain,
|
||||
}).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,
|
||||
"acceptList": accept,
|
||||
}).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 (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
@@ -36,6 +38,12 @@ func TestIsValid(t *testing.T) {
|
||||
accept: []string{"bar.example.com"},
|
||||
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": {
|
||||
resolver: resolver{"bar.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (s *BaseServer) EventStore() activity.Store {
|
||||
func (s *BaseServer) APIHandler() http.Handler {
|
||||
_ = CreateNamed(s, "http_v1api", func() http.Handler {
|
||||
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 {
|
||||
log.Fatalf("failed to create API handler: %v", err)
|
||||
}
|
||||
|
||||
@@ -335,7 +335,11 @@ func (s *BaseServer) Stop() error {
|
||||
}
|
||||
|
||||
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")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -6,5 +6,4 @@ type Engine string
|
||||
const (
|
||||
SqliteStoreEngine Engine = "sqlite"
|
||||
PostgresStoreEngine Engine = "postgres"
|
||||
MysqlStoreEngine Engine = "mysql"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -121,30 +120,6 @@ func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, erro
|
||||
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) {
|
||||
config, err := pgxpool.ParseConfig(dsn)
|
||||
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"))
|
||||
}
|
||||
@@ -39,10 +39,7 @@ func (c *Conn) RunInTx(ctx context.Context, fn func(tx *Tx) error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err := c.withForeignKeyChecksDisabled(tx, func() error {
|
||||
return fn(&Tx{db: tx})
|
||||
})
|
||||
if err != nil {
|
||||
if err := fn(&Tx{db: tx}); err != nil {
|
||||
tx.Rollback()
|
||||
c.logIfTimedOut(ctx, timeoutCtx, err, "transaction", startTime)
|
||||
return err
|
||||
@@ -73,31 +70,6 @@ func (c *Conn) applyStatementTimeouts(tx *gorm.DB) error {
|
||||
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) {
|
||||
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())
|
||||
|
||||
@@ -152,7 +152,6 @@ func (am *DefaultAccountManager) runAccountDeletionHooks(ctx context.Context, ac
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "(SQLSTATE 23505)"),
|
||||
strings.Contains(err.Error(), "Error 1062 (23000)"),
|
||||
strings.Contains(err.Error(), "UNIQUE constraint failed"):
|
||||
return true
|
||||
|
||||
|
||||
@@ -3759,7 +3759,7 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel.
|
||||
|
||||
// peerUpdateTimeout bounds how long peerShouldReceiveUpdate and its outer
|
||||
// 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
|
||||
// when the channel delivers.
|
||||
const peerUpdateTimeout = 5 * time.Second
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"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/users"
|
||||
)
|
||||
@@ -20,6 +21,7 @@ func NewAPIV1Handler(
|
||||
ctx context.Context,
|
||||
router *mux.Router,
|
||||
accountManager account.Manager,
|
||||
peerManager peersmodule.Manager,
|
||||
networkMapController network_map.Controller,
|
||||
permissionsManager permissions.Manager) (http.Handler, error) {
|
||||
|
||||
@@ -30,7 +32,7 @@ func NewAPIV1Handler(
|
||||
|
||||
router.Use(v1validatorMiddleware.Handler)
|
||||
|
||||
peersHandler := peers.NewHandler(accountManager, networkMapController, permissionsManager)
|
||||
peersHandler := peers.NewHandler(accountManager, peerManager, networkMapController, permissionsManager)
|
||||
_ = peersHandler.WithEndpointsForRouter(router)
|
||||
|
||||
usersHandler := users.NewHandler(accountManager)
|
||||
|
||||
@@ -6,17 +6,22 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"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/activity"
|
||||
"github.com/netbirdio/netbird/management/server/api/v1alpha1/groups"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"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/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
@@ -26,12 +31,14 @@ type Handler struct {
|
||||
accountManager account.Manager
|
||||
permissionsManager permissions.Manager
|
||||
networkMapController network_map.Controller
|
||||
peersManager peers.Manager
|
||||
}
|
||||
|
||||
// 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{
|
||||
accountManager: accountManager,
|
||||
peersManager: peerManager,
|
||||
networkMapController: networkMapController,
|
||||
permissionsManager: permissionsManager,
|
||||
}
|
||||
@@ -210,14 +217,32 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
nameFilter := r.URL.Query().Get("name")
|
||||
ipFilter := r.URL.Query().Get("ip")
|
||||
macFilter := r.URL.Query().Get("mac")
|
||||
pagination, err := paginationFromQuery(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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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 {
|
||||
log.WithContext(r.Context()).Errorf("error getting peers: %v", err)
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
@@ -232,35 +257,135 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
|
||||
grps, _ := h.accountManager.GetAllGroups(r.Context(), accountID, userID)
|
||||
|
||||
grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers))
|
||||
respBody := make([]*apiv1alpha1.PeerBatch, 0, len(peers))
|
||||
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)
|
||||
validPeers, invalidPeers, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
|
||||
if err != nil {
|
||||
log.WithContext(r.Context()).Errorf("failed to get validated peers: %v", err)
|
||||
util.WriteError(r.Context(), fmt.Errorf("internal error"), w)
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *Handler) setApprovalRequiredFlag(respBody []*apiv1alpha1.PeerBatch, validPeersMap map[string]struct{}, invalidPeersMap map[string]string) {
|
||||
for _, peer := range respBody {
|
||||
_, ok := validPeersMap[peer.Id]
|
||||
if !ok {
|
||||
peer.ApprovalRequired = true
|
||||
func filtersFromQuery(v url.Values) (store.PeerFilters, error) {
|
||||
connected, err := parseBool(v.Get("connected"))
|
||||
if err != nil {
|
||||
return store.PeerFilters{}, err
|
||||
}
|
||||
|
||||
reason := invalidPeersMap[peer.Id]
|
||||
peer.DisapprovalReason = &reason
|
||||
approvalRequired, err := parseBool(v.Get("approval_required"))
|
||||
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) {
|
||||
@@ -337,61 +462,6 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinim
|
||||
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 {
|
||||
fqdn := peer.FQDN(dnsDomain)
|
||||
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"
|
||||
|
||||
"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"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
@@ -127,7 +128,10 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
}).
|
||||
AnyTimes()
|
||||
|
||||
peerManager := peersmodule.NewMockManager(ctrl2)
|
||||
|
||||
return &Handler{
|
||||
peersManager: peerManager,
|
||||
accountManager: &mock_server.MockAccountManager{
|
||||
UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
|
||||
var p *nbpeer.Peer
|
||||
@@ -173,8 +177,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
},
|
||||
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||
return peers, nil
|
||||
GetPeersFunc: func(_ context.Context, accountID, userID string, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
|
||||
return peers, len(peers), nil
|
||||
},
|
||||
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
|
||||
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
|
||||
// Use the metadata generated by initTestMetaData() to check for values
|
||||
func TestGetPeers(t *testing.T) {
|
||||
func TestGetPeer(t *testing.T) {
|
||||
|
||||
peer := &nbpeer.Peer{
|
||||
ID: testPeerID,
|
||||
@@ -262,23 +264,13 @@ func TestGetPeers(t *testing.T) {
|
||||
requestPath string
|
||||
contentType string
|
||||
requestBody io.Reader
|
||||
expectedArray bool
|
||||
expectedPeer *nbpeer.Peer
|
||||
}{
|
||||
{
|
||||
name: "GetPeersMetaData",
|
||||
requestType: http.MethodGet,
|
||||
requestPath: "/peers",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedArray: true,
|
||||
expectedPeer: peer,
|
||||
},
|
||||
{
|
||||
name: "GetPeer with update channel",
|
||||
requestType: http.MethodGet,
|
||||
requestPath: "/peers/" + testPeerID,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedArray: false,
|
||||
expectedPeer: peer,
|
||||
},
|
||||
{
|
||||
@@ -287,7 +279,6 @@ func TestGetPeers(t *testing.T) {
|
||||
requestPath: "/peers/" + testPeerID,
|
||||
contentType: "application/json",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedArray: false,
|
||||
requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true, \"inactivity_expiration_enabled\":true}"),
|
||||
expectedPeer: expectedUpdatedPeer,
|
||||
},
|
||||
@@ -330,32 +321,123 @@ func TestGetPeers(t *testing.T) {
|
||||
t.Fatalf("I don't know what I expected; %v", err)
|
||||
}
|
||||
|
||||
var got *apiv1alpha1.Peer
|
||||
if tc.expectedArray {
|
||||
respBody := []*apiv1alpha1.Peer{}
|
||||
err = json.Unmarshal(content, &respBody)
|
||||
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)
|
||||
}
|
||||
var 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.Meta.WtVersion, got.Version)
|
||||
|
||||
Vendored
+1
-1
@@ -39,7 +39,7 @@ func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (Store
|
||||
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{
|
||||
StoreInterface: redisstore.NewRedis(redisClient),
|
||||
|
||||
@@ -78,8 +78,8 @@ func initGroupTestData(initGroups ...*types.Group) *handler {
|
||||
|
||||
return nil, status.Errorf(status.NotFound, "unknown group name")
|
||||
},
|
||||
GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||
return maps.Values(TestPeers), nil
|
||||
GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
|
||||
return maps.Values(TestPeers), len(maps.Values(TestPeers)), nil
|
||||
},
|
||||
DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error {
|
||||
if groupID == "linked-grp" {
|
||||
|
||||
@@ -173,8 +173,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
},
|
||||
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||
return peers, nil
|
||||
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
|
||||
return peers, len(peers), nil
|
||||
},
|
||||
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
|
||||
peersID := make([]string, len(peers))
|
||||
|
||||
@@ -429,10 +429,6 @@ func Test_SyncStatusRace(t *testing.T) {
|
||||
if os.Getenv("NETBIRD_STORE_ENGINE") == "postgres" {
|
||||
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++ {
|
||||
t.Run(fmt.Sprintf("TestRun-%d", i), func(t *testing.T) {
|
||||
|
||||
@@ -19,19 +19,11 @@ import (
|
||||
"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.
|
||||
// T is the type of the model that contains 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 {
|
||||
orgColumnName := fieldName
|
||||
oldColumnName := GetColumnName(db, orgColumnName)
|
||||
oldColumnName := fieldName
|
||||
newColumnName := fieldName + "_tmp"
|
||||
|
||||
var model T
|
||||
@@ -86,7 +78,7 @@ func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, f
|
||||
for _, row := range rows {
|
||||
var field S
|
||||
|
||||
str, ok := row[orgColumnName].(string)
|
||||
str, ok := row[oldColumnName].(string)
|
||||
if !ok {
|
||||
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.
|
||||
// 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 {
|
||||
orgColumnName := fieldName
|
||||
oldColumnName := GetColumnName(db, orgColumnName)
|
||||
oldColumnName := fieldName
|
||||
newColumnName := fieldName + "_tmp"
|
||||
|
||||
var model T
|
||||
@@ -178,7 +169,7 @@ func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fi
|
||||
|
||||
for _, row := range rows {
|
||||
var blobValue string
|
||||
if columnValue := row[orgColumnName]; columnValue != nil {
|
||||
if columnValue := row[oldColumnName]; columnValue != nil {
|
||||
value, ok := columnValue.(string)
|
||||
if !ok {
|
||||
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 {
|
||||
orgColumnName := "key"
|
||||
oldColumnName := GetColumnName(db, orgColumnName)
|
||||
oldColumnName := "key"
|
||||
newColumnName := "key_secret"
|
||||
|
||||
var model T
|
||||
@@ -268,7 +258,7 @@ func MigrateSetupKeyToHashedSetupKey[T any](ctx context.Context, db *gorm.DB) er
|
||||
for _, row := range rows {
|
||||
|
||||
var plainKey string
|
||||
if columnValue := row[orgColumnName]; columnValue != nil {
|
||||
if columnValue := row[oldColumnName]; columnValue != nil {
|
||||
value, ok := columnValue.(string)
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
tableName := stmt.Schema.Table
|
||||
dialect := db.Name()
|
||||
|
||||
if db.Migrator().HasIndex(&model, indexName) {
|
||||
log.WithContext(ctx).Infof("index %s already exists on table %s", indexName, tableName)
|
||||
return nil
|
||||
}
|
||||
|
||||
var columnClause string
|
||||
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)
|
||||
}
|
||||
createStmt := fmt.Sprintf("CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (%s)", indexName, tableName, strings.Join(columns, ", "))
|
||||
|
||||
log.WithContext(ctx).Infof("executing index creation: %s", createStmt)
|
||||
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.column_name = ?
|
||||
`, 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
|
||||
type fkInfo struct {
|
||||
From string
|
||||
@@ -592,7 +554,7 @@ func RemoveDuplicatePeerKeys(ctx context.Context, db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
keyColumn := GetColumnName(db, "key")
|
||||
keyColumn := "key"
|
||||
|
||||
var duplicates []struct {
|
||||
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
|
||||
// migration fails loudly rather than leaving an empty domain to collide with
|
||||
// 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 {
|
||||
model := &agentNetworkSettingsMigration{}
|
||||
migrator := db.Migrator()
|
||||
@@ -66,14 +57,9 @@ func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error
|
||||
}
|
||||
|
||||
if hasCluster {
|
||||
concat := "subdomain || '.' || cluster"
|
||||
if tx.Name() == "mysql" {
|
||||
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,
|
||||
))
|
||||
res := tx.Exec(
|
||||
"UPDATE agent_network_settings SET domain = subdomain || '.' || cluster, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
|
||||
)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("backfill agent_network_settings domain: %w", res.Error)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -32,17 +31,6 @@ func setupDatabase(t *testing.T) *gorm.DB {
|
||||
var dsn string
|
||||
var cleanup func()
|
||||
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":
|
||||
cleanup, dsn, err = testutil.CreatePostgresTestContainer()
|
||||
if err != nil {
|
||||
@@ -597,9 +585,8 @@ func TestCleanupOrphanedResources_Idempotent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
|
||||
engine := os.Getenv("NETBIRD_STORE_ENGINE")
|
||||
if engine != "postgres" && engine != "mysql" {
|
||||
t.Skip("FK constraint early-exit test requires postgres or mysql")
|
||||
if os.Getenv("NETBIRD_STORE_ENGINE") != "postgres" {
|
||||
t.Skip("FK constraint early-exit test requires postgres")
|
||||
}
|
||||
|
||||
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: "c2", ParentID: "p2"}).Error)
|
||||
|
||||
switch engine {
|
||||
case "postgres":
|
||||
require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT 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) 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)
|
||||
}
|
||||
require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT 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) NOT VALID",
|
||||
).Error)
|
||||
|
||||
err = migration.CleanupOrphanedResources[testChildWithFK, testParent](context.Background(), db, "parent_id")
|
||||
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.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{
|
||||
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6",
|
||||
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")
|
||||
}
|
||||
|
||||
// partialAgentNetworkSettings models the one non-atomic state a MySQL run can
|
||||
// be interrupted in: DDL auto-commits there, so a crash between the two legacy
|
||||
// column drops leaves subdomain behind while cluster (and the completed
|
||||
// backfill) are already committed.
|
||||
// partialAgentNetworkSettings models a schema left between the two legacy
|
||||
// column drops: subdomain is still present while cluster is gone and the
|
||||
// backfill has already completed.
|
||||
type partialAgentNetworkSettings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Subdomain string
|
||||
@@ -832,8 +804,8 @@ type partialAgentNetworkSettings struct {
|
||||
|
||||
func (partialAgentNetworkSettings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins MySQL
|
||||
// resumability: a rerun over the interrupted state must remove the leftover
|
||||
// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins
|
||||
// resumability: a rerun over the partial state must remove the leftover
|
||||
// subdomain column without re-running the backfill (the cluster column that
|
||||
// feeds it is gone) and without touching the migrated values.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.T) {
|
||||
|
||||
@@ -39,7 +39,7 @@ type MockAccountManager struct {
|
||||
GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error)
|
||||
GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*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
|
||||
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)
|
||||
@@ -820,7 +820,8 @@ func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, user
|
||||
// GetPeers mocks GetPeers of the AccountManager interface
|
||||
func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -73,7 +73,9 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
|
||||
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
|
||||
|
||||
@@ -730,7 +730,6 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) {
|
||||
return
|
||||
}
|
||||
assert.NotNil(t, peers)
|
||||
|
||||
assert.Len(t, peers, testCase.expectedPeerCount)
|
||||
|
||||
})
|
||||
@@ -1567,8 +1566,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
|
||||
|
||||
func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
|
||||
engine := os.Getenv("NETBIRD_STORE_ENGINE")
|
||||
if engine == "sqlite" || engine == "mysql" || engine == "" {
|
||||
// we intentionally disabled foreign keys in mysql
|
||||
if engine == "sqlite" || engine == "" {
|
||||
t.Skip("Skipping test because store is not respecting foreign keys")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -2220,10 +2218,6 @@ func Test_IsUniqueConstraintError(t *testing.T) {
|
||||
name: "PostgreSQL uniqueness error",
|
||||
engine: types.PostgresStoreEngine,
|
||||
},
|
||||
{
|
||||
name: "MySQL uniqueness error",
|
||||
engine: types.MysqlStoreEngine,
|
||||
},
|
||||
{
|
||||
name: "SQLite uniqueness error",
|
||||
engine: types.SqliteStoreEngine,
|
||||
|
||||
@@ -33,7 +33,6 @@ import (
|
||||
const (
|
||||
idQueryCondition = "id = ?"
|
||||
keyQueryCondition = "key = ?"
|
||||
mysqlKeyQueryCondition = "`key` = ?"
|
||||
accountAndIDQueryCondition = "account_id = ? and id = ?"
|
||||
accountAndAnyIDQueryCondition = "account_id = ? and (id = ? or public_id = ?)"
|
||||
accountAndPeerIDQueryCondition = "account_id = ? and peer_id = ?"
|
||||
@@ -119,13 +118,6 @@ func (s *SqlStore) pgxPool() *pgxpool.Pool {
|
||||
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
|
||||
func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func NewSqliteStoreFromFileStore(ctx context.Context, fileStore *FileStore, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// sqlite seed store into a freshly created engine store.
|
||||
func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) error {
|
||||
@@ -259,20 +237,6 @@ func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) erro
|
||||
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
|
||||
// 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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
// MySQL reports RowsAffected=0 for no-op updates where values don't change,
|
||||
// unlike SQLite/Postgres which report matched rows. Skip the check since the
|
||||
// caller (UpdateAccountSettings) already verified the account exists via
|
||||
// GetAccountSettings with LockingStrengthUpdate.
|
||||
// No RowsAffected check: the caller (UpdateAccountSettings) already verified
|
||||
// the account exists via GetAccountSettings with LockingStrengthUpdate.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -69,10 +68,6 @@ func TestSqlStore_GetGroupsByIDs(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())
|
||||
t.Cleanup(cleanup)
|
||||
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.
|
||||
// MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0).
|
||||
func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error {
|
||||
if s.conn.Engine() == types.SqliteStoreEngine {
|
||||
return tx.Exec("PRAGMA defer_foreign_keys = ON").Error
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
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 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) {
|
||||
var peer nbpeer.Peer
|
||||
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 errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
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
|
||||
result := tx.Take(&peer, GetKeyQueryCondition(s), peerKey)
|
||||
result := tx.Take(&peer, keyQueryCondition, peerKey)
|
||||
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
@@ -494,7 +495,6 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking
|
||||
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) {
|
||||
var peers []*nbpeer.Peer
|
||||
tx := s.db
|
||||
@@ -514,7 +514,6 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
|
||||
if macFilter != "" {
|
||||
query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%")
|
||||
}
|
||||
|
||||
if err := query.Find(&peers).Error; err != nil {
|
||||
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")
|
||||
@@ -523,6 +522,162 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
|
||||
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.
|
||||
func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) {
|
||||
tx := s.db
|
||||
@@ -759,7 +914,7 @@ func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingS
|
||||
var userID string
|
||||
result := tx.Model(&nbpeer.Peer{}).
|
||||
Select("user_id").
|
||||
Take(&userID, GetKeyQueryCondition(s), peerKey)
|
||||
Take(&userID, keyQueryCondition, peerKey)
|
||||
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
@@ -780,7 +935,7 @@ func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStren
|
||||
var peerID string
|
||||
result := tx.Model(&nbpeer.Peer{}).
|
||||
Select("id").
|
||||
Where(GetKeyQueryCondition(s), key).
|
||||
Where(keyQueryCondition, key).
|
||||
Limit(1).
|
||||
Scan(&peerID)
|
||||
if result.Error != nil {
|
||||
|
||||
@@ -512,7 +512,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
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.Len(t, peers, tt.expectedCount)
|
||||
})
|
||||
@@ -555,7 +555,7 @@ func TestSqlStore_GetAccountPeers_FilterByMac(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
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.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
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -21,8 +21,8 @@ func (s *SqlStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) {
|
||||
var m ProxyMetrics
|
||||
activeCutoff := time.Now().Add(-proxyActiveThreshold)
|
||||
|
||||
// COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite/postgres
|
||||
// (MySQL too) and keeps the round-trip to one. proxy.StatusConnected
|
||||
// COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite and postgres
|
||||
// and keeps the round-trip to one. proxy.StatusConnected
|
||||
// is the same string the cluster-capability queries use; the active
|
||||
// window matches the cluster-capability semantics (only proxies
|
||||
// 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, "+
|
||||
// COUNT(CASE WHEN ... THEN 1 END) counts only non-NULL — i.e. only
|
||||
// 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, "+
|
||||
// MAX(CASE …) > 0 expresses BOOL_OR in a way Postgres tolerates
|
||||
// (Postgres can't MAX a boolean column).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user