mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-21 21:09:07 +02:00
Merge branch 'main' into profile-ownership
This commit is contained in:
@@ -34,7 +34,7 @@ jobs:
|
|||||||
while IFS= read -r dir; do
|
while IFS= read -r dir; do
|
||||||
echo "=== Checking $dir ==="
|
echo "=== Checking $dir ==="
|
||||||
# Search for problematic imports, excluding test files
|
# Search for problematic imports, excluding test files
|
||||||
RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" | grep -v "tools/mysql-migrate/" || true)
|
RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" || true)
|
||||||
if [ -n "$RESULTS" ]; then
|
if [ -n "$RESULTS" ]; then
|
||||||
echo "❌ Found problematic dependencies:"
|
echo "❌ Found problematic dependencies:"
|
||||||
echo "$RESULTS"
|
echo "$RESULTS"
|
||||||
@@ -93,7 +93,7 @@ jobs:
|
|||||||
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
||||||
|
|
||||||
# Check if any importer is NOT in management/signal/relay
|
# Check if any importer is NOT in management/signal/relay
|
||||||
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\|tools/mysql-migrate\)" | head -1)
|
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
||||||
|
|
||||||
if [ -n "$BSD_IMPORTER" ]; then
|
if [ -n "$BSD_IMPORTER" ]; then
|
||||||
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ name: Post release topic on Discourse
|
|||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
workflow_run:
|
||||||
|
workflows: [Release]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
post:
|
post:
|
||||||
|
if: github.event_name == 'release'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
|
- uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
|
||||||
@@ -15,3 +22,78 @@ jobs:
|
|||||||
discourse-author-username: NetBird
|
discourse-author-username: NetBird
|
||||||
discourse-category: 17
|
discourse-category: 17
|
||||||
discourse-tags: releases
|
discourse-tags: releases
|
||||||
|
|
||||||
|
# goreleaser publishes the release of a pushed tag with GITHUB_TOKEN, and events
|
||||||
|
# raised by that token start no workflow, so the release event above never fires.
|
||||||
|
post-tag-release:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_run' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
startsWith(github.event.workflow_run.head_branch, 'v') &&
|
||||||
|
!contains(github.event.workflow_run.head_branch, '-')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Post the release topic
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
|
||||||
|
DISCOURSE_URL: https://forum.netbird.io
|
||||||
|
DISCOURSE_USER: NetBird
|
||||||
|
DISCOURSE_CATEGORY: "17"
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! release=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>gh.err); then
|
||||||
|
if grep -q 'HTTP 404' gh.err; then
|
||||||
|
echo "no release for ${TAG}, nothing to announce"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cat gh.err >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A release a person published already fired the release event above.
|
||||||
|
author=$(jq -r '.author.login' <<<"$release")
|
||||||
|
if [ "$author" != "github-actions[bot]" ] || [ "$(jq -r '.draft' <<<"$release")" = "true" ]; then
|
||||||
|
echo "${TAG} published by ${author}, announced by the release event"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Same title and body as the action above, so both paths read alike.
|
||||||
|
title="${GITHUB_REPOSITORY#*/} ${TAG} released"
|
||||||
|
|
||||||
|
# A re-run of a successful Release run fires this event again. A failed lookup
|
||||||
|
# stops the step rather than risk a duplicate topic.
|
||||||
|
search=$(curl -fsS --retry 3 --proto '=https' --proto-redir '=https' -G \
|
||||||
|
-H "Api-Key: ${DISCOURSE_API_KEY}" \
|
||||||
|
-H "Api-Username: ${DISCOURSE_USER}" \
|
||||||
|
"${DISCOURSE_URL}/search.json" \
|
||||||
|
--data-urlencode "q=\"${title}\" in:title")
|
||||||
|
existing=$(jq -r --arg t "$title" --argjson c "$DISCOURSE_CATEGORY" \
|
||||||
|
'[(.topics // [])[] | select(.category_id == $c and (.title | ascii_downcase) == ($t | ascii_downcase))] | length' \
|
||||||
|
<<<"$search")
|
||||||
|
if [ "$existing" != "0" ]; then
|
||||||
|
echo "topic '${title}' already exists"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# goreleaser leaves the body empty; the notes are written on the release page afterwards.
|
||||||
|
url=$(jq -r '.html_url' <<<"$release")
|
||||||
|
body=$(jq -r '.body // ""' <<<"$release")
|
||||||
|
if [ -z "${body//[[:space:]]/}" ]; then
|
||||||
|
body="Release notes and downloads: ${url}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg title "$title" \
|
||||||
|
--arg raw "$(printf '[%s](%s)\n\n%s' "$title" "$url" "$body")" \
|
||||||
|
--argjson category "$DISCOURSE_CATEGORY" \
|
||||||
|
'{title: $title, raw: $raw, category: $category, tags: ["releases"]}' \
|
||||||
|
| curl -fsS -X POST "${DISCOURSE_URL}/posts.json" \
|
||||||
|
-H "Api-Key: ${DISCOURSE_API_KEY}" \
|
||||||
|
-H "Api-Username: ${DISCOURSE_USER}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data @- \
|
||||||
|
| jq -r '"posted \(env.DISCOURSE_URL)/t/\(.topic_slug)/\(.topic_id)"'
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ jobs:
|
|||||||
sh -c ' \
|
sh -c ' \
|
||||||
apk update; apk add --no-cache \
|
apk update; apk add --no-cache \
|
||||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||||
go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged -e tools/mysql-migrate)
|
go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
|
||||||
'
|
'
|
||||||
|
|
||||||
test_relay:
|
test_relay:
|
||||||
@@ -498,7 +498,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: ["amd64"]
|
arch: ["amd64"]
|
||||||
store: ["sqlite", "postgres", "mysql"]
|
store: ["sqlite", "postgres"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -547,10 +547,6 @@ jobs:
|
|||||||
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||||
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
|
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
|
||||||
|
|
||||||
- name: download mysql image
|
|
||||||
if: matrix.store == 'mysql'
|
|
||||||
run: docker pull mlsmaycon/warmed-mysql:8
|
|
||||||
|
|
||||||
# The -json stream goes through tools/gotestsummary so the log shows one
|
# The -json stream goes through tools/gotestsummary so the log shows one
|
||||||
# line per test, the output of failed tests, the head of a timeout panic
|
# line per test, the output of failed tests, the head of a timeout panic
|
||||||
# with the still-running tests, and the slowest tests per package.
|
# with the still-running tests, and the slowest tests per package.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
# resolve; the Where-Object pipeline then drops the broken package by
|
# resolve; the Where-Object pipeline then drops the broken package by
|
||||||
# path. Without -e, go list aborts with empty stdout.
|
# path. Without -e, go list aborts with empty stdout.
|
||||||
run: |
|
run: |
|
||||||
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' } | Where-Object { $_ -notmatch '/tools/mysql-migrate' }
|
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' }
|
||||||
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
||||||
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
||||||
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
store: ["sqlite", "postgres", "mysql"]
|
store: ["sqlite", "postgres"]
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
||||||
@@ -35,19 +35,6 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
mysql:
|
|
||||||
image: ${{ (matrix.store == 'mysql') && 'mysql' || '' }}
|
|
||||||
env:
|
|
||||||
MYSQL_USER: netbird
|
|
||||||
MYSQL_PASSWORD: mysql
|
|
||||||
MYSQL_ROOT_PASSWORD: mysqlroot
|
|
||||||
MYSQL_DATABASE: netbird
|
|
||||||
options: >-
|
|
||||||
--health-cmd "mysqladmin ping --silent"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
ports:
|
|
||||||
- 3306:3306
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set Database Connection String
|
- name: Set Database Connection String
|
||||||
run: |
|
run: |
|
||||||
@@ -56,11 +43,6 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "NETBIRD_STORE_ENGINE_POSTGRES_DSN==" >> $GITHUB_ENV
|
echo "NETBIRD_STORE_ENGINE_POSTGRES_DSN==" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
if [ "${{ matrix.store }}" == "mysql" ]; then
|
|
||||||
echo "NETBIRD_STORE_ENGINE_MYSQL_DSN=netbird:mysql@tcp($(hostname -I | awk '{print $1}'):3306)/netbird" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "NETBIRD_STORE_ENGINE_MYSQL_DSN==" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install jq
|
- name: Install jq
|
||||||
run: sudo apt-get install -y jq
|
run: sudo apt-get install -y jq
|
||||||
@@ -86,15 +68,6 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Setup MySQL privileges
|
|
||||||
if: matrix.store == 'mysql'
|
|
||||||
run: |
|
|
||||||
sleep 10
|
|
||||||
mysql -h 127.0.0.1 -u root -pmysqlroot -e "
|
|
||||||
GRANT SYSTEM_VARIABLES_ADMIN ON *.* TO 'netbird'@'%';
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: cp setup.env
|
- name: cp setup.env
|
||||||
run: cp infrastructure_files/tests/setup.env infrastructure_files/
|
run: cp infrastructure_files/tests/setup.env infrastructure_files/
|
||||||
|
|
||||||
@@ -114,7 +87,6 @@ jobs:
|
|||||||
CI_NETBIRD_AUTH_SUPPORTED_SCOPES: "openid profile email offline_access api email_verified"
|
CI_NETBIRD_AUTH_SUPPORTED_SCOPES: "openid profile email offline_access api email_verified"
|
||||||
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||||
NETBIRD_STORE_ENGINE_POSTGRES_DSN: ${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}
|
NETBIRD_STORE_ENGINE_POSTGRES_DSN: ${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}
|
||||||
NETBIRD_STORE_ENGINE_MYSQL_DSN: ${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}
|
|
||||||
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
||||||
|
|
||||||
- name: check values
|
- name: check values
|
||||||
@@ -143,7 +115,6 @@ jobs:
|
|||||||
CI_NETBIRD_SIGNAL_PORT: 12345
|
CI_NETBIRD_SIGNAL_PORT: 12345
|
||||||
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||||
NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
|
NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
|
||||||
NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
|
|
||||||
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
||||||
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
||||||
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
||||||
@@ -182,7 +153,6 @@ jobs:
|
|||||||
grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_SUPPORTED_SCOPES"
|
grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_SUPPORTED_SCOPES"
|
||||||
grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep -A 3 RedirectURLs | grep "http://localhost:53000"
|
grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep -A 3 RedirectURLs | grep "http://localhost:53000"
|
||||||
grep "external-ip" turnserver.conf | grep $CI_NETBIRD_TURN_EXTERNAL_IP
|
grep "external-ip" turnserver.conf | grep $CI_NETBIRD_TURN_EXTERNAL_IP
|
||||||
grep "NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN" docker-compose.yml
|
|
||||||
grep NETBIRD_STORE_ENGINE_POSTGRES_DSN docker-compose.yml | egrep "$NETBIRD_STORE_ENGINE_POSTGRES_DSN"
|
grep NETBIRD_STORE_ENGINE_POSTGRES_DSN docker-compose.yml | egrep "$NETBIRD_STORE_ENGINE_POSTGRES_DSN"
|
||||||
# check relay values
|
# check relay values
|
||||||
grep "NB_EXPOSED_ADDRESS=rels://$CI_NETBIRD_DOMAIN:33445" docker-compose.yml
|
grep "NB_EXPOSED_ADDRESS=rels://$CI_NETBIRD_DOMAIN:33445" docker-compose.yml
|
||||||
|
|||||||
@@ -280,28 +280,6 @@ builds:
|
|||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
- id: netbird-mysql-migrate
|
|
||||||
dir: tools/mysql-migrate
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=1
|
|
||||||
- >-
|
|
||||||
{{- if eq .Runtime.Goos "linux" }}
|
|
||||||
{{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }}
|
|
||||||
{{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
binary: netbird-mysql-migrate
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
- arm64
|
|
||||||
- arm
|
|
||||||
goarm:
|
|
||||||
- 7
|
|
||||||
ldflags:
|
|
||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
|
||||||
|
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
- id: netbird
|
- id: netbird
|
||||||
|
|
||||||
@@ -318,10 +296,6 @@ archives:
|
|||||||
builds:
|
builds:
|
||||||
- netbird-idp-migrate
|
- netbird-idp-migrate
|
||||||
name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
- id: netbird-mysql-migrate
|
|
||||||
builds:
|
|
||||||
- netbird-mysql-migrate
|
|
||||||
name_template: "netbird-mysql-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
|
||||||
- id: nblink
|
- id: nblink
|
||||||
builds:
|
builds:
|
||||||
- nblink
|
- nblink
|
||||||
|
|||||||
@@ -58,23 +58,17 @@ func (p *Preferences) SetManagementURL(url string) {
|
|||||||
p.configInput.ManagementURL = url
|
p.configInput.ManagementURL = url
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAdminURL reads URL from config file
|
// GetAdminURL returns the default admin panel URL.
|
||||||
|
//
|
||||||
|
// Deprecated: the admin URL is no longer stored or used by the client.
|
||||||
func (p *Preferences) GetAdminURL() (string, error) {
|
func (p *Preferences) GetAdminURL() (string, error) {
|
||||||
if p.configInput.AdminURL != "" {
|
return profilemanager.DefaultAdminURL, nil
|
||||||
return p.configInput.AdminURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return cfg.AdminURL.String(), err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAdminURL stores the given URL and waits for commit
|
// SetAdminURL is a no-op kept for API compatibility.
|
||||||
func (p *Preferences) SetAdminURL(url string) {
|
//
|
||||||
p.configInput.AdminURL = url
|
// Deprecated: the admin URL is no longer stored or used by the client.
|
||||||
}
|
func (p *Preferences) SetAdminURL(_ string) {}
|
||||||
|
|
||||||
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
||||||
// enforced by MDM; the key itself is never handed to the native layer.
|
// enforced by MDM; the key itself is never handed to the native layer.
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
|||||||
cfgFile := filepath.Join(t.TempDir(), "netbird.json")
|
cfgFile := filepath.Join(t.TempDir(), "netbird.json")
|
||||||
p := NewPreferences(cfgFile)
|
p := NewPreferences(cfgFile)
|
||||||
|
|
||||||
|
// The admin URL is deprecated: setting it is a no-op.
|
||||||
p.SetAdminURL(exampleString)
|
p.SetAdminURL(exampleString)
|
||||||
resp, err := p.GetAdminURL()
|
resp, err := p.GetAdminURL()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read admin url: %s", err)
|
t.Fatalf("failed to read admin url: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp != exampleString {
|
if resp != profilemanager.DefaultAdminURL {
|
||||||
t.Errorf("unexpected admin url: %s", resp)
|
t.Errorf("unexpected admin url: %s", resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ func TestPreferences_Commit(t *testing.T) {
|
|||||||
t.Fatalf("failed to read admin url: %s", err)
|
t.Fatalf("failed to read admin url: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp != exampleURL {
|
if resp != profilemanager.DefaultAdminURL {
|
||||||
t.Errorf("unexpected admin url: %s", resp)
|
t.Errorf("unexpected admin url: %s", resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -164,7 +164,8 @@ func init() {
|
|||||||
|
|
||||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|npipe]://[path|name]. tcp://host:port is deprecated: it carries no caller identity, so the daemon refuses every request on it")
|
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|npipe]://[path|name]. tcp://host:port is deprecated: it carries no caller identity, so the daemon refuses every request on it")
|
||||||
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
|
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
|
||||||
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
|
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", "(DEPRECATED) Admin Panel URL - This flag is no longer functional")
|
||||||
|
_ = rootCmd.PersistentFlags().MarkDeprecated("admin-url", "the admin-url flag is no longer functional and will be removed in a future version")
|
||||||
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
|
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
|
||||||
rootCmd.PersistentFlags().StringSliceVar(&logFiles, "log-file", []string{defaultLogFile}, "sets NetBird log paths written to simultaneously. If `console` is specified the log will be output to stdout. If `syslog` is specified the log will be sent to syslog daemon. You can pass the flag multiple times or separate entries by `,` character")
|
rootCmd.PersistentFlags().StringSliceVar(&logFiles, "log-file", []string{defaultLogFile}, "sets NetBird log paths written to simultaneously. If `console` is specified the log will be output to stdout. If `syslog` is specified the log will be sent to syslog daemon. You can pass the flag multiple times or separate entries by `,` character")
|
||||||
rootCmd.PersistentFlags().StringVarP(&setupKey, "setup-key", "k", "", "Setup key obtained from the Management Service Dashboard (used to register peer)")
|
rootCmd.PersistentFlags().StringVarP(&setupKey, "setup-key", "k", "", "Setup key obtained from the Management Service Dashboard (used to register peer)")
|
||||||
|
|||||||
@@ -503,7 +503,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
|||||||
req.Username = username
|
req.Username = username
|
||||||
|
|
||||||
req.ManagementUrl = managementURL
|
req.ManagementUrl = managementURL
|
||||||
req.AdminURL = adminURL
|
|
||||||
req.NatExternalIPs = natExternalIPs
|
req.NatExternalIPs = natExternalIPs
|
||||||
req.CustomDNSAddress = customDNSAddressConverted
|
req.CustomDNSAddress = customDNSAddressConverted
|
||||||
req.ExtraIFaceBlacklist = extraIFaceBlackList
|
req.ExtraIFaceBlacklist = extraIFaceBlackList
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
|
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||||
@@ -34,14 +35,14 @@ func (f *fakeTunDevice) Create() (device.WGConfigurer, error) {
|
|||||||
func (f *fakeTunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
func (f *fakeTunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
func (f *fakeTunDevice) UpdateAddr(wgaddr.Address) error { return nil }
|
func (f *fakeTunDevice) UpdateAddr(wgaddr.Address) error { return nil }
|
||||||
func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr.Address{} }
|
func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr.Address{} }
|
||||||
func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU }
|
func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU }
|
||||||
func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" }
|
func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" }
|
||||||
func (f *fakeTunDevice) FilteredDevice() *device.FilteredDevice { return nil }
|
func (f *fakeTunDevice) FilteredDevice() *device.FilteredDevice { return nil }
|
||||||
func (f *fakeTunDevice) Device() *wgdevice.Device { return nil }
|
func (f *fakeTunDevice) Device() *wgdevice.Device { return nil }
|
||||||
func (f *fakeTunDevice) GetNet() *netstack.Net { return nil }
|
func (f *fakeTunDevice) GetNet() *netstack.Net { return nil }
|
||||||
func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil }
|
func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil }
|
||||||
|
|
||||||
func (f *fakeTunDevice) Close() error {
|
func (f *fakeTunDevice) Close() error {
|
||||||
close(f.closeStarted)
|
close(f.closeStarted)
|
||||||
@@ -62,6 +63,10 @@ func (fakeProxyFactory) Free() error { return nil }
|
|||||||
// WGIface.GetDevice() which needs the same mutex. The fix is to drop
|
// WGIface.GetDevice() which needs the same mutex. The fix is to drop
|
||||||
// the lock before tun.Close() returns control.
|
// the lock before tun.Close() returns control.
|
||||||
func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) {
|
func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) {
|
||||||
|
// Netstack mode skips the platform interface cleanup in Close, so only
|
||||||
|
// the mutex and tun close behavior is exercised.
|
||||||
|
t.Setenv(nbnetstack.EnvUseNetstackMode, "true")
|
||||||
|
|
||||||
tun := &fakeTunDevice{
|
tun := &fakeTunDevice{
|
||||||
closeStarted: make(chan struct{}),
|
closeStarted: make(chan struct{}),
|
||||||
unblockClose: make(chan struct{}),
|
unblockClose: make(chan struct{}),
|
||||||
@@ -105,7 +110,10 @@ func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) {
|
|||||||
|
|
||||||
close(tun.unblockClose)
|
close(tun.unblockClose)
|
||||||
select {
|
select {
|
||||||
case <-closeDone:
|
case err := <-closeDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("WGIface.Close() returned an error: %v", err)
|
||||||
|
}
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("WGIface.Close() never returned after the tun was unblocked")
|
t.Fatal("WGIface.Close() never returned after the tun was unblocked")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ The profiles.txt file lists every profile JSON on disk, including profiles the r
|
|||||||
Configuration
|
Configuration
|
||||||
The config.txt file contains anonymized configuration information of the NetBird client. Sensitive information such as private keys and SSH keys are excluded. The following fields are anonymized:
|
The config.txt file contains anonymized configuration information of the NetBird client. Sensitive information such as private keys and SSH keys are excluded. The following fields are anonymized:
|
||||||
- ManagementURL
|
- ManagementURL
|
||||||
- AdminURL
|
|
||||||
- NATExternalIPs
|
- NATExternalIPs
|
||||||
- CustomDNSAddress
|
- CustomDNSAddress
|
||||||
|
|
||||||
@@ -611,9 +610,6 @@ func (g *BundleGenerator) addConfig() error {
|
|||||||
if g.internalConfig.ManagementURL != nil {
|
if g.internalConfig.ManagementURL != nil {
|
||||||
configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String())))
|
configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String())))
|
||||||
}
|
}
|
||||||
if g.internalConfig.AdminURL != nil {
|
|
||||||
configContent.WriteString(fmt.Sprintf("AdminURL: %s\n", g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.String())))
|
|
||||||
}
|
|
||||||
configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", anonymizeNATExternalIPs(g.internalConfig.NATExternalIPs, g.anonymizer)))
|
configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", anonymizeNATExternalIPs(g.internalConfig.NATExternalIPs, g.anonymizer)))
|
||||||
if g.internalConfig.CustomDNSAddress != "" {
|
if g.internalConfig.CustomDNSAddress != "" {
|
||||||
configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress)))
|
configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress)))
|
||||||
@@ -622,9 +618,6 @@ func (g *BundleGenerator) addConfig() error {
|
|||||||
if g.internalConfig.ManagementURL != nil {
|
if g.internalConfig.ManagementURL != nil {
|
||||||
configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.internalConfig.ManagementURL.String()))
|
configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.internalConfig.ManagementURL.String()))
|
||||||
}
|
}
|
||||||
if g.internalConfig.AdminURL != nil {
|
|
||||||
configContent.WriteString(fmt.Sprintf("AdminURL: %s\n", g.internalConfig.AdminURL.String()))
|
|
||||||
}
|
|
||||||
configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", g.internalConfig.NATExternalIPs))
|
configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", g.internalConfig.NATExternalIPs))
|
||||||
if g.internalConfig.CustomDNSAddress != "" {
|
if g.internalConfig.CustomDNSAddress != "" {
|
||||||
configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.internalConfig.CustomDNSAddress))
|
configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.internalConfig.CustomDNSAddress))
|
||||||
|
|||||||
@@ -858,14 +858,12 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mURL, _ := url.Parse("https://api.example.com:443")
|
mURL, _ := url.Parse("https://api.example.com:443")
|
||||||
aURL, _ := url.Parse("https://admin.example.com:443")
|
|
||||||
bTrue := true
|
bTrue := true
|
||||||
iVal := 42
|
iVal := 42
|
||||||
cfg := &profilemanager.Config{
|
cfg := &profilemanager.Config{
|
||||||
PrivateKey: "priv",
|
PrivateKey: "priv",
|
||||||
PreSharedKey: "psk",
|
PreSharedKey: "psk",
|
||||||
ManagementURL: mURL,
|
ManagementURL: mURL,
|
||||||
AdminURL: aURL,
|
|
||||||
WgIface: "wt0",
|
WgIface: "wt0",
|
||||||
WgPort: 51820,
|
WgPort: 51820,
|
||||||
NetworkMonitor: &bTrue,
|
NetworkMonitor: &bTrue,
|
||||||
@@ -949,7 +947,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderAddConfigSpecific renders the fields handled by the anonymize/non-anonymize
|
// renderAddConfigSpecific renders the fields handled by the anonymize/non-anonymize
|
||||||
// branches in addConfig (ManagementURL, AdminURL, NATExternalIPs, CustomDNSAddress).
|
// branches in addConfig (ManagementURL, NATExternalIPs, CustomDNSAddress).
|
||||||
// addCommonConfigFields covers the rest. Keeping this in the test mirrors the
|
// addCommonConfigFields covers the rest. Keeping this in the test mirrors the
|
||||||
// production shape without needing to write an actual zip.
|
// production shape without needing to write an actual zip.
|
||||||
func renderAddConfigSpecific(g *BundleGenerator) string {
|
func renderAddConfigSpecific(g *BundleGenerator) string {
|
||||||
@@ -958,9 +956,6 @@ func renderAddConfigSpecific(g *BundleGenerator) string {
|
|||||||
if g.internalConfig.ManagementURL != nil {
|
if g.internalConfig.ManagementURL != nil {
|
||||||
sb.WriteString("ManagementURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()) + "\n")
|
sb.WriteString("ManagementURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()) + "\n")
|
||||||
}
|
}
|
||||||
if g.internalConfig.AdminURL != nil {
|
|
||||||
sb.WriteString("AdminURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.String()) + "\n")
|
|
||||||
}
|
|
||||||
sb.WriteString("NATExternalIPs: x\n")
|
sb.WriteString("NATExternalIPs: x\n")
|
||||||
if g.internalConfig.CustomDNSAddress != "" {
|
if g.internalConfig.CustomDNSAddress != "" {
|
||||||
sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n")
|
sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n")
|
||||||
@@ -969,9 +964,6 @@ func renderAddConfigSpecific(g *BundleGenerator) string {
|
|||||||
if g.internalConfig.ManagementURL != nil {
|
if g.internalConfig.ManagementURL != nil {
|
||||||
sb.WriteString("ManagementURL: " + g.internalConfig.ManagementURL.String() + "\n")
|
sb.WriteString("ManagementURL: " + g.internalConfig.ManagementURL.String() + "\n")
|
||||||
}
|
}
|
||||||
if g.internalConfig.AdminURL != nil {
|
|
||||||
sb.WriteString("AdminURL: " + g.internalConfig.AdminURL.String() + "\n")
|
|
||||||
}
|
|
||||||
sb.WriteString("NATExternalIPs: x\n")
|
sb.WriteString("NATExternalIPs: x\n")
|
||||||
if g.internalConfig.CustomDNSAddress != "" {
|
if g.internalConfig.CustomDNSAddress != "" {
|
||||||
sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n")
|
sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n")
|
||||||
|
|||||||
@@ -35,11 +35,7 @@ func parseBackupResolvConf() (*resolvConf, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) {
|
func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) {
|
||||||
rconf := &resolvConf{
|
rconf := newResolvConf()
|
||||||
searchDomains: make([]string, 0),
|
|
||||||
nameServers: make([]netip.Addr, 0),
|
|
||||||
others: make([]string, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(resolvConfFile)
|
file, err := os.Open(resolvConfFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,56 +52,74 @@ func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) {
|
|||||||
return rconf, fmt.Errorf("failed to read %s file: %w", resolvConfFile, err)
|
return rconf, fmt.Errorf("failed to read %s file: %w", resolvConfFile, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return parseResolvConf(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseResolvConf parses resolv.conf content. The returned config is never nil, so callers can
|
||||||
|
// fall back to its empty lists on error.
|
||||||
|
func parseResolvConf(cur []byte) (*resolvConf, error) {
|
||||||
|
rconf := newResolvConf()
|
||||||
|
|
||||||
if len(cur) == 0 {
|
if len(cur) == 0 {
|
||||||
return rconf, fmt.Errorf("file is empty")
|
return rconf, fmt.Errorf("file is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, line := range strings.Split(string(cur), "\n") {
|
for _, line := range strings.Split(string(cur), "\n") {
|
||||||
line = strings.TrimSpace(line)
|
parseResolvConfLine(rconf, strings.TrimSpace(line))
|
||||||
|
|
||||||
if strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(line, "domain") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(line, "options") && strings.Contains(line, "rotate") {
|
|
||||||
line = strings.ReplaceAll(line, "rotate", "")
|
|
||||||
splitLines := strings.Fields(line)
|
|
||||||
if len(splitLines) == 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
line = strings.Join(splitLines, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(line, "search") {
|
|
||||||
splitLines := strings.Fields(line)
|
|
||||||
if len(splitLines) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rconf.searchDomains = splitLines[1:]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(line, "nameserver") {
|
|
||||||
splitLines := strings.Fields(line)
|
|
||||||
if len(splitLines) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if addr, err := netip.ParseAddr(splitLines[1]); err == nil {
|
|
||||||
rconf.nameServers = append(rconf.nameServers, addr.Unmap())
|
|
||||||
} else {
|
|
||||||
log.Warnf("invalid nameserver address in resolv.conf: %s, skipping", splitLines[1])
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if line != "" {
|
|
||||||
rconf.others = append(rconf.others, line)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return rconf, nil
|
return rconf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseResolvConfLine(rconf *resolvConf, line string) {
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "domain") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "options") && strings.Contains(line, "rotate") {
|
||||||
|
line = strings.ReplaceAll(line, "rotate", "")
|
||||||
|
splitLines := strings.Fields(line)
|
||||||
|
if len(splitLines) == 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line = strings.Join(splitLines, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "search") {
|
||||||
|
splitLines := strings.Fields(line)
|
||||||
|
if len(splitLines) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rconf.searchDomains = splitLines[1:]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "nameserver") {
|
||||||
|
splitLines := strings.Fields(line)
|
||||||
|
if len(splitLines) != 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(splitLines[1]); err == nil {
|
||||||
|
rconf.nameServers = append(rconf.nameServers, addr.Unmap())
|
||||||
|
} else {
|
||||||
|
log.Warnf("invalid nameserver address in resolv.conf: %s, skipping", splitLines[1])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if line != "" {
|
||||||
|
rconf.others = append(rconf.others, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResolvConf() *resolvConf {
|
||||||
|
return &resolvConf{
|
||||||
|
searchDomains: make([]string, 0),
|
||||||
|
nameServers: make([]netip.Addr, 0),
|
||||||
|
others: make([]string, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
package dns
|
package dns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -23,7 +25,8 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type repairConfFn func([]string, netip.Addr, *resolvConf, *statemanager.Manager) error
|
// repairConfFn writes the NetBird-managed resolv.conf and returns the exact content it wrote.
|
||||||
|
type repairConfFn func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error)
|
||||||
|
|
||||||
type repair struct {
|
type repair struct {
|
||||||
operationFile string
|
operationFile string
|
||||||
@@ -43,7 +46,9 @@ func newRepair(operationFile string, updateFn repairConfFn) *repair {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP netip.Addr, stateManager *statemanager.Manager) {
|
// watchFileChanges keeps resolv.conf pointing at NetBird. written is the content NetBird last
|
||||||
|
// wrote to the file, used to recognize the echo of its own writes.
|
||||||
|
func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP netip.Addr, written []byte, stateManager *statemanager.Manager) {
|
||||||
if f.inotify != nil {
|
if f.inotify != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -54,54 +59,80 @@ func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP netip
|
|||||||
log.Errorf("failed to start inotify watcher for resolv.conf: %s", err)
|
log.Errorf("failed to start inotify watcher for resolv.conf: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := inotify.Add(f.watchDir); err != nil {
|
||||||
|
log.Errorf("failed to add inotify watch for resolv.conf: %s", err)
|
||||||
|
if err := inotify.Close(); err != nil {
|
||||||
|
log.Debugf("failed to close resolv.conf inotify: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
f.inotify = inotify
|
f.inotify = inotify
|
||||||
|
|
||||||
f.inotifyWg.Add(1)
|
f.inotifyWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer f.inotifyWg.Done()
|
defer f.inotifyWg.Done()
|
||||||
for event := range f.inotify.Events {
|
|
||||||
if !f.isEventRelevant(event) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Tracef("%s changed, check if it is broken", f.operationFile)
|
// A change that landed before the watch was armed produced no event.
|
||||||
|
lastWritten := f.checkAndRepair(nbSearchDomains, nbNameserverIP, written, stateManager)
|
||||||
rConf, err := parseResolvConfFile(f.operationFile)
|
for {
|
||||||
if err != nil {
|
select {
|
||||||
log.Warnf("failed to parse resolv conf: %s", err)
|
case event, ok := <-inotify.Events:
|
||||||
continue
|
if !ok {
|
||||||
}
|
return
|
||||||
|
}
|
||||||
log.Debugf("check resolv.conf parameters: %s", rConf)
|
if !f.isEventRelevant(event) {
|
||||||
if !isNbParamsMissing(nbSearchDomains, nbNameserverIP, rConf) {
|
continue
|
||||||
log.Tracef("resolv.conf still correct, skip the update")
|
}
|
||||||
continue
|
case err, ok := <-inotify.Errors:
|
||||||
}
|
if !ok {
|
||||||
log.Info("broken params in resolv.conf, repairing it...")
|
return
|
||||||
|
}
|
||||||
err = f.inotify.Remove(f.watchDir)
|
// fsnotify stops delivering events until an error is received, and an
|
||||||
if err != nil {
|
// error such as a queue overflow means events were dropped.
|
||||||
log.Errorf("failed to rm inotify watch for resolv.conf: %s", err)
|
log.Warnf("resolv.conf inotify error, checking the file: %v", err)
|
||||||
}
|
|
||||||
|
|
||||||
err = f.updateFn(nbSearchDomains, nbNameserverIP, rConf, stateManager)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to repair resolv.conf: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = f.inotify.Add(f.watchDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to re-add inotify watch for resolv.conf: %s", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
lastWritten = f.checkAndRepair(nbSearchDomains, nbNameserverIP, lastWritten, stateManager)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
err = f.inotify.Add(f.watchDir)
|
// checkAndRepair rewrites resolv.conf when NetBird's parameters are missing from it and returns
|
||||||
|
// the content NetBird last wrote. The watch stays armed while repairing, so a third-party write
|
||||||
|
// racing the repair still raises an event; the echo of NetBird's own write is skipped by content.
|
||||||
|
func (f *repair) checkAndRepair(nbSearchDomains []string, nbNameserverIP netip.Addr, lastWritten []byte, stateManager *statemanager.Manager) []byte {
|
||||||
|
content, err := os.ReadFile(f.operationFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to add inotify watch for resolv.conf: %s", err)
|
log.Warnf("failed to read resolv.conf: %s", err)
|
||||||
return
|
return lastWritten
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bytes.Equal(content, lastWritten) {
|
||||||
|
log.Tracef("%s still holds the NetBird managed content, skip the check", f.operationFile)
|
||||||
|
return lastWritten
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Tracef("%s changed, check if it is broken", f.operationFile)
|
||||||
|
|
||||||
|
rConf, err := parseResolvConf(content)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to parse resolv conf: %s", err)
|
||||||
|
return lastWritten
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("check resolv.conf parameters: %s", rConf)
|
||||||
|
if !isNbParamsMissing(nbSearchDomains, nbNameserverIP, rConf) {
|
||||||
|
log.Tracef("resolv.conf still correct, skip the update")
|
||||||
|
return lastWritten
|
||||||
|
}
|
||||||
|
log.Info("broken params in resolv.conf, repairing it...")
|
||||||
|
|
||||||
|
written, err := f.updateFn(nbSearchDomains, nbNameserverIP, rConf, stateManager)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to repair resolv.conf: %v", err)
|
||||||
|
return lastWritten
|
||||||
|
}
|
||||||
|
return written
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *repair) stopWatchFileChanges() {
|
func (f *repair) stopWatchFileChanges() {
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ package dns
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
@@ -106,14 +113,14 @@ nameserver 8.8.8.8`,
|
|||||||
|
|
||||||
var changed bool
|
var changed bool
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) error {
|
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
|
||||||
changed = true
|
changed = true
|
||||||
cancel()
|
cancel()
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
r := newRepair(operationFile, updateFn)
|
r := newRepair(operationFile, updateFn)
|
||||||
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil)
|
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil, nil)
|
||||||
|
|
||||||
err = os.WriteFile(operationFile, []byte(tt.touchedConfContent), 0755)
|
err = os.WriteFile(operationFile, []byte(tt.touchedConfContent), 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -153,14 +160,14 @@ searchdomain netbird.cloud something`
|
|||||||
|
|
||||||
var changed bool
|
var changed bool
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) error {
|
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
|
||||||
changed = true
|
changed = true
|
||||||
cancel()
|
cancel()
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
r := newRepair(tmpLink, updateFn)
|
r := newRepair(tmpLink, updateFn)
|
||||||
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil)
|
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil, nil)
|
||||||
|
|
||||||
err = os.WriteFile(tmpLink, []byte(modifyContent), 0755)
|
err = os.WriteFile(tmpLink, []byte(modifyContent), 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -175,3 +182,149 @@ searchdomain netbird.cloud something`
|
|||||||
t.Errorf("unexpected result: want: %v, got: %v", true, false)
|
t.Errorf("unexpected result: want: %v, got: %v", true, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// systemd-resolved can rewrite resolv.conf twice in a row, e.g. on restart. When the second write
|
||||||
|
// lands right after NetBird's repair, the watcher must still see it and repair again; otherwise the
|
||||||
|
// host silently stays on the upstream resolver until the next unrelated change of the file.
|
||||||
|
func Test_repairCatchesWriteRacingRepair(t *testing.T) {
|
||||||
|
good := "nameserver 10.0.0.1\nsearch netbird.cloud\n"
|
||||||
|
upstream := "nameserver 8.8.8.8\nsearch .\n"
|
||||||
|
operationFile := filepath.Join(t.TempDir(), "resolv.conf")
|
||||||
|
require.NoError(t, os.WriteFile(operationFile, []byte(good), 0644))
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
|
||||||
|
n := calls.Add(1)
|
||||||
|
if err := os.WriteFile(operationFile, []byte(good), 0644); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
// the third party's second write lands right after the repair
|
||||||
|
if err := writeFileAtomic(operationFile, upstream); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []byte(good), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r := newRepair(operationFile, updateFn)
|
||||||
|
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), []byte(good), nil)
|
||||||
|
t.Cleanup(r.stopWatchFileChanges)
|
||||||
|
|
||||||
|
require.NoError(t, writeFileAtomic(operationFile, upstream))
|
||||||
|
|
||||||
|
assert.Eventually(t, func() bool {
|
||||||
|
content, err := os.ReadFile(operationFile)
|
||||||
|
return err == nil && string(content) == good
|
||||||
|
}, 2*time.Second, 10*time.Millisecond, "resolv.conf should be repaired after the racing write")
|
||||||
|
assert.GreaterOrEqual(t, calls.Load(), int32(2), "the racing write should trigger a second repair")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A change made between NetBird's write and the arming of the watch produces no event, so the
|
||||||
|
// watcher must check the file as soon as it starts.
|
||||||
|
func Test_repairChecksFileWhenArmed(t *testing.T) {
|
||||||
|
good := "nameserver 10.0.0.1\nsearch netbird.cloud\n"
|
||||||
|
operationFile := filepath.Join(t.TempDir(), "resolv.conf")
|
||||||
|
require.NoError(t, os.WriteFile(operationFile, []byte("nameserver 8.8.8.8\n"), 0644))
|
||||||
|
|
||||||
|
repaired := make(chan struct{}, 1)
|
||||||
|
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
|
||||||
|
select {
|
||||||
|
case repaired <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return []byte(good), os.WriteFile(operationFile, []byte(good), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := newRepair(operationFile, updateFn)
|
||||||
|
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), []byte(good), nil)
|
||||||
|
t.Cleanup(r.stopWatchFileChanges)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-repaired:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("watcher should repair a file changed before it was armed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When NetBird has more search domains than resolv.conf can hold, its own write lacks some of them
|
||||||
|
// and fails the parameter check. The watcher must recognize that write as its own and not repair
|
||||||
|
// it again in a loop.
|
||||||
|
func Test_repairNoLoopOnTruncatedSearchDomains(t *testing.T) {
|
||||||
|
nbDomains := make([]string, 0, fileMaxNumberOfSearchDomains+2)
|
||||||
|
for i := range fileMaxNumberOfSearchDomains + 2 {
|
||||||
|
nbDomains = append(nbDomains, fmt.Sprintf("d%d.netbird.cloud", i))
|
||||||
|
}
|
||||||
|
truncated := fmt.Sprintf("nameserver 10.0.0.1\nsearch %s\n", strings.Join(nbDomains[:fileMaxNumberOfSearchDomains], " "))
|
||||||
|
|
||||||
|
operationFile := filepath.Join(t.TempDir(), "resolv.conf")
|
||||||
|
require.NoError(t, os.WriteFile(operationFile, []byte(truncated), 0644))
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return []byte(truncated), os.WriteFile(operationFile, []byte(truncated), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := newRepair(operationFile, updateFn)
|
||||||
|
r.watchFileChanges(nbDomains, netip.MustParseAddr("10.0.0.1"), []byte(truncated), nil)
|
||||||
|
t.Cleanup(r.stopWatchFileChanges)
|
||||||
|
|
||||||
|
require.NoError(t, writeFileAtomic(operationFile, "nameserver 8.8.8.8\n"))
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool { return calls.Load() >= 1 }, 2*time.Second, 10*time.Millisecond,
|
||||||
|
"the third-party write should be repaired")
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
assert.Equal(t, int32(1), calls.Load(), "NetBird's own truncated write should not trigger another repair")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fsnotify blocks its reader until an error is received, and an error such as a queue overflow
|
||||||
|
// means events were dropped. The watcher must consume errors and check the file, so it keeps working
|
||||||
|
// and catches a change whose event was lost.
|
||||||
|
func Test_repairChecksFileOnWatcherError(t *testing.T) {
|
||||||
|
good := "nameserver 10.0.0.1\nsearch netbird.cloud\n"
|
||||||
|
operationFile := filepath.Join(t.TempDir(), "resolv.conf")
|
||||||
|
require.NoError(t, os.WriteFile(operationFile, []byte(good), 0644))
|
||||||
|
|
||||||
|
// A hard link in another directory changes the file without an event in the watched one,
|
||||||
|
// standing in for an event the kernel dropped.
|
||||||
|
alias := filepath.Join(t.TempDir(), "alias")
|
||||||
|
require.NoError(t, os.Link(operationFile, alias))
|
||||||
|
|
||||||
|
repaired := make(chan struct{}, 1)
|
||||||
|
updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) {
|
||||||
|
select {
|
||||||
|
case repaired <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return []byte(good), os.WriteFile(operationFile, []byte(good), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := newRepair(operationFile, updateFn)
|
||||||
|
r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), []byte(good), nil)
|
||||||
|
t.Cleanup(r.stopWatchFileChanges)
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(alias, []byte("nameserver 8.8.8.8\n"), 0644))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r.inotify.Errors <- fsnotify.ErrEventOverflow:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("watcher should consume inotify errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-repaired:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("watcher should check the file after an inotify error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileAtomic replaces the file the way systemd-resolved does: write a temporary file in the same
|
||||||
|
// directory, then rename it over the target.
|
||||||
|
func writeFileAtomic(target, content string) error {
|
||||||
|
tmp := target + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, []byte(content), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, target)
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ func (f *fileConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *st
|
|||||||
|
|
||||||
f.repair.stopWatchFileChanges()
|
f.repair.stopWatchFileChanges()
|
||||||
|
|
||||||
err = f.updateConfig(nbSearchDomains, f.nbNameserverIP, resolvConf, stateManager)
|
written, err := f.updateConfig(nbSearchDomains, f.nbNameserverIP, resolvConf, stateManager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.repair.watchFileChanges(nbSearchDomains, f.nbNameserverIP, stateManager)
|
f.repair.watchFileChanges(nbSearchDomains, f.nbNameserverIP, written, stateManager)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ func (f *fileConfigurator) getOriginalNameservers() []netip.Addr {
|
|||||||
return f.originalNameservers
|
return f.originalNameservers
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP netip.Addr, cfg *resolvConf, stateManager *statemanager.Manager) error {
|
func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP netip.Addr, cfg *resolvConf, stateManager *statemanager.Manager) ([]byte, error) {
|
||||||
searchDomainList := mergeSearchDomains(nbSearchDomains, cfg.searchDomains)
|
searchDomainList := mergeSearchDomains(nbSearchDomains, cfg.searchDomains)
|
||||||
|
|
||||||
buf := prepareResolvConfContent(
|
buf := prepareResolvConfContent(
|
||||||
@@ -90,7 +90,7 @@ func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP
|
|||||||
if restoreErr != nil {
|
if restoreErr != nil {
|
||||||
log.Errorf("attempt to restore default file failed with error: %s", err)
|
log.Errorf("attempt to restore default file failed with error: %s", err)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("creating resolver file %s. Error: %w", defaultResolvConfPath, err)
|
return nil, fmt.Errorf("creating resolver file %s. Error: %w", defaultResolvConfPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("created a NetBird managed %s file with the DNS settings. Added %d search domains. Search list: %s", defaultResolvConfPath, len(searchDomainList), searchDomainList)
|
log.Infof("created a NetBird managed %s file with the DNS settings. Added %d search domains. Search list: %s", defaultResolvConfPath, len(searchDomainList), searchDomainList)
|
||||||
@@ -100,7 +100,7 @@ func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP
|
|||||||
log.Errorf("failed to create unclean shutdown resolv.conf backup: %s", err)
|
log.Errorf("failed to create unclean shutdown resolv.conf backup: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fileConfigurator) restoreHostDNS() error {
|
func (f *fileConfigurator) restoreHostDNS() error {
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ const (
|
|||||||
DefaultManagementURL = "https://api.netbird.io:443"
|
DefaultManagementURL = "https://api.netbird.io:443"
|
||||||
// oldDefaultManagementURL points to the NetBird's old cloud management endpoint
|
// oldDefaultManagementURL points to the NetBird's old cloud management endpoint
|
||||||
oldDefaultManagementURL = "https://api.wiretrustee.com:443"
|
oldDefaultManagementURL = "https://api.wiretrustee.com:443"
|
||||||
// DefaultAdminURL points to NetBird's cloud management console
|
// DefaultAdminURL points to NetBird's cloud management console. It is only
|
||||||
|
// reported by the deprecated mobile admin URL getters.
|
||||||
DefaultAdminURL = "https://app.netbird.io:443"
|
DefaultAdminURL = "https://app.netbird.io:443"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,7 +62,6 @@ var DefaultInterfaceBlacklist = []string{
|
|||||||
// ConfigInput carries configuration changes to the client
|
// ConfigInput carries configuration changes to the client
|
||||||
type ConfigInput struct {
|
type ConfigInput struct {
|
||||||
ManagementURL string
|
ManagementURL string
|
||||||
AdminURL string
|
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
StateFilePath string
|
StateFilePath string
|
||||||
PreSharedKey *string
|
PreSharedKey *string
|
||||||
@@ -120,7 +120,6 @@ type Config struct {
|
|||||||
PrivateKey string
|
PrivateKey string
|
||||||
PreSharedKey string
|
PreSharedKey string
|
||||||
ManagementURL *url.URL
|
ManagementURL *url.URL
|
||||||
AdminURL *url.URL
|
|
||||||
WgIface string
|
WgIface string
|
||||||
WgPort int
|
WgPort int
|
||||||
NetworkMonitor *bool
|
NetworkMonitor *bool
|
||||||
@@ -520,29 +519,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.AdminURL == nil {
|
|
||||||
log.Infof("using default Admin URL %s", DefaultAdminURL)
|
|
||||||
config.AdminURL, err = parseURL("Admin URL", DefaultAdminURL)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The admin panel is opened, not dialed, so unlike the Management URL its
|
|
||||||
// path is part of what identifies it: a panel served under /netbird is not
|
|
||||||
// the one served at the root.
|
|
||||||
if input.AdminURL != "" {
|
|
||||||
newURL, err := parseURL("Admin Panel URL", input.AdminURL)
|
|
||||||
if err != nil {
|
|
||||||
return updated, err
|
|
||||||
}
|
|
||||||
if !SameServiceURLIncludingPath(newURL, config.AdminURL) {
|
|
||||||
log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)",
|
|
||||||
newURL.String(), config.AdminURL.String())
|
|
||||||
config.AdminURL = newURL
|
|
||||||
updated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.WireguardPort != nil && *input.WireguardPort != config.WgPort {
|
if input.WireguardPort != nil && *input.WireguardPort != config.WgPort {
|
||||||
log.Infof("updating Wireguard port %d (old value %d)",
|
log.Infof("updating Wireguard port %d (old value %d)",
|
||||||
*input.WireguardPort, config.WgPort)
|
*input.WireguardPort, config.WgPort)
|
||||||
@@ -1046,8 +1022,7 @@ func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
|
|||||||
// matches only another nil one.
|
// matches only another nil one.
|
||||||
//
|
//
|
||||||
// The path plays no part: a management URL is dialed, and only its host and
|
// The path plays no part: a management URL is dialed, and only its host and
|
||||||
// port are. util.SameServiceURL is this comparison plus the path, which is
|
// port are. util.SameServiceURL is this comparison plus the path.
|
||||||
// what SameServiceURLIncludingPath needs and delegates to.
|
|
||||||
func SameServiceURL(a, b *url.URL) bool {
|
func SameServiceURL(a, b *url.URL) bool {
|
||||||
if a == nil || b == nil {
|
if a == nil || b == nil {
|
||||||
return a == b
|
return a == b
|
||||||
@@ -1058,25 +1033,6 @@ func SameServiceURL(a, b *url.URL) bool {
|
|||||||
util.ServiceURLPort(a) == util.ServiceURLPort(b)
|
util.ServiceURLPort(a) == util.ServiceURLPort(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SameServiceURLIncludingPath is SameServiceURL plus everything a URL carries
|
|
||||||
// past its endpoint: path, query, fragment and userinfo.
|
|
||||||
//
|
|
||||||
// Use it for a URL that gets opened rather than dialed. The admin panel can
|
|
||||||
// live under a path, so two URLs with the same endpoint and different paths are
|
|
||||||
// two different panels — where for a URL the client dials over gRPC only the
|
|
||||||
// endpoint is ever used. Equivalent spellings still compare equal: a missing
|
|
||||||
// path and "/" are the same root, and so is a trailing slash on any path.
|
|
||||||
func SameServiceURLIncludingPath(a, b *url.URL) bool {
|
|
||||||
if a == nil || b == nil {
|
|
||||||
return a == b
|
|
||||||
}
|
|
||||||
|
|
||||||
return util.SameServiceURL(a, b) &&
|
|
||||||
a.RawQuery == b.RawQuery &&
|
|
||||||
a.Fragment == b.Fragment &&
|
|
||||||
a.User.String() == b.User.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||||
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -36,17 +36,14 @@ func TestGetConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, config.ManagementURL.String(), DefaultManagementURL)
|
assert.Equal(t, config.ManagementURL.String(), DefaultManagementURL)
|
||||||
assert.Equal(t, config.AdminURL.String(), DefaultAdminURL)
|
|
||||||
|
|
||||||
managementURL := "https://test.management.url:33071"
|
managementURL := "https://test.management.url:33071"
|
||||||
adminURL := "https://app.admin.url:443"
|
|
||||||
path := filepath.Join(t.TempDir(), "config.json")
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
preSharedKey := "preSharedKey"
|
preSharedKey := "preSharedKey"
|
||||||
|
|
||||||
// case 2: new config has to be generated
|
// case 2: new config has to be generated
|
||||||
config, err = UpdateOrCreateConfig(ConfigInput{
|
config, err = UpdateOrCreateConfig(ConfigInput{
|
||||||
ManagementURL: managementURL,
|
ManagementURL: managementURL,
|
||||||
AdminURL: adminURL,
|
|
||||||
ConfigPath: path,
|
ConfigPath: path,
|
||||||
PreSharedKey: &preSharedKey,
|
PreSharedKey: &preSharedKey,
|
||||||
})
|
})
|
||||||
@@ -64,7 +61,6 @@ func TestGetConfig(t *testing.T) {
|
|||||||
// case 3: existing config -> fetch it
|
// case 3: existing config -> fetch it
|
||||||
config, err = UpdateOrCreateConfig(ConfigInput{
|
config, err = UpdateOrCreateConfig(ConfigInput{
|
||||||
ManagementURL: managementURL,
|
ManagementURL: managementURL,
|
||||||
AdminURL: adminURL,
|
|
||||||
ConfigPath: path,
|
ConfigPath: path,
|
||||||
PreSharedKey: &preSharedKey,
|
PreSharedKey: &preSharedKey,
|
||||||
})
|
})
|
||||||
@@ -79,7 +75,6 @@ func TestGetConfig(t *testing.T) {
|
|||||||
newManagementURL := "https://test.newManagement.url:33071"
|
newManagementURL := "https://test.newManagement.url:33071"
|
||||||
config, err = UpdateOrCreateConfig(ConfigInput{
|
config, err = UpdateOrCreateConfig(ConfigInput{
|
||||||
ManagementURL: newManagementURL,
|
ManagementURL: newManagementURL,
|
||||||
AdminURL: adminURL,
|
|
||||||
ConfigPath: path,
|
ConfigPath: path,
|
||||||
PreSharedKey: &preSharedKey,
|
PreSharedKey: &preSharedKey,
|
||||||
})
|
})
|
||||||
@@ -164,7 +159,6 @@ func TestNewProfileDefaults(t *testing.T) {
|
|||||||
require.NoError(t, err, "should create new config")
|
require.NoError(t, err, "should create new config")
|
||||||
|
|
||||||
assert.Equal(t, DefaultManagementURL, config.ManagementURL.String(), "ManagementURL should have default")
|
assert.Equal(t, DefaultManagementURL, config.ManagementURL.String(), "ManagementURL should have default")
|
||||||
assert.Equal(t, DefaultAdminURL, config.AdminURL.String(), "AdminURL should have default")
|
|
||||||
assert.NotEmpty(t, config.PrivateKey, "PrivateKey should be generated")
|
assert.NotEmpty(t, config.PrivateKey, "PrivateKey should be generated")
|
||||||
assert.NotEmpty(t, config.SSHKey, "SSHKey should be generated")
|
assert.NotEmpty(t, config.SSHKey, "SSHKey should be generated")
|
||||||
assert.Equal(t, iface.WgInterfaceDefault, config.WgIface, "WgIface should have default")
|
assert.Equal(t, iface.WgInterfaceDefault, config.WgIface, "WgIface should have default")
|
||||||
|
|||||||
@@ -334,40 +334,34 @@ func TestCreateInMemoryConfigCarriesAnIdentity(t *testing.T) {
|
|||||||
require.NotEmpty(t, cfg.SSHKey)
|
require.NotEmpty(t, cfg.SSHKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin panel is opened, not dialed, so its path identifies it. Comparing
|
// Profiles written before the admin URL was dropped still carry an AdminURL key.
|
||||||
// it as a bare endpoint left a custom panel URL unable to change.
|
// It must not break loading, and the next write must drop it.
|
||||||
func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) {
|
func TestLegacyAdminURLIsIgnored(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "panel.json")
|
path := filepath.Join(t.TempDir(), "legacy.json")
|
||||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
_, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path})
|
||||||
ConfigPath: path,
|
|
||||||
AdminURL: "https://app.example.com/netbird",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg, err := GetExistingConfig(path)
|
raw, err := os.ReadFile(path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String())
|
var stored map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &stored))
|
||||||
// Equivalent spellings of the same panel are still not a change.
|
stored["AdminURL"] = json.RawMessage(`{"Scheme":"https","Host":"panel.example.com:443","Path":"/netbird"}`)
|
||||||
for _, same := range []string{
|
rewritten, err := json.Marshal(stored)
|
||||||
"https://app.example.com/netbird",
|
|
||||||
"https://app.example.com:443/netbird",
|
|
||||||
"https://app.example.com/netbird/",
|
|
||||||
"https://APP.example.com/netbird",
|
|
||||||
} {
|
|
||||||
changed, err := cfg.WouldChange(ConfigInput{AdminURL: same})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, changed, "%q is the stored panel written differently", same)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A different path is a different panel, and it must be persisted.
|
|
||||||
changed, err := cfg.WouldChange(ConfigInput{AdminURL: "https://app.example.com/other"})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, changed, "a different panel path is a change")
|
require.NoError(t, os.WriteFile(path, rewritten, 0600))
|
||||||
|
|
||||||
updated, err := UpdateConfig(ConfigInput{ConfigPath: path, AdminURL: "https://app.example.com/other"})
|
_, err = GetExistingConfig(path)
|
||||||
|
require.NoError(t, err, "a legacy AdminURL key must not prevent loading the profile")
|
||||||
|
|
||||||
|
port := 51999
|
||||||
|
_, err = UpdateConfig(ConfigInput{ConfigPath: path, WireguardPort: &port})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "https://app.example.com:443/other", updated.AdminURL.String(), "the new panel path was not persisted")
|
|
||||||
|
raw, err = os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
stored = nil
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &stored))
|
||||||
|
require.NotContains(t, stored, "AdminURL", "the legacy AdminURL key should be dropped on write")
|
||||||
}
|
}
|
||||||
|
|
||||||
// unsetOnDisk rewrites the stored config so the named fields carry a JSON null,
|
// unsetOnDisk rewrites the stored config so the named fields carry a JSON null,
|
||||||
|
|||||||
@@ -61,23 +61,17 @@ func (p *Preferences) SetManagementURL(url string) {
|
|||||||
p.configInput.ManagementURL = url
|
p.configInput.ManagementURL = url
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAdminURL read url from config file
|
// GetAdminURL returns the default admin panel URL.
|
||||||
|
//
|
||||||
|
// Deprecated: the admin URL is no longer stored or used by the client.
|
||||||
func (p *Preferences) GetAdminURL() (string, error) {
|
func (p *Preferences) GetAdminURL() (string, error) {
|
||||||
if p.configInput.AdminURL != "" {
|
return profilemanager.DefaultAdminURL, nil
|
||||||
return p.configInput.AdminURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return cfg.AdminURL.String(), err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAdminURL store the given url and wait for commit
|
// SetAdminURL is a no-op kept for API compatibility.
|
||||||
func (p *Preferences) SetAdminURL(url string) {
|
//
|
||||||
p.configInput.AdminURL = url
|
// Deprecated: the admin URL is no longer stored or used by the client.
|
||||||
}
|
func (p *Preferences) SetAdminURL(_ string) {}
|
||||||
|
|
||||||
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
||||||
// enforced by MDM; the key itself is never handed to the native layer.
|
// enforced by MDM; the key itself is never handed to the native layer.
|
||||||
|
|||||||
@@ -47,13 +47,14 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
|||||||
stateFile := filepath.Join(t.TempDir(), "state.json")
|
stateFile := filepath.Join(t.TempDir(), "state.json")
|
||||||
p := NewPreferences(cfgFile, stateFile)
|
p := NewPreferences(cfgFile, stateFile)
|
||||||
|
|
||||||
|
// The admin URL is deprecated: setting it is a no-op.
|
||||||
p.SetAdminURL(exampleString)
|
p.SetAdminURL(exampleString)
|
||||||
resp, err := p.GetAdminURL()
|
resp, err := p.GetAdminURL()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read admin url: %s", err)
|
t.Fatalf("failed to read admin url: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp != exampleString {
|
if resp != profilemanager.DefaultAdminURL {
|
||||||
t.Errorf("unexpected admin url: %s", resp)
|
t.Errorf("unexpected admin url: %s", resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ func TestPreferences_Commit(t *testing.T) {
|
|||||||
t.Fatalf("failed to read admin url: %s", err)
|
t.Fatalf("failed to read admin url: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp != exampleURL {
|
if resp != profilemanager.DefaultAdminURL {
|
||||||
t.Errorf("unexpected admin url: %s", resp)
|
t.Errorf("unexpected admin url: %s", resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -613,9 +613,6 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest, resolved
|
|||||||
if msg.ManagementUrl != "" {
|
if msg.ManagementUrl != "" {
|
||||||
config.ManagementURL = msg.ManagementUrl
|
config.ManagementURL = msg.ManagementUrl
|
||||||
}
|
}
|
||||||
if msg.AdminURL != "" {
|
|
||||||
config.AdminURL = msg.AdminURL
|
|
||||||
}
|
|
||||||
if msg.InterfaceName != nil {
|
if msg.InterfaceName != nil {
|
||||||
config.InterfaceName = msg.InterfaceName
|
config.InterfaceName = msg.InterfaceName
|
||||||
}
|
}
|
||||||
@@ -2461,7 +2458,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
|||||||
// overlay (Loader lives outside profilemanager).
|
// overlay (Loader lives outside profilemanager).
|
||||||
cfg.ApplyMDMPolicy(s.mdmLoader.Load())
|
cfg.ApplyMDMPolicy(s.mdmLoader.Load())
|
||||||
managementURL := cfg.ManagementURL
|
managementURL := cfg.ManagementURL
|
||||||
adminURL := cfg.AdminURL
|
|
||||||
|
|
||||||
var preSharedKey = cfg.PreSharedKey
|
var preSharedKey = cfg.PreSharedKey
|
||||||
if preSharedKey != "" {
|
if preSharedKey != "" {
|
||||||
@@ -2517,7 +2513,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
|||||||
return &proto.GetConfigResponse{
|
return &proto.GetConfigResponse{
|
||||||
ManagementUrl: managementURL.String(),
|
ManagementUrl: managementURL.String(),
|
||||||
PreSharedKey: preSharedKey,
|
PreSharedKey: preSharedKey,
|
||||||
AdminURL: adminURL.String(),
|
|
||||||
InterfaceName: cfg.WgIface,
|
InterfaceName: cfg.WgIface,
|
||||||
WireguardPort: int64(cfg.WgPort),
|
WireguardPort: int64(cfg.WgPort),
|
||||||
Mtu: int64(cfg.MTU),
|
Mtu: int64(cfg.MTU),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -133,7 +134,10 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.Equal(t, "https://new-api.netbird.io:443", cfg.ManagementURL.String())
|
require.Equal(t, "https://new-api.netbird.io:443", cfg.ManagementURL.String())
|
||||||
require.Equal(t, "https://new-admin.netbird.io:443", cfg.AdminURL.String())
|
// Older clients still send the deprecated admin URL; it must be ignored.
|
||||||
|
raw, err := os.ReadFile(cfgPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(raw), "new-admin.netbird.io", "deprecated AdminURL must not be persisted")
|
||||||
require.Equal(t, rosenpassEnabled, cfg.RosenpassEnabled)
|
require.Equal(t, rosenpassEnabled, cfg.RosenpassEnabled)
|
||||||
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
|
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
|
||||||
require.NotNil(t, cfg.ServerSSHAllowed)
|
require.NotNil(t, cfg.ServerSSHAllowed)
|
||||||
@@ -186,11 +190,11 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
|||||||
"CleanNATExternalIPs": true, // control flag for clearing
|
"CleanNATExternalIPs": true, // control flag for clearing
|
||||||
"CleanDNSLabels": true, // control flag for clearing
|
"CleanDNSLabels": true, // control flag for clearing
|
||||||
"LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied
|
"LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied
|
||||||
|
"AdminURL": true, // deprecated: proto field retained for compat, no longer applied
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedFields := map[string]bool{
|
expectedFields := map[string]bool{
|
||||||
"ManagementUrl": true,
|
"ManagementUrl": true,
|
||||||
"AdminURL": true,
|
|
||||||
"RosenpassEnabled": true,
|
"RosenpassEnabled": true,
|
||||||
"RosenpassPermissive": true,
|
"RosenpassPermissive": true,
|
||||||
"ServerSSHAllowed": true,
|
"ServerSSHAllowed": true,
|
||||||
@@ -253,7 +257,6 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
|||||||
// This map must be updated when adding new config-related CLI flags.
|
// This map must be updated when adding new config-related CLI flags.
|
||||||
flagToField := map[string]string{
|
flagToField := map[string]string{
|
||||||
"management-url": "ManagementUrl",
|
"management-url": "ManagementUrl",
|
||||||
"admin-url": "AdminURL",
|
|
||||||
"enable-rosenpass": "RosenpassEnabled",
|
"enable-rosenpass": "RosenpassEnabled",
|
||||||
"rosenpass-permissive": "RosenpassPermissive",
|
"rosenpass-permissive": "RosenpassPermissive",
|
||||||
"allow-server-ssh": "ServerSSHAllowed",
|
"allow-server-ssh": "ServerSSHAllowed",
|
||||||
@@ -290,6 +293,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
|||||||
fieldsWithoutCLIFlags := map[string]bool{
|
fieldsWithoutCLIFlags := map[string]bool{
|
||||||
"DisableNotifications": true, // Only settable via UI
|
"DisableNotifications": true, // Only settable via UI
|
||||||
"LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN)
|
"LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN)
|
||||||
|
"AdminURL": true, // deprecated: the admin-url flag is no longer functional
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all SetConfigRequest fields to verify our map is complete.
|
// Get all SetConfigRequest fields to verify our map is complete.
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ func (c *Client) ExecuteCommandWithIO(ctx context.Context, command string) error
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = session.Signal(ssh.SIGTERM)
|
_ = session.Signal(ssh.SIGTERM)
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case err := <-done:
|
||||||
|
log.Debugf("session ended after SIGTERM: %v", err)
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-time.After(100 * time.Millisecond):
|
case <-time.After(100 * time.Millisecond):
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -205,7 +206,8 @@ func (c *Client) ExecuteCommandWithPTY(ctx context.Context, command string) erro
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = session.Signal(ssh.SIGTERM)
|
_ = session.Signal(ssh.SIGTERM)
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case err := <-done:
|
||||||
|
log.Debugf("session ended after SIGTERM: %v", err)
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-time.After(100 * time.Millisecond):
|
case <-time.After(100 * time.Millisecond):
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ Settings.SetConfig(p: SetConfigParams): Promise<void> // partial update
|
|||||||
Settings.GetFeatures(): Promise<Features> // operator-disabled UI sections
|
Settings.GetFeatures(): Promise<Features> // operator-disabled UI sections
|
||||||
```
|
```
|
||||||
|
|
||||||
`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons.
|
`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` is always-string for historical reasons.
|
||||||
|
|
||||||
**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`.
|
**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`.
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front
|
|||||||
|
|
||||||
`Config` (read-only mirror, all required):
|
`Config` (read-only mirror, all required):
|
||||||
```ts
|
```ts
|
||||||
{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string;
|
{ managementUrl, configFile, logFile, preSharedKey, interfaceName: string;
|
||||||
wireguardPort, mtu, sshJwtCacheTtl: number;
|
wireguardPort, mtu, sshJwtCacheTtl: number;
|
||||||
disableAutoConnect, serverSshAllowed,
|
disableAutoConnect, serverSshAllowed,
|
||||||
rosenpassEnabled, rosenpassPermissive,
|
rosenpassEnabled, rosenpassPermissive,
|
||||||
@@ -254,7 +254,7 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front
|
|||||||
disableSshAuth: boolean; }
|
disableSshAuth: boolean; }
|
||||||
```
|
```
|
||||||
|
|
||||||
`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings.
|
`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` as required strings.
|
||||||
|
|
||||||
`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`.
|
`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
|
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
|
||||||
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"},
|
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"},
|
||||||
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"},
|
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"},
|
||||||
{"code": "gl", "displayName": "Galego", "englishName": "Galician"}
|
{"code": "gl", "displayName": "Galego", "englishName": "Galician"},
|
||||||
|
{"code": "he", "displayName": "עברית", "englishName": "Hebrew"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -552,12 +552,10 @@
|
|||||||
"message": "Ändern des Autostarts fehlgeschlagen"
|
"message": "Ändern des Autostarts fehlgeschlagen"
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.label": {
|
"settings.general.keepConnectedOnQuit.label": {
|
||||||
"message": "Nach dem Beenden verbunden bleiben",
|
"message": "Nach dem Beenden verbunden bleiben"
|
||||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.help": {
|
"settings.general.keepConnectedOnQuit.help": {
|
||||||
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.",
|
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen."
|
||||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
|
||||||
},
|
},
|
||||||
"settings.general.language.label": {
|
"settings.general.language.label": {
|
||||||
"message": "Anzeigesprache"
|
"message": "Anzeigesprache"
|
||||||
|
|||||||
@@ -552,12 +552,10 @@
|
|||||||
"message": "Error al cambiar el inicio automático"
|
"message": "Error al cambiar el inicio automático"
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.label": {
|
"settings.general.keepConnectedOnQuit.label": {
|
||||||
"message": "Permanecer conectado al salir",
|
"message": "Permanecer conectado al salir"
|
||||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.help": {
|
"settings.general.keepConnectedOnQuit.help": {
|
||||||
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.",
|
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú."
|
||||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
|
||||||
},
|
},
|
||||||
"settings.general.language.label": {
|
"settings.general.language.label": {
|
||||||
"message": "Idioma de la interfaz"
|
"message": "Idioma de la interfaz"
|
||||||
|
|||||||
@@ -552,12 +552,10 @@
|
|||||||
"message": "Échec de la modification du démarrage automatique"
|
"message": "Échec de la modification du démarrage automatique"
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.label": {
|
"settings.general.keepConnectedOnQuit.label": {
|
||||||
"message": "Rester connecté après la fermeture",
|
"message": "Rester connecté après la fermeture"
|
||||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.help": {
|
"settings.general.keepConnectedOnQuit.help": {
|
||||||
"message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.",
|
"message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même."
|
||||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
|
||||||
},
|
},
|
||||||
"settings.general.language.label": {
|
"settings.general.language.label": {
|
||||||
"message": "Langue d’affichage"
|
"message": "Langue d’affichage"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -552,12 +552,10 @@
|
|||||||
"message": "Az automatikus indítás módosítása sikertelen"
|
"message": "Az automatikus indítás módosítása sikertelen"
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.label": {
|
"settings.general.keepConnectedOnQuit.label": {
|
||||||
"message": "Kapcsolat megtartása kilépéskor",
|
"message": "Kapcsolat megtartása kilépéskor"
|
||||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.help": {
|
"settings.general.keepConnectedOnQuit.help": {
|
||||||
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.",
|
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod."
|
||||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
|
||||||
},
|
},
|
||||||
"settings.general.language.label": {
|
"settings.general.language.label": {
|
||||||
"message": "Megjelenítési nyelv"
|
"message": "Megjelenítési nyelv"
|
||||||
|
|||||||
@@ -552,12 +552,10 @@
|
|||||||
"message": "Modifica avvio automatico non riuscita"
|
"message": "Modifica avvio automatico non riuscita"
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.label": {
|
"settings.general.keepConnectedOnQuit.label": {
|
||||||
"message": "Resta connesso dopo la chiusura",
|
"message": "Resta connesso dopo la chiusura"
|
||||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.help": {
|
"settings.general.keepConnectedOnQuit.help": {
|
||||||
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.",
|
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu."
|
||||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
|
||||||
},
|
},
|
||||||
"settings.general.language.label": {
|
"settings.general.language.label": {
|
||||||
"message": "Lingua dell'interfaccia"
|
"message": "Lingua dell'interfaccia"
|
||||||
|
|||||||
@@ -552,12 +552,10 @@
|
|||||||
"message": "自動起動の変更に失敗しました"
|
"message": "自動起動の変更に失敗しました"
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.label": {
|
"settings.general.keepConnectedOnQuit.label": {
|
||||||
"message": "終了後も接続を維持",
|
"message": "終了後も接続を維持"
|
||||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.help": {
|
"settings.general.keepConnectedOnQuit.help": {
|
||||||
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。",
|
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。"
|
||||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
|
||||||
},
|
},
|
||||||
"settings.general.language.label": {
|
"settings.general.language.label": {
|
||||||
"message": "表示言語"
|
"message": "表示言語"
|
||||||
|
|||||||
@@ -552,12 +552,10 @@
|
|||||||
"message": "Falha ao alterar o início automático"
|
"message": "Falha ao alterar o início automático"
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.label": {
|
"settings.general.keepConnectedOnQuit.label": {
|
||||||
"message": "Permanecer conectado ao sair",
|
"message": "Permanecer conectado ao sair"
|
||||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.help": {
|
"settings.general.keepConnectedOnQuit.help": {
|
||||||
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.",
|
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta."
|
||||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
|
||||||
},
|
},
|
||||||
"settings.general.language.label": {
|
"settings.general.language.label": {
|
||||||
"message": "Idioma de exibição"
|
"message": "Idioma de exibição"
|
||||||
|
|||||||
@@ -552,12 +552,10 @@
|
|||||||
"message": "Не удалось изменить автозапуск"
|
"message": "Не удалось изменить автозапуск"
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.label": {
|
"settings.general.keepConnectedOnQuit.label": {
|
||||||
"message": "Оставаться подключённым после выхода",
|
"message": "Оставаться подключённым после выхода"
|
||||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.help": {
|
"settings.general.keepConnectedOnQuit.help": {
|
||||||
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.",
|
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами."
|
||||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
|
||||||
},
|
},
|
||||||
"settings.general.language.label": {
|
"settings.general.language.label": {
|
||||||
"message": "Язык интерфейса"
|
"message": "Язык интерфейса"
|
||||||
|
|||||||
@@ -552,12 +552,10 @@
|
|||||||
"message": "更改自启动设置失败"
|
"message": "更改自启动设置失败"
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.label": {
|
"settings.general.keepConnectedOnQuit.label": {
|
||||||
"message": "退出后保持连接",
|
"message": "退出后保持连接"
|
||||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
|
||||||
},
|
},
|
||||||
"settings.general.keepConnectedOnQuit.help": {
|
"settings.general.keepConnectedOnQuit.help": {
|
||||||
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。",
|
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。"
|
||||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
|
||||||
},
|
},
|
||||||
"settings.general.language.label": {
|
"settings.general.language.label": {
|
||||||
"message": "显示语言"
|
"message": "显示语言"
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ type ConfigParams struct {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ManagementURL string `json:"managementUrl"`
|
ManagementURL string `json:"managementUrl"`
|
||||||
AdminURL string `json:"adminUrl"`
|
|
||||||
ConfigFile string `json:"configFile"`
|
ConfigFile string `json:"configFile"`
|
||||||
LogFile string `json:"logFile"`
|
LogFile string `json:"logFile"`
|
||||||
PreSharedKeySet bool `json:"preSharedKeySet"`
|
PreSharedKeySet bool `json:"preSharedKeySet"`
|
||||||
@@ -84,7 +83,6 @@ type SetConfigParams struct {
|
|||||||
ProfileName string `json:"profileName"`
|
ProfileName string `json:"profileName"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
ManagementURL string `json:"managementUrl"`
|
ManagementURL string `json:"managementUrl"`
|
||||||
AdminURL string `json:"adminUrl"`
|
|
||||||
InterfaceName *string `json:"interfaceName,omitempty"`
|
InterfaceName *string `json:"interfaceName,omitempty"`
|
||||||
WireguardPort *int64 `json:"wireguardPort,omitempty"`
|
WireguardPort *int64 `json:"wireguardPort,omitempty"`
|
||||||
MTU *int64 `json:"mtu,omitempty"`
|
MTU *int64 `json:"mtu,omitempty"`
|
||||||
@@ -146,7 +144,6 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
|||||||
}
|
}
|
||||||
return Config{
|
return Config{
|
||||||
ManagementURL: resp.GetManagementUrl(),
|
ManagementURL: resp.GetManagementUrl(),
|
||||||
AdminURL: resp.GetAdminURL(),
|
|
||||||
ConfigFile: resp.GetConfigFile(),
|
ConfigFile: resp.GetConfigFile(),
|
||||||
LogFile: resp.GetLogFile(),
|
LogFile: resp.GetLogFile(),
|
||||||
PreSharedKeySet: resp.GetPreSharedKey() != "",
|
PreSharedKeySet: resp.GetPreSharedKey() != "",
|
||||||
@@ -183,7 +180,6 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom
|
|||||||
ProfileName: p.ProfileName,
|
ProfileName: p.ProfileName,
|
||||||
Username: p.Username,
|
Username: p.Username,
|
||||||
ManagementUrl: p.ManagementURL,
|
ManagementUrl: p.ManagementURL,
|
||||||
AdminURL: p.AdminURL,
|
|
||||||
InterfaceName: p.InterfaceName,
|
InterfaceName: p.InterfaceName,
|
||||||
WireguardPort: p.WireguardPort,
|
WireguardPort: p.WireguardPort,
|
||||||
Mtu: p.MTU,
|
Mtu: p.MTU,
|
||||||
|
|||||||
@@ -35,13 +35,9 @@ func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T
|
|||||||
|
|
||||||
func TestApplyServerStoreEnv(t *testing.T) {
|
func TestApplyServerStoreEnv(t *testing.T) {
|
||||||
t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "")
|
t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "")
|
||||||
t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "")
|
|
||||||
t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "")
|
t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "")
|
||||||
|
|
||||||
applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"})
|
applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"})
|
||||||
require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN"))
|
require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN"))
|
||||||
require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE"))
|
require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE"))
|
||||||
|
|
||||||
applyServerStoreEnv(StoreConfig{Engine: "mysql", DSN: "mysql-dsn"})
|
|
||||||
require.Equal(t, "mysql-dsn", os.Getenv("NB_STORE_ENGINE_MYSQL_DSN"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ type RelaysConfig struct {
|
|||||||
type StoreConfig struct {
|
type StoreConfig struct {
|
||||||
Engine string `yaml:"engine"`
|
Engine string `yaml:"engine"`
|
||||||
EncryptionKey string `yaml:"encryptionKey"`
|
EncryptionKey string `yaml:"encryptionKey"`
|
||||||
DSN string `yaml:"dsn"` // Connection string for postgres or mysql engines
|
DSN string `yaml:"dsn"` // Connection string for the postgres engine
|
||||||
File string `yaml:"file"` // SQLite database file path (optional, defaults to dataDir)
|
File string `yaml:"file"` // SQLite database file path (optional, defaults to dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-11
@@ -127,13 +127,8 @@ func execute(cmd *cobra.Command, _ []string) error {
|
|||||||
|
|
||||||
// initializeConfig loads and validates the configuration, then initializes logging.
|
// initializeConfig loads and validates the configuration, then initializes logging.
|
||||||
func applyServerStoreEnv(storeConfig StoreConfig) {
|
func applyServerStoreEnv(storeConfig StoreConfig) {
|
||||||
if dsn := storeConfig.DSN; dsn != "" {
|
if dsn := storeConfig.DSN; dsn != "" && strings.EqualFold(storeConfig.Engine, "postgres") {
|
||||||
switch strings.ToLower(storeConfig.Engine) {
|
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
|
||||||
case "postgres":
|
|
||||||
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
|
|
||||||
case "mysql":
|
|
||||||
os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if file := storeConfig.File; file != "" {
|
if file := storeConfig.File; file != "" {
|
||||||
os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file)
|
os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file)
|
||||||
@@ -758,7 +753,7 @@ func logAgentNetworkConfig(cfg *CombinedConfig) {
|
|||||||
|
|
||||||
// logEnvVars logs all NB_ environment variables that are currently set
|
// logEnvVars logs all NB_ environment variables that are currently set
|
||||||
func logEnvVars() {
|
func logEnvVars() {
|
||||||
log.Info("=== Environment Variables ===")
|
log.Debug("=== Environment Variables ===")
|
||||||
found := false
|
found := false
|
||||||
for _, env := range os.Environ() {
|
for _, env := range os.Environ() {
|
||||||
if strings.HasPrefix(env, "NB_") {
|
if strings.HasPrefix(env, "NB_") {
|
||||||
@@ -770,14 +765,14 @@ func logEnvVars() {
|
|||||||
} else if strings.Contains(keyLower, "dsn") {
|
} else if strings.Contains(keyLower, "dsn") {
|
||||||
value = maskDSNPassword(value)
|
value = maskDSNPassword(value)
|
||||||
}
|
}
|
||||||
log.Infof(" %s=%s", key, value)
|
log.Debugf(" %s=%s", key, value)
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
log.Info(" (none set)")
|
log.Debug(" (none set)")
|
||||||
}
|
}
|
||||||
log.Info("=== End Environment Variables ===")
|
log.Debug("=== End Environment Variables ===")
|
||||||
}
|
}
|
||||||
|
|
||||||
// maskDSNPassword masks the password in a DSN string.
|
// maskDSNPassword masks the password in a DSN string.
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ server:
|
|||||||
|
|
||||||
# Store configuration
|
# Store configuration
|
||||||
store:
|
store:
|
||||||
engine: "sqlite" # sqlite, postgres, or mysql
|
engine: "sqlite" # sqlite or postgres
|
||||||
dsn: "" # Connection string for postgres or mysql
|
dsn: "" # Connection string for postgres
|
||||||
encryptionKey: ""
|
encryptionKey: ""
|
||||||
# file: "" # Custom SQLite file path (optional, defaults to {dataDir}/store.db)
|
# file: "" # Custom SQLite file path (optional, defaults to {dataDir}/store.db)
|
||||||
|
|
||||||
|
|||||||
@@ -64,17 +64,17 @@ Reads decrypt provider secrets in-place; writes do `provider.Copy().EncryptSensi
|
|||||||
|
|
||||||
### Correctness
|
### Correctness
|
||||||
- `SaveAgentNetworkProvider` saves the copy (sql_store_agentnetwork.go:95). The caller's in-memory pointer therefore keeps plaintext `api_key` and any `CreatedAt`/`UpdatedAt` gorm autofills land on the copy, not the original. Callers that need synced timestamps must re-fetch.
|
- `SaveAgentNetworkProvider` saves the copy (sql_store_agentnetwork.go:95). The caller's in-memory pointer therefore keeps plaintext `api_key` and any `CreatedAt`/`UpdatedAt` gorm autofills land on the copy, not the original. Callers that need synced timestamps must re-fetch.
|
||||||
- `IncrementAgentNetworkConsumption`'s `Create` provides initial counter values (`TokensInput: tokensIn`, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres, mysql) silently rejects the `OnConflict` clause — GORM emits engine-specific SQL but `ON DUPLICATE KEY UPDATE` (mysql) vs `ON CONFLICT (...)` (sqlite/postgres) need their unique constraint to match the composite PK on `agent_network_consumption`; it does, by construction.
|
- `IncrementAgentNetworkConsumption`'s `Create` provides initial counter values (`TokensInput: tokensIn`, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres) silently rejects the `OnConflict` clause — GORM emits `ON CONFLICT (...)`, which needs its unique constraint to match the composite PK on `agent_network_consumption`; it does, by construction.
|
||||||
- `IncrementAgentNetworkConsumption` writes `updated_at: time.Now().UTC()` literally inside the assignments map (sql_store_agentnetwork.go:333) — fine, but it's a Go-side timestamp captured at call time, not a DB-side `now()`. Acceptable for an audit field.
|
- `IncrementAgentNetworkConsumption` writes `updated_at: time.Now().UTC()` literally inside the assignments map (sql_store_agentnetwork.go:333) — fine, but it's a Go-side timestamp captured at call time, not a DB-side `now()`. Acceptable for an audit field.
|
||||||
- `GetAgentNetworkConsumption` returns a zero-valued non-nil row on `ErrRecordNotFound` (sql_store_agentnetwork.go:364-371). Document or rename — a typed sentinel error would be more orthodox; callers must know not to error-check.
|
- `GetAgentNetworkConsumption` returns a zero-valued non-nil row on `ErrRecordNotFound` (sql_store_agentnetwork.go:364-371). Document or rename — a typed sentinel error would be more orthodox; callers must know not to error-check.
|
||||||
|
|
||||||
### Concurrency / transactions
|
### Concurrency / transactions
|
||||||
- Hot-path `IncrementAgentNetworkConsumption` runs outside any explicit transaction; concurrency safety relies entirely on the DB serialising the `ON CONFLICT` upsert against the composite PK. This is correct for postgres and mysql; for sqlite it serialises behind the single writer.
|
- Hot-path `IncrementAgentNetworkConsumption` runs outside any explicit transaction; concurrency safety relies entirely on the DB serialising the `ON CONFLICT` upsert against the composite PK. This is correct for postgres; for sqlite it serialises behind the single writer.
|
||||||
- `SaveAgentNetworkSettings` is a blind upsert with no version/etag — concurrent writes from two operators last-write-wins on the collection-toggle flags (settings.go:23-25). Acceptable for admin-curated state but worth flagging.
|
- `SaveAgentNetworkSettings` is a blind upsert with no version/etag — concurrent writes from two operators last-write-wins on the collection-toggle flags (settings.go:23-25). Acceptable for admin-curated state but worth flagging.
|
||||||
- `Save*Provider` uses `db.Save` on a struct with a PK already set — GORM emits UPDATE or INSERT based on row existence. No upsert clause is attached, so a race between two creates with the same generated `xid` (vanishingly unlikely) would surface as a PK violation.
|
- `Save*Provider` uses `db.Save` on a struct with a PK already set — GORM emits UPDATE or INSERT based on row existence. No upsert clause is attached, so a race between two creates with the same generated `xid` (vanishingly unlikely) would surface as a PK violation.
|
||||||
|
|
||||||
### Migration safety
|
### Migration safety
|
||||||
- All six tables ride `AutoMigrate` (sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Three `bool` columns on `agent_network_settings` (`EnableLogCollection`, `EnablePromptCollection`, `RedactPii`) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres/mysql produce the same default.
|
- All six tables ride `AutoMigrate` (sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Three `bool` columns on `agent_network_settings` (`EnableLogCollection`, `EnablePromptCollection`, `RedactPii`) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres produces the same default.
|
||||||
- The named index `idx_agent_network_settings_cluster_subdomain` on settings.go:15 is declared on only `subdomain`. Either the cluster column also needs `gorm:"index:idx_agent_network_settings_cluster_subdomain"` to make it composite, or the name is misleading.
|
- The named index `idx_agent_network_settings_cluster_subdomain` on settings.go:15 is declared on only `subdomain`. Either the cluster column also needs `gorm:"index:idx_agent_network_settings_cluster_subdomain"` to make it composite, or the name is misleading.
|
||||||
- The named index `idx_agent_network_provider` on `Provider.ProviderID` (provider.go:30) is *not* unique and not scoped to account — two providers in the same account with the same `provider_id` are permitted at the DB layer; uniqueness, if any, must live above the store.
|
- The named index `idx_agent_network_provider` on `Provider.ProviderID` (provider.go:30) is *not* unique and not scoped to account — two providers in the same account with the same `provider_id` are permitted at the DB layer; uniqueness, if any, must live above the store.
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ Gap: there is no store-level test for providers (encryption round-trip), policie
|
|||||||
|
|
||||||
- No retention / GC for `agent_network_consumption`.
|
- No retention / GC for `agent_network_consumption`.
|
||||||
- No `Delete` for `Settings` (one row per account, cleared with the account).
|
- No `Delete` for `Settings` (one row per account, cleared with the account).
|
||||||
- No DB-engine-specific tuning — the same struct tags drive sqlite, mysql, postgres.
|
- No DB-engine-specific tuning — the same struct tags drive sqlite and postgres.
|
||||||
- Provider `extra_values` and `models` are JSON blobs; querying inside them is not supported by design.
|
- Provider `extra_values` and `models` are JSON blobs; querying inside them is not supported by design.
|
||||||
- `GetAgentNetworkConsumption` "not-found = zero row" contract is convenient but unconventional.
|
- `GetAgentNetworkConsumption` "not-found = zero row" contract is convenient but unconventional.
|
||||||
|
|
||||||
|
|||||||
+29
-5
@@ -65,11 +65,7 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie
|
|||||||
opt(&o)
|
opt(&o)
|
||||||
}
|
}
|
||||||
|
|
||||||
root, err := repoRoot(ctx)
|
clientImage, err := ClientImage(ctx)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
clientImage, err := resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -105,6 +101,34 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie
|
|||||||
return &Client{container: ctr, name: o.name}, nil
|
return &Client{container: ctr, name: o.name}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClientImage returns the client image StartClient runs, building it from this
|
||||||
|
// module's source unless NB_E2E_CLIENT_IMAGE names a published one. A suite that
|
||||||
|
// runs the agent outside a harness container, such as in a Kubernetes cluster,
|
||||||
|
// uses it to get the same build.
|
||||||
|
func ClientImage(ctx context.Context) (string, error) {
|
||||||
|
root, err := repoRoot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec runs a command inside the client container and returns its exit code and
|
||||||
|
// combined output. It reaches whatever the agent routes, so a suite can check
|
||||||
|
// access the request helpers here do not model, such as plain HTTP to a routed
|
||||||
|
// address.
|
||||||
|
func (cl *Client) Exec(ctx context.Context, cmd ...string) (int, string, error) {
|
||||||
|
code, reader, err := cl.container.Exec(ctx, cmd, tcexec.Multiplexed())
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("exec %v: %w", cmd, err)
|
||||||
|
}
|
||||||
|
out, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return code, string(out), fmt.Errorf("read output of %v: %w", cmd, err)
|
||||||
|
}
|
||||||
|
return code, string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Hostname returns the container hostname the agent reports to management —
|
// Hostname returns the container hostname the agent reports to management —
|
||||||
// the name the registered peer appears under in the peers API.
|
// the name the registered peer appears under in the peers API.
|
||||||
func (cl *Client) Hostname() string {
|
func (cl *Client) Hostname() string {
|
||||||
|
|||||||
@@ -332,6 +332,14 @@ func (c *Combined) Restart(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NetworkName returns the Docker network the deployment runs on. Peers reach
|
||||||
|
// management, signal and relay at the combined server's alias on this network
|
||||||
|
// only, so a suite running agents outside the harness, such as in a Kind
|
||||||
|
// cluster, attaches them here.
|
||||||
|
func (c *Combined) NetworkName() string {
|
||||||
|
return c.network.Name
|
||||||
|
}
|
||||||
|
|
||||||
// Logs returns the combined server container logs, for diagnostics.
|
// Logs returns the combined server container logs, for diagnostics.
|
||||||
func (c *Combined) Logs(ctx context.Context) string {
|
func (c *Combined) Logs(ctx context.Context) string {
|
||||||
return containerLogs(ctx, c.container)
|
return containerLogs(ctx, c.container)
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ require (
|
|||||||
github.com/pkg/sftp v1.13.9
|
github.com/pkg/sftp v1.13.9
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/prometheus/client_model v0.6.2
|
github.com/prometheus/client_model v0.6.2
|
||||||
github.com/quic-go/quic-go v0.62.0
|
github.com/quic-go/quic-go v0.64.0
|
||||||
github.com/redis/go-redis/v9 v9.7.3
|
github.com/redis/go-redis/v9 v9.7.3
|
||||||
github.com/rs/xid v1.3.0
|
github.com/rs/xid v1.3.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.8
|
github.com/shirou/gopsutil/v4 v4.25.8
|
||||||
@@ -113,7 +113,6 @@ require (
|
|||||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||||
github.com/stretchr/testify v1.12.1
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/testcontainers/testcontainers-go v0.37.0
|
github.com/testcontainers/testcontainers-go v0.37.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0
|
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/redis v0.37.0
|
github.com/testcontainers/testcontainers-go/modules/redis v0.37.0
|
||||||
github.com/things-go/go-socks5 v0.0.4
|
github.com/things-go/go-socks5 v0.0.4
|
||||||
@@ -142,7 +141,6 @@ require (
|
|||||||
google.golang.org/api v0.276.0
|
google.golang.org/api v0.276.0
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/mysql v1.5.7
|
|
||||||
gorm.io/driver/postgres v1.5.7
|
gorm.io/driver/postgres v1.5.7
|
||||||
gorm.io/driver/sqlite v1.5.7
|
gorm.io/driver/sqlite v1.5.7
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
@@ -369,7 +367,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024
|
|||||||
|
|
||||||
replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949
|
replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949
|
||||||
|
|
||||||
replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9
|
replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0
|
||||||
|
|
||||||
replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6
|
replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6
|
||||||
|
|
||||||
|
|||||||
@@ -252,7 +252,6 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg
|
|||||||
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
|
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
|
||||||
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
|
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
|
||||||
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
|
||||||
@@ -541,8 +540,8 @@ github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470
|
|||||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d h1:DV0FHFhNAaU69JHXKclYQWr/ZJoc2BA/ZpqlnX8l22U=
|
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d h1:DV0FHFhNAaU69JHXKclYQWr/ZJoc2BA/ZpqlnX8l22U=
|
||||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU=
|
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU=
|
||||||
github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9 h1:Mjt3tIBGa1/mJh1Xn8uZP30cd2VYd0NHANqTn+HBsaU=
|
github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0 h1:nCK1YzDybjbAfrx+C2QPmcQ+7jYj4Rcb3FskqTNZEXo=
|
||||||
github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||||
@@ -647,8 +646,8 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4
|
|||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8=
|
github.com/quic-go/quic-go v0.64.0 h1:dlUpiyp9+7UrbbuhLvV7CJ9F8uoZxXf/vi2h8tkuuoA=
|
||||||
github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
|
github.com/quic-go/quic-go v0.64.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
|
||||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||||
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
@@ -721,8 +720,6 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
|
|||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
|
github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
|
||||||
github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
|
github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
|
||||||
github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI=
|
|
||||||
github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc=
|
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc=
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc=
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc=
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc=
|
||||||
github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 h1:9HIY28I9ME/Zmb+zey1p/I1mto5+5ch0wLX+nJdOsQ4=
|
github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 h1:9HIY28I9ME/Zmb+zey1p/I1mto5+5ch0wLX+nJdOsQ4=
|
||||||
@@ -1025,13 +1022,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
|
||||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
|
||||||
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
|
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
|
||||||
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
|
|||||||
@@ -53,18 +53,6 @@ if [[ "$NETBIRD_STORE_CONFIG_ENGINE" == "postgres" ]]; then
|
|||||||
export NETBIRD_STORE_ENGINE_POSTGRES_DSN
|
export NETBIRD_STORE_ENGINE_POSTGRES_DSN
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if MySQL is set as the store engine
|
|
||||||
if [[ "$NETBIRD_STORE_CONFIG_ENGINE" == "mysql" ]]; then
|
|
||||||
# Exit if 'NETBIRD_STORE_ENGINE_MYSQL_DSN' is not set
|
|
||||||
if [[ -z "$NETBIRD_STORE_ENGINE_MYSQL_DSN" ]]; then
|
|
||||||
echo "Warning: NETBIRD_STORE_CONFIG_ENGINE=mysql but NETBIRD_STORE_ENGINE_MYSQL_DSN is not set."
|
|
||||||
echo "Please add the following line to your setup.env file:"
|
|
||||||
echo 'NETBIRD_STORE_ENGINE_MYSQL_DSN="<username>:<password>@tcp(127.0.0.1:3306)/<database>"'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
export NETBIRD_STORE_ENGINE_MYSQL_DSN
|
|
||||||
fi
|
|
||||||
|
|
||||||
# local development or tests
|
# local development or tests
|
||||||
if [[ $NETBIRD_DOMAIN == "localhost" || $NETBIRD_DOMAIN == "127.0.0.1" ]]; then
|
if [[ $NETBIRD_DOMAIN == "localhost" || $NETBIRD_DOMAIN == "127.0.0.1" ]]; then
|
||||||
export NETBIRD_MGMT_SINGLE_ACCOUNT_MODE_DOMAIN="netbird.selfhosted"
|
export NETBIRD_MGMT_SINGLE_ACCOUNT_MODE_DOMAIN="netbird.selfhosted"
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ services:
|
|||||||
]
|
]
|
||||||
environment:
|
environment:
|
||||||
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN
|
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN
|
||||||
- NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN
|
|
||||||
|
|
||||||
# Coturn
|
# Coturn
|
||||||
coturn:
|
coturn:
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ services:
|
|||||||
- traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c
|
- traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c
|
||||||
environment:
|
environment:
|
||||||
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN
|
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN
|
||||||
- NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN
|
|
||||||
|
|
||||||
# Coturn
|
# Coturn
|
||||||
coturn:
|
coturn:
|
||||||
|
|||||||
@@ -855,7 +855,7 @@ configure_sqlite_store() {
|
|||||||
POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||||
}
|
}
|
||||||
|
|
||||||
# mysql, or something this script has never seen. Swapping the images is still
|
# An engine this script has never seen. Swapping the images is still
|
||||||
# valid; touching the store is not.
|
# valid; touching the store is not.
|
||||||
configure_unsupported_store() {
|
configure_unsupported_store() {
|
||||||
MIGRATE_POSTGRES="no"
|
MIGRATE_POSTGRES="no"
|
||||||
|
|||||||
@@ -472,12 +472,6 @@ detect_store_config() {
|
|||||||
if [[ -n "$pg_dsn" ]]; then
|
if [[ -n "$pg_dsn" ]]; then
|
||||||
STORE_DSN="$pg_dsn"
|
STORE_DSN="$pg_dsn"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local mysql_dsn
|
|
||||||
mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "")
|
|
||||||
if [[ -n "$mysql_dsn" ]]; then
|
|
||||||
STORE_DSN="$mysql_dsn"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Also check base.setup.env
|
# Also check base.setup.env
|
||||||
@@ -487,12 +481,6 @@ detect_store_config() {
|
|||||||
if [[ -n "$pg_dsn" ]]; then
|
if [[ -n "$pg_dsn" ]]; then
|
||||||
STORE_DSN="$pg_dsn"
|
STORE_DSN="$pg_dsn"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local mysql_dsn
|
|
||||||
mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "")
|
|
||||||
if [[ -n "$mysql_dsn" ]]; then
|
|
||||||
STORE_DSN="$mysql_dsn"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "Store engine: $STORE_ENGINE"
|
log_success "Store engine: $STORE_ENGINE"
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ func TestGetAgentConfigForUser_RealStore(t *testing.T) {
|
|||||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||||
|
|
||||||
// users.account_id is a foreign key into accounts, enforced on
|
// users.account_id is a foreign key into accounts, enforced on
|
||||||
// MySQL/Postgres, so the account row must exist before its users.
|
// Postgres, so the account row must exist before its users.
|
||||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
||||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||||
Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
|
Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
|
||||||
|
|||||||
@@ -1277,7 +1277,6 @@ func isUniqueConstraintError(err error) bool {
|
|||||||
}
|
}
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres
|
return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres
|
||||||
strings.Contains(msg, "Error 1062 (23000)") || // mysql
|
|
||||||
strings.Contains(msg, "UNIQUE constraint failed") // sqlite
|
strings.Contains(msg, "UNIQUE constraint failed") // sqlite
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,8 @@ func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) {
|
|||||||
// keeps the account-wide view with full config.
|
// keeps the account-wide view with full config.
|
||||||
|
|
||||||
// newSelfScopeStore seeds the account and its users only, so each test
|
// newSelfScopeStore seeds the account and its users only, so each test
|
||||||
// declares exactly the providers and policies it asserts on — the store
|
// declares exactly the providers and policies it asserts on, so tests never
|
||||||
// rejects re-saving a policy id on MySQL, so tests never overwrite each
|
// overwrite each other's rows.
|
||||||
// other's rows.
|
|
||||||
func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) {
|
func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mgr, s := newAgentConfigTestMgr(t)
|
mgr, s := newAgentConfigTestMgr(t)
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ type Settings struct {
|
|||||||
AccountID string `gorm:"primaryKey"`
|
AccountID string `gorm:"primaryKey"`
|
||||||
|
|
||||||
// Domain is the gateway endpoint hostname agents call. Globally unique
|
// Domain is the gateway endpoint hostname agents call. Globally unique
|
||||||
// across accounts. Sized explicitly because MySQL cannot index an
|
// across accounts. 255 covers the RFC 1035 253-octet bound.
|
||||||
// unbounded TEXT column; 255 covers the RFC 1035 253-octet bound.
|
|
||||||
Domain string `gorm:"type:varchar(255);uniqueIndex:idx_agent_network_settings_domain"`
|
Domain string `gorm:"type:varchar(255);uniqueIndex:idx_agent_network_settings_domain"`
|
||||||
|
|
||||||
// ProxyAddress is the declared cluster address of the proxy serving this
|
// ProxyAddress is the declared cluster address of the proxy serving this
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func bucketStart(t time.Time, g UsageGranularity) time.Time {
|
|||||||
// AggregateUsageByGranularity buckets the usage rows by the requested
|
// AggregateUsageByGranularity buckets the usage rows by the requested
|
||||||
// granularity and returns the buckets ordered oldest-first. Aggregation is done
|
// granularity and returns the buckets ordered oldest-first. Aggregation is done
|
||||||
// in Go (rather than per-engine SQL date_trunc) so granularities stay portable
|
// in Go (rather than per-engine SQL date_trunc) so granularities stay portable
|
||||||
// across SQLite/Postgres/MySQL and easy to extend.
|
// across SQLite and Postgres and easy to extend.
|
||||||
func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) []*AgentNetworkUsageBucket {
|
func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) []*AgentNetworkUsageBucket {
|
||||||
byPeriod := make(map[string]*AgentNetworkUsageBucket)
|
byPeriod := make(map[string]*AgentNetworkUsageBucket)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ import (
|
|||||||
type Manager interface {
|
type Manager interface {
|
||||||
GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error)
|
GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error)
|
||||||
GetPeerAccountID(ctx context.Context, peerID string) (string, error)
|
GetPeerAccountID(ctx context.Context, peerID string) (string, error)
|
||||||
GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error)
|
|
||||||
GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error)
|
GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error)
|
||||||
|
GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error)
|
||||||
DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error
|
DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error
|
||||||
SetNetworkMapController(networkMapController network_map.Controller)
|
SetNetworkMapController(networkMapController network_map.Controller)
|
||||||
SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator)
|
SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator)
|
||||||
@@ -51,8 +51,7 @@ type managerImpl struct {
|
|||||||
permissionsManager permissions.Manager
|
permissionsManager permissions.Manager
|
||||||
integratedPeerValidator integrated_validator.IntegratedValidator
|
integratedPeerValidator integrated_validator.IntegratedValidator
|
||||||
accountManager account.Manager
|
accountManager account.Manager
|
||||||
|
networkMapController network_map.Controller
|
||||||
networkMapController network_map.Controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(store store.Store, permissionsManager permissions.Manager) Manager {
|
func NewManager(store store.Store, permissionsManager permissions.Manager) Manager {
|
||||||
@@ -87,19 +86,6 @@ func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID str
|
|||||||
return m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
|
return m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) {
|
|
||||||
allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) {
|
func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) {
|
||||||
return m.store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID)
|
return m.store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID)
|
||||||
}
|
}
|
||||||
@@ -108,6 +94,35 @@ func (m *managerImpl) GetPeersByGroupIDs(ctx context.Context, accountID string,
|
|||||||
return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs)
|
return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *managerImpl) GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error) {
|
||||||
|
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, status.NewPermissionValidationError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowed {
|
||||||
|
return m.store.GetAccountPeersPaginated(ctx, store.LockingStrengthNone, accountID, pagination, filters, sorting)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to get account settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.IsRestrictable() && settings.RegularUsersViewBlocked {
|
||||||
|
return []*peer.Peer{}, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
peers, err := m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
|
||||||
|
|
||||||
|
return peers, len(peers), err
|
||||||
|
}
|
||||||
|
|
||||||
// GetPeerByTunnelIP delegates to the store's indexed lookup.
|
// GetPeerByTunnelIP delegates to the store's indexed lookup.
|
||||||
func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) {
|
func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) {
|
||||||
return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip)
|
return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
account "github.com/netbirdio/netbird/management/server/account"
|
account "github.com/netbirdio/netbird/management/server/account"
|
||||||
integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
|
integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
|
||||||
peer "github.com/netbirdio/netbird/management/server/peer"
|
peer "github.com/netbirdio/netbird/management/server/peer"
|
||||||
|
store "github.com/netbirdio/netbird/management/server/store"
|
||||||
types "github.com/netbirdio/netbird/management/server/types"
|
types "github.com/netbirdio/netbird/management/server/types"
|
||||||
gomock "go.uber.org/mock/gomock"
|
gomock "go.uber.org/mock/gomock"
|
||||||
)
|
)
|
||||||
@@ -74,21 +75,6 @@ func (mr *MockManagerMockRecorder) DeletePeers(ctx, accountID, peerIDs, userID,
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeers", reflect.TypeOf((*MockManager)(nil).DeletePeers), ctx, accountID, peerIDs, userID, checkConnected)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeers", reflect.TypeOf((*MockManager)(nil).DeletePeers), ctx, accountID, peerIDs, userID, checkConnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllPeers mocks base method.
|
|
||||||
func (m *MockManager) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) {
|
|
||||||
m.ctrl.T.Helper()
|
|
||||||
ret := m.ctrl.Call(m, "GetAllPeers", ctx, accountID, userID)
|
|
||||||
ret0, _ := ret[0].([]*peer.Peer)
|
|
||||||
ret1, _ := ret[1].(error)
|
|
||||||
return ret0, ret1
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllPeers indicates an expected call of GetAllPeers.
|
|
||||||
func (mr *MockManagerMockRecorder) GetAllPeers(ctx, accountID, userID any) *gomock.Call {
|
|
||||||
mr.mock.ctrl.T.Helper()
|
|
||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeers", reflect.TypeOf((*MockManager)(nil).GetAllPeers), ctx, accountID, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPeer mocks base method.
|
// GetPeer mocks base method.
|
||||||
func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) {
|
func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
@@ -180,6 +166,22 @@ func (mr *MockManagerMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupsIDs
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockManager)(nil).GetPeersByGroupIDs), ctx, accountID, groupsIDs)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockManager)(nil).GetPeersByGroupIDs), ctx, accountID, groupsIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPeersPaginated mocks base method.
|
||||||
|
func (m *MockManager) GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetPeersPaginated", ctx, accountID, userID, pagination, filters, sorting)
|
||||||
|
ret0, _ := ret[0].([]*peer.Peer)
|
||||||
|
ret1, _ := ret[1].(int)
|
||||||
|
ret2, _ := ret[2].(error)
|
||||||
|
return ret0, ret1, ret2
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPeersPaginated indicates an expected call of GetPeersPaginated.
|
||||||
|
func (mr *MockManagerMockRecorder) GetPeersPaginated(ctx, accountID, userID, pagination, filters, sorting any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersPaginated", reflect.TypeOf((*MockManager)(nil).GetPeersPaginated), ctx, accountID, userID, pagination, filters, sorting)
|
||||||
|
}
|
||||||
|
|
||||||
// SetAccountManager mocks base method.
|
// SetAccountManager mocks base method.
|
||||||
func (m *MockManager) SetAccountManager(accountManager account.Manager) {
|
func (m *MockManager) SetAccountManager(accountManager account.Manager) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package peers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/server/peer"
|
||||||
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
|
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||||
|
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||||
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
|
"github.com/netbirdio/netbird/management/server/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManager_GetPeersPaginated_HappyPath(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
// GetUserByUserID
|
||||||
|
// GetAccountPeersPaginated
|
||||||
|
// GetAccountSettings
|
||||||
|
// GetUserPeers
|
||||||
|
mockStore := store.NewMockStore(ctrl)
|
||||||
|
// ValidateUserPermissions
|
||||||
|
permissionsManager := permissions.NewMockManager(ctrl)
|
||||||
|
|
||||||
|
m := NewManager(mockStore, permissionsManager)
|
||||||
|
|
||||||
|
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return(&types.User{
|
||||||
|
Id: "test-user",
|
||||||
|
}, nil)
|
||||||
|
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
|
||||||
|
Return(true, context.Background(), nil)
|
||||||
|
mockStore.EXPECT().GetAccountPeersPaginated(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"),
|
||||||
|
gomock.Eq(store.PaginationState{Page: 11, PageSize: 111}), gomock.Eq(store.PeerFilters{UserId: "test-user"}),
|
||||||
|
gomock.Eq(store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})).Times(1).
|
||||||
|
Return([]*peer.Peer{{ID: "test-peer"}}, 10, nil)
|
||||||
|
|
||||||
|
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
|
||||||
|
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
|
||||||
|
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
|
||||||
|
|
||||||
|
assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers)
|
||||||
|
assert.Equal(t, 10, total)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_GetPeersPaginated_GetUserWithError(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockStore := store.NewMockStore(ctrl)
|
||||||
|
permissionsManager := permissions.NewMockManager(ctrl)
|
||||||
|
|
||||||
|
m := NewManager(mockStore, permissionsManager)
|
||||||
|
|
||||||
|
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return(nil, fmt.Errorf("boom!"))
|
||||||
|
|
||||||
|
_, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
|
||||||
|
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
|
||||||
|
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
|
||||||
|
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.ErrorContains(t, err, "boom!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_GetPeersPaginated_NoPermission(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockStore := store.NewMockStore(ctrl)
|
||||||
|
permissionsManager := permissions.NewMockManager(ctrl)
|
||||||
|
|
||||||
|
m := NewManager(mockStore, permissionsManager)
|
||||||
|
|
||||||
|
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return(&types.User{
|
||||||
|
Id: "test-user",
|
||||||
|
}, nil)
|
||||||
|
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
|
||||||
|
Return(false, context.Background(), nil)
|
||||||
|
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
|
||||||
|
Return(&types.Settings{RegularUsersViewBlocked: false}, nil)
|
||||||
|
mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return([]*peer.Peer{{ID: "test-peer"}}, nil)
|
||||||
|
|
||||||
|
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
|
||||||
|
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
|
||||||
|
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
|
||||||
|
|
||||||
|
assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers)
|
||||||
|
assert.Equal(t, 1, total)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_GetPeersPaginated_NoPermission_GetUserPeers_WithError(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockStore := store.NewMockStore(ctrl)
|
||||||
|
permissionsManager := permissions.NewMockManager(ctrl)
|
||||||
|
|
||||||
|
m := NewManager(mockStore, permissionsManager)
|
||||||
|
|
||||||
|
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return(&types.User{
|
||||||
|
Id: "test-user",
|
||||||
|
}, nil)
|
||||||
|
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
|
||||||
|
Return(false, context.Background(), nil)
|
||||||
|
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
|
||||||
|
Return(&types.Settings{RegularUsersViewBlocked: false}, nil)
|
||||||
|
mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return(nil, fmt.Errorf("boom!"))
|
||||||
|
|
||||||
|
_, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
|
||||||
|
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
|
||||||
|
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
|
||||||
|
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.ErrorContains(t, err, "boom!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_GetPeersPaginated_GetAccountSettings_WithError(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockStore := store.NewMockStore(ctrl)
|
||||||
|
permissionsManager := permissions.NewMockManager(ctrl)
|
||||||
|
|
||||||
|
m := NewManager(mockStore, permissionsManager)
|
||||||
|
|
||||||
|
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return(&types.User{
|
||||||
|
Id: "test-user",
|
||||||
|
}, nil)
|
||||||
|
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
|
||||||
|
Return(false, context.Background(), nil)
|
||||||
|
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
|
||||||
|
Return(nil, fmt.Errorf("boom!"))
|
||||||
|
|
||||||
|
_, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
|
||||||
|
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
|
||||||
|
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
|
||||||
|
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.ErrorContains(t, err, "boom!")
|
||||||
|
}
|
||||||
|
func TestManager_GetPeersPaginated_NoPermission_User_IsRestrictable_ViewUnblocked(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockStore := store.NewMockStore(ctrl)
|
||||||
|
permissionsManager := permissions.NewMockManager(ctrl)
|
||||||
|
|
||||||
|
m := NewManager(mockStore, permissionsManager)
|
||||||
|
|
||||||
|
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return(&types.User{
|
||||||
|
Id: "test-user",
|
||||||
|
Role: types.UserRoleUser,
|
||||||
|
}, nil)
|
||||||
|
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
|
||||||
|
Return(false, context.Background(), nil)
|
||||||
|
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
|
||||||
|
Return(&types.Settings{RegularUsersViewBlocked: false}, nil)
|
||||||
|
mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return([]*peer.Peer{{ID: "test-peer"}}, nil)
|
||||||
|
|
||||||
|
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
|
||||||
|
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
|
||||||
|
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
|
||||||
|
|
||||||
|
assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers)
|
||||||
|
assert.Equal(t, 1, total)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_GetPeersPaginated_NoPermission_User_IsRestrictable_ViewBlocked(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockStore := store.NewMockStore(ctrl)
|
||||||
|
permissionsManager := permissions.NewMockManager(ctrl)
|
||||||
|
|
||||||
|
m := NewManager(mockStore, permissionsManager)
|
||||||
|
|
||||||
|
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
|
||||||
|
Return(&types.User{
|
||||||
|
Id: "test-user",
|
||||||
|
Role: types.UserRoleUser,
|
||||||
|
}, nil)
|
||||||
|
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
|
||||||
|
Return(false, context.Background(), nil)
|
||||||
|
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
|
||||||
|
Return(&types.Settings{RegularUsersViewBlocked: true}, nil)
|
||||||
|
|
||||||
|
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
|
||||||
|
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
|
||||||
|
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
|
||||||
|
|
||||||
|
assert.Equal(t, make([]*peer.Peer, 0), peers)
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -8,6 +8,6 @@ type Manager interface {
|
|||||||
GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error)
|
GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error)
|
||||||
CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error)
|
CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error)
|
||||||
DeleteDomain(ctx context.Context, accountID, userID, domainID string) error
|
DeleteDomain(ctx context.Context, accountID, userID, domainID string) error
|
||||||
ValidateDomain(ctx context.Context, accountID, userID, domainID string)
|
ValidateDomain(ctx context.Context, accountID, userID, domainID string) error
|
||||||
GetClusterDomains() []string
|
GetClusterDomains() []string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,10 @@ func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID)
|
if err := h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID); err != nil {
|
||||||
|
util.WriteError(r.Context(), err, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusAccepted)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestValidateDomain_ExpiredRegistration(t *testing.T) {
|
|||||||
Update("validation_expires_at", expiresAt).Error)
|
Update("validation_expires_at", expiresAt).Error)
|
||||||
env.resolver.set("validation.expired.example.com", testCluster)
|
env.resolver.set("validation.expired.example.com", testCluster)
|
||||||
|
|
||||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "an expired registration must be refused")
|
||||||
|
|
||||||
stored := storedDomain(t, env.store, accountA, d.Domain)
|
stored := storedDomain(t, env.store, accountA, d.Domain)
|
||||||
require.NotNil(t, stored)
|
require.NotNil(t, stored)
|
||||||
@@ -47,7 +47,7 @@ func TestCreateDomain_ValidationDeadline(t *testing.T) {
|
|||||||
assert.Equal(t, createdAt.Add(48*time.Hour), *d.ValidationExpiresAt, "new registrations get 48 hours")
|
assert.Equal(t, createdAt.Add(48*time.Hour), *d.ValidationExpiresAt, "new registrations get 48 hours")
|
||||||
|
|
||||||
time.Sleep(time.Hour)
|
time.Sleep(time.Hour)
|
||||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "a missing CNAME must fail validation")
|
||||||
stored := storedDomain(t, env.store, accountA, d.Domain)
|
stored := storedDomain(t, env.store, accountA, d.Domain)
|
||||||
require.NotNil(t, stored)
|
require.NotNil(t, stored)
|
||||||
require.NotNil(t, stored.ValidationExpiresAt)
|
require.NotNil(t, stored.ValidationExpiresAt)
|
||||||
@@ -198,9 +198,10 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
|
|||||||
resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
|
resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
|
||||||
env.manager.validator.Resolver = resolver
|
env.manager.validator.Resolver = resolver
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
var validateErr error
|
||||||
go func() {
|
go func() {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
validateErr = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
||||||
}()
|
}()
|
||||||
<-resolver.started
|
<-resolver.started
|
||||||
time.Sleep(48 * time.Hour)
|
time.Sleep(48 * time.Hour)
|
||||||
@@ -211,6 +212,7 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
close(resolver.release)
|
close(resolver.release)
|
||||||
<-done
|
<-done
|
||||||
|
assert.Error(t, validateErr, "a validation finishing after the deadline must be refused")
|
||||||
owner := accountA
|
owner := accountA
|
||||||
if cleanup {
|
if cleanup {
|
||||||
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "late validation must not restore the old claim")
|
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "late validation must not restore the old claim")
|
||||||
|
|||||||
@@ -227,22 +227,19 @@ func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) {
|
// validationTimeout bounds the DNS check so it ends, and reports why, before a proxy in
|
||||||
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
|
// front of management times the request out.
|
||||||
|
const validationTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// ValidateDomain checks the domain's validation CNAME and marks it validated. A failure
|
||||||
|
// the user can act on is returned as PreconditionFailed carrying the reason.
|
||||||
|
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) error {
|
||||||
|
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithFields(log.Fields{
|
return status.NewPermissionValidationError(err)
|
||||||
"accountID": accountID,
|
|
||||||
"domainID": domainID,
|
|
||||||
}).WithError(err).Error("validate domain")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
log.WithFields(log.Fields{
|
return status.NewPermissionDeniedError()
|
||||||
"accountID": accountID,
|
|
||||||
"domainID": domainID,
|
|
||||||
"userID": userID,
|
|
||||||
}).Error("validate domain: permission denied")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
@@ -250,32 +247,23 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
|||||||
"domainID": domainID,
|
"domainID": domainID,
|
||||||
}).Info("starting domain validation")
|
}).Info("starting domain validation")
|
||||||
|
|
||||||
d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID)
|
d, err := m.store.GetCustomDomain(ctx, accountID, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithFields(log.Fields{
|
return fmt.Errorf("get domain from store: %w", err)
|
||||||
"accountID": accountID,
|
|
||||||
"domainID": domainID,
|
|
||||||
}).WithError(err).Error("get custom domain from store")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if d.Validated {
|
if d.Validated {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) {
|
if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) {
|
||||||
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
|
return validationFailed(domain.NewValidationError(domain.ValidationReasonExpired,
|
||||||
Debug("custom domain validation window has expired")
|
"custom domain %s validation window has expired; delete and add the domain again", d.Domain))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate only against the domain's target cluster
|
// Validate only against the domain's target cluster
|
||||||
targetCluster := d.TargetCluster
|
targetCluster := d.TargetCluster
|
||||||
if targetCluster == "" {
|
if targetCluster == "" {
|
||||||
log.WithFields(log.Fields{
|
return validationFailed(domain.NewValidationError(domain.ValidationReasonNoTargetCluster,
|
||||||
"accountID": accountID,
|
"custom domain %s has no target cluster", d.Domain))
|
||||||
"domainID": domainID,
|
|
||||||
"domain": d.Domain,
|
|
||||||
}).Warn("domain has no target cluster set, skipping validation")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
@@ -285,32 +273,35 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
|||||||
"targetCluster": targetCluster,
|
"targetCluster": targetCluster,
|
||||||
}).Info("validating domain against target cluster")
|
}).Info("validating domain against target cluster")
|
||||||
|
|
||||||
if m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) {
|
lookupCtx, cancel := context.WithTimeout(ctx, validationTimeout)
|
||||||
|
_, err = m.validator.Validate(lookupCtx, d.Domain, []string{targetCluster})
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
d.Validated = true
|
d.Validated = true
|
||||||
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
|
if _, err := m.store.UpdateCustomDomain(ctx, accountID, d); err != nil {
|
||||||
entry := log.WithFields(log.Fields{
|
// A concurrent request may have validated the domain first.
|
||||||
"accountID": accountID,
|
if current, getErr := m.store.GetCustomDomain(ctx, accountID, domainID); getErr == nil && current.Validated {
|
||||||
"domainID": domainID,
|
return nil
|
||||||
}).WithError(err)
|
|
||||||
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.PreconditionFailed {
|
|
||||||
entry.Debug("custom domain registration is no longer pending validation")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
entry.Error("update custom domain in store")
|
return fmt.Errorf("update domain in store: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
|
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
|
||||||
Info("custom domain validated successfully")
|
Info("custom domain validated successfully")
|
||||||
|
|
||||||
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
|
m.accountManager.StoreEvent(ctx, userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
|
||||||
} else {
|
return nil
|
||||||
log.WithFields(log.Fields{
|
|
||||||
"accountID": accountID,
|
|
||||||
"domainID": domainID,
|
|
||||||
"domain": d.Domain,
|
|
||||||
"targetCluster": targetCluster,
|
|
||||||
}).Warn("domain validation failed - CNAME does not match target cluster")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return validationFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validationFailed shows a classified failure to the user and keeps anything else internal.
|
||||||
|
func validationFailed(err error) error {
|
||||||
|
var vErr *domain.ValidationError
|
||||||
|
if errors.As(err, &vErr) {
|
||||||
|
return status.Errorf(status.PreconditionFailed, "%s", vErr.Message)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("validate domain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClusterDomains returns a list of proxy cluster domains.
|
// GetClusterDomains returns a list of proxy cluster domains.
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ func TestValidateDomain_UnlocksClusterDerivation(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|
||||||
env.resolver.set("validation.later.example.com", testCluster)
|
env.resolver.set("validation.later.example.com", testCluster)
|
||||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID)
|
require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID))
|
||||||
|
|
||||||
require.True(t, storedDomain(t, env.store, accountA, "later.example.com").Validated)
|
require.True(t, storedDomain(t, env.store, accountA, "later.example.com").Validated)
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ func TestValidateDomain_PermissionDeniedDoesNotValidate(t *testing.T) {
|
|||||||
// a validated domain is the permission check.
|
// a validated domain is the permission check.
|
||||||
env.resolver.set("validation.guarded.example.com", testCluster)
|
env.resolver.set("validation.guarded.example.com", testCluster)
|
||||||
|
|
||||||
env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID)
|
assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID), "a caller without permission must be refused")
|
||||||
|
|
||||||
stored := storedDomain(t, env.store, accountA, "guarded.example.com")
|
stored := storedDomain(t, env.store, accountA, "guarded.example.com")
|
||||||
require.NotNil(t, stored)
|
require.NotNil(t, stored)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestCreateDomain_NormalizedNameCanValidateLater(t *testing.T) {
|
|||||||
require.False(t, created.Validated, "a missing CNAME must leave the normalized registration pending")
|
require.False(t, created.Validated, "a missing CNAME must leave the normalized registration pending")
|
||||||
|
|
||||||
env.resolver.set("validation.apps.example.com", testCluster)
|
env.resolver.set("validation.apps.example.com", testCluster)
|
||||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID)
|
require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID))
|
||||||
stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID)
|
stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "apps.example.com", stored.Domain, "retrying validation must retain the normalized name")
|
assert.Equal(t, "apps.example.com", stored.Domain, "retrying validation must retain the normalized name")
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||||
|
nbstore "github.com/netbirdio/netbird/management/server/store"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The validator tests cover each DNS outcome. These cover what the manager adds on top:
|
||||||
|
// the status type the API turns into a response code, and the checks before the lookup.
|
||||||
|
func TestValidateDomain_ReturnsReason(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(t *testing.T, env *domainTestEnv, d *domain.Domain)
|
||||||
|
user string
|
||||||
|
want status.Type
|
||||||
|
message string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cname mismatch",
|
||||||
|
setup: func(_ *testing.T, env *domainTestEnv, d *domain.Domain) {
|
||||||
|
env.resolver.set("validation."+d.Domain, "other.example.net")
|
||||||
|
},
|
||||||
|
want: status.PreconditionFailed,
|
||||||
|
message: "CNAME record validation.test.example.com points to other.example.net; point it to " + testCluster,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired window",
|
||||||
|
setup: func(t *testing.T, env *domainTestEnv, d *domain.Domain) {
|
||||||
|
setDomainColumn(t, env, d, "validation_expires_at", time.Now().Add(-time.Second))
|
||||||
|
},
|
||||||
|
want: status.PreconditionFailed,
|
||||||
|
message: "custom domain test.example.com validation window has expired; delete and add the domain again",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no target cluster",
|
||||||
|
setup: func(t *testing.T, env *domainTestEnv, d *domain.Domain) {
|
||||||
|
setDomainColumn(t, env, d, "target_cluster", "")
|
||||||
|
},
|
||||||
|
want: status.PreconditionFailed,
|
||||||
|
message: "custom domain test.example.com has no target cluster",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission denied",
|
||||||
|
setup: func(_ *testing.T, env *domainTestEnv, d *domain.Domain) {
|
||||||
|
env.resolver.set("validation."+d.Domain, testCluster)
|
||||||
|
},
|
||||||
|
user: accountAMember,
|
||||||
|
want: status.PermissionDenied,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
env := setupDomainTest(t)
|
||||||
|
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "test.example.com", testCluster)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tt.setup(t, env, d)
|
||||||
|
user := accountAUser
|
||||||
|
if tt.user != "" {
|
||||||
|
user = tt.user
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.manager.ValidateDomain(ctx, accountA, user, d.ID)
|
||||||
|
sErr, ok := status.FromError(err)
|
||||||
|
require.True(t, ok, "expected a status error, got %v", err)
|
||||||
|
assert.Equal(t, tt.want, sErr.Type(), "status type")
|
||||||
|
if tt.message != "" {
|
||||||
|
assert.Equal(t, tt.message, sErr.Message, "user-facing message")
|
||||||
|
}
|
||||||
|
assert.False(t, storedDomain(t, env.store, accountA, d.Domain).Validated, "a failed check must not validate the domain")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomain_SuccessIsIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
env := setupDomainTest(t)
|
||||||
|
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "ok.example.com", testCluster)
|
||||||
|
require.NoError(t, err)
|
||||||
|
env.resolver.set("validation.ok.example.com", testCluster)
|
||||||
|
|
||||||
|
require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID))
|
||||||
|
assert.True(t, storedDomain(t, env.store, accountA, d.Domain).Validated, "domain should be validated")
|
||||||
|
assert.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "an already validated domain is a success")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDomainColumn(t *testing.T, env *domainTestEnv, d *domain.Domain, column string, value any) {
|
||||||
|
t.Helper()
|
||||||
|
db := env.store.(*nbstore.SqlStore).GetDB()
|
||||||
|
require.NoError(t, db.Model(&domain.Domain{}).Where("id = ?", d.ID).Update(column, value).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two verify requests can both read the domain as pending. The one that loses the store
|
||||||
|
// update must still report success when the other one validated the domain.
|
||||||
|
func TestValidateDomain_ConcurrentValidationSucceeds(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
env := setupDomainTest(t)
|
||||||
|
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "twice.example.com", testCluster)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
|
||||||
|
env.manager.validator.Resolver = resolver
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
||||||
|
}()
|
||||||
|
<-resolver.started
|
||||||
|
// The other request completes while this one is still looking up the CNAME.
|
||||||
|
setDomainColumn(t, env, d, "validated", true)
|
||||||
|
close(resolver.release)
|
||||||
|
|
||||||
|
assert.NoError(t, <-done, "a domain validated by a concurrent request is a success")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hangingResolver never answers, so only a deadline on the context ends the lookup.
|
||||||
|
type hangingResolver struct{}
|
||||||
|
|
||||||
|
func (hangingResolver) LookupCNAME(ctx context.Context, _ string) (string, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lookup that hangs must end on the validation timeout and still report the reason,
|
||||||
|
// rather than leaving the request to whatever timeout sits in front of management.
|
||||||
|
func TestValidateDomain_LookupTimeout(t *testing.T) {
|
||||||
|
env := setupClockDomainTest(t)
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "slow.example.com", testCluster)
|
||||||
|
require.NoError(t, err)
|
||||||
|
env.manager.validator.Resolver = hangingResolver{}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
||||||
|
sErr, ok := status.FromError(err)
|
||||||
|
require.True(t, ok, "expected a status error, got %v", err)
|
||||||
|
assert.Equal(t, status.PreconditionFailed, sErr.Type(), "status type")
|
||||||
|
assert.Equal(t, "DNS lookup for validation.slow.example.com failed; retry the validation", sErr.Message, "user-facing message")
|
||||||
|
assert.Equal(t, validationTimeout, time.Since(start), "the lookup must end on the validation timeout")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package domain
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -16,6 +18,33 @@ type Validator struct {
|
|||||||
Resolver resolver
|
Resolver resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidationReason classifies why a custom domain failed validation.
|
||||||
|
type ValidationReason string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ValidationReasonCNAMENotFound ValidationReason = "cname_not_found"
|
||||||
|
ValidationReasonCNAMEMismatch ValidationReason = "cname_mismatch"
|
||||||
|
ValidationReasonLookupFailed ValidationReason = "lookup_failed"
|
||||||
|
ValidationReasonExpired ValidationReason = "validation_expired"
|
||||||
|
ValidationReasonNoTargetCluster ValidationReason = "no_target_cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationError is a validation failure whose message is safe to show to the user.
|
||||||
|
type ValidationError struct {
|
||||||
|
Reason ValidationReason
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the user-facing message.
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidationError returns a ValidationError with a formatted message.
|
||||||
|
func NewValidationError(reason ValidationReason, format string, args ...any) *ValidationError {
|
||||||
|
return &ValidationError{Reason: reason, Message: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
// NewValidator initializes a validator with a specific DNS Resolver.
|
// NewValidator initializes a validator with a specific DNS Resolver.
|
||||||
// If a Validator is used without specifying a Resolver, then it will
|
// If a Validator is used without specifying a Resolver, then it will
|
||||||
// use the net.DefaultResolver.
|
// use the net.DefaultResolver.
|
||||||
@@ -39,6 +68,13 @@ func (v *Validator) IsValid(ctx context.Context, domain string, accept []string)
|
|||||||
// ValidateWithCluster validates a custom domain and returns the matched cluster address.
|
// ValidateWithCluster validates a custom domain and returns the matched cluster address.
|
||||||
// Returns the cluster address and true if valid, or empty string and false if invalid.
|
// Returns the cluster address and true if valid, or empty string and false if invalid.
|
||||||
func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) {
|
func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) {
|
||||||
|
cluster, err := v.Validate(ctx, domain, accept)
|
||||||
|
return cluster, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates a custom domain and returns the matched cluster address.
|
||||||
|
// On failure it returns a *ValidationError whose message is safe to show to the user.
|
||||||
|
func (v *Validator) Validate(ctx context.Context, domain string, accept []string) (string, error) {
|
||||||
if v.Resolver == nil {
|
if v.Resolver == nil {
|
||||||
v.Resolver = net.DefaultResolver
|
v.Resolver = net.DefaultResolver
|
||||||
}
|
}
|
||||||
@@ -56,10 +92,19 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
|
|||||||
"domain": domain,
|
"domain": domain,
|
||||||
"lookupDomain": lookupDomain,
|
"lookupDomain": lookupDomain,
|
||||||
}).WithError(err).Warn("CNAME lookup failed for domain validation")
|
}).WithError(err).Warn("CNAME lookup failed for domain validation")
|
||||||
return "", false
|
// Never forward the resolver error: its text includes the resolver address.
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||||
|
return "", cnameNotFound(lookupDomain, accept)
|
||||||
|
}
|
||||||
|
return "", NewValidationError(ValidationReasonLookupFailed, "DNS lookup for %s failed; retry the validation", lookupDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
nakedCNAME := strings.TrimSuffix(cname, ".")
|
nakedCNAME := strings.TrimSuffix(cname, ".")
|
||||||
|
// A name without a CNAME record resolves to itself.
|
||||||
|
if strings.EqualFold(nakedCNAME, lookupDomain) {
|
||||||
|
return "", cnameNotFound(lookupDomain, accept)
|
||||||
|
}
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"cname": cname,
|
"cname": cname,
|
||||||
@@ -69,13 +114,13 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
|
|||||||
|
|
||||||
for _, acceptDomain := range accept {
|
for _, acceptDomain := range accept {
|
||||||
normalizedAccept := strings.TrimSuffix(acceptDomain, ".")
|
normalizedAccept := strings.TrimSuffix(acceptDomain, ".")
|
||||||
if nakedCNAME == normalizedAccept {
|
if strings.EqualFold(nakedCNAME, normalizedAccept) {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"cname": nakedCNAME,
|
"cname": nakedCNAME,
|
||||||
"cluster": acceptDomain,
|
"cluster": acceptDomain,
|
||||||
}).Info("domain CNAME matched cluster")
|
}).Info("domain CNAME matched cluster")
|
||||||
return acceptDomain, true
|
return acceptDomain, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,5 +129,11 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
|
|||||||
"cname": nakedCNAME,
|
"cname": nakedCNAME,
|
||||||
"acceptList": accept,
|
"acceptList": accept,
|
||||||
}).Warn("domain CNAME does not match any accepted cluster")
|
}).Warn("domain CNAME does not match any accepted cluster")
|
||||||
return "", false
|
return "", NewValidationError(ValidationReasonCNAMEMismatch, "CNAME record %s points to %s; point it to %s",
|
||||||
|
lookupDomain, nakedCNAME, strings.Join(accept, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cnameNotFound(lookupDomain string, accept []string) *ValidationError {
|
||||||
|
return NewValidationError(ValidationReasonCNAMENotFound, "no CNAME record found for %s; point it to %s",
|
||||||
|
lookupDomain, strings.Join(accept, ", "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package domain_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||||
@@ -36,6 +38,12 @@ func TestIsValid(t *testing.T) {
|
|||||||
accept: []string{"bar.example.com"},
|
accept: []string{"bar.example.com"},
|
||||||
expect: false,
|
expect: false,
|
||||||
},
|
},
|
||||||
|
"match in other case": {
|
||||||
|
resolver: resolver{"Bar.Example.COM."},
|
||||||
|
domain: "foo.example.com",
|
||||||
|
accept: []string{"bar.example.com"},
|
||||||
|
expect: true,
|
||||||
|
},
|
||||||
"accept trailing dot": {
|
"accept trailing dot": {
|
||||||
resolver: resolver{"bar.example.com."},
|
resolver: resolver{"bar.example.com."},
|
||||||
domain: "foo.example.com",
|
domain: "foo.example.com",
|
||||||
@@ -54,3 +62,61 @@ func TestIsValid(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type errResolver struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r errResolver) LookupCNAME(context.Context, string) (string, error) {
|
||||||
|
return "", r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_Reason(t *testing.T) {
|
||||||
|
const notFound = "no CNAME record found for validation.foo.example.com; point it to eu.proxy.example.com"
|
||||||
|
tests := map[string]struct {
|
||||||
|
resolver interface {
|
||||||
|
LookupCNAME(context.Context, string) (string, error)
|
||||||
|
}
|
||||||
|
reason domain.ValidationReason
|
||||||
|
message string
|
||||||
|
}{
|
||||||
|
"mismatch": {
|
||||||
|
resolver: resolver{"other.example.net."},
|
||||||
|
reason: domain.ValidationReasonCNAMEMismatch,
|
||||||
|
message: "CNAME record validation.foo.example.com points to other.example.net; point it to eu.proxy.example.com",
|
||||||
|
},
|
||||||
|
"no cname resolves to itself": {
|
||||||
|
resolver: resolver{"validation.foo.example.com."},
|
||||||
|
reason: domain.ValidationReasonCNAMENotFound,
|
||||||
|
message: notFound,
|
||||||
|
},
|
||||||
|
"no cname resolves to itself in other case": {
|
||||||
|
resolver: resolver{"Validation.Foo.example.com."},
|
||||||
|
reason: domain.ValidationReasonCNAMENotFound,
|
||||||
|
message: notFound,
|
||||||
|
},
|
||||||
|
"not found": {
|
||||||
|
resolver: errResolver{&net.DNSError{Err: "no such host", Name: "validation.foo.example.com", Server: "10.0.0.2:53", IsNotFound: true}},
|
||||||
|
reason: domain.ValidationReasonCNAMENotFound,
|
||||||
|
message: notFound,
|
||||||
|
},
|
||||||
|
"other error is not forwarded": {
|
||||||
|
resolver: errResolver{errors.New("dial udp 10.0.0.2:53: connect: network is unreachable")},
|
||||||
|
reason: domain.ValidationReasonLookupFailed,
|
||||||
|
message: "DNS lookup for validation.foo.example.com failed; retry the validation",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, test := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := domain.NewValidator(test.resolver).Validate(t.Context(), "foo.example.com", []string{"eu.proxy.example.com"})
|
||||||
|
var vErr *domain.ValidationError
|
||||||
|
if !errors.As(err, &vErr) {
|
||||||
|
t.Fatalf("expected a *domain.ValidationError, got %v", err)
|
||||||
|
}
|
||||||
|
if vErr.Reason != test.reason || vErr.Message != test.message {
|
||||||
|
t.Errorf("expect %q %q, actual %q %q", test.reason, test.message, vErr.Reason, vErr.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func (s *BaseServer) EventStore() activity.Store {
|
|||||||
func (s *BaseServer) APIHandler() http.Handler {
|
func (s *BaseServer) APIHandler() http.Handler {
|
||||||
_ = CreateNamed(s, "http_v1api", func() http.Handler {
|
_ = CreateNamed(s, "http_v1api", func() http.Handler {
|
||||||
apiv1Router := s.ApiV1Router()
|
apiv1Router := s.ApiV1Router()
|
||||||
_, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.NetworkMapController(), s.PermissionsManager())
|
_, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.PeersManager(), s.NetworkMapController(), s.PermissionsManager())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to create API handler: %v", err)
|
log.Fatalf("failed to create API handler: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,7 +335,11 @@ func (s *BaseServer) Stop() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-s.Errors():
|
case err := <-s.Errors():
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).Infof("stopped Management Service: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
log.WithContext(ctx).Infof("stopped Management Service")
|
log.WithContext(ctx).Infof("stopped Management Service")
|
||||||
return nil
|
return nil
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
|||||||
@@ -6,5 +6,4 @@ type Engine string
|
|||||||
const (
|
const (
|
||||||
SqliteStoreEngine Engine = "sqlite"
|
SqliteStoreEngine Engine = "sqlite"
|
||||||
PostgresStoreEngine Engine = "postgres"
|
PostgresStoreEngine Engine = "postgres"
|
||||||
MysqlStoreEngine Engine = "mysql"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"gorm.io/driver/mysql"
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -121,30 +120,6 @@ func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, erro
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MysqlDSN adds the connection parameters every MySQL handle needs, keeping
|
|
||||||
// the options already present in dsn.
|
|
||||||
func MysqlDSN(dsn string) string {
|
|
||||||
separator := "?"
|
|
||||||
if strings.Contains(dsn, "?") {
|
|
||||||
separator = "&"
|
|
||||||
}
|
|
||||||
return dsn + separator + "charset=utf8&parseTime=True&loc=Local"
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenMysql opens a MySQL database through gorm.
|
|
||||||
func OpenMysql(ctx context.Context, dsn string) (*Conn, error) {
|
|
||||||
gormDB, err := gorm.Open(mysql.Open(MysqlDSN(dsn)), GormConfig())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
conn, err := NewConn(ctx, gormDB, MysqlStoreEngine, nil)
|
|
||||||
if err != nil {
|
|
||||||
closeGorm(gormDB)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return conn, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) {
|
func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) {
|
||||||
config, err := pgxpool.ParseConfig(dsn)
|
config, err := pgxpool.ParseConfig(dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMysqlDSN(t *testing.T) {
|
|
||||||
assert.Equal(t, "user:pw@tcp(host:3306)/db?charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db"))
|
|
||||||
assert.Equal(t, "user:pw@tcp(host:3306)/db?tls=true&charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db?tls=true"))
|
|
||||||
}
|
|
||||||
@@ -39,10 +39,7 @@ func (c *Conn) RunInTx(ctx context.Context, fn func(tx *Tx) error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.withForeignKeyChecksDisabled(tx, func() error {
|
if err := fn(&Tx{db: tx}); err != nil {
|
||||||
return fn(&Tx{db: tx})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
c.logIfTimedOut(ctx, timeoutCtx, err, "transaction", startTime)
|
c.logIfTimedOut(ctx, timeoutCtx, err, "transaction", startTime)
|
||||||
return err
|
return err
|
||||||
@@ -73,31 +70,6 @@ func (c *Conn) applyStatementTimeouts(tx *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// withForeignKeyChecksDisabled runs fn with MySQL's FK checks off, which avoids
|
|
||||||
// deadlocks on MySQL and Aurora without needing SUPER privilege. The setting is
|
|
||||||
// session-scoped and survives a rollback, so it is turned back on whenever fn
|
|
||||||
// returns or panics; otherwise the pooled connection would keep it disabled.
|
|
||||||
func (c *Conn) withForeignKeyChecksDisabled(tx *gorm.DB, fn func() error) (err error) {
|
|
||||||
if c.engine != MysqlStoreEngine {
|
|
||||||
return fn()
|
|
||||||
}
|
|
||||||
if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to disable FK checks: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
restoreErr := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error
|
|
||||||
if restoreErr == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
err = fmt.Errorf("failed to re-enable FK checks: %w", restoreErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.WithContext(tx.Statement.Context).Warnf("failed to re-enable FK checks after failed transaction: %v", restoreErr)
|
|
||||||
}()
|
|
||||||
return fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Conn) logIfTimedOut(ctx, timeoutCtx context.Context, err error, phase string, startTime time.Time) {
|
func (c *Conn) logIfTimedOut(ctx, timeoutCtx context.Context, err error, phase string, startTime time.Time) {
|
||||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||||
log.WithContext(ctx).Warnf("%s exceeded %s timeout after %v, stack: %s", phase, c.txTimeout, time.Since(startTime), debug.Stack())
|
log.WithContext(ctx).Warnf("%s exceeded %s timeout after %v, stack: %s", phase, c.txTimeout, time.Since(startTime), debug.Stack())
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ func (am *DefaultAccountManager) runAccountDeletionHooks(ctx context.Context, ac
|
|||||||
func isUniqueConstraintError(err error) bool {
|
func isUniqueConstraintError(err error) bool {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(err.Error(), "(SQLSTATE 23505)"),
|
case strings.Contains(err.Error(), "(SQLSTATE 23505)"),
|
||||||
strings.Contains(err.Error(), "Error 1062 (23000)"),
|
|
||||||
strings.Contains(err.Error(), "UNIQUE constraint failed"):
|
strings.Contains(err.Error(), "UNIQUE constraint failed"):
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|||||||
@@ -3759,7 +3759,7 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel.
|
|||||||
|
|
||||||
// peerUpdateTimeout bounds how long peerShouldReceiveUpdate and its outer
|
// peerUpdateTimeout bounds how long peerShouldReceiveUpdate and its outer
|
||||||
// wrappers wait for an expected update message. Sized for slow CI runners
|
// wrappers wait for an expected update message. Sized for slow CI runners
|
||||||
// (MySQL, FreeBSD, loaded sqlite) where the channel publish can take
|
// (FreeBSD, loaded sqlite) where the channel publish can take
|
||||||
// seconds. Only runs down on failure; passing tests return immediately
|
// seconds. Only runs down on failure; passing tests return immediately
|
||||||
// when the channel delivers.
|
// when the channel delivers.
|
||||||
const peerUpdateTimeout = 5 * time.Second
|
const peerUpdateTimeout = 5 * time.Second
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
|
|
||||||
|
peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||||
"github.com/netbirdio/netbird/management/server/api/v1alpha1/peers"
|
"github.com/netbirdio/netbird/management/server/api/v1alpha1/peers"
|
||||||
"github.com/netbirdio/netbird/management/server/api/v1alpha1/users"
|
"github.com/netbirdio/netbird/management/server/api/v1alpha1/users"
|
||||||
)
|
)
|
||||||
@@ -20,6 +21,7 @@ func NewAPIV1Handler(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
router *mux.Router,
|
router *mux.Router,
|
||||||
accountManager account.Manager,
|
accountManager account.Manager,
|
||||||
|
peerManager peersmodule.Manager,
|
||||||
networkMapController network_map.Controller,
|
networkMapController network_map.Controller,
|
||||||
permissionsManager permissions.Manager) (http.Handler, error) {
|
permissionsManager permissions.Manager) (http.Handler, error) {
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ func NewAPIV1Handler(
|
|||||||
|
|
||||||
router.Use(v1validatorMiddleware.Handler)
|
router.Use(v1validatorMiddleware.Handler)
|
||||||
|
|
||||||
peersHandler := peers.NewHandler(accountManager, networkMapController, permissionsManager)
|
peersHandler := peers.NewHandler(accountManager, peerManager, networkMapController, permissionsManager)
|
||||||
_ = peersHandler.WithEndpointsForRouter(router)
|
_ = peersHandler.WithEndpointsForRouter(router)
|
||||||
|
|
||||||
usersHandler := users.NewHandler(accountManager)
|
usersHandler := users.NewHandler(accountManager)
|
||||||
|
|||||||
@@ -6,17 +6,22 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||||
"github.com/netbirdio/netbird/management/server/account"
|
"github.com/netbirdio/netbird/management/server/account"
|
||||||
"github.com/netbirdio/netbird/management/server/activity"
|
"github.com/netbirdio/netbird/management/server/activity"
|
||||||
"github.com/netbirdio/netbird/management/server/api/v1alpha1/groups"
|
"github.com/netbirdio/netbird/management/server/api/v1alpha1/groups"
|
||||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
|
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||||
"github.com/netbirdio/netbird/shared/management/status"
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
@@ -26,12 +31,14 @@ type Handler struct {
|
|||||||
accountManager account.Manager
|
accountManager account.Manager
|
||||||
permissionsManager permissions.Manager
|
permissionsManager permissions.Manager
|
||||||
networkMapController network_map.Controller
|
networkMapController network_map.Controller
|
||||||
|
peersManager peers.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new peers Handler
|
// NewHandler creates a new peers Handler
|
||||||
func NewHandler(accountManager account.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler {
|
func NewHandler(accountManager account.Manager, peerManager peers.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
accountManager: accountManager,
|
accountManager: accountManager,
|
||||||
|
peersManager: peerManager,
|
||||||
networkMapController: networkMapController,
|
networkMapController: networkMapController,
|
||||||
permissionsManager: permissionsManager,
|
permissionsManager: permissionsManager,
|
||||||
}
|
}
|
||||||
@@ -210,14 +217,32 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nameFilter := r.URL.Query().Get("name")
|
pagination, err := paginationFromQuery(r.URL.Query())
|
||||||
ipFilter := r.URL.Query().Get("ip")
|
if err != nil {
|
||||||
macFilter := r.URL.Query().Get("mac")
|
log.WithContext(r.Context()).Errorf("error parsing query: %v", err)
|
||||||
|
util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filters, err := filtersFromQuery(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(r.Context()).Errorf("error parsing query: %v", err)
|
||||||
|
util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sorting, err := sortingOrderFromQuery(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(r.Context()).Errorf("error parsing query: %v", err)
|
||||||
|
util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
accountID, userID := userAuth.AccountId, userAuth.UserId
|
accountID, userID := userAuth.AccountId, userAuth.UserId
|
||||||
|
|
||||||
peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter)
|
peers, peerCount, err := h.peersManager.GetPeersPaginated(r.Context(), accountID, userID, pagination, filters, sorting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.WithContext(r.Context()).Errorf("error getting peers: %v", err)
|
||||||
util.WriteError(r.Context(), err, w)
|
util.WriteError(r.Context(), err, w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -232,35 +257,135 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
grps, _ := h.accountManager.GetAllGroups(r.Context(), accountID, userID)
|
grps, _ := h.accountManager.GetAllGroups(r.Context(), accountID, userID)
|
||||||
|
|
||||||
grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers))
|
grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers))
|
||||||
respBody := make([]*apiv1alpha1.PeerBatch, 0, len(peers))
|
validPeers, invalidPeers, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
|
||||||
for _, peer := range peers {
|
|
||||||
if peer.ProxyMeta.Embedded {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
respBody = append(respBody, toPeerListItemResponse(peer, grpsInfoMap[peer.ID], dnsDomain, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
validPeersMap, invalidPeersMap, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithContext(r.Context()).Errorf("failed to get validated peers: %v", err)
|
log.WithContext(r.Context()).Errorf("failed to get validated peers: %v", err)
|
||||||
util.WriteError(r.Context(), fmt.Errorf("internal error"), w)
|
util.WriteError(r.Context(), fmt.Errorf("internal error"), w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.setApprovalRequiredFlag(respBody, validPeersMap, invalidPeersMap)
|
|
||||||
|
respBody := apiv1alpha1.PeerBatch{Peers: make([]apiv1alpha1.Peer, 0, len(peers))}
|
||||||
|
for _, peer := range peers {
|
||||||
|
_, valid := validPeers[peer.ID]
|
||||||
|
reason := invalidPeers[peer.ID]
|
||||||
|
|
||||||
|
respBody.Peers = append(respBody.Peers, *toSinglePeerResponse(peer, grpsInfoMap[peer.ID], dnsDomain, valid, reason))
|
||||||
|
}
|
||||||
|
respBody.Page = pagination.Page
|
||||||
|
respBody.PageSize = pagination.PageSize
|
||||||
|
respBody.PeerCount = peerCount
|
||||||
|
|
||||||
util.WriteJSONObject(r.Context(), w, respBody)
|
util.WriteJSONObject(r.Context(), w, respBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) setApprovalRequiredFlag(respBody []*apiv1alpha1.PeerBatch, validPeersMap map[string]struct{}, invalidPeersMap map[string]string) {
|
func filtersFromQuery(v url.Values) (store.PeerFilters, error) {
|
||||||
for _, peer := range respBody {
|
connected, err := parseBool(v.Get("connected"))
|
||||||
_, ok := validPeersMap[peer.Id]
|
if err != nil {
|
||||||
if !ok {
|
return store.PeerFilters{}, err
|
||||||
peer.ApprovalRequired = true
|
}
|
||||||
|
|
||||||
reason := invalidPeersMap[peer.Id]
|
approvalRequired, err := parseBool(v.Get("approval_required"))
|
||||||
peer.DisapprovalReason = &reason
|
if err != nil {
|
||||||
|
return store.PeerFilters{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var isServer *bool
|
||||||
|
if v.Get("kind") != "" {
|
||||||
|
isServer = new(v.Get("kind") == "server")
|
||||||
|
if *isServer && v.Get("user_id") != "" {
|
||||||
|
return store.PeerFilters{}, fmt.Errorf("incompatible filters: kind == server and a non-empty user_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return store.PeerFilters{
|
||||||
|
UserId: v.Get("user_id"),
|
||||||
|
GroupIds: v["group_ids"],
|
||||||
|
Connected: connected,
|
||||||
|
ApprovalRequired: approvalRequired,
|
||||||
|
Os: v["os"],
|
||||||
|
IP: v.Get("ip"),
|
||||||
|
MAC: v.Get("mac"),
|
||||||
|
Name: v.Get("name"),
|
||||||
|
Hostname: v.Get("hostname"),
|
||||||
|
IsServer: isServer,
|
||||||
|
PeersIds: v["peer_ids"],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(maybeBool string) (*bool, error) {
|
||||||
|
if maybeBool == "" {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.ParseBool(maybeBool)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(parsed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func paginationFromQuery(v url.Values) (store.PaginationState, error) {
|
||||||
|
page, err := strconv.Atoi(v.Get("page"))
|
||||||
|
if err != nil {
|
||||||
|
return store.PaginationState{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSize, err := strconv.Atoi(v.Get("page_size"))
|
||||||
|
if err != nil {
|
||||||
|
return store.PaginationState{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.PaginationState{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortingOrderFromQuery(v url.Values) (store.PeerSorting, error) {
|
||||||
|
toret := store.PeerSorting{}
|
||||||
|
sortby, ok := v["sort_by"]
|
||||||
|
if !ok {
|
||||||
|
return toret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(sortby); {
|
||||||
|
// ascending sorting order by default
|
||||||
|
sortkey := store.PeerSortingKey{Order: store.AscOrderBy}
|
||||||
|
field, order := parseFieldAndOrder(sortby[i])
|
||||||
|
|
||||||
|
switch field {
|
||||||
|
case "name":
|
||||||
|
sortkey.Field = store.PeerSortByName
|
||||||
|
case "last_seen":
|
||||||
|
sortkey.Field = store.PeerSortByLastSeen
|
||||||
|
case "version":
|
||||||
|
sortkey.Field = store.PeerSortByVersion
|
||||||
|
default:
|
||||||
|
return toret, fmt.Errorf("unable to parse sorting order '%s'", sortby)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch order {
|
||||||
|
case "asc", "":
|
||||||
|
// nop, already set
|
||||||
|
case "desc":
|
||||||
|
sortkey.Order = store.DescOrderBy
|
||||||
|
default:
|
||||||
|
return store.PeerSorting{}, fmt.Errorf("unable to parse sorting order '%s'", sortby)
|
||||||
|
}
|
||||||
|
|
||||||
|
toret.Fields = append(toret.Fields, sortkey)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return toret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFieldAndOrder(fieldOrder string) (string, string) {
|
||||||
|
maybeFieldAndOrder := strings.SplitN(fieldOrder, ",", 2)
|
||||||
|
if len(maybeFieldAndOrder) == 2 {
|
||||||
|
return strings.TrimSpace(maybeFieldAndOrder[0]), strings.TrimSpace(maybeFieldAndOrder[1])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(maybeFieldAndOrder[0]), ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseIPv6(s *string) (netip.Addr, error) {
|
func parseIPv6(s *string) (netip.Addr, error) {
|
||||||
@@ -337,61 +462,6 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinim
|
|||||||
return apiPeer
|
return apiPeer
|
||||||
}
|
}
|
||||||
|
|
||||||
func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinimum, dnsDomain string, accessiblePeersCount int) *apiv1alpha1.PeerBatch {
|
|
||||||
osVersion := peer.Meta.OSVersion
|
|
||||||
if osVersion == "" {
|
|
||||||
osVersion = peer.Meta.Core
|
|
||||||
}
|
|
||||||
|
|
||||||
return &apiv1alpha1.PeerBatch{
|
|
||||||
CreatedAt: peer.CreatedAt,
|
|
||||||
AccessiblePeersCount: accessiblePeersCount,
|
|
||||||
Peer: apiv1alpha1.Peer{
|
|
||||||
PeerMinimum: apiv1alpha1.PeerMinimum{
|
|
||||||
Id: peer.ID,
|
|
||||||
Name: peer.Name,
|
|
||||||
},
|
|
||||||
Ip: peer.IP.String(),
|
|
||||||
Ipv6: peerIPv6String(peer),
|
|
||||||
ConnectionIp: peer.Location.ConnectionIP.String(),
|
|
||||||
Connected: peer.Status.Connected,
|
|
||||||
LastSeen: peer.Status.LastSeen,
|
|
||||||
Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion),
|
|
||||||
KernelVersion: peer.Meta.KernelVersion,
|
|
||||||
GeonameId: int(peer.Location.GeoNameID),
|
|
||||||
Version: peer.Meta.WtVersion,
|
|
||||||
Groups: groupsInfo,
|
|
||||||
SshEnabled: peer.SSHEnabled,
|
|
||||||
Hostname: peer.Meta.Hostname,
|
|
||||||
UserId: peer.UserID,
|
|
||||||
UiVersion: peer.Meta.UIVersion,
|
|
||||||
DnsLabel: fqdn(peer, dnsDomain),
|
|
||||||
ExtraDnsLabels: fqdnList(peer.ExtraDNSLabels, dnsDomain),
|
|
||||||
LoginExpirationEnabled: peer.LoginExpirationEnabled,
|
|
||||||
LastLogin: peer.GetLastLogin(),
|
|
||||||
LoginExpired: peer.Status.LoginExpired,
|
|
||||||
CountryCode: apiv1alpha1.CountryCode(peer.Location.CountryCode),
|
|
||||||
CityName: apiv1alpha1.CityName(peer.Location.CityName),
|
|
||||||
SerialNumber: peer.Meta.SystemSerialNumber,
|
|
||||||
InactivityExpirationEnabled: peer.InactivityExpirationEnabled,
|
|
||||||
Ephemeral: peer.Ephemeral,
|
|
||||||
LocalFlags: &apiv1alpha1.PeerLocalFlags{
|
|
||||||
BlockInbound: &peer.Meta.Flags.BlockInbound,
|
|
||||||
BlockLanAccess: &peer.Meta.Flags.BlockLANAccess,
|
|
||||||
DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes,
|
|
||||||
DisableDns: &peer.Meta.Flags.DisableDNS,
|
|
||||||
DisableFirewall: &peer.Meta.Flags.DisableFirewall,
|
|
||||||
DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes,
|
|
||||||
LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled,
|
|
||||||
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
|
|
||||||
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
|
|
||||||
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
|
|
||||||
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fqdn(peer *nbpeer.Peer, dnsDomain string) string {
|
func fqdn(peer *nbpeer.Peer, dnsDomain string) string {
|
||||||
fqdn := peer.FQDN(dnsDomain)
|
fqdn := peer.FQDN(dnsDomain)
|
||||||
if fqdn == "" {
|
if fqdn == "" {
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package peers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||||
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||||
|
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||||
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
|
"github.com/netbirdio/netbird/shared/auth"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetPeersPagination(t *testing.T) {
|
||||||
|
peer := &nbpeer.Peer{
|
||||||
|
ID: testPeerID,
|
||||||
|
Status: &nbpeer.PeerStatus{Connected: true},
|
||||||
|
Meta: nbpeer.PeerSystemMeta{},
|
||||||
|
}
|
||||||
|
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
expectedStatus int
|
||||||
|
requestPath string
|
||||||
|
setExpectations func(p *Handler)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GetPeers default paging, filtering, and sorting",
|
||||||
|
requestPath: "/peers",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
setExpectations: func(p *Handler) {
|
||||||
|
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
|
||||||
|
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}), gomock.Any(), gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers paging",
|
||||||
|
requestPath: "/peers?page=10&page_size=250",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
setExpectations: func(p *Handler) {
|
||||||
|
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
|
||||||
|
gomock.Eq(store.PaginationState{Page: 10, PageSize: 250}),
|
||||||
|
gomock.Any(), gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers paging with invalid page",
|
||||||
|
requestPath: "/peers?page=aaa&page_size=250",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers paging with invalid page size",
|
||||||
|
requestPath: "/peers?page=10&page_size=aaa",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers filtering all valid params",
|
||||||
|
requestPath: "/peers?user_id=test-user&group_ids=1&group_ids=2&group_ids=3&group_ids=4&peer_ids=peer-1&peer_ids=peer-2&connected=true&approval_required=false&os=linux&os=windows&os=darwin&os=android&os=ios&os=js&ip=10.10.10.10&mac=00:11:22:33:44:55&name=test&hostname=test-host&kind=user_device",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
setExpectations: func(p *Handler) {
|
||||||
|
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
|
||||||
|
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}),
|
||||||
|
gomock.Eq(store.PeerFilters{
|
||||||
|
UserId: "test-user",
|
||||||
|
GroupIds: []string{"1", "2", "3", "4"},
|
||||||
|
Connected: new(true),
|
||||||
|
ApprovalRequired: new(false),
|
||||||
|
Os: []string{"linux", "windows", "darwin", "android", "ios", "js"},
|
||||||
|
IP: "10.10.10.10",
|
||||||
|
MAC: "00:11:22:33:44:55",
|
||||||
|
Name: "test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
IsServer: new(false),
|
||||||
|
PeersIds: []string{"peer-1", "peer-2"},
|
||||||
|
}),
|
||||||
|
gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers filtering with kind server",
|
||||||
|
requestPath: "/peers?kind=server",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
setExpectations: func(p *Handler) {
|
||||||
|
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
|
||||||
|
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}),
|
||||||
|
gomock.Eq(store.PeerFilters{IsServer: new(true)}),
|
||||||
|
gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers filtering with invalid connected value",
|
||||||
|
requestPath: "/peers?connected=aaa",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers filtering with invalid approval_required value",
|
||||||
|
requestPath: "/peers?approval_required=aaa",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers filtering with invalid os value",
|
||||||
|
requestPath: "/peers?os=nosuchos",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers filtering with invalid kind value",
|
||||||
|
requestPath: "/peers?kind=nosuchkind",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers filtering with conflicting user_id and kind",
|
||||||
|
requestPath: "/peers?user_id=test-user&kind=server",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers sorting preserves field ordering",
|
||||||
|
requestPath: "/peers?sort_by=last_seen&sort_by=name&sort_by=version",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
setExpectations: func(p *Handler) {
|
||||||
|
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
|
||||||
|
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}),
|
||||||
|
gomock.Any(),
|
||||||
|
gomock.Eq(store.PeerSorting{
|
||||||
|
Fields: []store.PeerSortingKey{
|
||||||
|
{Field: store.PeerSortByLastSeen, Order: store.AscOrderBy},
|
||||||
|
{Field: store.PeerSortByName, Order: store.AscOrderBy},
|
||||||
|
{Field: store.PeerSortByVersion, Order: store.AscOrderBy},
|
||||||
|
},
|
||||||
|
})).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers sorting with invalid field",
|
||||||
|
requestPath: "/peers?sort_by=nosuchfield,asc",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers sorting with invalid field",
|
||||||
|
requestPath: "/peers?sort_by=nosuchfield,asc",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetPeers sorting with invalid sorting order",
|
||||||
|
requestPath: "/peers?sort_by=name,nosuchorder&sort_by=version",
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
|
setExpectations: func(p *Handler) {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p := initTestMetaData(t, peer)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.requestPath, nil)
|
||||||
|
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||||
|
UserId: "admin_user",
|
||||||
|
Domain: "hotmail.com",
|
||||||
|
AccountId: "test_id",
|
||||||
|
})
|
||||||
|
|
||||||
|
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
router := mux.NewRouter()
|
||||||
|
router.Use(v1validator.Handler)
|
||||||
|
router = p.WithEndpointsForRouter(router)
|
||||||
|
|
||||||
|
tc.setExpectations(p)
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
res := recorder.Result()
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, tc.expectedStatus, recorder.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"golang.org/x/exp/maps"
|
"golang.org/x/exp/maps"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||||
|
peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
@@ -127,7 +128,10 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
|||||||
}).
|
}).
|
||||||
AnyTimes()
|
AnyTimes()
|
||||||
|
|
||||||
|
peerManager := peersmodule.NewMockManager(ctrl2)
|
||||||
|
|
||||||
return &Handler{
|
return &Handler{
|
||||||
|
peersManager: peerManager,
|
||||||
accountManager: &mock_server.MockAccountManager{
|
accountManager: &mock_server.MockAccountManager{
|
||||||
UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
|
UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
|
||||||
var p *nbpeer.Peer
|
var p *nbpeer.Peer
|
||||||
@@ -173,8 +177,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
|||||||
return nil, fmt.Errorf("user not found")
|
return nil, fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
GetPeersFunc: func(_ context.Context, accountID, userID string, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
|
||||||
return peers, nil
|
return peers, len(peers), nil
|
||||||
},
|
},
|
||||||
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
|
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
|
||||||
peersID := make([]string, len(peers))
|
peersID := make([]string, len(peers))
|
||||||
@@ -220,9 +224,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tests the GetAllPeers endpoint reachable in the route /api/peers
|
func TestGetPeer(t *testing.T) {
|
||||||
// Use the metadata generated by initTestMetaData() to check for values
|
|
||||||
func TestGetPeers(t *testing.T) {
|
|
||||||
|
|
||||||
peer := &nbpeer.Peer{
|
peer := &nbpeer.Peer{
|
||||||
ID: testPeerID,
|
ID: testPeerID,
|
||||||
@@ -262,23 +264,13 @@ func TestGetPeers(t *testing.T) {
|
|||||||
requestPath string
|
requestPath string
|
||||||
contentType string
|
contentType string
|
||||||
requestBody io.Reader
|
requestBody io.Reader
|
||||||
expectedArray bool
|
|
||||||
expectedPeer *nbpeer.Peer
|
expectedPeer *nbpeer.Peer
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
name: "GetPeersMetaData",
|
|
||||||
requestType: http.MethodGet,
|
|
||||||
requestPath: "/peers",
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedArray: true,
|
|
||||||
expectedPeer: peer,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "GetPeer with update channel",
|
name: "GetPeer with update channel",
|
||||||
requestType: http.MethodGet,
|
requestType: http.MethodGet,
|
||||||
requestPath: "/peers/" + testPeerID,
|
requestPath: "/peers/" + testPeerID,
|
||||||
expectedStatus: http.StatusOK,
|
expectedStatus: http.StatusOK,
|
||||||
expectedArray: false,
|
|
||||||
expectedPeer: peer,
|
expectedPeer: peer,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -287,7 +279,6 @@ func TestGetPeers(t *testing.T) {
|
|||||||
requestPath: "/peers/" + testPeerID,
|
requestPath: "/peers/" + testPeerID,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
expectedStatus: http.StatusOK,
|
expectedStatus: http.StatusOK,
|
||||||
expectedArray: false,
|
|
||||||
requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true, \"inactivity_expiration_enabled\":true}"),
|
requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true, \"inactivity_expiration_enabled\":true}"),
|
||||||
expectedPeer: expectedUpdatedPeer,
|
expectedPeer: expectedUpdatedPeer,
|
||||||
},
|
},
|
||||||
@@ -330,32 +321,123 @@ func TestGetPeers(t *testing.T) {
|
|||||||
t.Fatalf("I don't know what I expected; %v", err)
|
t.Fatalf("I don't know what I expected; %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var got *apiv1alpha1.Peer
|
var got apiv1alpha1.Peer
|
||||||
if tc.expectedArray {
|
err = json.Unmarshal(content, &got)
|
||||||
respBody := []*apiv1alpha1.Peer{}
|
if err != nil {
|
||||||
err = json.Unmarshal(content, &respBody)
|
t.Fatalf("Sent content is not in correct json format; %v", err)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Sent content is not in correct json format; %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hardcode this check for now as we only have two peers in this suite
|
|
||||||
assert.Equal(t, len(respBody), 2)
|
|
||||||
|
|
||||||
for _, peer := range respBody {
|
|
||||||
if peer.Id == testPeerID {
|
|
||||||
got = peer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
got = &apiv1alpha1.Peer{}
|
|
||||||
err = json.Unmarshal(content, got)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Sent content is not in correct json format; %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Log(got)
|
assert.Equal(t, tc.expectedPeer.Name, got.Name)
|
||||||
|
assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version)
|
||||||
|
assert.Equal(t, tc.expectedPeer.IP.String(), got.Ip)
|
||||||
|
assert.Equal(t, "OS core", got.Os)
|
||||||
|
assert.Equal(t, tc.expectedPeer.LoginExpirationEnabled, got.LoginExpirationEnabled)
|
||||||
|
assert.Equal(t, tc.expectedPeer.SSHEnabled, got.SshEnabled)
|
||||||
|
assert.Equal(t, tc.expectedPeer.Status.Connected, got.Connected)
|
||||||
|
assert.Equal(t, tc.expectedPeer.Meta.SystemSerialNumber, got.SerialNumber)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPeers(t *testing.T) {
|
||||||
|
peer := &nbpeer.Peer{
|
||||||
|
ID: testPeerID,
|
||||||
|
Key: "key",
|
||||||
|
IP: netip.MustParseAddr("100.64.0.1"),
|
||||||
|
IPv6: netip.MustParseAddr("fd00::1"),
|
||||||
|
Status: &nbpeer.PeerStatus{Connected: true},
|
||||||
|
Name: "PeerName",
|
||||||
|
LoginExpirationEnabled: false,
|
||||||
|
Meta: nbpeer.PeerSystemMeta{
|
||||||
|
Hostname: "hostname",
|
||||||
|
GoOS: "GoOS",
|
||||||
|
Kernel: "kernel",
|
||||||
|
Core: "core",
|
||||||
|
Platform: "platform",
|
||||||
|
OS: "OS",
|
||||||
|
WtVersion: "development",
|
||||||
|
SystemSerialNumber: "C02XJ0J0JGH7",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
peer1 := peer.Copy()
|
||||||
|
peer1.ID = noUpdateChannelTestPeerID
|
||||||
|
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
expectedStatus int
|
||||||
|
requestType string
|
||||||
|
requestPath string
|
||||||
|
contentType string
|
||||||
|
expectedPeer *nbpeer.Peer
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GetPeersMetaData",
|
||||||
|
requestType: http.MethodGet,
|
||||||
|
requestPath: "/peers",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedPeer: peer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p := initTestMetaData(t, peer, peer1)
|
||||||
|
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(tc.requestType, tc.requestPath, nil)
|
||||||
|
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||||
|
UserId: "admin_user",
|
||||||
|
Domain: "hotmail.com",
|
||||||
|
AccountId: "test_id",
|
||||||
|
})
|
||||||
|
if tc.contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", tc.contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
router := mux.NewRouter()
|
||||||
|
router.Use(v1validator.Handler)
|
||||||
|
|
||||||
|
router = p.WithEndpointsForRouter(router)
|
||||||
|
|
||||||
|
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
Return([]*nbpeer.Peer{peer, peer1}, 2, nil)
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
res := recorder.Result()
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if status := recorder.Code; status != tc.expectedStatus {
|
||||||
|
t.Fatalf("handler returned wrong status code: got %v want %v",
|
||||||
|
status, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("I don't know what I expected; %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got *apiv1alpha1.Peer
|
||||||
|
respBody := apiv1alpha1.PeerBatch{}
|
||||||
|
err = json.Unmarshal(content, &respBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sent content is not in correct json format; %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, respBody.Page, 1)
|
||||||
|
assert.Equal(t, respBody.PageSize, 100)
|
||||||
|
assert.Equal(t, respBody.PeerCount, 2)
|
||||||
|
assert.Equal(t, len(respBody.Peers), 2)
|
||||||
|
|
||||||
|
for _, peer := range respBody.Peers {
|
||||||
|
if peer.Id == testPeerID {
|
||||||
|
got = &peer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assert.Equal(t, tc.expectedPeer.Name, got.Name)
|
assert.Equal(t, tc.expectedPeer.Name, got.Name)
|
||||||
assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version)
|
assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version)
|
||||||
|
|||||||
Vendored
+1
-1
@@ -39,7 +39,7 @@ func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (Store
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr)
|
log.WithContext(subCtx).Infof("using redis cache at %s", options.Addr)
|
||||||
|
|
||||||
return &redisStore{
|
return &redisStore{
|
||||||
StoreInterface: redisstore.NewRedis(redisClient),
|
StoreInterface: redisstore.NewRedis(redisClient),
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ func initGroupTestData(initGroups ...*types.Group) *handler {
|
|||||||
|
|
||||||
return nil, status.Errorf(status.NotFound, "unknown group name")
|
return nil, status.Errorf(status.NotFound, "unknown group name")
|
||||||
},
|
},
|
||||||
GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
|
||||||
return maps.Values(TestPeers), nil
|
return maps.Values(TestPeers), len(maps.Values(TestPeers)), nil
|
||||||
},
|
},
|
||||||
DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error {
|
DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error {
|
||||||
if groupID == "linked-grp" {
|
if groupID == "linked-grp" {
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
|||||||
return nil, fmt.Errorf("user not found")
|
return nil, fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
|
||||||
return peers, nil
|
return peers, len(peers), nil
|
||||||
},
|
},
|
||||||
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
|
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
|
||||||
peersID := make([]string, len(peers))
|
peersID := make([]string, len(peers))
|
||||||
|
|||||||
@@ -429,10 +429,6 @@ func Test_SyncStatusRace(t *testing.T) {
|
|||||||
if os.Getenv("NETBIRD_STORE_ENGINE") == "postgres" {
|
if os.Getenv("NETBIRD_STORE_ENGINE") == "postgres" {
|
||||||
t.Skip("Skipping on CI and Postgres store")
|
t.Skip("Skipping on CI and Postgres store")
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Getenv("NETBIRD_STORE_ENGINE") == "mysql" {
|
|
||||||
t.Skip("Skipping on CI and MySQL store")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for i := 0; i < 500; i++ {
|
for i := 0; i < 500; i++ {
|
||||||
t.Run(fmt.Sprintf("TestRun-%d", i), func(t *testing.T) {
|
t.Run(fmt.Sprintf("TestRun-%d", i), func(t *testing.T) {
|
||||||
|
|||||||
@@ -19,19 +19,11 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetColumnName(db *gorm.DB, column string) string {
|
|
||||||
if db.Name() == "mysql" {
|
|
||||||
return fmt.Sprintf("`%s`", column)
|
|
||||||
}
|
|
||||||
return column
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrateFieldFromGobToJSON migrates a column from Gob encoding to JSON encoding.
|
// MigrateFieldFromGobToJSON migrates a column from Gob encoding to JSON encoding.
|
||||||
// T is the type of the model that contains the field to be migrated.
|
// T is the type of the model that contains the field to be migrated.
|
||||||
// S is the type of the field to be migrated.
|
// S is the type of the field to be migrated.
|
||||||
func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, fieldName string) error {
|
func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, fieldName string) error {
|
||||||
orgColumnName := fieldName
|
oldColumnName := fieldName
|
||||||
oldColumnName := GetColumnName(db, orgColumnName)
|
|
||||||
newColumnName := fieldName + "_tmp"
|
newColumnName := fieldName + "_tmp"
|
||||||
|
|
||||||
var model T
|
var model T
|
||||||
@@ -86,7 +78,7 @@ func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, f
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var field S
|
var field S
|
||||||
|
|
||||||
str, ok := row[orgColumnName].(string)
|
str, ok := row[oldColumnName].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("type assertion failed")
|
return fmt.Errorf("type assertion failed")
|
||||||
}
|
}
|
||||||
@@ -125,8 +117,7 @@ func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, f
|
|||||||
// MigrateNetIPFieldFromBlobToJSON migrates a Net IP column from Blob encoding to JSON encoding.
|
// MigrateNetIPFieldFromBlobToJSON migrates a Net IP column from Blob encoding to JSON encoding.
|
||||||
// T is the type of the model that contains the field to be migrated.
|
// T is the type of the model that contains the field to be migrated.
|
||||||
func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fieldName string, indexName string) error {
|
func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fieldName string, indexName string) error {
|
||||||
orgColumnName := fieldName
|
oldColumnName := fieldName
|
||||||
oldColumnName := GetColumnName(db, orgColumnName)
|
|
||||||
newColumnName := fieldName + "_tmp"
|
newColumnName := fieldName + "_tmp"
|
||||||
|
|
||||||
var model T
|
var model T
|
||||||
@@ -178,7 +169,7 @@ func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fi
|
|||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var blobValue string
|
var blobValue string
|
||||||
if columnValue := row[orgColumnName]; columnValue != nil {
|
if columnValue := row[oldColumnName]; columnValue != nil {
|
||||||
value, ok := columnValue.(string)
|
value, ok := columnValue.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("type assertion failed")
|
return fmt.Errorf("type assertion failed")
|
||||||
@@ -225,8 +216,7 @@ func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func MigrateSetupKeyToHashedSetupKey[T any](ctx context.Context, db *gorm.DB) error {
|
func MigrateSetupKeyToHashedSetupKey[T any](ctx context.Context, db *gorm.DB) error {
|
||||||
orgColumnName := "key"
|
oldColumnName := "key"
|
||||||
oldColumnName := GetColumnName(db, orgColumnName)
|
|
||||||
newColumnName := "key_secret"
|
newColumnName := "key_secret"
|
||||||
|
|
||||||
var model T
|
var model T
|
||||||
@@ -268,7 +258,7 @@ func MigrateSetupKeyToHashedSetupKey[T any](ctx context.Context, db *gorm.DB) er
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
|
|
||||||
var plainKey string
|
var plainKey string
|
||||||
if columnValue := row[orgColumnName]; columnValue != nil {
|
if columnValue := row[oldColumnName]; columnValue != nil {
|
||||||
value, ok := columnValue.(string)
|
value, ok := columnValue.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("type assertion failed")
|
return fmt.Errorf("type assertion failed")
|
||||||
@@ -394,33 +384,13 @@ func CreateIndexIfNotExists[T any](ctx context.Context, db *gorm.DB, indexName s
|
|||||||
return fmt.Errorf("failed to parse model schema: %w", err)
|
return fmt.Errorf("failed to parse model schema: %w", err)
|
||||||
}
|
}
|
||||||
tableName := stmt.Schema.Table
|
tableName := stmt.Schema.Table
|
||||||
dialect := db.Name()
|
|
||||||
|
|
||||||
if db.Migrator().HasIndex(&model, indexName) {
|
if db.Migrator().HasIndex(&model, indexName) {
|
||||||
log.WithContext(ctx).Infof("index %s already exists on table %s", indexName, tableName)
|
log.WithContext(ctx).Infof("index %s already exists on table %s", indexName, tableName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var columnClause string
|
createStmt := fmt.Sprintf("CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (%s)", indexName, tableName, strings.Join(columns, ", "))
|
||||||
if dialect == "mysql" {
|
|
||||||
var withLength []string
|
|
||||||
for _, col := range columns {
|
|
||||||
quotedCol := fmt.Sprintf("`%s`", col)
|
|
||||||
if col == "ip" || col == "dns_label" || col == "key" {
|
|
||||||
withLength = append(withLength, fmt.Sprintf("%s(64)", quotedCol))
|
|
||||||
} else {
|
|
||||||
withLength = append(withLength, quotedCol)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
columnClause = strings.Join(withLength, ", ")
|
|
||||||
} else {
|
|
||||||
columnClause = strings.Join(columns, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
createStmt := fmt.Sprintf("CREATE UNIQUE INDEX %s ON %s (%s)", indexName, tableName, columnClause)
|
|
||||||
if dialect == "postgres" || dialect == "sqlite" {
|
|
||||||
createStmt = strings.Replace(createStmt, "CREATE UNIQUE INDEX", "CREATE UNIQUE INDEX IF NOT EXISTS", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.WithContext(ctx).Infof("executing index creation: %s", createStmt)
|
log.WithContext(ctx).Infof("executing index creation: %s", createStmt)
|
||||||
if err := db.Exec(createStmt).Error; err != nil {
|
if err := db.Exec(createStmt).Error; err != nil {
|
||||||
@@ -505,14 +475,6 @@ func hasForeignKey(db *gorm.DB, table, column string) bool {
|
|||||||
AND kcu.table_name = ?
|
AND kcu.table_name = ?
|
||||||
AND kcu.column_name = ?
|
AND kcu.column_name = ?
|
||||||
`, table, column).Scan(&count)
|
`, table, column).Scan(&count)
|
||||||
case "mysql":
|
|
||||||
db.Raw(`
|
|
||||||
SELECT COUNT(*) FROM information_schema.key_column_usage
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = ?
|
|
||||||
AND column_name = ?
|
|
||||||
AND referenced_table_name IS NOT NULL
|
|
||||||
`, table, column).Scan(&count)
|
|
||||||
default: // sqlite
|
default: // sqlite
|
||||||
type fkInfo struct {
|
type fkInfo struct {
|
||||||
From string
|
From string
|
||||||
@@ -592,7 +554,7 @@ func RemoveDuplicatePeerKeys(ctx context.Context, db *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
keyColumn := GetColumnName(db, "key")
|
keyColumn := "key"
|
||||||
|
|
||||||
var duplicates []struct {
|
var duplicates []struct {
|
||||||
Key string
|
Key string
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ func (agentNetworkSettingsMigration) TableName() string { return "agent_network_
|
|||||||
// bootstrap always wrote both, so such a row indicates corruption and the
|
// bootstrap always wrote both, so such a row indicates corruption and the
|
||||||
// migration fails loudly rather than leaving an empty domain to collide with
|
// migration fails loudly rather than leaving an empty domain to collide with
|
||||||
// the unique index confusingly.
|
// the unique index confusingly.
|
||||||
//
|
|
||||||
// The transaction is real only on sqlite and postgres, where DDL is
|
|
||||||
// transactional. MySQL implicitly commits around every ALTER TABLE, so there
|
|
||||||
// each step stands alone; what makes an interrupted run resumable on MySQL is
|
|
||||||
// that every step is guarded by the schema state it changes — the entry check
|
|
||||||
// fires while either legacy column remains, the adds skip existing columns,
|
|
||||||
// the backfill and its loud-failure check run only while the legacy cluster
|
|
||||||
// column exists (they provably completed before any drop), and each drop
|
|
||||||
// skips what is already gone.
|
|
||||||
func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error {
|
func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error {
|
||||||
model := &agentNetworkSettingsMigration{}
|
model := &agentNetworkSettingsMigration{}
|
||||||
migrator := db.Migrator()
|
migrator := db.Migrator()
|
||||||
@@ -66,14 +57,9 @@ func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
if hasCluster {
|
if hasCluster {
|
||||||
concat := "subdomain || '.' || cluster"
|
res := tx.Exec(
|
||||||
if tx.Name() == "mysql" {
|
"UPDATE agent_network_settings SET domain = subdomain || '.' || cluster, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
|
||||||
concat = "CONCAT(subdomain, '.', cluster)"
|
)
|
||||||
}
|
|
||||||
res := tx.Exec(fmt.Sprintf(
|
|
||||||
"UPDATE agent_network_settings SET domain = %s, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
|
|
||||||
concat,
|
|
||||||
))
|
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return fmt.Errorf("backfill agent_network_settings domain: %w", res.Error)
|
return fmt.Errorf("backfill agent_network_settings domain: %w", res.Error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/mysql"
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -32,17 +31,6 @@ func setupDatabase(t *testing.T) *gorm.DB {
|
|||||||
var dsn string
|
var dsn string
|
||||||
var cleanup func()
|
var cleanup func()
|
||||||
switch os.Getenv("NETBIRD_STORE_ENGINE") {
|
switch os.Getenv("NETBIRD_STORE_ENGINE") {
|
||||||
case "mysql":
|
|
||||||
cleanup, dsn, err = testutil.CreateMysqlTestContainer()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create MySQL test container: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dsn == "" {
|
|
||||||
t.Fatal("MySQL connection string is empty, ensure the test container is running")
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err = gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{})
|
|
||||||
case "postgres":
|
case "postgres":
|
||||||
cleanup, dsn, err = testutil.CreatePostgresTestContainer()
|
cleanup, dsn, err = testutil.CreatePostgresTestContainer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -597,9 +585,8 @@ func TestCleanupOrphanedResources_Idempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
|
func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
|
||||||
engine := os.Getenv("NETBIRD_STORE_ENGINE")
|
if os.Getenv("NETBIRD_STORE_ENGINE") != "postgres" {
|
||||||
if engine != "postgres" && engine != "mysql" {
|
t.Skip("FK constraint early-exit test requires postgres")
|
||||||
t.Skip("FK constraint early-exit test requires postgres or mysql")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db := setupDatabase(t)
|
db := setupDatabase(t)
|
||||||
@@ -614,24 +601,12 @@ func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
|
|||||||
require.NoError(t, db.Create(&testChildWithFK{ID: "c1", ParentID: "p1"}).Error)
|
require.NoError(t, db.Create(&testChildWithFK{ID: "c1", ParentID: "p1"}).Error)
|
||||||
require.NoError(t, db.Create(&testChildWithFK{ID: "c2", ParentID: "p2"}).Error)
|
require.NoError(t, db.Create(&testChildWithFK{ID: "c2", ParentID: "p2"}).Error)
|
||||||
|
|
||||||
switch engine {
|
require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent").Error)
|
||||||
case "postgres":
|
require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error)
|
||||||
require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent").Error)
|
require.NoError(t, db.Exec(
|
||||||
require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error)
|
"ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+
|
||||||
require.NoError(t, db.Exec(
|
"FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID",
|
||||||
"ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+
|
).Error)
|
||||||
"FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID",
|
|
||||||
).Error)
|
|
||||||
case "mysql":
|
|
||||||
require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error)
|
|
||||||
require.NoError(t, db.Exec("ALTER TABLE test_children DROP FOREIGN KEY fk_test_children_parent").Error)
|
|
||||||
require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error)
|
|
||||||
require.NoError(t, db.Exec(
|
|
||||||
"ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+
|
|
||||||
"FOREIGN KEY (parent_id) REFERENCES test_parents(id)",
|
|
||||||
).Error)
|
|
||||||
require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = migration.CleanupOrphanedResources[testChildWithFK, testParent](context.Background(), db, "parent_id")
|
err = migration.CleanupOrphanedResources[testChildWithFK, testParent](context.Background(), db, "parent_id")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -717,8 +692,6 @@ func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) {
|
|||||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
|
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
|
||||||
|
|
||||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}))
|
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}))
|
||||||
// Timestamp must be set explicitly: a zero time.Time serialises as
|
|
||||||
// '0000-00-00 00:00:00', which MySQL rejects under strict mode.
|
|
||||||
require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{
|
require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{
|
||||||
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6",
|
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6",
|
||||||
Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC),
|
Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC),
|
||||||
@@ -819,10 +792,9 @@ func TestMigrateAgentNetworkSettingsToDomain_FailsOnUnmigratableRow(t *testing.T
|
|||||||
assert.Contains(t, err.Error(), "resolve them manually", "the error must tell the operator what to do")
|
assert.Contains(t, err.Error(), "resolve them manually", "the error must tell the operator what to do")
|
||||||
}
|
}
|
||||||
|
|
||||||
// partialAgentNetworkSettings models the one non-atomic state a MySQL run can
|
// partialAgentNetworkSettings models a schema left between the two legacy
|
||||||
// be interrupted in: DDL auto-commits there, so a crash between the two legacy
|
// column drops: subdomain is still present while cluster is gone and the
|
||||||
// column drops leaves subdomain behind while cluster (and the completed
|
// backfill has already completed.
|
||||||
// backfill) are already committed.
|
|
||||||
type partialAgentNetworkSettings struct {
|
type partialAgentNetworkSettings struct {
|
||||||
AccountID string `gorm:"primaryKey"`
|
AccountID string `gorm:"primaryKey"`
|
||||||
Subdomain string
|
Subdomain string
|
||||||
@@ -832,8 +804,8 @@ type partialAgentNetworkSettings struct {
|
|||||||
|
|
||||||
func (partialAgentNetworkSettings) TableName() string { return "agent_network_settings" }
|
func (partialAgentNetworkSettings) TableName() string { return "agent_network_settings" }
|
||||||
|
|
||||||
// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins MySQL
|
// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins
|
||||||
// resumability: a rerun over the interrupted state must remove the leftover
|
// resumability: a rerun over the partial state must remove the leftover
|
||||||
// subdomain column without re-running the backfill (the cluster column that
|
// subdomain column without re-running the backfill (the cluster column that
|
||||||
// feeds it is gone) and without touching the migrated values.
|
// feeds it is gone) and without touching the migrated values.
|
||||||
func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.T) {
|
func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.T) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type MockAccountManager struct {
|
|||||||
GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error)
|
GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error)
|
||||||
GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
|
GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
|
||||||
ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error)
|
ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error)
|
||||||
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error)
|
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error)
|
||||||
MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
|
MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
|
||||||
MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
|
MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
|
||||||
SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
|
SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
|
||||||
@@ -820,7 +820,8 @@ func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, user
|
|||||||
// GetPeers mocks GetPeers of the AccountManager interface
|
// GetPeers mocks GetPeers of the AccountManager interface
|
||||||
func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||||
if am.GetPeersFunc != nil {
|
if am.GetPeersFunc != nil {
|
||||||
return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter)
|
peers, _, err := am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter)
|
||||||
|
return peers, err
|
||||||
}
|
}
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
|
|||||||
return []*nbpeer.Peer{}, nil
|
return []*nbpeer.Peer{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
|
peers, err := am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
|
||||||
|
|
||||||
|
return peers, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkPeerConnected marks a peer as connected with optimistic-locked
|
// MarkPeerConnected marks a peer as connected with optimistic-locked
|
||||||
|
|||||||
@@ -730,7 +730,6 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.NotNil(t, peers)
|
assert.NotNil(t, peers)
|
||||||
|
|
||||||
assert.Len(t, peers, testCase.expectedPeerCount)
|
assert.Len(t, peers, testCase.expectedPeerCount)
|
||||||
|
|
||||||
})
|
})
|
||||||
@@ -1567,8 +1566,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
|
|||||||
|
|
||||||
func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
|
func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
|
||||||
engine := os.Getenv("NETBIRD_STORE_ENGINE")
|
engine := os.Getenv("NETBIRD_STORE_ENGINE")
|
||||||
if engine == "sqlite" || engine == "mysql" || engine == "" {
|
if engine == "sqlite" || engine == "" {
|
||||||
// we intentionally disabled foreign keys in mysql
|
|
||||||
t.Skip("Skipping test because store is not respecting foreign keys")
|
t.Skip("Skipping test because store is not respecting foreign keys")
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
@@ -2220,10 +2218,6 @@ func Test_IsUniqueConstraintError(t *testing.T) {
|
|||||||
name: "PostgreSQL uniqueness error",
|
name: "PostgreSQL uniqueness error",
|
||||||
engine: types.PostgresStoreEngine,
|
engine: types.PostgresStoreEngine,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "MySQL uniqueness error",
|
|
||||||
engine: types.MysqlStoreEngine,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "SQLite uniqueness error",
|
name: "SQLite uniqueness error",
|
||||||
engine: types.SqliteStoreEngine,
|
engine: types.SqliteStoreEngine,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
idQueryCondition = "id = ?"
|
idQueryCondition = "id = ?"
|
||||||
keyQueryCondition = "key = ?"
|
keyQueryCondition = "key = ?"
|
||||||
mysqlKeyQueryCondition = "`key` = ?"
|
|
||||||
accountAndIDQueryCondition = "account_id = ? and id = ?"
|
accountAndIDQueryCondition = "account_id = ? and id = ?"
|
||||||
accountAndAnyIDQueryCondition = "account_id = ? and (id = ? or public_id = ?)"
|
accountAndAnyIDQueryCondition = "account_id = ? and (id = ? or public_id = ?)"
|
||||||
accountAndPeerIDQueryCondition = "account_id = ? and peer_id = ?"
|
accountAndPeerIDQueryCondition = "account_id = ? and peer_id = ?"
|
||||||
@@ -119,13 +118,6 @@ func (s *SqlStore) pgxPool() *pgxpool.Pool {
|
|||||||
return s.conn.Pool(s.tx)
|
return s.conn.Pool(s.tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetKeyQueryCondition(s *SqlStore) string {
|
|
||||||
if s.conn.Engine() == db.MysqlStoreEngine {
|
|
||||||
return mysqlKeyQueryCondition
|
|
||||||
}
|
|
||||||
return keyQueryCondition
|
|
||||||
}
|
|
||||||
|
|
||||||
// AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock
|
// AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock
|
||||||
func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) {
|
func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) {
|
||||||
log.WithContext(ctx).Tracef("acquiring global lock")
|
log.WithContext(ctx).Tracef("acquiring global lock")
|
||||||
@@ -174,15 +166,6 @@ func NewPostgresqlStore(ctx context.Context, dsn string, metrics telemetry.AppMe
|
|||||||
return newStore(ctx, conn, metrics, skipMigration)
|
return newStore(ctx, conn, metrics, skipMigration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMysqlStore creates a new MySQL store.
|
|
||||||
func NewMysqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
|
|
||||||
conn, err := db.OpenMysql(ctx, dsn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return newStore(ctx, conn, metrics, skipMigration)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSqliteStoreFromFileStore restores a store from FileStore and stores SQLite DB in the file located in datadir.
|
// NewSqliteStoreFromFileStore restores a store from FileStore and stores SQLite DB in the file located in datadir.
|
||||||
func NewSqliteStoreFromFileStore(ctx context.Context, fileStore *FileStore, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
|
func NewSqliteStoreFromFileStore(ctx context.Context, fileStore *FileStore, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
|
||||||
store, err := NewSqliteStore(ctx, dataDir, metrics, skipMigration)
|
store, err := NewSqliteStore(ctx, dataDir, metrics, skipMigration)
|
||||||
@@ -240,11 +223,6 @@ func NewPostgresqlStoreForTests(ctx context.Context, dsn string, metrics telemet
|
|||||||
return newStore(ctx, conn, metrics, skipMigration)
|
return newStore(ctx, conn, metrics, skipMigration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMysqlStoreFromSqlStore restores a store from SqlStore and stores MySQL DB.
|
|
||||||
func NewMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics) (*SqlStore, error) {
|
|
||||||
return newMysqlStoreFromSqlStore(ctx, sqliteStore, dsn, metrics, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedFromSqliteStore copies the installation ID and the accounts of the
|
// seedFromSqliteStore copies the installation ID and the accounts of the
|
||||||
// sqlite seed store into a freshly created engine store.
|
// sqlite seed store into a freshly created engine store.
|
||||||
func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) error {
|
func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) error {
|
||||||
@@ -259,20 +237,6 @@ func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
|
|
||||||
store, err := NewMysqlStore(ctx, dsn, metrics, skipMigration)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := seedFromSqliteStore(ctx, store, sqliteStore); err != nil {
|
|
||||||
_ = store.Close(ctx)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return store, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteInTransaction runs operation in a transaction. A store that is already
|
// ExecuteInTransaction runs operation in a transaction. A store that is already
|
||||||
// bound to one joins it instead of opening a second, independent transaction.
|
// bound to one joins it instead of opening a second, independent transaction.
|
||||||
func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error {
|
func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error {
|
||||||
|
|||||||
@@ -1084,10 +1084,8 @@ func (s *SqlStore) SaveAccountSettings(ctx context.Context, accountID string, se
|
|||||||
return status.Errorf(status.Internal, "failed to save account settings to store")
|
return status.Errorf(status.Internal, "failed to save account settings to store")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MySQL reports RowsAffected=0 for no-op updates where values don't change,
|
// No RowsAffected check: the caller (UpdateAccountSettings) already verified
|
||||||
// unlike SQLite/Postgres which report matched rows. Skip the check since the
|
// the account exists via GetAccountSettings with LockingStrengthUpdate.
|
||||||
// caller (UpdateAccountSettings) already verified the account exists via
|
|
||||||
// GetAccountSettings with LockingStrengthUpdate.
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -69,10 +68,6 @@ func TestSqlStore_GetGroupsByIDs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSqlStore_CreateGroup(t *testing.T) {
|
func TestSqlStore_CreateGroup(t *testing.T) {
|
||||||
if os.Getenv("CI") == "true" {
|
|
||||||
t.Log("Skipping MySQL test on CI")
|
|
||||||
}
|
|
||||||
t.Setenv("NETBIRD_STORE_ENGINE", string(types.MysqlStoreEngine))
|
|
||||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||||
t.Cleanup(cleanup)
|
t.Cleanup(cleanup)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ func (s *SqlStore) ListUsers(ctx context.Context) ([]*types.User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction.
|
// txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction.
|
||||||
// MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0).
|
|
||||||
func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error {
|
func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error {
|
||||||
if s.conn.Engine() == types.SqliteStoreEngine {
|
if s.conn.Engine() == types.SqliteStoreEngine {
|
||||||
return tx.Exec("PRAGMA defer_foreign_keys = ON").Error
|
return tx.Exec("PRAGMA defer_foreign_keys = ON").Error
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -374,7 +375,7 @@ func (s *SqlStore) GetAccountByPeerID(ctx context.Context, peerID string) (*type
|
|||||||
|
|
||||||
func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types.Account, error) {
|
func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types.Account, error) {
|
||||||
var peer nbpeer.Peer
|
var peer nbpeer.Peer
|
||||||
result := s.db.Select("account_id").Take(&peer, GetKeyQueryCondition(s), peerKey)
|
result := s.db.Select("account_id").Take(&peer, keyQueryCondition, peerKey)
|
||||||
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
@@ -393,7 +394,7 @@ func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (
|
|||||||
func (s *SqlStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string) (string, error) {
|
func (s *SqlStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string) (string, error) {
|
||||||
var peer nbpeer.Peer
|
var peer nbpeer.Peer
|
||||||
var accountID string
|
var accountID string
|
||||||
result := s.db.Model(&peer).Select("account_id").Where(GetKeyQueryCondition(s), peerKey).Take(&accountID)
|
result := s.db.Model(&peer).Select("account_id").Where(keyQueryCondition, peerKey).Take(&accountID)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return "", status.Errorf(status.NotFound, "account not found: index lookup failed")
|
return "", status.Errorf(status.NotFound, "account not found: index lookup failed")
|
||||||
@@ -482,7 +483,7 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking
|
|||||||
}
|
}
|
||||||
|
|
||||||
var peer nbpeer.Peer
|
var peer nbpeer.Peer
|
||||||
result := tx.Take(&peer, GetKeyQueryCondition(s), peerKey)
|
result := tx.Take(&peer, keyQueryCondition, peerKey)
|
||||||
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
@@ -494,7 +495,6 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking
|
|||||||
return &peer, nil
|
return &peer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccountPeers retrieves peers for an account.
|
|
||||||
func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||||
var peers []*nbpeer.Peer
|
var peers []*nbpeer.Peer
|
||||||
tx := s.db
|
tx := s.db
|
||||||
@@ -514,7 +514,6 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
|
|||||||
if macFilter != "" {
|
if macFilter != "" {
|
||||||
query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%")
|
query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := query.Find(&peers).Error; err != nil {
|
if err := query.Find(&peers).Error; err != nil {
|
||||||
log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err)
|
log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err)
|
||||||
return nil, status.Errorf(status.Internal, "failed to get peers from store")
|
return nil, status.Errorf(status.Internal, "failed to get peers from store")
|
||||||
@@ -523,6 +522,162 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
|
|||||||
return peers, nil
|
return peers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccountPeers retrieves peers for an account.
|
||||||
|
func (s *SqlStore) GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*nbpeer.Peer, int, error) {
|
||||||
|
// main query
|
||||||
|
var peers []*nbpeer.Peer
|
||||||
|
{
|
||||||
|
tx := s.db
|
||||||
|
if lockStrength != LockingStrengthNone {
|
||||||
|
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||||
|
}
|
||||||
|
query := tx.Where("peers.account_id=?", accountID)
|
||||||
|
query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)")
|
||||||
|
query = filters.AsConditions(query)
|
||||||
|
query = pagination.AsScope(query)
|
||||||
|
query = sorting.AsOrderBy(ctx, query)
|
||||||
|
|
||||||
|
if err := query.Distinct().Find(&peers).Error; err != nil {
|
||||||
|
log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err)
|
||||||
|
return nil, 0, status.Errorf(status.Internal, "failed to get peers from store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// total number of records
|
||||||
|
var peerCount int64
|
||||||
|
{
|
||||||
|
tx := s.db
|
||||||
|
if lockStrength != LockingStrengthNone {
|
||||||
|
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||||
|
}
|
||||||
|
query := tx.Where("peers.account_id=?", accountID)
|
||||||
|
query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)")
|
||||||
|
query = filters.AsConditions(query)
|
||||||
|
|
||||||
|
if err := query.Table("peers").Select("count(distinct(peers.id))").Count(&peerCount).Error; err != nil {
|
||||||
|
log.WithContext(ctx).Errorf("failed to get total number of peers from the store: %s", err)
|
||||||
|
return nil, 0, status.Errorf(status.Internal, "failed to get total number of peers from store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return peers, int(peerCount), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationState struct {
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PaginationState) AsScope(db *gorm.DB) *gorm.DB {
|
||||||
|
// empty pagination state
|
||||||
|
if p.PageSize == 0 {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
offset := max(p.Page-1, 0) * p.PageSize
|
||||||
|
return db.Offset(offset).Limit(p.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerFilters struct {
|
||||||
|
UserId string
|
||||||
|
GroupIds []string
|
||||||
|
Connected *bool
|
||||||
|
ApprovalRequired *bool
|
||||||
|
Os []string
|
||||||
|
IP string
|
||||||
|
MAC string
|
||||||
|
Hostname string
|
||||||
|
Name string
|
||||||
|
IsServer *bool
|
||||||
|
PeersIds []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *PeerFilters) AsConditions(db *gorm.DB) *gorm.DB {
|
||||||
|
if len(f.GroupIds) > 0 {
|
||||||
|
db = db.Joins("join group_peers on peers.id=group_peers.peer_id and peers.account_id=group_peers.account_id").Where("group_peers.group_id in (?)", f.GroupIds)
|
||||||
|
}
|
||||||
|
if f.UserId != "" {
|
||||||
|
db = db.Where("user_id=?", f.UserId)
|
||||||
|
}
|
||||||
|
if f.Connected != nil {
|
||||||
|
db = db.Where("peer_status_connected=?", *f.Connected)
|
||||||
|
}
|
||||||
|
if f.ApprovalRequired != nil {
|
||||||
|
db = db.Where("peer_status_requires_approval=?", *f.ApprovalRequired)
|
||||||
|
}
|
||||||
|
if len(f.Os) > 0 {
|
||||||
|
db = db.Where("meta_go_os in (?)", f.Os)
|
||||||
|
}
|
||||||
|
if f.IP != "" {
|
||||||
|
db = db.Where("ip like ? or lower(ipv6) like ?", fmt.Sprintf("%%%s%%", f.IP), fmt.Sprintf("%%%s%%", strings.ToLower(f.IP)))
|
||||||
|
}
|
||||||
|
if f.MAC != "" {
|
||||||
|
db = db.Where("lower(meta_network_addresses) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.MAC)))
|
||||||
|
}
|
||||||
|
if f.Hostname != "" {
|
||||||
|
db = db.Where("lower(meta_hostname) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Hostname)))
|
||||||
|
}
|
||||||
|
if f.Name != "" {
|
||||||
|
db = db.Where("lower(name) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Name)))
|
||||||
|
}
|
||||||
|
if f.IsServer != nil {
|
||||||
|
if *f.IsServer {
|
||||||
|
db = db.Where("(user_id='' or user_id is null)")
|
||||||
|
} else {
|
||||||
|
db = db.Where("user_id<>'' and user_id is not null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.PeersIds) > 0 {
|
||||||
|
db = db.Where("id in (?)", f.PeersIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerSortByField int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PeerSortByName PeerSortByField = iota
|
||||||
|
PeerSortByLastSeen
|
||||||
|
PeerSortByVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderBy int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AscOrderBy OrderBy = iota
|
||||||
|
DescOrderBy
|
||||||
|
)
|
||||||
|
|
||||||
|
type PeerSortingKey struct {
|
||||||
|
Field PeerSortByField
|
||||||
|
Order OrderBy
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerSorting struct {
|
||||||
|
Fields []PeerSortingKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PeerSorting) AsOrderBy(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||||
|
order := make([]clause.OrderByColumn, 0)
|
||||||
|
|
||||||
|
for _, key := range s.Fields {
|
||||||
|
switch key.Field {
|
||||||
|
case PeerSortByName:
|
||||||
|
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.name"}, Desc: key.Order == DescOrderBy})
|
||||||
|
case PeerSortByLastSeen:
|
||||||
|
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.peer_status_last_seen"}, Desc: key.Order == DescOrderBy})
|
||||||
|
case PeerSortByVersion:
|
||||||
|
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "meta_wt_version"}, Desc: key.Order == DescOrderBy})
|
||||||
|
default:
|
||||||
|
log.WithContext(ctx).Warnf("invalid order by column '%v', skipping", key.Field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// always order/break ties by id asc order
|
||||||
|
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.id"}, Desc: false})
|
||||||
|
return db.Order(clause.OrderBy{Columns: order})
|
||||||
|
}
|
||||||
|
|
||||||
// GetUserPeers retrieves peers for a user.
|
// GetUserPeers retrieves peers for a user.
|
||||||
func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) {
|
func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) {
|
||||||
tx := s.db
|
tx := s.db
|
||||||
@@ -759,7 +914,7 @@ func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingS
|
|||||||
var userID string
|
var userID string
|
||||||
result := tx.Model(&nbpeer.Peer{}).
|
result := tx.Model(&nbpeer.Peer{}).
|
||||||
Select("user_id").
|
Select("user_id").
|
||||||
Take(&userID, GetKeyQueryCondition(s), peerKey)
|
Take(&userID, keyQueryCondition, peerKey)
|
||||||
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
@@ -780,7 +935,7 @@ func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStren
|
|||||||
var peerID string
|
var peerID string
|
||||||
result := tx.Model(&nbpeer.Peer{}).
|
result := tx.Model(&nbpeer.Peer{}).
|
||||||
Select("id").
|
Select("id").
|
||||||
Where(GetKeyQueryCondition(s), key).
|
Where(keyQueryCondition, key).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Scan(&peerID)
|
Scan(&peerID)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter, "")
|
peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, tt.accountID, PaginationState{}, PeerFilters{Hostname: tt.nameFilter, IP: tt.ipFilter}, PeerSorting{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, peers, tt.expectedCount)
|
require.Len(t, peers, tt.expectedCount)
|
||||||
})
|
})
|
||||||
@@ -555,7 +555,7 @@ func TestSqlStore_GetAccountPeers_FilterByMac(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
peers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", tt.macFilter)
|
peers, _, err := store.GetAccountPeersPaginated(ctx, LockingStrengthNone, accountID, PaginationState{}, PeerFilters{MAC: tt.macFilter}, PeerSorting{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, peers, tt.expectedCount)
|
require.Len(t, peers, tt.expectedCount)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSqlStore_FilterPeers(t *testing.T) {
|
||||||
|
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
|
||||||
|
var tests = []struct {
|
||||||
|
description string
|
||||||
|
filters PeerFilters
|
||||||
|
expectedLength int
|
||||||
|
expectedPeers []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "filter by user_id",
|
||||||
|
filters: PeerFilters{UserId: "user-id-1"},
|
||||||
|
expectedLength: 1,
|
||||||
|
expectedPeers: []string{"Peer-1-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by connected status",
|
||||||
|
filters: PeerFilters{Connected: new(true)},
|
||||||
|
expectedLength: 2,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by approval required status",
|
||||||
|
filters: PeerFilters{ApprovalRequired: new(true)},
|
||||||
|
expectedLength: 2,
|
||||||
|
expectedPeers: []string{"peer-2-name", "peer-3-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by ipv4",
|
||||||
|
filters: PeerFilters{IP: "10.10.10"},
|
||||||
|
expectedLength: 2,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by ipv6",
|
||||||
|
filters: PeerFilters{IP: "ba80:6aa5:89f1:44d7:8701:869"},
|
||||||
|
expectedLength: 2,
|
||||||
|
expectedPeers: []string{"peer-2-name", "peer-3-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by ipv6 (uppercase)",
|
||||||
|
filters: PeerFilters{IP: "BA80:6AA5:89f1:44d7:8701:869"},
|
||||||
|
expectedLength: 2,
|
||||||
|
expectedPeers: []string{"peer-2-name", "peer-3-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by mac",
|
||||||
|
filters: PeerFilters{MAC: ":15:5d:24:0c"},
|
||||||
|
expectedLength: 3,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by mac (uppercase)",
|
||||||
|
filters: PeerFilters{MAC: ":15:5D:24:0C"},
|
||||||
|
expectedLength: 3,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by kind (server)",
|
||||||
|
filters: PeerFilters{IsServer: new(true), Os: []string{"linux"}},
|
||||||
|
expectedLength: 3,
|
||||||
|
expectedPeers: []string{"peer-4-name", "peer-5-name", "peer-6-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by kind (user_device)",
|
||||||
|
filters: PeerFilters{IsServer: new(false)},
|
||||||
|
expectedLength: 3,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by group_ids",
|
||||||
|
filters: PeerFilters{GroupIds: []string{"group-one-resource-id", "group-two-resources-id"}},
|
||||||
|
expectedLength: 2,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by os",
|
||||||
|
filters: PeerFilters{Os: []string{"linux", "ios"}},
|
||||||
|
expectedLength: 5,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-5-name", "peer-6-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by peer id",
|
||||||
|
filters: PeerFilters{PeersIds: []string{"peer-id-one", "peer-id-five", "peer-id-three"}},
|
||||||
|
expectedLength: 3,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-3-name", "peer-5-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by name",
|
||||||
|
filters: PeerFilters{Name: "peer-1"},
|
||||||
|
expectedLength: 1,
|
||||||
|
expectedPeers: []string{"Peer-1-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by name (upper-case)",
|
||||||
|
filters: PeerFilters{Name: "PEER-1"},
|
||||||
|
expectedLength: 1,
|
||||||
|
expectedPeers: []string{"Peer-1-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by hostname",
|
||||||
|
filters: PeerFilters{Hostname: "one-name.local"},
|
||||||
|
expectedLength: 1,
|
||||||
|
expectedPeers: []string{"Peer-1-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "filter by hostname (upper-case)",
|
||||||
|
filters: PeerFilters{Hostname: "ONE-NAME.LOCAL"},
|
||||||
|
expectedLength: 1,
|
||||||
|
expectedPeers: []string{"Peer-1-name"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description+" "+engine, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
|
||||||
|
PaginationState{}, tt.filters, PeerSorting{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, peers, tt.expectedLength)
|
||||||
|
require.Equal(t, tt.expectedLength, count)
|
||||||
|
actualPeerNames := make([]string, 0, len(peers))
|
||||||
|
for _, p := range peers {
|
||||||
|
actualPeerNames = append(actualPeerNames, p.Name)
|
||||||
|
}
|
||||||
|
require.ElementsMatch(t, actualPeerNames, tt.expectedPeers)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqlStore_PeersPaging(t *testing.T) {
|
||||||
|
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
|
||||||
|
var tests = []struct {
|
||||||
|
description string
|
||||||
|
page int
|
||||||
|
pageSize int
|
||||||
|
expectedLength int
|
||||||
|
expectedCount int
|
||||||
|
expectedPeers []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "page 1, size 4",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 4,
|
||||||
|
expectedLength: 4,
|
||||||
|
expectedCount: 6,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "page 2, size 4",
|
||||||
|
page: 2,
|
||||||
|
pageSize: 4,
|
||||||
|
expectedLength: 2,
|
||||||
|
expectedCount: 6,
|
||||||
|
expectedPeers: []string{"peer-5-name", "peer-6-name"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description+" "+engine, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
|
||||||
|
PaginationState{Page: tt.page, PageSize: tt.pageSize}, PeerFilters{}, PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, peers, tt.expectedLength)
|
||||||
|
require.Equal(t, tt.expectedCount, count)
|
||||||
|
actualPeerNames := make([]string, 0, len(peers))
|
||||||
|
for _, p := range peers {
|
||||||
|
actualPeerNames = append(actualPeerNames, p.Name)
|
||||||
|
}
|
||||||
|
require.ElementsMatch(t, actualPeerNames, tt.expectedPeers)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqlStore_PeersSorting(t *testing.T) {
|
||||||
|
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
|
||||||
|
var tests = []struct {
|
||||||
|
description string
|
||||||
|
sorting PeerSorting
|
||||||
|
expectedLength int
|
||||||
|
expectedPeers []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "default order (by id ascending)",
|
||||||
|
sorting: PeerSorting{Fields: []PeerSortingKey{}},
|
||||||
|
expectedLength: 6,
|
||||||
|
expectedPeers: []string{"peer-5-name", "peer-4-name", "Peer-1-name", "peer-6-name", "peer-3-name", "peer-2-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "by name ascending",
|
||||||
|
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}},
|
||||||
|
expectedLength: 6,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name", "peer-5-name", "peer-6-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "by name descending",
|
||||||
|
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName, Order: DescOrderBy}}},
|
||||||
|
expectedLength: 6,
|
||||||
|
expectedPeers: []string{"peer-6-name", "peer-5-name", "peer-4-name", "peer-3-name", "peer-2-name", "Peer-1-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "by last_seen ascending",
|
||||||
|
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen}}},
|
||||||
|
expectedLength: 6,
|
||||||
|
expectedPeers: []string{"peer-6-name", "peer-4-name", "peer-5-name", "peer-3-name", "peer-2-name", "Peer-1-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "by last_seen descending",
|
||||||
|
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen, Order: DescOrderBy}}},
|
||||||
|
expectedLength: 6,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-5-name", "peer-4-name", "peer-6-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "by version ascending",
|
||||||
|
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion}}},
|
||||||
|
expectedLength: 6,
|
||||||
|
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-3-name", "peer-6-name", "peer-5-name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "by version descending",
|
||||||
|
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion, Order: DescOrderBy}}},
|
||||||
|
expectedLength: 6,
|
||||||
|
expectedPeers: []string{"peer-5-name", "peer-6-name", "peer-3-name", "peer-4-name", "peer-2-name", "Peer-1-name"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description+" "+engine, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
|
||||||
|
PaginationState{}, PeerFilters{}, tt.sorting)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, peers, tt.expectedLength)
|
||||||
|
actualPeerNames := make([]string, 0, len(peers))
|
||||||
|
for _, p := range peers {
|
||||||
|
actualPeerNames = append(actualPeerNames, p.Name)
|
||||||
|
}
|
||||||
|
require.Equal(t, tt.expectedPeers, actualPeerNames)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestPgxServiceColumnsMatchGorm guards the Postgres pgx read path against
|
// TestPgxServiceColumnsMatchGorm guards the Postgres pgx read path against
|
||||||
// drifting from the gorm model. The SQLite/MySQL gorm path loads rows by struct,
|
// drifting from the gorm model. The SQLite gorm path loads rows by struct,
|
||||||
// so a new column on a model is picked up automatically, but the hand-written
|
// so a new column on a model is picked up automatically, but the hand-written
|
||||||
// pgx SELECT in sql_store.go must be updated by hand. This test fails when a
|
// pgx SELECT in sql_store.go must be updated by hand. This test fails when a
|
||||||
// gorm column is missing from the pgx column list, which otherwise silently
|
// gorm column is missing from the pgx column list, which otherwise silently
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ func (s *SqlStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) {
|
|||||||
var m ProxyMetrics
|
var m ProxyMetrics
|
||||||
activeCutoff := time.Now().Add(-proxyActiveThreshold)
|
activeCutoff := time.Now().Add(-proxyActiveThreshold)
|
||||||
|
|
||||||
// COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite/postgres
|
// COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite and postgres
|
||||||
// (MySQL too) and keeps the round-trip to one. proxy.StatusConnected
|
// and keeps the round-trip to one. proxy.StatusConnected
|
||||||
// is the same string the cluster-capability queries use; the active
|
// is the same string the cluster-capability queries use; the active
|
||||||
// window matches the cluster-capability semantics (only proxies
|
// window matches the cluster-capability semantics (only proxies
|
||||||
// heartbeating within ~2 * heartbeat interval count as connected).
|
// heartbeating within ~2 * heartbeat interval count as connected).
|
||||||
@@ -292,7 +292,7 @@ func (s *SqlStore) GetProxyClusters(ctx context.Context, accountID string) ([]pr
|
|||||||
"cluster_address AS address, "+
|
"cluster_address AS address, "+
|
||||||
// COUNT(CASE WHEN ... THEN 1 END) counts only non-NULL — i.e. only
|
// COUNT(CASE WHEN ... THEN 1 END) counts only non-NULL — i.e. only
|
||||||
// rows that satisfy the predicate — so it works portably across
|
// rows that satisfy the predicate — so it works portably across
|
||||||
// sqlite/postgres/mysql without dialect-specific FILTER syntax.
|
// sqlite and postgres without dialect-specific FILTER syntax.
|
||||||
"COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS connected_proxies, "+
|
"COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS connected_proxies, "+
|
||||||
// MAX(CASE …) > 0 expresses BOOL_OR in a way Postgres tolerates
|
// MAX(CASE …) > 0 expresses BOOL_OR in a way Postgres tolerates
|
||||||
// (Postgres can't MAX a boolean column).
|
// (Postgres can't MAX a boolean column).
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user