Merge branch 'main' into profile-ownership

This commit is contained in:
Theodor S. Midtlien
2026-10-20 21:52:17 +02:00
121 changed files with 4002 additions and 2810 deletions
@@ -34,7 +34,7 @@ jobs:
while IFS= read -r dir; do
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"
+82
View File
@@ -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)"'
+2 -6
View File
@@ -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.
+1 -1
View File
@@ -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
-26
View File
@@ -280,28 +280,6 @@ builds:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
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
+8 -14
View File
@@ -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.
+3 -2
View File
@@ -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
View File
@@ -164,7 +164,8 @@ func init() {
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|npipe]://[path|name]. tcp://host:port is deprecated: it carries no caller identity, so the daemon refuses every request on it")
rootCmd.PersistentFlags().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)")
-1
View File
@@ -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
+16 -8
View File
@@ -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")
}
-7
View File
@@ -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))
+1 -9
View File
@@ -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")
+64 -50
View File
@@ -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),
}
}
+69 -38
View File
@@ -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() {
+159 -6
View File
@@ -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)
}
+5 -5
View File
@@ -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 {
+3 -47
View File
@@ -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,
+8 -14
View File
@@ -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.
+3 -2
View File
@@ -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)
}
-5
View File
@@ -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),
+7 -3
View File
@@ -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.
+4 -2
View File
@@ -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()
+3 -3
View File
@@ -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 }`.
+2 -1
View File
@@ -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"}
]
}
+2 -4
View File
@@ -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"
+2 -4
View File
@@ -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"
+2 -4
View File
@@ -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
+2 -4
View File
@@ -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"
+2 -4
View File
@@ -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"
+2 -4
View File
@@ -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": "表示言語"
+2 -4
View File
@@ -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"
+2 -4
View File
@@ -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": "Язык интерфейса"
+2 -4
View File
@@ -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": "显示语言"
-4
View File
@@ -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,
-4
View File
@@ -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"))
}
+1 -1
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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 {
+8
View File
@@ -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)
+2 -4
View File
@@ -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
+4 -10
View File
@@ -252,7 +252,6 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-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=
-12
View File
@@ -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"
-12
View File
@@ -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 {
+31 -16
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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)
}
+5 -1
View File
@@ -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():
-1
View File
@@ -6,5 +6,4 @@ type Engine string
const (
SqliteStoreEngine Engine = "sqlite"
PostgresStoreEngine Engine = "postgres"
MysqlStoreEngine Engine = "mysql"
)
-25
View File
@@ -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"))
}
+1 -29
View File
@@ -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())
-1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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)
+1 -1
View File
@@ -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) {
+8 -46
View File
@@ -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)
}
+13 -41
View File
@@ -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")
}
+3 -1
View File
@@ -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
+1 -7
View File
@@ -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,
-36
View File
@@ -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 {
+2 -4
View File
@@ -1084,10 +1084,8 @@ func (s *SqlStore) SaveAccountSettings(ctx context.Context, accountID string, se
return status.Errorf(status.Internal, "failed to save account settings to store")
}
// 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
+162 -7
View File
@@ -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
+3 -3
View File
@@ -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